diff --git a/.github/actions/export-env-vars/action.yml b/.github/actions/export-env-vars/action.yml index 81128f172a..8264b27c7c 100644 --- a/.github/actions/export-env-vars/action.yml +++ b/.github/actions/export-env-vars/action.yml @@ -1,10 +1,18 @@ name: Export Repository Variables -description: Export all repository/environment variables from a JSON map into GITHUB_ENV. +description: Export repository variables and the provider RPC URL and backend peer secrets into GITHUB_ENV. inputs: vars_json: description: JSON object map of repository/environment variables. required: true + rpc_urls: + description: KEY=VALUE lines of BB_DEV_RPC_URL_* / BB_PROD_RPC_URL_* (pass secrets.BB_RPC_URLS). + required: false + default: "" + backend_peers: + description: KEY=VALUE lines of BB_ADDNODE_* private backend peers (pass secrets.BB_BACKEND_PEERS). + required: false + default: "" runs: using: composite @@ -13,6 +21,8 @@ runs: shell: bash env: VARS_JSON: ${{ inputs.vars_json }} + RPC_URLS: ${{ inputs.rpc_urls }} + BACKEND_PEERS: ${{ inputs.backend_peers }} run: | python3 - <<'PY' import json @@ -30,20 +40,46 @@ runs: for line in prefixes_file if line.strip() and not line.strip().startswith("#") ) + # Provider URLs embed auth tokens and Actions variables are printed unmasked in public logs. + secret_prefixes = ("BB_DEV_RPC_URL_", "BB_PROD_RPC_URL_") + # Private peers must stay out of the public repo and its logs. + peer_prefix = "BB_ADDNODE_" def write_env_var(env_file, key, value): delimiter = f"__{uuid.uuid4().hex}__" env_file.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n") + def normalize(key): + for prefix in alias_prefixes: + if key.startswith(prefix): + alias = key[len(prefix):] + # Blockbook env lookups use lowercase coin aliases from configs/coins. + return prefix + alias.lower().replace("-", "_") + return key + + leaked = sorted(key for key in vars_map if key.startswith(secret_prefixes)) + if leaked: + raise SystemExit(f"::error::move {', '.join(leaked)} from Actions variables to the BB_RPC_URLS secret") + leaked = sorted(key for key in vars_map if key.startswith(peer_prefix)) + if leaked: + raise SystemExit(f"::error::move {', '.join(leaked)} from Actions variables to the BB_BACKEND_PEERS secret") + 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) + write_env_var(env_file, normalize(key), "" if value is None else str(value)) + for line in os.environ.get("RPC_URLS", "").splitlines(): + key, sep, value = line.strip().partition("=") + if not sep or not value or not key.startswith(secret_prefixes): + continue + # The runner masks whole secret lines; the bare URL also shows up in later env headers and test errors. + print(f"::add-mask::{value}", flush=True) + write_env_var(env_file, normalize(key), value) + for line in os.environ.get("BACKEND_PEERS", "").splitlines(): + key, sep, value = line.strip().partition("=") + if not sep or not value or not key.startswith(peer_prefix): + continue + # Mask each peer too, so a single host printed later stays hidden. + for peer in [value, *value.replace(",", " ").split()]: + print(f"::add-mask::{peer}", flush=True) + write_env_var(env_file, normalize(key), value) PY diff --git a/.github/scripts/admin_api_e2e.py b/.github/scripts/admin_api_e2e.py new file mode 100644 index 0000000000..e851dbc475 --- /dev/null +++ b/.github/scripts/admin_api_e2e.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""E2E tests of the blockbook admin API (/admin/runtime-settings/, +/admin/contract-info/) against a deployed dev instance. + +The tests are idempotent and leave the instance's database and its live +rpcCall policy exactly as they were found: + +* runtime settings are exercised with a zero-policy-impact roundtrip — the + POSTed value is always the *current effective value* (or the explicit-empty + override when unset), so the parsed allowlist is identical at every instant, + and the final DELETE restores the original source; +* the contract-info roundtrip uses a reserved never-real address and deletes + its record at the end (and before starting, which heals a crashed run). + +Configuration (environment): + BB_ADMIN_E2E_COIN coin this test targets (default base_archive) + BB_ADMIN_E2E_URL explicit internal-server base URL; when unset, the host + is derived from the BB_DEV_API_URL_HTTP_ repo + variable and the port from the coin config's + ports.blockbook_internal, so a host move is a one-place + repo-variable change + DEPLOYED_COINS comma-separated deploy config names of this deploy run; + when set and the target coin is absent, the test skips + itself + BB_ENV content of blockbook.env; BB_ADMIN_USER and + BB_ADMIN_PASSWORD are extracted from it (never printed) + BB_ADMIN_USER/BB_ADMIN_PASSWORD direct credentials, override BB_ENV + SYNC_CA_FILE/SYNC_TLS_INSECURE TLS verification, as in wait_for_sync.py +""" + +import base64 +import json +import os +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from runner import LOG_PREFIX, ValidationError, fail, load_test_coin_name, log +from wait_for_sync import build_ssl_context, resolve_http_base + +REPO_ROOT = Path(__file__).resolve().parents[2] + +SETTING_KEYS = ("ALLOWED_RPC_CALL_TO", "ALLOWED_EVM_CALL_METHODS") +# Invalid values per setting; each must be rejected with 400 and change nothing. +INVALID_SETTING_VALUES = { + "ALLOWED_RPC_CALL_TO": ",", + "ALLOWED_EVM_CALL_METHODS": "0x12", +} +# Reserved, never-real address the contract roundtrip may create and delete. +TEST_CONTRACT_ADDRESS = "0xE2Ee00000000000000000000000000000000e2eE" + + +def strip_matching_quotes(value: str) -> str: + """Remove one pair of surrounding quotes, the way systemd's + EnvironmentFile does before the value reaches the server process.""" + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def parse_env_credentials(blob: str) -> tuple[str, str]: + """Extract BB_ADMIN_USER/BB_ADMIN_PASSWORD from KEY=value lines.""" + values = {} + for line in blob.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key in ("BB_ADMIN_USER", "BB_ADMIN_PASSWORD"): + values[key] = strip_matching_quotes(value.strip()) + return values.get("BB_ADMIN_USER", ""), values.get("BB_ADMIN_PASSWORD", "") + + +def coin_deployed(deployed_csv: str, coin: str) -> bool: + """Whether coin is in the comma-separated deploy list; an empty list means + the caller did not restrict the run (local use) and the test proceeds.""" + csv = deployed_csv.strip() + if not csv: + return True + coins = {part.strip().lower() for part in csv.split(",") if part.strip()} + return coin.strip().lower() in coins + + +def resolve_admin_url(coin: str) -> str: + """Resolve the internal-server base URL: an explicit BB_ADMIN_E2E_URL + wins; otherwise the host follows the same repo variable every other + post-deploy check uses — keyed by the coin's *test name* (tests.json + convention, e.g. BB_DEV_API_URL_HTTP_base for base_archive), not the + deploy alias — with the internal port from the coin config. The internal + server always serves HTTPS (the packaged self-signed certificate).""" + explicit = os.environ.get("BB_ADMIN_E2E_URL", "").strip() + if explicit: + return explicit.rstrip("/") + config_path = REPO_ROOT / "configs" / "coins" / (coin + ".json") + try: + test_coin = load_test_coin_name(config_path) + except ValidationError as exc: + fail(str(exc)) + host = urllib.parse.urlparse(resolve_http_base(test_coin)).hostname + try: + with open(config_path, encoding="utf-8") as f: + port = json.load(f)["ports"]["blockbook_internal"] + except (OSError, ValueError, KeyError) as exc: + fail(f"cannot read ports.blockbook_internal from {config_path}: {exc}") + return f"https://{host}:{port}" + + +def skip(message: str, annotation: str) -> None: + """Exit 0 with a visible workflow annotation, so a skip can never be + mistaken for a pass in the run summary.""" + print(f"::{annotation}::admin API e2e tests skipped: {message}") + log(f"{message}; skipping admin API e2e tests") + raise SystemExit(0) + + +def plan_settings_roundtrip(value: str, source: str) -> list[tuple[str, str | None, str, str]]: + """Return the (method, body value, expected value, expected source) steps + that exercise POST/GET/DELETE for a setting currently at (value, source) + while keeping the parsed policy identical at every instant and ending in + the starting state. A db-sourced setting is only rewritten in place: its + env fallback is unknown, so a DELETE could change policy.""" + if source == "db": + return [("POST", value, value, "db")] + if source == "env": + return [("POST", value, value, "db"), ("DELETE", None, value, "env")] + if source == "unset": + # the explicit-empty override behaves exactly like unset + return [("POST", "", "", "db"), ("DELETE", None, "", "unset")] + raise ValueError(f"unknown setting source {source!r}") + + +class AdminClient: + # one retry absorbs a transient connection error without masking a real + # outage; every request in this test is idempotent, so retrying is safe + RETRIES = 1 + RETRY_DELAY_SECONDS = 3 + + def __init__(self, base_url: str, user: str, password: str, timeout: int, context: ssl.SSLContext): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.context = context + token = base64.b64encode(f"{user}:{password}".encode()).decode() + self.auth_header = "Basic " + token + + def request(self, method: str, path: str, body: object = None, auth: bool = True) -> tuple[int, object]: + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(self.base_url + path, data=data, method=method, headers=headers) + if auth: + req.add_header("Authorization", self.auth_header) + for attempt in range(self.RETRIES + 1): + try: + with urllib.request.urlopen(req, timeout=self.timeout, context=self.context) as resp: + return resp.getcode(), self._parse(resp.read()) + except urllib.error.HTTPError as exc: + return exc.code, self._parse(exc.read()) + except OSError as exc: # URLError, TLS errors, timeouts + if attempt < self.RETRIES: + log(f"{method} {path} failed ({exc}), retrying in {self.RETRY_DELAY_SECONDS}s") + time.sleep(self.RETRY_DELAY_SECONDS) + continue + fail(f"{method} {self.base_url + path} failed after {attempt + 1} attempts: {exc}") + + @staticmethod + def _parse(raw: bytes) -> object: + try: + return json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError): + return {"raw": raw.decode("utf-8", "replace")} + + +def check(condition: bool, what: str, actual: object) -> None: + if not condition: + fail(f"{what}: got {actual!r}") + + +def get_setting(client: AdminClient, key: str) -> dict: + status, resp = client.request("GET", f"/admin/runtime-settings/{key}") + check(status == 200 and isinstance(resp, dict), f"GET {key} expected 200 object", (status, resp)) + return resp + + +def check_auth_gating(client: AdminClient) -> None: + status, resp = client.request("GET", "/admin/runtime-settings/", auth=False) + if status == 503: + fail("admin interface is disabled on the instance (503) — BB_ADMIN_USER/BB_ADMIN_PASSWORD not configured there") + check(status == 401, "unauthenticated GET expected 401", (status, resp)) + log("PASS auth: admin API rejects unauthenticated requests with 401") + + +def check_settings_list(client: AdminClient) -> dict[str, dict]: + status, resp = client.request("GET", "/admin/runtime-settings/") + check(status == 200 and isinstance(resp, list), "GET runtime-settings list expected 200 array", (status, resp)) + settings = {entry.get("key"): entry for entry in resp if isinstance(entry, dict)} + for key in SETTING_KEYS: + check(key in settings, f"runtime-settings list must contain {key}", sorted(settings)) + entry = settings[key] + check(entry.get("source") in ("db", "env", "unset"), f"{key} has a valid source", entry) + log("PASS settings: list returns all settings " + json.dumps([settings[k] for k in SETTING_KEYS])) + return settings + + +def check_settings_negative(client: AdminClient, before: dict[str, dict]) -> None: + status, resp = client.request("GET", "/admin/runtime-settings/NO_SUCH_SETTING") + check(status == 400, "GET unknown setting expected 400", (status, resp)) + for key, invalid in INVALID_SETTING_VALUES.items(): + status, resp = client.request("POST", f"/admin/runtime-settings/{key}", {"value": invalid}) + check(status == 400, f"POST invalid value to {key} expected 400", (status, resp)) + after = get_setting(client, key) + check(after == before[key], f"rejected POST must not change {key}", (before[key], after)) + log("PASS settings: unknown key and invalid values are rejected without effect") + + +def settings_roundtrip(client: AdminClient, key: str, current: dict) -> None: + value, source = current.get("value", ""), current.get("source", "") + steps = plan_settings_roundtrip(value, source) + # If the run dies between POST and DELETE, still try to restore the + # original source. The flag is raised before the POST is attempted: a + # spurious restore DELETE of a missing override is a harmless no-op that + # reverts to the env value, i.e. the state the failed POST left in place. + restore_delete = False + try: + for method, body_value, want_value, want_source in steps: + if method == "POST" and source != "db": + restore_delete = True + body = {"value": body_value} if method == "POST" else None + status, resp = client.request(method, f"/admin/runtime-settings/{key}", body) + want = {"key": key, "value": want_value, "source": want_source} + check(status == 200 and resp == want, f"{method} {key} expected {want}", (status, resp)) + after = get_setting(client, key) + check(after == want, f"GET {key} after {method} expected {want}", after) + if method == "DELETE": + restore_delete = False + finally: + if restore_delete: + try: + status, resp = client.request("DELETE", f"/admin/runtime-settings/{key}") + log(f"restore: DELETE {key} -> {status} {json.dumps(resp)}") + except Exception as exc: # do not mask the original failure + print(f"{LOG_PREFIX} restore DELETE {key} failed: {exc}", file=sys.stderr) + log(f"PASS settings: {key} roundtrip from source={source!r} left state unchanged") + + +def contract_roundtrip(client: AdminClient) -> None: + address = TEST_CONTRACT_ADDRESS + path = f"/admin/contract-info/{address}" + + # pre-clean: heals a previously crashed run; deleted:false is the norm + status, resp = client.request("DELETE", path) + check(status == 200 and isinstance(resp, dict), "pre-clean DELETE expected 200", (status, resp)) + if resp.get("deleted"): + log(f"pre-clean removed a leftover test record: {json.dumps(resp)}") + + # list pagination + status, resp = client.request("GET", "/admin/contract-info/?limit=1") + check( + status == 200 and isinstance(resp, dict) and isinstance(resp.get("contracts"), list), + "GET contract list expected 200 with contracts array", + (status, resp), + ) + check(len(resp["contracts"]) <= 1, "contract list must honor limit=1", len(resp["contracts"])) + if resp.get("next"): + status, page2 = client.request("GET", f"/admin/contract-info/?limit=1&from={resp['next']}") + check( + status == 200 and isinstance(page2, dict) and isinstance(page2.get("contracts"), list), + "GET contract list next page expected 200", + (status, page2), + ) + status, resp = client.request("GET", "/admin/contract-info/?limit=0") + check(status == 400, "GET contract list with limit=0 expected 400", (status, resp)) + + # negative writes + status, resp = client.request("POST", path, []) + check(status == 400, "POST to an address path expected 400", (status, resp)) + status, resp = client.request("PATCH", "/admin/contract-info/") + check(status == 400, "PATCH expected 400", (status, resp)) + + # create -> read -> list from cursor -> delete -> delete again + record = { + "contract": address, + "name": "TestToken-e2e", + "symbol": "TSTE2E", + "decimals": 18, + "standard": "ERC20", + "type": "ERC20", + } + # raised before the POST is attempted: the server may commit even when the + # response is lost, and a spurious cleanup DELETE of a missing row is a + # harmless no-op (same reasoning as the settings restore flag) + created = False + try: + created = True + status, resp = client.request("POST", "/admin/contract-info/", [record]) + check(status == 200 and resp == {"updated": 1}, "POST test contract expected {'updated': 1}", (status, resp)) + + status, resp = client.request("GET", path) + check(status == 200 and isinstance(resp, dict), "GET test contract expected 200", (status, resp)) + got = {k: resp.get(k) for k in ("name", "symbol", "decimals", "standard")} + want = {k: record[k] for k in ("name", "symbol", "decimals", "standard")} + check(got == want, f"GET test contract expected {want}", got) + check( + str(resp.get("contract", "")).lower() == address.lower(), + "GET test contract address echo", + resp.get("contract"), + ) + + status, resp = client.request("GET", f"/admin/contract-info/?limit=1&from={address}") + check( + status == 200 + and isinstance(resp, dict) + and len(resp.get("contracts", [])) == 1 + and str(resp["contracts"][0].get("contract", "")).lower() == address.lower(), + "list from= must start with the test record", + (status, resp), + ) + + status, resp = client.request("DELETE", path) + check( + status == 200 + and isinstance(resp, dict) + and resp.get("deleted") is True + and resp.get("purged", {}).get("name") == record["name"], + "DELETE must report deleted:true with the purged record", + (status, resp), + ) + created = False + status, resp = client.request("DELETE", path) + check( + status == 200 and isinstance(resp, dict) and resp.get("deleted") is False and "purged" not in resp, + "repeated DELETE must report deleted:false", + (status, resp), + ) + finally: + if created: # a failure after the POST must not leave the record behind + try: + status, resp = client.request("DELETE", path) + log(f"cleanup: DELETE test contract -> {status} {json.dumps(resp)}") + except Exception as exc: # do not mask the original failure + print(f"{LOG_PREFIX} cleanup DELETE {address} failed: {exc}", file=sys.stderr) + log("PASS contracts: list/POST/GET/DELETE roundtrip left no record behind") + + +def main() -> None: + coin = os.environ.get("BB_ADMIN_E2E_COIN", "base_archive") + if not coin_deployed(os.environ.get("DEPLOYED_COINS", ""), coin): + skip(f"{coin} is not part of this deploy run", "notice") + + user = os.environ.get("BB_ADMIN_USER", "").strip() + password = os.environ.get("BB_ADMIN_PASSWORD", "").strip() + if not (user and password): + blob = os.environ.get("BB_ENV", "") + if not blob.strip(): + # a warning, not a notice: in CI the secret is expected to exist, + # so this skip usually means a lost/renamed BB_RUNTIME_ENV + skip("BB_RUNTIME_ENV secret not set", "warning") + user, password = parse_env_credentials(blob) + if not (user and password): + fail("BB_ENV does not contain BB_ADMIN_USER and BB_ADMIN_PASSWORD") + + base_url = resolve_admin_url(coin) + ssl_context, tls_mode = build_ssl_context() + log(f"TLS certificate verification: {tls_mode}") + timeout = int(os.environ.get("ADMIN_E2E_REQUEST_TIMEOUT_SECONDS", "20")) + client = AdminClient(base_url, user, password, timeout, ssl_context) + log(f"Running admin API e2e tests against {base_url} ({coin})") + + check_auth_gating(client) + settings = check_settings_list(client) + check_settings_negative(client, settings) + for key in SETTING_KEYS: + settings_roundtrip(client, key, settings[key]) + contract_roundtrip(client) + log("All admin API e2e tests passed; instance state is unchanged.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/admin_api_e2e_test.py b/.github/scripts/admin_api_e2e_test.py new file mode 100644 index 0000000000..3ecaf9b2ce --- /dev/null +++ b/.github/scripts/admin_api_e2e_test.py @@ -0,0 +1,111 @@ +import os +import unittest +from unittest import mock + +from admin_api_e2e import ( + coin_deployed, + parse_env_credentials, + plan_settings_roundtrip, + resolve_admin_url, +) + + +class ResolveAdminUrlTest(unittest.TestCase): + def test_explicit_url_wins(self) -> None: + with mock.patch.dict(os.environ, {"BB_ADMIN_E2E_URL": "https://example.test:9999/"}, clear=False): + self.assertEqual(resolve_admin_url("base_archive"), "https://example.test:9999") + + def test_derives_from_test_name_variable_and_config_port(self) -> None: + # the URL repo variables follow the tests.json test-name convention + # (BB_DEV_API_URL_HTTP_base for base_archive, not ..._base_archive); + # the port comes from the real coin config's ports.blockbook_internal + env = { + "BB_ADMIN_E2E_URL": "", + "BB_DEV_API_URL_HTTP_base": "https://blockbook-dev.test:9311", + } + with mock.patch.dict(os.environ, env, clear=False): + self.assertEqual(resolve_admin_url("base_archive"), "https://blockbook-dev.test:9211") + + +class ParseEnvCredentialsTest(unittest.TestCase): + def test_plain(self) -> None: + blob = "ETH_ALLOWED_RPC_CALL_TO=0xabc\nBB_ADMIN_USER=admin\nBB_ADMIN_PASSWORD=s3cr3t\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "s3cr3t")) + + def test_whitespace_and_cr(self) -> None: + blob = " BB_ADMIN_USER = admin \r\nBB_ADMIN_PASSWORD=s3cr3t \r\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "s3cr3t")) + + def test_value_containing_equals(self) -> None: + blob = "BB_ADMIN_USER=admin\nBB_ADMIN_PASSWORD=pa=ss=word\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "pa=ss=word")) + + def test_surrounding_quotes_stripped_like_systemd(self) -> None: + # systemd's EnvironmentFile strips one pair of quotes before the value + # reaches the server, so the script must send the same unquoted value + blob = "BB_ADMIN_USER=\"admin\"\nBB_ADMIN_PASSWORD='s3c r3t'\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "s3c r3t")) + + def test_inner_and_unmatched_quotes_kept(self) -> None: + blob = "BB_ADMIN_USER=ad\"min\nBB_ADMIN_PASSWORD=\"s3cr3t\n" + self.assertEqual(parse_env_credentials(blob), ('ad"min', '"s3cr3t')) + + def test_comments_and_missing_keys(self) -> None: + blob = "# BB_ADMIN_USER=commented\nOTHER=x\n" + self.assertEqual(parse_env_credentials(blob), ("", "")) + + +class CoinDeployedTest(unittest.TestCase): + def test_empty_list_means_unrestricted(self) -> None: + self.assertTrue(coin_deployed("", "base_archive")) + self.assertTrue(coin_deployed(" ", "base_archive")) + + def test_membership(self) -> None: + self.assertTrue(coin_deployed("bitcoin, base_archive ,zcash", "base_archive")) + self.assertTrue(coin_deployed("Base_Archive", "base_archive")) + self.assertFalse(coin_deployed("bitcoin,zcash", "base_archive")) + + +class PlanSettingsRoundtripTest(unittest.TestCase): + """The planner must keep the parsed policy identical at every instant and + end in the starting state (except db-sourced settings, which are only + rewritten in place because their env fallback is unknown).""" + + def test_env_sourced(self) -> None: + self.assertEqual( + plan_settings_roundtrip("0xabc,0xdef", "env"), + [ + ("POST", "0xabc,0xdef", "0xabc,0xdef", "db"), + ("DELETE", None, "0xabc,0xdef", "env"), + ], + ) + + def test_unset(self) -> None: + self.assertEqual( + plan_settings_roundtrip("", "unset"), + [("POST", "", "", "db"), ("DELETE", None, "", "unset")], + ) + + def test_db_sourced_is_rewritten_in_place_only(self) -> None: + self.assertEqual( + plan_settings_roundtrip("0xabc", "db"), + [("POST", "0xabc", "0xabc", "db")], + ) + + def test_every_step_preserves_the_effective_value(self) -> None: + for value, source in (("0xabc", "env"), ("", "unset"), ("0xdd62ed3e", "db")): + for _, _, expected_value, _ in plan_settings_roundtrip(value, source): + self.assertEqual(expected_value, value, (value, source)) + + def test_end_state_matches_start_state(self) -> None: + for value, source in (("0xabc", "env"), ("", "unset")): + _, _, final_value, final_source = plan_settings_roundtrip(value, source)[-1] + self.assertEqual((final_value, final_source), (value, source)) + + def test_unknown_source_raises(self) -> None: + with self.assertRaises(ValueError): + plan_settings_roundtrip("x", "bogus") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/build_plan.py b/.github/scripts/build_plan.py index 22ceb38e6b..603aa68b00 100644 --- a/.github/scripts/build_plan.py +++ b/.github/scripts/build_plan.py @@ -13,6 +13,7 @@ log, parse_json_object, resolve_build_selection, + write_step_summary, ) @@ -67,6 +68,22 @@ def main() -> None: + ", ".join(item["coins"]) ) + summary_lines = [ + f"### Build plan ({build_env})", + "", + "| Runner | Coins |", + "| --- | --- |", + ] + for item in runner_matrix: + summary_lines.append(f"| {item['runner']} | {', '.join(item['coins'])} |") + if selection.skipped_prod_only and selection.requested_all: + summary_lines += [ + "", + "Skipped prod-only coins for env=dev: " + + ", ".join(selection.skipped_prod_only), + ] + write_step_summary("\n".join(summary_lines)) + if __name__ == "__main__": main() diff --git a/.github/scripts/deploy_plan.py b/.github/scripts/deploy_plan.py index 8539cf7101..903d05590d 100644 --- a/.github/scripts/deploy_plan.py +++ b/.github/scripts/deploy_plan.py @@ -14,6 +14,7 @@ parse_json_object, require_coin_config, resolve_deploy_selection, + write_step_summary, ) @@ -90,6 +91,20 @@ def main() -> None: log("Selected coins: " + ", ".join(requested)) log("Connectivity regex: " + connectivity_regex) + summary_lines = [ + "### Deploy plan", + "", + "| Coin | Runner |", + "| --- | --- |", + ] + for item in runner_matrix: + summary_lines.append(f"| {item['coin']} | {item['runner']} |") + summary_lines += [ + "", + "E2E test coins: " + ", ".join(unique_test_coins), + ] + write_step_summary("\n".join(summary_lines)) + if __name__ == "__main__": main() diff --git a/.github/scripts/runner.py b/.github/scripts/runner.py index 06de28d61f..44ca78fc07 100644 --- a/.github/scripts/runner.py +++ b/.github/scripts/runner.py @@ -41,6 +41,15 @@ def log(message: str) -> None: print(f"{LOG_PREFIX} {message}", flush=True) +def write_step_summary(markdown: str) -> None: + # Renders on the workflow run's summary page; a no-op outside Actions. + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + with open(summary_path, "a", encoding="utf-8") as summary: + summary.write(markdown.rstrip("\n") + "\n") + + def parse_json_object(raw: str, description: str) -> dict: try: payload = json.loads(raw) @@ -104,6 +113,34 @@ def load_runner_map(vars_map: dict) -> dict[str, str]: return mapping +def load_staging_coins(vars_map: dict) -> set[str]: + # BB_STAGING lists work-in-progress coins (comma/space separated); include the + # '-'/'_' variants so it matches either coin-name spelling. + staging: set[str] = set() + for token in re.split(r"[\s,]+", str(vars_map.get("BB_STAGING") or "")): + name = token.strip().lower() + if name: + staging.update({name, name.replace("-", "_"), name.replace("_", "-")}) + return staging + + +def prune_staging_coins( + workspace: Path, runner_map: dict[str, str], staging: set[str] +) -> dict[str, str]: + # Drop a staging coin only where its config is absent, so an unmerged coin's + # repo-wide variables don't break sibling branches while the feature branch that + # carries the config still builds and deploys it normally. + if not staging: + return runner_map + kept = {} + for coin, runner in runner_map.items(): + if coin in staging and not coin_config_path(workspace, coin).exists(): + log(f"BB_STAGING: skipping '{coin}' (no configs/coins/{coin}.json on this branch)") + continue + kept[coin] = runner + return kept + + def parse_coin_tokens(raw: str, *, allow_all: bool) -> tuple[bool, list[str]]: text = raw.strip() if not text: @@ -192,6 +229,7 @@ def validate_runner_map_configs(workspace: Path, runner_map: dict[str, str]) -> raise ValidationError( "BB_RUNNER_* entries without matching configs/coins/.json: " + ", ".join(missing) + + " (add a work-in-progress coin to the BB_STAGING variable if its config is on another branch)" ) @@ -298,6 +336,11 @@ def load_coin_context( runner_map = normalize_runner_map(workspace, runner_map) if not runner_map: raise ValidationError("no BB_RUNNER_* variables found") + runner_map = prune_staging_coins(workspace, runner_map, load_staging_coins(vars_map)) + if not runner_map: + raise ValidationError( + "all BB_RUNNER_* coins are BB_STAGING work-in-progress without a config on this branch" + ) validate_runner_map_configs(workspace, runner_map) all_coins = list_all_coins(workspace, runner_map) diff --git a/.github/scripts/runner_test.py b/.github/scripts/runner_test.py index 3dd1529e5f..0f7d1c2aad 100644 --- a/.github/scripts/runner_test.py +++ b/.github/scripts/runner_test.py @@ -63,6 +63,43 @@ def test_load_coin_context_rejects_runner_mapping_without_config(self) -> None: ): load_coin_context(self.workspace, self.stale_vars_map) + def test_load_coin_context_prunes_staging_coin_without_config(self) -> None: + vars_map = {**self.stale_vars_map, "BB_STAGING": "stale"} + + context = load_coin_context(self.workspace, vars_map) + + self.assertNotIn("stale", context.all_coins) + self.assertIn("dogecoin", context.all_coins) + + def test_load_coin_context_keeps_staging_coin_that_has_config(self) -> None: + vars_map = {**self.valid_vars_map, "BB_STAGING": "dogecoin"} + + context = load_coin_context(self.workspace, vars_map) + + self.assertIn("dogecoin", context.all_coins) + + def test_load_coin_context_reports_all_staging_pruned_distinctly(self) -> None: + vars_map = { + "BB_RUNNER_ROBINHOOD_ARCHIVE": "blockbook-dev", + "BB_STAGING": "robinhood_archive", + } + + with self.assertRaisesRegex( + ValidationError, r"all BB_RUNNER_\* coins are BB_STAGING" + ): + load_coin_context(self.workspace, vars_map) + + def test_staging_prune_matches_hyphen_underscore_variant(self) -> None: + vars_map = { + **self.valid_vars_map, + "BB_RUNNER_ROBINHOOD_ARCHIVE": "blockbook-dev", + "BB_STAGING": "robinhood-archive", + } + + context = load_coin_context(self.workspace, vars_map) + + self.assertNotIn("robinhood_archive", context.all_coins) + def test_build_all_uses_all_configured_runner_mapped_coins(self) -> None: context = load_coin_context(self.workspace, self.valid_vars_map) diff --git a/.github/scripts/validate_branch_or_tag.py b/.github/scripts/validate_branch_or_tag.py index 5ae9fe3320..2aa95fef9b 100755 --- a/.github/scripts/validate_branch_or_tag.py +++ b/.github/scripts/validate_branch_or_tag.py @@ -23,8 +23,8 @@ def fail(message: str) -> None: 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], + result = subprocess.run( + ["git", "ls-remote", "--exit-code", f"--{kind}", remote, "--", ref], check=True, capture_output=True, text=True, @@ -34,7 +34,15 @@ def ref_exists(repo: str, ref: str, kind: str) -> bool: raise AssertionError("unreachable") from exc except subprocess.CalledProcessError: return False - return True + # ls-remote patterns are tail-matching globs, so e.g. 'mas*' or 'master' + # can match a different ref than the one actions/checkout will later use; + # accept only an exact ref-name match. + expected = f"refs/{kind}/{ref}" + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) == 2 and parts[1] == expected: + return True + return False def validate_branch_or_tag(repo: str, ref: str) -> str: diff --git a/.github/scripts/validate_branch_or_tag_test.py b/.github/scripts/validate_branch_or_tag_test.py index 630115730e..0592566625 100644 --- a/.github/scripts/validate_branch_or_tag_test.py +++ b/.github/scripts/validate_branch_or_tag_test.py @@ -1,7 +1,8 @@ +import subprocess import unittest from unittest.mock import patch -from validate_branch_or_tag import validate_branch_or_tag +from validate_branch_or_tag import ref_exists, validate_branch_or_tag class ValidateBranchOrTagTest(unittest.TestCase): @@ -21,5 +22,34 @@ def test_rejects_missing_ref(self) -> None: validate_branch_or_tag("trezor/blockbook", "missing-ref") +class RefExistsTest(unittest.TestCase): + @staticmethod + def _ls_remote(stdout: str) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout, stderr="") + + def test_accepts_exact_ref_name(self) -> None: + with patch( + "validate_branch_or_tag.subprocess.run", + return_value=self._ls_remote("abc123\trefs/heads/master\n"), + ): + self.assertTrue(ref_exists("trezor/blockbook", "master", "heads")) + + def test_rejects_glob_pattern_matching_another_ref(self) -> None: + with patch( + "validate_branch_or_tag.subprocess.run", + return_value=self._ls_remote("abc123\trefs/heads/master\n"), + ): + self.assertFalse(ref_exists("trezor/blockbook", "mas*", "heads")) + + def test_rejects_tail_match_of_longer_ref(self) -> None: + # ls-remote patterns tail-match, so 'master' would match + # 'feature/master' even when no branch named 'master' exists. + with patch( + "validate_branch_or_tag.subprocess.run", + return_value=self._ls_remote("abc123\trefs/heads/feature/master\n"), + ): + self.assertFalse(ref_exists("trezor/blockbook", "master", "heads")) + + if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/wait_for_sync.py b/.github/scripts/wait_for_sync.py index 41c4770396..c4760efff4 100644 --- a/.github/scripts/wait_for_sync.py +++ b/.github/scripts/wait_for_sync.py @@ -21,6 +21,15 @@ def log(message: str) -> None: print(f"{LOG_PREFIX} {message}", flush=True) +def read_error_body(exc: urllib.error.HTTPError) -> bytes: + # The connection can be reset while reading the error body (e.g. a proxy + # 502); that must degrade to an empty body, not kill the retry loop. + try: + return exc.read() + except Exception: + return b"" + + def parse_requested_coins(raw: str) -> list[str]: text = raw.strip() if not text: @@ -192,7 +201,7 @@ def main() -> None: status, body = fetch_status(base_url, request_timeout, ssl_context) except urllib.error.HTTPError as exc: status = exc.code - body = exc.read() + body = read_error_body(exc) except Exception as exc: last_seen[coin] = f"{base_url}/api/status request failed: {exc}" continue @@ -204,7 +213,7 @@ def main() -> None: status, body = fetch_status(base_url, request_timeout, ssl_context) except urllib.error.HTTPError as exc: status = exc.code - body = exc.read() + body = read_error_body(exc) except Exception as exc: last_seen[coin] = f"{base_url}/api/status request failed: {exc}" continue @@ -224,14 +233,16 @@ def main() -> None: if not pending: break - remaining_seconds = int(max(0, deadline - time.monotonic())) - if remaining_seconds == 0: + # Keep sub-second remainders: int() truncation used to declare a + # timeout with up to a second of budget (and one last poll) unused. + remaining_seconds = 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}") + log(f"Still waiting for Blockbook sync ({remaining_seconds:.0f}s left): {details}") time.sleep(min(poll_seconds, remaining_seconds)) if pending: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c1e2177174..92e74de377 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,5 +1,10 @@ name: Build / Deploy +# Surface what the run actually does in the run list; without this every +# dispatch shows only the generic workflow name. env context is not +# available in run-name, so the ref fallback is inlined. +run-name: "${{ inputs.mode }} ${{ inputs.coins }} (${{ inputs.mode == 'build' && inputs.env || 'dev' }}) @ ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }}" + on: workflow_dispatch: inputs: @@ -39,19 +44,21 @@ on: permissions: contents: read +env: + RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Validate branch or tag env: @@ -59,9 +66,10 @@ jobs: 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + persist-credentials: false - name: Build build plan id: plan @@ -75,16 +83,15 @@ jobs: 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Validate branch or tag env: @@ -92,9 +99,10 @@ jobs: 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + persist-credentials: false - name: Build deploy/e2e plan id: plan @@ -104,11 +112,12 @@ jobs: run: python3 ./.github/scripts/deploy_plan.py build: - name: Build (${{ matrix.runner }}) + name: "Build (${{ matrix.runner }}: ${{ matrix.coins_csv }})" needs: prepare_build if: ${{ inputs.mode == 'build' }} - env: - RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + # Generous ceilings so a hung step can't hold a self-hosted runner for + # the 360-minute default; sized well above normal runtimes. + timeout-minutes: 240 strategy: fail-fast: false matrix: @@ -116,14 +125,17 @@ jobs: runs-on: ${{ fromJSON(matrix.labels_json) }} steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + persist-credentials: false - name: Export repository variables uses: ./.github/actions/export-env-vars with: vars_json: ${{ toJSON(vars) }} + rpc_urls: ${{ secrets.BB_RPC_URLS }} + backend_peers: ${{ secrets.BB_BACKEND_PEERS }} - name: Build packages env: @@ -139,8 +151,7 @@ jobs: 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 }} + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -148,14 +159,16 @@ jobs: runs-on: [self-hosted, bb-dev-selfhosted, "${{ matrix.runner }}"] steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + persist-credentials: false - name: Export repository variables uses: ./.github/actions/export-env-vars with: vars_json: ${{ toJSON(vars) }} + rpc_urls: ${{ secrets.BB_RPC_URLS }} - name: Materialize blockbook runtime env env: @@ -175,28 +188,33 @@ jobs: 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 + DEPLOY_COIN: ${{ matrix.coin }} + run: ./contrib/scripts/deploy-bb-and-backend.sh "$DEPLOY_COIN" --force-confnew wait-for-sync: name: Wait For Sync needs: [prepare_deploy, deploy] - if: ${{ needs.deploy.result == 'success' }} + # deploy is a fail-fast:false matrix: one failed coin must not skip sync + # verification for the coins that did deploy, so run on partial failure + # too. Only 'skipped' (mode=build) and cancellation keep this job off. + if: ${{ !cancelled() && (needs.deploy.result == 'success' || needs.deploy.result == 'failure') }} runs-on: [self-hosted, bb-dev-selfhosted] - timeout-minutes: 31 + timeout-minutes: 6 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" + SYNC_TIMEOUT_SECONDS: "300" steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + persist-credentials: false - name: Export repository variables uses: ./.github/actions/export-env-vars with: vars_json: ${{ toJSON(vars) }} + rpc_urls: ${{ secrets.BB_RPC_URLS }} - name: Wait for Blockbook sync env: @@ -206,21 +224,25 @@ jobs: 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' }} + # Mirror wait-for-sync: partially failed deploys still get e2e coverage + # for the coins that deployed, as long as they reached sync. + if: ${{ !cancelled() && (needs.deploy.result == 'success' || needs.deploy.result == 'failure') && needs.wait-for-sync.result == 'success' }} runs-on: [self-hosted, bb-dev-selfhosted] + timeout-minutes: 60 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + persist-credentials: false - name: Export repository variables uses: ./.github/actions/export-env-vars with: vars_json: ${{ toJSON(vars) }} + rpc_urls: ${{ secrets.BB_RPC_URLS }} - name: Run Blockbook connectivity tests env: @@ -232,15 +254,25 @@ jobs: run: make test-connectivity ARGS="-v" - name: Setup Node.js for OpenAPI tests - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - cache: npm - cache-dependency-path: tests/openapi/package-lock.json + + - name: Ensure npm cache directory exists + run: mkdir -p .npm-cache + + - name: Cache npm packages + id: openapi-npm-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .npm-cache + key: openapi-npm-cache-node22-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/openapi/package-lock.json') }} + restore-keys: | + openapi-npm-cache-node22-${{ runner.os }}-${{ runner.arch }}- - name: Cache OpenAPI test dependencies id: openapi-node-modules-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: tests/openapi/node_modules key: openapi-node-modules-node22-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/openapi/package-lock.json') }} @@ -249,7 +281,7 @@ jobs: - 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 + run: npm ci --prefix tests/openapi --cache .npm-cache --prefer-offline --no-audit --no-fund - name: Run OpenAPI API e2e tests env: @@ -262,8 +294,24 @@ jobs: # 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 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: openapi-e2e-report path: tests/openapi/reports/ if-no-files-found: warn + + # Idempotent roundtrip of the internal-server admin API (runtime settings + # + contract info) against the deployed dev instance. The target URL is + # derived from the BB_DEV_API_URL_HTTP_ repo variable (exported + # above) plus the coin config's internal port. The script skips itself — + # with a visible annotation — when the target coin is not part of this + # deploy run (DEPLOYED_COINS carries deploy config names, hence + # coins_csv, not test_coins_csv) or the BB_RUNTIME_ENV secret is not + # set; credentials are parsed inside the script from BB_ENV, which is + # intentionally not exported to other steps. + - name: Run admin API e2e tests + env: + BB_ENV: ${{ secrets.BB_RUNTIME_ENV }} + BB_ADMIN_E2E_COIN: base_archive + DEPLOYED_COINS: ${{ needs.prepare_deploy.outputs.coins_csv }} + run: python3 ./.github/scripts/admin_api_e2e.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c86ab1a3da..356dae677c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -5,14 +5,26 @@ on: branches: [ master, develop ] pull_request: branches: [ '**' ] + # Lets a maintainer run the RPC-dependent jobs on a Dependabot branch. + workflow_dispatch: permissions: contents: read +# Rapid pushes queue full unit->connectivity->integration chains for +# already-superseded commits on the shared self-hosted pool. Cancel only +# superseded PR runs; pushes to master/develop are never cancelled. +concurrency: + group: testing-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: unit-tests: name: Unit Tests runs-on: [self-hosted, bb-dev-selfhosted] + # Generous ceilings so a hung step can't hold a self-hosted runner for + # the 360-minute default; sized well above normal runtimes. + timeout-minutes: 60 # 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 @@ -21,7 +33,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -33,12 +45,14 @@ jobs: connectivity-tests: name: Connectivity Tests runs-on: [self-hosted, bb-dev-selfhosted] + timeout-minutes: 30 needs: unit-tests - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + # Dependabot runs get no Actions secrets, so BB_RPC_URLS is empty; dispatch the workflow on the branch instead. + if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' }} steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -46,6 +60,7 @@ jobs: uses: ./.github/actions/export-env-vars with: vars_json: ${{ toJSON(vars) }} + rpc_urls: ${{ secrets.BB_RPC_URLS }} - name: Run connectivity tests env: @@ -58,12 +73,13 @@ jobs: integration-tests: name: Integration Tests (RPC + Sync) runs-on: [self-hosted, bb-dev-selfhosted] + timeout-minutes: 180 needs: connectivity-tests - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' }} steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -71,6 +87,7 @@ jobs: uses: ./.github/actions/export-env-vars with: vars_json: ${{ toJSON(vars) }} + rpc_urls: ${{ secrets.BB_RPC_URLS }} - name: Run integration tests env: @@ -89,7 +106,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -105,3 +122,8 @@ jobs: - name: Validate backend artifact sources run: python3 ./.github/scripts/validate_backend_artifacts.py + + - name: Run CI pipeline script tests + # Stdlib-only unit tests for the build/deploy plan scripts; without + # this step a regression in .github/scripts ships with green CI. + run: python3 -m unittest discover -s .github/scripts -p '*_test.py' diff --git a/AGENTS.md b/AGENTS.md index 2beed2b800..45dc6d1397 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,8 +78,28 @@ Prometheus metrics and the Grafana dashboard share one source of truth, `configs 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`). +- Legends never repeat `{{coin}}` (the dropdown fixes it); they start with `{{instance}}` for a + per-replica series or `all replicas - ` for a query that sums replicas away -- see + `configs/grafana/README.md`, "Legend convention". `--check` enforces it. - After any of these, run `python3 contrib/scripts/render_grafana.py` (CI gates with `--check`). +## API TypeScript types + +`blockbook-api.ts` is generated from the Go API structs by `build/tools/typescriptify`; never edit it by hand. + +- **New REST/websocket request or response struct:** add it to `apiTypes` in + `build/tools/typescriptify/typescriptify.go`. Only roots need listing; structs reachable through typed + fields are discovered, but anything sent through `interface{}`/`json.RawMessage` (e.g. websocket + `params`/`data`) is not. `TestCommittedFileIsCurrent` cannot catch a missing root, so it silently + stays out of the file. +- **New chain-specific `chainExtraData` payload:** add an entry to `bchain.ChainExtraPayloads`, not to `apiTypes`. +- **New type with custom JSON marshaling** (like `api.Amount` → string): register it with `ManageType` + in `newConverter`, or the generated shape will not match the wire. +- Field typing comes from tags: `json` names/`omitempty` (optional), `ts_doc`, `ts_type` for overrides, + and `ts_nullable:"true"` for a pointer without `omitempty` (`T | null`). +- After any API struct change, run `make typescriptify` (or `go run ./build/tools/typescriptify`) and + commit the result; `TestCommittedFileIsCurrent` fails the unit tests while the file is stale. + ## Facts to keep in mind to avoid regressions and waste - Blockbook instance should be able to : diff --git a/Makefile b/Makefile index f73da991b3..c3c2773419 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ 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 = +JEMALLOC = 1 PORTABLE = 0 ARGS ?= GITCOMMIT ?= $(shell git describe --always --dirty 2>/dev/null) @@ -15,10 +16,13 @@ GITCOMMIT ?= $(shell git describe --always --dirty 2>/dev/null) # (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) -) +# BB_STAGING is a standalone control var (no coin-alias suffix, like BB_BUILD_ENV): +# forward it too so its config-absent coin exemption applies inside the container. +BB_RPC_ENV := $(BB_RPC_ENV)$(if $(BB_STAGING), -e BB_STAGING) TARGETS=$(subst .json,, $(shell ls configs/coins)) -.PHONY: build build-debug test test-connectivity test-integration test-e2e test-all deb +.PHONY: build build-debug test test-connectivity test-integration test-e2e test-all typescriptify deb build: .bin-image docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(BIN_IMAGE) make build ARGS="$(ARGS)" @@ -54,6 +58,9 @@ test-e2e: 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)" +typescriptify: .bin-image + docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" $(BIN_IMAGE) make typescriptify + test-all: .bin-image 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)" @@ -78,7 +85,7 @@ build-images: clean-images .bin-image: @if [ $$(build/tools/image_status.sh $(BIN_IMAGE):latest build/docker) != "ok" ]; then \ echo "Building image $(BIN_IMAGE) from $(BASE_IMAGE)"; \ - docker build --no-cache=$(NO_CACHE) --build-arg TCMALLOC=$(TCMALLOC) --build-arg BASE_IMAGE=$(BASE_IMAGE) --build-arg PORTABLE_ROCKSDB=$(PORTABLE) -t $(BIN_IMAGE) build/docker/bin; \ + docker build --no-cache=$(NO_CACHE) --build-arg TCMALLOC=$(TCMALLOC) --build-arg JEMALLOC=$(JEMALLOC) --build-arg BASE_IMAGE=$(BASE_IMAGE) --build-arg PORTABLE_ROCKSDB=$(PORTABLE) -t $(BIN_IMAGE) build/docker/bin; \ else \ echo "Image $(BIN_IMAGE) is up to date"; \ fi diff --git a/api/contract_probe.go b/api/contract_probe.go new file mode 100644 index 0000000000..8205cd39ac --- /dev/null +++ b/api/contract_probe.go @@ -0,0 +1,109 @@ +package api + +import ( + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/db" +) + +// contractProbeCacheCapacity bounds the negative cache; eviction is not a correctness event, +// it just costs the one probe every request pays for that contract today. +const contractProbeCacheCapacity = 8192 + +// contractInfoProbes are the verdicts a batched pre-pass already resolved, keyed by address +// descriptor, so the per-contract path does not read the same contract again. +type contractInfoProbes map[string]bchain.EthereumContractInfoResult + +// contractInfoBatchResolver is the chain-side seam for batched metadata reads, satisfied by +// EVM chains via Multicall3 aggregate3. +type contractInfoBatchResolver interface { + EthereumTypeGetContractInfos(contractDescs []bchain.AddressDescriptor) []bchain.EthereumContractInfoResult +} + +// contractProbeCacheState samples the height and reorg generation that scope negative cache +// entries. A zero height (nothing indexed yet) disables the cache for this request. +func (w *Worker) contractProbeCacheState() (bestHeight uint32, reorgGen uint64) { + _, bestHeight, _, _ = w.is.GetSyncState() + return bestHeight, w.db.ReorgGeneration() +} + +// resolveContractInfo returns the pre-pass verdict for cd, else reads this one contract from the +// chain, collapsing concurrent probes. (nil, nil) is a conclusive "no token here" - cacheable. +func (w *Worker) resolveContractInfo(cd bchain.AddressDescriptor, probes contractInfoProbes) (*bchain.ContractInfo, error) { + if probe, ok := probes[string(cd)]; ok { + return copyContractInfo(probe.Info), probe.Err + } + v, err, _ := w.contractProbeGroup.Do(string(cd), func() (interface{}, error) { + return w.chain.GetContractInfo(cd) + }) + info, _ := v.(*bchain.ContractInfo) + return copyContractInfo(info), err +} + +// copyContractInfo hands every caller its own struct: the batch pre-pass shares one result +// across callers that go on to mutate and store it. +func copyContractInfo(ci *bchain.ContractInfo) *bchain.ContractInfo { + if ci == nil { + return nil + } + c := *ci + return &c +} + +// unknownContractInfo is how an unresolved contract is surfaced: no name or symbol and the coin's +// decimals. Callers pair it with validContract=false, which keeps the contract out of balance reads. +func (w *Worker) unknownContractInfo(cd bchain.AddressDescriptor) *bchain.ContractInfo { + contractInfo := &bchain.ContractInfo{Standard: bchain.UnknownTokenStandard, Decimals: w.chainParser.AmountDecimals()} + if addresses, _, _ := w.chainParser.GetAddressesFromAddrDesc(cd); len(addresses) > 0 { + contractInfo.Contract = addresses[0] + } + return contractInfo +} + +// contractNeedsChainRefresh reports whether a stored record must still be read from the chain: an +// unhandled standard (sync skips the fetch for internal-tx contracts), or a zero-parsed name/symbol. +func contractNeedsChainRefresh(contractInfo *bchain.ContractInfo) bool { + return contractInfo.Standard == bchain.UnhandledTokenStandard || + (len(contractInfo.Name) > 0 && contractInfo.Name[0] == 0) || + (len(contractInfo.Symbol) > 0 && contractInfo.Symbol[0] == 0) +} + +// prefetchContractInfos resolves in one batched call the metadata of the contracts the token loop +// would otherwise probe one at a time, skipping those the index describes or a recent probe settled. +func (w *Worker) prefetchContractInfos(contracts []db.AddrContract) contractInfoProbes { + resolver, ok := w.chain.(contractInfoBatchResolver) + if !ok { + return nil + } + bestHeight, reorgGen := w.contractProbeCacheState() + unresolved := make([]bchain.AddressDescriptor, 0, len(contracts)) + for i := range contracts { + cd := contracts[i].Contract + // read with an unknown standard: promoting it is the per-contract path's job + contractInfo, err := w.db.GetContractInfo(cd, bchain.UnknownTokenStandard) + if err != nil { + continue + } + if contractInfo == nil { + if !w.contractProbeCache.contains(string(cd), bestHeight, reorgGen) { + unresolved = append(unresolved, cd) + } + continue + } + if contractNeedsChainRefresh(contractInfo) { + unresolved = append(unresolved, cd) + } + } + // one contract is not worth a batch - the per-contract path reads it just as cheaply + if len(unresolved) < 2 { + return nil + } + results := resolver.EthereumTypeGetContractInfos(unresolved) + if len(results) != len(unresolved) { + return nil + } + probes := make(contractInfoProbes, len(unresolved)) + for i, cd := range unresolved { + probes[string(cd)] = results[i] + } + return probes +} diff --git a/api/contract_probe_test.go b/api/contract_probe_test.go new file mode 100644 index 0000000000..ac4937b2ab --- /dev/null +++ b/api/contract_probe_test.go @@ -0,0 +1,319 @@ +//go:build unittest + +package api + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// contractProbeChain counts what the metadata path reads from the chain; a contract absent from +// tokens is reported as holding no token, the way a dead contract or an ERC1155 is. +type contractProbeChain struct { + bchain.BlockChain + mu sync.Mutex + tokens map[string]*bchain.ContractInfo + err error + singleCalls int + batchCalls int + batchSizes []int +} + +func (c *contractProbeChain) GetContractInfo(contractDesc bchain.AddressDescriptor) (*bchain.ContractInfo, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.singleCalls++ + if c.err != nil { + return nil, c.err + } + return copyContractInfo(c.tokens[string(contractDesc)]), nil +} + +func (c *contractProbeChain) EthereumTypeGetContractInfos(contractDescs []bchain.AddressDescriptor) []bchain.EthereumContractInfoResult { + c.mu.Lock() + defer c.mu.Unlock() + c.batchCalls++ + c.batchSizes = append(c.batchSizes, len(contractDescs)) + results := make([]bchain.EthereumContractInfoResult, len(contractDescs)) + for i, cd := range contractDescs { + if c.err != nil { + results[i].Err = c.err + continue + } + results[i].Info = copyContractInfo(c.tokens[string(cd)]) + } + return results +} + +// without a block time the negative TTL resolves to 0 and the cache is disabled +func (c *contractProbeChain) AverageBlockTimeDuration() (time.Duration, error) { + return 12 * time.Second, nil +} + +func (c *contractProbeChain) counts() (single, batch int) { + c.mu.Lock() + defer c.mu.Unlock() + return c.singleCalls, c.batchCalls +} + +// contractProbeChainNoBatch is a chain that offers no batched resolve, e.g. a non-EVM coin. +type contractProbeChainNoBatch struct { + bchain.BlockChain + inner *contractProbeChain +} + +func (c *contractProbeChainNoBatch) GetContractInfo(contractDesc bchain.AddressDescriptor) (*bchain.ContractInfo, error) { + return c.inner.GetContractInfo(contractDesc) +} + +func (c *contractProbeChainNoBatch) AverageBlockTimeDuration() (time.Duration, error) { + return c.inner.AverageBlockTimeDuration() +} + +// setupContractProbeWorker indexes the two EthereumType test blocks: contract 4a ends up described +// in the index, 0d/cd absent from it - the state that sends the token loop to the chain. +func setupContractProbeWorker(t *testing.T, chain bchain.BlockChain) (*Worker, *db.RocksDB, *eth.EthereumParser) { + t.Helper() + parser := eth.NewEthereumParser(1, true) + database, err := db.NewRocksDB(t.TempDir(), 100000, -1, parser, nil, false) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + is, err := database.LoadInternalState(&common.Config{CoinName: "eth-unittest"}) + require.NoError(t, err) + database.SetInternalState(is) + require.NoError(t, database.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock1(parser))) + require.NoError(t, database.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock2(parser))) + // the ContractInfo cache is package-level, so rows an earlier test stored would still be visible + db.ResetContractInfoCache() + w, err := NewWorker(database, chain, nil, nil, nil, is, nil) + require.NoError(t, err) + return w, database, parser +} + +func newContractProbeChain(t *testing.T) *contractProbeChain { + t.Helper() + fake, err := dbtestdata.NewFakeBlockChainEthereumType(eth.NewEthereumParser(1, true)) + require.NoError(t, err) + return &contractProbeChain{BlockChain: fake, tokens: map[string]*bchain.ContractInfo{}} +} + +func addrDesc(t *testing.T, parser *eth.EthereumParser, address string) bchain.AddressDescriptor { + t.Helper() + cd, err := parser.GetAddrDescFromAddress("0x" + address) + require.NoError(t, err) + return cd +} + +// A "no token here" verdict never reaches the index, so without the cache every request re-probes. +func TestGetContractDescriptorInfo_NotATokenIsProbedOnce(t *testing.T) { + chain := newContractProbeChain(t) + w, _, parser := setupContractProbeWorker(t, chain) + cd := addrDesc(t, parser, dbtestdata.EthAddrContract0d) + + first, validFirst, err := w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + second, validSecond, err := w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + + single, _ := chain.counts() + require.Equal(t, 1, single, "the second request must not reach the chain") + require.False(t, validFirst) + require.False(t, validSecond) + // a cached verdict has to be indistinguishable from a freshly probed one + require.Equal(t, first, second) + require.Equal(t, parser.AmountDecimals(), second.Decimals) + require.Equal(t, bchain.UnknownTokenStandard, second.Standard) +} + +// Caching a failed read would blank a live token out of the response for the whole TTL. +func TestGetContractDescriptorInfo_FailedReadIsNotCached(t *testing.T) { + chain := newContractProbeChain(t) + chain.err = errors.New("dial tcp: connection refused") + w, _, parser := setupContractProbeWorker(t, chain) + cd := addrDesc(t, parser, dbtestdata.EthAddrContract0d) + + for i := 0; i < 2; i++ { + _, valid, err := w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + require.False(t, valid) + } + + single, _ := chain.counts() + require.Equal(t, 2, single, "a failed read must leave the contract probeable") +} + +// The negative is a safety valve, not a permanent verdict: a contract that gains token code later +// has to be picked up once the TTL passes. +func TestGetContractDescriptorInfo_NegativeCacheExpires(t *testing.T) { + chain := newContractProbeChain(t) + w, database, parser := setupContractProbeWorker(t, chain) + cd := addrDesc(t, parser, dbtestdata.EthAddrContract0d) + + _, _, err := w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + _, bestHeight, _, _ := database.GetInternalState().GetSyncState() + // 15 minutes of 12s blocks, plus one to pass the expiry + database.GetInternalState().UpdateBestHeight(bestHeight + blocksForDuration(defaultNegativeProbeTTL, 12*time.Second) + 1) + _, _, err = w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + + single, _ := chain.counts() + require.Equal(t, 2, single) +} + +// A resolved token heals through the index instead of the cache, and stays resolved. +func TestGetContractDescriptorInfo_ResolvedTokenIsStored(t *testing.T) { + chain := newContractProbeChain(t) + w, database, parser := setupContractProbeWorker(t, chain) + cd := addrDesc(t, parser, dbtestdata.EthAddrContract0d) + chain.tokens[string(cd)] = &bchain.ContractInfo{ + Contract: eth.EIP55Address(cd), + Name: "Mint Token", + Symbol: "MTT", + Decimals: 8, + } + + got, valid, err := w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + require.True(t, valid) + require.Equal(t, "MTT", got.Symbol) + require.Equal(t, bchain.ERC20TokenStandard, got.Standard) + + stored, err := database.GetContractInfo(cd, bchain.UnknownTokenStandard) + require.NoError(t, err) + require.NotNil(t, stored, "a resolved token must reach the index") + + _, _, err = w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + single, _ := chain.counts() + require.Equal(t, 1, single) +} + +// The pre-pass covers exactly what the loop would have probed: contracts the index does not describe. +func TestPrefetchContractInfos_BatchesUnknownContracts(t *testing.T) { + chain := newContractProbeChain(t) + w, database, parser := setupContractProbeWorker(t, chain) + ca, err := database.GetAddrDescContracts(addrDesc(t, parser, dbtestdata.EthAddr7b), false) + require.NoError(t, err) + + probes := w.prefetchContractInfos(ca.Contracts) + + single, batch := chain.counts() + require.Equal(t, 1, batch, "one batched call for the whole address") + require.Equal(t, 0, single) + require.Equal(t, []int{2}, chain.batchSizes, "contract 4a is already in the index") + require.Contains(t, probes, string(addrDesc(t, parser, dbtestdata.EthAddrContract0d))) + require.Contains(t, probes, string(addrDesc(t, parser, dbtestdata.EthAddrContractCd))) + require.NotContains(t, probes, string(addrDesc(t, parser, dbtestdata.EthAddrContract4a))) +} + +// Contracts a recent probe settled stay out of the batch, and one contract left is not worth one. +func TestPrefetchContractInfos_SkipsCachedNegatives(t *testing.T) { + chain := newContractProbeChain(t) + w, database, parser := setupContractProbeWorker(t, chain) + ca, err := database.GetAddrDescContracts(addrDesc(t, parser, dbtestdata.EthAddr7b), false) + require.NoError(t, err) + bestHeight, reorgGen := w.contractProbeCacheState() + w.contractProbeCache.add(string(addrDesc(t, parser, dbtestdata.EthAddrContract0d)), bestHeight, 100, reorgGen) + + require.Nil(t, w.prefetchContractInfos(ca.Contracts)) + + _, batch := chain.counts() + require.Equal(t, 0, batch) +} + +// A conclusive not-a-token from the batch is as cacheable as one from a single read, which is what +// keeps the batch from being re-run on every request. +func TestGetProbedContractDescriptorInfo_CachesBatchedNegative(t *testing.T) { + chain := newContractProbeChain(t) + w, database, parser := setupContractProbeWorker(t, chain) + ca, err := database.GetAddrDescContracts(addrDesc(t, parser, dbtestdata.EthAddr7b), false) + require.NoError(t, err) + cd := addrDesc(t, parser, dbtestdata.EthAddrContract0d) + + probes := w.prefetchContractInfos(ca.Contracts) + _, valid, err := w.getProbedContractDescriptorInfo(cd, bchain.ERC20TokenStandard, probes) + require.NoError(t, err) + require.False(t, valid) + // the next request has neither a batch nor a probe to run + _, _, err = w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + + single, batch := chain.counts() + require.Equal(t, 1, batch) + require.Equal(t, 0, single) +} + +// A batched read that failed is not a verdict either, so the contract stays probeable. +func TestGetProbedContractDescriptorInfo_BatchedErrorIsNotCached(t *testing.T) { + chain := newContractProbeChain(t) + chain.err = errors.New("context deadline exceeded") + w, database, parser := setupContractProbeWorker(t, chain) + ca, err := database.GetAddrDescContracts(addrDesc(t, parser, dbtestdata.EthAddr7b), false) + require.NoError(t, err) + cd := addrDesc(t, parser, dbtestdata.EthAddrContract0d) + + probes := w.prefetchContractInfos(ca.Contracts) + _, _, err = w.getProbedContractDescriptorInfo(cd, bchain.ERC20TokenStandard, probes) + require.NoError(t, err) + _, _, err = w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + + single, _ := chain.counts() + require.Equal(t, 1, single, "the failed batch must leave the contract probeable") +} + +// The point of the pre-pass: an address of undescribed contracts costs one batched call, not up to +// three serialized eth_calls per contract. +func TestGetEthereumTypeAddressBalances_ResolvesMetadataInOneBatch(t *testing.T) { + chain := newContractProbeChain(t) + w, _, parser := setupContractProbeWorker(t, chain) + cd := addrDesc(t, parser, dbtestdata.EthAddr7b) + cdCd := addrDesc(t, parser, dbtestdata.EthAddrContractCd) + chain.tokens[string(cdCd)] = &bchain.ContractInfo{ + Contract: eth.EIP55Address(cdCd), + Name: "Nifty", + Symbol: "NFT", + } + + _, data, err := w.getEthereumTypeAddressBalances(cd, AccountDetailsTokens, &AddressFilter{Vout: AddressFilterVoutOff}, "") + require.NoError(t, err) + + single, batch := chain.counts() + require.Equal(t, 1, batch) + require.Equal(t, 0, single) + names := map[string]string{} + for _, token := range data.tokens { + names[token.Contract] = token.Name + } + require.Equal(t, "Nifty", names[eth.EIP55Address(cdCd)]) + require.Equal(t, "Contract 74", names[eth.EIP55Address(addrDesc(t, parser, dbtestdata.EthAddrContract4a))], "the indexed contract keeps its stored metadata") +} + +// Without a batched resolve the per-contract path still works, and still caches its verdicts. +func TestPrefetchContractInfos_NoBatchResolver(t *testing.T) { + inner := newContractProbeChain(t) + w, database, parser := setupContractProbeWorker(t, &contractProbeChainNoBatch{BlockChain: inner.BlockChain, inner: inner}) + ca, err := database.GetAddrDescContracts(addrDesc(t, parser, dbtestdata.EthAddr7b), false) + require.NoError(t, err) + + require.Nil(t, w.prefetchContractInfos(ca.Contracts)) + + cd := addrDesc(t, parser, dbtestdata.EthAddrContract0d) + for i := 0; i < 2; i++ { + _, _, err := w.getContractDescriptorInfo(cd, bchain.ERC20TokenStandard) + require.NoError(t, err) + } + single, batch := inner.counts() + require.Equal(t, 0, batch) + require.Equal(t, 1, single) +} diff --git a/api/erc4626.go b/api/erc4626.go index 8b2cdf84cf..d4837a1c9a 100644 --- a/api/erc4626.go +++ b/api/erc4626.go @@ -5,12 +5,12 @@ import ( "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" + "github.com/trezor/blockbook/common" ) const ( @@ -18,14 +18,6 @@ const ( 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 ( @@ -57,47 +49,6 @@ 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 @@ -115,7 +66,7 @@ func (w *Worker) enrichErc4626Tokens(tokens Tokens, bestHeight uint32, bestHash 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() + negativeTTLBlocks := w.negativeProbeTTLBlocks(defaultNegativeProbeTTL) setVault := func(addr, asset string) error { return w.db.SetContractInfoErc4626Vault(addr, asset, bestHeight, bestHash, reorgGen) } @@ -127,7 +78,7 @@ func enrichErc4626TokensWithDeps( getContractInfo erc4626ContractInfoFetcher, mc erc4626MulticallCaller, setVault erc4626VaultPersister, - negativeCache *erc4626NegativeCache, + negativeCache *negativeProbeCache, bestHeight uint32, negativeTTLBlocks uint32, reorgGen uint64, @@ -141,6 +92,7 @@ func enrichErc4626TokensWithDeps( type candidate struct { token *Token contract string + cacheKey string } var candidates []candidate @@ -153,15 +105,16 @@ func enrichErc4626TokensWithDeps( if err != nil || ci == nil { continue } + cacheKey := erc4626ContractKey(token.Contract) if ci.IsErc4626 { - negativeCache.remove(token.Contract) + negativeCache.remove(cacheKey) token.Protocols = append(token.Protocols, contractInfoProtocolErc4626) continue } - if negativeCache.contains(token.Contract, bestHeight, reorgGen) { + if negativeCache.contains(cacheKey, bestHeight, reorgGen) { continue } - candidates = append(candidates, candidate{token: token, contract: token.Contract}) + candidates = append(candidates, candidate{token: token, contract: token.Contract, cacheKey: cacheKey}) } if len(candidates) == 0 || mc == nil { @@ -199,11 +152,11 @@ func enrichErc4626TokensWithDeps( } } if assetContract == "" || !totalAssetsResult.Success { - negativeCache.add(c.contract, bestHeight, negativeTTLBlocks, reorgGen) + negativeCache.add(c.cacheKey, bestHeight, negativeTTLBlocks, reorgGen) continue } if _, derr := erc4626DecodeUint(totalAssetsResult.Data); derr != nil { - negativeCache.add(c.contract, bestHeight, negativeTTLBlocks, reorgGen) + negativeCache.add(c.cacheKey, bestHeight, negativeTTLBlocks, reorgGen) continue } // Persistence is best-effort; on error or silent refusal (reorg @@ -212,7 +165,7 @@ func enrichErc4626TokensWithDeps( if err := setVault(c.contract, assetContract); err != nil { glog.Warningf("SetContractInfoErc4626Vault contract %v asset %v: %v", c.contract, assetContract, err) } - negativeCache.remove(c.contract) + negativeCache.remove(c.cacheKey) c.token.Protocols = append(c.token.Protocols, contractInfoProtocolErc4626) } } @@ -390,7 +343,8 @@ func buildErc4626TokenCold( } if len(errs) > 0 { - result.Error = strings.Join(errs, "; ") + // the joined errors reach the client; an rpc dial error carries the full backend url + result.Error = common.RedactURLs(strings.Join(errs, "; ")) } return result, transientErr } @@ -504,7 +458,8 @@ func buildErc4626TokenWarm( } if len(errs) > 0 { - result.Error = strings.Join(errs, "; ") + // the joined errors reach the client; an rpc dial error carries the full backend url + result.Error = common.RedactURLs(strings.Join(errs, "; ")) } return result, transientErr } diff --git a/api/erc4626_live_cache.go b/api/erc4626_live_cache.go index 061837c190..e00874f517 100644 --- a/api/erc4626_live_cache.go +++ b/api/erc4626_live_cache.go @@ -1,10 +1,8 @@ package api import ( - "container/list" "strconv" "strings" - "sync" "golang.org/x/sync/singleflight" ) @@ -15,85 +13,7 @@ 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) -} +var erc4626NegativeProbeCache = newNegativeProbeCache(erc4626NegativeProbeCacheCapacity) // erc4626Cache memoises Erc4626Token (including nil for non-vaults) per // (contract, height, gen); singleflight dedupes concurrent builds. @@ -149,61 +69,3 @@ func erc4626CacheLookupOrBuild(cache *erc4626Cache, key string, build func() (*E 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 index 6da751dd95..452d2c5b2a 100644 --- a/api/erc4626_live_cache_test.go +++ b/api/erc4626_live_cache_test.go @@ -288,76 +288,3 @@ func TestErc4626CacheLookupOrBuild_NilCacheFallsThrough(t *testing.T) { 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 index 8ed680e062..d667853104 100644 --- a/api/erc4626_test.go +++ b/api/erc4626_test.go @@ -632,8 +632,8 @@ func TestEnrichErc4626Tokens_RecentNegativeSkipsReprobe(t *testing.T) { store := fakeContractInfoStore{ strings.ToLower(fakeFungible): {Contract: fakeFungible, Standard: erc4626Standard}, } - negativeCache := newErc4626NegativeCache(4) - negativeCache.add(fakeFungible, 100, 2, 0) + negativeCache := newNegativeProbeCache(4) + negativeCache.add(erc4626ContractKey(fakeFungible), 100, 2, 0) mc := &fakeMulticaller{ handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { @@ -662,7 +662,7 @@ func TestEnrichErc4626Tokens_NegativeCacheExpiresAndReprobes(t *testing.T) { store := fakeContractInfoStore{ strings.ToLower(fakeFungible): {Contract: fakeFungible, Standard: erc4626Standard}, } - negativeCache := newErc4626NegativeCache(4) + negativeCache := newNegativeProbeCache(4) mc := &fakeMulticaller{ handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { @@ -926,3 +926,31 @@ func TestErc4626MathAndEncodingBoundaries(t *testing.T) { t.Fatalf("unexpected 10^0 result: %v, %v", unit, err) } } + +func TestBuildErc4626Token_MulticallErrorRedactsBackendURL(t *testing.T) { + // A dial failure on the multicall renders the backend url; the joined error string is served + // to the client and must keep only the host. + const vault = "0x00000000000000000000000000000000000000a1" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return nil, errors.New(`Post "https://rpc.example.io/v2/SECRET_PROVIDER_KEY": dial tcp: connection refused`) + }, + }, + } + persister := func(string, string) error { return nil } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + return &bchain.ContractInfo{Contract: "0x00000000000000000000000000000000000000b2", Name: "USDC", Symbol: "USDC", Decimals: 6}, true, nil + } + ci := &bchain.ContractInfo{Contract: vault, Decimals: 18, Erc4626AssetContract: "0x00000000000000000000000000000000000000b2"} + got, _ := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got == nil { + t.Fatal("expected partial result on multicall error") + } + if strings.Contains(got.Error, "SECRET_PROVIDER_KEY") { + t.Fatalf("backend url must be redacted, got %q", got.Error) + } + if !strings.Contains(got.Error, `multicall: Post "https://rpc.example.io": dial tcp`) { + t.Fatalf("expected redacted multicall error, got %q", got.Error) + } +} diff --git a/api/ethereumtype.go b/api/ethereumtype.go index 1f98f2eea0..03a2177206 100644 --- a/api/ethereumtype.go +++ b/api/ethereumtype.go @@ -1,92 +1,165 @@ package api import ( - "sync" + stdErrors "errors" + "math" + "os" + "time" "github.com/golang/glog" - "github.com/linxGnu/grocksdb" + "github.com/juju/errors" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" ) -// refetch internal data -var refetchingInternalData = false -var refetchInternalDataMux sync.Mutex +// maxHealBackoffShift caps the backoff at 2^6 passes - about 2.7 days at the hourly heal +// cadence - so a block that keeps failing is slowed down but never abandoned. +const maxHealBackoffShift = 6 -func (w *Worker) IsRefetchingInternalData() bool { - refetchInternalDataMux.Lock() - defer refetchInternalDataMux.Unlock() - return refetchingInternalData +// healInterBlockDelay spaces consecutive heal attempts. After a long backend outage the +// queue can hold tens of thousands of blocks, and pass 0 retries all of them after a +// restart - back-to-back traces would compete with live sync for the backend. +const healInterBlockDelay = time.Second + +// HealInternalData walks the internal data error queue once and heals the blocks whose +// backoff schedule is due in this pass. Blocks are handled one at a time, spaced by +// healInterBlockDelay, to bound the trace load healing adds to the backend. Called +// serially from a single loop, so it needs no single-flight guard; stop cuts the pass +// short on shutdown. +func (w *Worker) HealInternalData(pass uint64, stop <-chan os.Signal) { + internalErrors, err := w.db.GetBlockInternalDataErrorsEthereumType() + if err != nil { + glog.Error("HealInternalData: GetBlockInternalDataErrorsEthereumType ", err) + return + } + var healed, failed, fetchErrors, reconnectErrors, postponed int + attempted := false + for i := range internalErrors { + // give up between blocks so shutdown waits for at most one of them + if common.IsInShutdown() { + glog.Info("HealInternalData: interrupted by shutdown") + break + } + ie := &internalErrors[i] + if !healDue(ie.Retries, pass) { + postponed++ + continue + } + // rate-limit only actual attempts - postponed entries cost the backend nothing + if attempted && !sleepBetweenHeals(stop) { + glog.Info("HealInternalData: interrupted by shutdown") + break + } + attempted = true + glog.Infof("HealInternalData: healing internal data of %d %s, previous failures %d", ie.Height, ie.Hash, ie.Retries) + block, err := w.fetchBlockForHealing(ie.Hash, ie.Height) + // the fetch above cannot be aborted - go-ethereum's rpc client Close is a no-op + // over HTTP - so check again before touching the database, to keep shutdown + // bounded by one write instead of an RPC timeout + if common.IsInShutdown() { + glog.Info("HealInternalData: interrupted by shutdown") + break + } + if err != nil || block == nil { + glog.Errorf("HealInternalData: %d %s, fetch error %v", ie.Height, ie.Hash, err) + // a backend that does not have the block has answered definitively about this + // block, so charge a failure. Every other error is backend-wide and usually + // transient, and must not back off every queued block during an outage. + if stdErrors.Is(errors.Cause(err), bchain.ErrBlockNotFound) { + failed++ + w.chargeInternalDataFailure(ie, err.Error()) + } else { + // uncharged, but counted - a pass lost to an outage must not look like + // a pass that attempted nothing + fetchErrors++ + } + continue + } + blockSpecificData, _ := block.CoinSpecificData.(*bchain.EthereumBlockSpecificData) + if blockSpecificData != nil && blockSpecificData.InternalDataError != "" { + glog.Errorf("HealInternalData: %d %s, internal data error %v", ie.Height, ie.Hash, blockSpecificData.InternalDataError) + failed++ + w.chargeInternalDataFailure(ie, blockSpecificData.InternalDataError) + continue + } + if err = w.db.ReconnectInternalDataToBlockEthereumType(block); err != nil { + // a reconnect failure is local - rocksdb or the disk - or a reorg that moved + // the block out of the index, rather than a property of the block, so it does + // not spend the backoff budget; a block stuck here stays visible through the + // reconnect_error series of the heals metric + glog.Errorf("HealInternalData: ReconnectInternalDataToBlockEthereumType %d %s, error %v", ie.Height, ie.Hash, err) + reconnectErrors++ + continue + } + healed++ + glog.Infof("HealInternalData: healed internal data of %d %s", ie.Height, ie.Hash) + } + if healed > 0 || failed > 0 || fetchErrors > 0 || reconnectErrors > 0 { + glog.Infof("HealInternalData: pass %d done, %d healed, %d failed, %d fetch errors, %d reconnect errors, %d backed off", pass, healed, failed, fetchErrors, reconnectErrors, postponed) + } + if w.metrics != nil { + // zero Adds still create the series, so dashboards see the counters from the first pass + w.metrics.InternalDataHeals.With(common.Labels{"result": "healed"}).Add(float64(healed)) + w.metrics.InternalDataHeals.With(common.Labels{"result": "failed"}).Add(float64(failed)) + w.metrics.InternalDataHeals.With(common.Labels{"result": "fetch_error"}).Add(float64(fetchErrors)) + w.metrics.InternalDataHeals.With(common.Labels{"result": "reconnect_error"}).Add(float64(reconnectErrors)) + // healed blocks left the queue with their reconnect; new sync failures show up next pass + w.metrics.InternalDataErrorQueue.Set(float64(len(internalErrors) - healed)) + } } -func (w *Worker) RefetchInternalData() error { - refetchInternalDataMux.Lock() - defer refetchInternalDataMux.Unlock() - if !refetchingInternalData { - refetchingInternalData = true - go w.RefetchInternalDataRoutine() +// sleepBetweenHeals rate-limits a healing pass to one block per healInterBlockDelay, +// reporting false when the wait is cut short by shutdown. +func sleepBetweenHeals(stop <-chan os.Signal) bool { + select { + case <-stop: + return false + case <-time.After(healInterBlockDelay): + return true } - return nil } -const maxNumberOfRetires = 25 +// healDue spaces out repeated failures: a block that has failed n times is attempted on +// passes divisible by 2^n (n capped by maxHealBackoffShift), so the wait after a failure +// is up to 2^n passes, not exactly 2^n - the schedule is aligned to the pass counter, not +// to the block. Pass 0 matches every schedule, so a restart gives the whole queue one +// immediate attempt. +func healDue(failures uint8, pass uint64) bool { + shift := uint(failures) + if shift > maxHealBackoffShift { + shift = maxHealBackoffShift + } + return pass%(1<= 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) - } - } +// fetchBlockForHealing fetches a block, retrying once when its internal data are still +// missing - the second attempt succeeds many times more often, apparently because the +// first one warms the backend's trace cache. The returned block may still carry an +// InternalDataError; the caller decides what that means. +func (w *Worker) fetchBlockForHealing(hash string, height uint32) (*bchain.Block, error) { + block, err := w.chain.GetBlock(hash, height) + if err == nil && block != nil { + if bsd, _ := block.CoinSpecificData.(*bchain.EthereumBlockSpecificData); bsd == nil || bsd.InternalDataError == "" { + return block, nil } } - refetchInternalDataMux.Lock() - refetchingInternalData = false - refetchInternalDataMux.Unlock() + if stdErrors.Is(errors.Cause(err), bchain.ErrBlockNotFound) { + // the backend does not have the block at all, warming its cache cannot help + return nil, err + } + glog.Errorf("HealInternalData: %d %s, first fetch failed (error %v), retrying", height, hash, err) + return w.chain.GetBlock(hash, height) } diff --git a/api/ethereumtype_basic_test.go b/api/ethereumtype_basic_test.go new file mode 100644 index 0000000000..48e6200770 --- /dev/null +++ b/api/ethereumtype_basic_test.go @@ -0,0 +1,69 @@ +//go:build unittest + +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// details=basic reads only the header of the address-contracts record; every counter it exposes +// must match the full decode, while the contract array stays untouched. +func TestGetEthereumTypeAddressBalances_BasicMatchesFullDecode(t *testing.T) { + w, _, parser := setupContractProbeWorker(t, newContractProbeChain(t)) + for _, address := range []string{dbtestdata.EthAddr4b, dbtestdata.EthAddr7b} { + ad := addrDesc(t, parser, address) + baBasic, dBasic, err := w.getEthereumTypeAddressBalances(ad, AccountDetailsBasic, &AddressFilter{Vout: AddressFilterVoutOff}, "") + require.NoError(t, err) + baFull, dFull, err := w.getEthereumTypeAddressBalances(ad, AccountDetailsTokens, &AddressFilter{Vout: AddressFilterVoutOff}, "") + require.NoError(t, err) + + require.Equal(t, baFull.Txs, baBasic.Txs, address) + require.Equal(t, baFull.BalanceSat, baBasic.BalanceSat, address) + require.Equal(t, dFull.nonContractTxs, dBasic.nonContractTxs, address) + require.Equal(t, dFull.internalTxs, dBasic.internalTxs, address) + require.Equal(t, dFull.totalResults, dBasic.totalResults, address) + require.Equal(t, dFull.nonce, dBasic.nonce, address) + require.Empty(t, dBasic.tokens, address) + require.NotEmpty(t, dFull.tokens, address) + } +} + +// A fresh address has no record on either path; basic must keep the "never sent" branch intact. +func TestGetEthereumTypeAddressBalances_BasicFreshAddress(t *testing.T) { + w, _, parser := setupContractProbeWorker(t, newContractProbeChain(t)) + fresh := addrDesc(t, parser, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + ba, d, err := w.getEthereumTypeAddressBalances(fresh, AccountDetailsBasic, &AddressFilter{Vout: AddressFilterVoutOff}, "") + require.NoError(t, err) + require.NotNil(t, ba) + require.Equal(t, uint32(0), ba.Txs) + require.Equal(t, -1, d.totalResults) + require.Equal(t, 0, d.nonContractTxs) + require.Equal(t, 0, d.internalTxs) + require.Equal(t, "0", d.nonce) +} + +// Per-contract paging and a contract filter stay on the header path at basic: totalResults is +// consumed only above basic, so the undecoded contract array is never observable. +func TestGetEthereumTypeAddressBalances_BasicIgnoresContractFilters(t *testing.T) { + w, database, parser := setupContractProbeWorker(t, newContractProbeChain(t)) + ad := addrDesc(t, parser, dbtestdata.EthAddr4b) + full, err := database.GetAddrDescContracts(ad, false) + require.NoError(t, err) + require.NotEmpty(t, full.Contracts) + + ba, d, err := w.getEthereumTypeAddressBalances(ad, AccountDetailsBasic, &AddressFilter{Vout: db.ContractIndexOffset}, "") + require.NoError(t, err) + require.Equal(t, uint32(full.TotalTxs), ba.Txs) + require.Equal(t, -1, d.totalResults) + require.Empty(t, d.tokens) + + ba, d, err = w.getEthereumTypeAddressBalances(ad, AccountDetailsBasic, &AddressFilter{Vout: AddressFilterVoutOff, Contract: dbtestdata.EthAddrContract4a}, "") + require.NoError(t, err) + require.Equal(t, uint32(full.TotalTxs), ba.Txs) + require.Equal(t, int(full.TotalTxs), d.totalResults) + require.Empty(t, d.tokens) +} diff --git a/api/ethereumtype_test.go b/api/ethereumtype_test.go new file mode 100644 index 0000000000..1c594e001a --- /dev/null +++ b/api/ethereumtype_test.go @@ -0,0 +1,267 @@ +//go:build unittest + +package api + +import ( + "os" + "testing" + "time" + + "github.com/juju/errors" + "github.com/linxGnu/grocksdb" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// The delay between heals must not hold up shutdown - a pass over a large queue sleeps +// most of its wall-clock time, so the sleep has to give up as soon as stop closes. +func Test_sleepBetweenHeals_Shutdown(t *testing.T) { + stop := make(chan os.Signal) + close(stop) + start := time.Now() + if sleepBetweenHeals(stop) { + t.Error("sleepBetweenHeals() = true on a closed stop channel, want false") + } + if elapsed := time.Since(start); elapsed >= healInterBlockDelay { + t.Errorf("sleepBetweenHeals waited %v on a closed stop channel", elapsed) + } +} + +func Test_healDue(t *testing.T) { + tests := []struct { + name string + failures uint8 + pass uint64 + want bool + }{ + // a restart resets the pass counter, so the whole queue gets one immediate attempt + {name: "first pass attempts a fresh block", failures: 0, pass: 0, want: true}, + {name: "first pass attempts a long failing block", failures: 200, pass: 0, want: true}, + {name: "no failures is attempted every pass", failures: 0, pass: 7, want: true}, + {name: "one failure skips the next pass", failures: 1, pass: 1, want: false}, + {name: "one failure is attempted every second pass", failures: 1, pass: 2, want: true}, + {name: "three failures wait eight passes", failures: 3, pass: 4, want: false}, + {name: "three failures are attempted every eighth pass", failures: 3, pass: 8, want: true}, + // the shift saturates, so the slowest schedule stays at 64 passes + {name: "the cap is attempted every 64th pass", failures: maxHealBackoffShift, pass: 64, want: true}, + {name: "above the cap keeps the capped schedule", failures: 200, pass: 64, want: true}, + {name: "above the cap is not attempted in between", failures: 200, pass: 65, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := healDue(tt.failures, tt.pass); got != tt.want { + t.Errorf("healDue(%d, %d) = %v, want %v", tt.failures, tt.pass, got, tt.want) + } + }) + } +} + +// healTestChain stubs only GetBlock - HealInternalData touches nothing else on the +// chain, and the embedded nil interface panics loudly if that ever changes. +type healTestChain struct { + bchain.BlockChain + calls int + getBlock func() (*bchain.Block, error) +} + +func (c *healTestChain) GetBlock(hash string, height uint32) (*bchain.Block, error) { + c.calls++ + return c.getBlock() +} + +// failedInternalDataBlock2 returns test block 2 the way a failed internal data fetch +// leaves it: no per-tx internal data, only the error marker and the log-derived aliases. +func failedInternalDataBlock2(parser bchain.BlockChainParser) *bchain.Block { + block := dbtestdata.GetTestEthereumTypeBlock2(parser) + for i := range block.Txs { + csd, _ := block.Txs[i].CoinSpecificData.(bchain.EthereumSpecificData) + csd.InternalData = nil + block.Txs[i].CoinSpecificData = csd + } + block.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + InternalDataError: dbtestdata.Block2SpecificData.InternalDataError, + AddressAliasRecords: dbtestdata.Block2SpecificData.AddressAliasRecords, + } + return block +} + +// healedInternalDataBlock2 returns test block 2 as a successful refetch delivers it. +func healedInternalDataBlock2(parser bchain.BlockChainParser) *bchain.Block { + block := dbtestdata.GetTestEthereumTypeBlock2(parser) + block.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + AddressAliasRecords: dbtestdata.Block2SpecificData.AddressAliasRecords, + Contracts: dbtestdata.Block2SpecificData.Contracts, + } + return block +} + +func queuedInternalDataError(t *testing.T, d *db.RocksDB, height uint32) *db.BlockInternalDataError { + t.Helper() + queue, err := d.GetBlockInternalDataErrorsEthereumType() + if err != nil { + t.Fatal(err) + } + for i := range queue { + if queue[i].Height == height { + return &queue[i] + } + } + return nil +} + +// HealInternalData decides what a failure means - only a definitive answer about the +// block itself spends the backoff budget - and that rule lives nowhere else, so it is +// pinned here with a stubbed chain against a real database. +func TestWorker_HealInternalData(t *testing.T) { + parser := eth.NewEthereumParser(1, true) + d, err := db.NewRocksDB(t.TempDir(), 100000, -1, parser, nil, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + is, err := d.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + if err != nil { + t.Fatal(err) + } + d.SetInternalState(is) + + // GetMetrics registers in the global prometheus registry, so it can run only once + // per test binary; this is currently the only api test that needs it + metrics, err := common.GetMetrics("coin-unittest") + if err != nil { + t.Fatal(err) + } + chain := &healTestChain{} + w := &Worker{db: d, chain: chain, metrics: metrics} + stop := make(chan os.Signal) + metricValue := func(m prometheus.Metric) float64 { + var pb dto.Metric + if err := m.Write(&pb); err != nil { + t.Fatal(err) + } + if pb.Counter != nil { + return pb.Counter.GetValue() + } + return pb.Gauge.GetValue() + } + heals := func(result string) float64 { + return metricValue(metrics.InternalDataHeals.With(common.Labels{"result": result})) + } + assertOutcomes := func(stage string, healed, failed, fetchErr, queueGauge float64) { + t.Helper() + if got := heals("healed"); got != healed { + t.Errorf("%s: healed = %v, want %v", stage, got, healed) + } + if got := heals("failed"); got != failed { + t.Errorf("%s: failed = %v, want %v", stage, got, failed) + } + if got := heals("fetch_error"); got != fetchErr { + t.Errorf("%s: fetch_error = %v, want %v", stage, got, fetchErr) + } + if got := heals("reconnect_error"); got != 0 { + t.Errorf("%s: reconnect_error = %v, want 0", stage, got) + } + if got := metricValue(metrics.InternalDataErrorQueue); got != queueGauge { + t.Errorf("%s: queue gauge = %v, want %v", stage, got, queueGauge) + } + } + + // sync: block 2 fails its internal data fetch and is queued + if err := d.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock1(parser)); err != nil { + t.Fatal(err) + } + if err := d.ConnectBlock(failedInternalDataBlock2(parser)); err != nil { + t.Fatal(err) + } + block2 := dbtestdata.GetTestEthereumTypeBlock2(parser) + if entry := queuedInternalDataError(t, d, block2.Height); entry == nil { + t.Fatal("block 2 was not queued by the failed sync") + } + + // a transport error is backend-wide: retried once within the pass, left uncharged, + // so an outage cannot back off the queue + chain.getBlock = func() (*bchain.Block, error) { + return nil, errors.New("Post \"http://backend\": context deadline exceeded") + } + w.HealInternalData(0, stop) + if chain.calls != 2 { + t.Errorf("transport error: %d fetches, want 2 (the warm-cache retry)", chain.calls) + } + entry := queuedInternalDataError(t, d, block2.Height) + if entry == nil || entry.Retries != 0 { + t.Errorf("transport error: entry = %+v, want retries 0 - outages must not be charged", entry) + } + assertOutcomes("transport error", 0, 0, 1, 1) + + // a backend without the block has answered definitively: charged, and the + // warm-cache retry is skipped because it cannot help. The juju annotation mimics + // what chain.GetBlock produces, exercising the Cause unwrapping. + chain.calls = 0 + chain.getBlock = func() (*bchain.Block, error) { + return nil, errors.Annotatef(bchain.ErrBlockNotFound, "hash %v, height %v", block2.Hash, block2.Height) + } + w.HealInternalData(0, stop) + if chain.calls != 1 { + t.Errorf("block not found: %d fetches, want 1 (no retry for a missing block)", chain.calls) + } + entry = queuedInternalDataError(t, d, block2.Height) + if entry == nil || entry.Retries != 1 { + t.Errorf("block not found: entry = %+v, want retries 1", entry) + } + assertOutcomes("block not found", 0, 1, 1, 1) + + // the trace failed again on the refetched block: about the block, charged + chain.calls = 0 + chain.getBlock = func() (*bchain.Block, error) { + return &bchain.Block{ + BlockHeader: bchain.BlockHeader{Hash: block2.Hash, Height: block2.Height}, + CoinSpecificData: &bchain.EthereumBlockSpecificData{InternalDataError: "trace failed again"}, + }, nil + } + w.HealInternalData(2, stop) // retries 1 is due on even passes + if chain.calls != 2 { + t.Errorf("internal data error: %d fetches, want 2 (the warm-cache retry)", chain.calls) + } + entry = queuedInternalDataError(t, d, block2.Height) + if entry == nil || entry.Retries != 2 || entry.ErrorMessage != "trace failed again" { + t.Errorf("internal data error: entry = %+v, want retries 2 and the new message", entry) + } + assertOutcomes("internal data error", 0, 2, 1, 1) + + // the refetch succeeds: the block is reconnected and leaves the queue + chain.calls = 0 + chain.getBlock = func() (*bchain.Block, error) { + return healedInternalDataBlock2(parser), nil + } + w.HealInternalData(4, stop) // retries 2 is due on passes divisible by 4 + if entry = queuedInternalDataError(t, d, block2.Height); entry != nil { + t.Errorf("heal: entry still queued: %+v", entry) + } + if internalData, err := d.GetEthereumInternalData(block2.Txs[0].Txid); err != nil || internalData == nil { + t.Errorf("heal: internal data of %s not stored (%v)", block2.Txs[0].Txid, err) + } + assertOutcomes("heal", 1, 2, 1, 0) + + // a backed off block is not attempted before its pass is due + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.StoreBlockInternalDataErrorEthereumType(wb, &bchain.Block{ + BlockHeader: bchain.BlockHeader{Hash: block2.Hash, Height: block2.Height}, + }, "trace failed", 3); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } + chain.calls = 0 + w.HealInternalData(1, stop) // retries 3 is due only on passes divisible by 8 + if chain.calls != 0 { + t.Errorf("backed off: %d fetches, want 0", chain.calls) + } + assertOutcomes("backed off", 1, 2, 1, 1) +} diff --git a/api/fiat_balance_history.go b/api/fiat_balance_history.go index 0c6f3698ba..8c5afdfb7d 100644 --- a/api/fiat_balance_history.go +++ b/api/fiat_balance_history.go @@ -1,7 +1,6 @@ package api import ( - "strings" "time" "github.com/golang/glog" @@ -15,14 +14,6 @@ func normalizeBalanceHistoryPathLabel(pathLabel string) string { 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 { @@ -175,20 +166,21 @@ func (w *Worker) applyFallbackTickersToBalanceHistories(histories BalanceHistori w.observeBalanceHistoryFiatDuration(pathLabel, "fallback", stats.status(), fallbackStarted) } -func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, currencies []string, pathLabel string) error { +// setFiatRateToBalanceHistories fills FiatRates of the passed histories. +// currenciesLowercase must already be normalized by normalizeFiatCurrencies; +// both entry points (GetBalanceHistory, GetXpubBalanceHistory) do so. +func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, currenciesLowercase []string, pathLabel string) { if len(histories) == 0 || w.fiatRates == nil || !w.fiatRates.Enabled { - return nil + return } 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 + return } 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 index e165b4345c..fc9b68f155 100644 --- a/api/fiat_balance_history_test.go +++ b/api/fiat_balance_history_test.go @@ -37,10 +37,7 @@ func TestSetFiatRateToBalanceHistories_BatchesTickerLookup(t *testing.T) { return &tickers, nil } - err := w.setFiatRateToBalanceHistories(histories, []string{"USD", "eur", "cad"}, "address") - if err != nil { - t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) - } + w.setFiatRateToBalanceHistories(histories, []string{"usd", "eur", "cad"}, "address") if calls != 1 { t.Fatalf("expected 1 ticker lookup call, got %d", calls) } @@ -77,10 +74,7 @@ func TestSetFiatRateToBalanceHistories_AllRatesWhenCurrenciesNotSpecified(t *tes return &tickers, nil } - err := w.setFiatRateToBalanceHistories(histories, nil, "address") - if err != nil { - t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) - } + w.setFiatRateToBalanceHistories(histories, nil, "address") if !reflect.DeepEqual(histories[0].FiatRates, map[string]float32{"usd": 11, "eur": 22}) { t.Fatalf("unexpected rates for histories[0]: %v", histories[0].FiatRates) } @@ -127,10 +121,7 @@ func TestSetFiatRateToBalanceHistories_BatchFailureFallsBackToPerPoint(t *testin } } - err := w.setFiatRateToBalanceHistories(histories, []string{"usd"}, "address") - if err != nil { - t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) - } + w.setFiatRateToBalanceHistories(histories, []string{"usd"}, "address") if calls != 4 { t.Fatalf("expected 4 ticker lookup calls (1 batch + 3 point), got %d", calls) } @@ -170,10 +161,7 @@ func TestSetFiatRateToBalanceHistories_SkipsLookupForEmptyHistory(t *testing.T) return &tickers, nil } - err := w.setFiatRateToBalanceHistories(BalanceHistories{}, []string{"usd"}, "address") - if err != nil { - t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) - } + w.setFiatRateToBalanceHistories(BalanceHistories{}, []string{"usd"}, "address") if calls != 0 { t.Fatalf("expected 0 ticker lookup calls, got %d", calls) } @@ -196,10 +184,7 @@ func TestSetFiatRateToBalanceHistories_SkipsLookupWhenFiatRatesDisabled(t *testi return &tickers, nil } - err := w.setFiatRateToBalanceHistories(histories, []string{"usd"}, "address") - if err != nil { - t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) - } + w.setFiatRateToBalanceHistories(histories, []string{"usd"}, "address") if calls != 0 { t.Fatalf("expected 0 ticker lookup calls when fiat rates are disabled, got %d", calls) } diff --git a/api/fiat_rates_api.go b/api/fiat_rates_api.go index ba44dd3f74..c561cf9775 100644 --- a/api/fiat_rates_api.go +++ b/api/fiat_rates_api.go @@ -13,15 +13,40 @@ import ( // 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) +// MaxFiatRatesCurrencies limits explicit currency selectors. An empty list still +// means "all available currencies", so legitimate callers do not need a large +// explicit list. +const MaxFiatRatesCurrencies = 128 + +// MaxFiatRatesCurrencyCodeLength bounds each selector before it is copied into +// per-result response maps. +const MaxFiatRatesCurrencyCodeLength = 16 + +// normalizeFiatCurrencies trims, lowercases, deduplicates, and bounds explicit +// fiat currency selectors before any per-result response maps are allocated. +// The count limit applies to the raw input length so the work done here is +// bounded by it as well. +func normalizeFiatCurrencies(currencies []string) ([]string, error) { + if len(currencies) > MaxFiatRatesCurrencies { + return nil, NewAPIError(fmt.Sprintf("too many currencies, max %d", MaxFiatRatesCurrencies), true) + } + seen := make(map[string]struct{}, len(currencies)) + normalized := make([]string, 0, len(currencies)) + for _, currency := range currencies { + currency = strings.ToLower(strings.TrimSpace(currency)) + if currency == "" { + continue } + if len(currency) > MaxFiatRatesCurrencyCodeLength { + return nil, NewAPIError(fmt.Sprintf("currency code too long, max %d", MaxFiatRatesCurrencyCodeLength), true) + } + if _, found := seen[currency]; found { + continue + } + seen[currency] = struct{}{} + normalized = append(normalized, currency) } - return ret + return normalized, nil } func copyTickerRates(rates map[string]float32) map[string]float32 { @@ -33,6 +58,7 @@ func copyTickerRates(rates map[string]float32) map[string]float32 { } // getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result. +// currencies must already be normalized by normalizeFiatCurrencies. func (w *Worker) getFiatRatesResult(currencies []string, ticker *common.CurrencyRatesTicker, token string) (*FiatTicker, error) { if token != "" { capacity := len(currencies) @@ -51,7 +77,6 @@ func (w *Worker) getFiatRatesResult(currencies []string, ticker *common.Currency } } else { for _, currency := range currencies { - currency = strings.ToLower(currency) rate := ticker.TokenRateInCurrency(token, currency) if rate <= 0 { rate = -1 @@ -74,7 +99,6 @@ func (w *Worker) getFiatRatesResult(currencies []string, ticker *common.Currency // 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 { @@ -90,12 +114,15 @@ func (w *Worker) getFiatRatesResult(currencies []string, ticker *common.Currency // GetCurrentFiatRates returns last available fiat rates. func (w *Worker) GetCurrentFiatRates(currencies []string, token string) (*FiatTicker, error) { vsCurrency := "" - currencies = removeEmpty(currencies) + var err error + currencies, err = normalizeFiatCurrencies(currencies) + if err != nil { + return nil, err + } 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 @@ -135,7 +162,10 @@ func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []stri return nil, NewAPIError(fmt.Sprintf("too many timestamps, max %d", MaxFiatRatesTimestamps), true) } vsCurrency := "" - currencies = removeEmpty(currencies) + currencies, err := normalizeFiatCurrencies(currencies) + if err != nil { + return nil, err + } if len(currencies) == 1 { vsCurrency = currencies[0] } diff --git a/api/fiat_rates_api_test.go b/api/fiat_rates_api_test.go index 90df2b00e5..5f2edf38f7 100644 --- a/api/fiat_rates_api_test.go +++ b/api/fiat_rates_api_test.go @@ -28,11 +28,65 @@ func requireAPIError(t *testing.T, err error, wantPublic bool) *APIError { return apiErr } -func TestRemoveEmpty(t *testing.T) { - got := removeEmpty([]string{"usd", "", "eur", "", ""}) +func TestNormalizeFiatCurrencies(t *testing.T) { + got, err := normalizeFiatCurrencies([]string{" usd ", "", "USD", "eur", " Eur "}) + if err != nil { + t.Fatalf("normalizeFiatCurrencies returned error: %v", err) + } want := []string{"usd", "eur"} if !reflect.DeepEqual(got, want) { - t.Fatalf("unexpected filtered currencies: got %v, want %v", got, want) + t.Fatalf("unexpected normalized currencies: got %v, want %v", got, want) + } +} + +func TestNormalizeFiatCurrencies_LimitsCurrencyCount(t *testing.T) { + currencies := make([]string, MaxFiatRatesCurrencies+1) + for i := range currencies { + currencies[i] = fmt.Sprintf("c%03d", i) + } + _, err := normalizeFiatCurrencies(currencies) + apiErr := requireAPIError(t, err, true) + want := fmt.Sprintf("too many currencies, max %d", MaxFiatRatesCurrencies) + if apiErr.Text != want { + t.Fatalf("unexpected error text: got %q, want %q", apiErr.Text, want) + } +} + +func TestNormalizeFiatCurrencies_DeduplicatesWithinLimit(t *testing.T) { + currencies := make([]string, MaxFiatRatesCurrencies) + for i := range currencies { + currencies[i] = "USD" + } + got, err := normalizeFiatCurrencies(currencies) + if err != nil { + t.Fatalf("normalizeFiatCurrencies returned error: %v", err) + } + if !reflect.DeepEqual(got, []string{"usd"}) { + t.Fatalf("unexpected normalized currencies: got %v", got) + } +} + +func TestNormalizeFiatCurrencies_LimitAppliesToRawInputLength(t *testing.T) { + // The count cap bounds the raw input length, so even a list that would + // deduplicate to a single entry is rejected when it is too long. + currencies := make([]string, MaxFiatRatesCurrencies+1) + for i := range currencies { + currencies[i] = "USD" + } + _, err := normalizeFiatCurrencies(currencies) + apiErr := requireAPIError(t, err, true) + want := fmt.Sprintf("too many currencies, max %d", MaxFiatRatesCurrencies) + if apiErr.Text != want { + t.Fatalf("unexpected error text: got %q, want %q", apiErr.Text, want) + } +} + +func TestNormalizeFiatCurrencies_RejectsLongCode(t *testing.T) { + _, err := normalizeFiatCurrencies([]string{strings.Repeat("a", MaxFiatRatesCurrencyCodeLength+1)}) + apiErr := requireAPIError(t, err, true) + want := fmt.Sprintf("currency code too long, max %d", MaxFiatRatesCurrencyCodeLength) + if apiErr.Text != want { + t.Fatalf("unexpected error text: got %q, want %q", apiErr.Text, want) } } @@ -57,7 +111,7 @@ func TestGetFiatRatesResult_NonTokenSelectedCurrencies(t *testing.T) { }, } - got, err := w.getFiatRatesResult([]string{"USD", "gbp"}, ticker, "") + got, err := w.getFiatRatesResult([]string{"usd", "gbp"}, ticker, "") if err != nil { t.Fatalf("getFiatRatesResult returned error: %v", err) } @@ -111,7 +165,7 @@ func TestGetFiatRatesResult_TokenRates(t *testing.T) { }, } - got, err := w.getFiatRatesResult([]string{"USD", "EUR", "JPY"}, ticker, "0xToken") + got, err := w.getFiatRatesResult([]string{"usd", "eur", "jpy"}, ticker, "0xToken") if err != nil { t.Fatalf("getFiatRatesResult returned error: %v", err) } @@ -153,8 +207,8 @@ func TestGetCurrentFiatRates_UsesGetterAndCurrencyFilter(t *testing.T) { 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 gotVsCurrency != "usd" { + t.Fatalf("unexpected vsCurrency: got %q, want %q", gotVsCurrency, "usd") } if gotToken != "" { t.Fatalf("unexpected token: got %q, want empty", gotToken) @@ -203,6 +257,60 @@ func TestGetFiatRatesForTimestamps_LimitsInput(t *testing.T) { } } +func TestGetFiatRatesForTimestamps_LimitsCurrenciesBeforeLookup(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + calls := 0 + getTickersForTimestamps = func(_ *fiat.FiatRates, _ []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + calls++ + tickers := []*common.CurrencyRatesTicker{} + return &tickers, nil + } + + currencies := make([]string, MaxFiatRatesCurrencies+1) + for i := range currencies { + currencies[i] = fmt.Sprintf("c%03d", i) + } + _, err := w.GetFiatRatesForTimestamps([]int64{1}, currencies, "") + apiErr := requireAPIError(t, err, true) + want := fmt.Sprintf("too many currencies, max %d", MaxFiatRatesCurrencies) + if apiErr.Text != want { + t.Fatalf("unexpected error text: got %q, want %q", apiErr.Text, want) + } + if calls != 0 { + t.Fatalf("expected ticker lookup not to be called, got %d calls", calls) + } +} + +func TestGetFiatRatesForTimestamps_RejectsLongCurrencyBeforeLookup(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + 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.GetFiatRatesForTimestamps([]int64{1}, []string{strings.Repeat("a", MaxFiatRatesCurrencyCodeLength+1)}, "") + apiErr := requireAPIError(t, err, true) + want := fmt.Sprintf("currency code too long, max %d", MaxFiatRatesCurrencyCodeLength) + if apiErr.Text != want { + t.Fatalf("unexpected error text: got %q, want %q", apiErr.Text, want) + } + if calls != 0 { + t.Fatalf("expected ticker lookup not to be called, got %d calls", calls) + } +} + func TestGetFiatRatesForTimestamps_LenMismatchReturnsNonPublicError(t *testing.T) { w := &Worker{fiatRates: &fiat.FiatRates{}} originalGetter := getTickersForTimestamps diff --git a/api/ignored_contract_test.go b/api/ignored_contract_test.go new file mode 100644 index 0000000000..dcd7d8f372 --- /dev/null +++ b/api/ignored_contract_test.go @@ -0,0 +1,55 @@ +//go:build unittest + +package api + +import ( + "bytes" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// balanceRecordingChain records which contracts the token loop asks balances for. +type balanceRecordingChain struct { + bchain.BlockChain + balances []bchain.AddressDescriptor +} + +func (c *balanceRecordingChain) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc bchain.AddressDescriptor) (*big.Int, error) { + c.balances = append(c.balances, contractDesc) + return c.BlockChain.EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc) +} + +func (c *balanceRecordingChain) EthereumTypeGetErc20ContractBalances(addrDesc bchain.AddressDescriptor, contractDescs []bchain.AddressDescriptor) ([]*big.Int, error) { + c.balances = append(c.balances, contractDescs...) + return c.BlockChain.EthereumTypeGetErc20ContractBalances(addrDesc, contractDescs) +} + +// Holdings of an ignored contract indexed before its transfers were dropped are neither listed +// nor looked up, so the fix does not need a reindex to reach the address token list. +func TestGetAddress_SkipsIgnoredContractHoldings(t *testing.T) { + ethParser := eth.NewEthereumParser(1, true) + fake, err := dbtestdata.NewFakeBlockChainEthereumType(ethParser) + require.NoError(t, err) + chain := &balanceRecordingChain{BlockChain: fake} + w, _, _ := setupContractProbeWorker(t, chain) + // ignored only after the fixtures were indexed, as on an index built before the upgrade + require.NoError(t, ethParser.IgnoreContract("0x"+dbtestdata.EthAddrContract4a)) + ignored := addrDesc(t, ethParser, dbtestdata.EthAddrContract4a) + + addr, err := w.GetAddress(dbtestdata.EthAddr7bEIP55, 1, 25, AccountDetailsTokenBalances, &AddressFilter{Vout: AddressFilterVoutOff, OnlyConfirmed: true}, "") + require.NoError(t, err) + + require.NotEmpty(t, addr.Tokens, "the other contracts of the address are still listed") + for _, token := range addr.Tokens { + require.NotEqual(t, eth.EIP55Address(ignored), token.Contract) + } + for _, contract := range chain.balances { + require.False(t, bytes.Equal(ignored, contract), "balance looked up for the ignored contract") + } + require.NotEmpty(t, chain.balances, "balances of the other contracts are still looked up") +} diff --git a/api/paging_test.go b/api/paging_test.go new file mode 100644 index 0000000000..8c6411a546 --- /dev/null +++ b/api/paging_test.go @@ -0,0 +1,131 @@ +//go:build unittest + +package api + +import ( + "strconv" + "testing" +) + +func TestComputeAccountPagingWindows(t *testing.T) { + tests := []struct { + name string + mempool, confirmed int + page, itemsOnPage int + wantMempoolFrom, wantMempoolTo int + wantConfirmedFrom, wantConfirmedTo int + wantPage, wantTotalPages int + }{ + { + name: "first page is shortened by the mempool entries it carries", + mempool: 1, confirmed: 50, page: 0, itemsOnPage: 25, + wantMempoolFrom: 0, wantMempoolTo: 1, + wantConfirmedFrom: 0, wantConfirmedTo: 24, + wantPage: 1, wantTotalPages: 3, + }, + { + name: "second page resumes where the first one stopped", + mempool: 1, confirmed: 50, page: 1, itemsOnPage: 25, + wantMempoolFrom: 1, wantMempoolTo: 1, + wantConfirmedFrom: 24, wantConfirmedTo: 49, + wantPage: 2, wantTotalPages: 3, + }, + { + name: "no mempool entries keeps plain confirmed paging", + mempool: 0, confirmed: 50, page: 1, itemsOnPage: 25, + wantMempoolFrom: 0, wantMempoolTo: 0, + wantConfirmedFrom: 25, wantConfirmedTo: 50, + wantPage: 2, wantTotalPages: 2, + }, + { + name: "more mempool entries than fit on a page spill to the next page", + mempool: 4, confirmed: 2, page: 1, itemsOnPage: 3, + wantMempoolFrom: 3, wantMempoolTo: 4, + wantConfirmedFrom: 0, wantConfirmedTo: 2, + wantPage: 2, wantTotalPages: 2, + }, + { + name: "page beyond the last one is clamped to the last page", + mempool: 1, confirmed: 3, page: 9, itemsOnPage: 2, + wantMempoolFrom: 1, wantMempoolTo: 1, + wantConfirmedFrom: 1, wantConfirmedTo: 3, + wantPage: 2, wantTotalPages: 2, + }, + { + name: "empty account has a single empty page", + mempool: 0, confirmed: 0, page: 0, itemsOnPage: 25, + wantMempoolFrom: 0, wantMempoolTo: 0, + wantConfirmedFrom: 0, wantConfirmedTo: 0, + wantPage: 1, wantTotalPages: 1, + }, + { + name: "mempool only account", + mempool: 2, confirmed: 0, page: 0, itemsOnPage: 25, + wantMempoolFrom: 0, wantMempoolTo: 2, + wantConfirmedFrom: 0, wantConfirmedTo: 0, + wantPage: 1, wantTotalPages: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pg, mFrom, mTo, cFrom, cTo, _ := computeAccountPaging(tt.mempool, tt.confirmed, tt.page, tt.itemsOnPage) + if mFrom != tt.wantMempoolFrom || mTo != tt.wantMempoolTo { + t.Errorf("mempool window = [%d:%d], want [%d:%d]", mFrom, mTo, tt.wantMempoolFrom, tt.wantMempoolTo) + } + if cFrom != tt.wantConfirmedFrom || cTo != tt.wantConfirmedTo { + t.Errorf("confirmed window = [%d:%d], want [%d:%d]", cFrom, cTo, tt.wantConfirmedFrom, tt.wantConfirmedTo) + } + if pg.Page != tt.wantPage || pg.TotalPages != tt.wantTotalPages || pg.ItemsOnPage != tt.itemsOnPage { + t.Errorf("paging = %+v, want page %d of %d, %d items on page", pg, tt.wantPage, tt.wantTotalPages, tt.itemsOnPage) + } + if got := (mTo - mFrom) + (cTo - cFrom); got > tt.itemsOnPage { + t.Errorf("page holds %d items, more than the requested %d", got, tt.itemsOnPage) + } + }) + } +} + +// TestComputeAccountPagingWalksWholeSequence is the invariant issue #1099 is about: a client +// walking every page must see each tx exactly once, in order, and never get an oversized page. +func TestComputeAccountPagingWalksWholeSequence(t *testing.T) { + for mempool := 0; mempool <= 5; mempool++ { + for confirmed := 0; confirmed <= 12; confirmed++ { + for itemsOnPage := 1; itemsOnPage <= 5; itemsOnPage++ { + var walked []string + pg, _, _, _, _, _ := computeAccountPaging(mempool, confirmed, 0, itemsOnPage) + for page := 0; page < pg.TotalPages; page++ { + _, mFrom, mTo, cFrom, cTo, normalizedPage := computeAccountPaging(mempool, confirmed, page, itemsOnPage) + if normalizedPage != page { + t.Fatalf("mempool=%d confirmed=%d itemsOnPage=%d: page %d was clamped to %d within TotalPages=%d", + mempool, confirmed, itemsOnPage, page, normalizedPage, pg.TotalPages) + } + if got := (mTo - mFrom) + (cTo - cFrom); got > itemsOnPage { + t.Fatalf("mempool=%d confirmed=%d itemsOnPage=%d page=%d: page holds %d items", + mempool, confirmed, itemsOnPage, page, got) + } + for i := mFrom; i < mTo; i++ { + walked = append(walked, "m"+strconv.Itoa(i)) + } + for i := cFrom; i < cTo; i++ { + walked = append(walked, "c"+strconv.Itoa(i)) + } + } + var want []string + for i := 0; i < mempool; i++ { + want = append(want, "m"+strconv.Itoa(i)) + } + for i := 0; i < confirmed; i++ { + want = append(want, "c"+strconv.Itoa(i)) + } + if len(walked) != len(want) { + t.Fatalf("mempool=%d confirmed=%d itemsOnPage=%d: walked %v, want %v", mempool, confirmed, itemsOnPage, walked, want) + } + for i := range want { + if walked[i] != want[i] { + t.Fatalf("mempool=%d confirmed=%d itemsOnPage=%d: walked %v, want %v", mempool, confirmed, itemsOnPage, walked, want) + } + } + } + } + } +} diff --git a/api/probe_cache.go b/api/probe_cache.go new file mode 100644 index 0000000000..68059d5a6f --- /dev/null +++ b/api/probe_cache.go @@ -0,0 +1,176 @@ +package api + +import ( + "container/list" + "sync" + "time" + + "github.com/golang/glog" +) + +// 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) +} + +// defaultNegativeProbeTTL bounds how long a "the chain says no" verdict is trusted, so a later +// change (CREATE2 redeploy, proxy upgrade) is still picked up. Wall-clock so it is chain-agnostic. +const defaultNegativeProbeTTL = 15 * time.Minute + +type negativeProbeCacheEntry struct { + expireAt uint64 + reorgGen uint64 +} + +// negativeProbeCache is an in-memory LRU of "the chain says no" verdicts, keyed by whatever the +// caller uses to identify a probe; entries expire on ttlBlocks and on a reorgGen mismatch. +type negativeProbeCache struct { + lru *lruCache[negativeProbeCacheEntry] +} + +func newNegativeProbeCache(capacity int) *negativeProbeCache { + lru := newLRUCache[negativeProbeCacheEntry](capacity) + if lru == nil { + return nil + } + return &negativeProbeCache{lru: lru} +} + +func (c *negativeProbeCache) contains(key string, currentHeight uint32, reorgGen uint64) bool { + if c == nil || currentHeight == 0 { + return false + } + 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 *negativeProbeCache) add(key string, currentHeight, ttlBlocks uint32, reorgGen uint64) { + if c == nil || currentHeight == 0 || ttlBlocks == 0 { + return + } + c.lru.add(key, negativeProbeCacheEntry{ + expireAt: uint64(currentHeight) + uint64(ttlBlocks), + reorgGen: reorgGen, + }) +} + +func (c *negativeProbeCache) remove(key string) { + if c == nil { + return + } + c.lru.remove(key) +} + +// blockTimeProvider exposes the chain's configured average block time, so wall-clock settings can +// be converted to a per-coin block count at request time. Implemented by EVM coins. +type blockTimeProvider interface { + AverageBlockTimeDuration() (time.Duration, error) +} + +// blocksForDuration converts a wall-clock duration to a block count, rounding up so "at least d" is +// honored. Returns 0 on non-positive input - callers read 0 as "unavailable, skip the behavior". +func blocksForDuration(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) +} + +// negativeProbeTTLBlocks resolves a TTL to a per-coin block count. Returns 0 when the chain exposes +// no block time (e.g. non-EVM); the caller then just forfeits the negative cache for this request. +func (w *Worker) negativeProbeTTLBlocks(ttl time.Duration) uint32 { + provider, ok := w.chain.(blockTimeProvider) + if !ok { + return 0 + } + bt, err := provider.AverageBlockTimeDuration() + if err != nil { + glog.Warningf("averageBlockTime unavailable, negative probe cache disabled: %v", err) + return 0 + } + return blocksForDuration(ttl, bt) +} diff --git a/api/probe_cache_test.go b/api/probe_cache_test.go new file mode 100644 index 0000000000..ce8a2d7138 --- /dev/null +++ b/api/probe_cache_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "testing" + "time" +) + +func TestNegativeProbeCache_HitExpireAndRemove(t *testing.T) { + cache := newNegativeProbeCache(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 TestNegativeProbeCache_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 := newNegativeProbeCache(2) + cache.add("0xabc", 10, 0, 0) + if cache.contains("0xabc", 10, 0) { + t.Fatal("entry inserted with ttlBlocks==0 should be absent") + } +} + +func TestNegativeProbeCache_ReorgGenInvalidates(t *testing.T) { + cache := newNegativeProbeCache(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 TestBlocksForDuration(t *testing.T) { + // 15 minutes / 12s blocks → 75 blocks (Ethereum). + if got := blocksForDuration(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 := blocksForDuration(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 := blocksForDuration(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 := blocksForDuration(0, time.Second); got != 0 { + t.Fatalf("zero duration must yield 0, got %d", got) + } + if got := blocksForDuration(time.Minute, 0); got != 0 { + t.Fatalf("zero blockTime must yield 0, got %d", got) + } +} diff --git a/api/redact.go b/api/redact.go new file mode 100644 index 0000000000..997291ebb3 --- /dev/null +++ b/api/redact.go @@ -0,0 +1,17 @@ +package api + +import "github.com/trezor/blockbook/common" + +// RedactAPIError returns a copy of e with the URLs in its text reduced to scheme and host (see +// common.RedactURLs), leaving the original error untouched so a caller that logs it still gets the +// full URL. Returns e itself when nothing changes. +func RedactAPIError(e *APIError) *APIError { + if e == nil { + return nil + } + text := common.RedactURLs(e.Text) + if text == e.Text { + return e + } + return &APIError{Text: text, Public: e.Public} +} diff --git a/api/redact_test.go b/api/redact_test.go new file mode 100644 index 0000000000..0c6d27ef1e --- /dev/null +++ b/api/redact_test.go @@ -0,0 +1,34 @@ +//go:build unittest + +package api + +import "testing" + +func TestRedactAPIError(t *testing.T) { + t.Run("redacts and keeps the original intact", func(t *testing.T) { + original := &APIError{Text: `Post "https://relay.example.com/v1/SECRET": EOF`, Public: true} + redacted := RedactAPIError(original) + if redacted.Text != `Post "https://relay.example.com": EOF` { + t.Errorf("redacted text = %q", redacted.Text) + } + if !redacted.Public { + t.Error("Public flag not carried over") + } + if original.Text != `Post "https://relay.example.com/v1/SECRET": EOF` { + t.Errorf("original error was mutated: %q", original.Text) + } + }) + + t.Run("returns the same error when there is nothing to redact", func(t *testing.T) { + original := &APIError{Text: "nonce too low", Public: true} + if RedactAPIError(original) != original { + t.Error("expected the original error to be returned unchanged") + } + }) + + t.Run("nil", func(t *testing.T) { + if RedactAPIError(nil) != nil { + t.Error("expected nil") + } + }) +} diff --git a/api/spending_txid_test.go b/api/spending_txid_test.go new file mode 100644 index 0000000000..ae13f5401a --- /dev/null +++ b/api/spending_txid_test.go @@ -0,0 +1,132 @@ +//go:build unittest + +package api + +import ( + "testing" + + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// setupSpendingWorker builds a worker over the two-block Fakecoin fixture. The +// fixture already contains the shape this test needs: TxidB1T1 pays the same +// SatB1T1A2 to Addr2 twice (vout 1 and vout 2) and only vout 1 is ever spent. +func setupSpendingWorker(t *testing.T, extendedIndex bool) *Worker { + t.Helper() + 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 { + t.Fatal(err) + } + d, err := db.NewRocksDB(t.TempDir(), 100000, -1, parser, nil, extendedIndex) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + is, err := d.LoadInternalState(&common.Config{CoinName: "Fakecoin", CoinShortcut: "FAKE"}) + if err != nil { + t.Fatal(err) + } + d.SetInternalState(is) + + block1 := dbtestdata.GetTestBitcoinTypeBlock1(parser) + block2 := dbtestdata.GetTestBitcoinTypeBlock2(parser) + // BlockTimes is indexed by height, so pad up to the first fixture block + is.BlockTimes = make([]uint32, block1.Height) + if err := d.ConnectBlock(block1); err != nil { + t.Fatal(err) + } + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + is.FinishedSync(block2.Height) + + m := newRefreshTestMetrics(t) + // caching disabled, as in server/public_test.go: the fixture txs carry no Hex, + // which BitcoinLikeParser.PackTx needs, so a cached tx cannot be read back + txCache, err := db.NewTxCache(d, chain, m, is, false) + if err != nil { + t.Fatal(err) + } + // nil mempool: GetSpendingTxid never touches it for confirmed transactions + w, err := NewWorker(d, chain, nil, txCache, m, is, nil) + if err != nil { + t.Fatal(err) + } + return w +} + +// TestGetSpendingTxid covers trezor/blockbook#1029: an output must not inherit +// the spender of a sibling output that happens to share its address and value. +func TestGetSpendingTxid(t *testing.T) { + for _, extendedIndex := range []bool{false, true} { + name := "legacyIndex" + if extendedIndex { + name = "extendedIndex" + } + t.Run(name, func(t *testing.T) { + w := setupSpendingWorker(t, extendedIndex) + + tests := []struct { + name string + txid string + n int + want string + }{ + { + // spent by TxidB2T1 input 1 + name: "spent output resolves to its spender", + txid: dbtestdata.TxidB1T1, + n: 1, + want: dbtestdata.TxidB2T1, + }, + { + // same address and same value as vout 1, but unspent: the + // address+value scan used to match TxidB2T1 here + name: "unspent sibling of a spent output is not spent", + txid: dbtestdata.TxidB1T1, + n: 2, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := w.GetSpendingTxid(tt.txid, tt.n) + if err != nil { + t.Fatalf("GetSpendingTxid(%s, %d) error %v", tt.txid, tt.n, err) + } + if got != tt.want { + t.Errorf("GetSpendingTxid(%s, %d) = %q, want %q", tt.txid, tt.n, got, tt.want) + } + }) + } + + // bypass the unspent guard in GetSpendingTxid to pin the outpoint match + // itself: TxidB2T1's input matches vout 2 on txid, address and value, and + // only its outpoint (vout 1) tells the two sibling outputs apart + t.Run("scan rejects a candidate spending a sibling outpoint", func(t *testing.T) { + tx, err := w.getTransaction(dbtestdata.TxidB1T1, false, false, nil) + if err != nil { + t.Fatal(err) + } + if err := w.setSpendingTxToVout(&tx.Vout[2], tx.Txid, uint32(tx.Blockheight)); err != nil { + t.Fatal(err) + } + if tx.Vout[2].SpentTxID != "" { + t.Errorf("setSpendingTxToVout matched %q for unspent vout 2, want no match", tx.Vout[2].SpentTxID) + } + }) + }) + } +} diff --git a/api/syscoin_merge_test.go b/api/syscoin_merge_test.go new file mode 100644 index 0000000000..1d61ebcca7 --- /dev/null +++ b/api/syscoin_merge_test.go @@ -0,0 +1,89 @@ +//go:build unittest + +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func TestGetAddressSummaryPagesMempoolWithConfirmed(t *testing.T) { + const pageSize = 2 + w, pending, confirmed := buildPagingWorker(t, 3, 3) + want := append(append([]string{}, pending...), confirmed...) + var walked []string + for page := 1; ; page++ { + addr, err := w.GetAddress(dbtestdata.Addr1, page, pageSize, AccountDetailsTxHistorySummary, + &AddressFilter{Vout: AddressFilterVoutOff}, "") + require.NoError(t, err) + require.Equal(t, 3, addr.TotalPages) + require.LessOrEqual(t, len(addr.Transactions), pageSize) + for _, tx := range addr.Transactions { + require.Empty(t, tx.Vin) + require.Empty(t, tx.Vout) + require.Equal(t, "received", tx.Direction) + require.NotNil(t, tx.AddressValueOutSat) + walked = append(walked, tx.Txid) + } + if page >= addr.TotalPages { + break + } + } + require.Equal(t, want, walked, "summary pages must return every pending and confirmed tx once") +} + +func TestGetXpubSummaryPagesMempoolWithConfirmed(t *testing.T) { + w, pending := buildXpubPagingWorker(t) + confirmed, err := w.GetXpubAddress(dbtestdata.Xpub, 1, 100, AccountDetailsTxidHistory, + &AddressFilter{Vout: AddressFilterVoutOff, OnlyConfirmed: true}, 5, "") + require.NoError(t, err) + want := append([]string{pending}, confirmed.Txids...) + var walked []string + for page := 1; ; page++ { + addr, err := w.GetXpubAddress(dbtestdata.Xpub, page, 1, AccountDetailsTxHistorySummary, + &AddressFilter{Vout: AddressFilterVoutOff}, 5, "") + require.NoError(t, err) + require.Len(t, addr.Transactions, 1) + tx := addr.Transactions[0] + require.Empty(t, tx.Vin) + require.Empty(t, tx.Vout) + require.NotNil(t, tx.AddressValueInSat) + require.NotNil(t, tx.AddressValueOutSat) + walked = append(walked, tx.Txid) + if page >= addr.TotalPages { + break + } + } + require.Equal(t, want, walked) +} + +func TestGetAddressAssetMaskStopsAtScanBudget(t *testing.T) { + for _, tc := range []struct { + name string + vout int + }{{"asset only", AddressFilterVoutOff}, {"asset and output", AddressFilterVoutOutputs}} { + t.Run(tc.name, func(t *testing.T) { + w, _, _ := buildPagingWorker(t, 10, 0) + setFilteredScanBudget(t, 5) + _, err := w.GetAddress(dbtestdata.Addr1, 1, 25, AccountDetailsTxidHistory, + &AddressFilter{Vout: tc.vout, AssetsMask: bchain.AssetAllocationSendMask}, "") + require.Equal(t, errFilteredScanLimit, err, "asset-mask rejections must count toward the same sparse-scan budget") + }) + } +} + +func TestGetAddressAssetMaskWithinScanBudget(t *testing.T) { + w, _, confirmed := buildPagingWorker(t, 3, 0) + setFilteredScanBudget(t, 3) + addr, err := w.GetAddress(dbtestdata.Addr1, 1, 25, AccountDetailsTxidHistory, + &AddressFilter{Vout: AddressFilterVoutOff, AssetsMask: bchain.AssetAllocationSendMask}, "") + require.NoError(t, err) + require.Empty(t, addr.Txids) + addr, err = w.GetAddress(dbtestdata.Addr1, 1, 25, AccountDetailsTxidHistory, + &AddressFilter{Vout: AddressFilterVoutOff, AssetsMask: bchain.BaseCoinMask}, "") + require.NoError(t, err) + require.Equal(t, confirmed, addr.Txids) +} diff --git a/api/system_info_redact_test.go b/api/system_info_redact_test.go new file mode 100644 index 0000000000..4c48cf5d5f --- /dev/null +++ b/api/system_info_redact_test.go @@ -0,0 +1,43 @@ +//go:build unittest + +package api + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/fiat" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// chainInfoFailingChain answers GetChainInfo with the *url.Error shape the http client produces +// on a dial failure, which carries the whole backend url. +type chainInfoFailingChain struct { + bchain.BlockChain +} + +func (c *chainInfoFailingChain) GetChainInfo() (*bchain.ChainInfo, error) { + return nil, errors.New(`Post "https://mainnet.example.io/v3/SECRET_PROVIDER_KEY": dial tcp: connection refused`) +} + +func TestGetSystemInfo_BackendErrorRedactsBackendURL(t *testing.T) { + fake, err := dbtestdata.NewFakeBlockChainEthereumType(eth.NewEthereumParser(1, true)) + require.NoError(t, err) + w, _, _ := setupContractProbeWorker(t, &chainInfoFailingChain{BlockChain: fake}) + w.fiatRates = &fiat.FiatRates{} + + si, err := w.GetSystemInfo(false) + require.NoError(t, err) + require.False(t, si.Blockbook.InSync) + + body, err := json.Marshal(si) + require.NoError(t, err) + require.NotContains(t, string(body), "SECRET_PROVIDER_KEY") + require.Equal(t, `GetChainInfo: Post "https://mainnet.example.io": dial tcp: connection refused`, si.Backend.BackendError) + require.True(t, strings.Contains(string(body), `mainnet.example.io`), "host is kept for the operator") +} diff --git a/api/types.go b/api/types.go index 8ea89f3903..84eef1f711 100644 --- a/api/types.go +++ b/api/types.go @@ -273,8 +273,8 @@ type ContractInfoResult struct { // 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'"` + Type bchain.TokenStandardName `json:"type" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPTAllocated'" ts_doc:"@deprecated: Use standard instead."` + Standard bchain.TokenStandardName `json:"standard" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPTAllocated'"` 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."` @@ -329,8 +329,8 @@ func (a Tokens) Less(i, j int) bool { // 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'"` + Type bchain.TokenStandardName `json:"type" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPT'" ts_doc:"@deprecated: Use standard instead."` + Standard bchain.TokenStandardName `json:"standard" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPT'"` 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."` @@ -360,7 +360,8 @@ type EthereumSpecific struct { 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."` + GasPrice *Amount `json:"gasPrice,omitempty" ts_doc:"Price (in Wei or base units) per gas unit bid by the sender."` + EffectiveGasPrice *Amount `json:"effectiveGasPrice,omitempty" ts_doc:"Actual gas price paid per gas unit; on L2 networks this differs from gasPrice and is used for the fee."` MaxPriorityFeePerGas *Amount `json:"maxPriorityFeePerGas,omitempty"` MaxFeePerGas *Amount `json:"maxFeePerGas,omitempty"` BaseFeePerGas *Amount `json:"baseFeePerGas,omitempty"` @@ -403,7 +404,7 @@ type Tx struct { 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."` + ChainExtraData *TxChainExtraData `json:"chainExtraData,omitempty" 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: account-context transaction summary fields for compact wallet history. Direction string `json:"direction,omitempty" ts_doc:"Transaction direction relative to the queried account, when returned by account summary endpoints."` @@ -412,7 +413,7 @@ type Tx struct { AccountAssetTransfers []TokenTransfer `json:"accountAssetTransfers,omitempty" ts_doc:"Syscoin SPT transfers summarized relative to the queried account."` // 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."` + Memo []byte `json:"memo,omitempty" ts_type:"string" ts_doc:"Base64-encoded 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."` } @@ -468,6 +469,12 @@ type AddressFilter struct { // 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)."` + // PrivatePendingNonces carries the account nonces of the caller's in-flight private (alternative + // send-tx) transactions when the request declared them (see server.WsPrivatePending). For + // Ethereum-like nonce lookups it routes the query to the alternative provider and each value + // counts as an occupied nonce slot in the pending-nonce walk, alongside Blockbook's own cached + // private txs (see raiseToPendingFloor); empty otherwise. + PrivatePendingNonces []uint64 `ts_doc:"Ethereum-like: account nonces of the caller's in-flight private transactions, if the request declared them."` } // StakingPool holds data about address participation in a staking pool contract @@ -517,7 +524,7 @@ type Address struct { 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."` + ChainExtraData *AccountChainExtraData `json:"chainExtraData,omitempty" ts_doc:"Additional normalized chain-specific account/address data. Use payloadType as discriminator for payload."` // helpers for explorer 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)."` diff --git a/api/types_chainextradata.go b/api/types_chainextradata.go index d202d9ff13..b00d079b2e 100644 --- a/api/types_chainextradata.go +++ b/api/types_chainextradata.go @@ -16,3 +16,11 @@ type TxChainExtraData chainExtraDataBase // AccountChainExtraData wraps normalized chain-specific account/address data with a payload discriminator. type AccountChainExtraData chainExtraDataBase + +// ChainExtraPayload implements bchain.ChainExtraPayloadWrapper with the transaction payload. +func (TxChainExtraData) ChainExtraPayload(p bchain.ChainExtraPayload) interface{} { return p.Tx } + +// ChainExtraPayload implements bchain.ChainExtraPayloadWrapper with the account payload. +func (AccountChainExtraData) ChainExtraPayload(p bchain.ChainExtraPayload) interface{} { + return p.Account +} diff --git a/api/worker.go b/api/worker.go index 82f6abcbf2..f3b73c1eb2 100644 --- a/api/worker.go +++ b/api/worker.go @@ -21,6 +21,7 @@ import ( "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" "github.com/trezor/blockbook/fiat" + "golang.org/x/sync/singleflight" ) // Worker is handle to api worker @@ -31,10 +32,18 @@ type Worker struct { chainParser bchain.BlockChainParser chainType bchain.ChainType useAddressAliases bool - mempool bchain.Mempool - is *common.InternalState - fiatRates *fiat.FiatRates - metrics *common.Metrics + // useEnsReverseAliases gates serving ENS reverse labels (EthereumType only). + useEnsReverseAliases bool + mempool bchain.Mempool + is *common.InternalState + fiatRates *fiat.FiatRates + metrics *common.Metrics + xpubConfig XpubConfig + // contractProbeCache remembers contracts the chain reported as holding no token. The + // index never records that verdict, so without the cache every request re-probes them. + contractProbeCache *negativeProbeCache + // contractProbeGroup collapses concurrent probes of the same contract into one read. + contractProbeGroup singleflight.Group } var getTickersForTimestamps = func(fr *fiat.FiatRates, timestamps []int64, vsCurrency string, token string) (*[]*common.CurrencyRatesTicker, error) { @@ -50,17 +59,31 @@ 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, fiatRates *fiat.FiatRates) (*Worker, error) { + // Resolve per-chain xpub config override, if any. + xpubCfg := DefaultXpubConfig() + if provider, ok := chain.(interface { + XpubConfigOverride() *bchain.XpubConfig + }); ok { + if override := provider.XpubConfigOverride(); override != nil { + xpubCfg = ApplyXpubConfig(override) + glog.Infof("xpub: xpubConfig override applied: maxCacheEntries=%d maxCacheBytes=%d maxCacheExpirationSeconds=%d defaultAddressesGap=%d maxAddressesGap=%d", + xpubCfg.MaxCacheEntries, xpubCfg.MaxCacheBytes, xpubCfg.MaxCacheExpirationSeconds, xpubCfg.DefaultAddressesGap, xpubCfg.MaxAddressesGap) + } + } w := &Worker{ - 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, + db: db, + txCache: txCache, + chain: chain, + chainParser: chain.GetChainParser(), + chainType: chain.GetChainParser().GetChainType(), + useAddressAliases: chain.GetChainParser().UseAddressAliases(), + useEnsReverseAliases: chain.GetChainParser().UseEnsReverseAliases(), + mempool: mempool, + is: is, + fiatRates: fiatRates, + metrics: metrics, + xpubConfig: xpubCfg, + contractProbeCache: newNegativeProbeCache(contractProbeCacheCapacity), } if w.chainType == bchain.ChainBitcoinType { w.initXpubCache() @@ -329,7 +352,9 @@ func (w *Worker) setSpendingTxToVout(vout *Vout, txid string, height uint32) err glog.Warning("Tx ", t, ": not found") } else { if len(spentTx.Vin) > int(index) { - if spentTx.Vin[index].Txid == txid { + // the outpoint must match, not just the tx: sibling outputs + // sharing this address and value would match on txid alone + if spentTx.Vin[index].Txid == txid && spentTx.Vin[index].Vout == uint32(vout.N) { vout.SpentTxID = t vout.SpentHeight = int(spentHeight) vout.SpentIndex = int(index) @@ -369,6 +394,11 @@ func (w *Worker) GetSpendingTxid(txid string, n int) (string, error) { if n >= len(tx.Vout) || n < 0 { return "", NewAPIError(fmt.Sprintf("Passed incorrect vout index %v for tx %v, len vout %v", n, tx.Txid, len(tx.Vout)), false) } + // an unspent output has no spender, and the scan below has no early exit for + // that case - it would walk the address index all the way to the chain tip + if !tx.Vout[n].Spent { + return "", nil + } err = w.setSpendingTxToVout(&tx.Vout[n], tx.Txid, uint32(tx.Blockheight)) if err != nil { return "", err @@ -456,6 +486,10 @@ func (w *Worker) getAddressAliases(addresses map[string]struct{}) AddressAliases aliases[a] = AddressAlias{Type: "Contract", Alias: ci.Name} } } + // keep the contract/token name, skip the ENS label when disabled + if !w.useEnsReverseAliases { + continue + } } n := w.db.GetAddressAlias(a) if len(n) > 0 { @@ -555,6 +589,12 @@ func (w *Worker) getConfirmationETA(tx *Tx) (int64, uint32) { return etaSeconds, etaBlocks } +// isReplaceableInput reports whether an unconfirmed input lets the tx be replaced: UTXO inputs opt in via +// the BIP125 sequence, while any EVM tx is replaceable at its nonce and Suite gates cancel/speed-up on it. +func (w *Worker) isReplaceableInput(sequence uint32) bool { + return w.chainType == bchain.ChainEthereumType || sequence < 0xffffffff-1 +} + // GetTransactionFromBchainTx reads transaction data from txid func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spendingTxs bool, specificJSON bool, addresses map[string]struct{}) (*Tx, error) { var err error @@ -585,8 +625,7 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe vin.N = i vin.Vout = bchainVin.Vout vin.Sequence = int64(bchainVin.Sequence) - // detect explicit Replace-by-Fee transactions as defined by BIP125 - if bchainTx.Confirmations == 0 && bchainVin.Sequence < 0xffffffff-1 { + if bchainTx.Confirmations == 0 && w.isReplaceableInput(bchainVin.Sequence) { rbf = true } vin.Hex = bchainVin.ScriptSig.Hex @@ -594,12 +633,13 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe if w.chainType == bchain.ChainBitcoinType { // bchainVin.Txid=="" is coinbase transaction if bchainVin.Txid != "" { - // load spending addresses from TxAddresses - tas, err := w.db.GetTxAddresses(bchainVin.Txid) + // load the spending address from TxAddresses; only the spent output + // is needed, so avoid unpacking the whole previous-tx record + output, err := w.db.GetTxAddressesOutput(bchainVin.Txid, vin.Vout) if err != nil { - return nil, errors.Annotatef(err, "GetTxAddresses %v", bchainVin.Txid) + return nil, errors.Annotatef(err, "GetTxAddressesOutput %v", bchainVin.Txid) } - if tas == nil { + if output == nil { // try to load from backend otx, _, err := w.txCache.GetTransaction(bchainVin.Txid) if err != nil { @@ -632,18 +672,14 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } } else { - if len(tas.Outputs) > int(vin.Vout) { - output := &tas.Outputs[vin.Vout] - 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) - } - aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) + vin.ValueSat = (*Amount)(&output.ValueSat) + vin.AssetInfo = w.assetInfoToAPI(output.AssetInfo) // SYSCOIN + 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) } + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } if vin.ValueSat != nil { valInSat.Add(&valInSat, (*big.Int)(vin.ValueSat)) @@ -718,19 +754,14 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe 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) - } - } + feesSat = *getEthereumFeesSat(ethTxData) if len(bchainTx.Vout) > 0 { valOutSat = bchainTx.Vout[0].ValueSat } ethSpecific = &EthereumSpecific{ GasLimit: ethTxData.GasLimit, GasPrice: (*Amount)(ethTxData.GasPrice), + EffectiveGasPrice: (*Amount)(ethTxData.EffectiveGasPrice), MaxPriorityFeePerGas: (*Amount)(ethTxData.MaxPriorityFeePerGas), MaxFeePerGas: (*Amount)(ethTxData.MaxFeePerGas), BaseFeePerGas: (*Amount)(ethTxData.BaseFeePerGas), @@ -826,8 +857,7 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, vin.N = i vin.Vout = bchainVin.Vout vin.Sequence = int64(bchainVin.Sequence) - // detect explicit Replace-by-Fee transactions as defined by BIP125 - if bchainVin.Sequence < 0xffffffff-1 { + if w.isReplaceableInput(bchainVin.Sequence) { rbf = true } vin.Hex = bchainVin.ScriptSig.Hex @@ -942,41 +972,46 @@ func (w *Worker) GetContractInfo(contract string, standardFromContext bchain.Tok } func (w *Worker) getContractDescriptorInfo(cd bchain.AddressDescriptor, standardFromContext bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { - var err error - validContract := true + return w.getProbedContractDescriptorInfo(cd, standardFromContext, nil) +} + +// getProbedContractDescriptorInfo serves contract metadata from the index, falling back to a chain +// read. probes carries what a batched pre-pass resolved; nil means read the chain per contract. +func (w *Worker) getProbedContractDescriptorInfo(cd bchain.AddressDescriptor, standardFromContext bchain.TokenStandardName, probes contractInfoProbes) (*bchain.ContractInfo, bool, error) { contractInfo, err := w.db.GetContractInfo(cd, standardFromContext) if err != nil { return nil, false, err } if contractInfo == nil { + bestHeight, reorgGen := w.contractProbeCacheState() + // already reported as holding no token: nothing left to read, and nothing to warn about + if w.contractProbeCache.contains(string(cd), bestHeight, reorgGen) { + return w.unknownContractInfo(cd), false, 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) } - contractInfo, err = w.chain.GetContractInfo(cd) - if err != nil { - glog.Errorf("GetContractInfo from chain error %v, contract %v", err, cd) - } + contractInfo, err = w.resolveContractInfo(cd, probes) 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 err != nil { + // a failed read says nothing about the contract, so it must not be cached + glog.Errorf("GetContractInfo from chain error %v, contract %v", err, cd) + } else { + w.contractProbeCache.add(string(cd), bestHeight, w.negativeProbeTTLBlocks(defaultNegativeProbeTTL), reorgGen) } + return w.unknownContractInfo(cd), false, nil } - } else if (contractInfo.Standard == bchain.UnhandledTokenStandard || len(contractInfo.Name) > 0 && contractInfo.Name[0] == 0) || (len(contractInfo.Symbol) > 0 && contractInfo.Symbol[0] == 0) { + if standardFromContext != bchain.UnknownTokenStandard && contractInfo.Standard == bchain.UnknownTokenStandard { + contractInfo.Standard = standardFromContext + contractInfo.Type = standardFromContext + } + if err = w.db.StoreContractInfo(contractInfo); err != nil { + glog.Errorf("StoreContractInfo error %v, contract %v", err, cd) + } + } else if contractNeedsChainRefresh(contractInfo) { // fix contract name/symbol that was parsed as a string consisting of zeroes - blockchainContractInfo, err := w.chain.GetContractInfo(cd) + blockchainContractInfo, err := w.resolveContractInfo(cd, probes) if err != nil { glog.Errorf("GetContractInfo from chain error %v, contract %v", err, cd) } else { @@ -1015,25 +1050,27 @@ func (w *Worker) getContractDescriptorInfo(cd bchain.AddressDescriptor, standard if contractInfo.Decimals == 0 && contractInfo.Standard == bchain.UnhandledTokenStandard { contractInfo.Decimals = w.chainParser.AmountDecimals() } - return contractInfo, validContract, nil + // unresolved contracts already returned above, with validContract false + return contractInfo, true, nil } +// getEthereumTokensTransfers maps parsed transfers to the API shape. Addresses and value come from +// the log or calldata, so a transfer whose contract metadata cannot be read is kept with the coin's +// decimals rather than dropped or left as a zero value (trezor/blockbook#1715). func (w *Worker) getEthereumTokensTransfers(transfers bchain.TokenTransfers, addresses map[string]struct{}) []TokenTransfer { - tokens := make([]TokenTransfer, len(transfers)) + tokens := make([]TokenTransfer, 0, 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 { + contractInfo, ok := contractCache[t.Contract] + if !ok { info, _, err := w.GetContractInfo(t.Contract, standard) if err != nil { glog.Errorf("getContractInfo error %v, contract %v", err, t.Contract) - continue + info = &bchain.ContractInfo{Standard: standard, Decimals: w.chainParser.AmountDecimals()} } contractInfo = info contractCache[t.Contract] = info @@ -1051,7 +1088,7 @@ func (w *Worker) getEthereumTokensTransfers(transfers bchain.TokenTransfers, add } aggregateAddress(addresses, t.From) aggregateAddress(addresses, t.To) - tokens[i] = TokenTransfer{ + tokens = append(tokens, TokenTransfer{ Type: standard, Standard: standard, Contract: t.Contract, @@ -1062,7 +1099,7 @@ func (w *Worker) getEthereumTokensTransfers(transfers bchain.TokenTransfers, add Decimals: contractInfo.Decimals, Name: contractInfo.Name, Symbol: contractInfo.Symbol, - } + }) } } return tokens @@ -1088,6 +1125,12 @@ func (w *Worker) GetEthereumTokenURI(contract string, id string) (string, *bchai return uri, ci, nil } +// maxFilteredAddressScanNonMatching caps entries a filtered walk may skip; matches are already bounded by maxResults. +var maxFilteredAddressScanNonMatching = 200000 + +// errFilteredScanLimit is public so callers narrow from/to instead of reading a short page as the end of history. +var errFilteredScanLimit = NewAPIError("Filtered history is too large to scan, narrow the range with from/to", true) + func (w *Worker) getAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool, filter *AddressFilter, maxResults int) ([]string, error) { var err error txids := make([]string, 0, 4) @@ -1105,29 +1148,26 @@ func (w *Worker) getAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool } return uint32(filter.AssetsMask)&uint32(mask) == uint32(mask), nil } - var callback db.GetTransactionsCallback - if filter.Vout == AddressFilterVoutOff { - callback = func(txid string, height uint32, indexes []int32) error { - if !mempool { - matches, err := txMatchesAssetMask(txid) // SYSCOIN - if err != nil || !matches { - return err - } + nonMatching := 0 + skipNonMatching := func() error { + nonMatching++ + if nonMatching > maxFilteredAddressScanNonMatching { + return errFilteredScanLimit + } + return nil + } + callback := func(txid string, height uint32, indexes []int32) error { + if !mempool { + matches, err := txMatchesAssetMask(txid) // SYSCOIN + if err != nil { + return err } - txids = append(txids, txid) - if len(txids) >= maxResults { - return &db.StopIteration{} + if !matches { + return skipNonMatching() } - return nil } - } else { - callback = func(txid string, height uint32, indexes []int32) error { - if !mempool { - matches, err := txMatchesAssetMask(txid) // SYSCOIN - if err != nil || !matches { - return err - } - } + matches := filter.Vout == AddressFilterVoutOff + if !matches { for _, index := range indexes { vout := index if vout < 0 { @@ -1136,15 +1176,19 @@ func (w *Worker) getAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool if (filter.Vout == AddressFilterVoutInputs && index < 0) || (filter.Vout == AddressFilterVoutOutputs && index >= 0) || (vout == int32(filter.Vout)) { - txids = append(txids, txid) - if len(txids) >= maxResults { - return &db.StopIteration{} - } + matches = true break } } - return nil } + if !matches { + return skipNonMatching() + } + txids = append(txids, txid) + if len(txids) >= maxResults { + return &db.StopIteration{} + } + return nil } if mempool { uniqueTxs := make(map[string]struct{}) @@ -1866,39 +1910,24 @@ func computePaging(count, page, itemsOnPage int) (Paging, int, int, int) { }, from, to, page } -// SYSCOIN -func computePagingWithFirstPageMempool(confirmedCount, mempoolCount, page, itemsOnPage int) (Paging, int, int, int) { - if itemsOnPage <= 0 { - itemsOnPage = 1 - } - mempoolSlots := mempoolCount - if mempoolSlots > itemsOnPage { - mempoolSlots = itemsOnPage - } - if mempoolSlots < 0 { - mempoolSlots = 0 - } - pg, from, to, page := computePaging(confirmedCount+mempoolSlots, page, itemsOnPage) - from -= mempoolSlots - if from < 0 { - from = 0 - } - to -= mempoolSlots - if to < 0 { - to = 0 - } - if from > confirmedCount { - from = confirmedCount - } - if to > confirmedCount { - to = confirmedCount - } - return pg, from, to, page +// computeAccountPaging pages over mempool entries followed by confirmed txids as a single +// sequence, so that a page never exceeds itemsOnPage and no tx falls between two pages. +func computeAccountPaging(mempoolCount, confirmedCount, page, itemsOnPage int) (pg Paging, mempoolFrom, mempoolTo, confirmedFrom, confirmedTo, normalizedPage int) { + pg, from, to, normalizedPage := computePaging(mempoolCount+confirmedCount, page, itemsOnPage) + mempoolFrom, mempoolTo = clampRange(from, to, mempoolCount) + confirmedFrom, confirmedTo = clampRange(from-mempoolCount, to-mempoolCount, confirmedCount) + return +} + +// clampRange limits a window of the merged sequence to the bounds of one of its parts. +func clampRange(from, to, length int) (int, int) { + from = min(max(from, 0), length) + return from, min(max(to, from), length) } -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) { +func (w *Worker) getEthereumContractBalance(addrDesc bchain.AddressDescriptor, index int, c *db.AddrContract, details AccountDetails, ticker *common.CurrencyRatesTicker, secondaryCoin string, erc20Balance *big.Int, erc20Batched bool, probes contractInfoProbes) (*Token, error) { standard := bchain.EthereumTokenStandardMap[c.Standard] - ci, validContract, err := w.getContractDescriptorInfo(c.Contract, standard) + ci, validContract, err := w.getProbedContractDescriptorInfo(c.Contract, standard, probes) if err != nil { return nil, errors.Annotatef(err, "getEthereumContractBalance %v", c.Contract) } @@ -1925,7 +1954,13 @@ func (w *Worker) getEthereumContractBalance(addrDesc bchain.AddressDescriptor, i 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 errors.Cause(err) == eth.ErrInvalidErc20Balance { + // Benign and high-volume: unparseable/empty balanceOf from a dead or non-ERC20-conforming + // token. Tracked via the "invalid" metric; logged at V(2) to avoid flooding logs. + glog.V(2).Infof("EthereumTypeGetErc20ContractBalance addr %v, contract %v, %v", addrDesc, c.Contract, err) + } else { + glog.Warningf("EthereumTypeGetErc20ContractBalance addr %v, contract %v, %v", addrDesc, c.Contract, err) + } } } if b != nil { @@ -1993,7 +2028,11 @@ func (w *Worker) getEthereumContractBalanceFromBlockchain(addrDesc, contract bch b, err = w.chain.EthereumTypeGetErc20ContractBalance(addrDesc, 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, contract, err) + if errors.Cause(err) == eth.ErrInvalidErc20Balance { + glog.V(2).Infof("EthereumTypeGetErc20ContractBalance addr %v, contract %v, %v", addrDesc, contract, err) + } else { + glog.Warningf("EthereumTypeGetErc20ContractBalance addr %v, contract %v, %v", addrDesc, contract, err) + } } } else { b = nil @@ -2065,8 +2104,8 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto 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) + // details=basic consumes only the header counters; nothing it returns reads the contract array + ca, err := w.db.GetAddrDescContracts(addrDesc, details == AccountDetailsBasic) if err != nil { return nil, nil, NewAPIError(fmt.Sprintf("Address not found, %v", err), true) } @@ -2091,7 +2130,7 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto if b != nil { ba.BalanceSat = *b } - nPending, nConfirmed, confirmedNonceOK, err = w.chain.EthereumTypeGetNonces(addrDesc, filter.WithConfirmedNonce) + nPending, nConfirmed, confirmedNonceOK, err = w.chain.EthereumTypeGetNonces(addrDesc, filter.WithConfirmedNonce, filter.PrivatePendingNonces...) if err != nil { return nil, nil, errors.Annotatef(err, "EthereumTypeGetNonces %v", addrDesc) } @@ -2103,7 +2142,7 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto 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 { + if c.Standard != bchain.FungibleToken || w.chainParser.EthereumTypeIsIgnoredContract(c.Contract) { continue } if len(filterDesc) > 0 && !bytes.Equal(filterDesc, c.Contract) { @@ -2128,10 +2167,19 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto } } if details > AccountDetailsBasic { + // a single-contract filter is never worth a batch - the per-contract path is as cheap + var probes contractInfoProbes + if len(filterDesc) == 0 { + probes = w.prefetchContractInfos(ca.Contracts) + } d.tokens = make([]Token, len(ca.Contracts)) var j int for i := range ca.Contracts { c := &ca.Contracts[i] + // skip, not remove: filter.Vout below relies on the index into ca.Contracts + if w.chainParser.EthereumTypeIsIgnoredContract(c.Contract) { + continue + } if len(filterDesc) > 0 { if !bytes.Equal(filterDesc, c.Contract) { continue @@ -2146,7 +2194,7 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto if erc20Balances != nil { erc20Balance, erc20Batched = erc20Balances[string(c.Contract)] } - t, err := w.getEthereumContractBalance(addrDesc, i+db.ContractIndexOffset, c, details, ticker, secondaryCoin, erc20Balance, erc20Batched) + t, err := w.getEthereumContractBalance(addrDesc, i+db.ContractIndexOffset, c, details, ticker, secondaryCoin, erc20Balance, erc20Batched, probes) if err != nil { return nil, nil, err } @@ -2303,8 +2351,8 @@ func (w *Worker) txFromTxid(txid string, bestHeight uint32, option AccountDetail func (w *Worker) getAddrDescAndNormalizeAddress(address string) (bchain.AddressDescriptor, string, error) { addrDesc, err := w.chainParser.GetAddrDescFromAddress(address) if err != nil { - var errAd error // try if the address is not address descriptor converted to string + var errAd error addrDesc, errAd = bchain.AddressDescriptorFromString(address) if errAd != nil { return nil, "", NewAPIError(fmt.Sprintf("Invalid address, %v", err), true) @@ -2385,6 +2433,8 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco var ( ba *db.AddrBalance txm []string + mempoolTxs []*Tx + mempoolTxCount int txs []*Tx txids []string accountChainExtraData *AccountChainExtraData @@ -2427,10 +2477,9 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco } } } - // if there are only unconfirmed transactions, there is no paging + // address only in mempool; the paged sequence is then the pending txs alone if ba == nil { ba = &db.AddrBalance{} - page = 0 } addresses := w.newAddressesMapForAliases() assetMempool := make(map[string]*syscoinTokenMempoolInfo) // SYSCOIN @@ -2465,24 +2514,38 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco 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 == AccountDetailsTxHistorySummary { - txs = append(txs, w.summarizeTxForAccount(tx, accountAddrDescs)) // SYSCOIN - } else if option >= AccountDetailsTxHistoryLight { - setIsOwnAddress(tx, address) - txs = append(txs, tx) + if option >= AccountDetailsTxidHistory { + // entries past (page+1)*txsOnPage can never fall into the page window; + // not retaining them bounds memory on addresses with a huge pending set + if filter.Vout == AddressFilterVoutQueryNotNecessary || len(mempoolTxs) < (page+1)*txsOnPage { + mempoolTxs = append(mempoolTxs, tx) } + mempoolTxCount++ } } } } } } + appendMempoolTxs := func(from, to int) { + for _, tx := range mempoolTxs[from:to] { + if option == AccountDetailsTxidHistory { + txids = append(txids, tx.Txid) + } else if option == AccountDetailsTxHistorySummary { + txs = append(txs, w.summarizeTxForAccount(tx, accountAddrDescs)) // SYSCOIN + } else if option >= AccountDetailsTxHistoryLight { + setIsOwnAddress(tx, address) + txs = append(txs, tx) + } + } + } // get tx history if requested by option or check mempool if there are some transactions for a new address if option >= AccountDetailsTxidHistory && filter.Vout != AddressFilterVoutQueryNotNecessary { txc, err := w.getAddressTxids(addrDesc, false, filter, (page+1)*txsOnPage) + if err == errFilteredScanLimit { + // unwrapped: the servers recognise public errors only by a plain *APIError assertion + return nil, err + } if err != nil { return nil, errors.Annotatef(err, "getAddressTxids %v false", addrDesc) } @@ -2490,24 +2553,19 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco if err != nil { return nil, errors.Annotatef(err, "GetBestBlock") } - var from, to int - if option == AccountDetailsTxHistorySummary { - pg, from, to, page = computePagingWithFirstPageMempool(len(txc), unconfirmedTxs, page, txsOnPage) // SYSCOIN - } else { - pg, from, to, page = computePaging(len(txc), page, txsOnPage) - } + var from, to, mempoolFrom, mempoolTo int + pg, mempoolFrom, mempoolTo, from, to, page = computeAccountPaging(mempoolTxCount, len(txc), page, txsOnPage) + // len(txc) >= txsOnPage means txc may have been truncated by maxResults, so the exact + // TotalPages must come from totalResults; the mempool is always fetched in full if len(txc) >= txsOnPage { if totalResults < 0 { pg.TotalPages = -1 } else { - // SYSCOIN - if option == AccountDetailsTxHistorySummary { - pg, _, _, _ = computePagingWithFirstPageMempool(totalResults, unconfirmedTxs, page, txsOnPage) - } else { - pg, _, _, _ = computePaging(totalResults, page, txsOnPage) - } + pg, _, _, _ = computePaging(totalResults+mempoolTxCount, page, txsOnPage) } } + // mempool txs take the first slots of the paged sequence, before confirmed history + appendMempoolTxs(mempoolFrom, mempoolTo) for i := from; i < to; i++ { txid := txc[i] if option == AccountDetailsTxidHistory { @@ -2525,16 +2583,9 @@ 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 >= AccountDetailsTxHistorySummary && len(txs) > txsOnPage { - // SYSCOIN - txs = txs[:txsOnPage] - } + } else if page == 0 { + // no confirmed history is queried, so there is nothing to page against + appendMempoolTxs(0, len(mempoolTxs)) } if w.chainType == bchain.ChainBitcoinType { totalReceived = ba.ReceivedSat() @@ -2677,13 +2728,32 @@ func (w *Worker) processInternalTransactionsForBalanceHistory(addrDesc bchain.Ad return nil } -func addEthereumFeesToBalanceHistory(ethTxData *bchain.EthereumTxData, bh *BalanceHistory) { - var feesSat big.Int - // mempool txs do not have fees yet - if ethTxData.GasUsed != nil && ethTxData.GasPrice != nil { - feesSat.Mul(ethTxData.GasPrice, ethTxData.GasUsed) +// getEthereumFeesSat computes the transaction fee actually paid, in the smallest +// unit of the chain. On L2 networks (Arbitrum, Optimism, Base, ...) the transaction +// gasPrice is only the bid price, while the receipt's effectiveGasPrice is the price +// actually charged, so it is preferred when available. Rollups that report a separate +// L1 data fee (l1Fee, e.g. the OP stack) have it added on top. Mempool transactions +// have no gasUsed yet and therefore no fee. +func getEthereumFeesSat(ethTxData *bchain.EthereumTxData) *big.Int { + feesSat := new(big.Int) + if ethTxData.GasUsed == nil { + return feesSat + } + gasPrice := ethTxData.GasPrice + if ethTxData.EffectiveGasPrice != nil && ethTxData.EffectiveGasPrice.Sign() > 0 { + gasPrice = ethTxData.EffectiveGasPrice + } + if gasPrice != nil { + feesSat.Mul(gasPrice, ethTxData.GasUsed) } - (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &feesSat) + if ethTxData.L1Fee != nil { + feesSat.Add(feesSat, ethTxData.L1Fee) + } + return feesSat +} + +func addEthereumFeesToBalanceHistory(ethTxData *bchain.EthereumTxData, bh *BalanceHistory) { + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), getEthereumFeesSat(ethTxData)) } func (w *Worker) processPrimaryVoutForBalanceHistory( @@ -2885,7 +2955,7 @@ func (w *Worker) balanceHistoryForTxid(addrDesc bchain.AddressDescriptor, txid s // (0) via _{WS,REST}_BALANCE_HISTORY_MAX_TXS (_BALANCE_HISTORY_MAX_TXS // sets both). const ( - DefaultBalanceHistoryMaxTxsREST = 250000 + DefaultBalanceHistoryMaxTxsREST = 100000 DefaultBalanceHistoryMaxTxsWS = 1000000 ) @@ -2901,7 +2971,11 @@ const ( // 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) + var err error + currencies, err = normalizeFiatCurrencies(currencies) + if err != nil { + return nil, err + } bhs := make(BalanceHistories, 0) start := time.Now() addrDesc, _, err := w.getAddrDescAndNormalizeAddress(address) @@ -2958,10 +3032,7 @@ func (w *Worker) GetBalanceHistory(address string, fromTimestamp, toTimestamp in 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 - } + w.setFiatRateToBalanceHistories(bha, currencies, "address") glog.V(1).Info("GetBalanceHistory ", address, ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), ", ", time.Since(start)) return bha, nil } @@ -3480,15 +3551,37 @@ func nonZeroTime(t time.Time) *time.Time { } 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 + systemInfoSyncStartGrace = 5 * time.Second + systemInfoStaleBlocks = 12 + // systemInfoMinStale floors the window systemInfoStaleBlocks derives from the chain + // cadence. Twelve block times is 3s on Arbitrum and 1.2s on Robinhood, under ordinary + // RPC and GC jitter. eth.TipStaleThreshold clamps its analogous window the same way, + // and the floor is what lets a fast chain's configured cadence be corrected downwards + // without tightening this check. + systemInfoMinStale = 30 * time.Second + // systemInfoSyncedGap is the floor for how far the indexed height may trail the backend + // tip and still count as synchronized. It covers the one-block window between the tip + // advancing and that block being connected, which would otherwise flap the status. + systemInfoSyncedGap = 1 + // systemInfoSyncedGapWindow turns that floor into wall clock. One block is 12s of slack + // on Ethereum but 0.25s on Arbitrum, where an excursion peaking at 211 blocks - 53s of + // real lag - read as out-of-sync and paged. Same value as systemInfoMinStale on purpose: + // under 30s is jitter for both checks. + systemInfoSyncedGapWindow = systemInfoMinStale + // systemInfoMinBlockPeriod floors the cadence the window is divided by, capping the + // derived tolerance at 300 blocks. 100ms is the fastest cadence any supported chain + // configures (Robinhood); anything smaller is a misconfigured averageBlockTimeMs or + // a degenerate observed average. + systemInfoMinBlockPeriod = 100 * time.Millisecond ) +// systemInfoInSync decides the externally reported in-sync state from the raw +// IsSynchronized flag plus a freshness/tip check. The tip rescue (out-of-sync -> synced) +// applies to any chain with a known block period, not only EVM: the raw flag is cleared for +// the whole of every resync iteration, so without it a chain that takes longer than the +// start grace to connect a batch reads out-of-sync mid-iteration even though its index is +// at the tip. The demotion (synced -> out-of-sync on a stale index) stays EVM-only; see the +// comment on it. A chain with no configured cadence (blockPeriod <= 0) keeps the raw flag. 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 @@ -3498,61 +3591,152 @@ func systemInfoInSync(inSync bool, initialSync bool, chainType bchain.ChainType, } } - if chainType != bchain.ChainEthereumType || blockPeriod <= 0 { + if blockPeriod <= 0 { return inSync } + // Neither input path validates the cadence beyond > 0, and the gap tolerance below + // scales linearly with it, so floor it to bound what a bad value can buy. + if blockPeriod < systemInfoMinBlockPeriod { + blockPeriod = systemInfoMinBlockPeriod + } - threshold := systemInfoEthereumStaleBlocks * blockPeriod + threshold := systemInfoStaleBlocks * blockPeriod + if threshold < systemInfoMinStale { + threshold = systemInfoMinStale + } 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. + // Tolerate the blocks the chain produces inside the window, never fewer than one. + // Derived from the constant, not from threshold above: 12 block times divided by the + // block time is 12 blocks on every chain, which would stretch Bitcoin to two hours. + syncedGap := int64(systemInfoSyncedGap) + if lag := int64(systemInfoSyncedGapWindow / blockPeriod); lag > syncedGap { + syncedGap = lag + } + + // A sync loop can stay inside ResyncIndex while new blocks keep arriving. If the indexed + // height is at (or close behind) the backend tip and the index was updated recently, + // report the externally observable state as synchronized. int64 avoids underflow if the + // backend reports a lower tip. gap := int64(backendBlocks) - int64(bestHeight) - if !inSync && !initialSync && gap >= 0 && gap <= systemInfoEthereumSyncedGap && isFresh { + // A negative gap is the steady state for RefreshSyncMetrics, whose backend tip is a + // snapshot from the end of the previous resync iteration, so the same distance is + // tolerated in both directions. backendBlocks > 0 keeps the rescue from firing before + // any tip was observed. + if gap < 0 { + // Bounded, not clamped to zero: disconnect/reconnect churn refreshes LastSync, + // so an index far past a live tip would read fresh indefinitely. + gap = -gap + } + if !inSync && !initialSync && backendBlocks > 0 && gap <= syncedGap && isFresh { return true } - if inSync && !isFresh { + // Demote a synced-but-stale index on EVM chains only. Their tip arrives over a newHeads + // subscription that can die silently, their spacing is near-deterministic, and 12 block + // times floored at 30s leaves large headroom. UTXO block arrival is Poisson: a natural + // gap past 12 mean spacings is ~6e-6 per block, and Bitcoin's configured 600s against + // an observed ~682s makes the window tighter than nominal, so across the fleet this + // would demote healthy nodes a few times a year and page via blockbookApiInSync. A + // stalled UTXO indexer is caught by BlockbookIndexStalled (blockbook_best_height against + // blockbook_backend_best_height), which is chain-agnostic and does not read this flag. + if inSync && !isFresh && chainType == bchain.ChainEthereumType { return false } return inSync } -// GetSystemInfo returns information about system -func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { - start := time.Now().UTC() - vi := common.GetVersionInfo() - 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 { +// syncBlockPeriod returns the block cadence the sync freshness checks normalize by. +// It prefers the configured averageBlockTimeMs - stable, available before enough blocks +// are observed, sub-second capable, and the same value the tip watchdog uses - and falls +// back to the observed average. Shared by GetSystemInfo and RefreshSyncMetrics so the +// /api/ inSync field and the blockbook_synchronized gauge cannot disagree. +func syncBlockPeriod(chain bchain.BlockChain, is *common.InternalState) time.Duration { + // Fractional seconds: the integer GetAvgBlockPeriod truncates a sub-second cadence to + // 0, and a 0 here makes systemInfoInSync return at blockPeriod <= 0, silently skipping + // the freshness and tip-gap checks on exactly the sub-second EVM chains this targets. + blockPeriod := time.Duration(is.GetAvgBlockPeriodSeconds() * float64(time.Second)) + if p, ok := chain.(interface { AverageBlockTimeDuration() (time.Duration, error) }); ok { if d, err := p.AverageBlockTimeDuration(); err == nil && d > 0 { blockPeriod = d } } + return blockPeriod +} + +// syncChainType returns the chain type systemInfoInSync gates its demotion on. A chain or +// parser that is not wired up yet (startup, and the unit tests) degrades to the UTXO type, +// which is the side of the gate that leaves the raw flag alone. +func syncChainType(chain bchain.BlockChain) bchain.ChainType { + if chain == nil { + return bchain.ChainBitcoinType + } + if p := chain.GetChainParser(); p != nil { + return p.GetChainType() + } + return bchain.ChainBitcoinType +} + +// RefreshSyncMetrics publishes the sync-state gauges from internal state and the cached +// backend info with no backend round trip, so it can run on a short timer. These gauges +// used to be written only by the ~15-minute app-info loop, which needs three RPCs, so a +// stall stayed invisible for up to 16 minutes. backend_best_height is held back until a +// height has been observed, since a 0 reads as "no blocks produced" to the stuck rules. +func RefreshSyncMetrics(is *common.InternalState, chain bchain.BlockChain, metrics *common.Metrics) { + if is == nil || metrics == nil { + return + } + // One locked snapshot: taking backend info, the initial-sync flag and the sync state + // through separate accessors let a concurrent resync land between them and tear the + // backend-vs-indexed height comparison. + bi, initialSync, isSynchronized, bestHeight, lastSync, startSync := is.GetSyncMetricsSnapshot() + // Note: a cached BackendError is deliberately not forced to inSync=false here. That + // force was sticky for up to a full resync period after the backend recovered (bi is + // only refreshed at the end of a resync iteration) and disagreed with /api, which + // re-queries live. A genuine backend outage freezes bi.Blocks and lastSync, so the + // freshness check below degrades EVM chains to 0 on its own; a stalled backend is the + // job of blockbook_tip_age_seconds and the backend-stuck rules, not this gauge. + inSync := systemInfoInSync(isSynchronized, initialSync, syncChainType(chain), bestHeight, + bi.Blocks, lastSync, startSync, time.Now().UTC(), syncBlockPeriod(chain, is)) + + metrics.BlockbookBestHeight.Set(float64(bestHeight)) + if bi.Blocks > 0 { + metrics.BackendBestHeight.Set(float64(bi.Blocks)) + } + synchronized := 0.0 + if inSync { + synchronized = 1 + } + metrics.Synchronized.Set(synchronized) + initial := 0.0 + if initialSync { + initial = 1 + } + metrics.InitialSync.Set(initial) +} + +// GetSystemInfo returns information about system +func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { + start := time.Now().UTC() + vi := common.GetVersionInfo() + inSync, bestHeight, lastBlockTime, startSync := w.is.GetSyncState() + blockPeriod := syncBlockPeriod(w.chain, w.is) inSyncMempool, lastMempoolTime, mempoolSize := w.is.GetMempoolSyncState() ci, err := w.chain.GetChainInfo() var backendError string if err != nil { glog.Error("GetChainInfo error ", err) - backendError = errors.Annotatef(err, "GetChainInfo").Error() + // served verbatim on /api/ and /, and a dial error carries the full backend url + backendError = common.RedactURLs(errors.Annotatef(err, "GetChainInfo").Error()) ci = &bchain.ChainInfo{} // 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) + inSync = systemInfoInSync(inSync, w.is.GetInitialSync(), w.chainType, bestHeight, ci.Blocks, lastBlockTime, startSync, time.Now().UTC(), blockPeriod) } var columnStats []common.InternalStateColumn var internalDBSize int64 @@ -3573,7 +3757,7 @@ func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { GitCommit: vi.GitCommit, BuildTime: vi.BuildTime, SyncMode: w.is.SyncMode, - InitialSync: w.is.InitialSync, + InitialSync: w.is.GetInitialSync(), InSync: inSync, BestHeight: bestHeight, LastBlockTime: lastBlockTime, diff --git a/api/worker_balance_history_tron.go b/api/worker_balance_history_tron.go index a355ccdcc9..494ce3ef15 100644 --- a/api/worker_balance_history_tron.go +++ b/api/worker_balance_history_tron.go @@ -53,14 +53,23 @@ func tronBalanceHistoryOverrideFromExtraDataParsed(extra *bchain.TronChainExtraD case "freeze": override.direction = tronBalanceHistoryDirectionOutgoing amountText = extra.StakeAmount - case "withdraw": - override.direction = tronBalanceHistoryDirectionIncoming - amountText = extra.UnstakeAmount + case "withdraw", "cancelUnfreeze": + // The principal these operations return is credited generically from + // WithdrawnUnfreeze, so crediting it here too would double count it. + override.direction = tronBalanceHistoryDirectionNone + override.amount.SetInt64(0) + return override, true case "voteRewardAmount": override.direction = tronBalanceHistoryDirectionIncoming amountText = extra.ClaimedVoteReward case "unfreeze": - // Unfreeze starts unlock period but funds are not yet spendable. + if extra.ContractType == "UnfreezeBalanceContract" { + // Stake 1.0 has no unlock period, the principal is spendable right away. + override.direction = tronBalanceHistoryDirectionIncoming + amountText = extra.UnstakeAmount + break + } + // Stake 2.0 unfreeze starts the 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) @@ -80,6 +89,19 @@ func tronBalanceHistoryOverrideFromExtraDataParsed(extra *bchain.TronChainExtraD return override, true } +// tronBalanceHistoryWithdrawnUnfreezeFromExtraDataParsed returns the matured unstake +// that java-tron swept back into the owner's spendable balance in this transaction. +func tronBalanceHistoryWithdrawnUnfreezeFromExtraDataParsed(extra *bchain.TronChainExtraData) big.Int { + var amount big.Int + if extra == nil { + return amount + } + if a, ok := parseBase10BigInt(extra.WithdrawnUnfreeze); ok { + amount.Set(a) + } + return amount +} + func tronBalanceHistoryFeeFromExtraDataParsed(extra *bchain.TronChainExtraData) big.Int { var fee big.Int if extra == nil { @@ -116,6 +138,7 @@ func (w *Worker) processTronBalanceHistory( } } feeSat := tronBalanceHistoryFeeFromExtraDataParsed(extra) + withdrawnUnfreezeSat := tronBalanceHistoryWithdrawnUnfreezeFromExtraDataParsed(extra) override, hasOverride := tronBalanceHistoryOverrideFromExtraDataParsed(extra, &value) @@ -169,6 +192,9 @@ func (w *Worker) processTronBalanceHistory( } } } + // Matured unstake returns to the owner on any Stake 2.0 operation, so it is + // credited independently of that operation's own principal movement. + (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &withdrawnUnfreezeSat) } // Fees always reduce spendable balance for sender-side matches. (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &feeSat) diff --git a/api/worker_ens_aliases_test.go b/api/worker_ens_aliases_test.go new file mode 100644 index 0000000000..a9ea38366e --- /dev/null +++ b/api/worker_ens_aliases_test.go @@ -0,0 +1,102 @@ +//go:build unittest + +package api + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// setupEnsAliasDB indexes the two EthereumType test blocks, which between them store an +// ENS reverse label (cfAddressAliases) and a named ERC20 contract (cfContracts) - the two +// alias sources getAddressAliases must tell apart when the ENS gate is closed. +func setupEnsAliasDB(t *testing.T) (*db.RocksDB, *eth.EthereumParser) { + t.Helper() + parser := eth.NewEthereumParser(1, true) + tmp, err := os.MkdirTemp("", "ens-aliases") + require.NoError(t, err) + database, err := db.NewRocksDB(tmp, 100000, -1, parser, nil, false) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, database.Close()) + require.NoError(t, os.RemoveAll(tmp)) + }) + is, err := database.LoadInternalState(&common.Config{CoinName: "eth-unittest"}) + require.NoError(t, err) + database.SetInternalState(is) + require.NoError(t, database.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock1(parser))) + require.NoError(t, database.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock2(parser))) + return database, parser +} + +// TestGetAddressAliases_EnsReverseGate locks in the read-path half of the ENS reverse +// opt-out: closing the gate must drop only the ENS label, never the contract/token name +// that shares the addressAliases response. +func TestGetAddressAliases_EnsReverseGate(t *testing.T) { + database, parser := setupEnsAliasDB(t) + + const contractAddr = "0x" + dbtestdata.EthAddrContract4a + ensAddr := dbtestdata.EthAddr7bEIP55 + addresses := map[string]struct{}{contractAddr: {}, ensAddr: {}} + + contractAlias := AddressAlias{Type: "Contract", Alias: "Contract 74"} + ensAlias := AddressAlias{Type: "ENS", Alias: parser.FormatAddressAlias(ensAddr, "address7b")} + + newWorker := func(useEnsReverseAliases bool) *Worker { + return &Worker{ + db: database, + chainParser: parser, + chainType: bchain.ChainEthereumType, + useAddressAliases: true, + useEnsReverseAliases: useEnsReverseAliases, + } + } + + t.Run("enabled serves both alias kinds", func(t *testing.T) { + got := newWorker(true).getAddressAliases(addresses) + require.Equal(t, AddressAliasesMap{contractAddr: contractAlias, ensAddr: ensAlias}, got) + }) + + t.Run("disabled keeps the contract name and drops the ENS label", func(t *testing.T) { + got := newWorker(false).getAddressAliases(addresses) + require.Equal(t, AddressAliasesMap{contractAddr: contractAlias}, got) + }) +} + +// TestNewWorker_ReadsEnsReverseOptInFromParser closes the parser->worker hop. This is the +// seam Test_PublicServer_EthereumType depends on: it configures only the parser and expects +// the rendered pages to carry the ENS labels, so a worker that stopped consulting the parser +// would break there rather than here. Also pins the wire format the server asserts +// (address7b.eth), not just "some non-empty alias". +func TestNewWorker_ReadsEnsReverseOptInFromParser(t *testing.T) { + for _, enableEns := range []bool{false, true} { + name := "gate closed" + if enableEns { + name = "gate open" + } + t.Run(name, func(t *testing.T) { + database, parser := setupEnsAliasDB(t) + parser.EnableEnsReverseAliases = enableEns + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + require.NoError(t, err) + + w, err := NewWorker(database, chain, nil, nil, nil, database.GetInternalState(), nil) + require.NoError(t, err) + require.Equal(t, enableEns, w.useEnsReverseAliases) + + got := w.getAddressAliases(map[string]struct{}{dbtestdata.EthAddr7bEIP55: {}}) + if enableEns { + require.Equal(t, AddressAlias{Type: "ENS", Alias: "address7b.eth"}, got[dbtestdata.EthAddr7bEIP55]) + } else { + require.NotContains(t, got, dbtestdata.EthAddr7bEIP55) + } + }) + } +} diff --git a/api/worker_filtered_scan_test.go b/api/worker_filtered_scan_test.go new file mode 100644 index 0000000000..adb3197437 --- /dev/null +++ b/api/worker_filtered_scan_test.go @@ -0,0 +1,53 @@ +//go:build unittest + +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func setFilteredScanBudget(t *testing.T, budget int) { + t.Helper() + prev := maxFilteredAddressScanNonMatching + maxFilteredAddressScanNonMatching = budget + t.Cleanup(func() { maxFilteredAddressScanNonMatching = prev }) +} + +// TestGetAddressSparseFilterStopsAtScanBudget: a filter that matches nothing must not walk the +// whole address index, and the caller must get a public error instead of an empty history. +func TestGetAddressSparseFilterStopsAtScanBudget(t *testing.T) { + // every indexed tx only pays to Addr1, so filter=inputs never matches + w, _, _ := buildPagingWorker(t, 10, 0) + setFilteredScanBudget(t, 5) + + _, err := w.GetAddress(dbtestdata.Addr1, 1, 25, AccountDetailsTxidHistory, &AddressFilter{Vout: AddressFilterVoutInputs}, "") + require.Error(t, err) + apiErr, ok := err.(*APIError) + require.True(t, ok, "error must stay a plain *APIError so the servers return it to the client, got %T", err) + require.True(t, apiErr.Public) + require.Equal(t, errFilteredScanLimit, apiErr) +} + +// TestGetAddressSparseFilterWithinScanBudget: a sparse walk that fits the budget still succeeds. +func TestGetAddressSparseFilterWithinScanBudget(t *testing.T) { + w, _, _ := buildPagingWorker(t, 10, 0) + setFilteredScanBudget(t, 10) + + addr, err := w.GetAddress(dbtestdata.Addr1, 1, 25, AccountDetailsTxidHistory, &AddressFilter{Vout: AddressFilterVoutInputs}, "") + require.NoError(t, err) + require.Empty(t, addr.Txids) +} + +// TestGetAddressDenseFilterIgnoresScanBudget: matching entries do not count against the budget, +// so a filter that matches often pages as deep as an unfiltered walk. +func TestGetAddressDenseFilterIgnoresScanBudget(t *testing.T) { + w, _, confirmed := buildPagingWorker(t, 10, 0) + setFilteredScanBudget(t, 1) + + addr, err := w.GetAddress(dbtestdata.Addr1, 1, 25, AccountDetailsTxidHistory, &AddressFilter{Vout: AddressFilterVoutOutputs}, "") + require.NoError(t, err) + require.Equal(t, confirmed, addr.Txids) +} diff --git a/api/worker_paging_test.go b/api/worker_paging_test.go new file mode 100644 index 0000000000..aa039e3e4f --- /dev/null +++ b/api/worker_paging_test.go @@ -0,0 +1,273 @@ +//go:build unittest + +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// pagingMempool serves the same entries for every address, which is enough to put a +// pending tx in front of the confirmed history of the tested address. +type pagingMempool struct { + entries []bchain.Outpoint +} + +func (m *pagingMempool) Resync() (int, error) { return len(m.entries), nil } +func (m *pagingMempool) GetTransactions(address string) ([]bchain.Outpoint, error) { + return m.entries, nil +} +func (m *pagingMempool) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor) ([]bchain.Outpoint, error) { + return m.entries, nil +} +func (m *pagingMempool) GetTxAssets(assetGuid uint64) bchain.MempoolTxidEntries { return nil } +func (m *pagingMempool) GetAllEntries() bchain.MempoolTxidEntries { return nil } +func (m *pagingMempool) GetTransactionTime(txid string) uint32 { return 0 } +func (m *pagingMempool) GetTxidFilterEntries(filterScripts string, fromTimestamp uint32) (bchain.MempoolTxidFilterEntries, error) { + return bchain.MempoolTxidFilterEntries{}, nil +} + +// pagingChain serves the pending txs, which are not in the index and are therefore +// loaded from the backend. +type pagingChain struct { + bchain.BlockChain + txs map[string]*bchain.Tx +} + +func (c *pagingChain) GetTransaction(txid string) (*bchain.Tx, error) { + if tx, found := c.txs[txid]; found { + return tx, nil + } + return nil, bchain.ErrTxNotFound +} + +func (c *pagingChain) GetTransactionSpecific(tx *bchain.Tx) (json.RawMessage, error) { + if _, found := c.txs[tx.Txid]; found { + return json.RawMessage("{}"), nil + } + return c.BlockChain.GetTransactionSpecific(tx) +} + +// buildPagingWorker indexes confirmedTxs blocks, each with one tx paying Addr1, and puts +// pendingTxs txs for the same address in the mempool. Returns the worker and the txids of +// the pending and the confirmed txs, both newest first - the order the API returns them in. +func buildPagingWorker(t *testing.T, confirmedTxs, pendingTxs int) (*Worker, []string, []string) { + t.Helper() + parser := btc.NewBitcoinParser(btc.GetChainParams("test"), &btc.Configuration{BlockAddressesToKeep: 1}) + script := dbtestdata.AddressToPubKeyHex(dbtestdata.Addr1, parser) + + blocks := make([]*bchain.Block, 0, confirmedTxs) + confirmed := make([]string, 0, confirmedTxs) + for i := 1; i <= confirmedTxs; i++ { + tx := fanInTx(fanInHash(uint64(i)), uint32(i), []bchain.Vin{{Coinbase: "01", Sequence: 0xffffffff}}, []bchain.Vout{fanInVout(0, script)}) + blocks = append(blocks, &bchain.Block{ + BlockHeader: bchain.BlockHeader{Hash: fanInHash(uint64(1_000_000 + i)), Height: uint32(i), Time: int64(1_700_000_000 + i)}, + Txs: []bchain.Tx{*tx}, + }) + // newest first + confirmed = append([]string{tx.Txid}, confirmed...) + } + + mempool := &pagingMempool{} + base, err := dbtestdata.NewFakeBlockChain(parser) + require.NoError(t, err) + chain := &pagingChain{BlockChain: base, txs: make(map[string]*bchain.Tx)} + pending := make([]string, 0, pendingTxs) + for i := 1; i <= pendingTxs; i++ { + txid := fanInHash(uint64(2_000_000 + i)) + chain.txs[txid] = &bchain.Tx{ + Txid: txid, + Version: 1, + Vin: []bchain.Vin{{Coinbase: "01", Sequence: 0xffffffff}}, + Vout: []bchain.Vout{fanInVout(0, script)}, + Confirmations: 0, + Time: int64(1_800_000_000 + i), + Blocktime: int64(1_800_000_000 + i), + } + mempool.entries = append(mempool.entries, bchain.Outpoint{Txid: txid, Vout: 0}) + pending = append(pending, txid) + } + + database, is := newIndexedWorkerDB(t, parser, blocks) + + // caching is switched off, so pending txs are always served by pagingChain + txCache, err := db.NewTxCache(database, chain, newRefreshTestMetrics(t), is, false) + require.NoError(t, err) + + w := &Worker{ + db: database, + chain: chain, + chainParser: parser, + chainType: bchain.ChainBitcoinType, + mempool: mempool, + txCache: txCache, + is: is, + } + return w, pending, confirmed +} + +// TestGetAddressPagesMempoolWithConfirmed is the regression test for issue #1099: a page +// must never be longer than requested, and walking the pages must return every tx once. +func TestGetAddressPagesMempoolWithConfirmed(t *testing.T) { + const confirmedTxs, pendingTxs, txsOnPage = 3, 1, 2 + w, pending, confirmed := buildPagingWorker(t, confirmedTxs, pendingTxs) + + want := append(append([]string{}, pending...), confirmed...) + filter := &AddressFilter{Vout: AddressFilterVoutOff} + + var walked []string + totalPages := 0 + for page := 1; ; page++ { + addr, err := w.GetAddress(dbtestdata.Addr1, page, txsOnPage, AccountDetailsTxidHistory, filter, "") + require.NoError(t, err) + require.LessOrEqual(t, len(addr.Txids), txsOnPage, + "page %d returned %d txids, more than the requested pageSize %d", page, len(addr.Txids), txsOnPage) + walked = append(walked, addr.Txids...) + totalPages = addr.TotalPages + if page >= addr.TotalPages { + break + } + } + + require.Equal(t, 2, totalPages, "pending txs must be counted into the total page count") + require.Equal(t, want, walked, "walking all pages must return every tx exactly once, pending first") +} + +// TestGetAddressPagesWithoutMempoolUnchanged pins the paging of an account with no pending +// txs, which must stay exactly as it was before mempool entries joined the paged sequence. +func TestGetAddressPagesWithoutMempoolUnchanged(t *testing.T) { + const confirmedTxs, txsOnPage = 5, 2 + w, _, confirmed := buildPagingWorker(t, confirmedTxs, 0) + + filter := &AddressFilter{Vout: AddressFilterVoutOff} + var walked []string + for page := 1; ; page++ { + addr, err := w.GetAddress(dbtestdata.Addr1, page, txsOnPage, AccountDetailsTxidHistory, filter, "") + require.NoError(t, err) + require.LessOrEqual(t, len(addr.Txids), txsOnPage) + require.Equal(t, 3, addr.TotalPages) + walked = append(walked, addr.Txids...) + if page >= addr.TotalPages { + break + } + } + require.Equal(t, confirmed, walked) +} + +// TestGetAddressMempoolOnlyAccount covers an address that is only in the mempool, where +// the paged sequence consists of the pending txs alone. +func TestGetAddressMempoolOnlyAccount(t *testing.T) { + const pendingTxs, txsOnPage = 3, 1 + w, pending, _ := buildPagingWorker(t, 0, pendingTxs) + filter := &AddressFilter{Vout: AddressFilterVoutOff} + + addr, err := w.GetAddress(dbtestdata.Addr1, 1, 25, AccountDetailsTxidHistory, filter, "") + require.NoError(t, err) + require.Equal(t, pending, addr.Txids) + require.Equal(t, pendingTxs, addr.UnconfirmedTxs) + require.Equal(t, 1, addr.TotalPages) + + // every advertised page must be reachable even without confirmed history + var walked []string + for page := 1; ; page++ { + addr, err := w.GetAddress(dbtestdata.Addr1, page, txsOnPage, AccountDetailsTxidHistory, filter, "") + require.NoError(t, err) + require.LessOrEqual(t, len(addr.Txids), txsOnPage, + "page %d returned %d txids, more than the requested pageSize %d", page, len(addr.Txids), txsOnPage) + require.Equal(t, pendingTxs, addr.TotalPages, "pending txs must be counted into the total page count") + walked = append(walked, addr.Txids...) + if page >= addr.TotalPages { + break + } + } + require.Equal(t, pending, walked, "walking all pages must return every pending tx exactly once") +} + +// buildXpubPagingWorker indexes the shared bitcoin-type test blocks, which contain +// addresses derived from dbtestdata.Xpub, and puts one pending tx in the mempool. +func buildXpubPagingWorker(t *testing.T) (*Worker, string) { + t.Helper() + // the xpub magics are what makes the derived addresses match the indexed test data + parser := btc.NewBitcoinParser(btc.GetChainParams("test"), &btc.Configuration{ + BlockAddressesToKeep: 1, + XPubMagic: 70617039, + XPubMagicSegwitP2sh: 71979618, + XPubMagicSegwitNative: 73342198, + Slip44: 1, + }) + + base, err := dbtestdata.NewFakeBlockChain(parser) + require.NoError(t, err) + chain := &pagingChain{BlockChain: base, txs: make(map[string]*bchain.Tx)} + pendingTxid := fanInHash(3_000_001) + chain.txs[pendingTxid] = &bchain.Tx{ + Txid: pendingTxid, + Version: 1, + Vin: []bchain.Vin{{Coinbase: "01", Sequence: 0xffffffff}}, + Vout: []bchain.Vout{fanInVout(0, dbtestdata.AddressToPubKeyHex(dbtestdata.Addr4, parser))}, + Confirmations: 0, + Time: 1_800_000_000, + Blocktime: 1_800_000_000, + } + mempool := &pagingMempool{entries: []bchain.Outpoint{{Txid: pendingTxid, Vout: 0}}} + + database, is := newIndexedWorkerDB(t, parser, []*bchain.Block{ + dbtestdata.GetTestBitcoinTypeBlock1(parser), + dbtestdata.GetTestBitcoinTypeBlock2(parser), + }) + + metrics := newRefreshTestMetrics(t) + txCache, err := db.NewTxCache(database, chain, metrics, is, false) + require.NoError(t, err) + + // the xpub cache is package global, so it must not leak between tests + resetXpubCache() + + w := &Worker{ + db: database, + chain: chain, + chainParser: parser, + chainType: bchain.ChainBitcoinType, + mempool: mempool, + txCache: txCache, + is: is, + metrics: metrics, + xpubConfig: DefaultXpubConfig(), + } + return w, pendingTxid +} + +// TestGetXpubAddressPagesMempoolWithConfirmed is the xpub endpoint from issue #1099: with a +// pending tx, pageSize was exceeded and a confirmed tx fell between page 1 and page 2. +func TestGetXpubAddressPagesMempoolWithConfirmed(t *testing.T) { + const txsOnPage = 1 + w, pendingTxid := buildXpubPagingWorker(t) + + // the confirmed history alone, in the order the API returns it + confirmedOnly, err := w.GetXpubAddress(dbtestdata.Xpub, 1, 100, AccountDetailsTxidHistory, + &AddressFilter{Vout: AddressFilterVoutOff, OnlyConfirmed: true}, 5, "") + require.NoError(t, err) + require.NotEmpty(t, confirmedOnly.Txids, "test data must give the xpub some confirmed txs") + want := append([]string{pendingTxid}, confirmedOnly.Txids...) + + var walked []string + for page := 1; ; page++ { + addr, err := w.GetXpubAddress(dbtestdata.Xpub, page, txsOnPage, AccountDetailsTxidHistory, + &AddressFilter{Vout: AddressFilterVoutOff}, 5, "") + require.NoError(t, err) + require.LessOrEqual(t, len(addr.Txids), txsOnPage, + "page %d returned %d txids, more than the requested pageSize %d", page, len(addr.Txids), txsOnPage) + walked = append(walked, addr.Txids...) + if page >= addr.TotalPages { + require.Equal(t, len(want), addr.TotalPages*txsOnPage, "pending txs must be counted into the total page count") + break + } + } + require.Equal(t, want, walked, "walking all pages must return every tx exactly once, pending first") +} diff --git a/api/worker_test.go b/api/worker_test.go index f4a2aa12fb..8229906848 100644 --- a/api/worker_test.go +++ b/api/worker_test.go @@ -4,13 +4,21 @@ package api import ( "encoding/json" + "fmt" "math/big" + "strconv" "testing" "time" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" "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 TestSystemInfoInSync(t *testing.T) { @@ -44,15 +52,16 @@ func TestSystemInfoInSync(t *testing.T) { chainType: bchain.ChainEthereumType, bestHeight: 100, backendBlocks: 100, - lastBlockTime: now.Add(-25 * time.Second), + lastBlockTime: now.Add(-45 * time.Second), startSync: oldStart, blockPeriod: 2 * time.Second, }, { + // 20 blocks at 2s is 40s of lag, past the 15 the window allows there. name: "does not report synced while local height is behind", chainType: bchain.ChainEthereumType, bestHeight: 90, - backendBlocks: 100, + backendBlocks: 110, lastBlockTime: now.Add(-10 * time.Second), startSync: oldStart, blockPeriod: 2 * time.Second, @@ -78,18 +87,206 @@ func TestSystemInfoInSync(t *testing.T) { want: true, }, { - name: "does not report synced more than one block behind tip", + name: "keeps sub-second chain synced inside the freshness floor", + chainType: bchain.ChainEthereumType, + inSync: true, + bestHeight: 100, + backendBlocks: 100, + // 12 x 250ms is 3s; without the floor this would already read stale. + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + want: true, + }, + { + name: "marks sub-second chain stale past the freshness floor", + chainType: bchain.ChainEthereumType, + inSync: true, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-45 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + }, + { + // Was "more than one block behind": the tolerance is now the blocks produced in + // 30s, so at 2s the limit is 15 and it takes 16 to fall out. + name: "does not report synced past the tolerated window on a two-second chain", + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 116, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + }, + { + // The boundary is 30s/250ms = 120 blocks, not 1. This is the shape that spent + // 203 min/week in the blockbookApiInSync alert on arb-b4. + name: "reports synced trailing the sub-second tip inside the window", + chainType: bchain.ChainEthereumType, + bestHeight: 100000, + backendBlocks: 100120, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + want: true, + }, + { + name: "does not report synced one block past the window on a sub-second chain", + chainType: bchain.ChainEthereumType, + bestHeight: 100000, + backendBlocks: 100121, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + }, + { + // 100ms is the fastest cadence any supported chain configures (Robinhood) and + // sits exactly on the systemInfoMinBlockPeriod floor: its full 30s window, + // 300 blocks, must survive the floor untouched. + name: "reports synced trailing a 100ms tip inside the window", + chainType: bchain.ChainEthereumType, + bestHeight: 100000, + backendBlocks: 100300, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 100 * time.Millisecond, + want: true, + }, + { + name: "does not report synced one block past the window on a 100ms chain", + chainType: bchain.ChainEthereumType, + bestHeight: 100000, + backendBlocks: 100301, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 100 * time.Millisecond, + }, + { + // A cadence below any supported chain's is a config typo (25ms where Arbitrum + // means 250) or a degenerate observed average. Unfloored it would buy 30s/25ms + // = 1200 blocks of tolerance; the floor caps what it can buy at 300. + name: "caps the tolerance bought by an implausibly fast cadence", + chainType: bchain.ChainEthereumType, + bestHeight: 100000, + backendBlocks: 100301, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 25 * time.Millisecond, + }, + { + // Regression anchor: the 2026-08-09 arb-b4 excursion peaked at 211 blocks, 53s + // of real lag, and must still read out-of-sync. + name: "does not report synced during the measured 211-block arbitrum lag", + chainType: bchain.ChainEthereumType, + bestHeight: 100000, + backendBlocks: 100211, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + }, + { + name: "widens the ethereum tolerance to two blocks", chainType: bchain.ChainEthereumType, bestHeight: 98, backendBlocks: 100, lastBlockTime: now.Add(-10 * time.Second), startSync: oldStart, + blockPeriod: 12 * time.Second, + want: true, + }, + { + // 30s/12s is 2.5, and the truncation has to round down, not up. + name: "does not widen the ethereum tolerance to three blocks", + chainType: bchain.ChainEthereumType, + bestHeight: 97, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 12 * time.Second, + }, + { + name: "keeps the one-block tolerance on a ten-minute chain", + chainType: bchain.ChainBitcoinType, + bestHeight: 99, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 600 * time.Second, + want: true, + }, + { + // The UTXO constraint. Also the guard against deriving the window from the + // computed threshold, which would be 12 block times here - two hours of lag. + name: "does not widen the tolerance on a ten-minute chain", + chainType: bchain.ChainBitcoinType, + bestHeight: 98, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 600 * time.Second, + }, + { + // RefreshSyncMetrics compares against a backend height cached at the end of the + // previous resync iteration, so the index is routinely past it. + name: "reports synced when the index is ahead of the cached backend tip", + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 98, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + want: true, + }, + { + // isFresh still gates the tolerated path. + name: "does not rescue an index ahead of a stale cached tip", + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 98, + lastBlockTime: now.Add(-45 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + }, + { + // Ahead of the tip is tolerated to the same bound as behind it. + name: "reports synced ahead of the tip up to the tolerated window", + chainType: bchain.ChainEthereumType, + bestHeight: 100120, + backendBlocks: 100000, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + want: true, + }, + { + // Freshness cannot end the ahead direction - a fork-suspect disconnect and + // reconnect loop refreshes LastSync on every writeHeight - so an index far + // past the tip (rolled back, replaced or wrong-chain backend) must fall out + // of the rescue on the gap bound alone. + name: "does not rescue an index far ahead of a fresh tip", + chainType: bchain.ChainEthereumType, + bestHeight: 100121, + backendBlocks: 100000, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + }, + { + // Without the backendBlocks > 0 guard the clamp turns gap into 0 at cold start + // and rescues before any backend height was ever observed. + name: "does not rescue before a backend height is known", + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 0, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, blockPeriod: 2 * time.Second, }, { name: "does not report synced during initial sync", - initialSync: true, chainType: bchain.ChainEthereumType, + initialSync: true, bestHeight: 100, backendBlocks: 100, lastBlockTime: now.Add(-10 * time.Second), @@ -105,18 +302,18 @@ func TestSystemInfoInSync(t *testing.T) { }, { name: "marks already synced evm stale", - inSync: true, chainType: bchain.ChainEthereumType, + inSync: true, bestHeight: 100, backendBlocks: 100, - lastBlockTime: now.Add(-25 * time.Second), + lastBlockTime: now.Add(-45 * time.Second), startSync: oldStart, blockPeriod: 2 * time.Second, }, { name: "keeps already synced evm fresh", - inSync: true, chainType: bchain.ChainEthereumType, + inSync: true, bestHeight: 100, backendBlocks: 100, lastBlockTime: now.Add(-10 * time.Second), @@ -125,13 +322,42 @@ func TestSystemInfoInSync(t *testing.T) { want: true, }, { - name: "does not extend tip equality rescue to bitcoin", + // The tip/freshness rescue applies to any chain with a configured cadence, not + // only EVM: a UTXO chain at the tip mid-resync-iteration must not read stale. + name: "reports synced at a fresh tip for a non-evm chain", chainType: bchain.ChainBitcoinType, bestHeight: 100, backendBlocks: 100, lastBlockTime: now.Add(-10 * time.Second), startSync: oldStart, blockPeriod: 2 * time.Second, + want: true, + }, + { + // The demotion, unlike the rescue, stays EVM-only. UTXO block arrival is + // Poisson, so a gap past 12 mean spacings happens on a healthy chain often + // enough to page; a stalled UTXO indexer is caught by comparing + // blockbook_best_height against blockbook_backend_best_height instead. + name: "does not demote a stale non-evm chain", + chainType: bchain.ChainBitcoinType, + inSync: true, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-3 * time.Hour), + startSync: oldStart, + blockPeriod: 600 * time.Second, + want: true, + }, + { + // No configured cadence (blockPeriod <= 0): fall back to the raw flag, no + // freshness rescue or demotion. + name: "keeps the raw out-of-sync flag without a configured cadence", + chainType: bchain.ChainBitcoinType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 0, }, } @@ -194,6 +420,22 @@ func TestGetSecondaryTicker_PerformsLookupWithSecondaryCurrency(t *testing.T) { } } +func TestGetAddrDescAndNormalizeAddressPreservesNonStandardScript(t *testing.T) { + parser := btc.NewBitcoinParser(btc.GetChainParams("test"), &btc.Configuration{}) + w := &Worker{chainParser: parser} + addrDesc := bchain.AddressDescriptor{0x51} // OP_TRUE is indexable but has no address representation + + gotAddrDesc, gotAddress, err := w.getAddrDescAndNormalizeAddress(addrDesc.String()) + + require.NoError(t, err) + require.Equal(t, addrDesc, gotAddrDesc) + require.Equal(t, addrDesc.String(), gotAddress) + require.True(t, parser.IsAddrDescIndexable(addrDesc)) + addresses, _, err := parser.GetAddressesFromAddrDesc(addrDesc) + require.NoError(t, err) + require.Empty(t, addresses) +} + func TestTronBalanceHistoryOverrides(t *testing.T) { tests := []struct { name string @@ -214,22 +456,23 @@ func TestTronBalanceHistoryOverrides(t *testing.T) { wantAmount: "42000000", }, { - name: "withdraw uses unstake amount", - payload: `{"operation":"withdraw","unstakeAmount":"77000000"}`, + // The principal arrives through the generic WithdrawnUnfreeze credit instead. + name: "withdraw defers to the withdrawn unfreeze credit", + payload: `{"operation":"withdraw","withdrawnUnfreeze":"77000000"}`, fallbackAmount: "1", hasFallbackAmount: true, wantOverride: true, - wantDirection: tronBalanceHistoryDirectionIncoming, - wantAmount: "77000000", + wantDirection: tronBalanceHistoryDirectionNone, + wantAmount: "0", }, { - name: "withdraw falls back to tx value", - payload: `{"operation":"withdraw"}`, + name: "cancel unfreeze defers to the withdrawn unfreeze credit", + payload: `{"operation":"cancelUnfreeze","withdrawnUnfreeze":"77000000"}`, fallbackAmount: "123", hasFallbackAmount: true, wantOverride: true, - wantDirection: tronBalanceHistoryDirectionIncoming, - wantAmount: "123", + wantDirection: tronBalanceHistoryDirectionNone, + wantAmount: "0", }, { name: "vote reward amount uses claimed vote reward", @@ -259,7 +502,21 @@ func TestTronBalanceHistoryOverrides(t *testing.T) { wantAmount: "999", }, { - name: "unfreeze has explicit no-move override", + name: "stake 2.0 unfreeze has explicit no-move override", + payload: `{"operation":"unfreeze","contractType":"UnfreezeBalanceV2Contract","unstakeAmount":"77000000"}`, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionNone, + wantAmount: "0", + }, + { + name: "stake 1.0 unfreeze credits the principal immediately", + payload: `{"operation":"unfreeze","contractType":"UnfreezeBalanceContract","unstakeAmount":"77000000"}`, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionIncoming, + wantAmount: "77000000", + }, + { + name: "unfreeze without a contract type stays neutral", payload: `{"operation":"unfreeze","unstakeAmount":"77000000"}`, wantOverride: true, wantDirection: tronBalanceHistoryDirectionNone, @@ -304,3 +561,358 @@ func TestTronBalanceHistoryOverrides(t *testing.T) { }) } } + +// TestGetEthereumFeesSat verifies the transaction fee is computed from the +// receipt's effectiveGasPrice on L2 networks (issue #1227), while falling back +// to the transaction gasPrice for older/legacy transactions, and that a +// separately reported L1 data fee is added on top. +func TestGetEthereumFeesSat(t *testing.T) { + bi := func(v int64) *big.Int { return big.NewInt(v) } + tests := []struct { + name string + txData *bchain.EthereumTxData + wantFee string + }{ + { + name: "L2 uses effectiveGasPrice, not the gasPrice bid", + txData: &bchain.EthereumTxData{GasUsed: bi(21000), GasPrice: bi(1200), EffectiveGasPrice: bi(10)}, + wantFee: "210000", // 21000 * 10, NOT 21000 * 1200 + }, + { + name: "OP-stack adds l1Fee on top of the L2 execution fee", + txData: &bchain.EthereumTxData{GasUsed: bi(21000), GasPrice: bi(1200), EffectiveGasPrice: bi(10), L1Fee: bi(5000)}, + wantFee: "215000", // 21000 * 10 + 5000 + }, + { + name: "legacy tx without effectiveGasPrice falls back to gasPrice", + txData: &bchain.EthereumTxData{GasUsed: bi(21000), GasPrice: bi(1200)}, + wantFee: "25200000", // 21000 * 1200 + }, + { + name: "legacy fallback still adds l1Fee on top", + txData: &bchain.EthereumTxData{GasUsed: bi(21000), GasPrice: bi(1200), L1Fee: bi(5000)}, + wantFee: "25205000", // 21000 * 1200 + 5000 (nil effectiveGasPrice, l1Fee present) + }, + { + name: "zero effectiveGasPrice falls back to gasPrice", + txData: &bchain.EthereumTxData{GasUsed: bi(21000), GasPrice: bi(1200), EffectiveGasPrice: bi(0)}, + wantFee: "25200000", + }, + { + name: "mempool tx without gasUsed has no fee", + txData: &bchain.EthereumTxData{GasPrice: bi(1200), EffectiveGasPrice: bi(10)}, + wantFee: "0", + }, + { + // pins that the gasUsed==nil guard returns before l1Fee is added + name: "mempool tx with l1Fee but no gasUsed still has no fee", + txData: &bchain.EthereumTxData{GasPrice: bi(1200), EffectiveGasPrice: bi(10), L1Fee: bi(5000)}, + wantFee: "0", + }, + { + name: "no gas price at all does not panic and yields zero", + txData: &bchain.EthereumTxData{GasUsed: bi(21000)}, + wantFee: "0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getEthereumFeesSat(tt.txData).String(); got != tt.wantFee { + t.Errorf("getEthereumFeesSat() = %s, want %s", got, tt.wantFee) + } + }) + } +} + +// TestAddEthereumFeesToBalanceHistory pins the balance-history fee accounting: +// the sent amount must include the effectiveGasPrice-based execution fee plus any +// L1 data fee. Before the fix this path used the bid gasPrice and omitted l1Fee. +func TestAddEthereumFeesToBalanceHistory(t *testing.T) { + bi := func(v int64) *big.Int { return big.NewInt(v) } + tests := []struct { + name string + txData *bchain.EthereumTxData + initialSent int64 + wantSent string + }{ + { + name: "L2 execution fee plus l1Fee is added to sent", + txData: &bchain.EthereumTxData{GasUsed: bi(21000), GasPrice: bi(1200), EffectiveGasPrice: bi(10), L1Fee: bi(5000)}, + wantSent: "215000", // 21000 * 10 + 5000 + }, + { + name: "fee accumulates onto an existing sent amount", + txData: &bchain.EthereumTxData{GasUsed: bi(21000), GasPrice: bi(1200), EffectiveGasPrice: bi(10)}, + initialSent: 1000, + wantSent: "211000", // 1000 + 21000 * 10 + }, + { + name: "mempool tx contributes no fee", + txData: &bchain.EthereumTxData{GasPrice: bi(1200), EffectiveGasPrice: bi(10)}, + wantSent: "0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bh := &BalanceHistory{SentSat: (*Amount)(big.NewInt(tt.initialSent))} + addEthereumFeesToBalanceHistory(tt.txData, bh) + if got := (*big.Int)(bh.SentSat).String(); got != tt.wantSent { + t.Errorf("SentSat = %s, want %s", got, tt.wantSent) + } + }) + } +} + +// BenchmarkTxInputResolution measures GetTransactionFromBchainTx as a function of +// a transaction's input fan-in, using the plain Bitcoin-testnet parser. Each input's +// previous output is resolved from the database, so this tracks how per-transaction +// allocation and time scale with the input count. The fan-in values span a wide range +// to make that scaling visible and to guard against regressions in input resolution. +// +// go test -tags unittest -run x -bench TxInputResolution -benchmem ./api/ +func BenchmarkTxInputResolution(b *testing.B) { + for _, fanIn := range []int{8, 32, 128, 512, 2048} { + b.Run("fanIn="+strconv.Itoa(fanIn), func(b *testing.B) { + worker, subjectTx := buildHighFanInWorker(b, fanIn) + addresses := make(map[string]struct{}) + + tx, err := worker.GetTransactionFromBchainTx(subjectTx, 2, false, false, addresses) + require.NoError(b, err) + require.Len(b, tx.Vin, fanIn) + require.True(b, tx.Vin[fanIn-1].IsAddress && tx.Vin[fanIn-1].ValueSat != nil) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := worker.GetTransactionFromBchainTx(subjectTx, 2, false, false, addresses); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// newIndexedWorkerDB indexes the blocks into a RocksDB in a test temp directory and +// returns the database with its loaded internal state, both closed via tb.Cleanup. +func newIndexedWorkerDB(tb testing.TB, parser bchain.BlockChainParser, blocks []*bchain.Block) (*db.RocksDB, *common.InternalState) { + tb.Helper() + database, err := db.NewRocksDB(tb.TempDir(), 100000, -1, parser, nil, false) + require.NoError(tb, err) + tb.Cleanup(func() { require.NoError(tb, database.Close()) }) + + is, err := database.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + require.NoError(tb, err) + database.SetInternalState(is) + + bulk, err := database.InitBulkConnect() + require.NoError(tb, err) + for i, block := range blocks { + require.NoError(tb, bulk.ConnectBlock(block, i == len(blocks)-1)) + } + require.NoError(tb, bulk.Close()) + var syncHeight uint32 + if len(blocks) > 0 { + syncHeight = blocks[len(blocks)-1].Height + } + is.FinishedSync(syncHeight) + return database, is +} + +// buildHighFanInWorker indexes a previous transaction with fanIn outputs and a +// subject transaction whose fanIn inputs each spend one of them (all referencing the +// same previous tx, concentrating resolution on a single large record), and returns +// a Worker plus the subject tx. Only db + parser are needed: GetTransactionFromBchainTx +// for a confirmed Bitcoin-type tx (specificJSON=false) does not touch the chain. +func buildHighFanInWorker(tb testing.TB, fanIn int) (*Worker, *bchain.Tx) { + tb.Helper() + parser := btc.NewBitcoinParser(btc.GetChainParams("test"), &btc.Configuration{BlockAddressesToKeep: 1}) + script := dbtestdata.AddressToPubKeyHex(dbtestdata.Addr1, parser) + + prevTxid := fanInHash(1) + prevOuts := make([]bchain.Vout, fanIn) + subjectIns := make([]bchain.Vin, fanIn) + for j := 0; j < fanIn; j++ { + prevOuts[j] = fanInVout(uint32(j), script) + subjectIns[j] = bchain.Vin{Txid: prevTxid, Vout: uint32(j), Sequence: 0xffffffff} + } + prevTx := fanInTx(prevTxid, 1, []bchain.Vin{{Coinbase: "01", Sequence: 0xffffffff}}, prevOuts) + subjectTx := fanInTx(fanInHash(2), 2, subjectIns, []bchain.Vout{fanInVout(0, script), fanInVout(1, script)}) + + blocks := []*bchain.Block{ + {BlockHeader: bchain.BlockHeader{Hash: fanInHash(1_000_001), Height: 1, Time: 1_700_000_001}, Txs: []bchain.Tx{*prevTx}}, + {BlockHeader: bchain.BlockHeader{Hash: fanInHash(1_000_002), Height: 2, Time: 1_700_000_002}, Txs: []bchain.Tx{*subjectTx}}, + } + + database, is := newIndexedWorkerDB(tb, parser, blocks) + return &Worker{db: database, chainParser: parser, chainType: bchain.ChainBitcoinType, is: is}, subjectTx +} + +func fanInTx(txid string, height uint32, vin []bchain.Vin, vout []bchain.Vout) *bchain.Tx { + return &bchain.Tx{ + Txid: txid, + Version: 1, + Vin: vin, + Vout: vout, + BlockHeight: height, + Confirmations: 1, + Time: int64(1_700_000_000 + height), + Blocktime: int64(1_700_000_000 + height), + } +} + +func fanInVout(n uint32, scriptHex string) bchain.Vout { + return bchain.Vout{ + ValueSat: *big.NewInt(100000), + N: n, + ScriptPubKey: bchain.ScriptPubKey{Hex: scriptHex, Addresses: []string{dbtestdata.Addr1}}, + } +} + +func fanInHash(n uint64) string { return fmt.Sprintf("%064x", n) } + +// newRefreshTestMetrics returns a fresh Metrics for one test, registered on a private +// registry swapped in for the duration. This keeps each test from sharing gauge state with +// the others (which made them order-dependent under -shuffle) and from registering ~60 +// Blockbook collectors on the process-wide prometheus.DefaultRegisterer. +// +// The swap is of package-level globals, so a caller must not run under t.Parallel() - no +// test in this package does. common.GetMetrics takes no registry, which is why the globals +// are the seam; give it one if a parallel caller ever needs this. +func newRefreshTestMetrics(t *testing.T) *common.Metrics { + t.Helper() + oldReg, oldGath := prometheus.DefaultRegisterer, prometheus.DefaultGatherer + registry := prometheus.NewRegistry() + prometheus.DefaultRegisterer = registry + prometheus.DefaultGatherer = registry + t.Cleanup(func() { + prometheus.DefaultRegisterer = oldReg + prometheus.DefaultGatherer = oldGath + }) + m, err := common.GetMetrics("api_refresh_sync_metrics_test") + if err != nil { + t.Fatalf("GetMetrics: %v", err) + } + return m +} + +func gaugeValue(t *testing.T, g prometheus.Gauge) float64 { + t.Helper() + var m dto.Metric + if err := g.Write(&m); err != nil { + t.Fatalf("gauge Write: %v", err) + } + return m.GetGauge().GetValue() +} + +// TestRefreshSyncMetrics covers the gauges an alert on blockbook's sync state reads. They +// used to be written only by the ~15-minute app-info loop (which needs three backend +// RPCs), so a stall was invisible for up to 16 minutes; RefreshSyncMetrics publishes them +// from internal state alone and must agree with what /api/ reports. +func TestRefreshSyncMetrics(t *testing.T) { + m := newRefreshTestMetrics(t) + + is := &common.InternalState{} + is.SetBackendInfo(&common.BackendInfo{Blocks: 900}) + is.FinishedSync(900) + + // Bitcoin-type: inSync is the raw flag, so a synced state publishes 1. + RefreshSyncMetrics(is, nil, m) + if got := gaugeValue(t, m.Synchronized); got != 1 { + t.Errorf("synchronized = %v, want 1", got) + } + if got := gaugeValue(t, m.BlockbookBestHeight); got != 900 { + t.Errorf("best_height = %v, want 900", got) + } + if got := gaugeValue(t, m.BackendBestHeight); got != 900 { + t.Errorf("backend_best_height = %v, want 900", got) + } + if got := gaugeValue(t, m.InitialSync); got != 0 { + t.Errorf("initial_sync = %v, want 0", got) + } + + // A stalled index: the flag is cleared while the backend keeps advancing. This is the + // 2026-07-30 Tron shape, and the gauge has to read 0 for an alert to see it. StartSync + // is pushed back past systemInfoSyncStartGrace, which otherwise reports the first few + // seconds of any sync as still-synced to avoid flapping. + is.StartedSync() + is.StartSync = time.Now().UTC().Add(-time.Minute) + is.SetBackendInfo(&common.BackendInfo{Blocks: 1000}) + RefreshSyncMetrics(is, nil, m) + if got := gaugeValue(t, m.Synchronized); got != 0 { + t.Errorf("synchronized = %v, want 0 while the index is behind", got) + } + if got := gaugeValue(t, m.BackendBestHeight); got != 1000 { + t.Errorf("backend_best_height = %v, want 1000", got) + } + + // A reindex must be distinguishable from that stall, or the alert has to be silenced. + is.InitialSync = true + RefreshSyncMetrics(is, nil, m) + if got := gaugeValue(t, m.InitialSync); got != 1 { + t.Errorf("initial_sync = %v, want 1 during a reindex", got) + } +} + +// TestRefreshSyncMetricsSkipsUnobservedBackend guards the one case where publishing the +// cached backend height would be wrong: before the first successful GetChainInfo it is 0, +// and a 0 reads as "the backend produced no blocks" to the backend-stuck rules. +func TestRefreshSyncMetricsSkipsUnobservedBackend(t *testing.T) { + m := newRefreshTestMetrics(t) + m.BackendBestHeight.Set(12345) + + RefreshSyncMetrics(&common.InternalState{}, nil, m) + if got := gaugeValue(t, m.BackendBestHeight); got != 12345 { + t.Errorf("backend_best_height = %v, want the previous 12345 left untouched", got) + } +} + +// TestRefreshSyncMetricsKeepsLastGoodOnBackendError checks that a cached BackendError does +// NOT force the gauge to 0. bi is only refreshed at the end of a resync iteration, so a +// force would stay 0 for up to a full resync period after the backend recovered and would +// disagree with /api, which re-queries live. The last known good backend height is still +// published, so the backend-stuck rules see a flat series rather than a drop to 0. +func TestRefreshSyncMetricsKeepsLastGoodOnBackendError(t *testing.T) { + m := newRefreshTestMetrics(t) + + is := &common.InternalState{} + is.SetBackendInfo(&common.BackendInfo{Blocks: 700}) + is.FinishedSync(700) + RefreshSyncMetrics(is, nil, m) + if got := gaugeValue(t, m.Synchronized); got != 1 { + t.Fatalf("synchronized = %v, want 1 before the backend fails", got) + } + + is.SetBackendInfo(&common.BackendInfo{BackendError: "GetChainInfo: connection refused"}) + RefreshSyncMetrics(is, nil, m) + if got := gaugeValue(t, m.Synchronized); got != 1 { + t.Errorf("synchronized = %v, want it to hold the last computed value, not be forced to 0 by a cached error", got) + } + if got := gaugeValue(t, m.BackendBestHeight); got != 700 { + t.Errorf("backend_best_height = %v, want the retained 700", got) + } +} + +// Suite hides its EVM cancel/speed-up actions when a pending transaction is not rbf, so the +// flag must stay true for EVM regardless of the (unset) input sequence. +func TestIsReplaceableInput(t *testing.T) { + tests := []struct { + name string + chainType bchain.ChainType + sequence uint32 + want bool + }{ + {name: "evm with zero sequence", chainType: bchain.ChainEthereumType, sequence: 0, want: true}, + {name: "evm with final sequence", chainType: bchain.ChainEthereumType, sequence: 0xffffffff, want: true}, + {name: "utxo opted in per BIP125", chainType: bchain.ChainBitcoinType, sequence: 0xfffffffd, want: true}, + {name: "utxo final sequence", chainType: bchain.ChainBitcoinType, sequence: 0xffffffff, want: false}, + {name: "utxo max minus one is not opted in", chainType: bchain.ChainBitcoinType, sequence: 0xfffffffe, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{chainType: tt.chainType} + if got := w.isReplaceableInput(tt.sequence); got != tt.want { + t.Errorf("isReplaceableInput(%#x) = %v, want %v", tt.sequence, got, tt.want) + } + }) + } +} diff --git a/api/worker_token_transfers_test.go b/api/worker_token_transfers_test.go new file mode 100644 index 0000000000..458f3d266b --- /dev/null +++ b/api/worker_token_transfers_test.go @@ -0,0 +1,53 @@ +//go:build unittest + +package api + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// A pending contract creation has no "to", so the calldata parser emits a transfer whose contract is +// "0x"; its metadata lookup fails. The transfer must still be returned with its addresses and value +// instead of a zero-valued placeholder (trezor/blockbook#1715). +func TestGetEthereumTokensTransfers_KeepsTransferWhenContractInfoFails(t *testing.T) { + w, _, parser := setupContractProbeWorker(t, newContractProbeChain(t)) + var one, two big.Int + one.SetInt64(1) + two.SetInt64(2) + transfers := bchain.TokenTransfers{ + {Standard: bchain.FungibleToken, Contract: "0x", From: "0x" + dbtestdata.EthAddr3e, To: "0x" + dbtestdata.EthAddr55, Value: one}, + {Standard: bchain.FungibleToken, Contract: "0x" + dbtestdata.EthAddrContract4a, From: "0x" + dbtestdata.EthAddr55, To: "0x" + dbtestdata.EthAddr3e, Value: two}, + } + addresses := map[string]struct{}{} + + tokens := w.getEthereumTokensTransfers(transfers, addresses) + + require.Len(t, tokens, 2) + byContract := map[string]TokenTransfer{} + for _, tt := range tokens { + byContract[tt.Contract] = tt + } + broken, ok := byContract["0x"] + require.True(t, ok, "the transfer with unreadable contract metadata must not be dropped") + require.Equal(t, bchain.ERC20TokenStandard, broken.Standard) + require.Equal(t, "0x"+dbtestdata.EthAddr3e, broken.From) + require.Equal(t, "0x"+dbtestdata.EthAddr55, broken.To) + require.Equal(t, "1", broken.Value.String()) + require.Equal(t, parser.AmountDecimals(), broken.Decimals) + require.Empty(t, broken.Name) + require.Empty(t, broken.Symbol) + + known := byContract["0x"+dbtestdata.EthAddrContract4a] + require.Equal(t, "Contract 74", known.Name) + require.Equal(t, "S74", known.Symbol) + require.Equal(t, 12, known.Decimals) + require.Equal(t, "2", known.Value.String()) + + require.Contains(t, addresses, "0x"+dbtestdata.EthAddr3e) + require.Contains(t, addresses, "0x"+dbtestdata.EthAddr55) +} diff --git a/api/xpub.go b/api/xpub.go index da6df497f7..aed6068587 100644 --- a/api/xpub.go +++ b/api/xpub.go @@ -15,15 +15,66 @@ import ( "github.com/trezor/blockbook/db" ) -const defaultAddressesGap = 20 -const maxAddressesGap = 10000 - const txInput = 1 const txOutput = 2 -const xpubCacheExpirationSeconds = 3600 -const xpubCacheMaxEntries = 128 -const maxXpubAddressDerivations = (maxAddressesGap + 1) * 2 +// XpubConfig is the resolved xpub configuration for a single Worker instance. +type XpubConfig struct { + MaxCacheExpirationSeconds int + MaxCacheEntries int + MaxCacheBytes int + DefaultAddressesGap int + MaxAddressesGap int + MaxAddressDerivations int // computed: (MaxAddressesGap + 1) * 2 +} + +// DefaultXpubConfig returns the built-in defaults for xpub expansion and +// caching. Operators can override these per-coin via +// `additional_params.xpubConfig` in `configs/coins/*.json`. +func DefaultXpubConfig() XpubConfig { + return finalizeXpubConfig(XpubConfig{ + MaxCacheExpirationSeconds: 3600, + MaxCacheEntries: 5000, + MaxCacheBytes: 1 << 30, + DefaultAddressesGap: 20, + MaxAddressesGap: 10000, + }) +} + +// finalizeXpubConfig computes MaxAddressDerivations from MaxAddressesGap and clamps DefaultAddressesGap to not exceed +// MaxAddressesGap; every path producing an XpubConfig must run through it. +func finalizeXpubConfig(cfg XpubConfig) XpubConfig { + if cfg.DefaultAddressesGap > cfg.MaxAddressesGap { + cfg.DefaultAddressesGap = cfg.MaxAddressesGap + } + cfg.MaxAddressDerivations = (cfg.MaxAddressesGap + 1) * 2 + return cfg +} + +// ApplyXpubConfig overlays the optional per-coin override onto the built-in +// defaults. Zero / missing wire fields keep the default; explicitly set but +// invalid values (negative) keep the default and log a warning. +func ApplyXpubConfig(o *bchain.XpubConfig) XpubConfig { + cfg := DefaultXpubConfig() + if o == nil { + return cfg + } + apply := func(field string, v int, set func(int)) { + if v <= 0 { + if v < 0 { + glog.Warningf("xpub: xpubConfig.%s=%d is invalid, keeping default", field, v) + } + return + } + set(v) + } + apply("maxCacheExpirationSeconds", o.MaxCacheExpirationSeconds, func(v int) { cfg.MaxCacheExpirationSeconds = v }) + apply("maxCacheEntries", o.MaxCacheEntries, func(v int) { cfg.MaxCacheEntries = v }) + apply("maxCacheBytes", o.MaxCacheBytes, func(v int) { cfg.MaxCacheBytes = v }) + apply("defaultAddressesGap", o.DefaultAddressesGap, func(v int) { cfg.DefaultAddressesGap = v }) + apply("maxAddressesGap", o.MaxAddressesGap, func(v int) { cfg.MaxAddressesGap = v }) + return finalizeXpubConfig(cfg) +} var cachedXpubs map[string]xpubData var cachedXpubsMux sync.Mutex @@ -71,6 +122,62 @@ type xpubData struct { balanceSat big.Int mergedTxids xpubTxids addresses [][]xpubAddress + // bytes is the estimate charged to cachedXpubsBytes for this entry, kept so + // eviction can credit exactly what insertion debited + bytes int +} + +var cachedXpubsBytes int64 + +// Per-item heap costs of an xpubData entry, calibrated against runtime.MemStats +// on synthetic accounts (within ~7% from an empty account up to 25k txids). +const ( + xpubCacheEntryOverheadBytes = 256 + xpubCacheAddressBytes = 96 + xpubCacheBalanceBytes = 144 + xpubCacheUtxoBytes = 112 + xpubCacheTxidBytes = 96 + xpubCacheMergedTxidBytes = 24 +) + +// xpubDataEstimatedBytes approximates the heap held by one cache entry; txids +// dominate because every one is a separate 64-byte string. +func xpubDataEstimatedBytes(data *xpubData, keyLen int) int { + size := xpubCacheEntryOverheadBytes + keyLen + len(data.mergedTxids)*xpubCacheMergedTxidBytes + for _, da := range data.addresses { + size += len(da) * xpubCacheAddressBytes + for i := range da { + ad := &da[i] + size += len(ad.txids) * xpubCacheTxidBytes + if ad.balance != nil { + size += xpubCacheBalanceBytes + len(ad.balance.Utxos)*xpubCacheUtxoBytes + } + } + } + return size +} + +// deleteCachedXpubLocked removes an entry and credits its bytes; the only way +// entries may leave the map so the byte total cannot drift. +func deleteCachedXpubLocked(key string) { + if data, ok := cachedXpubs[key]; ok { + cachedXpubsBytes -= int64(data.bytes) + delete(cachedXpubs, key) + } +} + +// putCachedXpubLocked is the only way entries enter the map; it charges the +// estimate that deleteCachedXpubLocked later credits. +func putCachedXpubLocked(key string, data *xpubData) { + deleteCachedXpubLocked(key) + data.bytes = xpubDataEstimatedBytes(data, len(key)) + cachedXpubs[key] = *data + cachedXpubsBytes += int64(data.bytes) +} + +func (w *Worker) setXpubCacheMetricsLocked() { + w.metrics.XPubCacheSize.Set(float64(len(cachedXpubs))) + w.metrics.XPubCacheBytes.Set(float64(cachedXpubsBytes)) } func (w *Worker) initXpubCache() { @@ -90,58 +197,79 @@ func (w *Worker) initXpubCache() { func (w *Worker) evictXpubCacheItems() { now := time.Now().Unix() cachedXpubsMux.Lock() - count := evictXpubCacheItemsLocked(now) + count := evictXpubCacheItemsLocked(now, int64(w.xpubConfig.MaxCacheExpirationSeconds), w.xpubConfig.MaxCacheEntries, int64(w.xpubConfig.MaxCacheBytes)) cacheSize := len(cachedXpubs) + cacheBytes := cachedXpubsBytes + w.setXpubCacheMetricsLocked() cachedXpubsMux.Unlock() - w.metrics.XPubCacheSize.Set(float64(cacheSize)) - glog.Info("Evicted ", count, " items from xpub cache, cache size ", cacheSize) + glog.Info("Evicted ", count, " items from xpub cache, cache size ", cacheSize, ", ~", cacheBytes>>20, " MB") } -func evictXpubCacheItemsLocked(now int64) int { - threshold := now - xpubCacheExpirationSeconds +func evictXpubCacheItemsLocked(now int64, expirationSeconds int64, maxEntries int, maxBytes int64) int { + threshold := now - expirationSeconds count := 0 for k, v := range cachedXpubs { if v.accessed < threshold { - delete(cachedXpubs, k) + deleteCachedXpubLocked(k) count++ } } - return count + trimXpubCacheItemsLocked() + return count + trimXpubCacheItemsLocked(maxEntries, maxBytes) } -func trimXpubCacheItemsLocked() int { - if len(cachedXpubs) <= xpubCacheMaxEntries { +// trimXpubCacheItemsLocked evicts the least recently accessed entries until +// both caps hold; the byte cap bounds memory, since one entry's txid history is +// unbounded. An entry larger than maxBytes evicts itself too. +func trimXpubCacheItemsLocked(maxEntries int, maxBytes int64) int { + over := func() bool { + return len(cachedXpubs) > maxEntries || (len(cachedXpubs) > 0 && cachedXpubsBytes > maxBytes) + } + if !over() { return 0 } - type cacheEntry struct { + // an insert usually overshoots by one entry, where a linear scan beats sorting + type keyAccess struct { key string accessed int64 } - entries := make([]cacheEntry, 0, len(cachedXpubs)) + var oldest keyAccess + found := false 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 + if !found || v.accessed < oldest.accessed || (v.accessed == oldest.accessed && k < oldest.key) { + oldest, found = keyAccess{k, v.accessed}, true } - return entries[i].accessed < entries[j].accessed + } + deleteCachedXpubLocked(oldest.key) + count := 1 + if !over() { + return count + } + // a large entry can displace thousands; sort once instead of rescanning per eviction + lru := make([]keyAccess, 0, len(cachedXpubs)) + for k, v := range cachedXpubs { + lru = append(lru, keyAccess{k, v.accessed}) + } + sort.Slice(lru, func(i, j int) bool { + return lru[i].accessed < lru[j].accessed || (lru[i].accessed == lru[j].accessed && lru[i].key < lru[j].key) }) - count := len(cachedXpubs) - xpubCacheMaxEntries - for i := 0; i < count; i++ { - delete(cachedXpubs, entries[i].key) + for _, e := range lru { + if !over() { + break + } + deleteCachedXpubLocked(e.key) + count++ } return count } -func validateXpubScanLimits(xd *bchain.XpubDescriptor, gap int) error { +func validateXpubScanLimits(xd *bchain.XpubDescriptor, gap int, maxDerivations 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) + if derivations > maxDerivations { + return errors.Errorf("Xpub descriptor scan size %d exceeds limit %d", derivations, maxDerivations) } return nil } @@ -272,9 +400,9 @@ func (w *Worker) xpubDerivedAddressBalance(data *xpubData, ad *xpubAddress) (boo return false, nil } -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) +func (w *Worker) xpubScanAddresses(xd *bchain.XpubDescriptor, data *xpubData, addresses []xpubAddress, gap int, change uint32, minDerivedIndex int, fork bool, derivedBefore int, maxDerivations int) (int, []xpubAddress, error) { + if total := derivedBefore + len(addresses); total > maxDerivations { + return 0, nil, errors.Errorf("Xpub descriptor scan size %d exceeds limit %d", total, maxDerivations) } // rescan known addresses lastUsed := 0 @@ -303,8 +431,8 @@ 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) + if total := derivedBefore + to; total > maxDerivations { + return 0, nil, errors.Errorf("Xpub descriptor scan size %d exceeds limit %d", total, maxDerivations) } descriptors, err := w.chainParser.DeriveAddressDescriptorsFromTo(xd, change, uint32(from), uint32(to)) if err != nil { @@ -410,6 +538,39 @@ func setIsOwnAddresses(txs []*Tx, xpubAddresses map[string]struct{}) { } } +// getXpubDataCached returns the published snapshot without taking the +// per-descriptor lock when the request cannot mutate it: same tip, same gap, +// nothing to load. Keeps warm reads of a hot descriptor parallel. +func (w *Worker) getXpubDataCached(xd *bchain.XpubDescriptor, option AccountDetails, filter *AddressFilter, gap int) (*xpubData, uint32, bool) { + cachedXpubsMux.Lock() + data, inCache := cachedXpubs[xd.XpubDescriptor] + cachedXpubsMux.Unlock() + if !inCache || data.gap != gap { + return nil, 0, false + } + bestheight, besthash, err := w.db.GetBestBlock() + if err != nil || besthash != data.dataHash { + return nil, 0, false + } + if option >= AccountDetailsTxidHistory { + if xpubTxidLoadNeeded(&data) { + return nil, 0, false + } + if data.mergedTxids == nil && isUnfilteredXpubTxidFilter(filter) { + return nil, 0, false + } + } + // bump accessed so the evictor does not drop a hot entry served fast-path only + now := time.Now().Unix() + cachedXpubsMux.Lock() + if current, ok := cachedXpubs[xd.XpubDescriptor]; ok { + current.accessed = now + cachedXpubs[xd.XpubDescriptor] = current + } + cachedXpubsMux.Unlock() + return &data, bestheight, true +} + func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, option AccountDetails, filter *AddressFilter, gap int) (*xpubData, uint32, bool, error) { if w.chainType != bchain.ChainBitcoinType { return nil, 0, false, ErrUnsupportedXpub @@ -420,20 +581,38 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, besthash string ) if gap <= 0 { - gap = defaultAddressesGap - } else if gap > maxAddressesGap { + gap = w.xpubConfig.DefaultAddressesGap + } else if gap > w.xpubConfig.MaxAddressesGap { // limit the maximum gap to protect against unreasonably big values that could cause high load of the server - gap = maxAddressesGap + gap = w.xpubConfig.MaxAddressesGap } // 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 { + if err := validateXpubScanLimits(xd, gap, w.xpubConfig.MaxAddressDerivations); err != nil { return nil, 0, false, err } + if data, bestheight, ok := w.getXpubDataCached(xd, option, filter, gap); ok { + w.metrics.XPubCacheHits.Inc() + return data, bestheight, true, nil + } + // Serialize updates for this descriptor; the expensive per-tx response + // building in the caller runs after this returns and stays concurrent. + mu := xpubUpdateLock(xd.XpubDescriptor) + mu.Lock() + defer mu.Unlock() var processedHash string cachedXpubsMux.Lock() data, inCache := cachedXpubs[xd.XpubDescriptor] cachedXpubsMux.Unlock() + owned := !inCache // whether data.addresses is a private copy safe to mutate + // absent = derived from scratch, gap = cached under another gap and re-derived, + // stale = every address rescanned after a new block, history = txids loaded from the db + missReason := "" + if !inCache { + missReason = "absent" + } else if data.gap != gap { + missReason = "gap" + } // to load all data for xpub may take some time, do it in a loop to process a possible new block for { bestheight, besthash, err = w.db.GetBestBlock() @@ -453,6 +632,7 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, if err != nil { return nil, 0, inCache, err } + owned = true } else { hash, err := w.db.GetBlockHash(data.dataHeight) if err != nil { @@ -464,7 +644,17 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, } } processedHash = besthash - if data.dataHeight < bestheight || fork { + needsRescan := data.dataHeight < bestheight || fork + // Copy-on-write before any mutation so a concurrent reader holding the + // previously published snapshot never has its arrays changed underneath. + if !owned && (needsRescan || (option >= AccountDetailsTxidHistory && xpubTxidLoadNeeded(&data))) { + data.addresses = cloneXpubAddresses(data.addresses) + owned = true + } + if needsRescan { + if missReason == "" { + missReason = "stale" + } data.dataHeight = bestheight data.dataHash = besthash data.balanceSat = *new(big.Int) @@ -474,7 +664,7 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, 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, totalDerived) + minDerivedIndex, data.addresses[i], err = w.xpubScanAddresses(xd, &data, data.addresses[i], gap, change, minDerivedIndex, fork, totalDerived, w.xpubConfig.MaxAddressDerivations) if err != nil { return nil, 0, inCache, err } @@ -494,6 +684,9 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, } if txidsChanged { data.mergedTxids = nil + if missReason == "" { + missReason = "history" + } } if isUnfilteredXpubTxidFilter(filter) && data.mergedTxids == nil { data.mergedTxids = mergeXpubTxids(&data) @@ -505,14 +698,28 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, if cachedXpubs == nil { cachedXpubs = make(map[string]xpubData) } - cachedXpubs[xd.XpubDescriptor] = data - trimXpubCacheItemsLocked() - cacheSize := len(cachedXpubs) + putCachedXpubLocked(xd.XpubDescriptor, &data) + trimXpubCacheItemsLocked(w.xpubConfig.MaxCacheEntries, int64(w.xpubConfig.MaxCacheBytes)) + w.setXpubCacheMetricsLocked() cachedXpubsMux.Unlock() - w.metrics.XPubCacheSize.Set(float64(cacheSize)) + if missReason != "" { + w.metrics.XPubCacheMisses.With(common.Labels{"reason": missReason}).Inc() + } else { + w.metrics.XPubCacheHits.Inc() + } return &data, bestheight, inCache, nil } +// xpubParseError classifies a rejected xpub for GetXpubAddress: a malformed key is caller input +// and must surface as a public 400, not as a logged 500. Only GetXpubAddress uses it; the utxo and +// balance-history callers rely on a non-public error to fall back to the plain-address path. +func (w *Worker) xpubParseError(err error) error { + if w.chainType != bchain.ChainBitcoinType { + return ErrUnsupportedXpub + } + return NewAPIError(fmt.Sprintf("Invalid xpub, %v", err), true) +} + // 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, secondaryCoin string) (*Address, error) { start := time.Now() @@ -534,13 +741,14 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc txs []*Tx txids []string pg Paging + mempoolEntries bchain.MempoolTxidEntries filtered bool uBalSat big.Int unconfirmedTxs int ) xd, err := w.chainParser.ParseXpub(xpub) if err != nil { - return nil, err + return nil, w.xpubParseError(err) } data, bestheight, inCache, err := w.getXpubData(xd, page, txsOnPage, option, filter, gap) if err != nil { @@ -598,13 +806,12 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc } addresses := w.newAddressesMapForAliases() xpubAssetMempool := make(map[string]map[string]*syscoinTokenMempoolInfo) // SYSCOIN - mempoolEntryCount := 0 // 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) + mempoolEntries = make(bchain.MempoolTxidEntries, 0) for _, da := range data.addresses { for i := range da { ad := &da[i] @@ -660,16 +867,13 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc tx.addAddrVoutAssetMempool(ad.addrDesc, assetMempool) tx.addAddrVinAssetMempool(ad.addrDesc, assetMempool) } - // mempool txs are returned only on the first page, uniquely and filtered + // Mempool txs enter the paged sequence once, uniquely and filtered. if xpubMempoolTxidFilter(&txid) { if _, added := addedMempoolEntries[txid.txid]; added { continue } addedMempoolEntries[txid.txid] = struct{}{} - mempoolEntryCount++ // SYSCOIN - if page == 0 { - mempoolEntries = append(mempoolEntries, bchain.MempoolTxidEntry{Txid: txid.txid, Time: uint32(tx.Blocktime)}) - } + mempoolEntries = append(mempoolEntries, bchain.MempoolTxidEntry{Txid: txid.txid, Time: uint32(tx.Blocktime)}) } } } @@ -677,19 +881,6 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc } // sort the entries by time descending sort.Sort(mempoolEntries) - mempoolEntriesToReturn := mempoolEntries - if option == AccountDetailsTxHistorySummary && txsOnPage > 0 && len(mempoolEntriesToReturn) > txsOnPage { - mempoolEntriesToReturn = mempoolEntriesToReturn[:txsOnPage] // SYSCOIN - } - for _, entry := range mempoolEntriesToReturn { - if option == AccountDetailsTxidHistory { - txids = append(txids, entry.Txid) - } else if option == AccountDetailsTxHistorySummary { - txs = append(txs, w.summarizeTxForAccount(txmMap[entry.Txid], accountAddrDescs)) // SYSCOIN - } else if option >= AccountDetailsTxHistoryLight { - txs = append(txs, txmMap[entry.Txid]) - } - } } if option >= AccountDetailsTxidHistory { if txidFilter == nil { @@ -721,26 +912,20 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc sort.Stable(txc) txCount = len(txcMap) } - totalResults := txCount - if filtered { - totalResults = -1 - } - var from, to int - if option == AccountDetailsTxHistorySummary { - pg, from, to, page = computePagingWithFirstPageMempool(len(txc), mempoolEntryCount, page, txsOnPage) // SYSCOIN - } else { - pg, from, to, page = computePaging(len(txc), page, txsOnPage) + var from, to, mempoolFrom, mempoolTo int + pg, mempoolFrom, mempoolTo, from, to, page = computeAccountPaging(len(mempoolEntries), len(txc), page, txsOnPage) + // filtered txc is not the whole history, so an exact TotalPages cannot be computed + if filtered && len(txc) >= txsOnPage { + pg.TotalPages = -1 } - if len(txc) >= txsOnPage { - if totalResults < 0 { - pg.TotalPages = -1 - } else { - // SYSCOIN - if option == AccountDetailsTxHistorySummary { - pg, _, _, _ = computePagingWithFirstPageMempool(totalResults, mempoolEntryCount, page, txsOnPage) - } else { - pg, _, _, _ = computePaging(totalResults, page, txsOnPage) - } + // mempool txs take the first slots of the paged sequence, before confirmed history + for _, entry := range mempoolEntries[mempoolFrom:mempoolTo] { + if option == AccountDetailsTxidHistory { + txids = append(txids, entry.Txid) + } else if option == AccountDetailsTxHistorySummary { + txs = append(txs, w.summarizeTxForAccount(txmMap[entry.Txid], accountAddrDescs)) // SYSCOIN + } else if option >= AccountDetailsTxHistoryLight { + txs = append(txs, txmMap[entry.Txid]) } } // get confirmed transactions @@ -879,7 +1064,8 @@ func (w *Worker) GetXpubUtxo(xpub string, onlyConfirmed bool, gap int) (Utxos, e return nil, err } if len(utxos) > 0 { - t := w.tokenFromXpubAddress(data, ad, ci, i, AccountDetailsTokens) + // ci is the position in data.addresses, the derivation path needs the configured change index + t := w.tokenFromXpubAddress(data, ad, int(xd.ChangeIndexes[ci]), i, AccountDetailsTokens) for j := range utxos { a := &utxos[j] a.Address = t.Name @@ -900,6 +1086,11 @@ func (w *Worker) GetXpubUtxo(xpub string, onlyConfirmed bool, gap int) (Utxos, e // 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) { + var err error + currencies, err = normalizeFiatCurrencies(currencies) + if err != nil { + return nil, err + } bhs := make(BalanceHistories, 0) start := time.Now() fromUnix, fromHeight, toUnix, toHeight := w.balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp) @@ -991,10 +1182,7 @@ func (w *Worker) GetXpubBalanceHistory(xpub string, fromTimestamp, toTimestamp i 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 - } + w.setFiatRateToBalanceHistories(bha, currencies, "xpub") 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_cache.go b/api/xpub_cache.go new file mode 100644 index 0000000000..9e2a670f71 --- /dev/null +++ b/api/xpub_cache.go @@ -0,0 +1,45 @@ +package api + +import ( + "hash/fnv" + "sync" +) + +// Serialize getXpubData per descriptor so concurrent requests for the same xpub +// never mutate one shared cache entry; sharding keeps distinct xpubs parallel. +const xpubUpdateShards = 256 + +var xpubUpdateLocks [xpubUpdateShards]sync.Mutex + +func xpubUpdateLock(descriptor string) *sync.Mutex { + h := fnv.New32a() + h.Write([]byte(descriptor)) + return &xpubUpdateLocks[h.Sum32()%xpubUpdateShards] +} + +// xpubTxidLoadNeeded reports whether xpubCheckAndLoadTxids would mutate any +// address, i.e. some used address has incomplete or stale txids. +func xpubTxidLoadNeeded(data *xpubData) bool { + for _, da := range data.addresses { + for i := range da { + ad := &da[i] + if ad.balance != nil && (!ad.complete || ad.balance.Txs != ad.txs) { + return true + } + } + } + return false +} + +// Private copy of the address slices so the update phase leaves any previously +// published snapshot immutable for concurrent readers. Shallow is enough: each +// entry's txids/balance are only ever reassigned, never mutated in place. +func cloneXpubAddresses(src [][]xpubAddress) [][]xpubAddress { + dst := make([][]xpubAddress, len(src)) + for i, inner := range src { + cp := make([]xpubAddress, len(inner)) + copy(cp, inner) + dst[i] = cp + } + return dst +} diff --git a/api/xpub_race_test.go b/api/xpub_race_test.go new file mode 100644 index 0000000000..f04f37c71c --- /dev/null +++ b/api/xpub_race_test.go @@ -0,0 +1,163 @@ +//go:build unittest + +package api + +import ( + "runtime" + "sync" + "testing" + + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// setupXpubRaceWorker builds a Bitcoin-type worker backed by the shared test +// fixtures (two connected blocks) so getXpubData exercises the real cache path. +func setupXpubRaceWorker(t *testing.T) *Worker { + t.Helper() + 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 { + t.Fatal(err) + } + d, err := db.NewRocksDB(t.TempDir(), 100000, -1, parser, nil, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + is, err := d.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + if err != nil { + t.Fatal(err) + } + d.SetInternalState(is) + block1 := dbtestdata.GetTestBitcoinTypeBlock1(parser) + for i := uint32(0); i < block1.Height; i++ { + is.BlockTimes = append(is.BlockTimes, 0) + } + if err := d.ConnectBlock(block1); err != nil { + t.Fatal(err) + } + block2 := dbtestdata.GetTestBitcoinTypeBlock2(parser) + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + is.FinishedSync(block2.Height) + + metrics, err := common.GetMetrics("Fakecoin-" + t.Name()) // unique per test: collectors register globally + if err != nil { + t.Fatal(err) + } + mempool, err := chain.CreateMempool(chain) + if err != nil { + t.Fatal(err) + } + txCache, err := db.NewTxCache(d, chain, metrics, is, false) + if err != nil { + t.Fatal(err) + } + w, err := NewWorker(d, chain, mempool, txCache, metrics, is, nil) + if err != nil { + t.Fatal(err) + } + return w +} + +func resetXpubCache() { + cachedXpubsMux.Lock() + resetXpubCacheLocked() + cachedXpubsMux.Unlock() +} + +// resetXpubCacheLocked empties the package-global cache together with its byte +// total and returns a func restoring the previous contents; caller holds cachedXpubsMux. +func resetXpubCacheLocked() (restore func()) { + prevCache, prevBytes := cachedXpubs, cachedXpubsBytes + cachedXpubs, cachedXpubsBytes = make(map[string]xpubData), 0 + return func() { cachedXpubs, cachedXpubsBytes = prevCache, prevBytes } +} + +// TestGetXpubAddressConcurrent verifies that concurrent requests for the same +// descriptor do not race on the shared cache entry and all observe the same +// txid history. Run with -race: before the copy-on-write / per-descriptor lock +// fix, the concurrent first-time txid load reported write/write and read/write +// data races in getXpubData. +func TestGetXpubAddressConcurrent(t *testing.T) { + // the race only manifests with real parallelism; ensure it regardless of + // the runner's default GOMAXPROCS. + if runtime.GOMAXPROCS(0) < 4 { + defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(4)) + } + + w := setupXpubRaceWorker(t) + + const gap = 1000 + filter := &AddressFilter{Vout: AddressFilterVoutOff} + + // clean sequential baseline (fully populates then reads the cache) + resetXpubCache() + base, err := w.GetXpubAddress(dbtestdata.Xpub, 1, 25, AccountDetailsTxidHistory, filter, gap, "") + if err != nil { + t.Fatalf("baseline GetXpubAddress: %v", err) + } + if base.Txs == 0 || len(base.Txids) == 0 { + t.Fatalf("baseline has no txids (Txs=%d, txids=%d); fixture/derivation changed", base.Txs, len(base.Txids)) + } + + const ( + workers = 16 + rounds = 8 + ) + for r := 0; r < rounds; r++ { + // reset and warm only with a basic call: addresses get scanned but txids + // are not loaded, so the burst below all races to load them for the first + // time. + resetXpubCache() + if _, err := w.GetXpubAddress(dbtestdata.Xpub, 1, 25, AccountDetailsBasic, filter, gap, ""); err != nil { + t.Fatalf("round %d warmup: %v", r, err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + results := make([]*Address, workers) + errs := make([]error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + <-start + results[idx], errs[idx] = w.GetXpubAddress(dbtestdata.Xpub, 1, 25, AccountDetailsTxidHistory, filter, gap, "") + }(i) + } + close(start) + wg.Wait() + + for i := 0; i < workers; i++ { + if errs[i] != nil { + t.Fatalf("round %d worker %d: %v", r, i, errs[i]) + } + got := results[i] + if got.Txs != base.Txs { + t.Errorf("round %d worker %d: Txs=%d, want %d", r, i, got.Txs, base.Txs) + } + if len(got.Txids) != len(base.Txids) { + t.Errorf("round %d worker %d: %d txids, want %d", r, i, len(got.Txids), len(base.Txids)) + continue + } + for j := range got.Txids { + if got.Txids[j] != base.Txids[j] { + t.Errorf("round %d worker %d txid %d: %s, want %s", r, i, j, got.Txids[j], base.Txids[j]) + } + } + } + } +} diff --git a/api/xpub_test.go b/api/xpub_test.go index a0713df1b1..6930ef963b 100644 --- a/api/xpub_test.go +++ b/api/xpub_test.go @@ -4,46 +4,81 @@ package api import ( "fmt" + "math" "testing" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/tests/dbtestdata" ) +func TestDefaultXpubConfigDerivedFields(t *testing.T) { + cfg := DefaultXpubConfig() + if cfg.MaxAddressDerivations != (cfg.MaxAddressesGap+1)*2 { + t.Fatalf("DefaultXpubConfig().MaxAddressDerivations = %d, want %d", cfg.MaxAddressDerivations, (cfg.MaxAddressesGap+1)*2) + } + if cfg.MaxAddressDerivations == 0 { + t.Fatal("DefaultXpubConfig().MaxAddressDerivations must be non-zero, otherwise every xpub scan is rejected") + } + // The default config must accept a normal scan at the default gap; this is + // the value NewWorker hands to validateXpubScanLimits when a coin has no + // xpubConfig override. + if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: []uint32{0, 1}}, cfg.DefaultAddressesGap+1, cfg.MaxAddressDerivations); err != nil { + t.Fatalf("default config rejected a default-gap scan: %v", err) + } +} + +func TestApplyXpubConfigClampsAndDerives(t *testing.T) { + // A default gap larger than the max gap must be clamped down so it never + // exceeds the accepted maximum. + cfg := ApplyXpubConfig(&bchain.XpubConfig{DefaultAddressesGap: 50, MaxAddressesGap: 20}) + if cfg.DefaultAddressesGap > cfg.MaxAddressesGap { + t.Fatalf("DefaultAddressesGap = %d not clamped to MaxAddressesGap = %d", cfg.DefaultAddressesGap, cfg.MaxAddressesGap) + } + if cfg.MaxAddressDerivations != (cfg.MaxAddressesGap+1)*2 { + t.Fatalf("MaxAddressDerivations = %d, want %d", cfg.MaxAddressDerivations, (cfg.MaxAddressesGap+1)*2) + } + + // A nil override yields the finalized defaults. + if got, want := ApplyXpubConfig(nil), DefaultXpubConfig(); got != want { + t.Fatalf("ApplyXpubConfig(nil) = %+v, want %+v", got, want) + } +} + func TestValidateXpubScanLimits(t *testing.T) { - if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: []uint32{0, 1}}, maxAddressesGap+1); err != nil { + const testMaxDerivations = 20002 + if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: []uint32{0, 1}}, 10001, testMaxDerivations); 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 { + if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: changes}, 21, testMaxDerivations); 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 { + if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: changes}, 10001, testMaxDerivations); err == nil { t.Fatal("expected scan size above limit to fail") } } func TestTrimXpubCacheItemsLocked(t *testing.T) { + const testMaxEntries = 128 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++ { + defer resetXpubCacheLocked()() + for i := 0; i < testMaxEntries+2; i++ { cachedXpubs[fmt.Sprintf("xpub-%03d", i)] = xpubData{accessed: int64(i)} } - if got := trimXpubCacheItemsLocked(); got != 2 { + if got := trimXpubCacheItemsLocked(testMaxEntries, math.MaxInt64); 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 got := len(cachedXpubs); got != testMaxEntries { + t.Fatalf("cachedXpubs length = %d, want %d", got, testMaxEntries) } if _, ok := cachedXpubs["xpub-000"]; ok { t.Fatal("oldest cache entry was not evicted") @@ -97,3 +132,125 @@ func TestIsUnfilteredXpubTxidFilter(t *testing.T) { t.Fatal("height filter should not be treated as unfiltered") } } + +func TestXpubCacheBytesAccounting(t *testing.T) { + cachedXpubsMux.Lock() + defer cachedXpubsMux.Unlock() + defer resetXpubCacheLocked()() + + // an entry with two used addresses (one utxo, two txids each) and one gap address + used := xpubAddress{ + addrDesc: make(bchain.AddressDescriptor, 22), + balance: &db.AddrBalance{Txs: 2, Utxos: []db.Utxo{{BtxID: make([]byte, 32)}}}, + txids: xpubTxids{{txid: "a"}, {txid: "b"}}, + } + data := xpubData{addresses: [][]xpubAddress{{used, used}, {{addrDesc: make(bchain.AddressDescriptor, 22)}}}} + data.mergedTxids = mergeXpubTxids(&data) + + empty := xpubDataEstimatedBytes(&xpubData{}, 10) + got := xpubDataEstimatedBytes(&data, 10) + want := empty + 3*xpubCacheAddressBytes + 2*xpubCacheBalanceBytes + 2*xpubCacheUtxoBytes + 4*xpubCacheTxidBytes + len(data.mergedTxids)*xpubCacheMergedTxidBytes + if got != want { + t.Fatalf("xpubDataEstimatedBytes() = %d, want %d", got, want) + } + + insert := func(key string, accessed int64) { + d := data + d.accessed = accessed + putCachedXpubLocked(key, &d) + } + for i := 0; i < 4; i++ { + insert(fmt.Sprintf("xpub-%d", i), int64(i)) + } + // re-inserting an existing key must not double count it + insert("xpub-3", 10) + if cachedXpubsBytes != 4*int64(cachedXpubs["xpub-0"].bytes) { + t.Fatalf("cachedXpubsBytes = %d after 4 inserts and one replace, want %d", cachedXpubsBytes, 4*cachedXpubs["xpub-0"].bytes) + } + if trimXpubCacheItemsLocked(2, math.MaxInt64) != 2 || cachedXpubsBytes != 2*int64(cachedXpubs["xpub-2"].bytes) { + t.Fatalf("trim did not credit evicted entries: bytes=%d entries=%d", cachedXpubsBytes, len(cachedXpubs)) + } + if evictXpubCacheItemsLocked(1000, 1, 100, math.MaxInt64) != 2 || cachedXpubsBytes != 0 || len(cachedXpubs) != 0 { + t.Fatalf("expiry eviction left bytes=%d entries=%d, want both 0", cachedXpubsBytes, len(cachedXpubs)) + } +} + +func TestXpubCacheByteBudget(t *testing.T) { + cachedXpubsMux.Lock() + defer cachedXpubsMux.Unlock() + defer resetXpubCacheLocked()() + + small := xpubData{addresses: [][]xpubAddress{{{}}}} + for i := 0; i < 4; i++ { + d := small + d.accessed = int64(i) + putCachedXpubLocked(fmt.Sprintf("small-%d", i), &d) + } + smallBytes := int64(cachedXpubs["small-0"].bytes) + // a whale with a long txid history, worth ~2.5 small entries + whale := xpubData{accessed: 10, addresses: [][]xpubAddress{{{txids: make(xpubTxids, (int(smallBytes)*5/2)/xpubCacheTxidBytes)}}}} + putCachedXpubLocked("whale", &whale) + whaleBytes := int64(cachedXpubs["whale"].bytes) + + // entry cap alone would keep everything; the byte cap evicts the oldest small entries + budget := whaleBytes + 2*smallBytes + if n := trimXpubCacheItemsLocked(100, budget); n != 2 { + t.Fatalf("trim evicted %d entries, want 2", n) + } + if _, ok := cachedXpubs["small-1"]; ok { + t.Fatal("small-1 should have been evicted as least recently accessed") + } + if _, ok := cachedXpubs["whale"]; !ok || cachedXpubsBytes != budget { + t.Fatalf("after trim bytes=%d (want %d), whale cached=%v", cachedXpubsBytes, budget, ok) + } + // an entry larger than the whole budget evicts everything, itself included + if n := trimXpubCacheItemsLocked(100, whaleBytes-1); n != 3 || len(cachedXpubs) != 0 || cachedXpubsBytes != 0 { + t.Fatalf("oversized entry: evicted %d, left entries=%d bytes=%d", n, len(cachedXpubs), cachedXpubsBytes) + } +} + +func TestXpubCacheMissReasons(t *testing.T) { + w := setupXpubRaceWorker(t) + resetXpubCache() + defer resetXpubCache() + + filter := &AddressFilter{Vout: AddressFilterVoutOff} + count := func(c prometheus.Counter) float64 { + var m dto.Metric + if err := c.Write(&m); err != nil { + t.Fatal(err) + } + return m.GetCounter().GetValue() + } + misses := func(reason string) float64 { + return count(w.metrics.XPubCacheMisses.With(common.Labels{"reason": reason})) + } + steps := []struct { + name string + option AccountDetails + gap int + want string // miss reason, "" for a hit + }{ + {"first lookup derives", AccountDetailsBasic, 20, "absent"}, + {"same request is served cached", AccountDetailsBasic, 20, ""}, + {"history not cached yet", AccountDetailsTxidHistory, 20, "history"}, + {"history now cached", AccountDetailsTxidHistory, 20, ""}, + {"different gap re-derives", AccountDetailsBasic, 30, "gap"}, + } + for _, s := range steps { + hits, missed := count(w.metrics.XPubCacheHits), 0.0 + if s.want != "" { + missed = misses(s.want) + } + if _, err := w.GetXpubAddress(dbtestdata.Xpub, 1, 25, s.option, filter, s.gap, ""); err != nil { + t.Fatalf("%s: %v", s.name, err) + } + if s.want == "" { + if count(w.metrics.XPubCacheHits) != hits+1 { + t.Fatalf("%s: not counted as a hit", s.name) + } + } else if misses(s.want) != missed+1 { + t.Fatalf("%s: not counted as a %q miss", s.name, s.want) + } + } +} diff --git a/bchain/basechain.go b/bchain/basechain.go index 60a52370e4..164364f22c 100644 --- a/bchain/basechain.go +++ b/bchain/basechain.go @@ -35,11 +35,6 @@ func (b *BaseChain) GetBlockRaw(hash string) (string, error) { return "", errors.New("GetBlockRaw: not supported") } -// GetMempoolEntry is not supported by default -func (b *BaseChain) GetMempoolEntry(txid string) (*MempoolEntry, error) { - return nil, errors.New("GetMempoolEntry: not supported") -} - // GetSPVProof is not supported by default. // // SYSCOIN @@ -57,13 +52,18 @@ func (b *BaseChain) LongTermFeeRate() (*LongTermFeeRate, error) { return nil, errors.New("not supported") } +// EthereumTypeGetBestTip is not supported +func (b *BaseChain) EthereumTypeGetBestTip() (*EVMTip, 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") } // EthereumTypeGetNonces is not supported -func (b *BaseChain) EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { +func (b *BaseChain) EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { return 0, 0, false, errors.New("not supported") } diff --git a/bchain/basemempool.go b/bchain/basemempool.go index 7feb62c9b4..45c3c513b6 100644 --- a/bchain/basemempool.go +++ b/bchain/basemempool.go @@ -16,6 +16,10 @@ type txEntry struct { addrIndexes []addrIndex time uint32 filter string + // EthereumType only: sender addrDesc and account nonce, so a mined nonce can retire the + // entry without asking the backend whether it is still in the pool. + from string + nonce uint64 } type txidio struct { diff --git a/bchain/baseparser.go b/bchain/baseparser.go index 9cbd251369..a6a79521dd 100644 --- a/bchain/baseparser.go +++ b/bchain/baseparser.go @@ -201,6 +201,12 @@ func (p *BaseParser) UseAddressAliases() bool { return p.AddressAliases } +// UseEnsReverseAliases returns false - ENS reverse labels are an EthereumType concern, +// and off unless that parser opts the chain in. +func (p *BaseParser) UseEnsReverseAliases() bool { + return false +} + // ParseTxFromJson parses JSON message containing transaction and returns Tx struct func (p *BaseParser) ParseTxFromJson(msg json.RawMessage) (*Tx, error) { var tx Tx @@ -410,6 +416,11 @@ func (p *BaseParser) EthereumTypeGetTokenTransfersFromTx(tx *Tx) (TokenTransfers return nil, errors.New("Not supported") } +// EthereumTypeIsIgnoredContract ignores no contract by default +func (p *BaseParser) EthereumTypeIsIgnoredContract(contract AddressDescriptor) bool { + return false +} + // GetEthereumTxData returns default pending status for non-Ethereum-like chains. func (p *BaseParser) GetEthereumTxData(tx *Tx) *EthereumTxData { return &EthereumTxData{Status: TxStatusPending} diff --git a/bchain/coins/avalanche/avalancherpc.go b/bchain/coins/avalanche/avalancherpc.go index 028d18246e..eb19faad0a 100644 --- a/bchain/coins/avalanche/avalancherpc.go +++ b/bchain/coins/avalanche/avalancherpc.go @@ -5,33 +5,30 @@ import ( "encoding/json" "fmt" "net/url" + "time" - jsontypes "github.com/ava-labs/avalanchego/utils/json" - "github.com/ethereum/go-ethereum/common" + ethcommon "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" + "github.com/trezor/blockbook/common" ) const ( // MainNet is production network MainNet eth.Network = 43114 + // nodeVersionTTL is how long an info.getNodeVersion answer is reused, see probeNodeVersion. + nodeVersionTTL = 60 * time.Second ) 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...) + return rpc.DialOptions(context.Background(), rawURL, eth.RPCDialOptions(rawURL)...) } // OpenRPC opens RPC connections for Avalanche to separate HTTP and WS endpoints. @@ -63,6 +60,8 @@ var OpenRPC = func(httpURL, wsURL string) (bchain.EVMRPCClient, bchain.EVMClient type AvalancheRPC struct { *eth.EthereumRPC info *rpc.Client + // avm version snapshot, see probeNodeVersion. + nodeVersion *common.TTLValue[string] } // NewAvalancheRPC returns new AvalancheRPC instance. @@ -75,6 +74,7 @@ func NewAvalancheRPC(config json.RawMessage, pushHandler func(bchain.Notificatio s := &AvalancheRPC{ EthereumRPC: c.(*eth.EthereumRPC), } + s.nodeVersion = common.NewTTLValue(nodeVersionTTL, s.probeNodeVersion) return s, nil } @@ -98,7 +98,8 @@ func (b *AvalancheRPC) Initialize() error { scheme = "https" } - infoClient, err := rpc.DialHTTP(fmt.Sprintf("%s://%s/ext/info", scheme, rpcUrl.Host)) + infoURL := fmt.Sprintf("%s://%s/ext/info", scheme, rpcUrl.Host) + infoClient, err := rpc.DialOptions(context.Background(), infoURL, eth.RPCDialOptions(infoURL)...) if err != nil { return err } @@ -109,7 +110,7 @@ func (b *AvalancheRPC) Initialize() error { b.info = infoClient b.MainNetChainID = MainNet b.NewBlock = &AvalancheNewBlock{channel: make(chan *Header)} - b.NewTx = &AvalancheNewTx{channel: make(chan common.Hash)} + b.NewTx = &AvalancheNewTx{channel: make(chan ethcommon.Hash)} ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() @@ -144,22 +145,29 @@ func (b *AvalancheRPC) GetChainInfo() (*bchain.ChainInfo, error) { return nil, err } + // the avm version is a label, never a reason to fail GetChainInfo - nil means never probed + if v, _, _ := b.nodeVersion.Get(time.Now()); v != nil { + ci.Version = *v + } + + return ci, nil +} + +// probeNodeVersion fetches the node's avm version from the info endpoint; GetChainInfo +// prefers it over the parent's web3_clientVersion. +func (b *AvalancheRPC) probeNodeVersion() (string, error) { 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"` + 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 - } + if err := b.info.CallContext(ctx, &v, "info.getNodeVersion"); err != nil { + glog.V(1).Info("avalanche: info.getNodeVersion failed: ", err) + return "", err } - - return ci, nil + if v.VMVersions["avm"] == "" { + return "", errors.New("info endpoint reported no avm version") + } + return v.VMVersions["avm"], nil } diff --git a/bchain/coins/avalanche/evm.go b/bchain/coins/avalanche/evm.go index 7593593ce1..644519c501 100644 --- a/bchain/coins/avalanche/evm.go +++ b/bchain/coins/avalanche/evm.go @@ -114,6 +114,11 @@ func (h *AvalancheHeader) Hash() string { return h.Header.Hash().Hex() } +// ParentHash returns the parent block hash as a hex string +func (h *AvalancheHeader) ParentHash() string { + return h.Header.ParentHash.Hex() +} + // Number returns the block number func (h *AvalancheHeader) Number() *big.Int { return h.Header.Number diff --git a/bchain/coins/blockchain.go b/bchain/coins/blockchain.go index 7f3b674247..70bba9e5f8 100644 --- a/bchain/coins/blockchain.go +++ b/bchain/coins/blockchain.go @@ -35,6 +35,7 @@ import ( "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/hyperevm" "github.com/trezor/blockbook/bchain/coins/koto" "github.com/trezor/blockbook/bchain/coins/liquid" "github.com/trezor/blockbook/bchain/coins/litecoin" @@ -51,6 +52,7 @@ import ( "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/robinhood" "github.com/trezor/blockbook/bchain/coins/snowgem" // SYSCOIN sys "github.com/trezor/blockbook/bchain/coins/sys" @@ -82,8 +84,6 @@ func init() { BlockChainFactories["Ethereum Classic"] = 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 @@ -155,6 +155,9 @@ func init() { BlockChainFactories["Arbitrum Nova Archive"] = arbitrum.NewArbitrumRPC BlockChainFactories["Base"] = base.NewBaseRPC BlockChainFactories["Base Archive"] = base.NewBaseRPC + BlockChainFactories["Robinhood Archive"] = robinhood.NewRobinhoodRPC + BlockChainFactories["Robinhood Testnet"] = robinhood.NewRobinhoodRPC + BlockChainFactories["HyperEVM Archive"] = hyperevm.NewHyperevmRPC BlockChainFactories["Tron"] = tron.NewTronRPC BlockChainFactories["Tron Testnet Nile"] = tron.NewTronRPC // SYSCOIN @@ -325,7 +328,10 @@ func (c *blockChainWithMetrics) LongTermFeeRate() (v *bchain.LongTermFeeRate, er } func (c *blockChainWithMetrics) SendRawTransaction(tx string, disableAlternativeRPC bool) (v string, err error) { - defer func(s time.Time) { c.observeRPCLatency("SendRawTransaction", s, err) }(time.Now()) + defer func(s time.Time) { + c.observeRPCLatency("SendRawTransaction", s, err) + c.observeSendTx(err) + }(time.Now()) return c.b.SendRawTransaction(tx, disableAlternativeRPC) } @@ -334,16 +340,26 @@ func (c *blockChainWithMetrics) SendRawTransaction(tx string, disableAlternative // // SYSCOIN func (c *blockChainWithMetrics) SendRawTransactionWithOpts(p bchain.SendRawTransactionParams) (v string, err error) { - defer func(s time.Time) { c.observeRPCLatency("SendRawTransactionWithOpts", s, err) }(time.Now()) + defer func(s time.Time) { + c.observeRPCLatency("SendRawTransactionWithOpts", s, err) + c.observeSendTx(err) + }(time.Now()) 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) { - defer func(s time.Time) { c.observeRPCLatency("GetMempoolEntry", s, err) }(time.Now()) - return c.b.GetMempoolEntry(txid) +// observeSendTx counts a client broadcast by outcome and, when it failed, by the class of +// rejection. This wrapper is the single point every API (websocket, REST, explorer form) and every +// coin goes through, so it is where the overall send success rate is authoritative; the class is +// what makes the failure rate actionable - a transaction refused on its own merits and a backend +// that never answered are indistinguishable in a bare error ratio. +func (c *blockChainWithMetrics) observeSendTx(err error) { + if c.m == nil || c.m.SendTxRequests == nil { + return + } + c.m.SendTxRequests.With(common.Labels{"status": bchain.SendTxStatus(err), "reason": bchain.ClassifySendTxError(err)}).Inc() } // GetSPVProof forwards Syscoin's bridge SPV proof RPC through metrics. @@ -363,9 +379,9 @@ func (c *blockChainWithMetrics) EthereumTypeGetBalance(addrDesc bchain.AddressDe return c.b.EthereumTypeGetBalance(addrDesc) } -func (c *blockChainWithMetrics) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (pending uint64, confirmed uint64, confirmedOK bool, err error) { +func (c *blockChainWithMetrics) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (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) + return c.b.EthereumTypeGetNonces(addrDesc, withConfirmed, privatePendingNonces...) } func (c *blockChainWithMetrics) EthereumTypeEstimateGas(params map[string]interface{}) (v uint64, err error) { @@ -446,6 +462,11 @@ func (c *blockChainWithMetrics) EthereumTypeGetTransactionReceipt(txid string) ( return c.b.EthereumTypeGetTransactionReceipt(txid) } +func (c *blockChainWithMetrics) EthereumTypeGetBestTip() (v *bchain.EVMTip, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetBestTip", s, err) }(time.Now()) + return c.b.EthereumTypeGetBestTip() +} + type mempoolWithMetrics struct { mempool bchain.Mempool m *common.Metrics @@ -493,6 +514,9 @@ func (c *mempoolWithMetrics) Resync() (count int, err error) { }).Observe(throughput) if err == nil { c.m.MempoolSize.Set(float64(count)) + // GaugeVec, not a plain gauge, so the series stays absent until this first + // successful reload instead of reading 0 from process start. + c.m.MempoolLastSync.With(common.Labels{"chain": c.chainTypeLabel()}).Set(float64(time.Now().Unix())) } return count, err } @@ -595,3 +619,14 @@ func (c *blockChainWithMetrics) GetContractExplorerBaseURL() string { } return "" } + +// XpubConfigOverride forwards the wrapped chain's per-chain xpub config +// override through the metrics wrapper; nil when none is provided. +func (c *blockChainWithMetrics) XpubConfigOverride() *bchain.XpubConfig { + if p, ok := c.b.(interface { + XpubConfigOverride() *bchain.XpubConfig + }); ok { + return p.XpubConfigOverride() + } + return nil +} diff --git a/bchain/coins/blockchain_syscoin_test.go b/bchain/coins/blockchain_syscoin_test.go new file mode 100644 index 0000000000..52a2fa2ef1 --- /dev/null +++ b/bchain/coins/blockchain_syscoin_test.go @@ -0,0 +1,107 @@ +//go:build unittest + +package coins + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +func TestSyscoinBroadcastOptionsThroughMetrics(t *testing.T) { + for _, coin := range []string{"Syscoin", "Syscoin Testnet"} { + t.Run(coin, func(t *testing.T) { + requests := make(chan []string, 3) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Method string `json:"method"` + Params []string `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if request.Method != "sendrawtransaction" { + t.Errorf("unexpected method %q", request.Method) + } + requests <- request.Params + w.Header().Set("Content-Type", "application/json") + if len(request.Params) > 0 && request.Params[0] == "rejected" { + _, _ = w.Write([]byte(`{"error":{"code":-26,"message":"txn-mempool-conflict"}}`)) + return + } + _, _ = w.Write([]byte(`{"result":"accepted"}`)) + })) + defer backend.Close() + config, err := json.Marshal(map[string]interface{}{"rpc_url": backend.URL, "rpc_timeout": 5}) + if err != nil { + t.Fatal(err) + } + factory, ok := BlockChainFactories[coin] + if !ok { + t.Fatalf("missing %s chain factory", coin) + } + chain, err := factory(config, nil) + if err != nil { + t.Fatal(err) + } + defer chain.Shutdown(context.Background()) + metrics := &common.Metrics{ + RPCLatency: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "test_syscoin_rpc_latency", + }, []string{"method", "error"}), + SendTxRequests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "test_syscoin_send_requests_total", + }, []string{"status", "reason"}), + } + wrapped := &blockChainWithMetrics{b: chain, m: metrics} + if txid, err := wrapped.SendRawTransaction("default", false); err != nil || txid != "accepted" { + t.Fatalf("default broadcast = %q, %v", txid, err) + } + if got, want := <-requests, []string{"default", "0.10", "150"}; !reflect.DeepEqual(got, want) { + t.Fatalf("default broadcast params = %v, want %v", got, want) + } + maxFeeRate, maxBurnAmount := "0.02", "200" + if txid, err := wrapped.SendRawTransactionWithOpts(bchain.SendRawTransactionParams{ + Hex: "custom", MaxFeeRate: &maxFeeRate, MaxBurnAmount: &maxBurnAmount, + }); err != nil || txid != "accepted" { + t.Fatalf("custom broadcast = %q, %v", txid, err) + } + if got, want := <-requests, []string{"custom", "0.02", "200"}; !reflect.DeepEqual(got, want) { + t.Fatalf("custom broadcast params = %v, want %v", got, want) + } + if _, err := wrapped.SendRawTransactionWithOpts(bchain.SendRawTransactionParams{Hex: "rejected"}); err == nil { + t.Fatal("backend rejection was lost") + } + if got, want := <-requests, []string{"rejected", "0.10", "150"}; !reflect.DeepEqual(got, want) { + t.Fatalf("rejected broadcast params = %v, want %v", got, want) + } + for _, expected := range []struct { + status string + reason string + count float64 + }{ + {bchain.SendTxStatusSuccess, bchain.ReasonOK, 2}, + {bchain.SendTxStatusFailure, bchain.ReasonConflict, 1}, + } { + var metric dto.Metric + counter := metrics.SendTxRequests.WithLabelValues(expected.status, expected.reason) + if err := counter.Write(&metric); err != nil { + t.Fatal(err) + } + if got := metric.GetCounter().GetValue(); got != expected.count { + t.Errorf("broadcast metric %s/%s = %v, want %v", expected.status, expected.reason, got, expected.count) + } + } + }) + } +} diff --git a/bchain/coins/btc/bitcoinparser.go b/bchain/coins/btc/bitcoinparser.go index d746e82619..929bf21564 100644 --- a/bchain/coins/btc/bitcoinparser.go +++ b/bchain/coins/btc/bitcoinparser.go @@ -19,11 +19,17 @@ const ( // chain parameters var ( TestNet4Params chaincfg.Params + SigNetParams chaincfg.Params ) func init() { TestNet4Params = chaincfg.TestNet3Params TestNet4Params.Net = Testnet4Magic + + // chaincfg builds signet params from a challenge and leaves AddressMagicLen + // zero, which makes base58 address decoding fail on the version byte. + SigNetParams = chaincfg.SigNetParams + SigNetParams.AddressMagicLen = 1 } // BitcoinParser handle @@ -55,7 +61,7 @@ func GetChainParams(chain string) *chaincfg.Params { case "regtest": return &chaincfg.RegressionNetParams case "signet": - return &chaincfg.SigNetParams + return &SigNetParams } return &chaincfg.MainNetParams } diff --git a/bchain/coins/btc/bitcoinparser_test.go b/bchain/coins/btc/bitcoinparser_test.go index 4f5c5c2228..673cd28646 100644 --- a/bchain/coins/btc/bitcoinparser_test.go +++ b/bchain/coins/btc/bitcoinparser_test.go @@ -165,6 +165,66 @@ func TestGetAddrDescFromAddressTestnet(t *testing.T) { } } +// signet shares the testnet address magics, so the same vectors must decode there +func TestGetAddrDescFromAddressSignet(t *testing.T) { + type args struct { + address string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "pubkeyhash", + args: args{address: "mtkbaiLiUH3fvGJeSzuN3kUgmJzqinLejJ"}, + want: "76a914912e2b234f941f30b18afbb4fa46171214bf66c888ac", + wantErr: false, + }, + { + name: "scripthash", + args: args{address: "2Mv28xcUJdFXBTfGMtja6fVBMCEbsH3r2AW"}, + want: "a9141e6ec5a1d12912b396d77d98dcb000e91f517fa487", + wantErr: false, + }, + { + // address from https://github.com/trezor/blockbook/issues/1305 + name: "scripthash_p2wpkh", + args: args{address: "2MzoQhv5qzNFybSnMMN6uuueywkRV64Kirj"}, + want: "a91452df2355d174c913bccbf6319335d29b5a1d17d487", + wantErr: false, + }, + { + name: "witness_v0_keyhash", + args: args{address: "tb1qupjdck20as3y4l95cd5wepkv0grcz0p7d8rd5s"}, + want: "0014e064dc594fec224afcb4c368ec86cc7a07813c3e", + wantErr: false, + }, + { + name: "witness_v1_taproot", + args: args{address: "tb1pqsv2qyp8hsma46422ecfd3ek02jayumkkzjx7vkf3cqpmfd4ucpsx0cc9h"}, + want: "51200418a01027bc37daeaaa567096c7367aa5d27376b0a46f32c98e001da5b5e603", + wantErr: false, + }, + } + parser := NewBitcoinParser(GetChainParams("signet"), &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 TestGetAddrDescFromVout(t *testing.T) { type args struct { vout bchain.Vout diff --git a/bchain/coins/btc/bitcoinrpc.go b/bchain/coins/btc/bitcoinrpc.go index e5fbcc2c34..0c2e4c019d 100644 --- a/bchain/coins/btc/bitcoinrpc.go +++ b/bchain/coins/btc/bitcoinrpc.go @@ -8,6 +8,7 @@ import ( "math/big" "net" "net/http" + "strings" "time" "github.com/golang/glog" @@ -64,35 +65,46 @@ func (b *BitcoinRPC) MissingBlockRetryOverride() *bchain.MissingBlockRetry { return b.ChainConfig.MissingBlockRetry } +// XpubConfigOverride exposes the per-chain xpub expansion and cache config +// override (or nil to use built-in defaults). Consumed by api.NewWorker via +// a duck-typed interface assertion. +func (b *BitcoinRPC) XpubConfigOverride() *bchain.XpubConfig { + if b.ChainConfig == nil { + return nil + } + return b.ChainConfig.XpubConfig +} + // Configuration represents json config file type Configuration struct { - CoinName string `json:"coin_name"` - CoinShortcut string `json:"coin_shortcut"` - RPCURL string `json:"rpc_url"` - 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"` - 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"` - AlternativeEstimateFee string `json:"alternative_estimate_fee,omitempty"` - AlternativeEstimateFeeParams string `json:"alternative_estimate_fee_params,omitempty"` - MinimumCoinbaseConfirmations int `json:"minimumCoinbaseConfirmations,omitempty"` - MempoolGolombFilterP uint8 `json:"mempool_golomb_filter_p,omitempty"` - MempoolFilterScripts string `json:"mempool_filter_scripts,omitempty"` - MempoolFilterUseZeroedKey bool `json:"mempool_filter_use_zeroed_key,omitempty"` + CoinName string `json:"coin_name"` + CoinShortcut string `json:"coin_shortcut"` + RPCURL string `json:"rpc_url"` + 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"` + MessageQueueCurve *bchain.CurveConfig `json:"message_queue_curve,omitempty"` + 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"` + 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"` + AlternativeEstimateFee string `json:"alternative_estimate_fee,omitempty"` + AlternativeEstimateFeeParams string `json:"alternative_estimate_fee_params,omitempty"` + MinimumCoinbaseConfirmations int `json:"minimumCoinbaseConfirmations,omitempty"` + MempoolGolombFilterP uint8 `json:"mempool_golomb_filter_p,omitempty"` + MempoolFilterScripts string `json:"mempool_filter_scripts,omitempty"` + MempoolFilterUseZeroedKey bool `json:"mempool_filter_use_zeroed_key,omitempty"` // SYSCOIN: NEVM RPC endpoints used for SPT metadata enrichment. Web3RPCURL string `json:"web3_rpc_url,omitempty"` Web3RPCURLBackup string `json:"web3_rpc_url_backup,omitempty"` @@ -104,6 +116,9 @@ type Configuration struct { // 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"` + // XpubConfig overrides the xpub address expansion and cache limits. + // All fields are optional; missing fields use built-in defaults. + XpubConfig *bchain.XpubConfig `json:"xpubConfig,omitempty"` } // AverageBlockTimeDuration returns AverageBlockTimeMs as a time.Duration. @@ -234,6 +249,13 @@ func (b *BitcoinRPC) Initialize() error { // disable AlternativeEstimateFee logic b.alternativeFeeProvider = nil } + } else if b.ChainConfig.AlternativeEstimateFee == "mempoolspaceprecise" { + glog.Info("Using MempoolSpacePreciseFee") + if b.alternativeFeeProvider, err = NewMempoolSpacePreciseFee(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + glog.Error("MempoolSpacePreciseFee 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 { @@ -281,7 +303,7 @@ func (b *BitcoinRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOut TxReceive: "hashtx", } - mq, err := bchain.NewMQ(b.ChainConfig.MessageQueueBinding, b.pushHandler, bitcoinTopics) + mq, err := bchain.NewMQ(b.ChainConfig.MessageQueueBinding, b.ChainConfig.MessageQueueCurve, b.pushHandler, bitcoinTopics) if err != nil { glog.Error("mq: ", err) return err @@ -354,6 +376,29 @@ type ResGetBlockCount struct { Result uint32 `json:"result"` } +// coreWarnings decodes the "warnings" field of getblockchaininfo and +// getnetworkinfo. Bitcoin Core 25.0+ returns it as an array of strings, while +// older versions (and nodes started with -deprecatedrpc=warnings) return a +// single string. Accepting either shape keeps blockbook compatible across Core +// versions. +type coreWarnings string + +func (w *coreWarnings) UnmarshalJSON(data []byte) error { + // Core 25.0+: array of warning strings. + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *w = coreWarnings(strings.Join(arr, " ")) + return nil + } + // Pre-25.0 / -deprecatedrpc=warnings: a single string. + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *w = coreWarnings(s) + return nil +} + // getblockchaininfo type CmdGetBlockChainInfo struct { @@ -369,7 +414,7 @@ type ResGetBlockChainInfo struct { Bestblockhash string `json:"bestblockhash"` Difficulty common.JSONNumber `json:"difficulty"` SizeOnDisk int64 `json:"size_on_disk"` - Warnings string `json:"warnings"` + Warnings coreWarnings `json:"warnings"` } `json:"result"` } @@ -386,7 +431,7 @@ type ResGetNetworkInfo struct { Subversion common.JSONNumber `json:"subversion"` ProtocolVersion common.JSONNumber `json:"protocolversion"` Timeoffset float64 `json:"timeoffset"` - Warnings string `json:"warnings"` + Warnings coreWarnings `json:"warnings"` } `json:"result"` } @@ -541,18 +586,6 @@ type ResSendRawTransaction struct { Result string `json:"result"` } -// getmempoolentry - -type CmdGetMempoolEntry struct { - Method string `json:"method"` - Params []string `json:"params"` -} - -type ResGetMempoolEntry struct { - Error *bchain.RPCError `json:"error"` - Result *bchain.MempoolEntry `json:"result"` -} - // GetBestBlockHash returns hash of the tip of the best-block-chain. func (b *BitcoinRPC) GetBestBlockHash() (string, error) { @@ -624,10 +657,10 @@ func (b *BitcoinRPC) GetChainInfo() (*bchain.ChainInfo, error) { rv.Version = string(resNi.Result.Version) rv.ProtocolVersion = string(resNi.Result.ProtocolVersion) if len(resCi.Result.Warnings) > 0 { - rv.Warnings = resCi.Result.Warnings + " " + rv.Warnings = string(resCi.Result.Warnings) + " " } if resCi.Result.Warnings != resNi.Result.Warnings { - rv.Warnings += resNi.Result.Warnings + rv.Warnings += string(resNi.Result.Warnings) } return rv, nil } @@ -1071,6 +1104,11 @@ func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) { return b.EstimateSmartFee(blocks, true) } + // Core-0.14-lineage estimatefee never estimates 1 block (always -1); the node's own + // estimatesmartfee bumps the target to 2, so mirror that here. + if blocks == 1 { + blocks = 2 + } glog.V(1).Info("rpc: estimatefee ", blocks) res := ResEstimateFee{} @@ -1088,6 +1126,10 @@ func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) { if err != nil { return r, err } + // the node signals "not enough data" with -1; never hand a negative fee to clients + if r.Sign() < 0 { + return r, errors.Errorf("estimatefee: no fee estimate available for %d blocks", blocks) + } return r, nil } @@ -1139,33 +1181,6 @@ func SendRawTransactionWithParams(b *BitcoinRPC, p bchain.SendRawTransactionPara return res.Result, nil } -// GetMempoolEntry returns mempool data for given transaction -func (b *BitcoinRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { - glog.V(1).Info("rpc: getmempoolentry") - - res := ResGetMempoolEntry{} - req := CmdGetMempoolEntry{ - Method: "getmempoolentry", - Params: []string{txid}, - } - err := b.Call(&req, &res) - if err != nil { - return nil, err - } - if res.Error != nil { - return nil, res.Error - } - res.Result.FeeSat, err = b.Parser.AmountToBigInt(res.Result.Fee) - if err != nil { - return nil, err - } - res.Result.ModifiedFeeSat, err = b.Parser.AmountToBigInt(res.Result.ModifiedFee) - if err != nil { - return nil, err - } - return res.Result, nil -} - // callBatch sends a JSON-RPC batch request and decodes responses. func (b *BitcoinRPC) callBatch(req []rpcBatchRequest, res *[]rpcBatchResponse) error { httpData, err := json.Marshal(req) diff --git a/bchain/coins/btc/bitcoinrpc_estimatefee_test.go b/bchain/coins/btc/bitcoinrpc_estimatefee_test.go new file mode 100644 index 0000000000..bc3feea84e --- /dev/null +++ b/bchain/coins/btc/bitcoinrpc_estimatefee_test.go @@ -0,0 +1,98 @@ +package btc + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// newLegacyFeeRPC wires a BitcoinRPC to an in-process backend (no listening socket) +// configured like dogecoind: only the legacy estimatefee is available. +func newLegacyFeeRPC(backend func(body string) string) *BitcoinRPC { + cfg := &Configuration{SupportsEstimateFee: true} + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(r.Body) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(backend(string(body)))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) + return &BitcoinRPC{ + BaseChain: &bchain.BaseChain{Parser: NewBitcoinParser(GetChainParams("main"), cfg)}, + client: http.Client{Transport: transport}, + rpcURL: "http://backend", + ChainConfig: cfg, + RPCMarshaler: JSONMarshalerV1{}, + } +} + +// Replies are what dogecoind 1.14.9 returned live on 2026-09-10. +func fakeLegacyEstimateFee(requests *[]string) func(string) string { + return func(body string) string { + *requests = append(*requests, body) + switch body { + case `{"method":"estimatefee","params":[1]}`: + return `{"result":-1,"error":null,"id":1}` + case `{"method":"estimatefee","params":[2]}`: + return `{"result":0.50427075,"error":null,"id":1}` + case `{"method":"estimatefee","params":[8]}`: + return `{"result":0.01002515,"error":null,"id":1}` + default: + return `{"result":-1,"error":null,"id":1}` + } + } +} + +func TestEstimateFeeLegacyRemapsOneBlock(t *testing.T) { + var requests []string + b := newLegacyFeeRPC(fakeLegacyEstimateFee(&requests)) + + tests := []struct { + name string + call func() (fee string, err error) + wantReq string + want string + }{ + {"EstimateFee(1) asks for 2", func() (string, error) { f, e := b.EstimateFee(1); return f.String(), e }, + `{"method":"estimatefee","params":[2]}`, "50427075"}, + {"EstimateSmartFee(1) falls back to estimatefee 2", func() (string, error) { f, e := b.EstimateSmartFee(1, true); return f.String(), e }, + `{"method":"estimatefee","params":[2]}`, "50427075"}, + {"other targets pass through", func() (string, error) { f, e := b.EstimateFee(8); return f.String(), e }, + `{"method":"estimatefee","params":[8]}`, "1002515"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests = requests[:0] + fee, err := tt.call() + if err != nil { + t.Fatal(err) + } + if fee != tt.want { + t.Errorf("fee = %s, want %s", fee, tt.want) + } + if len(requests) != 1 || requests[0] != tt.wantReq { + t.Errorf("requests = %q, want [%s]", requests, tt.wantReq) + } + }) + } +} + +func TestEstimateFeeLegacyNegativeIsError(t *testing.T) { + var requests []string + b := newLegacyFeeRPC(fakeLegacyEstimateFee(&requests)) + + if fee, err := b.EstimateFee(1008); err == nil { + t.Errorf("expected error for result -1, got fee %s", fee.String()) + } + if _, err := b.LongTermFeeRate(); err == nil { + t.Error("expected error for result -1 from LongTermFeeRate") + } +} diff --git a/bchain/coins/btc/bitcoinrpc_test.go b/bchain/coins/btc/bitcoinrpc_test.go index 6ca9a83536..d5d3f7bcc1 100644 --- a/bchain/coins/btc/bitcoinrpc_test.go +++ b/bchain/coins/btc/bitcoinrpc_test.go @@ -32,3 +32,44 @@ func TestDecodeBatchRawTransactions(t *testing.T) { t.Fatalf("expected txid %s, got %s", txid, got[txid].Txid) } } + +func TestCoreWarningsUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"legacy empty string", `""`, ""}, + {"legacy string", `"beware"`, "beware"}, + {"core 25 empty array", `[]`, ""}, + {"core 25 single element", `["beware"]`, "beware"}, + {"core 25 multiple elements", `["a","b"]`, "a b"}, + {"null", `null`, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var w coreWarnings + if err := json.Unmarshal([]byte(tt.in), &w); err != nil { + t.Fatalf("Unmarshal(%s): %v", tt.in, err) + } + if string(w) != tt.want { + t.Errorf("Unmarshal(%s) = %q, want %q", tt.in, string(w), tt.want) + } + }) + } +} + +// TestGetBlockChainInfoWarningsArray guards the actual regression: Bitcoin Core +// 25.0+ returns getblockchaininfo "warnings" as an array, which must decode +// through the struct field rather than failing with "cannot unmarshal array +// into Go struct field .result.warnings of type string". +func TestGetBlockChainInfoWarningsArray(t *testing.T) { + const data = `{"result":{"chain":"main","warnings":["disk space is low"]}}` + var res ResGetBlockChainInfo + if err := json.Unmarshal([]byte(data), &res); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got := string(res.Result.Warnings); got != "disk space is low" { + t.Errorf("warnings = %q, want %q", got, "disk space is low") + } +} diff --git a/bchain/coins/btc/mempoolspaceprecise.go b/bchain/coins/btc/mempoolspaceprecise.go new file mode 100644 index 0000000000..8d5df25472 --- /dev/null +++ b/bchain/coins/btc/mempoolspaceprecise.go @@ -0,0 +1,157 @@ +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/precise returns the recommended fees with sub-sat/vB precision. +// Example response: +// {"fastestFee":2.605,"halfHourFee":1.603,"hourFee":0.843,"economyFee":0.2,"minimumFee":0.1} + +type mempoolSpacePreciseFeeResult struct { + FastestFee float64 `json:"fastestFee"` + HalfHourFee float64 `json:"halfHourFee"` + HourFee float64 `json:"hourFee"` + EconomyFee float64 `json:"economyFee"` + MinimumFee float64 `json:"minimumFee"` +} + +type mempoolSpacePreciseFeeParams struct { + URL string `json:"url"` + PeriodSeconds int `json:"periodSeconds"` +} + +type mempoolSpacePreciseFeeProvider struct { + *alternativeFeeProvider + params mempoolSpacePreciseFeeParams +} + +// NewMempoolSpacePreciseFee initializes the provider completely. +func NewMempoolSpacePreciseFee(chain bchain.BlockChain, params string, metrics *common.Metrics) (alternativeFeeProviderInterface, error) { + var paramsParsed mempoolSpacePreciseFeeParams + err := json.Unmarshal([]byte(params), ¶msParsed) + if err != nil { + return nil, err + } + + p, err := NewMempoolSpacePreciseFeeProviderFromParamsWithoutChain(paramsParsed) + if err != nil { + return nil, err + } + + p.chain = chain + p.metrics = metrics + p.name = "mempoolspaceprecise" + go p.downloader() + return p, nil +} + +// NewMempoolSpacePreciseFeeProviderFromParamsWithoutChain initializes the provider from already parsed parameters and without chain. +// Refactored like this for better testability. +func NewMempoolSpacePreciseFeeProviderFromParamsWithoutChain(params mempoolSpacePreciseFeeParams) (*mempoolSpacePreciseFeeProvider, error) { + if params.URL == "" { + return nil, errors.New("NewMempoolSpacePreciseFee: Missing url") + } + // Negative period would make the timer fire immediately, busy-looping requests + if params.PeriodSeconds <= 0 { + return nil, errors.New("NewMempoolSpacePreciseFee: Missing periodSeconds") + } + + return &mempoolSpacePreciseFeeProvider{ + alternativeFeeProvider: &alternativeFeeProvider{}, + params: params, + }, nil +} + +func (p *mempoolSpacePreciseFeeProvider) downloader() { + period := time.Duration(p.params.PeriodSeconds) * time.Second + timer := time.NewTimer(period) + counter := 0 + for { + var data mempoolSpacePreciseFeeResult + 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 *mempoolSpacePreciseFeeProvider) processData(data *mempoolSpacePreciseFeeResult) bool { + // Block targets follow the mempool.space UI semantics, so Suite's targets 1/3/6/36 + // map to fastest/halfHour/hour/economy and users see the same numbers as on mempool.space + fees := []alternativeFeeProviderFee{ + {blocks: 1, feePerKB: feePerVBToFeePerKB(data.FastestFee)}, // ~10 minutes + {blocks: 3, feePerKB: feePerVBToFeePerKB(data.HalfHourFee)}, // ~30 minutes + {blocks: 6, feePerKB: feePerVBToFeePerKB(data.HourFee)}, // ~1 hour + {blocks: 500, feePerKB: feePerVBToFeePerKB(data.EconomyFee)}, // no priority + {blocks: 1008, feePerKB: feePerVBToFeePerKB(data.MinimumFee)}, // purge floor + } + // Validate after conversion and keep the previous table on a malformed poll, + // so the node fallback kicks in once the last good data goes stale + for _, fee := range fees { + if fee.feePerKB <= 0 { + glog.Errorf("processData: invalid data %+v", data) + return false + } + } + p.mux.Lock() + defer p.mux.Unlock() + p.fees = fees + p.lastSync = time.Now() + return true +} + +// feePerVBToFeePerKB converts exactly, without rounding to significant digits, +// so that the served values are identical to the ones on mempool.space. +// Returns 0 for values that are not sane positive fees (negative, NaN, Inf or huge), +// as their float to int conversion is implementation-dependent. +func feePerVBToFeePerKB(fee float64) int { + feePerKB := math.Round(fee * 1000) + if !(feePerKB >= 1 && feePerKB <= 1e9) { + return 0 + } + return int(feePerKB) +} + +func (p *mempoolSpacePreciseFeeProvider) 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/mempoolspaceprecise_test.go b/bchain/coins/btc/mempoolspaceprecise_test.go new file mode 100644 index 0000000000..6229ca5687 --- /dev/null +++ b/bchain/coins/btc/mempoolspaceprecise_test.go @@ -0,0 +1,208 @@ +//go:build unittest + +package btc + +import ( + "encoding/json" + "math" + "math/big" + "strconv" + "strings" + "testing" +) + +func newTestMempoolSpacePreciseFeeProvider(t *testing.T) *mempoolSpacePreciseFeeProvider { + m, err := NewMempoolSpacePreciseFeeProviderFromParamsWithoutChain(mempoolSpacePreciseFeeParams{ + URL: "https://mempool.space/api/v1/fees/precise", + PeriodSeconds: 20, + }) + if err != nil { + t.Fatalf("NewMempoolSpacePreciseFeeProviderFromParamsWithoutChain returned error: %v", err) + } + return m +} + +func Test_mempoolSpacePreciseFeeResultParsing(t *testing.T) { + // Both the float /fees/precise and the integer /fees/recommended responses must parse + tests := []struct { + name string + json string + want mempoolSpacePreciseFeeResult + }{ + { + name: "precise", + json: `{"fastestFee":2.605,"halfHourFee":1.603,"hourFee":0.843,"economyFee":0.2,"minimumFee":0.1}`, + want: mempoolSpacePreciseFeeResult{FastestFee: 2.605, HalfHourFee: 1.603, HourFee: 0.843, EconomyFee: 0.2, MinimumFee: 0.1}, + }, + { + name: "recommended", + json: `{"fastestFee":41,"halfHourFee":39,"hourFee":36,"economyFee":36,"minimumFee":20}`, + want: mempoolSpacePreciseFeeResult{FastestFee: 41, HalfHourFee: 39, HourFee: 36, EconomyFee: 36, MinimumFee: 20}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got mempoolSpacePreciseFeeResult + if err := json.Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if got != tt.want { + t.Errorf("json.Unmarshal = %+v, want %+v", got, tt.want) + } + }) + } +} + +func Test_mempoolSpacePreciseFeeProvider(t *testing.T) { + m := newTestMempoolSpacePreciseFeeProvider(t) + if !m.processData(&mempoolSpacePreciseFeeResult{ + FastestFee: 2.605, + HalfHourFee: 1.603, + HourFee: 0.843, + EconomyFee: 0.2, + MinimumFee: 0.1, + }) { + t.Fatal("expected data to be processed successfully") + } + + // 2605 (not 2610) proves the conversion does not round to significant digits + tests := []struct { + blocks int + want big.Int + }{ + {0, *big.NewInt(2605)}, + {1, *big.NewInt(2605)}, + {2, *big.NewInt(1603)}, + {3, *big.NewInt(1603)}, + {4, *big.NewInt(843)}, + {6, *big.NewInt(843)}, + {7, *big.NewInt(200)}, + {36, *big.NewInt(200)}, + {100, *big.NewInt(200)}, + {500, *big.NewInt(200)}, + {501, *big.NewInt(100)}, + {1008, *big.NewInt(100)}, + {5000000, *big.NewInt(100)}, + } + for _, tt := range tests { + t.Run(strconv.Itoa(tt.blocks), func(t *testing.T) { + got, err := m.estimateFee(tt.blocks) + if err != nil { + t.Errorf("estimateFee returned error: %v", err) + } + if got.Cmp(&tt.want) != 0 { + t.Errorf("estimateFee(%d) = %v, want %v", tt.blocks, got, tt.want) + } + }) + } +} + +func Test_feePerVBToFeePerKB(t *testing.T) { + tests := []struct { + fee float64 + want int + }{ + {2.605, 2605}, + {0.1, 100}, + {0.001, 1}, + // not sane positive fees, must convert to 0 + {0.0004, 0}, + {0, 0}, + {-1, 0}, + {1e100, 0}, + {math.NaN(), 0}, + {math.Inf(1), 0}, + } + for _, tt := range tests { + if got := feePerVBToFeePerKB(tt.fee); got != tt.want { + t.Errorf("feePerVBToFeePerKB(%v) = %d, want %d", tt.fee, got, tt.want) + } + } +} + +func Test_mempoolSpacePreciseFeeProviderInvalidData(t *testing.T) { + tests := []struct { + name string + data mempoolSpacePreciseFeeResult + }{ + { + name: "zero field", + data: mempoolSpacePreciseFeeResult{FastestFee: 2.605, HalfHourFee: 1.603, HourFee: 0.843, EconomyFee: 0.2}, + }, + { + name: "negative field", + data: mempoolSpacePreciseFeeResult{FastestFee: 2.605, HalfHourFee: -1, HourFee: 0.843, EconomyFee: 0.2, MinimumFee: 0.1}, + }, + { + name: "huge field overflowing int", + data: mempoolSpacePreciseFeeResult{FastestFee: 1e100, HalfHourFee: 1.603, HourFee: 0.843, EconomyFee: 0.2, MinimumFee: 0.1}, + }, + { + name: "field rounding to zero", + data: mempoolSpacePreciseFeeResult{FastestFee: 2.605, HalfHourFee: 1.603, HourFee: 0.843, EconomyFee: 0.2, MinimumFee: 0.0004}, + }, + { + name: "NaN field", + data: mempoolSpacePreciseFeeResult{FastestFee: math.NaN(), HalfHourFee: 1.603, HourFee: 0.843, EconomyFee: 0.2, MinimumFee: 0.1}, + }, + { + name: "Inf field", + data: mempoolSpacePreciseFeeResult{FastestFee: math.Inf(1), HalfHourFee: 1.603, HourFee: 0.843, EconomyFee: 0.2, MinimumFee: 0.1}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestMempoolSpacePreciseFeeProvider(t) + if m.processData(&tt.data) { + t.Error("expected processData to reject invalid data") + } + if _, err := m.estimateFee(1); err == nil { + t.Error("expected estimateFee to return error when no data was processed") + } + + // a rejected poll must keep the previously processed table + if !m.processData(&mempoolSpacePreciseFeeResult{FastestFee: 2.605, HalfHourFee: 1.603, HourFee: 0.843, EconomyFee: 0.2, MinimumFee: 0.1}) { + t.Fatal("expected valid data to be processed successfully") + } + if m.processData(&tt.data) { + t.Error("expected processData to reject invalid data") + } + got, err := m.estimateFee(1) + if err != nil { + t.Errorf("estimateFee returned error: %v", err) + } + if want := big.NewInt(2605); got.Cmp(want) != 0 { + t.Errorf("estimateFee(1) = %v, want %v", got, want) + } + }) + } +} + +func Test_mempoolSpacePreciseFeeProviderMissingUrl(t *testing.T) { + _, err := NewMempoolSpacePreciseFeeProviderFromParamsWithoutChain(mempoolSpacePreciseFeeParams{ + PeriodSeconds: 20, + }) + 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_mempoolSpacePreciseFeeProviderInvalidPeriodSeconds(t *testing.T) { + for _, periodSeconds := range []int{0, -5} { + _, err := NewMempoolSpacePreciseFeeProviderFromParamsWithoutChain(mempoolSpacePreciseFeeParams{ + URL: "https://mempool.space/api/v1/fees/precise", + PeriodSeconds: periodSeconds, + }) + if err == nil { + t.Fatalf("expected error for periodSeconds=%d, got nil", periodSeconds) + } + 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/eth/alternativefeeprovider.go b/bchain/coins/eth/alternativefeeprovider.go index 42cc257e6f..750fbcddec 100644 --- a/bchain/coins/eth/alternativefeeprovider.go +++ b/bchain/coins/eth/alternativefeeprovider.go @@ -1,19 +1,54 @@ package eth import ( + "net/http" "sync" "time" + "github.com/golang/glog" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" + "golang.org/x/sync/singleflight" ) +// feeProviderParams is the shared config for the EVM alternative fee providers +// (Infura and 1inch). The two were field-for-field identical and had already +// drifted once, so they share one struct to keep parsing and validation in sync. +type feeProviderParams struct { + URL string `json:"url"` + // PeriodSeconds is the cache TTL: the provider is contacted at most once per + // period, and only when a client requests an estimate (issue #1746). The name + // predates on-demand fetching, when it was the poll cadence. + PeriodSeconds int `json:"periodSeconds"` + // StaleSeconds is how long a cached estimate stays usable after the last + // successful refresh before it is considered stale (falls back to node + // estimation). It is independent of the cache TTL. Optional; defaults to + // defaultFeeStaleSeconds when unset. + StaleSeconds int `json:"staleSeconds"` +} + +// feeHTTPClient bounds each on-demand fetch so a blackholed connection (accepted, +// headers never sent) can't hold the coalesced estimateFee callers for more than +// a few seconds; http.DefaultClient has no timeout. +var feeHTTPClient = &http.Client{Timeout: 5 * time.Second} + +// feeFetchRetryDelay is the floor for pacing upstream retries after a failed +// fetch (the effective delay is max of this and the ttl, so the documented +// one-request-per-periodSeconds cap holds during outages too). Without it an +// outage would be retried at client-request rate — hammering a possibly throttled +// provider and adding fetch latency to every request instead of one per delay. +const feeFetchRetryDelay = 5 * time.Second + type alternativeFeeProvider struct { eip1559Fees *bchain.Eip1559Fees lastSync time.Time + lastFailure time.Time + ttl time.Duration staleSyncDuration time.Duration + fetch func() (*bchain.Eip1559Fees, error) chain bchain.BlockChain mux sync.Mutex + sf singleflight.Group metrics *common.Metrics name string } @@ -25,15 +60,151 @@ func (p *alternativeFeeProvider) observeRequest(status string) { p.metrics.AlternativeFeeProviderRequests.With(common.Labels{"provider": p.name, "status": status}).Inc() } +func (p *alternativeFeeProvider) observeCache(result string) { + if p.metrics == nil || p.metrics.AlternativeFeeProviderCache == nil { + return + } + p.metrics.AlternativeFeeProviderCache.With(common.Labels{"provider": p.name, "result": result}).Inc() +} + +// observeSync records a successful refresh of the cached fees: it advances lastSync (which the read +// path uses for its staleness check) and exports the timestamp so cache age can be plotted as +// time() - metric. Both must use the same instant, so the caller passes it in. Exporting a timestamp +// rather than an age keeps the plotted age rising when a provider wedges, since it is only written +// here on success. Callers hold p.mux. +func (p *alternativeFeeProvider) observeSync(t time.Time) { + p.lastSync = t + if p.metrics == nil || p.metrics.AlternativeFeeProviderLastSync == nil { + return + } + p.metrics.AlternativeFeeProviderLastSync.With(common.Labels{"provider": p.name}).Set(float64(t.Unix())) +} + type alternativeFeeProviderInterface interface { GetEip1559Fees() (*bchain.Eip1559Fees, error) } -func (p *alternativeFeeProvider) GetEip1559Fees() (*bchain.Eip1559Fees, error) { +// defaultFeeStaleSeconds is the cached-estimate stale window (10 minutes) used +// by the EVM alternative fee providers when the config omits "staleSeconds". +const defaultFeeStaleSeconds = 600 + +// feeStaleDuration returns the stale window for cached estimates: the configured +// staleSeconds (falling back to the 10-minute default when unset or non-positive), +// clamped up to periodSeconds. The clamp guarantees the window can never be +// shorter than the cache TTL — otherwise a config like {periodSeconds:60, +// staleSeconds:30} would flap provider→node on a healthy provider, a trap the +// old periodSeconds*30 formula made structurally impossible. +func feeStaleDuration(periodSeconds, staleSeconds int) time.Duration { + if staleSeconds <= 0 { + staleSeconds = defaultFeeStaleSeconds + } + if staleSeconds < periodSeconds { + staleSeconds = periodSeconds + } + return time.Duration(staleSeconds) * time.Second +} + +// freshDuration treats an unset ttl as one second rather than 0, so a provider +// that forgets to initialize it still caches a just-fetched value long enough +// to serve it instead of refetching on every request. +func (p *alternativeFeeProvider) freshDuration() time.Duration { + if p.ttl <= 0 { + return time.Second + } + return p.ttl +} + +// staleDuration treats an unset staleSyncDuration as the default rather than 0, +// so a future provider that forgets to initialize it degrades to the 10-minute +// window instead of a silent no-op (lastSync.Add(0) is always in the past). +func (p *alternativeFeeProvider) staleDuration() time.Duration { + if p.staleSyncDuration <= 0 { + return defaultFeeStaleSeconds * time.Second + } + return p.staleSyncDuration +} + +// cachedWithin returns a copy of the cached fees when the last successful fetch +// is within d, else nil. The copy keeps the cache safe from per-request mutation +// by callers (e.g. fee jitter). +func (p *alternativeFeeProvider) cachedWithin(d time.Duration) *bchain.Eip1559Fees { + p.mux.Lock() + defer p.mux.Unlock() + if p.lastSync.Add(d).Before(time.Now()) { + return nil + } + return p.eip1559Fees.Copy() +} + +// refresh runs one upstream fetch and stores the result; called only inside the +// singleflight group so at most one fetch is in flight. Errors are logged, not +// returned — the read path falls back to stale/on-chain data. +func (p *alternativeFeeProvider) refresh() { + retryDelay := p.freshDuration() + if retryDelay < feeFetchRetryDelay { + retryDelay = feeFetchRetryDelay + } + p.mux.Lock() + // a caller queued behind a just-completed flight must not refetch, and a + // failed fetch is retried at most once per ttl (with a feeFetchRetryDelay floor) + if p.fetch == nil || time.Since(p.lastSync) < p.freshDuration() || time.Since(p.lastFailure) < retryDelay { + p.mux.Unlock() + return + } + p.mux.Unlock() + // fetch outside the lock; bounded by feeHTTPClient's timeout + fees, err := p.fetch() p.mux.Lock() defer p.mux.Unlock() - if p.lastSync.Add(p.staleSyncDuration).Before(time.Now()) { + if err != nil { + glog.Errorf("%s alternative fee provider fetch: %v", p.name, err) + p.lastFailure = time.Now() + return + } + p.lastFailure = time.Time{} + p.observeSync(time.Now()) + p.eip1559Fees = fees +} + +// GetEip1559Fees serves fees from the cache when fresh and otherwise fetches from +// the provider on demand, coalescing concurrent callers into a single upstream +// request. It never returns an error: on a failed fetch it serves the last value +// within the stale window, and past that returns nil so the caller falls through +// to on-chain estimation. +func (p *alternativeFeeProvider) GetEip1559Fees() (*bchain.Eip1559Fees, error) { + if fees := p.cachedWithin(p.freshDuration()); fees != nil { + p.observeCache("fresh") + return fees, nil + } + // singleflight's shared flag is true for the leader too whenever anyone + // waited on it, so mark the leader from inside the closure instead + fetched := false + p.sf.Do("fees", func() (interface{}, error) { + fetched = true + p.refresh() return nil, nil + }) + if fees := p.cachedWithin(p.freshDuration()); fees != nil { + if fetched { + p.observeCache("fetched") + } else { + p.observeCache("coalesced") + } + return fees, nil + } + if fees := p.cachedWithin(p.staleDuration()); fees != nil { + p.observeCache("stale") + return fees, nil } - return p.eip1559Fees, nil + p.observeCache("miss") + return nil, nil +} + +// warmUp primes the cache once at startup so the first client does not pay the +// fetch latency and a broken URL/key surfaces in the log right away. +func (p *alternativeFeeProvider) warmUp() { + p.sf.Do("fees", func() (interface{}, error) { + p.refresh() + return nil, nil + }) } diff --git a/bchain/coins/eth/alternativefeeprovider_test.go b/bchain/coins/eth/alternativefeeprovider_test.go index aa17aa2921..b955e016b5 100644 --- a/bchain/coins/eth/alternativefeeprovider_test.go +++ b/bchain/coins/eth/alternativefeeprovider_test.go @@ -1,14 +1,28 @@ package eth -import "testing" +import ( + "io" + "math/big" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/juju/errors" + "github.com/prometheus/client_golang/prometheus" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) // 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}` + const infuraParams = `{"url":"https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees","periodSeconds":10}` + const oneInchParams = `{"url":"https://api.1inch.dev/gas-price/v1.5/1","periodSeconds":10}` tests := []struct { name string @@ -48,3 +62,191 @@ func TestInitAlternativeFeeProviderFailFast(t *testing.T) { }) } } + +const infuraTestFeesResponse = `{ + "estimatedBaseFee": "10", + "low": {"suggestedMaxPriorityFeePerGas": "1", "suggestedMaxFeePerGas": "11"}, + "medium": {"suggestedMaxPriorityFeePerGas": "2", "suggestedMaxFeePerGas": "12"}, + "high": {"suggestedMaxPriorityFeePerGas": "3", "suggestedMaxFeePerGas": "13"} +}` + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// stubFeeTransport routes feeHTTPClient through rt for the test, so the real +// getData/decode path runs without sockets (the sandbox forbids httptest binds). +func stubFeeTransport(t *testing.T, rt roundTripFunc) { + orig := feeHTTPClient.Transport + feeHTTPClient.Transport = rt + t.Cleanup(func() { feeHTTPClient.Transport = orig }) +} + +func infuraTestFeesRoundTrip() (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(infuraTestFeesResponse)), + }, nil +} + +// newTestInfuraProvider builds an infuraFeeProvider directly, bypassing the +// constructor so no API key env vars or warm-up fetch are involved. +func newTestInfuraProvider(ttl time.Duration) *infuraFeeProvider { + p := &infuraFeeProvider{ + alternativeFeeProvider: &alternativeFeeProvider{ + ttl: ttl, + staleSyncDuration: feeStaleDuration(int(ttl/time.Second), 0), + }, + params: feeProviderParams{URL: "http://fees.test/"}, + } + p.fetch = p.fetchFees + return p +} + +func TestGetEip1559FeesCoalescesConcurrentRequests(t *testing.T) { + var upstreamRequests int32 + release := make(chan struct{}) + stubFeeTransport(t, func(r *http.Request) (*http.Response, error) { + atomic.AddInt32(&upstreamRequests, 1) + <-release + return infuraTestFeesRoundTrip() + }) + + provider := newTestInfuraProvider(time.Minute) + // unregistered collector: GetMetrics registers globally and would collide across tests + cache := prometheus.NewCounterVec(prometheus.CounterOpts{Name: "test_fee_cache"}, []string{"provider", "result"}) + provider.metrics = &common.Metrics{AlternativeFeeProviderCache: cache} + + const n = 20 + var wg sync.WaitGroup + results := make([]*bchain.Eip1559Fees, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i], _ = provider.GetEip1559Fees() + }(i) + } + // let the goroutines pile up on the single in-flight fetch before releasing it + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&upstreamRequests); got != 1 { + t.Fatalf("upstream requests = %d, want 1 (requests must coalesce)", got) + } + for i, fees := range results { + if fees == nil { + t.Fatalf("request %d got nil fees", i) + } + if fees.Medium.MaxPriorityFeePerGas.Cmp(big.NewInt(2e9)) != 0 { + t.Fatalf("request %d got medium priority fee %s, want 2 gwei", i, fees.Medium.MaxPriorityFeePerGas) + } + } + // exactly the leader is labelled fetched; singleflight's shared flag would + // have labelled it coalesced too, hiding the upstream fetch from the metric + if m := gatherMetric(t, cache, map[string]string{"result": "fetched"}); m == nil || m.GetCounter().GetValue() != 1 { + t.Fatalf("cache result fetched = %v, want 1", m.GetCounter().GetValue()) + } + if m := gatherMetric(t, cache, map[string]string{"result": "coalesced"}); m == nil || m.GetCounter().GetValue() != n-1 { + t.Fatalf("cache result coalesced = %v, want %d", m.GetCounter().GetValue(), n-1) + } +} + +func TestGetEip1559FeesRespectsTTL(t *testing.T) { + var upstreamRequests int32 + stubFeeTransport(t, func(r *http.Request) (*http.Response, error) { + atomic.AddInt32(&upstreamRequests, 1) + return infuraTestFeesRoundTrip() + }) + + provider := newTestInfuraProvider(time.Minute) + + for i := 0; i < 3; i++ { + fees, err := provider.GetEip1559Fees() + if err != nil || fees == nil { + t.Fatalf("call %d: fees=%v err=%v", i, fees, err) + } + } + if got := atomic.LoadInt32(&upstreamRequests); got != 1 { + t.Fatalf("upstream requests = %d, want 1 (calls within the TTL must be served from cache)", got) + } + + // an expired TTL must trigger exactly one new upstream fetch + provider.mux.Lock() + provider.lastSync = time.Now().Add(-2 * time.Minute) + provider.mux.Unlock() + if fees, err := provider.GetEip1559Fees(); err != nil || fees == nil { + t.Fatalf("post-TTL call: fees=%v err=%v", fees, err) + } + if got := atomic.LoadInt32(&upstreamRequests); got != 2 { + t.Fatalf("upstream requests = %d, want 2 after TTL expiry", got) + } +} + +func TestGetEip1559FeesFailurePacingAndOnchainFallback(t *testing.T) { + var fetchCalls int32 + provider := &alternativeFeeProvider{ + ttl: time.Minute, + staleSyncDuration: 10 * time.Minute, + fetch: func() (*bchain.Eip1559Fees, error) { + atomic.AddInt32(&fetchCalls, 1) + return nil, errors.New("provider down") + }, + } + + // cold start with a failing provider: nil fees and nil error, so the caller + // falls through to on-chain estimation instead of failing the request + for i := 0; i < 3; i++ { + fees, err := provider.GetEip1559Fees() + if err != nil { + t.Fatalf("call %d: unexpected error %v", i, err) + } + if fees != nil { + t.Fatalf("call %d: got fees %v from a failing provider with an empty cache", i, fees) + } + } + if got := atomic.LoadInt32(&fetchCalls); got != 1 { + t.Fatalf("fetch calls = %d, want 1 (failures must be paced, not retried per request)", got) + } + + // failures are retried at most once per ttl, not once per the 5s floor — the + // one-request-per-periodSeconds cap must hold during an outage too + provider.mux.Lock() + provider.lastFailure = time.Now().Add(-10 * time.Second) + provider.mux.Unlock() + provider.GetEip1559Fees() + if got := atomic.LoadInt32(&fetchCalls); got != 1 { + t.Fatalf("fetch calls = %d, want 1 (retry before the ttl elapsed since the last failure)", got) + } + + provider.mux.Lock() + provider.lastFailure = time.Now().Add(-2 * time.Minute) + provider.mux.Unlock() + provider.GetEip1559Fees() + if got := atomic.LoadInt32(&fetchCalls); got != 2 { + t.Fatalf("fetch calls = %d, want 2 (retry once the ttl elapsed since the last failure)", got) + } +} + +func TestGetEip1559FeesReturnsCopy(t *testing.T) { + provider := &alternativeFeeProvider{ + ttl: time.Minute, + eip1559Fees: &bchain.Eip1559Fees{ + BaseFeePerGas: big.NewInt(10), + Medium: &bchain.Eip1559Fee{MaxFeePerGas: big.NewInt(12), MaxPriorityFeePerGas: big.NewInt(2)}, + }, + lastSync: time.Now(), + } + + fees, err := provider.GetEip1559Fees() + if err != nil || fees == nil { + t.Fatalf("GetEip1559Fees() fees=%v err=%v", fees, err) + } + fees.Medium.MaxFeePerGas.SetInt64(999) + + fees2, _ := provider.GetEip1559Fees() + if fees2.Medium.MaxFeePerGas.Cmp(big.NewInt(12)) != 0 { + t.Fatalf("cache was mutated through a returned value: medium maxFeePerGas = %s, want 12", fees2.Medium.MaxFeePerGas) + } +} diff --git a/bchain/coins/eth/alternativesendtx.go b/bchain/coins/eth/alternativesendtx.go index fc9ed68d2b..488abbfee9 100644 --- a/bchain/coins/eth/alternativesendtx.go +++ b/bchain/coins/eth/alternativesendtx.go @@ -3,6 +3,9 @@ package eth import ( "context" "encoding/json" + "fmt" + "math" + "net/url" "os" "strings" "sync" @@ -10,6 +13,7 @@ import ( ethcommon "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/rpc" "github.com/golang/glog" "github.com/juju/errors" @@ -17,13 +21,107 @@ import ( "github.com/trezor/blockbook/common" ) +// storedTx is one entry of the alternative-provider pending cache. Its tx body is IMMUTABLE once +// published into mempoolTxs: nothing writes through the pointer and GetTransaction hands out a copy, +// which is what makes every read of it race-free. Mutating a body in place reopens all of them at once. type storedTx struct { - tx *bchain.RpcTransaction - time uint32 + tx *bchain.RpcTransaction + time uint32 + gen uint64 // send generation of the submission that created this entry, orders it against later sends + lastProbe uint32 // unix seconds of the last reconcile probe, 0 until the first one; see probeInterval + // missingSince is the unix time the current run of consecutive null eth_getTransactionByHash + // answers started, 0 while the relay surfaces the tx. The relay answers from one consistent store + // over its whole pending window, so a run outlasting missingTimeout() means dropped or cancelled + // and reconcile evicts; requiring a run rather than one null absorbs a transient relay fluke. See + // defaultAlternativeMissingTxTimeout. + missingSince uint32 + // The nonce slot this entry fills, decoded from the body once at insert rather than on every scan. + // The body is immutable, so these cannot drift from it. Four scans walk the whole cache - the + // pending-nonce floor on every address request, and three per accepted send - and re-parsing an + // address and a quantity per entry inside mempoolTxsMux is what made cache depth cost. + from ethcommon.Address + nonce uint64 + decoded bool +} + +// recentSender records when an address last successfully sent a transaction through an +// alternative provider and which provider URL accepted it. +type recentSender struct { + time time.Time + url string + gen uint64 // monotonic send generation, orders the send against cached-tx evictions +} + +// nonceSlot identifies the account nonce slot a transaction fills - only one transaction per +// (sender, nonce) can ever mine. +type nonceSlot struct { + addr ethcommon.Address + nonce uint64 +} + +// acceptedSlot records the newest send generation a relay accepted for a nonce slot. It outlives the +// cache entry that send produces (or fails to produce) - see slotSupersededBy. +type acceptedSlot struct { + gen uint64 + time time.Time } const alternativeMempoolTxCheckPeriod = time.Minute +// alternativeNonceRoutingTimeout caps how long after an accepted send the sender's eth_getTransactionCount +// and eth_estimateGas lookups keep being routed to the relay. It is deliberately shorter than the cache +// retention: a released sender still gets the same nonce, because the cached transaction carries it and +// the floor is applied to the primary RPC's answer too (see EthereumTypeGetNonces), while eth_estimateGas +// is called on every send-form keystroke, which is how the relay's rate quota was exhausted in #1629. The +// window still covers the minutes right after a send, when a cache entry can be missing and only the +// relay has the transaction. +const alternativeNonceRoutingTimeout = 15 * time.Minute + +// probeInterval returns how often reconcile re-asks the relay about a cached entry of the given age; the +// tick itself stays at alternativeMempoolTxCheckPeriod. A young entry is where the answer still changes +// (about to mine, or its nonce about to be consumed), so it is probed every cycle. An entry that has +// waited an hour is waiting on a builder, where probing every cycle buys a quarter-hour of eviction +// latency at sixty times the relay quota: a fresh dial per probe plus, through +// transactionSupersededByNonce, an eth_getTransactionCount per URL. Past the relay's 3h pending window +// the retention can hold an entry for tens of hours more, so probing backs off to hourly there; only +// the missing-run eviction waits on the next due probe, the timeout eviction bypasses the gate. +func probeInterval(age time.Duration) time.Duration { + switch { + case age < 10*time.Minute: + return alternativeMempoolTxCheckPeriod + case age < time.Hour: + return 5 * time.Minute + case age < 3*time.Hour: + return 15 * time.Minute + default: + return time.Hour + } +} + +// maxBackgroundFetchBacks and maxExposeFetchBacks bound the post-send fetch-backs in flight. The send +// path no longer waits for its own, so in-flight work is no longer bounded by in-flight requests. The +// allowances are separate because a refused refresh loses nothing while a refused expose loses the send +// (see the two call sites), and one shared allowance let refresh traffic starve the exposing one. +const ( + maxBackgroundFetchBacks = 16 + maxExposeFetchBacks = 16 +) + +// backgroundDrainMargin and backgroundDrainPoll bound and pace the shutdown drain (see drainBackground). +const ( + backgroundDrainMargin = 2 * time.Second + backgroundDrainPoll = 10 * time.Millisecond +) + +// backgroundKind selects which allowance a fetch-back draws on: backgroundRefresh for the ones that only +// replace a cached body, backgroundExpose for the one that is the sole thing able to expose a send. +type backgroundKind int + +const ( + backgroundRefresh backgroundKind = iota + backgroundExpose +) + // AlternativeSendTxProvider handles sending transactions to alternative providers type AlternativeSendTxProvider struct { urls []string @@ -32,20 +130,31 @@ type AlternativeSendTxProvider struct { mempoolTxs map[string]storedTx mempoolTxsMux sync.Mutex mempoolTxsTimeout time.Duration + missingTxTimeout time.Duration rpcTimeout time.Duration - mempool *bchain.MempoolEthereumType + mempool wrappedMempool metrics *common.Metrics removeTransactionFromMempool func(string) watchMempoolTxsOnce sync.Once stop chan struct{} stopOnce sync.Once + recentSenders map[ethcommon.Address]recentSender + acceptedSlots map[nonceSlot]acceptedSlot // guarded by recentSendersMux + sendGeneration uint64 // counts successful sends; guarded by recentSendersMux + recentSendersMux sync.Mutex + backgroundCount int // refresh fetch-backs in flight; guarded by backgroundMux + exposeCount int // decode-failure fetch-backs in flight; guarded by backgroundMux + backgroundIdle *sync.Cond // signalled when backgroundCount reaches 0; created lazily + backgroundMux sync.Mutex + exposeFetchBackDelay time.Duration // test seam; 0 means exposeFetchBackRetryDelay } // 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" +func NewAlternativeSendTxProvider(network string, rpcTimeout int, mempoolTxsTimeout, missingTxTimeout time.Duration, metrics *common.Metrics) *AlternativeSendTxProvider { + prefix := strings.ToUpper(network) + urls := strings.Split(os.Getenv(prefix+"_ALTERNATIVE_SENDTX_URLS"), ",") + onlyAlternative := strings.ToUpper(os.Getenv(prefix+"_ALTERNATIVE_SENDTX_ONLY")) == "TRUE" + fetchMempoolTx := strings.ToUpper(os.Getenv(prefix+"_ALTERNATIVE_FETCH_MEMPOOL_TX")) == "TRUE" // Empty URL keeps the normal public RPC send path. if len(urls) == 0 || urls[0] == "" { return nil @@ -57,12 +166,15 @@ func NewAlternativeSendTxProvider(network string, rpcTimeout int, mempoolTxsTime fetchMempoolTx: fetchMempoolTx, rpcTimeout: time.Duration(rpcTimeout) * time.Second, mempoolTxsTimeout: mempoolTxsTimeout, + missingTxTimeout: missingTxTimeout, mempoolTxs: make(map[string]storedTx), + recentSenders: make(map[ethcommon.Address]recentSender), + acceptedSlots: make(map[nonceSlot]acceptedSlot), metrics: metrics, stop: make(chan struct{}), } - - glog.Infof("Using alternative send transaction providers %v. Only alternative providers %v", urls, onlyAlternative) + // hosts only - the configured URLs commonly carry an API key + glog.Infof("Using %d alternative send transaction providers %v. Only alternative providers %v", len(urls), providerLabels(urls), onlyAlternative) if fetchMempoolTx { glog.Infof("Alternative fetch mempool tx %v", fetchMempoolTx) } @@ -70,6 +182,13 @@ func NewAlternativeSendTxProvider(network string, rpcTimeout int, mempoolTxsTime return provider } +// wrappedMempool is the slice of MempoolEthereumType the cache path drives, narrowed so tests can +// observe the calls the retention coupling depends on. +type wrappedMempool interface { + AddOrRefreshTransactionInMempool(txid string) bool + RemoveTransactionFromMempool(txid string) +} + // SetupMempool sets up connection to the mempool func (p *AlternativeSendTxProvider) SetupMempool(mempool *bchain.MempoolEthereumType, removeTransactionFromMempool func(string)) { p.mempool = mempool @@ -81,14 +200,67 @@ func (p *AlternativeSendTxProvider) SetupMempool(mempool *bchain.MempoolEthereum } } -// SendRawTransaction sends raw transaction to alternative providers +// SendRawTransaction sends raw transaction to alternative providers. A wallet waits for this call, and a +// relay slow past the wallet's deadline means a transaction the user is told failed while it is on its +// way to the chain - a re-send at the next nonce then pays the recipient twice. So the broadcast to every +// relay runs concurrently and the post-send fetch-back is not waited for at all. func (p *AlternativeSendTxProvider) SendRawTransaction(hex string) (string, error) { var txid string var retErr error + var acceptedURL string + // decoded once for the log lines and the accepted-send bookkeeping below - a send is the only + // moment Blockbook sees the sender and nonce of a private transaction, and both are what a + // later "my tx vanished" or nonce-gap report has to be reconciled against + sent, decErr := decodeAlternativeSendTx(hex) + + // Fanning out rather than looping keeps the wallet's answer off len(urls) * rpcTimeout. Results are + // collected in URL order below, so the aggregation - and which URL counts as the accepting one - + // stays as deterministic as the sequential loop it replaces. + type sendResult struct { + txid string + err error + } + results := make([]sendResult, len(p.urls)) + var wg sync.WaitGroup 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) + // pre-set, so a panic recovered below cannot leave a zero value that reads as success + results[i] = sendResult{err: errors.New(p.urls[i] + " eth_sendRawTransaction : no result")} + wg.Add(1) + go func(i int) { + defer wg.Done() + host := providerLabel(p.urls[i]) + start := time.Now() + // a panic here would take the process down: the calling request handler's own recover + // cannot reach this goroutine + defer func() { + if r := recover(); r != nil { + // count the attempt too: a panicking provider call would otherwise vanish from + // eth_alternative_sendtx_total and the broadcast would look never-tried + p.observeSendTx(host, time.Since(start), fmt.Errorf("panic in eth_sendRawTransaction: %v", r)) + glog.Errorf("eth_sendRawTransaction to %s panicked: %v", host, r) + } + }() + r, err := p.callHttpStringResult(p.urls[i], "eth_sendRawTransaction", hex) + duration := time.Since(start) + p.observeSendTx(host, duration, err) + if err != nil { + // not terminal on its own - another provider may still accept the transaction, and the + // all-rejected case is logged as an error below + glog.Warningf("eth_sendRawTransaction to provider %d/%d %s rejected %s after %v: %v", i+1, len(p.urls), host, sentTxLogString(sent, decErr), duration.Round(time.Millisecond), err) + } else { + glog.Infof("eth_sendRawTransaction to provider %d/%d %s accepted %s as txid %s in %v", i+1, len(p.urls), host, sentTxLogString(sent, decErr), r, duration.Round(time.Millisecond)) + } + results[i] = sendResult{txid: r, err: err} + }(i) + } + wg.Wait() + + for i := range results { + r, err := results[i].txid, results[i].err + if err == nil && acceptedURL == "" { + acceptedURL = p.urls[i] + } // set success return value; or error only if there was no previous success if err == nil || len(txid) == 0 { txid = r @@ -96,61 +268,773 @@ func (p *AlternativeSendTxProvider) SendRawTransaction(hex string) (string, erro } } + // keyed on acceptedURL rather than retErr, so the follow-up work does not silently depend on + // callHttpStringResult never returning an empty result without an error. With nothing accepted + // there is no send to register and no txid to fetch back - the lookup would query the zero + // hash and log a confusing not-found error. + if acceptedURL == "" { + glog.Errorf("eth_sendRawTransaction rejected by all %d alternative providers, %s: %v", len(p.urls), sentTxLogString(sent, decErr), retErr) + return txid, retErr + } + + // Ordered before the decode-dependent registration, because a send that does not decode is + // still a send: leaving it at the zero generation made it read as OLDER than every earlier + // acceptance, so an undecodable replacement lost its own slot to the predecessor it replaces + // and ended up exposed nowhere. Generations order acceptances, which is knowable here; what + // the decode adds is which slot the acceptance fills. + gen := p.nextSendGeneration() + // the transaction was decoded once at the top of the send and the result is reused for + // recent-sender registration, the RBF eviction and the cache entry below - a single ECDSA + // sender recovery instead of one per consumer. On decode failure neither eviction nor local + // caching runs, but the transaction is still handed to handleMempoolTransaction(txid, gen). + if decErr != nil { + glog.Warningf("cannot decode accepted transaction: %v", decErr) + } else { + p.registerSuccessfulSend(sent.from, sent.nonce, acceptedURL, gen) + if !strings.EqualFold(sent.txid, txid) { + // Report the locally derived hash, which is what the chain will show and what everything + // below is keyed on; the echo would name a txid Blockbook has no entry for. + glog.Errorf("eth_sendRawTransaction echoed txid %s, signed bytes hash to %s", txid, sent.txid) + } + txid = sent.txid + } + if p.onlyAlternative && p.fetchMempoolTx { - p.handleMempoolTransaction(txid) + // An accepted (from, nonce) means the relay now builds with THIS tx, so retire the slot's + // predecessor now rather than waiting for the relay to surface the replacement - a drop-mode + // cancel never is, which left the predecessor "Unconfirmed" until the timeout (#1573). Not + // proof: a predecessor already forwarded to builders can still mine, and block sync + // re-indexes it then. Still needed below the insert, which can bail out as superseded. + if decErr == nil { + p.evictReplacedByNonce(sent.from, sent.nonce, sent.txid, gen) + // Cache and index from the signed bytes before asking the relay: an accepted send must + // never end up exposed nowhere, and a relay is free to never surface what it accepted. + p.cacheMempoolTransaction(sent.txid, sent.body, gen) + // The fetch-back only probes (see probeSentTransaction), so nothing observable depends on + // it and dropping it under load is safe - reconcile re-probes the entry within a cycle. + p.inBackground(backgroundRefresh, func() { p.probeSentTransaction(sent.txid) }) + } else { + // Nothing was cached (the raw hex did not decode), so here the fetch-back is the only + // thing that can expose the transaction. Dropping it loses the accepted send: nothing + // serves it, nothing indexes it and the pending-nonce floor does not rise, which is the + // nonce-reuse precursor - so count it like a relay that does not surface what it accepted. + if !p.inBackground(backgroundExpose, func() { p.exposeAcceptedSend(txid, gen) }) && !p.stopped() { + p.observeSendNotSurfaced("dropped") + } + } } return txid, retErr } -// handleMempoolTransaction handles the transaction when using only alternative providers -func (p *AlternativeSendTxProvider) handleMempoolTransaction(txid string) (string, error) { +// providerLabel reduces a provider URL to its host for log lines and metric labels: configured +// provider URLs routinely carry an API key in the path or query, which must not end up in logs or +// on the metrics endpoint. Providers sharing a host therefore share a label; the log lines keep +// the URL's position in the configured list to tell them apart. +func providerLabel(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "unknown" + } + return u.Host +} + +// providerLabels maps providerLabel over a list of provider URLs. +func providerLabels(rawURLs []string) []string { + labels := make([]string, len(rawURLs)) + for i, u := range rawURLs { + labels[i] = providerLabel(u) + } + return labels +} + +// decodedTx is a raw transaction decoded once per send. Recovering the sender costs an elliptic +// curve operation, by far the most expensive step of a broadcast outside the network call itself, +// so the value is decoded at the top of a send and reused by every consumer below it. +type decodedTx struct { + tx *types.Transaction + sender ethcommon.Address + err error +} + +// String renders the sender and nonce for a log line. Failures are rendered rather than returned - +// this only feeds logging and must never change a send's outcome. +func (d decodedTx) String() string { + if d.tx == nil { + return fmt.Sprintf("undecodable tx (%v)", d.err) + } + if d.err != nil { + return fmt.Sprintf("tx nonce %d with unrecoverable sender (%v)", d.tx.Nonce(), d.err) + } + return fmt.Sprintf("tx from %s nonce %d", d.sender.Hex(), d.tx.Nonce()) +} + +// observeSendTx records the outcome and latency of one provider's eth_sendRawTransaction call. +// Per-provider is the granularity that matters: a broadcast succeeds if any provider accepts, so +// a single provider degrading (timeouts, rate limits) stays invisible in the overall send rate +// until it is the last one left. +func (p *AlternativeSendTxProvider) observeSendTx(host string, duration time.Duration, err error) { + if p.metrics == nil { + return + } + // provider_host, not provider: the registry's provider label carries a symbolic implementation + // name (infura, 1inch), while these providers are configured as a list of URLs and a host is + // the only stable identity they have - two value domains must not share a label name + status := bchain.SendTxStatus(err) + if p.metrics.EthAlternativeSendTx != nil { + p.metrics.EthAlternativeSendTx.With(common.Labels{"provider_host": host, "status": status, "reason": bchain.ClassifySendTxError(err)}).Inc() + } + if p.metrics.EthAlternativeSendTxDuration != nil { + p.metrics.EthAlternativeSendTxDuration.With(common.Labels{"provider_host": host, "status": status}).Observe(duration.Seconds()) + } +} + +// decodeRawTx unmarshals a raw transaction hex and recovers its sender. The chain id needed to +// derive the sender is taken from the transaction itself. A decoded transaction is kept even when +// the sender cannot be recovered, so a caller that only logs can still report its nonce. +func decodeRawTx(rawTxHex string) decodedTx { + var tx types.Transaction + if err := tx.UnmarshalBinary(ethcommon.FromHex(rawTxHex)); err != nil { + return decodedTx{err: err} + } + sender, err := types.Sender(types.LatestSignerForChainID(tx.ChainId()), &tx) + return decodedTx{tx: &tx, sender: sender, err: err} +} + +// inBackground runs the post-send fetch-back off the send path, so the wallet's answer does not wait for +// a relay round-trip it cannot observe the outcome of. It reports whether it took the work: it declines +// at shutdown and drops beyond the per-kind allowance rather than queueing, because a queue either grows +// unbounded or pushes back into the wallet's deadline. What a drop costs differs per call site. +func (p *AlternativeSendTxProvider) inBackground(kind backgroundKind, fetchBack func()) bool { + if !p.startBackground(kind) { + return false + } + go func() { + defer p.finishBackground(kind) + // this goroutine outlives the request handler, whose own recover cannot protect it + defer func() { + if r := recover(); r != nil { + glog.Errorf("background fetch-back panicked: %v", r) + } + }() + fetchBack() + }() + + return true +} + +func (p *AlternativeSendTxProvider) inFlight(kind backgroundKind) int { + if kind == backgroundExpose { + return p.exposeCount + } + return p.backgroundCount +} + +// startBackground reserves one of the per-kind fetch-back slots, reporting whether the caller got one. A +// plain sync.WaitGroup cannot serve here: Add would race the Wait in waitForRefreshes (the documented +// reuse misuse, which panics and leaves the counter stuck), and it could not cap anything. +func (p *AlternativeSendTxProvider) startBackground(kind backgroundKind) bool { + p.backgroundMux.Lock() + defer p.backgroundMux.Unlock() + // under the mutex, so a send racing shutdown cannot pass this check and then spawn a goroutine + // the drain in shutdown has already stopped waiting for + if p.stopped() { + return false + } + max := maxBackgroundFetchBacks + if kind == backgroundExpose { + max = maxExposeFetchBacks + } + if p.inFlight(kind) >= max { + glog.Warningf("skipping fetch-back: %d of kind %d already in flight", p.inFlight(kind), kind) + return false + } + if p.backgroundIdle == nil { + p.backgroundIdle = sync.NewCond(&p.backgroundMux) + } + if kind == backgroundExpose { + p.exposeCount++ + } else { + p.backgroundCount++ + } + return true +} + +// stopped reports whether shutdown has been requested. A fetch-back declined at shutdown must not be +// reported as a lost send: an alert on the nonce-reuse precursor must not fire on every restart that +// catches a send in flight. +func (p *AlternativeSendTxProvider) stopped() bool { + if p.stop == nil { + return false + } + select { + case <-p.stop: + return true + default: + return false + } +} + +func (p *AlternativeSendTxProvider) finishBackground(kind backgroundKind) { + p.backgroundMux.Lock() + defer p.backgroundMux.Unlock() + if kind == backgroundExpose { + p.exposeCount-- + } else { + p.backgroundCount-- + } + if p.backgroundCount+p.exposeCount == 0 { + p.backgroundIdle.Broadcast() + } +} + +// waitForRefreshes blocks until every background fetch-back has finished. For tests, which need a +// deterministic cache state after a send; production never waits. +func (p *AlternativeSendTxProvider) waitForRefreshes() { + p.backgroundMux.Lock() + defer p.backgroundMux.Unlock() + if p.backgroundIdle == nil { + p.backgroundIdle = sync.NewCond(&p.backgroundMux) + } + for p.backgroundCount+p.exposeCount > 0 { + p.backgroundIdle.Wait() + } +} + +// probeSentTransaction asks the relay about a transaction the send path already cached from its signed +// bytes, and writes nothing. Adopting the relay's body would buy nothing (the signed bytes carry it all) +// and could serve its hash as the entry's identity, undoing the send path's own txid override. Evicting +// on a mined answer would be premature: the relay can see the block before Blockbook indexes it, and +// eviction clears the address index too, so the transaction would briefly be in neither store; block +// sync removes it as sync_removed, with reconcile as the backstop. +func (p *AlternativeSendTxProvider) probeSentTransaction(txid string) { tx, found, err := p.getTransactionFromProviders(txid) if err != nil { + p.observeSendNotSurfaced("error") glog.Errorf("eth_getTransactionByHash from alternative providers returned error %v", err) - return txid, err - } else if !found { + return + } + if !found { + p.observeSendNotSurfaced("not_found") glog.Errorf("eth_getTransactionByHash from alternative providers did not find txid %s", txid) - return txid, bchain.ErrTxNotFound + return + } + if tx.BlockNumber != "" { + glog.Infof("eth_getTransactionByHash from alternative providers already reports txid %s mined; leaving it to block sync", txid) + } +} + +// alternativeSendTx is everything the send path derives from an accepted raw transaction. +type alternativeSendTx struct { + from ethcommon.Address + nonce uint64 + txid string + body *bchain.RpcTransaction +} + +// sentTxLogString renders the sender and nonce of a decoded send for a log line. Failures are +// rendered rather than returned - this only feeds logging and must never change a send's outcome. +func sentTxLogString(sent *alternativeSendTx, decErr error) string { + if sent == nil { + return fmt.Sprintf("undecodable tx (%v)", decErr) + } + return fmt.Sprintf("tx from %s nonce %d", sent.from.Hex(), sent.nonce) +} + +// decodeAlternativeSendTx derives everything the send path needs from the signed bytes in one decode, +// taking the chain id from the transaction itself: the (from, nonce) slot the send fills, the txid the +// chain will show whatever the relay echoes, and a complete pending body, so an accepted send can be +// surfaced and indexed without the relay returning it. Encoding follows eth_getTransactionByHash: +// lower-case hex addresses, minimal quantities, gasPrice carrying the fee cap for typed fee-market txs. +func decodeAlternativeSendTx(rawTxHex string) (*alternativeSendTx, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(ethcommon.FromHex(rawTxHex)); err != nil { + return nil, err + } + // LatestSignerForChainID PANICS for a chain id of zero or nil, which is what an unprotected + // (pre-EIP-155) legacy transaction decodes to - and a panic here would abort the response to a + // wallet whose transaction the relay has already accepted. Such a transaction is Homestead-signed. + var signer types.Signer = types.HomesteadSigner{} + if chainID := tx.ChainId(); chainID != nil && chainID.Sign() > 0 { + signer = types.LatestSignerForChainID(chainID) + } + sender, err := types.Sender(signer, &tx) + if err != nil { + return nil, err + } + body := &bchain.RpcTransaction{ + AccountNonce: hexutil.EncodeUint64(tx.Nonce()), + GasPrice: hexutil.EncodeBig(tx.GasFeeCap()), + GasLimit: hexutil.EncodeUint64(tx.Gas()), + Value: hexutil.EncodeBig(tx.Value()), + Payload: hexutil.Encode(tx.Data()), + Hash: tx.Hash().Hex(), + From: strings.ToLower(sender.Hex()), + // BlockNumber, BlockHash and TransactionIndex stay empty - this is a mempool transaction, + // which is also what makes GetTransaction render it through the mempool branch. + } + if tx.Type() != types.LegacyTxType && tx.Type() != types.AccessListTxType { + body.MaxFeePerGas = hexutil.EncodeBig(tx.GasFeeCap()) + body.MaxPriorityFeePerGas = hexutil.EncodeBig(tx.GasTipCap()) + } + if to := tx.To(); to != nil { + body.To = strings.ToLower(to.Hex()) + } + return &alternativeSendTx{from: sender, nonce: tx.Nonce(), txid: body.Hash, body: body}, nil +} + +// routingTimeout is how long after an accepted send the sender's nonce and gas-estimate lookups keep +// being routed to the relay: alternativeNonceRoutingTimeout, or the cache retention when that is +// configured shorter. Computed rather than stored so a provider built by a test from a struct literal +// cannot end up with a zero horizon that silently routes nothing. +func (p *AlternativeSendTxProvider) routingTimeout() time.Duration { + if p.mempoolTxsTimeout < alternativeNonceRoutingTimeout { + return p.mempoolTxsTimeout + } + return alternativeNonceRoutingTimeout +} + +// missingTimeout is how long a cached transaction may stay missing from the relay - consecutive null +// eth_getTransactionByHash answers - before reconcile evicts it; the cache timeout stays the backstop +// regardless. Defaulted here as well so a provider built by a test from a struct literal cannot end up +// with a zero horizon that evicts on the first null answer. +func (p *AlternativeSendTxProvider) missingTimeout() time.Duration { + if p.missingTxTimeout > 0 { + return p.missingTxTimeout + } + return defaultAlternativeMissingTxTimeout +} + +// nextSendGeneration assigns the send generation of an accepted submission, sweeping the expired +// entries of both send-tracking maps on the way. Every acceptance takes one, whether or not its raw hex +// decodes, so the generation always orders it against the other acceptances; the caller carries it to +// the cache entry it creates, so releaseRecentSender and slotSupersededBy can order evictions against +// later sends. +func (p *AlternativeSendTxProvider) nextSendGeneration() uint64 { + p.recentSendersMux.Lock() + defer p.recentSendersMux.Unlock() + p.sweepRecentSendsLocked() + p.sendGeneration++ + + return p.sendGeneration +} + +// registerSuccessfulSend records the sender of an accepted transaction so useForNonces routes its nonce +// lookups to the accepting URL - the one provider guaranteed to know the tx - for the routing window, +// and records the (sender, nonce) slot so the acceptance survives a fetch-back that produces no cache +// entry (see slotSupersededBy). gen comes from nextSendGeneration; only a strictly newer acceptance may +// overwrite either entry, since separating the two calls left nothing else ordering them. +func (p *AlternativeSendTxProvider) registerSuccessfulSend(sender ethcommon.Address, nonce uint64, acceptedURL string, gen uint64) { + now := time.Now() + p.recentSendersMux.Lock() + defer p.recentSendersMux.Unlock() + if p.recentSenders == nil { + p.recentSenders = make(map[ethcommon.Address]recentSender) + } + if p.acceptedSlots == nil { + p.acceptedSlots = make(map[nonceSlot]acceptedSlot) + } + if s, found := p.recentSenders[sender]; !found || gen > s.gen { + p.recentSenders[sender] = recentSender{time: now, url: acceptedURL, gen: gen} + } + slot := nonceSlot{addr: sender, nonce: nonce} + if s, found := p.acceptedSlots[slot]; !found || gen > s.gen { + p.acceptedSlots[slot] = acceptedSlot{gen: gen, time: now} + } +} + +// sweepRecentSendsLocked drops the expired entries of both send-tracking maps and returns the instant +// it swept at. The two horizons differ on purpose: routing is a quota decision that stops paying off +// within minutes, while an accepted slot must outlive every predecessor it could still retire, i.e. as +// long as one can land (see slotSupersededBy). Callers must hold recentSendersMux. +func (p *AlternativeSendTxProvider) sweepRecentSendsLocked() time.Time { + now := time.Now() + routing := p.routingTimeout() + for addr, s := range p.recentSenders { + if now.Sub(s.time) > routing { + delete(p.recentSenders, addr) + } } + for slot, s := range p.acceptedSlots { + if now.Sub(s.time) > p.mempoolTxsTimeout { + delete(p.acceptedSlots, slot) + } + } + return now +} +// sweepRecentSends is the reconcile loop's call into the sweep above. Without it the maps shrink only +// when a new send arrives, so an instance that goes quiet after a burst holds every entry until the next +// one. +func (p *AlternativeSendTxProvider) sweepRecentSends() { + p.recentSendersMux.Lock() + defer p.recentSendersMux.Unlock() + p.sweepRecentSendsLocked() +} + +// slotSupersededBy reports whether a send strictly newer than gen has already been accepted for the +// (from, nonce) slot. It makes SendRawTransaction's acceptance-time retirement durable: that retirement +// scans only the cache, so it finds nothing while the predecessor's own fetch-back is still in flight, +// and if the replacement's fetch-back then fails (the Blink drop-mode case) no cache entry ever exists to +// order the predecessor against - which left it surfaced as pending until the cache timeout (#1573). +func (p *AlternativeSendTxProvider) slotSupersededBy(from ethcommon.Address, nonce uint64, gen uint64) bool { + slot := nonceSlot{addr: from, nonce: nonce} + p.recentSendersMux.Lock() + defer p.recentSendersMux.Unlock() + s, found := p.acceptedSlots[slot] + if !found { + return false + } + if time.Since(s.time) > p.mempoolTxsTimeout { + delete(p.acceptedSlots, slot) + return false + } + return s.gen > gen +} + +// useForNonces reports whether nonce and gas-estimate lookups for addr should be routed to the +// alternative provider. Only an address that sent through it within the routing window (see +// routingTimeout) can have a pending transaction the primary RPC does not know about AND no cache entry +// carrying it; for everybody else the primary is authoritative and the provider round-trip is pure waste +// of its rate-limit quota. Accepted limitations: a restart wipes the map, a transaction still pending +// past the window - after which the cache and the pending-nonce floor, not the relay, keep its nonce +// reserved - and sends accepted outside this instance, including by another replica in a load-balanced +// deployment without request affinity (see docs/env.md). +func (p *AlternativeSendTxProvider) useForNonces(addr ethcommon.Address) bool { + p.recentSendersMux.Lock() + defer p.recentSendersMux.Unlock() + s, found := p.recentSenders[addr] + if !found { + return false + } + if time.Since(s.time) > p.routingTimeout() { + delete(p.recentSenders, addr) + return false + } + return true +} + +// releaseRecentSender drops the sender's nonce-routing entry once its last cached transaction left the +// cache (mined, superseded, replaced or timed out), so address polling stops consuming the provider's +// quota before the routing window ends. The entry is kept when its send generation is newer than the +// evicted transaction's: the sender submitted again after that transaction was cached (even within the +// same wall-clock second) and the newer transaction may have no cache entry of its own. Residual risk, +// accepted: an UNCACHED send OLDER than the evicted transaction cannot be represented and loses its +// routing here - it needs a failed fetch-back, and sequential nonces mean an older transaction cannot +// still be pending once a newer one mined. +func (p *AlternativeSendTxProvider) releaseRecentSender(sender ethcommon.Address, evictedTxGen uint64) { + p.recentSendersMux.Lock() + defer p.recentSendersMux.Unlock() + s, found := p.recentSenders[sender] + if !found { + return + } + if s.gen > evictedTxGen { + return + } + delete(p.recentSenders, sender) +} + +// cachedNoncesFor returns the account nonces of the private transactions the cache holds for addr. +// Decoding through txSenderAndNonce keeps a body with no sender out of the answer instead of folding it +// into the zero address's floor. +func (p *AlternativeSendTxProvider) cachedNoncesFor(addr ethcommon.Address) map[uint64]struct{} { + nonces := make(map[uint64]struct{}) 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 + defer p.mempoolTxsMux.Unlock() + for _, storedTx := range p.mempoolTxs { + from, nonce, ok := storedTx.slot() + if !ok || from != addr { + continue + } + nonces[nonce] = struct{}{} + } + return nonces +} + +// raiseToPendingFloor advances the backend's pending nonce across the CONTIGUOUS run of in-flight +// nonces that starts at it, and reports whether one sits above that run. In flight means either +// cached here or declared by the caller (see server.WsPrivatePending): both are transactions that +// hold a nonce slot, so both belong in the same walk, and a wallet that declares a send this +// instance never cached gets the same answer as one whose send it did. +// +// Blockbook exposes the cached txs as pending, so answering below the run would hand a wallet the +// nonce of one that is in flight. Answering above a hole is the opposite failure and the one #1675 +// describes: N+1 can be in flight while nothing fills N - an accepted send that never reached the +// cache, or a reorg re-exposing the slot - and a wallet given N+2 there queues every later send +// behind a nonce that may never be consumed, which is all a blind max+1 floor could answer. The walk +// yields pending <= floor <= pending+len(nonces), always consistent with what Blockbook displays. +// +// The trade this accepts: an under-reporting backend now lowers the answer where max+1 rode over it. +// That input is indistinguishable from a real hole, and costs one collision that resolves in a block +// against a wallet stranded for the whole cache retention. +func (p *AlternativeSendTxProvider) raiseToPendingFloor(addr ethcommon.Address, pending uint64, declared []uint64) (uint64, bool) { + nonces := p.cachedNoncesFor(addr) + for _, nonce := range declared { + nonces[nonce] = struct{}{} + } + floor := pending + for { + if _, cached := nonces[floor]; !cached { + break + } + if floor == math.MaxUint64 { break } + floor++ } - p.mempoolTxs[txid] = storedTx{tx: tx, time: uint32(time.Now().Unix())} - p.mempoolTxsMux.Unlock() + for nonce := range nonces { + if nonce > floor { + return floor, true + } + } + return floor, false +} - 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) +// handleMempoolTransaction fetches the transaction back from the alternative providers and caches it - +// one ask; exposeAcceptedSend is the retrying send-path wrapper. A failed lookup (the provider error) +// is returned distinct from the relay answering null (bchain.ErrTxNotFound), because per the relay's +// contract an error means retry-the-poll while only a null is a statement about the transaction; the +// callers meter the two differently. gen must be THIS submission's generation rather than the sender's +// current one, which a concurrent send can have bumped during the round-trip: stamping the newer one +// would let this entry's eviction release the sender's routing while that newer transaction is still +// pending. +func (p *AlternativeSendTxProvider) handleMempoolTransaction(txid string, gen uint64) (string, error) { + tx, found, err := p.getTransactionFromProviders(txid) + if err != nil { + return txid, err + } + if !found { + return txid, bchain.ErrTxNotFound + } + if tx.BlockNumber != "" { + // Already mined: block sync owns it from here. Caching it would surface it as pending. + glog.Infof("eth_getTransactionByHash from alternative providers returned mined txid %s", txid) + return txid, nil + } + + p.cacheMempoolTransaction(txid, tx, gen) + + return txid, nil +} + +// exposeFetchBackAttempts and exposeFetchBackRetryDelay bound how long exposeAcceptedSend keeps +// re-asking the relay. No cache entry exists on that path, so nothing ever re-asks after it gives up - +// reconcile walks only cached entries. The relay answers from one consistent store, so the first ask +// should already surface an accepted send; the retries absorb a transient lookup error, which the +// relay's contract says means retry, never gone. Four asks over ~30s also stay inside the 96s +// revert-protection retention, the shortest window an accepted send can have. +const ( + exposeFetchBackAttempts = 4 + exposeFetchBackRetryDelay = 10 * time.Second +) + +// exposeAcceptedSend fetches an accepted send back from the relay and caches it, re-asking up to +// exposeFetchBackAttempts times. Reached only when the raw hex did not decode, which makes this +// fetch-back the only thing that can expose the transaction - so unlike the probe path, a final +// failure means the send is exposed NOWHERE (not served, not indexed, floor not raised), for +// not_found and error exactly as for dropped. The failure is observed once, at the final attempt, +// not per ask. +func (p *AlternativeSendTxProvider) exposeAcceptedSend(txid string, gen uint64) { + for attempt := 1; ; attempt++ { + _, err := p.handleMempoolTransaction(txid, gen) + if err == nil { + return } + if attempt < exposeFetchBackAttempts && !p.stopped() { + select { + case <-p.stop: + return + case <-time.After(p.exposeRetryDelay()): + } + continue + } + if err == bchain.ErrTxNotFound { + p.observeSendNotSurfaced("not_found") + glog.Errorf("eth_getTransactionByHash from alternative providers did not find accepted send %s; it is exposed nowhere", txid) + } else { + p.observeSendNotSurfaced("error") + glog.Errorf("eth_getTransactionByHash from alternative providers returned error %v for accepted send %s; it is exposed nowhere", err, txid) + } + return + } +} + +// exposeRetryDelay is exposeFetchBackRetryDelay unless a test shortens it through the struct field. +func (p *AlternativeSendTxProvider) exposeRetryDelay() time.Duration { + if p.exposeFetchBackDelay > 0 { + return p.exposeFetchBackDelay + } + return exposeFetchBackRetryDelay +} + +// normalizeTxid canonicalizes a transaction hash to the form the cache is keyed on - 0x-prefixed +// lower-case, the encoding of tx.Hash().Hex() the send path inserts under. The cache is authoritative +// for a relay-accepted send (the primary RPC does not know it), so a lookup or removal arriving in any +// other spelling - upper case, missing prefix - would read as the transaction not existing anywhere, +// which is the exposed-nowhere outcome this subsystem exists to prevent. Applied at every boundary +// that takes a caller-supplied txid, so no single call site has to remember it. +func normalizeTxid(txid string) string { + return ethcommon.HexToHash(txid).Hex() +} + +// cacheMempoolTransaction caches a pending transaction body under txid and indexes it in the wrapped +// Blockbook mempool, ordering itself against concurrent sends for the same (from, nonce) slot through +// the send generation gen. Reached from the send path, and from handleMempoolTransaction when the +// signed bytes did not decode - there txid is the relay's echo, which normalizeTxid folds onto the +// cache's canonical key form. +func (p *AlternativeSendTxProvider) cacheMempoolTransaction(txid string, tx *bchain.RpcTransaction, gen uint64) { + txid = normalizeTxid(txid) + from, nonce, decoded := txSenderAndNonce(tx) + + // checked before the cache scan below: the scan only sees slots that produced an entry + if decoded && p.slotSupersededBy(from, nonce, gen) { + return + } + + if !p.insertMempoolTx(txid, tx, gen, from, nonce, decoded) { + // a newer replacement already occupies this nonce slot + return + } + + // Retire any cached predecessor sharing this tx's sender and nonce. The send path already does + // this at acceptance time (see SendRawTransaction, #1573); repeating it here is a no-op in that + // flow and covers the decode-failure path. + if decoded { + p.evictReplacedByNonce(from, nonce, txid, gen) } if p.mempool != nil { - p.mempool.AddTransactionToMempool(txid) + // AddOrRefresh, not Add: a re-send restamps the cache entry, and the wrapped entry must age with + // it or the mempool sweep drops the address index while the cache still serves the tx (#1573). + p.mempool.AddOrRefreshTransactionInMempool(txid) + // A concurrent higher-generation send for this slot can evict txid from both stores during the + // add above, which then re-inserts it into the wrapped mempool only - and reconcile walks just + // the provider cache, so that orphan would linger as "Unconfirmed" until the wrapped mempool + // sweep (#1573). The lock covers only a map read, never a call. + p.mempoolTxsMux.Lock() + _, stillCached := p.mempoolTxs[txid] + p.mempoolTxsMux.Unlock() + if !stillCached { + p.mempool.RemoveTransactionFromMempool(txid) + } } +} - return txid, nil +// insertMempoolTx inserts the entry unless a strictly newer send for the same (from, nonce) slot has +// already cached its own, reporting whether it inserted. Deliberately a separate function so the unlock +// is deferred: a panic leaving mempoolTxsMux held would deadlock every send, read, reconcile and +// nonce-floor lookup instead of crashing the process, since the fetch-back goroutine recovers panics. +func (p *AlternativeSendTxProvider) insertMempoolTx(txid string, tx *bchain.RpcTransaction, gen uint64, from ethcommon.Address, nonce uint64, decoded bool) bool { + p.mempoolTxsMux.Lock() + defer p.mempoolTxsMux.Unlock() + if p.mempoolTxs == nil { + p.mempoolTxs = make(map[string]storedTx) + } + // Skip a stale insert: a concurrent higher-generation send for this slot may already have cached its + // replacement, and inserting this older submission would surface a second pending tx for the nonce + // and let the caller's eviction drop the newer one. The comparison must stay the plain `>` that + // evictReplacedByNonce applies, or a gen-0 submission neither skips here nor evicts there. + if decoded { + for otherTxid, st := range p.mempoolTxs { + if otherTxid == txid { + continue + } + if f, n, ok := st.slot(); ok && f == from && n == nonce && st.gen > gen { + return false + } + } + } + p.mempoolTxs[txid] = storedTx{tx: tx, time: uint32(time.Now().Unix()), gen: gen, from: from, nonce: nonce, decoded: decoded} + return true +} + +// slot returns the nonce slot the entry fills, from the copy decoded at insert. It falls back to +// decoding the body for an entry that did not come through insertMempoolTx - only tests build those, and +// the fallback is what keeps them equivalent to a real one rather than silently invisible to every scan. +// A body carrying no sender decodes to ok=false either way, so the two paths never disagree. +func (st storedTx) slot() (ethcommon.Address, uint64, bool) { + if st.decoded { + return st.from, st.nonce, true + } + + return txSenderAndNonce(st.tx) +} + +// slotLabel renders a cached entry's sender and nonce for a log line, empty when the body cannot be +// decoded, so the nonce gap that keeps a transaction pending is visible in the log itself. +func slotLabel(st storedTx) string { + from, nonce, ok := st.slot() + if !ok { + return "" + } + return fmt.Sprintf(" (from %s, nonce %d)", from.Hex(), nonce) +} + +// txSenderAndNonce decodes the sender address and account nonce of a cached RPC transaction, reporting +// ok=false when either is missing or unparsable so callers skip the entry rather than act on a zero value. +func txSenderAndNonce(tx *bchain.RpcTransaction) (ethcommon.Address, uint64, bool) { + if tx == nil || tx.From == "" || tx.AccountNonce == "" { + return ethcommon.Address{}, 0, false + } + nonce, err := hexutil.DecodeUint64(tx.AccountNonce) + if err != nil { + return ethcommon.Address{}, 0, false + } + return ethcommon.HexToAddress(tx.From), nonce, true +} + +// evictReplacedByNonce retires EVERY cached transaction sharing (from, nonce) with the newly accepted +// (keepTxid, keepGen) - a replacement for a slot means the others can never mine - and counts each exit +// as rbf_replaced. Matching is by decoded address and numeric nonce, so relay differences in hex casing +// cannot hide a predecessor. A strictly higher generation is left intact, so an older submission's slow +// fetch-back cannot evict the newer replacement; keepGen 0 (unknown send order) therefore evicts only +// other generation-0 entries. See docs/evm-send.md for why all matches, not just the first. +func (p *AlternativeSendTxProvider) evictReplacedByNonce(from ethcommon.Address, nonce uint64, keepTxid string, keepGen uint64) { + // Collect every victim, then remove them after unlocking: removeMempoolTx re-acquires this same + // non-reentrant mutex, so removing inside the scan would deadlock the provider. + type victim struct { + txid string + time uint32 + } + var victims []victim + p.mempoolTxsMux.Lock() + for txid, storedTx := range p.mempoolTxs { + if txid == keepTxid { + continue + } + cachedFrom, cachedNonce, ok := storedTx.slot() + if !ok || cachedFrom != from || cachedNonce != nonce { + continue + } + // see the doc comment; insertMempoolTx's staleness check uses the same comparison, so the two + // rules cannot disagree about a slot + if storedTx.gen > keepGen { + continue + } + victims = append(victims, victim{txid: txid, time: storedTx.time}) + } + p.mempoolTxsMux.Unlock() + + for _, v := range victims { + glog.Infof("eth_sendRawTransaction replacing txid %s by %s", v.txid, keepTxid) + // Meter the exit only if this call is the one that removed the predecessor: the acceptance-time + // pass, the cacheMempoolTransaction pass and a concurrent reconcile eviction can all target it. + if p.removeMempoolTx(v.txid) { + p.observeMempoolReconciliation("rbf_replaced") + p.observeMempoolTxResidence("rbf_replaced", v.time) + } + } } -// GetTransaction gets a transaction from alternative mempool cache +// GetTransaction gets a transaction from alternative mempool cache. The txid is normalized to the +// cache's key form first: the caller's spelling comes off the wire, and a case-mismatch miss here +// falls through to the primary RPC, which by design does not have the private transaction. func (p *AlternativeSendTxProvider) GetTransaction(txid string) (*bchain.RpcTransaction, bool) { if !p.fetchMempoolTx { return nil, false } + txid = normalizeTxid(txid) var storedTx storedTx var found bool @@ -161,17 +1045,25 @@ func (p *AlternativeSendTxProvider) GetTransaction(txid string) (*bchain.RpcTran 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) + // The reconcile loop's staleness timeout, reached on the read path first. It goes through + // removeMempoolTx so the wrapped mempool's address index is cleared too, or the expired tx + // stays listed as pending whenever the caller's own primary lookup errors instead of + // returning null. + if p.removeMempoolTx(txid) { + p.observeMempoolReconciliation("timeout") + p.observeMempoolTxResidence("timeout", storedTx.time) + } + return nil, false + } + if storedTx.tx == nil { return nil, false } - return storedTx.tx, true + // A copy, never the cached body: the caller passes it to EthTxToTx with fixEIP55=true, which + // rewrites From and To in place holding no lock - a data race against every reader here. + // RpcTransaction is all strings, so this shallow copy is complete; deep-copy any + // reference-typed field added later. + body := *storedTx.tx + return &body, true } return nil, false @@ -190,13 +1082,39 @@ func (p *AlternativeSendTxProvider) watchMempoolTxs() { } } -// shutdown stops the background mempool reconciliation goroutine. Safe to call on a -// nil receiver and more than once. +// shutdown stops the background mempool reconciliation goroutine. Safe on a nil receiver and repeated. func (p *AlternativeSendTxProvider) shutdown() { if p == nil || p.stop == nil { return } p.stopOnce.Do(func() { close(p.stop) }) + p.drainBackground() +} + +// drainBackground waits out the fetch-backs already in flight when shutdown was requested, so none of +// them is still running when the caller tears down what they write to: a fetch-back reaching +// cacheMempoolTransaction pushes a NewTx through the wrapped mempool, and returning before that lands +// puts the push after the public server has closed and on into the deferred database close. +// +// Bounded rather than unconditional, because closing the shutdown channel does not stop a probe: an +// HTTP rpc.Client's Close is a no-op, so a request already issued runs to its own rpcTimeout whatever +// happens here. The bound is that timeout plus a margin, which is how long the last probe can take; +// past it the work is abandoned, since a shutdown that does not finish is worse than a lost probe. +func (p *AlternativeSendTxProvider) drainBackground() { + deadline := time.Now().Add(p.rpcTimeout + backgroundDrainMargin) + for { + p.backgroundMux.Lock() + inFlight := p.backgroundCount + p.exposeCount + p.backgroundMux.Unlock() + if inFlight == 0 { + return + } + if time.Now().After(deadline) { + glog.Warningf("shutdown: abandoning %d alternative-provider fetch-backs still in flight", inFlight) + return + } + time.Sleep(backgroundDrainPoll) + } } func (p *AlternativeSendTxProvider) reconcileMempoolTxs() { @@ -205,6 +1123,8 @@ func (p *AlternativeSendTxProvider) reconcileMempoolTxs() { tx storedTx } + p.sweepRecentSends() + p.mempoolTxsMux.Lock() txs := make([]cachedTx, 0, len(p.mempoolTxs)) for txid, tx := range p.mempoolTxs { @@ -217,20 +1137,31 @@ func (p *AlternativeSendTxProvider) reconcileMempoolTxs() { 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 { + age := time.Since(time.Unix(int64(tx.tx.time), 0)) + // a freshly submitted tx was cached by the send path moments ago and its own fetch-back is + // probing the relay; re-asking within the first check period only spends relay quota + if age < alternativeMempoolTxCheckPeriod { p.observeMempoolReconciliation("skipped_fresh") continue } + // an entry that has already waited is re-asked less often (see probeInterval); the timeout + // eviction below is not gated on it, so a backed-off entry still leaves on schedule timedOut := time.Unix(int64(tx.tx.time), 0).Before(time.Now().Add(-p.mempoolTxsTimeout)) + if !timedOut && tx.tx.lastProbe != 0 && time.Since(time.Unix(int64(tx.tx.lastProbe), 0)) < probeInterval(age) { + p.observeMempoolReconciliation("skipped_backoff") + continue + } + p.markProbed(tx.txid, tx.tx) 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 { + // the third boundary exit: without its own line the surfacing record would go silent + // during a relay outage, exactly when the relay's behavior is most in question + glog.Warningf("alternative mempool: evicting %s at the cache timeout, provider unreachable (%v)%s, age %s", tx.txid, err, slotLabel(tx.tx), age.Round(time.Second)) p.evictMempoolTx("timeout", tx.txid, tx.tx.time) continue } + glog.Warningf("eth_getTransactionByHash from alternative provider failed for %s: %v", tx.txid, err) p.observeMempoolReconciliation("provider_error") continue } @@ -239,35 +1170,60 @@ func (p *AlternativeSendTxProvider) reconcileMempoolTxs() { 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. + // The provider answered without error and the tx is not mined. A nonce already consumed by a + // different transaction (e.g. a replacement submitted outside Blockbook) means this one can never + // mine, so evict it deterministically rather than wait for the timeout - whether or not the + // provider still surfaces it, since a spent nonce is a positive, irreversible on-chain fact. Only + // nonces strictly below the confirmed account nonce count; equal or higher are still mineable. 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. + // The relay answers eth_getTransactionByHash from one consistent store over the whole + // pending window (the Blinklabs alignment that closed #1573's root cause), so a null is no + // longer the non-authoritative "stopped surfacing" it used to be: a run of nulls outlasting + // missingTimeout means the tx was dropped or cancelled - a drop-mode cancel leaves no + // replacement behind to retire it - and the entry is evicted, releasing its nonce slot. + // Requiring a short run rather than a single null absorbs a transient relay fluke; a relay + // that stops answering while a tx is still mineable is accommodated by configuring + // alternativeMissingTxTimeout at or above the cache retention, which restores timeout-only + // eviction. + // An entry at the cache timeout leaves as timeout whatever the final probe answered, so + // provider_missing stays the missing-run rule's own exit and its residence reads as "how + // long after the drop". if timedOut { + glog.Warningf("alternative mempool: evicting %s at the cache timeout, last answer was null%s, age %s", tx.txid, slotLabel(tx.tx), age.Round(time.Second)) + p.evictMempoolTx("timeout", tx.txid, tx.tx.time) + continue + } + missingSince, startedMissing := p.markMissing(tx.txid, tx.tx) + // Logged on the transition, not per probe: the record of when the relay stopped answering for + // a tx that may still be mineable, telling a relay undercutting its window from a real drop. + if startedMissing { + // the eviction check reruns only at the next due probe, which the age backoff can push + // well past missingTimeout - name that real horizon, not missingTimeout alone + horizon := max(p.missingTimeout(), probeInterval(age)) + glog.Warningf("alternative mempool: %s no longer surfaced by the alternative provider%s, age %s; evicting at the next probe, no sooner than %s from now, unless it reappears", tx.txid, slotLabel(tx.tx), age.Round(time.Second), horizon) + } + if missingSince != 0 && time.Since(time.Unix(int64(missingSince), 0)) >= p.missingTimeout() { + glog.Warningf("alternative mempool: evicting %s%s, null answers for %s, age %s", tx.txid, slotLabel(tx.tx), time.Since(time.Unix(int64(missingSince), 0)).Round(time.Second), age.Round(time.Second)) p.evictMempoolTx("provider_missing", tx.txid, tx.tx.time) continue } p.observeMempoolReconciliation("provider_missing_pending") continue } + // surfaced (again): a transient gap must not accumulate toward the missing eviction + if missingSince := p.clearMissing(tx.txid, tx.tx); missingSince != 0 { + glog.Warningf("alternative mempool: %s surfaced by the alternative provider again after %s of null answers%s, age %s", tx.txid, time.Since(time.Unix(int64(missingSince), 0)).Round(time.Second), slotLabel(tx.tx), age.Round(time.Second)) + } if timedOut { + // the relay was still answering at the cache timeout, i.e. the retention - not the relay - + // is what stopped serving this transaction as pending + glog.Infof("alternative mempool: evicting %s at the cache timeout while still surfaced by the alternative provider%s, age %s", tx.txid, slotLabel(tx.tx), age.Round(time.Second)) p.evictMempoolTx("timeout", tx.txid, tx.tx.time) continue } @@ -276,8 +1232,65 @@ func (p *AlternativeSendTxProvider) reconcileMempoolTxs() { p.mempoolTxsMux.Lock() size := len(p.mempoolTxs) + var oldest uint32 + for _, st := range p.mempoolTxs { + if oldest == 0 || st.time < oldest { + oldest = st.time + } + } p.mempoolTxsMux.Unlock() p.setMempoolCacheSize(size) + p.setMempoolOldestAge(oldest) +} + +// markProbed stamps this cycle onto the cache entry so probeInterval can pace the next probe. It updates +// only an entry still identical to the snapshot the loop is working from, so a concurrent eviction is +// never undone and a replacement cached in the meantime never inherits its predecessor's probe schedule. +// The tx body is carried over untouched (see storedTx). +func (p *AlternativeSendTxProvider) markProbed(txid string, snapshot storedTx) { + p.mempoolTxsMux.Lock() + defer p.mempoolTxsMux.Unlock() + current, found := p.mempoolTxs[txid] + if !found || current.gen != snapshot.gen || current.time != snapshot.time { + return + } + current.lastProbe = uint32(time.Now().Unix()) + p.mempoolTxs[txid] = current +} + +// markMissing stamps the start of the entry's current run of null relay answers - keeping an already +// running one - and returns the run's start, 0 when the entry is gone or replaced, plus whether this +// call opened the run so the caller logs the transition once instead of once per probe. Guarded like +// markProbed, so a replacement cached in the meantime never inherits its predecessor's run. +func (p *AlternativeSendTxProvider) markMissing(txid string, snapshot storedTx) (uint32, bool) { + p.mempoolTxsMux.Lock() + defer p.mempoolTxsMux.Unlock() + current, found := p.mempoolTxs[txid] + if !found || current.gen != snapshot.gen || current.time != snapshot.time { + return 0, false + } + started := current.missingSince == 0 + if started { + current.missingSince = uint32(time.Now().Unix()) + p.mempoolTxs[txid] = current + } + return current.missingSince, started +} + +// clearMissing resets the entry's missing run once the relay surfaces the tx again, so transient gaps +// do not accumulate toward the missing eviction. It returns the run it ended, 0 when there was none, so +// the caller can log a relay that resumed answering. Guarded like markProbed. +func (p *AlternativeSendTxProvider) clearMissing(txid string, snapshot storedTx) uint32 { + p.mempoolTxsMux.Lock() + defer p.mempoolTxsMux.Unlock() + current, found := p.mempoolTxs[txid] + if !found || current.gen != snapshot.gen || current.time != snapshot.time || current.missingSince == 0 { + return 0 + } + missingSince := current.missingSince + current.missingSince = 0 + p.mempoolTxs[txid] = current + return missingSince } func (p *AlternativeSendTxProvider) observeMempoolReconciliation(action string) { @@ -287,20 +1300,21 @@ func (p *AlternativeSendTxProvider) observeMempoolReconciliation(action string) 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. +// evictMempoolTx removes the cache entry and, only when this call actually removed it, records the +// terminal reconcile decision plus the entry's residence: reconcile works off a cycle-start snapshot, so +// the read path or a concurrent RBF eviction may already have removed and metered the same entry. func (p *AlternativeSendTxProvider) evictMempoolTx(action, txid string, addedUnix uint32) { + if !p.removeMempoolTx(txid) { + return + } 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. +// moment it is evicted, labeled by the deciding action, making the non-deterministic lifetime of an +// unconfirmed tx visible per reason - provider_missing clustering within minutes rather than near the +// timeout is the premature-eviction regression #1573 describes. func (p *AlternativeSendTxProvider) observeMempoolTxResidence(action string, addedUnix uint32) { if p.metrics == nil || p.metrics.EthAlternativeMempoolTxResidence == nil { return @@ -320,9 +1334,39 @@ func (p *AlternativeSendTxProvider) setMempoolCacheSize(size int) { 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. +// setMempoolOldestAge records how long the oldest cached entry has lived (seconds since broadcast), or 0 +// when the cache is empty. A value climbing toward the cache timeout at non-zero depth is the live +// stuck-tx signal the exit-only residence histogram cannot show until an entry finally times out. +func (p *AlternativeSendTxProvider) setMempoolOldestAge(oldestUnix uint32) { + if p.metrics == nil || p.metrics.EthAlternativeMempoolOldestAge == nil { + return + } + var age float64 + if oldestUnix != 0 { + if a := time.Since(time.Unix(int64(oldestUnix), 0)).Seconds(); a > 0 { + age = a + } + } + p.metrics.EthAlternativeMempoolOldestAge.Set(age) +} + +// observeSendNotSurfaced counts a relay-accepted private send whose post-send fetch-back did not surface +// it. What a reason means depends on which fetch-back observed it. On the probe path (the raw hex +// decoded and the send is already cached from its signed bytes) not_found and error only mean the relay +// does not report back what it accepted; a relay that keeps not surfacing it retires the entry through +// the missing eviction (see reconcileMempoolTxs), with the cache timeout as the backstop. On the expose +// path (the raw hex did not decode, see exposeAcceptedSend) every reason means the send is exposed +// nowhere: not_found and error after the retries run out, dropped when the fetch-back was refused under +// load and never ran at all. +func (p *AlternativeSendTxProvider) observeSendNotSurfaced(reason string) { + if p.metrics == nil || p.metrics.EthAlternativeSendNotSurfaced == nil { + return + } + p.metrics.EthAlternativeSendNotSurfaced.With(common.Labels{"reason": reason}).Inc() +} + +// transactionSupersededByNonce reports whether a different transaction has already consumed the cached +// transaction's nonce, making it permanently unmineable. resolved/failed memoize the lookups per sender. 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 @@ -349,15 +1393,12 @@ func (p *AlternativeSendTxProvider) transactionSupersededByNonce(tx *bchain.RpcT 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. +// 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 get a still mineable +// transaction evicted. The "latest" tag carries the usual chain-tip caveat: a nonce consumed only in a +// tip block later reorged out makes the eviction premature, bounded as with the mined-tx removal above - +// eviction drops only the cache entry, 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 @@ -430,23 +1471,67 @@ func (p *AlternativeSendTxProvider) getTransactionFromProviders(txid string) (*b return nil, false, nil } -func (p *AlternativeSendTxProvider) removeMempoolTx(txid string) { - if p.removeTransactionFromMempool != nil { +// removeMempoolTx evicts txid from the provider cache and, when a delegate is wired, from the wrapped +// Blockbook mempool too, reporting whether the entry was present. The cache delete runs first and is the +// single point of truth: concurrent reconcile / read-path / RBF evictions of the same txid see false for +// all but the one that deleted it. +func (p *AlternativeSendTxProvider) removeMempoolTx(txid string) bool { + // action "" - the caller meters this exit under its own reconcile decision + removed := p.removeTransaction(txid, "") + if removed && p.removeTransactionFromMempool != nil { p.removeTransactionFromMempool(txid) - return } - p.RemoveTransaction(txid) + return removed } -// RemoveTransaction removes a transaction from alternative mempool cache -func (p *AlternativeSendTxProvider) RemoveTransaction(txid string) { +// RemoveTransaction removes a transaction from the alternative mempool cache. It is the entry point for +// removals carrying no reconcile decision of their own - block sync indexing a mined transaction or +// retiring the sender's entries at or below its nonce, the read path finding one mined - and meters +// them as sync_removed, which nothing else would. A null answer on the read path never removes +// anything (#1709). In practice block sync is the sole source: the read path serves cached entries +// without asking the node, so its mined branch runs only on a cache miss, where there is nothing to +// remove. Reached again as removeMempoolTx's delegate, where the entry is already gone, so nothing is +// metered twice. +func (p *AlternativeSendTxProvider) RemoveTransaction(txid string) bool { + return p.removeTransaction(txid, "sync_removed") +} + +// removeTransaction removes a transaction from the alternative mempool cache. When the removed +// transaction was the sender's last cached one, the sender's nonce-routing entry is released as well +// (see releaseRecentSender). It reports whether the entry was actually present, so callers can meter the +// exit exactly once (see evictMempoolTx); a non-empty action meters it here instead. +func (p *AlternativeSendTxProvider) removeTransaction(txid string, action string) bool { if !p.fetchMempoolTx { - return + return false } + // normalized like GetTransaction: block sync and the read path hand over the caller's spelling + txid = normalizeTxid(txid) p.mempoolTxsMux.Lock() + removedTx, found := p.mempoolTxs[txid] delete(p.mempoolTxs, txid) + senderSettled := false + var sender ethcommon.Address + if from, _, ok := removedTx.slot(); found && ok { + sender = from + senderSettled = true + for _, storedTx := range p.mempoolTxs { + if f, _, ok := storedTx.slot(); ok && f == sender { + senderSettled = false + break + } + } + } p.mempoolTxsMux.Unlock() + + if senderSettled { + p.releaseRecentSender(sender, removedTx.gen) + } + if found && action != "" { + p.observeMempoolReconciliation(action) + p.observeMempoolTxResidence(action, removedTx.time) + } + return found } // UseOnlyAlternativeProvider returns true if only alternative providers should be used @@ -489,18 +1574,30 @@ func (p *AlternativeSendTxProvider) callHttpStringResult(url string, rpcMethod s 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. +// nonceURL returns the provider URL to use for addr's nonce lookup: the URL that accepted the sender's +// most recent transaction when known (a broadcast succeeds if ANY configured provider accepts it, so the +// first URL may never have seen the transaction), falling back to the first configured URL. +func (p *AlternativeSendTxProvider) nonceURL(addr ethcommon.Address) string { + p.recentSendersMux.Lock() + defer p.recentSendersMux.Unlock() + if s, found := p.recentSenders[addr]; found && s.url != "" { + return s.url + } + return p.urls[0] +} + +// getNonces returns the pending account nonce from the alternative provider that accepted the sender's +// most recent transaction (see nonceURL), plus the confirmed (latest) nonce when withConfirmed is set, +// both in a single JSON-RPC batch round-trip. 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") } + url := p.nonceURL(addr) if !withConfirmed { - pendingHex, err := p.callHttpStringResult(p.urls[0], "eth_getTransactionCount", addr, "pending") + pendingHex, err := p.callHttpStringResult(url, "eth_getTransactionCount", addr, "pending") if err != nil { return 0, 0, false, err } @@ -512,7 +1609,7 @@ func (p *AlternativeSendTxProvider) getNonces(addr ethcommon.Address, withConfir } ctx, cancel := context.WithTimeout(context.Background(), p.rpcTimeout) defer cancel() - client, err := rpc.DialContext(ctx, p.urls[0]) + client, err := rpc.DialContext(ctx, url) if err != nil { return 0, 0, false, err } diff --git a/bchain/coins/eth/alternativesendtx_metrics_test.go b/bchain/coins/eth/alternativesendtx_metrics_test.go new file mode 100644 index 0000000000..c568b3b3c0 --- /dev/null +++ b/bchain/coins/eth/alternativesendtx_metrics_test.go @@ -0,0 +1,173 @@ +package eth + +import ( + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// newSendTxMetrics extends the reconcile collectors with the send-path ones, so the mempool event +// counter has a single owner - the fetch-back is observed on it from the send path too. +func newSendTxMetrics() *common.Metrics { + m := newReconcileTestMetrics() + m.EthAlternativeSendTx = prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_alt_sendtx_total"}, []string{"provider_host", "status", "reason"}) + m.EthAlternativeSendTxDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{Name: "test_alt_sendtx_duration_seconds", Buckets: []float64{1}}, []string{"provider_host", "status"}) + return m +} + +// gatherMetric returns the single sample of c whose labels match want, or nil when that series was +// never touched - an absent series and a zero-valued one are different assertions here. +func gatherMetric(t *testing.T, c prometheus.Collector, want map[string]string) *dto.Metric { + t.Helper() + reg := prometheus.NewRegistry() + if err := reg.Register(c); err != nil { + t.Fatalf("register collector: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Fatalf("gather: %v", err) + } + for _, mf := range families { + for _, mm := range mf.GetMetric() { + labels := make(map[string]string, len(mm.GetLabel())) + for _, lp := range mm.GetLabel() { + labels[lp.GetName()] = lp.GetValue() + } + match := true + for k, v := range want { + if labels[k] != v { + match = false + break + } + } + if match { + return mm + } + } + } + return nil +} + +// TestAlternativeSendTxProviderSendObservesProvider asserts a broadcast attempt is counted and +// timed per provider. A send succeeds if any provider accepts it, so a provider that is down shows +// up here and nowhere else. +func TestAlternativeSendTxProviderSendObservesProvider(t *testing.T) { + rawTx, _ := signedTestTx(t) + m := newSendTxMetrics() + provider := &AlternativeSendTxProvider{ + urls: []string{"http://127.0.0.1:1"}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + metrics: m, + } + + if _, err := provider.SendRawTransaction(rawTx); err == nil { + t.Fatal("expected error from unreachable provider") + } + + // the reason label is not pinned here - the dial error text is platform dependent, and the + // mapping from message to class is asserted in bchain.TestClassifySendTxError + failed := gatherMetric(t, m.EthAlternativeSendTx, map[string]string{"provider_host": "127.0.0.1:1", "status": bchain.SendTxStatusFailure}) + if failed == nil || failed.GetCounter().GetValue() != 1 { + t.Errorf("unreachable provider counter = %v, want 1", failed) + } + timed := gatherMetric(t, m.EthAlternativeSendTxDuration, map[string]string{"provider_host": "127.0.0.1:1", "status": bchain.SendTxStatusFailure}) + if timed == nil || timed.GetHistogram().GetSampleCount() != 1 { + t.Errorf("unreachable provider duration samples = %v, want 1 - a timing out provider is exactly what the histogram must show", timed) + } +} + +// TestAlternativeSendTxProviderObserveSendTxAccepted asserts an accepted broadcast is labeled +// success/ok under the provider's host rather than its full URL, which carries the API key. +func TestAlternativeSendTxProviderObserveSendTxAccepted(t *testing.T) { + m := newSendTxMetrics() + provider := &AlternativeSendTxProvider{metrics: m} + + provider.observeSendTx(providerLabel("https://relay.example.com/v1/SECRETKEY"), 250*time.Millisecond, nil) + + accepted := gatherMetric(t, m.EthAlternativeSendTx, map[string]string{"provider_host": "relay.example.com", "status": bchain.SendTxStatusSuccess, "reason": bchain.ReasonOK}) + if accepted == nil || accepted.GetCounter().GetValue() != 1 { + t.Errorf("accepted send counter = %v, want 1", accepted) + } + timed := gatherMetric(t, m.EthAlternativeSendTxDuration, map[string]string{"provider_host": "relay.example.com", "status": bchain.SendTxStatusSuccess}) + if timed == nil || timed.GetHistogram().GetSampleCount() != 1 { + t.Errorf("accepted send duration samples = %v, want 1", timed) + } +} + +// TestAlternativeSendTxProviderFetchBackObservesError asserts a failed post-send fetch-back is +// counted. The post-send fetch-back is only a probe (the send path caches the tx from its own +// signed bytes), so the failure surfaces as send_not_surfaced{reason=error} - the relay did not +// report back what it accepted - rather than the retired fetchback_error reconciliation action. +func TestAlternativeSendTxProviderFetchBackObservesError(t *testing.T) { + m := newSendTxMetrics() + provider := &AlternativeSendTxProvider{ + urls: []string{"http://127.0.0.1:1"}, + fetchMempoolTx: true, + mempoolTxs: make(map[string]storedTx), + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + metrics: m, + } + + provider.probeSentTransaction(testAlternativeTxID) + + failed := gatherMetric(t, m.EthAlternativeSendNotSurfaced, map[string]string{"reason": "error"}) + if failed == nil || failed.GetCounter().GetValue() != 1 { + t.Errorf("send_not_surfaced{reason=error} counter = %v, want 1", failed) + } +} + +// TestProviderLabel asserts a provider's metric label and log identity carries no secret - +// configured provider URLs commonly embed an API key in the path or query. +func TestProviderLabel(t *testing.T) { + tests := []struct{ url, want string }{ + {"https://relay.example.com/v1/SECRETKEY", "relay.example.com"}, + {"https://relay.example.com:8545/?apikey=SECRETKEY", "relay.example.com:8545"}, + {"not a url at all", "unknown"}, + } + for _, tt := range tests { + if got := providerLabel(tt.url); got != tt.want { + t.Errorf("providerLabel(%q) = %q, want %q", tt.url, got, tt.want) + } + } +} + +// TestAlternativeSendTxProviderErrorRedactedForClients pins the real error shape a failing provider +// call produces against the redaction the API applies before answering a client. The URL enters the +// message from the http client itself (Post "": dial tcp ...), so a key in a provider URL is +// only kept out of REST/WebSocket responses for as long as that shape stays redactable - this test +// fails if either side drifts. The unredacted error is what reaches the logs, by design. +func TestAlternativeSendTxProviderErrorRedactedForClients(t *testing.T) { + const key = "SECRET_API_KEY_ABCDEF" + rawTx, _ := signedTestTx(t) + provider := &AlternativeSendTxProvider{ + urls: []string{"http://127.0.0.1:1/v3/" + key}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + metrics: newSendTxMetrics(), + } + + _, err := provider.SendRawTransaction(rawTx) + if err == nil { + t.Fatal("expected error from unreachable provider") + } + if !strings.Contains(err.Error(), key) { + t.Skipf("provider error carries no url on this platform (%v), nothing to redact", err) + } + + redacted := common.RedactURLs(err.Error()) + if strings.Contains(redacted, key) { + t.Errorf("api key survives redaction, would be served to clients: %s", redacted) + } + if !strings.Contains(redacted, "127.0.0.1:1") { + t.Errorf("provider host should survive redaction, got: %s", redacted) + } +} diff --git a/bchain/coins/eth/alternativesendtx_test.go b/bchain/coins/eth/alternativesendtx_test.go index 5375ebf9a2..2cb7ebcf24 100644 --- a/bchain/coins/eth/alternativesendtx_test.go +++ b/bchain/coins/eth/alternativesendtx_test.go @@ -3,15 +3,23 @@ package eth import ( "bytes" "encoding/json" + "fmt" "io" + "math" + "math/big" "net/http" "net/http/httptest" + "slices" "strings" "sync" + "sync/atomic" "testing" "time" ethcommon "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/crypto" "github.com/prometheus/client_golang/prometheus" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" @@ -86,6 +94,48 @@ func assertReconcileOutcome(t *testing.T, provider *AlternativeSendTxProvider, r } } +// fakeWrappedMempool records the wrapped-mempool calls so tests can assert the cache path drives the +// store the retention coupling depends on. +type fakeWrappedMempool struct { + ops []string +} + +func (f *fakeWrappedMempool) AddOrRefreshTransactionInMempool(txid string) bool { + f.ops = append(f.ops, "addOrRefresh:"+txid) + return true +} + +func (f *fakeWrappedMempool) RemoveTransactionFromMempool(txid string) { + f.ops = append(f.ops, "remove:"+txid) +} + +// Pins that every cache write reaches AddOrRefreshTransactionInMempool (restamp semantics tested in +// bchain): the equal retentions shipped in the coin configs leave no margin for a missed restamp (#1573). +func TestAlternativeSendTxProviderRecacheRestampsWrappedMempoolEntry(t *testing.T) { + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + mempoolTxs: make(map[string]storedTx), + } + mempool := &fakeWrappedMempool{} + provider.mempool = mempool + + tx := &bchain.RpcTransaction{ + Hash: testAlternativeTxID, + From: "0x2222222222222222222222222222222222222222", + AccountNonce: "0x1", + } + provider.cacheMempoolTransaction(testAlternativeTxID, tx, 1) + provider.cacheMempoolTransaction(testAlternativeTxID, tx, 2) + want := []string{ + "addOrRefresh:" + testAlternativeTxID, + "addOrRefresh:" + testAlternativeTxID, + } + if !slices.Equal(mempool.ops, want) { + t.Fatalf("wrapped mempool ops = %v, want %v", mempool.ops, want) + } +} + 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 { @@ -93,10 +143,10 @@ func TestAlternativeSendTxProviderReconcileLivenessOutcomes(t *testing.T) { 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}, + // A single empty provider result is tolerated, absorbing a transient relay fluke; eviction + // needs a run of nulls outlasting the missing timeout (see + // TestAlternativeSendTxProviderReconcileEvictsSustainedMissingTx). + {"single empty provider result is tolerated", `{"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}, @@ -115,10 +165,9 @@ func TestAlternativeSendTxProviderReconcileLivenessOutcomes(t *testing.T) { } 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. + // A tx older than mempoolTxsTimeout must be evicted by the reconcile timeout "safety net", and the + // eviction must go through removeMempoolTx (the removeTransactionFromMempool callback) so it leaves + // the main mempool too, not only the cache; assertReconcileOutcome checks the callback fired. tests := []struct { name string serverURL func(t *testing.T) string @@ -162,6 +211,63 @@ func TestAlternativeSendTxProviderReconcileTimeoutEviction(t *testing.T) { } } +// TestAlternativeSendTxProviderReconcileEvictsSustainedMissingTx pins the eviction that aligns +// Blockbook with the relay surfacing an accepted tx for its whole pending window: a run of null +// eth_getTransactionByHash answers outlasting the missing timeout means dropped or cancelled (a +// drop-mode cancel leaves no replacement to retire it), so the entry leaves both stores well before the +// cache timeout instead of being served as pending for the full window. +func TestAlternativeSendTxProviderReconcileEvictsSustainedMissingTx(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + provider.metrics = newReconcileTestMetrics() + + provider.reconcileMempoolTxs() + + entry, found := provider.mempoolTxs[testAlternativeTxID] + if !found { + t.Fatal("entry evicted on the first null answer, want tolerated") + } + if entry.missingSince == 0 { + t.Fatal("first null answer did not start the missing run") + } + + // age the missing run past the timeout and let the probe backoff re-ask + entry.missingSince = uint32(time.Now().Add(-provider.missingTimeout() - time.Second).Unix()) + entry.lastProbe = uint32(time.Now().Add(-2 * alternativeMempoolTxCheckPeriod).Unix()) + provider.mempoolTxs[testAlternativeTxID] = entry + + provider.reconcileMempoolTxs() + + assertReconcileOutcome(t, provider, removed, true) + if got := labeledCounterValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "provider_missing"); got != 1 { + t.Errorf("reconciliation_events{action=provider_missing} = %v, want 1", got) + } +} + +// TestAlternativeSendTxProviderReconcileMissingRunResetsWhenSurfaced pins that the missing run is a run +// of CONSECUTIVE nulls: once the relay surfaces the tx again, an accumulated run is discarded rather +// than left to count a later transient gap toward eviction. +func TestAlternativeSendTxProviderReconcileMissingRunResetsWhenSurfaced(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + // confirmed nonce equals the tx nonce (0x1): not superseded + "eth_getTransactionCount": nonceCountResponse("0x1"), + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + entry := provider.mempoolTxs[testAlternativeTxID] + entry.missingSince = uint32(time.Now().Add(-provider.missingTimeout() - time.Second).Unix()) + provider.mempoolTxs[testAlternativeTxID] = entry + + provider.reconcileMempoolTxs() + + assertReconcileOutcome(t, provider, removed, false) + if got := provider.mempoolTxs[testAlternativeTxID].missingSince; got != 0 { + t.Errorf("missingSince = %d after the relay surfaced the tx again, want 0", got) + } +} + func TestAlternativeSendTxProviderReconcileSkipsFreshTransaction(t *testing.T) { server := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`) var removed string @@ -180,6 +286,185 @@ func TestAlternativeSendTxProviderReconcileSkipsFreshTransaction(t *testing.T) { } } +// TestAlternativeSendTxProviderReconcileBacksOffByAge pins the probe pacing that keeps a long cache +// retention from costing one relay round-trip per entry per minute for hours. The tick is unchanged. +func TestAlternativeSendTxProviderReconcileBacksOffByAge(t *testing.T) { + for _, tt := range []struct { + name string + age time.Duration + sinceProbe time.Duration + wantProbes int + wantAction string + }{ + {name: "young entry is probed every cycle", age: 5 * time.Minute, sinceProbe: 90 * time.Second, wantProbes: 1, wantAction: "provider_missing_pending"}, + {name: "waiting entry is not re-asked within its interval", age: 30 * time.Minute, sinceProbe: 90 * time.Second, wantProbes: 0, wantAction: "skipped_backoff"}, + {name: "waiting entry is re-asked once its interval elapses", age: 30 * time.Minute, sinceProbe: 6 * time.Minute, wantProbes: 1, wantAction: "provider_missing_pending"}, + {name: "hour-old entry backs off further", age: 2 * time.Hour, sinceProbe: 6 * time.Minute, wantProbes: 0, wantAction: "skipped_backoff"}, + {name: "multi-hour entry is not re-asked within the hourly interval", age: 5 * time.Hour, sinceProbe: 20 * time.Minute, wantProbes: 0, wantAction: "skipped_backoff"}, + {name: "multi-hour entry is re-asked once an hour elapses", age: 5 * time.Hour, sinceProbe: 61 * time.Minute, wantProbes: 1, wantAction: "provider_missing_pending"}, + } { + t.Run(tt.name, func(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + // the shipped retention, so the multi-hour cases exercise the backoff and not the timeout + provider.mempoolTxsTimeout = 48 * time.Hour + provider.metrics = newReconcileTestMetrics() + entry := provider.mempoolTxs[testAlternativeTxID] + entry.time = uint32(time.Now().Add(-tt.age).Unix()) + entry.lastProbe = uint32(time.Now().Add(-tt.sinceProbe).Unix()) + provider.mempoolTxs[testAlternativeTxID] = entry + + provider.reconcileMempoolTxs() + + if got := server.callCount("eth_getTransactionByHash"); got != tt.wantProbes { + t.Errorf("relay probes = %d, want %d", got, tt.wantProbes) + } + if got := labeledCounterValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", tt.wantAction); got != 1 { + t.Errorf("reconciliation_events{action=%s} = %v, want 1", tt.wantAction, got) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Error("entry evicted although it is neither timed out nor superseded") + } + }) + } +} + +// TestAlternativeSendTxProviderReconcileStampsProbe closes the loop the table above opens: with no +// lastProbe seeded, the pacing has to come from reconcile stamping the entry itself. A markProbed that +// silently stopped writing would re-probe every entry once a minute for the whole retention, unnoticed. +func TestAlternativeSendTxProviderReconcileStampsProbe(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + provider.mempoolTxsTimeout = 3 * time.Hour + provider.metrics = newReconcileTestMetrics() + entry := provider.mempoolTxs[testAlternativeTxID] + entry.time = uint32(time.Now().Add(-30 * time.Minute).Unix()) + provider.mempoolTxs[testAlternativeTxID] = entry + + provider.reconcileMempoolTxs() + if got := server.callCount("eth_getTransactionByHash"); got != 1 { + t.Fatalf("relay probes after the first cycle = %d, want 1 (a never-probed entry is always asked)", got) + } + if provider.mempoolTxs[testAlternativeTxID].lastProbe == 0 { + t.Fatal("reconcile did not stamp lastProbe, so nothing paces the next cycle") + } + + provider.reconcileMempoolTxs() + if got := server.callCount("eth_getTransactionByHash"); got != 1 { + t.Errorf("relay probes after the second cycle = %d, want 1 - a 30 min old entry is asked every 5 min", got) + } + if got := labeledCounterValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "skipped_backoff"); got != 1 { + t.Errorf("reconciliation_events{action=skipped_backoff} = %v, want 1", got) + } +} + +// TestAlternativeSendTxProviderMarkProbedOnlyStampsTheSnapshottedEntry pins the identity check that +// keeps the stamp from writing through a concurrent eviction or replacement: reconcile works off a +// snapshot, so by the time it stamps, the txid may have been evicted or re-cached by a newer send. +func TestAlternativeSendTxProviderMarkProbedOnlyStampsTheSnapshottedEntry(t *testing.T) { + body := &bchain.RpcTransaction{Hash: testAlternativeTxID, From: "0x2222222222222222222222222222222222222222", AccountNonce: "0x1"} + current := storedTx{tx: body, time: 1000, gen: 2} + + for _, tt := range []struct { + name string + snapshot storedTx + wantStamp bool + }{ + {name: "identical entry is stamped", snapshot: current, wantStamp: true}, + {name: "a newer generation holds the slot", snapshot: storedTx{tx: body, time: 1000, gen: 1}}, + {name: "the entry was re-cached at another time", snapshot: storedTx{tx: body, time: 900, gen: 2}}, + } { + t.Run(tt.name, func(t *testing.T) { + provider := &AlternativeSendTxProvider{mempoolTxs: map[string]storedTx{testAlternativeTxID: current}} + + provider.markProbed(testAlternativeTxID, tt.snapshot) + + stamped := provider.mempoolTxs[testAlternativeTxID] + if got := stamped.lastProbe != 0; got != tt.wantStamp { + t.Errorf("lastProbe stamped = %v, want %v", got, tt.wantStamp) + } + if stamped.tx != body { + t.Error("markProbed replaced the cached body, which must stay immutable once published") + } + }) + } + + // an entry that left the cache must not be recreated by a stamp + provider := &AlternativeSendTxProvider{mempoolTxs: map[string]storedTx{}} + provider.markProbed(testAlternativeTxID, current) + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Error("markProbed resurrected an evicted entry") + } +} + +// TestAlternativeSendTxProviderReconcileSweepsSendTracking covers the two send-tracking maps being +// pruned by the reconcile tick rather than only on the next send, on their two different horizons: +// routing lapses in minutes, an accepted nonce slot only when its transaction can no longer land. +func TestAlternativeSendTxProviderReconcileSweepsSendTracking(t *testing.T) { + lapsed := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + fresh := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + oldSlot := nonceSlot{addr: lapsed, nonce: 1} + liveSlot := nonceSlot{addr: fresh, nonce: 2} + provider := &AlternativeSendTxProvider{ + mempoolTxsTimeout: 3 * time.Hour, + mempoolTxs: map[string]storedTx{}, + recentSenders: map[ethcommon.Address]recentSender{ + lapsed: {time: time.Now().Add(-30 * time.Minute)}, + fresh: {time: time.Now()}, + }, + acceptedSlots: map[nonceSlot]acceptedSlot{ + oldSlot: {gen: 1, time: time.Now().Add(-4 * time.Hour)}, + liveSlot: {gen: 2, time: time.Now().Add(-30 * time.Minute)}, + }, + } + + provider.reconcileMempoolTxs() + + if _, found := provider.recentSenders[lapsed]; found { + t.Error("a sender past the routing horizon survived the reconcile sweep") + } + if _, found := provider.recentSenders[fresh]; !found { + t.Error("a sender inside the routing horizon was swept") + } + if _, found := provider.acceptedSlots[oldSlot]; found { + t.Error("an accepted slot past the cache retention survived the reconcile sweep") + } + if _, found := provider.acceptedSlots[liveSlot]; !found { + t.Error("an accepted slot was swept on the routing horizon instead of the cache retention") + } +} + +// TestAlternativeSendTxProviderReconcileEvictsTimedOutEntryDespiteBackoff pins that the backoff paces +// only the relay round-trip: the cache timeout is local and must still fire on the cycle it comes due, +// or a backed-off entry outlives the retention by up to a probe interval. +func TestAlternativeSendTxProviderReconcileEvictsTimedOutEntryDespiteBackoff(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + provider.metrics = newReconcileTestMetrics() + entry := provider.mempoolTxs[testAlternativeTxID] + entry.time = uint32(time.Now().Add(-2 * time.Hour).Unix()) // past the 1h test retention + entry.lastProbe = uint32(time.Now().Add(-time.Second).Unix()) + provider.mempoolTxs[testAlternativeTxID] = entry + + provider.reconcileMempoolTxs() + + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Error("timed-out entry survived because its probe was backed off") + } + if removed != testAlternativeTxID { + t.Errorf("removed txid = %q, want %q", removed, testAlternativeTxID) + } +} + func TestAlternativeSendTxProviderReconcileKeepsTransactionKnownByAnyProvider(t *testing.T) { droppedServer := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`) knownServer := newAlternativeTxProviderTestServer(t, testAlternativeKnownTxResponse) @@ -205,7 +490,7 @@ func TestAlternativeSendTxProviderHandleMempoolTransactionFetchesFromAnyProvider provider.mempoolTxs = make(map[string]storedTx) provider.urls = append(provider.urls, knownServer.URL) - if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err != nil { + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 0); err != nil { t.Fatalf("handleMempoolTransaction() error = %v", err) } if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { @@ -219,7 +504,7 @@ func TestAlternativeSendTxProviderHandleMempoolTransactionSkipsEmptyTransaction( provider := newTestAlternativeSendTxProvider(server.URL, &removed) provider.mempoolTxs = make(map[string]storedTx) - if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err == nil { + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 0); err == nil { t.Fatal("handleMempoolTransaction() error = nil, want ErrTxNotFound") } if _, found := provider.mempoolTxs[testAlternativeTxID]; found { @@ -233,7 +518,7 @@ func TestAlternativeSendTxProviderHandleMempoolTransactionSkipsTransactionWithou provider := newTestAlternativeSendTxProvider(server.URL, &removed) provider.mempoolTxs = make(map[string]storedTx) - if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err == nil { + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 0); err == nil { t.Fatal("handleMempoolTransaction() error = nil, want ErrTxNotFound") } if _, found := provider.mempoolTxs[testAlternativeTxID]; found { @@ -241,6 +526,127 @@ func TestAlternativeSendTxProviderHandleMempoolTransactionSkipsTransactionWithou } } +// TestAlternativeSendTxProviderNormalizesTxidSpelling pins that the cache answers whatever spelling of +// the hash a caller uses. It is keyed on tx.Hash().Hex() - 0x-prefixed lower case - and is the +// authoritative store for a relay-accepted send (the primary RPC does not know the tx), so a +// case-mismatch miss on the read path reads as the transaction not existing anywhere, and a missed +// removal leaves a mined transaction served as pending until the cache timeout. +func TestAlternativeSendTxProviderNormalizesTxidSpelling(t *testing.T) { + const canonical = "0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + for name, spelling := range map[string]string{ + "upper case": "0x" + strings.ToUpper(canonical[2:]), + "no prefix": canonical[2:], + "0X prefix": "0X" + canonical[2:], + "canonical form": canonical, + } { + t.Run(name, func(t *testing.T) { + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + canonical: {tx: &bchain.RpcTransaction{Hash: canonical}, time: uint32(time.Now().Unix())}, + }, + } + if _, found := provider.GetTransaction(spelling); !found { + t.Errorf("GetTransaction(%q) missed the cache entry keyed %q", spelling, canonical) + } + if !provider.RemoveTransaction(spelling) { + t.Errorf("RemoveTransaction(%q) missed the cache entry keyed %q", spelling, canonical) + } + if len(provider.mempoolTxs) != 0 { + t.Error("entry survived the removal") + } + }) + } +} + +// newSequencedTxProviderTestServer answers each eth_getTransactionByHash call with the next response in +// the sequence, repeating the last one once exhausted, and reports how many calls it served. +func newSequencedTxProviderTestServer(t *testing.T, responses []string) (*httptest.Server, func() int) { + t.Helper() + var mu sync.Mutex + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + resp := responses[min(calls, len(responses)-1)] + calls++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(resp)); err != nil { + t.Errorf("Write() error = %v", err) + } + })) + t.Cleanup(server.Close) + return server, func() int { mu.Lock(); defer mu.Unlock(); return calls } +} + +// TestAlternativeSendTxProviderExposeAcceptedSendRetriesTransientFailure pins the retry that keeps a +// transient relay fault from losing an accepted send for good: on this path no cache entry exists, so +// nothing ever re-asks after the fetch-back gives up. Per the relay's contract a lookup error means +// retry, never gone - here the first ask errors, the second answers null, the third surfaces the tx. +func TestAlternativeSendTxProviderExposeAcceptedSendRetriesTransientFailure(t *testing.T) { + server, calls := newSequencedTxProviderTestServer(t, []string{ + `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"transient"}}`, + `{"jsonrpc":"2.0","id":1,"result":null}`, + testAlternativeKnownTxResponse, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + exposeFetchBackDelay: time.Millisecond, + } + + provider.exposeAcceptedSend(testAlternativeTxID, 1) + + if got := calls(); got != 3 { + t.Errorf("eth_getTransactionByHash calls = %d, want 3 (error, null, found)", got) + } + entry, found := provider.mempoolTxs[testAlternativeTxID] + if !found { + t.Fatal("accepted send not cached after the retry surfaced it") + } + if entry.gen != 1 { + t.Errorf("cached tx generation = %d, want 1 (the generation of its own submission)", entry.gen) + } + for _, reason := range []string{"error", "not_found"} { + if got := labeledCounterValue(t, provider.metrics.EthAlternativeSendNotSurfaced, "reason", reason); got != 0 { + t.Errorf("send_not_surfaced{%s} = %v, want 0 - a retried-and-surfaced send is not a failure", reason, got) + } + } +} + +// TestAlternativeSendTxProviderExposeAcceptedSendGivesUpAfterRetries pins the other side: a send the +// relay never surfaces is asked exposeFetchBackAttempts times and observed exactly once, on the final +// attempt - retries must not inflate the counter. +func TestAlternativeSendTxProviderExposeAcceptedSendGivesUpAfterRetries(t *testing.T) { + server, calls := newSequencedTxProviderTestServer(t, []string{ + `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + exposeFetchBackDelay: time.Millisecond, + } + + provider.exposeAcceptedSend(testAlternativeTxID, 1) + + if got := calls(); got != exposeFetchBackAttempts { + t.Errorf("eth_getTransactionByHash calls = %d, want %d", got, exposeFetchBackAttempts) + } + if got := labeledCounterValue(t, provider.metrics.EthAlternativeSendNotSurfaced, "reason", "not_found"); got != 1 { + t.Errorf("send_not_surfaced{not_found} = %v, want 1 (once at the final attempt, not per ask)", got) + } + if len(provider.mempoolTxs) != 0 { + t.Errorf("cache size = %d, want 0", len(provider.mempoolTxs)) + } +} + // 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 { @@ -258,6 +664,15 @@ func (s *methodAwareServer) callCount(method string) int { func newMethodAwareTxProviderTestServer(t *testing.T, responses map[string]string) *methodAwareServer { t.Helper() + return newHookedMethodAwareTxProviderTestServer(t, responses, nil) +} + +// newHookedMethodAwareTxProviderTestServer is newMethodAwareTxProviderTestServer with a hook that runs +// before the response is written. Blocking in the hook holds a specific RPC method in flight, which is +// how the send path's concurrency is asserted exactly rather than with wall-clock margins. +func newHookedMethodAwareTxProviderTestServer(t *testing.T, responses map[string]string, before func(method 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) @@ -270,6 +685,10 @@ func newMethodAwareTxProviderTestServer(t *testing.T, responses map[string]strin s.calls[req.Method]++ s.mu.Unlock() + if before != nil { + before(req.Method) + } + resp, ok := responses[req.Method] if !ok { resp = `{"jsonrpc":"2.0","id":1,"result":null}` @@ -320,10 +739,9 @@ func TestAlternativeSendTxProviderReconcileNonceOutcomes(t *testing.T) { } 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. + // The provider no longer surfaces the tx, which alone would not evict it. But the confirmed account + // nonce (0x2) is above the cached tx nonce (0x1): that nonce is spent on-chain, so the tx can never + // mine and is evicted deterministically, 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"), @@ -384,9 +802,8 @@ func TestAlternativeSendTxProviderReconcileKeepsTransactionWithUnparsableNonce(t } 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. + // a failed lookup for one sender must not suppress eviction for another; single-provider servers + // cannot distinguish senders, so this asserts the failed-memo does not leak into the resolved map server := newMethodAwareTxProviderTestServer(t, map[string]string{ "eth_getTransactionByHash": testAlternativeKnownTxResponse, "eth_getTransactionCount": nonceCountResponse("0x2"), @@ -444,80 +861,51 @@ func newReconcileTestMetrics() *common.Metrics { 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"}), + EthAlternativeMempoolOldestAge: prometheus.NewGauge( + prometheus.GaugeOpts{Name: "test_alt_mempool_oldest_age_seconds"}), + EthAlternativeSendNotSurfaced: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_alt_send_not_surfaced_total"}, []string{"reason"}), } } -// 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). +// The readers below narrow gatherMetric (alternativesendtx_metrics_test.go) to the shapes the +// reconcile tests assert on, so a test can read metric values without pulling in the +// prometheus/testutil dependency (and its transitive modules). A series that was never touched +// reads as zero, which is what these call sites assert against. // 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 + return gatherMetric(t, g, nil).GetGauge().GetValue() } -// counterValue reads the value of the counter series carrying action=action. -func counterValue(t *testing.T, cv *prometheus.CounterVec, action string) float64 { +// counterVecValue reads the counter series carrying label=value. +func counterVecValue(t *testing.T, cv *prometheus.CounterVec, label, value 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 + return gatherMetric(t, cv, map[string]string{label: value}).GetCounter().GetValue() } // 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 + return gatherMetric(t, h, map[string]string{"action": action}).GetHistogram().GetSampleCount() +} + +// counterValue reads the value of the counter series carrying action=action. +func counterValue(t *testing.T, cv *prometheus.CounterVec, action string) float64 { + t.Helper() + return counterVecValue(t, cv, "action", action) +} + +// labeledCounterValue reads the counter series carrying label==value (counterValue is fixed to +// "action"); an alias of counterVecValue kept for the call sites this series added. +func labeledCounterValue(t *testing.T, cv *prometheus.CounterVec, label, value string) float64 { + t.Helper() + return counterVecValue(t, cv, label, value) } -// 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. +// TestAlternativeSendTxProviderReconcileObservesMetrics asserts the reconcile flow feeds the tx-lifetime +// histogram - only on eviction, under the same action label as the 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"}}` @@ -529,14 +917,12 @@ func TestAlternativeSendTxProviderReconcileObservesMetrics(t *testing.T) { provider.reconcileMempoolTxs() - if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "mined"); got != 1 { + if got := counterVecValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "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) } @@ -550,10 +936,9 @@ func TestAlternativeSendTxProviderReconcileObservesMetrics(t *testing.T) { provider.reconcileMempoolTxs() - if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "kept"); got != 1 { + if got := counterVecValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "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) @@ -566,9 +951,8 @@ func TestAlternativeSendTxProviderReconcileObservesMetrics(t *testing.T) { } 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. + // an entry past mempoolTxsTimeout is evicted on the read path, and that eviction must be metered like + // the reconcile-loop timeout or the timeout series is undercounted provider := &AlternativeSendTxProvider{ fetchMempoolTx: true, mempoolTxsTimeout: time.Minute, @@ -587,7 +971,7 @@ func TestAlternativeSendTxProviderGetTransactionTimeoutObservesMetrics(t *testin 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 { + if got := counterVecValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "timeout"); got != 1 { t.Errorf("timeout reconciliation events = %v, want 1", got) } if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "timeout"); got != 1 { @@ -596,9 +980,8 @@ func TestAlternativeSendTxProviderGetTransactionTimeoutObservesMetrics(t *testin } 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. + // handleMempoolTransaction replaces a cached entry sharing the incoming tx's sender+nonce; that exit + // is a fee replacement and must be counted with its residence, not silently dropped server := newAlternativeTxProviderTestServer(t, testAlternativeKnownTxResponse) provider := &AlternativeSendTxProvider{ urls: []string{server.URL}, @@ -625,14 +1008,14 @@ func TestAlternativeSendTxProviderRBFReplacementObservesMetrics(t *testing.T) { provider.RemoveTransaction(txid) } - if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err != nil { + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 0); 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 { + if got := counterVecValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "rbf_replaced"); got != 1 { t.Errorf("rbf_replaced events = %v, want 1", got) } if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "rbf_replaced"); got != 1 { @@ -677,11 +1060,9 @@ type jsonRPCReq struct { 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. +// nonceRPCServer is a JSON-RPC test server for eth_getTransactionCount. It serves a per-block-tag hex +// result (or error) over both single and batched requests, so it can drive getNonces over a real +// rpc.Client - a plain method-keyed mock cannot exercise BatchCallContext - and counts queries per tag. type nonceRPCServer struct { *httptest.Server mu sync.Mutex @@ -751,10 +1132,9 @@ func newNonceRPCServer(t *testing.T, results map[string]string, errs map[string] 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. +// TestAlternativeSendTxProviderGetNonces covers the alternative-provider nonce path backing the +// confirmedNonce field for private-relay coins; it mirrors the primary-RPC getNoncesRPC tests in +// nonce_test.go. func TestAlternativeSendTxProviderGetNonces(t *testing.T) { addr := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") @@ -823,3 +1203,2043 @@ func TestAlternativeSendTxProviderGetNonces(t *testing.T) { } }) } + +// signedTestTx builds a signed raw transaction with a throwaway key and returns its hex plus the sender. +func signedTestTx(t *testing.T) (string, ethcommon.Address) { + t.Helper() + to := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + raw, sender, _ := signTestTx(t, &types.LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + }) + return raw, sender +} + +// signedTestTxWithHash is signedTestTx plus the transaction's true hash, for tests whose relay mock must +// echo the txid the signed bytes hash to - the txid the send path caches under. +func signedTestTxWithHash(t *testing.T) (string, ethcommon.Address, string) { + t.Helper() + to := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + raw, sender, tx := signTestTx(t, &types.LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + }) + return raw, sender, tx.Hash().Hex() +} + +// signTestTx signs inner with the fixed test key on chain id 1 and returns its raw hex, the sender +// address and the signed transaction itself (so a test can assert against its true hash). +func signTestTx(t *testing.T, inner types.TxData) (string, ethcommon.Address, *types.Transaction) { + t.Helper() + key, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + if err != nil { + t.Fatalf("HexToECDSA() error = %v", err) + } + tx, err := types.SignNewTx(key, types.LatestSignerForChainID(big.NewInt(1)), inner) + if err != nil { + t.Fatalf("SignNewTx() error = %v", err) + } + raw, err := tx.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary() error = %v", err) + } + return hexutil.Encode(raw), crypto.PubkeyToAddress(key.PublicKey), tx +} + +// TestDecodeAlternativeSendTx checks the body derived from the signed bytes carries the same fields, in +// the same encoding, eth_getTransactionByHash would return for the same unmined transaction - the +// equivalence that lets the send path cache without the relay surfacing anything. +func TestDecodeAlternativeSendTx(t *testing.T) { + to := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + + t.Run("legacy transaction", func(t *testing.T) { + raw, sender, tx := signTestTx(t, &types.LegacyTx{ + Nonce: 7, + GasPrice: big.NewInt(1000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(12345), + }) + sent, err := decodeAlternativeSendTx(raw) + if err != nil { + t.Fatalf("decodeAlternativeSendTx() error = %v", err) + } + if sent.from != sender || sent.nonce != 7 || sent.txid != tx.Hash().Hex() { + t.Fatalf("got (from=%s nonce=%d txid=%s), want (%s 7 %s)", sent.from.Hex(), sent.nonce, sent.txid, sender.Hex(), tx.Hash().Hex()) + } + want := bchain.RpcTransaction{ + AccountNonce: "0x7", + GasPrice: "0x3b9aca00", + GasLimit: "0x5208", + To: strings.ToLower(to.Hex()), + Value: "0x3039", + Payload: "0x", + Hash: tx.Hash().Hex(), + From: strings.ToLower(sender.Hex()), + } + if *sent.body != want { + t.Errorf("body = %+v, want %+v", *sent.body, want) + } + }) + + t.Run("dynamic fee transaction with payload", func(t *testing.T) { + raw, sender, tx := signTestTx(t, &types.DynamicFeeTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(2000000000), + GasFeeCap: big.NewInt(30000000000), + Gas: 60000, + To: &to, + Value: big.NewInt(0), + Data: ethcommon.FromHex("0xa9059cbb"), + }) + sent, err := decodeAlternativeSendTx(raw) + if err != nil { + t.Fatalf("decodeAlternativeSendTx() error = %v", err) + } + want := bchain.RpcTransaction{ + AccountNonce: "0x3", + // a pending EIP-1559 transaction reports its fee cap as gasPrice + GasPrice: "0x6fc23ac00", + MaxFeePerGas: "0x6fc23ac00", + MaxPriorityFeePerGas: "0x77359400", + GasLimit: "0xea60", + To: strings.ToLower(to.Hex()), + Value: "0x0", + Payload: "0xa9059cbb", + Hash: tx.Hash().Hex(), + From: strings.ToLower(sender.Hex()), + } + if *sent.body != want { + t.Errorf("body = %+v, want %+v", *sent.body, want) + } + }) + + t.Run("contract creation has no recipient", func(t *testing.T) { + raw, _, _ := signTestTx(t, &types.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(1), + Gas: 100000, + Value: big.NewInt(0), + Data: ethcommon.FromHex("0x60006000"), + }) + sent, err := decodeAlternativeSendTx(raw) + if err != nil { + t.Fatalf("decodeAlternativeSendTx() error = %v", err) + } + if sent.body.To != "" { + t.Errorf("To = %q, want empty for a contract creation", sent.body.To) + } + }) + + t.Run("access list transaction has no fee-market fields", func(t *testing.T) { + // type 1 is the other half of the condition that adds maxFeePerGas/maxPriorityFeePerGas, and + // geth reports neither for it - coverage cannot see this, the branch is shared with legacy + raw, _, tx := signTestTx(t, &types.AccessListTx{ + ChainID: big.NewInt(1), + Nonce: 2, + GasPrice: big.NewInt(7), + Gas: 30000, + To: &to, + Value: big.NewInt(0), + }) + sent, err := decodeAlternativeSendTx(raw) + if err != nil { + t.Fatalf("decodeAlternativeSendTx() error = %v", err) + } + if sent.body.MaxFeePerGas != "" || sent.body.MaxPriorityFeePerGas != "" { + t.Errorf("access list body carries fee-market fields: maxFeePerGas=%q maxPriorityFeePerGas=%q", sent.body.MaxFeePerGas, sent.body.MaxPriorityFeePerGas) + } + if sent.body.GasPrice != "0x7" || sent.txid != tx.Hash().Hex() { + t.Errorf("got (gasPrice=%s txid=%s), want (0x7 %s)", sent.body.GasPrice, sent.txid, tx.Hash().Hex()) + } + }) + + t.Run("unprotected pre-EIP-155 transaction", func(t *testing.T) { + // ChainId() is a non-nil zero for these, which the latest signer rejects by PANICKING - and a + // panic here would abort the answer to a wallet whose transaction the relay already accepted + const unprotected = "0xf85f010182520894333333333333333333333333333333333333333301801ba0e993c688e0fefda43299dc6dffd9142da3d1135e31e7e637f4a479b1a139762ca01d30e00a368007ad7d4cc0102d95f156c3766590ced509d71fc8994153c745a4" + sent, err := decodeAlternativeSendTx(unprotected) + if err != nil { + t.Fatalf("decodeAlternativeSendTx() error = %v", err) + } + if want := "0x71562b71999873db5b286df957af199ec94617f7"; sent.body.From != want { + t.Errorf("from = %q, want %q recovered with the Homestead signer", sent.body.From, want) + } + }) + + t.Run("undecodable raw hex", func(t *testing.T) { + if _, err := decodeAlternativeSendTx("0xdeadbeef"); err == nil { + t.Fatal("decodeAlternativeSendTx() error = nil, want decode failure") + } + }) + + t.Run("unrecoverable signature", func(t *testing.T) { + // decodes as RLP but the signature recovers to nothing, so no sender and no (from, nonce) slot + bogus, err := types.NewTx(&types.LegacyTx{ + Nonce: 1, GasPrice: big.NewInt(1), Gas: 21000, To: &to, Value: big.NewInt(0), + V: big.NewInt(27), R: big.NewInt(0), S: big.NewInt(1), + }).MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary() error = %v", err) + } + if _, err := decodeAlternativeSendTx(hexutil.Encode(bogus)); err == nil { + t.Fatal("decodeAlternativeSendTx() error = nil, want sender recovery failure") + } + }) +} + +// TestAlternativeSendTxProviderAcceptedSendCachedWithoutFetchBack covers the send-not-surfaced hole: a +// relay that ACKs a send but never returns it from eth_getTransactionByHash left it exposed nowhere and +// raising no pending-nonce floor, so the next send could reuse its nonce. Caching from the signed bytes +// closes it, leaving the failed fetch-back to cost only the metric. +func TestAlternativeSendTxProviderAcceptedSendCachedWithoutFetchBack(t *testing.T) { + to := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + rawTx, sender, tx := signTestTx(t, &types.LegacyTx{ + Nonce: 4, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &to, + Value: big.NewInt(1), + }) + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + tx.Hash().Hex() + `"}`, + // the relay accepted the transaction but does not surface it + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + metrics := newReconcileTestMetrics() + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + onlyAlternative: true, + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + metrics: metrics, + } + + txid, err := provider.SendRawTransaction(rawTx) + if err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + if txid != tx.Hash().Hex() { + t.Fatalf("txid = %q, want %q", txid, tx.Hash().Hex()) + } + if server.callCount("eth_getTransactionByHash") == 0 { + t.Error("fetch-back was not attempted") + } + + cached, found := provider.GetTransaction(txid) + if !found { + t.Fatal("accepted transaction is not served as pending after a failed fetch-back") + } + if cached.From != strings.ToLower(sender.Hex()) || cached.AccountNonce != "0x4" { + t.Errorf("cached body = (from=%s nonce=%s), want (%s 0x4)", cached.From, cached.AccountNonce, strings.ToLower(sender.Hex())) + } + if floor, stranded := provider.raiseToPendingFloor(sender, 4, nil); floor != 5 || stranded { + t.Errorf("raiseToPendingFloor(4) = (%d, %v), want (5, false)", floor, stranded) + } + if got := counterVecValue(t, metrics.EthAlternativeSendNotSurfaced, "reason", "not_found"); got != 1 { + t.Errorf("send_not_surfaced{not_found} = %v, want 1", got) + } +} + +func TestAlternativeSendTxProviderUseForNonces(t *testing.T) { + recent := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + expired := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + unknown := ethcommon.HexToAddress("0x4444444444444444444444444444444444444444") + provider := &AlternativeSendTxProvider{ + mempoolTxsTimeout: time.Hour, + recentSenders: map[ethcommon.Address]recentSender{ + recent: {time: time.Now()}, + expired: {time: time.Now().Add(-2 * time.Hour)}, + }, + } + + if !provider.useForNonces(recent) { + t.Error("recent sender not routed to the alternative provider") + } + if provider.useForNonces(unknown) { + t.Error("unknown address routed to the alternative provider") + } + if provider.useForNonces(expired) { + t.Error("expired sender routed to the alternative provider") + } + if _, found := provider.recentSenders[expired]; found { + t.Error("expired sender not evicted on lookup") + } +} + +// TestAlternativeSendTxProviderRoutingHorizonIsIndependentOfRetention pins the split the long cache +// retention forces: a sender stops being routed to the relay well before its transaction stops being +// exposed as pending. Routing is a rate-quota decision (eth_estimateGas rides the same gate, once per +// send-form keystroke - #1629); keeping the nonce reserved is the cache's job, and works off the primary. +func TestAlternativeSendTxProviderRoutingHorizonIsIndependentOfRetention(t *testing.T) { + const senderHex = "0x2222222222222222222222222222222222222222" + sender := ethcommon.HexToAddress(senderHex) + provider := &AlternativeSendTxProvider{ + mempoolTxsTimeout: 3 * time.Hour, + recentSenders: map[ethcommon.Address]recentSender{ + sender: {time: time.Now().Add(-30 * time.Minute)}, + }, + acceptedSlots: map[nonceSlot]acceptedSlot{ + {addr: sender, nonce: 4}: {gen: 1, time: time.Now().Add(-30 * time.Minute)}, + }, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: {tx: &bchain.RpcTransaction{From: senderHex, AccountNonce: "0x4"}, gen: 1}, + }, + } + + if provider.useForNonces(sender) { + t.Error("sender still routed to the relay 30 min after its send, twice the routing horizon") + } + if floor, stranded := provider.raiseToPendingFloor(sender, 4, nil); floor != 5 || stranded { + t.Errorf("raiseToPendingFloor(4) = (%d, %v), want (5, false) - the cached tx must keep its nonce reserved after routing lapsed", floor, stranded) + } + // the acceptance must outlive routing: it is what retires a predecessor whose replacement the + // relay never surfaces (#1573), and a predecessor can land for as long as the cache holds it + if !provider.slotSupersededBy(sender, 4, 0) { + t.Error("accepted nonce slot swept on the routing horizon instead of the cache retention") + } +} + +func TestAlternativeSendTxProviderSendRecordsSender(t *testing.T) { + rawTx, sender := signedTestTx(t) + // callHttpStringResult dials a fresh client per call, so its first request always has id 1 + sendTxResponse := `{"jsonrpc":"2.0","id":1,"result":"` + testAlternativeTxID + `"}` + + t.Run("successful send records the decoded sender", func(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, sendTxResponse) + // recentSenders left nil to also cover the lazy initialization on write + provider := &AlternativeSendTxProvider{urls: []string{server.URL}, mempoolTxsTimeout: time.Hour, rpcTimeout: time.Second} + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + if !provider.useForNonces(sender) { + t.Error("sender not routed to the alternative provider after a successful send") + } + if s := provider.recentSenders[sender]; s.url != server.URL { + t.Errorf("recorded accepting url = %q, want %q", s.url, server.URL) + } + }) + + t.Run("failed send records nothing", func(t *testing.T) { + provider := &AlternativeSendTxProvider{urls: []string{"http://127.0.0.1:1"}, mempoolTxsTimeout: time.Hour, rpcTimeout: time.Second} + + if _, err := provider.SendRawTransaction(rawTx); err == nil { + t.Fatal("expected error from unreachable provider") + } + if provider.useForNonces(sender) { + t.Error("sender recorded despite failed send") + } + }) + + t.Run("undecodable transaction records nothing", func(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, sendTxResponse) + provider := &AlternativeSendTxProvider{urls: []string{server.URL}, mempoolTxsTimeout: time.Hour, rpcTimeout: time.Second} + + if _, err := provider.SendRawTransaction("0xdeadbeef"); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + if len(provider.recentSenders) != 0 { + t.Errorf("recentSenders has %d entries, want 0 after undecodable raw tx", len(provider.recentSenders)) + } + }) + + t.Run("nonce reads follow the accepting provider", func(t *testing.T) { + // urls[0] is unreachable: the broadcast succeeds only through the second provider, so nonce reads + // must go there too - urls[0] never saw the transaction. The nonce server answers + // eth_sendRawTransaction under the empty tag and eth_getTransactionCount under "pending". + server := newNonceRPCServer(t, map[string]string{"": testAlternativeTxID, "pending": "0x9"}, nil) + provider := &AlternativeSendTxProvider{ + urls: []string{"http://127.0.0.1:1", server.URL}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + pending, _, _, err := provider.getNonces(sender, false) + if err != nil { + t.Fatalf("getNonces() error = %v", err) + } + if pending != 9 { + t.Errorf("pending = %d, want 9 from the provider that accepted the send", pending) + } + if got := server.callCount("pending"); got != 1 { + t.Errorf("accepting provider queried %d times for pending, want 1", got) + } + }) + + t.Run("batched nonce read follows the accepting provider", func(t *testing.T) { + // withConfirmed=true exercises the batch branch of getNonces, which dials the url itself + // instead of going through callHttpStringResult - it must pick the accepting provider too + server := newNonceRPCServer(t, map[string]string{"": testAlternativeTxID, "pending": "0x9", "latest": "0x5"}, nil) + provider := &AlternativeSendTxProvider{ + urls: []string{"http://127.0.0.1:1", server.URL}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + pending, confirmed, confirmedOK, err := provider.getNonces(sender, true) + if err != nil { + t.Fatalf("getNonces() error = %v", err) + } + if pending != 9 || confirmed != 5 || !confirmedOK { + t.Errorf("got (pending=%d confirmed=%d ok=%v), want (9 5 true) from the provider that accepted the send", pending, confirmed, confirmedOK) + } + }) + + t.Run("send sweeps expired senders", func(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, sendTxResponse) + stale := ethcommon.HexToAddress("0x5555555555555555555555555555555555555555") + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + recentSenders: map[ethcommon.Address]recentSender{stale: {time: time.Now().Add(-2 * time.Hour)}}, + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + if _, found := provider.recentSenders[stale]; found { + t.Error("expired sender not swept on send") + } + if _, found := provider.recentSenders[sender]; !found { + t.Error("new sender not recorded") + } + }) +} + +func TestAlternativeSendTxProviderRemoveTransactionReleasesSender(t *testing.T) { + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + cachedTx := func(nonce string, gen uint64) storedTx { + return storedTx{ + tx: &bchain.RpcTransaction{ + Hash: testAlternativeTxID, + From: "0x2222222222222222222222222222222222222222", + AccountNonce: nonce, + }, + time: uint32(time.Now().Unix()), + gen: gen, + } + } + makeProvider := func(senderGen uint64, txs map[string]storedTx) *AlternativeSendTxProvider { + return &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + mempoolTxs: txs, + recentSenders: map[ethcommon.Address]recentSender{sender: {time: time.Now(), gen: senderGen}}, + } + } + + t.Run("evicting the last cached tx releases the sender", func(t *testing.T) { + provider := makeProvider(1, map[string]storedTx{testAlternativeTxID: cachedTx("0x1", 1)}) + + provider.RemoveTransaction(testAlternativeTxID) + + if provider.useForNonces(sender) { + t.Error("sender still routed to the alternative provider after its last cached tx settled") + } + }) + + t.Run("another cached tx from the sender keeps the entry", func(t *testing.T) { + provider := makeProvider(2, map[string]storedTx{ + testAlternativeTxID: cachedTx("0x1", 1), + testAlternativeSecondTxID: cachedTx("0x2", 2), + }) + + provider.RemoveTransaction(testAlternativeTxID) + + if !provider.useForNonces(sender) { + t.Error("sender released while another of its txs is still cached") + } + }) + + t.Run("a newer send since the evicted tx keeps the entry", func(t *testing.T) { + // the sender submitted again after the evicted tx was cached (possibly without a cache entry of + // its own, e.g. a failed post-send fetch-back) - the entry must survive. The generation counter + // orders the sends even when both landed within the same wall-clock second. + provider := makeProvider(2, map[string]storedTx{testAlternativeTxID: cachedTx("0x1", 1)}) + + provider.RemoveTransaction(testAlternativeTxID) + + if !provider.useForNonces(sender) { + t.Error("sender released although a newer private send may still be pending") + } + }) + + t.Run("unknown txid releases nothing", func(t *testing.T) { + provider := makeProvider(1, map[string]storedTx{testAlternativeTxID: cachedTx("0x1", 1)}) + + provider.RemoveTransaction("0xdoesnotexist") + + if !provider.useForNonces(sender) { + t.Error("sender released by removal of an unknown txid") + } + }) +} + +func TestAlternativeSendTxProviderHandleMempoolTransactionStampsOwnGeneration(t *testing.T) { + // The cached entry must carry the generation of ITS OWN submission, not the sender's current one: + // the fetch-back is a round-trip during which a concurrent send can bump the generation. Here A + // (gen 1) finishes its slow fetch-back after B (gen 2) was registered but left no cache entry; + // stamping A with gen 2 would make A's eviction release routing while B is still privately pending. + server := newAlternativeTxProviderTestServer(t, testAlternativeKnownTxResponse) + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + mempoolTxs: map[string]storedTx{}, + recentSenders: map[ethcommon.Address]recentSender{sender: {time: time.Now(), gen: 2}}, + } + + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 1); err != nil { + t.Fatalf("handleMempoolTransaction() error = %v", err) + } + if got := provider.mempoolTxs[testAlternativeTxID].gen; got != 1 { + t.Errorf("cached tx generation = %d, want 1 (the generation of its own submission)", got) + } + + // evicting A must keep the routing alive for the uncached, possibly still pending B + provider.RemoveTransaction(testAlternativeTxID) + + if !provider.useForNonces(sender) { + t.Error("sender routing released although a newer private send (generation 2) may still be pending") + } +} + +// TestAlternativeSendTxProviderRaiseToPendingFloor pins the contiguity clamp of #1675: the floor +// advances the backend's pending answer across the run of cached nonces starting at it, never across a +// hole, over the cached nonces and the caller-declared ones alike. A blind max+1 answers 8 for the gap +// case below, queuing every later send behind a nonce nothing fills. +func TestAlternativeSendTxProviderRaiseToPendingFloor(t *testing.T) { + const senderHex = "0x2222222222222222222222222222222222222222" + sender := ethcommon.HexToAddress(senderHex) + cached := func(nonces ...string) map[string]storedTx { + txs := make(map[string]storedTx, len(nonces)+1) + for i, nonce := range nonces { + txs[fmt.Sprintf("0x%02d", i)] = storedTx{tx: &bchain.RpcTransaction{From: senderHex, AccountNonce: nonce}} + } + // another sender's pending tx must never move this one's floor + txs["0xff"] = storedTx{tx: &bchain.RpcTransaction{From: "0x3333333333333333333333333333333333333333", AccountNonce: "0x9"}} + + return txs + } + + tests := []struct { + name string + addr ethcommon.Address + cached map[string]storedTx + declared []uint64 + pending uint64 + wantFloor uint64 + wantStranded bool + }{ + {name: "no cached tx: the backend answer stands", addr: sender, cached: cached(), pending: 4, wantFloor: 4}, + {name: "cached at the pending nonce", addr: sender, cached: cached("0x4"), pending: 4, wantFloor: 5}, + {name: "contiguous run", addr: sender, cached: cached("0x4", "0x5", "0x6"), pending: 4, wantFloor: 7}, + {name: "gap: nothing fills the pending nonce", addr: sender, cached: cached("0x7"), pending: 4, wantFloor: 4, wantStranded: true}, + {name: "run then island", addr: sender, cached: cached("0x4", "0x5", "0x8"), pending: 4, wantFloor: 6, wantStranded: true}, + {name: "cached below the pending nonce is already consumed", addr: sender, cached: cached("0x2", "0x3"), pending: 4, wantFloor: 4}, + {name: "unparsable nonce is skipped", addr: sender, cached: cached("0x4", "0xZZ"), pending: 4, wantFloor: 5}, + {name: "duplicate nonce across two txids counts once", addr: sender, cached: cached("0x4", "0x4"), pending: 4, wantFloor: 5}, + {name: "another sender's cache is invisible", addr: ethcommon.HexToAddress("0x4444444444444444444444444444444444444444"), cached: cached("0x4"), pending: 4, wantFloor: 4}, + {name: "first send of an account", addr: sender, cached: cached("0x0"), pending: 0, wantFloor: 1}, + {name: "the walk cannot wrap below the backend answer", addr: sender, cached: cached(hexutil.EncodeUint64(math.MaxUint64)), pending: math.MaxUint64, wantFloor: math.MaxUint64}, + // a caller-declared nonce holds a slot exactly like a cached one, which is what lets a wallet + // whose send this instance never saw get the same answer as one whose send it cached + {name: "declared alone, nothing cached", addr: sender, cached: cached(), declared: []uint64{4}, pending: 4, wantFloor: 5}, + {name: "declared continues the cached run", addr: sender, cached: cached("0x4"), declared: []uint64{5}, pending: 4, wantFloor: 6}, + {name: "declared fills the hole the cache left", addr: sender, cached: cached("0x5"), declared: []uint64{4}, pending: 4, wantFloor: 6}, + {name: "declared duplicates a cached nonce", addr: sender, cached: cached("0x4"), declared: []uint64{4}, pending: 4, wantFloor: 5}, + {name: "declared below the pending nonce is already consumed", addr: sender, cached: cached(), declared: []uint64{2, 3}, pending: 4, wantFloor: 4}, + {name: "declared above a hole strands, it does not jump", addr: sender, cached: cached(), declared: []uint64{7}, pending: 4, wantFloor: 4, wantStranded: true}, + {name: "declared nonce 0 is a literal, not a sentinel", addr: sender, cached: cached(), declared: []uint64{0}, pending: 0, wantFloor: 1}, + {name: "declared set is order-independent", addr: sender, cached: cached(), declared: []uint64{6, 4, 5}, pending: 4, wantFloor: 7}, + // declared nonces are folded in for whichever address is queried - unlike the cache, this + // function does not filter them by address; scoping the declaration to its own request (and so + // to its own descriptor) is the caller's job, see api.AddressFilter.PrivatePendingNonces + {name: "declared applies to the queried address, unlike another sender's cache", addr: ethcommon.HexToAddress("0x4444444444444444444444444444444444444444"), cached: cached("0x4"), declared: []uint64{4}, pending: 4, wantFloor: 5}, + // a physically unreachable declared nonce cannot corrupt the answer: it is a set member like + // any other, so it is simply never reached by a walk that starts far below it + {name: "declared MaxUint64 next to a real nonce", addr: sender, cached: cached(), declared: []uint64{math.MaxUint64, 4}, pending: 4, wantFloor: 5, wantStranded: true}, + {name: "declared MaxUint64 at the walk's own ceiling", addr: sender, cached: cached(), declared: []uint64{math.MaxUint64}, pending: math.MaxUint64, wantFloor: math.MaxUint64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &AlternativeSendTxProvider{mempoolTxs: tt.cached} + floor, stranded := provider.raiseToPendingFloor(tt.addr, tt.pending, tt.declared) + if floor != tt.wantFloor || stranded != tt.wantStranded { + t.Errorf("raiseToPendingFloor(%d) = (%d, %v), want (%d, %v)", tt.pending, floor, stranded, tt.wantFloor, tt.wantStranded) + } + if floor < tt.pending { + t.Errorf("floor %d fell below the backend's pending answer %d", floor, tt.pending) + } + }) + } +} + +// cachedPredecessor builds a cache entry aged past the reconcile grace period, so a test can attribute +// its retirement to a same-nonce replacement rather than to reconcile timing. +func cachedPredecessor(txid string, from ethcommon.Address, nonceHex string) storedTx { + return storedTx{ + tx: &bchain.RpcTransaction{ + Hash: txid, + From: from.Hex(), + AccountNonce: nonceHex, + }, + time: uint32(time.Now().Add(-3 * time.Minute).Unix()), + } +} + +// TestAlternativeSendTxProviderAcceptedSendRetiresUnsurfacedPredecessor is the core of #1573: a +// replacement or drop-mode cancel accepted by the relay retires the cached predecessor sharing its +// (from, nonce) immediately, even when the relay never surfaces the replacement. Gating that eviction +// behind a successful fetch-back left the predecessor "Unconfirmed" until the cache timeout. +func TestAlternativeSendTxProviderAcceptedSendRetiresUnsurfacedPredecessor(t *testing.T) { + rawTx, sender, sentTxID := signedTestTxWithHash(t) // nonce 1 + // relay accepts the send but never surfaces it (drop mode): getTransactionByHash returns null + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + sentTxID + `"}`, + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + testAlternativeSecondTxID: cachedPredecessor(testAlternativeSecondTxID, sender, "0x1"), + }, + metrics: newReconcileTestMetrics(), + } + var removed string + provider.removeTransactionFromMempool = func(txid string) { + removed = txid + provider.RemoveTransaction(txid) + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + // checked before waiting for the background fetch-back: the retirement must have happened by the + // time the wallet is answered, not merely by the time the relay has been asked about the send + if _, found := provider.GetTransaction(testAlternativeSecondTxID); found { + t.Fatal("predecessor still served as pending when the send returned") + } + provider.waitForRefreshes() + + if removed != testAlternativeSecondTxID { + t.Fatalf("removed txid = %q, want %q (predecessor must be retired on acceptance)", removed, testAlternativeSecondTxID) + } + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; found { + t.Fatal("predecessor remained in cache after an accepted same-nonce replacement") + } + if got := counterVecValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "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) + } +} + +// TestAlternativeSendTxProviderAcceptedSendCachesAndRetiresSurfacedReplacement covers the ordinary +// surfaced RBF path: the replacement is cached, the predecessor retired, and the fee-replacement exit +// counted exactly once - not doubled by the acceptance-time and handleMempoolTransaction removals. +func TestAlternativeSendTxProviderAcceptedSendCachesAndRetiresSurfacedReplacement(t *testing.T) { + rawTx, sender, replacementTxID := signedTestTxWithHash(t) // nonce 1 + // the surfaced replacement shares the sender and nonce of the predecessor + surfaced := `{"jsonrpc":"2.0","id":1,"result":{"hash":"` + replacementTxID + `","from":"` + sender.Hex() + `","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333"}}` + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + replacementTxID + `"}`, + "eth_getTransactionByHash": surfaced, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + testAlternativeSecondTxID: cachedPredecessor(testAlternativeSecondTxID, sender, "0x1"), + }, + metrics: newReconcileTestMetrics(), + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; found { + t.Fatal("predecessor remained in cache after a surfaced same-nonce replacement") + } + cached, found := provider.mempoolTxs[replacementTxID] + if !found { + t.Fatal("surfaced replacement was not cached") + } + // the entry is the one derived from the signed bytes; the fetch-back only probes, and it did run + if cached.tx.GasPrice == "" { + t.Error("cached body is not the one derived from the signed bytes") + } + if got := server.callCount("eth_getTransactionByHash"); got != 1 { + t.Errorf("eth_getTransactionByHash calls = %d, want 1", got) + } + if got := counterVecValue(t, provider.metrics.EthAlternativeMempoolEvents, "action", "rbf_replaced"); got != 1 { + t.Errorf("rbf_replaced events = %v, want 1 (must not double-count)", got) + } +} + +// TestAlternativeSendTxProviderAcceptedSendKeepsDifferentNonce scopes the acceptance-time eviction to +// the same nonce slot: a cached tx with a different nonce is a distinct, still-mineable in-flight tx. +func TestAlternativeSendTxProviderAcceptedSendKeepsDifferentNonce(t *testing.T) { + rawTx, sender, sentTxID := signedTestTxWithHash(t) // nonce 1 + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + sentTxID + `"}`, + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + // same sender but nonce 2 - not the slot the accepted nonce-1 tx fills + testAlternativeSecondTxID: cachedPredecessor(testAlternativeSecondTxID, sender, "0x2"), + }, + metrics: newReconcileTestMetrics(), + } + var removed string + provider.removeTransactionFromMempool = func(txid string) { + removed = txid + provider.RemoveTransaction(txid) + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + if removed != "" { + t.Fatalf("removed txid = %q, want none (different-nonce tx must be kept)", removed) + } + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; !found { + t.Fatal("different-nonce transaction was evicted, want kept") + } +} + +// TestAlternativeSendTxProviderRejectedSendSkipsMempoolFetch verifies that when every relay rejects the +// broadcast the provider skips the mempool-cache path: no eth_getTransactionByHash fan-out for the zero +// hash and no spurious error log (#1629 follow-up). +func TestAlternativeSendTxProviderRejectedSendSkipsMempoolFetch(t *testing.T) { + rawTx, _ := signedTestTx(t) + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"nonce too low"}}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + } + + if _, err := provider.SendRawTransaction(rawTx); err == nil { + t.Fatal("SendRawTransaction() error = nil, want rejection error") + } + provider.waitForRefreshes() + if got := server.callCount("eth_getTransactionByHash"); got != 0 { + t.Fatalf("eth_getTransactionByHash calls = %d, want 0 (rejected send must not touch the mempool cache path)", got) + } +} + +// TestAlternativeSendTxProviderEvictionMetricsGatedOnRemoval attributes the lifecycle metrics to the +// single eviction that actually removed the entry: a second eviction of the same txid (reconcile off a +// stale snapshot after the read path already evicted it) must record nothing under its own action. +func TestAlternativeSendTxProviderEvictionMetricsGatedOnRemoval(t *testing.T) { + added := uint32(time.Now().Add(-3 * time.Minute).Unix()) + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: "0x2222222222222222222222222222222222222222", AccountNonce: "0x1"}, + time: added, + }, + }, + metrics: newReconcileTestMetrics(), + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + + provider.evictMempoolTx("timeout", testAlternativeTxID, added) + // the already-gone entry under a different action must be a no-op + provider.evictMempoolTx("mined", testAlternativeTxID, added) + + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "timeout"); got != 1 { + t.Errorf("timeout events = %v, want 1", got) + } + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "mined"); got != 0 { + t.Errorf("mined events = %v, want 0 (entry already removed, must not be re-counted)", got) + } + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "timeout"); got != 1 { + t.Errorf("timeout residence samples = %d, want 1", got) + } + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "mined"); got != 0 { + t.Errorf("mined residence samples = %d, want 0", got) + } +} + +// TestAlternativeSendTxProviderEvictReplacedByNonceRespectsGeneration verifies the RBF eviction is +// generation-ordered: it retires a strictly-older predecessor for the nonce slot but leaves a strictly +// newer replacement, so an older submission's late fetch-back cannot drop the tx that will actually +// mine (#1573 follow-up / finding #1). +func TestAlternativeSendTxProviderEvictReplacedByNonceRespectsGeneration(t *testing.T) { + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + entry := func(txid string, gen uint64) storedTx { + return storedTx{ + tx: &bchain.RpcTransaction{Hash: txid, From: sender.Hex(), AccountNonce: "0x1"}, + time: uint32(time.Now().Unix()), + gen: gen, + } + } + + t.Run("retires every victim for the slot", func(t *testing.T) { + const thirdTxID = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + otherNonce := entry(thirdTxID, 2) + otherNonce.tx.AccountNonce = "0x2" + var removed []string + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{ + // two entries for one slot: reachable because insertMempoolTx refuses an insert only + // when a STRICTLY newer entry already holds it, so a send whose scan ran first still lands + testAlternativeTxID: entry(testAlternativeTxID, 1), + testAlternativeSecondTxID: entry(testAlternativeSecondTxID, 2), + thirdTxID: otherNonce, // different nonce, must survive + }, + metrics: newReconcileTestMetrics(), + } + provider.removeTransactionFromMempool = func(txid string) { + removed = append(removed, txid) + provider.RemoveTransaction(txid) + } + + provider.evictReplacedByNonce(sender, 1, "0xkeep", 3) + + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Error("gen-1 predecessor was kept") + } + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; found { + t.Error("gen-2 predecessor was kept: stopping after the first victim leaves two entries for one nonce slot") + } + if _, found := provider.mempoolTxs[thirdTxID]; !found { + t.Error("an entry for a different nonce was evicted") + } + if len(removed) != 2 { + t.Errorf("removeTransactionFromMempool fired %d times, want 2 (both stores must be cleared per victim)", len(removed)) + } + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "rbf_replaced"); got != 2 { + t.Errorf("rbf_replaced events = %v, want 2", got) + } + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "rbf_replaced"); got != 2 { + t.Errorf("rbf_replaced residence samples = %d, want 2", got) + } + }) + + t.Run("keeps a strictly newer replacement", func(t *testing.T) { + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{testAlternativeTxID: entry(testAlternativeTxID, 2)}, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + // an older send (keepGen 1) reconciling its own tx must not evict the newer gen-2 entry + provider.evictReplacedByNonce(sender, 1, "0xolder", 1) + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("strictly newer replacement was evicted by an older send") + } + }) + + t.Run("evicts a strictly older predecessor", func(t *testing.T) { + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{testAlternativeSecondTxID: entry(testAlternativeSecondTxID, 1)}, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + provider.evictReplacedByNonce(sender, 1, testAlternativeTxID, 2) + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; found { + t.Fatal("older predecessor was not evicted by a newer replacement") + } + }) + + t.Run("unknown keeper generation keeps a generation-carrying replacement", func(t *testing.T) { + // A keeper whose own send order is unknown (keepGen 0 - raw-hex sender recovery failed at send + // time, though the fetched tx still decodes) must NOT evict a cached replacement carrying a + // real generation: that dropped the newer tx that will mine and surfaced the stale one + // (#1573 follow-up / finding #2). + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{testAlternativeTxID: entry(testAlternativeTxID, 2)}, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + provider.evictReplacedByNonce(sender, 1, "0xunordered", 0) + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("generation-carrying replacement was evicted by an unknown-generation keeper") + } + }) + + t.Run("unknown keeper generation still evicts an unordered predecessor", func(t *testing.T) { + // When neither side carries a generation the order is genuinely unknown; the keeper is the + // just-accepted replacement, so retiring the equally-unordered predecessor keeps the #1573 + // acceptance-time eviction working for that path. + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{testAlternativeSecondTxID: entry(testAlternativeSecondTxID, 0)}, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + provider.evictReplacedByNonce(sender, 1, testAlternativeTxID, 0) + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; found { + t.Fatal("unordered predecessor was not evicted by an unknown-generation keeper") + } + }) +} + +// TestAlternativeSendTxProviderHandleMempoolTransactionSkipsStaleFetchBack verifies that a slow +// fetch-back for an older submission does not cache itself or evict the newer same-nonce +// replacement a concurrent higher-generation send already cached (#1573 follow-up / finding #1). +func TestAlternativeSendTxProviderHandleMempoolTransactionSkipsStaleFetchBack(t *testing.T) { + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + // getTransactionByHash returns the older tx A (testAlternativeTxID), same sender+nonce as the + // newer cached replacement B (testAlternativeSecondTxID, gen 2) + server := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":{"hash":"`+testAlternativeTxID+`","from":"`+sender.Hex()+`","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333"}}`) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + testAlternativeSecondTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeSecondTxID, From: sender.Hex(), AccountNonce: "0x1"}, + time: uint32(time.Now().Unix()), + gen: 2, + }, + }, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + + // A is gen 1 - older than the cached gen-2 replacement B + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 1); err != nil { + t.Fatalf("handleMempoolTransaction() error = %v", err) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Fatal("stale (older-generation) fetch-back was cached, should be skipped") + } + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; !found { + t.Fatal("newer replacement was evicted by a stale older-generation fetch-back") + } +} + +// TestAlternativeSendTxProviderSyncRemovalIsMetered verifies the removal path carrying no reconcile +// decision - block sync or the read path via EthereumRPC.removeTransactionFromMempool - records its +// exit: evictMempoolTx meters only the deleting goroutine, and block sync usually beats the next +// reconcile probe, so this exit was counted nowhere. +func TestAlternativeSendTxProviderSyncRemovalIsMetered(t *testing.T) { + added := uint32(time.Now().Add(-3 * time.Minute).Unix()) + newProvider := func() *AlternativeSendTxProvider { + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: "0x2222222222222222222222222222222222222222", AccountNonce: "0x1"}, + time: added, + }, + }, + metrics: newReconcileTestMetrics(), + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + return provider + } + + t.Run("block sync removal is counted as sync_removed", func(t *testing.T) { + provider := newProvider() + if !provider.RemoveTransaction(testAlternativeTxID) { + t.Fatal("RemoveTransaction() = false, want true") + } + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "sync_removed"); got != 1 { + t.Errorf("sync_removed events = %v, want 1", got) + } + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "sync_removed"); got != 1 { + t.Errorf("sync_removed residence samples = %d, want 1", got) + } + // a later reconcile off a stale snapshot must not re-count it + provider.evictMempoolTx("mined", testAlternativeTxID, added) + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "mined"); got != 0 { + t.Errorf("mined events = %v, want 0 (entry already removed and metered)", got) + } + }) + + t.Run("a reconcile eviction is not also counted as sync_removed", func(t *testing.T) { + // removeMempoolTx deletes the entry, then the delegate re-enters RemoveTransaction, which must + // find nothing and meter nothing + provider := newProvider() + provider.evictMempoolTx("mined", testAlternativeTxID, added) + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "mined"); got != 1 { + t.Errorf("mined events = %v, want 1", got) + } + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "sync_removed"); got != 0 { + t.Errorf("sync_removed events = %v, want 0 (reconcile exit must not be double-counted)", got) + } + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "sync_removed"); got != 0 { + t.Errorf("sync_removed residence samples = %d, want 0", got) + } + }) +} + +// TestAlternativeSendTxProviderAcceptedSlotRetiresLateFetchBack verifies the acceptance-time retirement +// survives a fetch-back that produces no cache entry: the relay ACK records the (sender, nonce) slot, so +// a slower submission for it is dropped on arrival with nothing cached to order it against (drop mode, +// #1573). +func TestAlternativeSendTxProviderAcceptedSlotRetiresLateFetchBack(t *testing.T) { + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + // getTransactionByHash surfaces the older submission A, sender+nonce 0x1 + response := `{"jsonrpc":"2.0","id":1,"result":{"hash":"` + testAlternativeTxID + `","from":"` + sender.Hex() + `","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333"}}` + + newProvider := func(t *testing.T, slot nonceSlot, slotGen uint64) *AlternativeSendTxProvider { + t.Helper() + server := newAlternativeTxProviderTestServer(t, response) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + // a newer send was accepted for this slot but never produced a cache entry + acceptedSlots: map[nonceSlot]acceptedSlot{slot: {gen: slotGen, time: time.Now()}}, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + return provider + } + + t.Run("drops a submission older than the accepted slot", func(t *testing.T) { + provider := newProvider(t, nonceSlot{addr: sender, nonce: 1}, 2) + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 1); err != nil { + t.Fatalf("handleMempoolTransaction() error = %v", err) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Fatal("submission superseded by a newer accepted send was cached") + } + }) + + t.Run("caches the submission that owns the accepted slot", func(t *testing.T) { + // the slot generation is this submission's own - it must not retire itself + provider := newProvider(t, nonceSlot{addr: sender, nonce: 1}, 2) + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 2); err != nil { + t.Fatalf("handleMempoolTransaction() error = %v", err) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("submission owning the accepted slot was not cached") + } + }) + + t.Run("ignores an accepted slot for a different nonce", func(t *testing.T) { + provider := newProvider(t, nonceSlot{addr: sender, nonce: 2}, 2) + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 1); err != nil { + t.Fatalf("handleMempoolTransaction() error = %v", err) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("submission for an unrelated nonce slot was dropped") + } + }) +} + +// TestAlternativeSendTxProviderSendRecordsAcceptedSlot verifies SendRawTransaction records the +// (sender, nonce) slot it filled even when the relay never surfaces the transaction. +func TestAlternativeSendTxProviderSendRecordsAcceptedSlot(t *testing.T) { + rawTx, sender, sentTxID := signedTestTxWithHash(t) // nonce 1 + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + sentTxID + `"}`, + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + slot, found := provider.acceptedSlots[nonceSlot{addr: sender, nonce: 1}] + if !found { + t.Fatal("accepted (sender, nonce) slot was not recorded") + } + if slot.gen == 0 { + t.Error("accepted slot generation = 0, want the send generation") + } +} + +// TestAlternativeSendTxProviderNotSurfacedMetered counts a relay-accepted send whose fetch-back never +// surfaces under eth_alternative_send_not_surfaced_total - the observable signal that a relay does not +// surface what it accepted (#1638 review). The tx is still cached from its signed bytes, so the counter +// does not imply an empty cache. +func TestAlternativeSendTxProviderNotSurfacedMetered(t *testing.T) { + rawTx, _, sentTxID := signedTestTxWithHash(t) + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + sentTxID + `"}`, + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, // accepted but never surfaced + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + if got := counterVecValue(t, provider.metrics.EthAlternativeSendNotSurfaced, "reason", "not_found"); got != 1 { + t.Errorf("send_not_surfaced{reason=not_found} = %v, want 1", got) + } + // the accepted send is still exposed: cached from the signed bytes, under its true hash + if len(provider.mempoolTxs) != 1 { + t.Errorf("cache size = %d, want 1 (accepted send cached from its signed bytes)", len(provider.mempoolTxs)) + } +} + +// TestAlternativeSendTxProviderSendBroadcastsConcurrently checks the broadcast to several relay URLs +// costs the slowest single URL rather than their sum. Sequentially one unresponsive relay held up the +// wallet's answer for its full timeout, and a wallet that gives up first reports a failure for a +// transaction that is on its way. +func TestAlternativeSendTxProviderSendBroadcastsConcurrently(t *testing.T) { + rawTx, _, txID := signedTestTxWithHash(t) + sendResponse := `{"jsonrpc":"2.0","id":1,"result":"` + txID + `"}` + + // Every relay holds its response until ALL of them have the broadcast in flight, so the send can only + // complete if the broadcasts really are simultaneous - a sequential fan-out (or one capped below + // len(urls)) fails on the barrier fallback rather than on a wall-clock margin a partial regression + // could still fit inside. + const parties = 3 + barrier := newTestBarrier(parties) + servers := make([]*methodAwareServer, parties) + urls := make([]string, len(servers)) + for i := range servers { + servers[i] = newHookedMethodAwareTxProviderTestServer(t, + map[string]string{"eth_sendRawTransaction": sendResponse}, + func(method string) { + if method == "eth_sendRawTransaction" { + barrier.arrive(t) + } + }) + urls[i] = servers[i].URL + } + provider := &AlternativeSendTxProvider{ + urls: urls, + mempoolTxsTimeout: time.Hour, + rpcTimeout: 10 * time.Second, + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + + if !barrier.reached() { + t.Errorf("the %d broadcasts were not in flight simultaneously, want a concurrent fan-out", parties) + } + for i, s := range servers { + if got := s.callCount("eth_sendRawTransaction"); got != 1 { + t.Errorf("url %d received %d broadcasts, want 1", i, got) + } + } +} + +// testBarrier releases every arriving caller only once `parties` of them are waiting, so a test can +// assert N calls really overlap. The fallback timeout keeps a regression a failed assertion, not a hang. +type testBarrier struct { + mu sync.Mutex + arrived int + parties int + open chan struct{} + all bool +} + +func newTestBarrier(parties int) *testBarrier { + return &testBarrier{parties: parties, open: make(chan struct{})} +} + +func (b *testBarrier) arrive(t *testing.T) { + t.Helper() + b.mu.Lock() + b.arrived++ + if b.arrived == b.parties { + b.all = true + close(b.open) + } + b.mu.Unlock() + select { + case <-b.open: + case <-time.After(3 * time.Second): + // the handler runs in a different goroutine, t.Fatalf must not be called from here + t.Error("barrier not reached: calls did not overlap") + } +} + +func (b *testBarrier) reached() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.all +} + +// TestAlternativeSendTxProviderSendAggregatesResultsInURLOrder pins the outcome aggregation of the +// concurrent broadcast: any accepting URL makes the send successful, the first accepting URL in +// configuration order is recorded for nonce routing, and a later failure never turns it into an error. +func TestAlternativeSendTxProviderSendAggregatesResultsInURLOrder(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + sendResponse := `{"jsonrpc":"2.0","id":1,"result":"` + txID + `"}` + rejection := `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"replacement transaction underpriced"}}` + + tests := []struct { + name string + responses []string + wantAccepts int // index of the url expected to be recorded as the accepting one + }{ + {name: "first url accepts", responses: []string{sendResponse, rejection}, wantAccepts: 0}, + {name: "second url accepts", responses: []string{rejection, sendResponse}, wantAccepts: 1}, + {name: "both accept", responses: []string{sendResponse, sendResponse}, wantAccepts: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + urls := make([]string, len(tt.responses)) + for i, response := range tt.responses { + urls[i] = newMethodAwareTxProviderTestServer(t, map[string]string{"eth_sendRawTransaction": response}).URL + } + provider := &AlternativeSendTxProvider{urls: urls, mempoolTxsTimeout: time.Hour, rpcTimeout: time.Second} + + got, err := provider.SendRawTransaction(rawTx) + if err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + if got != txID { + t.Errorf("txid = %q, want %q", got, txID) + } + if s := provider.recentSenders[sender]; s.url != urls[tt.wantAccepts] { + t.Errorf("recorded accepting url = %q, want url %d (%q)", s.url, tt.wantAccepts, urls[tt.wantAccepts]) + } + }) + } + + t.Run("unresponsive url does not fail or stall the send", func(t *testing.T) { + // The case the concurrent fan-out exists for: sequentially, each unresponsive relay burned its + // full rpcTimeout before the next URL was tried, pushing the answer past any wallet deadline. + // Cleanup order matters - the hanging handlers must be released before httptest.Server.Close, + // which waits for outstanding requests (t.Cleanup runs LIFO). + hang := make(chan struct{}) + hold := func(method string) { <-hang } + urls := []string{ + newHookedMethodAwareTxProviderTestServer(t, nil, hold).URL, + newHookedMethodAwareTxProviderTestServer(t, nil, hold).URL, + newMethodAwareTxProviderTestServer(t, map[string]string{"eth_sendRawTransaction": sendResponse}).URL, + } + t.Cleanup(func() { close(hang) }) + const rpcTimeout = 400 * time.Millisecond + provider := &AlternativeSendTxProvider{urls: urls, mempoolTxsTimeout: time.Hour, rpcTimeout: rpcTimeout} + + start := time.Now() + got, err := provider.SendRawTransaction(rawTx) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("SendRawTransaction() error = %v, want the accepting url to decide the outcome", err) + } + if got != txID { + t.Errorf("txid = %q, want %q", got, txID) + } + if s := provider.recentSenders[sender]; s.url != urls[2] { + t.Errorf("recorded accepting url = %q, want %q", s.url, urls[2]) + } + // two unresponsive urls cost 2*rpcTimeout sequentially and one rpcTimeout concurrently + if elapsed >= 2*rpcTimeout { + t.Errorf("send took %s with 2 unresponsive urls, want about one rpcTimeout (%s)", elapsed, rpcTimeout) + } + }) + + t.Run("no url accepts", func(t *testing.T) { + urls := []string{ + newMethodAwareTxProviderTestServer(t, map[string]string{"eth_sendRawTransaction": rejection}).URL, + newMethodAwareTxProviderTestServer(t, map[string]string{"eth_sendRawTransaction": rejection}).URL, + } + provider := &AlternativeSendTxProvider{urls: urls, mempoolTxsTimeout: time.Hour, rpcTimeout: time.Second} + + if _, err := provider.SendRawTransaction(rawTx); err == nil { + t.Fatal("SendRawTransaction() error = nil, want the relay rejection") + } + provider.waitForRefreshes() + if len(provider.recentSenders) != 0 { + t.Errorf("recentSenders has %d entries, want 0 when no url accepted", len(provider.recentSenders)) + } + }) +} + +// newBlockedFetchBackProvider wires a provider whose relay accepts the send immediately but holds its +// eth_getTransactionByHash response until the returned channel is closed, so a test can assert while the +// fetch-back is provably in flight. The surfaced body carries transactionIndex, which a real pending +// transaction never has - an unambiguous marker of the relay's view. +func newBlockedFetchBackProvider(t *testing.T, rawTx string, sender ethcommon.Address, txID string) (*AlternativeSendTxProvider, chan struct{}, *methodAwareServer) { + t.Helper() + return newBlockedFetchBackProviderWithBody(t, rawTx, txID, + `{"jsonrpc":"2.0","id":1,"result":{"hash":"`+txID+`","from":"`+sender.Hex()+`","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333","transactionIndex":"0x7"}}`) +} + +func newBlockedFetchBackProviderWithBody(t *testing.T, rawTx, txID, fetchBackResponse string) (*AlternativeSendTxProvider, chan struct{}, *methodAwareServer) { + t.Helper() + release := make(chan struct{}) + server := newHookedMethodAwareTxProviderTestServer(t, + map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + txID + `"}`, + "eth_getTransactionByHash": fetchBackResponse, + }, + func(method string) { + if method == "eth_getTransactionByHash" { + <-release + } + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: 10 * time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + return provider, release, server +} + +// sendWhileFetchBackBlocked runs the send and returns once it has answered, failing the test if it +// waits for the blocked fetch-back. +func sendWhileFetchBackBlocked(t *testing.T, provider *AlternativeSendTxProvider, rawTx string) string { + t.Helper() + var txid string + var err error + done := make(chan struct{}) + go func() { + defer close(done) + txid, err = provider.SendRawTransaction(rawTx) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("SendRawTransaction() did not answer while the fetch-back was still in flight") + } + if err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + return txid +} + +// TestAlternativeSendTxProviderSendDoesNotWaitForFetchBack checks the post-send fetch-back does not +// delay the wallet's answer: the transaction is cached from its signed bytes before the send returns. +func TestAlternativeSendTxProviderSendDoesNotWaitForFetchBack(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + provider, release, server := newBlockedFetchBackProvider(t, rawTx, sender, txID) + + sendWhileFetchBackBlocked(t, provider, rawTx) + + // the transaction is already exposed as pending, from the signed bytes + cached, found := provider.GetTransaction(txID) + if !found { + t.Fatal("accepted transaction is not served as pending before the fetch-back completes") + } + if cached.TransactionIndex != "" { + t.Error("cached body came from the relay, want the one derived from the signed bytes") + } + + close(release) + provider.waitForRefreshes() + if got := server.callCount("eth_getTransactionByHash"); got != 1 { + t.Errorf("eth_getTransactionByHash calls = %d, want exactly 1 (in the background)", got) + } + // and it stays the derived body afterwards: the fetch-back only probes, so the relay's view - which + // could carry a different hash, `to` or `value` - never becomes what Blockbook serves + after, found := provider.GetTransaction(txID) + if !found { + t.Fatal("transaction left the cache after the fetch-back") + } + if after.TransactionIndex != "" { + t.Errorf("the fetch-back adopted the relay's body: transactionIndex = %q, want it untouched", after.TransactionIndex) + } +} + +// TestAlternativeSendTxProviderFetchBackDoesNotResurrectRemoval is why the fetch-back never writes the +// cache: it answers up to rpcTimeout per URL after the wallet was told the send succeeded, so the tx may +// have mined and block sync cleared it while a relay still reports it pending. Re-inserting it would +// flip a confirmed transaction back to Unconfirmed. +func TestAlternativeSendTxProviderFetchBackDoesNotResurrectRemoval(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + provider, release, _ := newBlockedFetchBackProvider(t, rawTx, sender, txID) + + sendWhileFetchBackBlocked(t, provider, rawTx) + + // block sync indexing the mined transaction, while the fetch-back is still in flight + if !provider.RemoveTransaction(txID) { + t.Fatal("transaction was not cached by the send") + } + + close(release) + provider.waitForRefreshes() + + if _, found := provider.mempoolTxs[txID]; found { + t.Fatal("the fetch-back re-inserted a transaction that had already left the cache") + } + if floor, stranded := provider.raiseToPendingFloor(sender, 1, nil); floor != 1 || stranded { + t.Errorf("raiseToPendingFloor(1) = (%d, %v), want the backend answer (1, false) after the entry was removed", floor, stranded) + } +} + +// TestAlternativeSendTxProviderFetchBackKeepsDerivedBody covers the body a relay disagrees about: it +// must stay the one derived from the signed bytes, which is what keeps the entry visible to +// cachedNoncesFor, to releaseRecentSender and to the nonce-superseded reconcile check. +func TestAlternativeSendTxProviderFetchBackKeepsDerivedBody(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + // surfaced without `from`, so it identifies no nonce slot at all + provider, release, _ := newBlockedFetchBackProviderWithBody(t, rawTx, txID, + `{"jsonrpc":"2.0","id":1,"result":{"hash":"`+txID+`","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333"}}`) + + sendWhileFetchBackBlocked(t, provider, rawTx) + close(release) + provider.waitForRefreshes() + + cached, found := provider.GetTransaction(txID) + if !found { + t.Fatal("transaction left the cache after the fetch-back") + } + if cached.From != strings.ToLower(sender.Hex()) { + t.Errorf("cached From = %q, want the derived %q kept", cached.From, strings.ToLower(sender.Hex())) + } + if floor, stranded := provider.raiseToPendingFloor(sender, 1, nil); floor != 2 || stranded { + t.Errorf("raiseToPendingFloor(1) = (%d, %v), want (2, false)", floor, stranded) + } +} + +// TestAlternativeSendTxProviderFetchBackKeepsMinedTransactionForBlockSync pins that the fetch-back does +// not evict a transaction the relay already reports mined: the relay sees the block before Blockbook +// indexes it, and eviction clears the address index too, leaving the tx in neither store. Block sync +// removes it as sync_removed instead. +func TestAlternativeSendTxProviderFetchBackKeepsMinedTransactionForBlockSync(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + provider, release, _ := newBlockedFetchBackProviderWithBody(t, rawTx, txID, + `{"jsonrpc":"2.0","id":1,"result":{"hash":"`+txID+`","from":"`+sender.Hex()+`","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333","blockNumber":"0x10"}}`) + + sendWhileFetchBackBlocked(t, provider, rawTx) + close(release) + provider.waitForRefreshes() + + if _, found := provider.mempoolTxs[txID]; !found { + t.Fatal("the fetch-back evicted a mined transaction before block sync could index its block") + } + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "mined"); got != 0 { + t.Errorf("mined events = %v, want 0 from the fetch-back", got) + } +} + +// TestAlternativeSendTxProviderUndecodableSendUsesFetchBack covers the fallback: with undecodable raw +// hex nothing can be derived, so the fetch-back still creates the entry under the relay's echoed txid. +func TestAlternativeSendTxProviderUndecodableSendUsesFetchBack(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + testAlternativeTxID + `"}`, + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + } + + txid, err := provider.SendRawTransaction("0xdeadbeef") + if err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + if txid != testAlternativeTxID { + t.Errorf("txid = %q, want the relay echo %q when the raw hex does not decode", txid, testAlternativeTxID) + } + cached, found := provider.mempoolTxs[testAlternativeTxID] + if !found { + t.Fatal("the fetch-back did not cache the transaction when the raw hex could not be decoded") + } + // the acceptance is ordered even though the slot it fills is not: leaving the generation at 0 made + // the entry read as older than every earlier acceptance (see UndecodableReplacementIsCached) + if cached.gen == 0 { + t.Error("cached gen = 0, want the accepted send's generation - the send order is known, only its nonce slot is not") + } + if len(provider.recentSenders) != 0 { + t.Errorf("recentSenders has %d entries, want 0 after an undecodable raw tx", len(provider.recentSenders)) + } +} + +// TestAlternativeSendTxProviderDroppedFetchBackMetered covers the one drop that loses a transaction: on +// the raw-hex-decode-failure path the fetch-back is the only thing that can expose the send, so a refusal +// leaves it served nowhere and raising no pending-nonce floor - the nonce-reuse precursor. +func TestAlternativeSendTxProviderDroppedFetchBackMetered(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + testAlternativeTxID + `"}`, + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + // every slot of the expose allowance taken, so the fetch-back below is refused + exposeCount: maxExposeFetchBacks, + } + + // undecodable raw hex: nothing can be derived from it, so the refused fetch-back loses the send + if _, err := provider.SendRawTransaction("0xdeadbeef"); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + // no waitForRefreshes: the slots are held by hand, so the counter never drains + + if got := labeledCounterValue(t, provider.metrics.EthAlternativeSendNotSurfaced, "reason", "dropped"); got != 1 { + t.Errorf("send_not_surfaced{reason=dropped} = %v, want 1", got) + } + if got := server.callCount("eth_getTransactionByHash"); got != 0 { + t.Errorf("eth_getTransactionByHash calls = %d, want 0 (the fetch-back was refused)", got) + } + if len(provider.mempoolTxs) != 0 { + t.Errorf("cache size = %d, want 0 (nothing could be derived or fetched)", len(provider.mempoolTxs)) + } +} + +// TestAlternativeSendTxProviderDroppedRefreshNotMetered is the other half: a refused refresh must NOT be +// reported as a not-surfaced send - the tx is cached and indexed from its signed bytes, so counting it +// would false-alarm the metric documented as the nonce-reuse precursor. +func TestAlternativeSendTxProviderDroppedRefreshNotMetered(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + txID + `"}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + backgroundCount: maxBackgroundFetchBacks, + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + // no waitForRefreshes: the slots are held by hand, so the counter never drains + + if got := labeledCounterValue(t, provider.metrics.EthAlternativeSendNotSurfaced, "reason", "dropped"); got != 0 { + t.Errorf("send_not_surfaced{reason=dropped} = %v, want 0 for a refused refresh", got) + } + // the send is exposed regardless of the refresh, which is why the drop is not worth reporting + if _, found := provider.GetTransaction(txID); !found { + t.Error("accepted send is not served as pending") + } + if floor, stranded := provider.raiseToPendingFloor(sender, 1, nil); floor != 2 || stranded { + t.Errorf("raiseToPendingFloor(1) = (%d, %v), want (2, false)", floor, stranded) + } +} + +// TestAlternativeSendTxProviderShutdownDropNotMetered keeps a fetch-back declined at shutdown out of the +// counter, so an alert on the nonce-reuse precursor does not fire on every restart catching a send. +func TestAlternativeSendTxProviderShutdownDropNotMetered(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + testAlternativeTxID + `"}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + stop: make(chan struct{}), + } + provider.shutdown() + + if _, err := provider.SendRawTransaction("0xdeadbeef"); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + if got := labeledCounterValue(t, provider.metrics.EthAlternativeSendNotSurfaced, "reason", "dropped"); got != 0 { + t.Errorf("send_not_surfaced{reason=dropped} = %v, want 0 at shutdown", got) + } +} + +// TestAlternativeSendTxProviderNotSurfacedErrorMetered covers the error label of +// eth_alternative_send_not_surfaced_total, and that a failed fetch-back still leaves the tx cached. +func TestAlternativeSendTxProviderNotSurfacedErrorMetered(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + txID + `"}`, + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"rate limited"}}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + } + + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + if got := counterVecValue(t, provider.metrics.EthAlternativeSendNotSurfaced, "reason", "error"); got != 1 { + t.Errorf("send_not_surfaced{reason=error} = %v, want 1", got) + } + if _, found := provider.GetTransaction(txID); !found { + t.Error("transaction is not served as pending after a failed fetch-back") + } + if floor, stranded := provider.raiseToPendingFloor(sender, 1, nil); floor != 2 || stranded { + t.Errorf("raiseToPendingFloor(1) = (%d, %v), want (2, false)", floor, stranded) + } +} + +// TestAlternativeSendTxProviderSendKeysOnSignedBytesHash pins that the txid comes from the signed bytes +// rather than the relay's echo: the signed-bytes hash is what a wallet polls and keys its own optimistic +// pending entry on, so a relay echoing anything else must not decide where the transaction lives. +func TestAlternativeSendTxProviderSendKeysOnSignedBytesHash(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + // the relay echoes a different hash than the bytes it was given + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + testAlternativeSecondTxID + `"}`, + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + } + + got, err := provider.SendRawTransaction(rawTx) + if err != nil { + t.Fatalf("SendRawTransaction() error = %v", err) + } + provider.waitForRefreshes() + + if got != txID { + t.Errorf("returned txid = %q, want the signed bytes hash %q", got, txID) + } + if _, found := provider.mempoolTxs[txID]; !found { + t.Error("transaction was not cached under the hash of its signed bytes") + } + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; found { + t.Error("transaction was cached under the relay's echoed hash") + } + if floor, stranded := provider.raiseToPendingFloor(sender, 1, nil); floor != 2 || stranded { + t.Errorf("raiseToPendingFloor(1) = (%d, %v), want (2, false)", floor, stranded) + } +} + +// TestAlternativeSendTxProviderOldestAgeGauge verifies setMempoolOldestAge reports the age of the +// oldest cached entry and zeroes on an empty cache - the live stuck-tx signal (#1638 review). +func TestAlternativeSendTxProviderOldestAgeGauge(t *testing.T) { + provider := &AlternativeSendTxProvider{fetchMempoolTx: true, metrics: newReconcileTestMetrics()} + + provider.setMempoolOldestAge(uint32(time.Now().Add(-120 * time.Second).Unix())) + if got := gaugeValue(t, provider.metrics.EthAlternativeMempoolOldestAge); got < 115 || got > 125 { + t.Errorf("oldest age gauge = %v, want ~120", got) + } + + provider.setMempoolOldestAge(0) // empty cache + if got := gaugeValue(t, provider.metrics.EthAlternativeMempoolOldestAge); got != 0 { + t.Errorf("oldest age gauge on empty cache = %v, want 0", got) + } +} + +// TestAlternativeSendTxProviderHandleMempoolTransactionUnorderedFetchBackSkipsItself verifies a +// fetch-back of unknown send order (gen 0) skips itself when a generation-carrying entry holds the nonce +// slot: evictReplacedByNonce protects that entry from a gen-0 keeper, so an asymmetric check left BOTH +// cached for one slot. +func TestAlternativeSendTxProviderHandleMempoolTransactionUnorderedFetchBackSkipsItself(t *testing.T) { + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + // getTransactionByHash surfaces A (testAlternativeTxID), same sender+nonce as the cached gen-2 B + server := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":{"hash":"`+testAlternativeTxID+`","from":"`+sender.Hex()+`","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333"}}`) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + testAlternativeSecondTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeSecondTxID, From: sender.Hex(), AccountNonce: "0x1"}, + time: uint32(time.Now().Unix()), + gen: 2, + }, + }, + } + provider.removeTransactionFromMempool = func(txid string) { provider.RemoveTransaction(txid) } + + // gen 0 - this submission's send order is unknown + if _, err := provider.handleMempoolTransaction(testAlternativeTxID, 0); err != nil { + t.Fatalf("handleMempoolTransaction() error = %v", err) + } + + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Fatal("unordered fetch-back was cached alongside a generation-carrying entry for the same nonce slot") + } + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; !found { + t.Fatal("generation-carrying entry was evicted by an unordered fetch-back") + } + if len(provider.mempoolTxs) != 1 { + t.Fatalf("cached entries for one nonce slot = %d, want 1", len(provider.mempoolTxs)) + } +} + +// TestAlternativeSendTxProviderGetTransactionReturnsCopy pins that the cached body never leaves the +// provider: its only caller hands the result to EthTxToTx with fixEIP55=true, which rewrites From and To +// in place holding no lock. Mutating the result and finding the cache unchanged proves ownership without +// depending on the race detector catching a schedule. +func TestAlternativeSendTxProviderGetTransactionReturnsCopy(t *testing.T) { + const from = "0x2222222222222222222222222222222222222222" + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: from, To: "0x3333333333333333333333333333333333333333", AccountNonce: "0x1"}, + time: uint32(time.Now().Unix()), + }, + }, + } + + body, found := provider.GetTransaction(testAlternativeTxID) + if !found { + t.Fatal("cached tx not found") + } + if body == provider.mempoolTxs[testAlternativeTxID].tx { + t.Fatal("GetTransaction handed out the cached body itself") + } + + // stands in for EthTxToTx's in-place EIP-55 rewrite of From/To + body.From = strings.ToUpper(from) + body.To = "" + + cached := provider.mempoolTxs[testAlternativeTxID].tx + if cached.From != from { + t.Errorf("cached From = %q, want the untouched %q", cached.From, from) + } + if cached.To == "" { + t.Error("caller cleared the cached To") + } +} + +// TestAlternativeSendTxProviderGetTransactionNilBody covers the entry whose body is nil: every other +// reader of the cache guards against it, so the read path must too rather than dereference it. +func TestAlternativeSendTxProviderGetTransactionNilBody(t *testing.T) { + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{testAlternativeTxID: {time: uint32(time.Now().Unix())}}, + } + + if tx, found := provider.GetTransaction(testAlternativeTxID); found || tx != nil { + t.Fatalf("GetTransaction() = (%v, %v), want (nil, false) for an entry with no body", tx, found) + } +} + +// TestAlternativeSendTxProviderGetTransactionTimeoutCleansWrappedMempool verifies the read-path +// staleness eviction routes through the removeTransactionFromMempool delegate, which clears the wrapped +// mempool's address index, not the cache-only RemoveTransaction: otherwise an expired private tx lingers +// there whenever the caller's own primary-RPC lookup errors instead of returning null (finding #3). +func TestAlternativeSendTxProviderGetTransactionTimeoutCleansWrappedMempool(t *testing.T) { + var removed string + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Minute, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: "0x2222222222222222222222222222222222222222", AccountNonce: "0x1"}, + time: uint32(time.Now().Add(-2 * time.Minute).Unix()), // already past mempoolTxsTimeout + }, + }, + metrics: newReconcileTestMetrics(), + } + // stands in for EthereumRPC.removeTransactionFromMempool, which clears b.Mempool too + provider.removeTransactionFromMempool = func(txid string) { removed = txid; provider.RemoveTransaction(txid) } + + if _, found := provider.GetTransaction(testAlternativeTxID); found { + t.Fatal("expired tx returned as found") + } + if removed != testAlternativeTxID { + t.Fatalf("removeTransactionFromMempool delegate not invoked on read-path timeout; removed=%q, want %q", removed, testAlternativeTxID) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Fatal("expired tx remained in provider cache") + } +} + +// TestAlternativeSendTxProviderUndecodableReplacementIsCached pins the one path where the send +// generation cannot come from the signed bytes. A replacement whose raw hex Blockbook cannot decode +// (a transaction type newer than the pinned go-ethereum, or any sender-recovery failure) is still +// accepted by the relay, and the fetch-back is the only thing that can expose it. Its generation must +// therefore still order it AFTER the predecessor it replaces: with an unordered generation, the +// predecessor's own accepted slot read as strictly newer and the replacement was cached nowhere, +// indexed nowhere and never raised the floor - the #1573 symptom, on the path built to prevent it, +// and silently, since inBackground did take the work so no dropped metric fires. +func TestAlternativeSendTxProviderUndecodableReplacementIsCached(t *testing.T) { + rawTx, sender, txID := signedTestTxWithHash(t) + const replacementTxID = testAlternativeSecondTxID + // the relay surfaces the undecodable replacement at the same (from, nonce) slot as its predecessor + relayBody := `{"jsonrpc":"2.0","id":1,"result":{"hash":"` + replacementTxID + `","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","from":"` + strings.ToLower(sender.Hex()) + `","to":"0x3333333333333333333333333333333333333333"}}` + var sendCount int + server := newHookedMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_sendRawTransaction": `{"jsonrpc":"2.0","id":1,"result":"` + replacementTxID + `"}`, + "eth_getTransactionByHash": relayBody, + }, func(method string) { + if method == "eth_sendRawTransaction" { + sendCount++ + } + }) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{}, + metrics: newReconcileTestMetrics(), + } + + // predecessor: decodes, so it registers the (from, nonce) slot with a real generation + if _, err := provider.SendRawTransaction(rawTx); err != nil { + t.Fatalf("SendRawTransaction(predecessor) error = %v", err) + } + provider.waitForRefreshes() + if _, found := provider.GetTransaction(txID); !found { + t.Fatal("predecessor was not cached") + } + + // replacement: same slot, but the raw hex does not decode + if _, err := provider.SendRawTransaction("0xdeadbeef"); err != nil { + t.Fatalf("SendRawTransaction(replacement) error = %v", err) + } + provider.waitForRefreshes() + + if _, found := provider.GetTransaction(replacementTxID); !found { + t.Fatal("the undecodable replacement is exposed nowhere: not served, not indexed, floor not raised") + } + if floor, _ := provider.raiseToPendingFloor(sender, 1, nil); floor != 2 { + t.Errorf("raiseToPendingFloor(1) = %d, want 2 (the replacement holds nonce 1)", floor) + } + // and it retires the predecessor it replaces, exactly as a decodable replacement would + if _, found := provider.GetTransaction(txID); found { + t.Error("predecessor still served as pending after its replacement was accepted") + } + if sendCount != 2 { + t.Errorf("relay sends = %d, want 2", sendCount) + } +} + +// TestAlternativeSendTxProviderShutdownDrainsFetchBacks pins that shutdown waits out the fetch-backs +// already in flight. A fetch-back that outlives it reaches cacheMempoolTransaction and pushes a NewTx +// through the wrapped mempool - after the public server has closed and into the deferred database +// close - and its own work is abandoned mid-flight without being counted anywhere. +func TestAlternativeSendTxProviderShutdownDrainsFetchBacks(t *testing.T) { + release := make(chan struct{}) + var finished atomic.Bool + provider := &AlternativeSendTxProvider{ + rpcTimeout: time.Second, + stop: make(chan struct{}), + } + if !provider.inBackground(backgroundExpose, func() { + <-release + finished.Store(true) + }) { + t.Fatal("fetch-back was not started") + } + + done := make(chan struct{}) + go func() { + provider.shutdown() + close(done) + }() + + select { + case <-done: + t.Fatal("shutdown returned while a fetch-back was still in flight") + case <-time.After(50 * time.Millisecond): + } + + close(release) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("shutdown did not return after the fetch-back finished") + } + if !finished.Load() { + t.Error("shutdown returned before the fetch-back completed its work") + } +} + +// TestAlternativeSendTxProviderShutdownDrainIsBounded pins the drain's deadline: an HTTP rpc.Client's +// Close is a no-op, so a probe already issued cannot be cancelled and a drain that waited for it +// unconditionally would hang shutdown for as long as the relay does. +func TestAlternativeSendTxProviderShutdownDrainIsBounded(t *testing.T) { + release := make(chan struct{}) + defer close(release) + provider := &AlternativeSendTxProvider{ + rpcTimeout: 10 * time.Millisecond, + stop: make(chan struct{}), + } + if !provider.inBackground(backgroundExpose, func() { <-release }) { + t.Fatal("fetch-back was not started") + } + + done := make(chan struct{}) + go func() { + provider.shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("shutdown blocked on a fetch-back that never returns") + } +} + +// TestAlternativeSendTxProviderStartBackgroundRefusesAfterShutdown pins that the shutdown check is +// taken under the same mutex as the counter, so a send racing shutdown cannot pass the check and then +// spawn a goroutine the drain has already stopped waiting for. +func TestAlternativeSendTxProviderStartBackgroundRefusesAfterShutdown(t *testing.T) { + provider := &AlternativeSendTxProvider{rpcTimeout: time.Second, stop: make(chan struct{})} + provider.shutdown() + + if provider.inBackground(backgroundExpose, func() { t.Error("fetch-back ran after shutdown") }) { + t.Error("inBackground accepted work after shutdown") + } + if provider.exposeCount != 0 || provider.backgroundCount != 0 { + t.Errorf("in-flight counters = (%d, %d), want (0, 0)", provider.exposeCount, provider.backgroundCount) + } +} + +// TestStoredTxSlotMatchesTheBody pins that the slot decoded at insert and the one decoded from the body +// never disagree - the whole point of caching it is that four scans stop re-parsing, so the two must +// stay interchangeable, including for a body that carries no slot at all. +func TestStoredTxSlotMatchesTheBody(t *testing.T) { + const senderHex = "0x2222222222222222222222222222222222222222" + for _, tt := range []struct { + name string + tx *bchain.RpcTransaction + }{ + {name: "decodable", tx: &bchain.RpcTransaction{From: senderHex, AccountNonce: "0x4"}}, + {name: "no sender", tx: &bchain.RpcTransaction{AccountNonce: "0x4"}}, + {name: "unparsable nonce", tx: &bchain.RpcTransaction{From: senderHex, AccountNonce: "0xZZ"}}, + {name: "nil body", tx: nil}, + } { + t.Run(tt.name, func(t *testing.T) { + from, nonce, decoded := txSenderAndNonce(tt.tx) + stored := storedTx{tx: tt.tx, from: from, nonce: nonce, decoded: decoded} + gotFrom, gotNonce, gotOK := stored.slot() + if gotFrom != from || gotNonce != nonce || gotOK != decoded { + t.Errorf("slot() = (%v, %d, %v), want (%v, %d, %v)", gotFrom, gotNonce, gotOK, from, nonce, decoded) + } + // an entry built without the decoded copy (every test fixture) must answer identically + fallback := storedTx{tx: tt.tx} + fbFrom, fbNonce, fbOK := fallback.slot() + if fbFrom != from || fbNonce != nonce || fbOK != decoded { + t.Errorf("fallback slot() = (%v, %d, %v), want (%v, %d, %v)", fbFrom, fbNonce, fbOK, from, nonce, decoded) + } + }) + } +} + +// TestMarkMissingAndClearMissingReportTransitions pins the signals reconcile's logging is built on: a +// missing run is announced once however many nulls follow it, and clearing one hands back the ended run. +func TestMarkMissingAndClearMissingReportTransitions(t *testing.T) { + entry := storedTx{time: uint32(time.Now().Add(-time.Hour).Unix())} + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{testAlternativeTxID: entry}, + } + + missingSince, started := provider.markMissing(testAlternativeTxID, entry) + if !started { + t.Fatal("markMissing did not report opening the run") + } + if missingSince == 0 { + t.Fatal("markMissing returned no run start") + } + + if kept, startedAgain := provider.markMissing(testAlternativeTxID, entry); startedAgain || kept != missingSince { + t.Fatalf("markMissing on a running run = (%d, %v), want (%d, false)", kept, startedAgain, missingSince) + } + + if got := provider.clearMissing(testAlternativeTxID, entry); got != missingSince { + t.Fatalf("clearMissing() = %d, want the ended run %d", got, missingSince) + } + if got := provider.clearMissing(testAlternativeTxID, entry); got != 0 { + t.Fatalf("clearMissing() = %d with no run in progress, want 0", got) + } + + // a replaced entry must not inherit its predecessor's run, so neither call reports one + replaced := storedTx{time: entry.time, gen: entry.gen + 1} + provider.mempoolTxs[testAlternativeTxID] = replaced + if _, started := provider.markMissing(testAlternativeTxID, entry); started { + t.Fatal("markMissing opened a run on a stale snapshot") + } +} diff --git a/bchain/coins/eth/backendidentity.go b/bchain/coins/eth/backendidentity.go new file mode 100644 index 0000000000..e6ec69f880 --- /dev/null +++ b/bchain/coins/eth/backendidentity.go @@ -0,0 +1,87 @@ +package eth + +import ( + "context" + "time" + + "github.com/golang/glog" + "github.com/juju/errors" +) + +const ( + // net_version and web3_clientVersion answer with values that change only when the + // backend is restarted onto a new build, so they are refreshed on this period instead + // of on every GetChainInfo - which serves /, /api/ and /api/v2/ on every request. + backendIdentityTTL = 60 * time.Second + // Once refreshes have been failing for this long the cached identity stops standing in + // for a live backend: callers turn a GetChainInfo error into backendError and + // inSync=false, and on EVM chains these two calls are the only part of GetChainInfo + // that touches the backend at all. + backendIdentityMaxFailing = 5 * time.Minute +) + +// backendIdentity holds the near-immutable backend facts GetChainInfo reports. +type backendIdentity struct { + chainID uint64 + clientVersion string +} + +// getBackendIdentity returns the backend chain id and client version, from cache when fresh. +func (b *EthereumRPC) getBackendIdentity() (*backendIdentity, error) { + return identityFromCache(b.identity.Get(time.Now())) +} + +// identityFromCache maps the cache state onto GetChainInfo's contract: no identity at all, or +// refreshes failing for longer than backendIdentityMaxFailing, report the backend as broken; +// a shorter blip keeps serving the cached identity so a transient refresh error does not flip +// the whole instance out of sync over a cosmetic version string. +func identityFromCache(id *backendIdentity, failingFor time.Duration, lastErr error) (*backendIdentity, error) { + if id == nil { + if lastErr == nil { + // only possible while the very first fetch is still in flight + return nil, errors.New("backend identity not fetched yet") + } + return nil, lastErr + } + if lastErr != nil && failingFor > backendIdentityMaxFailing { + return nil, errors.Annotatef(lastErr, "backend identity refresh failing for %v", failingFor.Truncate(time.Second)) + } + return id, nil +} + +func (b *EthereumRPC) fetchBackendIdentity() (backendIdentity, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + netStart := time.Now() + id, err := b.Client.NetworkID(ctx) + b.observeSyncRPCLatency("net_version", netStart, err) + if err != nil { + return backendIdentity{}, err + } + if err := b.validateBackendChainID(id.Uint64()); err != nil { + return backendIdentity{}, err + } + var ver string + web3Start := time.Now() + err = b.RPC.CallContext(ctx, &ver, "web3_clientVersion") + b.observeSyncRPCLatency("web3_clientVersion", web3Start, err) + if err != nil { + return backendIdentity{}, err + } + return backendIdentity{chainID: id.Uint64(), clientVersion: ver}, nil +} + +// validateBackendChainID latches the first chain id the backend reports and refuses any later +// flip: an endpoint answering for a different chain would have the whole index built against +// the wrong backend, so the refresh keeps failing instead of adopting the new id as healthy. +func (b *EthereumRPC) validateBackendChainID(id uint64) error { + if b.validatedChainID.CompareAndSwap(0, id) { + return nil + } + if latched := b.validatedChainID.Load(); latched != id { + err := errors.Errorf("backend chain id changed from %d to %d, the RPC endpoint answers for a different chain", latched, id) + glog.Error(err) + return err + } + return nil +} diff --git a/bchain/coins/eth/backendidentity_test.go b/bchain/coins/eth/backendidentity_test.go new file mode 100644 index 0000000000..391a695e90 --- /dev/null +++ b/bchain/coins/eth/backendidentity_test.go @@ -0,0 +1,52 @@ +//go:build unittest + +package eth + +import ( + "strings" + "testing" + "time" + + "github.com/juju/errors" +) + +func TestIdentityFromCache(t *testing.T) { + id := &backendIdentity{chainID: 1, clientVersion: "Geth/v1.16"} + + if got, err := identityFromCache(id, 0, nil); err != nil || got != id { + t.Fatalf("healthy cache = %v, %v", got, err) + } + // a refresh blip must not flip the instance out of sync over a cosmetic version string + if got, err := identityFromCache(id, backendIdentityMaxFailing, errors.New("timeout")); err != nil || got != id { + t.Fatalf("short outage = %v, %v, want the cached identity", got, err) + } + _, err := identityFromCache(id, backendIdentityMaxFailing+time.Second, errors.New("dial tcp: refused")) + if err == nil || !strings.Contains(err.Error(), "failing for") || !strings.Contains(err.Error(), "dial tcp: refused") { + t.Fatalf("long outage error %v should carry the duration and the backend error", err) + } + // a cold cache has nothing to fall back on, so the fetch error propagates as before + if _, err := identityFromCache(nil, 0, errors.New("dial tcp: refused")); err == nil || !strings.Contains(err.Error(), "refused") { + t.Fatalf("cold failure = %v", err) + } + if _, err := identityFromCache(nil, 0, nil); err == nil { + t.Fatal("cold cache with the first fetch still in flight must report an error, not nil identity") + } +} + +func TestValidateBackendChainID(t *testing.T) { + b := &EthereumRPC{} + if err := b.validateBackendChainID(56); err != nil { + t.Fatal(err) + } + if err := b.validateBackendChainID(56); err != nil { + t.Fatal(err) + } + err := b.validateBackendChainID(1) + if err == nil || !strings.Contains(err.Error(), "56") || !strings.Contains(err.Error(), "1") { + t.Fatalf("chain id flip = %v, want an error naming both ids", err) + } + // the latch keeps the validated id: the flip is refused, not adopted + if err := b.validateBackendChainID(56); err != nil { + t.Fatalf("original id refused after a flip: %v", err) + } +} diff --git a/bchain/coins/eth/contract.go b/bchain/coins/eth/contract.go index 7f53f84963..ca01acb6ee 100644 --- a/bchain/coins/eth/contract.go +++ b/bchain/coins/eth/contract.go @@ -1,7 +1,9 @@ package eth import ( + "bytes" "context" + "encoding/binary" "math/big" "strings" @@ -14,7 +16,6 @@ import ( ) 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) @@ -24,28 +25,87 @@ const tokenTransferEventSignature = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f16 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 +const ( + evmWordBytes = 32 + evmWordHex = evmWordBytes * 2 +) + +// hexNibbles maps an ASCII byte to its hex value, or 0xff if it is not a hex digit. +var hexNibbles = func() (t [256]byte) { + for i := range t { + t[i] = 0xff + } + for c := '0'; c <= '9'; c++ { + t[c] = byte(c - '0') + } + for c := 'a'; c <= 'f'; c++ { + t[c] = byte(c-'a') + 10 + t[c-'a'+'A'] = byte(c-'a') + 10 + } + return t +}() + +// hexDecodeInto decodes exactly len(dst)*2 hex digits from s into dst without +// allocating. It returns false when s has a different length or is not hex. +func hexDecodeInto(dst []byte, s string) bool { + if len(s) != 2*len(dst) { + return false + } + for i := range dst { + hi, lo := hexNibbles[s[2*i]], hexNibbles[s[2*i+1]] + if hi|lo > 0xf { + return false + } + dst[i] = hi<<4 | lo + } + return true +} + +// setBigFromHexWord parses the hex number s into v. A full 32-byte ABI word, +// with or without 0x prefix, is decoded directly, which is roughly ten times +// cheaper than big.Int.SetString. Any other input falls back to SetString with +// the given base so that callers keep their behaviour for unusual data. +func setBigFromHexWord(v *big.Int, s string, base int) bool { + h := s + if has0xPrefix(h) { + h = h[2:] + } + var b [evmWordBytes]byte + if hexDecodeInto(b[:], h) { + v.SetBytes(b[:]) + return true + } + _, ok := v.SetString(s, base) + return ok +} +// addressFromPaddedHex returns the EIP-55 address stored in a 32-byte ABI word +// (log topic or calldata slot). The address is the last 20 bytes, which are +// decoded directly instead of through a big.Int round trip; the padding in +// front of them is not validated, the same way BigToAddress discards the high +// bytes. Inputs shorter than 20 bytes, or with invalid hex in the address +// bytes, take the numeric path. 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) + s = s[2:] } - if !ok { + const addrHexLen = EthereumTypeAddressDescriptorLen * 2 + if len(s) >= addrHexLen { + var a ethcommon.Address + if hexDecodeInto(a[:], s[len(s)-addrHexLen:]) { + return a.String(), nil + } + } + var t big.Int + if _, ok := t.SetString(s, 16); !ok { return "", errors.New("Data is not a number") } - a := ethcommon.BigToAddress(&t) - return a.String(), nil + return ethcommon.BigToAddress(&t).String(), nil } func processTransferEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err error) { @@ -59,14 +119,12 @@ func processTransferEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err var value big.Int if tl == 3 { standard = bchain.FungibleToken - _, ok := value.SetString(l.Data, 0) - if !ok { + if !setBigFromHexWord(&value, l.Data, 0) { 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 { + if !setBigFromHexWord(&value, l.Topics[3], 0) { return nil, errors.New("ERC721 log Topics[3] is not a number") } } else { @@ -84,8 +142,8 @@ func processTransferEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err return &bchain.TokenTransfer{ Standard: standard, Contract: EIP55AddressFromAddress(l.Address), - From: EIP55AddressFromAddress(from), - To: EIP55AddressFromAddress(to), + From: from, + To: to, Value: value, }, nil } @@ -114,23 +172,63 @@ func processERC1155TransferSingleEvent(l *bchain.RpcLog) (transfer *bchain.Token if has0xPrefix(l.Data) { data = data[2:] } - _, ok := id.SetString(data[:64], 16) - if !ok { + if !setBigFromHexWord(&id, data[:evmWordHex], 16) { return nil, errors.New("ERC1155 log Data id is not a number") } - _, ok = value.SetString(data[64:128], 16) - if !ok { + if !setBigFromHexWord(&value, data[evmWordHex:2*evmWordHex], 16) { 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), + From: from, + To: to, MultiTokenValues: []bchain.MultiTokenValue{{Id: id, Value: value}}, }, nil } +func parseEVMLogWordUint64(data string, offset int) (uint64, error) { + if offset < 0 || offset > len(data) || len(data)-offset < evmWordHex { + return 0, errors.New("ERC1155 TransferBatch, invalid data length") + } + var b [evmWordBytes]byte + if !hexDecodeInto(b[:], data[offset:offset+evmWordHex]) { + return 0, errors.New("ERC1155 TransferBatch, not a number") + } + for _, c := range b[:evmWordBytes-8] { + if c != 0 { + return 0, errors.New("ERC1155 TransferBatch, not a number") + } + } + return binary.BigEndian.Uint64(b[evmWordBytes-8:]), nil +} + +func erc1155BatchOffsetHex(offsetBytes uint64) (int, error) { + if offsetBytes < 2*evmWordBytes { + return 0, errors.New("ERC1155 TransferBatch, invalid offset") + } + if offsetBytes%evmWordBytes != 0 { + return 0, errors.New("ERC1155 TransferBatch, invalid offset") + } + maxInt := uint64(^uint(0) >> 1) + if offsetBytes > maxInt/2 { + return 0, errors.New("ERC1155 TransferBatch, invalid offset") + } + return int(offsetBytes * 2), nil +} + +func erc1155BatchArrayEnd(offset int, count uint64) (int, error) { + if offset < 0 { + return 0, errors.New("ERC1155 TransferBatch, invalid offset") + } + maxInt := uint64(^uint(0) >> 1) + base := uint64(offset) + evmWordHex + if base > maxInt || count > (maxInt-base)/evmWordHex { + return 0, errors.New("ERC1155 TransferBatch, invalid data length") + } + return int(base + count*evmWordHex), nil +} + func processERC1155TransferBatchEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err error) { defer func() { if r := recover(); r != nil { @@ -154,41 +252,62 @@ func processERC1155TransferBatchEvent(l *bchain.RpcLog) (transfer *bchain.TokenT 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") + if len(data) < 2*evmWordHex || len(data)%evmWordHex != 0 { + return nil, errors.New("ERC1155 TransferBatch, invalid data length") } - 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") + offsetIdsBytes, err := parseEVMLogWordUint64(data, 0) + if err != nil { + return nil, err } - 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") + offsetIds, err := erc1155BatchOffsetHex(offsetIdsBytes) + if err != nil { + return nil, err } - 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") + offsetValuesBytes, err := parseEVMLogWordUint64(data, evmWordHex) + if err != nil { + return nil, err + } + offsetValues, err := erc1155BatchOffsetHex(offsetValuesBytes) + if err != nil { + return nil, err + } + countIds, err := parseEVMLogWordUint64(data, offsetIds) + if err != nil { + return nil, err + } + countValues, err := parseEVMLogWordUint64(data, offsetValues) + if err != nil { + return nil, err } - 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++ { + endIds, err := erc1155BatchArrayEnd(offsetIds, countValues) + if err != nil { + return nil, err + } + if endIds > len(data) { + return nil, errors.New("ERC1155 TransferBatch, invalid ids data length") + } + endValues, err := erc1155BatchArrayEnd(offsetValues, countValues) + if err != nil { + return nil, err + } + if endValues > len(data) { + return nil, errors.New("ERC1155 TransferBatch, invalid values data length") + } + // countValues cannot truncate: the endValues <= len(data) check above bounds + // it to len(data)/evmWordHex. + count := int(countValues) + idValues := make([]bchain.MultiTokenValue, count) + for i := 0; i < count; i++ { var id, value big.Int - o := offsetIds + 64 + 64*i - _, ok := id.SetString(data[o:o+64], 16) - if !ok { + o := offsetIds + evmWordHex + evmWordHex*i + if !setBigFromHexWord(&id, data[o:o+evmWordHex], 16) { 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 { + o = offsetValues + evmWordHex + evmWordHex*i + if !setBigFromHexWord(&value, data[o:o+evmWordHex], 16) { return nil, errors.New("ERC1155 log Data value is not a number") } idValues[i] = bchain.MultiTokenValue{Id: id, Value: value} @@ -196,38 +315,63 @@ func processERC1155TransferBatchEvent(l *bchain.RpcLog) (transfer *bchain.TokenT return &bchain.TokenTransfer{ Standard: bchain.MultiToken, Contract: EIP55AddressFromAddress(l.Address), - From: EIP55AddressFromAddress(from), - To: EIP55AddressFromAddress(to), + From: from, + To: to, MultiTokenValues: idValues, }, nil } -func contractGetTransfersFromLog(logs []*bchain.RpcLog) (bchain.TokenTransfers, error) { +// contractGetTransfersFromLog extracts token transfers from receipt logs. +// An unparseable log is skipped with a warning so that one malformed event +// does not discard the valid transfers of the transaction. +func contractGetTransfersFromLog(logs []*bchain.RpcLog, txid string, ignored []bchain.AddressDescriptor) bchain.TokenTransfers { 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) - } + if len(l.Topics) == 0 { + continue + } + var process func(*bchain.RpcLog) (*bchain.TokenTransfer, error) + switch l.Topics[0] { + case tokenTransferEventSignature: + process = processTransferEvent + case tokenERC1155TransferSingleEventSignature: + process = processERC1155TransferSingleEvent + case tokenERC1155TransferBatchEventSignature: + process = processERC1155TransferBatchEvent + default: + continue + } + // checked only for transfer events, before the costly decode + if isIgnoredContract(ignored, l.Address) { + continue + } + tt, err := process(l) + if err != nil { + glog.Warningf("contractGetTransfersFromLog: skipping unparseable log of contract %s, tx %s: %v", l.Address, txid, err) + continue + } + if tt != nil { + r = append(r, tt) } } - return r, nil + return r +} + +// isIgnoredContract reports whether the hex address is one of the ignored contracts, without allocating. +func isIgnoredContract(ignored []bchain.AddressDescriptor, address string) bool { + if len(ignored) == 0 || !has0xPrefix(address) { + return false + } + var desc [EthereumTypeAddressDescriptorLen]byte + if !hexDecodeInto(desc[:], address[2:]) { + return false + } + for _, c := range ignored { + if bytes.Equal(c, desc[:]) { + return true + } + } + return false } func contractGetTransfersFromTx(tx *bchain.RpcTransaction) (bchain.TokenTransfers, error) { @@ -238,20 +382,20 @@ func contractGetTransfersFromTx(tx *bchain.RpcTransaction) (bchain.TokenTransfer return nil, err } var t big.Int - _, ok := t.SetString(tx.Payload[10+64:], 16) - if !ok { + if !setBigFromHexWord(&t, tx.Payload[10+64:], 16) { 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), + To: to, Value: t, }) } else if len(tx.Payload) >= 10+192 && - (strings.HasPrefix(tx.Payload, erc721TransferFromMethodSignature) || - strings.HasPrefix(tx.Payload, erc721SafeTransferFromMethodSignature) || + // transferFrom(address,address,uint256) 0x23b872dd is deliberately absent: ERC-20 shares the + // selector and is the common case, so without a receipt the standard cannot be told apart + (strings.HasPrefix(tx.Payload, erc721SafeTransferFromMethodSignature) || strings.HasPrefix(tx.Payload, erc721SafeTransferFromWithDataMethodSignature)) { from, err := addressFromPaddedHex(tx.Payload[10 : 10+64]) if err != nil { @@ -262,15 +406,14 @@ func contractGetTransfersFromTx(tx *bchain.RpcTransaction) (bchain.TokenTransfer return nil, err } var t big.Int - _, ok := t.SetString(tx.Payload[10+128:10+192], 16) - if !ok { + if !setBigFromHexWord(&t, tx.Payload[10+128:10+192], 16) { 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), + From: from, + To: to, Value: t, }) } @@ -284,6 +427,13 @@ func (b *EthereumRPC) EthereumTypeRpcCall(data, to, from string) (string, error) // 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) { + return b.ethCallAtBlock(data, to, from, blockNumber, "single", 1) +} + +// ethCallAtBlock is the shared single-eth_call primitive. mode labels the call in the eth_call +// metrics and subCalls is how many logical reads it carries (1 for direct reads, the batch size +// for a coalescing call), so no caller can opt out of the instrumentation. +func (b *EthereumRPC) ethCallAtBlock(data, to, from string, blockNumber *big.Int, mode string, subCalls int) (string, error) { args := map[string]interface{}{ "data": data, "to": to, @@ -291,15 +441,14 @@ func (b *EthereumRPC) EthereumTypeRpcCallAtBlock(data, to, from string, blockNum if from != "" { args["from"] = from } - - b.observeEthCall("single", 1) + b.observeEthCall(mode, subCalls) 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") + b.observeEthCallError(mode, "rpc") return "", err } return r, nil @@ -374,57 +523,213 @@ func erc20BalanceOfCallData(addrDesc bchain.AddressDescriptor) string { return contractBalanceOfSignature + padded[len(addr):] + addr } +// fetchContractInfo reads name/symbol/decimals, short-circuiting as soon as name() shows the address +// holds no token. (nil, nil) is a conclusive "no token"; an error says nothing about the contract. 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 + // since geth v1.9.15 a call to a non-contract address reverts instead of returning + // empty data, so a deterministic error is the usual "not a token" signal // 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) + if isNonRetriableEthCallError(err) { + return nil, nil + } + return nil, errors.Annotatef(err, "contract name() %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)) + if name == "" { + return nil, nil + } + b.observeEthCallContractInfo("symbol") + data, err = b.EthereumTypeRpcCall(contractSymbolSignature, address, "") + if err != nil { + // a contract whose symbol() reverts has never been surfaced as a token + if isNonRetriableEthCallError(err) { 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 nil, errors.Annotatef(err, "contract symbol() %v", address) + } + symbol := strings.TrimSpace(parseSimpleStringProperty(data)) + b.observeEthCallContractInfo("decimals") + // decimals() is optional; an unreadable value falls back to the coin default + data, _ = b.EthereumTypeRpcCall(contractDecimalsSignature, address, "") + return &bchain.ContractInfo{ + Contract: address, + Name: name, + Symbol: symbol, + Decimals: contractDecimalsOrDefault(parseSimpleNumericProperty(data)), + }, nil +} + +// contractDecimalsOrDefault narrows a decimals() return to the stored width, falling back to the +// coin default when the call yielded nothing usable. +func contractDecimalsOrDefault(d *big.Int) int { + if d == nil { + return EtherAmountDecimalPoint } - return &contract, nil + return int(uint8(d.Uint64())) } // GetContractInfo returns information about a contract func (b *EthereumRPC) GetContractInfo(contractDesc bchain.AddressDescriptor) (*bchain.ContractInfo, error) { - address := EIP55Address(contractDesc) - return b.fetchContractInfo(address) + info, err := b.fetchContractInfo(EIP55Address(contractDesc)) + if info != nil { + // the one place the coin's address form is applied; the batched and Tron paths inherit it + info.Contract = b.contractAddress(contractDesc) + } + return info, err +} + +// contractInfoFields are the metadata reads and their metric labels, in aggregate3 slot order. +var contractInfoFields = [...]struct{ field, signature string }{ + {"name", contractNameSignature}, + {"symbol", contractSymbolSignature}, + {"decimals", contractDecimalsSignature}, +} + +const contractInfoCallsPerContract = len(contractInfoFields) + +// multicall3StarvationCanary closes every aggregate3 chunk: a codeless call costs almost no gas, so +// it fails only once the chunk ran out - the only way to tell a bare revert from a starved sub-call. +const multicall3StarvationCanary = "0x0000000000000000000000000000000000000000" + +// EthereumTypeGetContractInfos resolves metadata for many contracts at once, in input order. One +// aggregate3 call carries a chunk; without Multicall3 each contract falls back to the sequential path. +func (b *EthereumRPC) EthereumTypeGetContractInfos(contractDescs []bchain.AddressDescriptor) []bchain.EthereumContractInfoResult { + if len(contractDescs) == 0 { + return nil + } + results := make([]bchain.EthereumContractInfoResult, len(contractDescs)) + deployed, err := b.probeMulticall3() + if err != nil { + // visible so an eth_getCode-restricting provider is not silent + b.ObserveChainDataFallback("contract_info_multicall", "probe_error") + } + if !deployed { + for i := range contractDescs { + results[i] = b.contractInfoSequential(contractDescs[i]) + } + return results + } + for _, i := range b.contractInfosMulticall3(contractDescs, results) { + results[i] = b.contractInfoSequential(contractDescs[i]) + } + return results +} + +// contractInfoSequential is the short-circuiting per-contract path: chains without Multicall3, and +// elements aggregate3 left ambiguous. +func (b *EthereumRPC) contractInfoSequential(contractDesc bchain.AddressDescriptor) bchain.EthereumContractInfoResult { + info, err := b.GetContractInfo(contractDesc) + return bchain.EthereumContractInfoResult{Info: info, Err: err} +} + +// contractAddress renders the descriptor in the coin's own address form (EIP-55, Tron's Base58) so +// a batched result carries the same address the single-contract path stores. +func (b *EthereumRPC) contractAddress(contractDesc bchain.AddressDescriptor) string { + if b.Parser != nil { + if addresses, _, err := b.Parser.GetAddressesFromAddrDesc(contractDesc); err == nil && len(addresses) > 0 { + return addresses[0] + } + } + return EIP55Address(contractDesc) +} + +// contractInfosMulticall3 fills results from gas-bounded aggregate3 chunks and returns the +// indexes it could not settle; the caller re-reads those with independent eth_calls. +func (b *EthereumRPC) contractInfosMulticall3(contractDescs []bchain.AddressDescriptor, results []bchain.EthereumContractInfoResult) (holes []int) { + valid := make([]int, 0, len(contractDescs)) + for i := range contractDescs { + if len(contractDescs[i]) == EthereumTypeAddressDescriptorLen { + valid = append(valid, i) + continue + } + // a malformed descriptor would fail the whole chunk at encode time + holes = append(holes, i) + } + // the canary takes a slot of its own, so the chunk still fits the configured budget + perChunk := (b.multicall3MaxCalls() - 1) / contractInfoCallsPerContract + if perChunk < 1 { + perChunk = 1 + } + starved := 0 + for start := 0; start < len(valid); start += perChunk { + end := start + perChunk + if end > len(valid) { + end = len(valid) + } + chunk := valid[start:end] + res, err := b.EthereumTypeMulticallAggregate3(contractInfoCalls(contractDescs, chunk), nil) + if err != nil { + // chunk failures are usually systemic: stop at the first and leave the rest to the + // sequential path, one fallback event and one log line per request + holes = append(holes, valid[start:]...) + b.ObserveChainDataFallback("contract_info_multicall", "error") + glog.Warningf("contract info multicall3 failed at chunk [%d:%d), falling back for %d contract(s): %v", start, end, len(valid)-start, err) + break + } + b.observeEthCallContractInfos(len(chunk)) + // the canary is the last slot; see multicall3StarvationCanary + gasStarved := !res[len(res)-1].Success + for j, i := range chunk { + slots := res[j*contractInfoCallsPerContract : (j+1)*contractInfoCallsPerContract] + if gasStarved && (emptyAggregate3Failure(slots[0]) || emptyAggregate3Failure(slots[1])) { + // re-read rather than record a verdict the caller would go on to cache + holes = append(holes, i) + starved++ + continue + } + results[i] = contractInfoFromAggregate3(b.contractAddress(contractDescs[i]), slots) + } + } + // element-level holes, counted apart from a failing chunk's one "error" event + b.observeChainDataFallback("contract_info_multicall", "elem_fallback", starved) + return holes +} + +// contractInfoCalls lays out one chunk: every contract's metadata reads in order, then the canary. +func contractInfoCalls(contractDescs []bchain.AddressDescriptor, chunk []int) []bchain.EthereumMulticallCall { + calls := make([]bchain.EthereumMulticallCall, 0, len(chunk)*contractInfoCallsPerContract+1) + for _, i := range chunk { + target := hexutil.Encode(contractDescs[i]) + for _, f := range contractInfoFields { + // a reverting metadata getter yields Success=false, not a failed batch + calls = append(calls, bchain.EthereumMulticallCall{Target: target, CallData: f.signature, AllowFailure: true}) + } + } + return append(calls, bchain.EthereumMulticallCall{Target: multicall3StarvationCanary, AllowFailure: true}) } +// contractInfoFromAggregate3 reaches the verdict fetchContractInfo would from the same three reads. +func contractInfoFromAggregate3(address string, slots []bchain.EthereumMulticallResult) bchain.EthereumContractInfoResult { + var name string + if slots[0].Success { + name = strings.TrimSpace(parseSimpleStringProperty(slots[0].Data)) + } + // a failed or blank name() is how the chain reports that there is no token here, and a + // reverting symbol() has never been surfaced as one either + if name == "" || !slots[1].Success { + return bchain.EthereumContractInfoResult{} + } + var decimals *big.Int + if slots[2].Success { + decimals = parseSimpleNumericProperty(slots[2].Data) + } + return bchain.EthereumContractInfoResult{Info: &bchain.ContractInfo{ + Contract: address, + Name: name, + Symbol: strings.TrimSpace(parseSimpleStringProperty(slots[1].Data)), + Decimals: contractDecimalsOrDefault(decimals), + }} +} + +// ErrInvalidErc20Balance means a balanceOf eth_call yielded no usable balance: either it returned +// unparseable data (empty "0x" or non-32-byte output) or it reverted deterministically. Both are +// benign and common for dead/non-conforming/rebasing tokens; callers treat it as "no balance" and +// must not log it at warning level (tracked via the observeEthCallError "invalid"/"reverted" metrics). +var ErrInvalidErc20Balance = errors.New("Invalid balance") + // 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) @@ -436,12 +741,18 @@ func (b *EthereumRPC) EthereumTypeGetErc20ContractBalanceAtBlock(addrDesc, contr req := erc20BalanceOfCallData(addrDesc) data, err := b.EthereumTypeRpcCallAtBlock(req, contract, "", blockNumber) if err != nil { + // A deterministic revert (balanceOf reverts for this holder) is benign: no usable + // balance. Match the batch path and treat it as ErrInvalidErc20Balance + if isNonRetriableEthCallError(err) { + b.observeEthCallError("single", "reverted") + return nil, ErrInvalidErc20Balance + } return nil, err } r := parseSimpleNumericProperty(data) if r == nil { b.observeEthCallError("single", "invalid") - return nil, errors.New("Invalid balance") + return nil, ErrInvalidErc20Balance } return r, nil } @@ -457,43 +768,156 @@ func (b *EthereumRPC) erc20BatchSize() int { 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. +// EthereumTypeGetErc20ContractBalances returns balances of multiple ERC20 contracts for a given +// address; see EthereumTypeGetErc20ContractBalancesAtBlock. 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. +// EthereumTypeGetErc20ContractBalancesAtBlock returns balances for multiple ERC20 contracts at a +// block: Multicall3 aggregate3 when available (contracts it leaves unsettled go through the +// JSON-RPC batch), else the JSON-RPC batch alone. Nil entries mark failed/invalid results, +// in input order. 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 { + // Same calldata for all balanceOf calls; only the contract address varies per element. + callData := erc20BalanceOfCallData(addrDesc) + batcher, hasBatcher := b.RPC.(batchCaller) + if !hasBatcher { // Some RPC clients do not support batching; caller will fall back to single calls. + // Multicall3 needs no batcher itself, but settling its holes does. 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) + + // Gate on the cached probe so non-multicall chains don't build calls just to discard them. + deployed, probeErr := b.probeMulticall3() + if probeErr != nil { + // Transient probe failure — visible so an eth_getCode-restricting provider is not silent. + b.ObserveChainDataFallback("erc20_multicall", "probe_error") + } + if !deployed { + return b.erc20BalancesBatchChunked(batcher, callData, contractDescs, blockNumber) + } + balances, holes := b.erc20BalancesMulticall3(callData, contractDescs, blockNumber) + if len(holes) == 0 { + return balances, nil + } + // Callers treat a present entry — even nil — as authoritative, so settle holes with + // independent eth_calls (full gas cap each) instead of reporting them as nil. + sub := make([]bchain.AddressDescriptor, len(holes)) + for j, i := range holes { + sub[j] = contractDescs[i] + } + subBalances, err := b.erc20BalancesBatchChunked(batcher, callData, sub, blockNumber) + if err != nil { + return nil, err + } + for j, i := range holes { + balances[i] = subBalances[j] + } + return balances, nil +} + +// erc20BalancesBatchChunked fetches balances via the JSON-RPC batch path, split into +// erc20BatchSize chunks to keep each batch request within provider limits. +func (b *EthereumRPC) erc20BalancesBatchChunked(batcher batchCaller, callData string, contractDescs []bchain.AddressDescriptor, blockNumber *big.Int) ([]*big.Int, error) { balances := make([]*big.Int, len(contractDescs)) + batchSize := b.erc20BatchSize() 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) + chunkBalances, 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) + copy(balances[start:end], chunkBalances) } return balances, nil } +// emptyAggregate3Failure reports an element that failed with no returndata: a bare revert() +// and gas starvation are indistinguishable from the element alone. +func emptyAggregate3Failure(r bchain.EthereumMulticallResult) bool { + return !r.Success && len(r.Data) <= 2 +} + +// erc20BalancesMulticall3 fetches balanceOf via gas-bounded aggregate3 chunks. holes are the +// indexes it could not settle; the caller must re-read those with independent eth_calls. +func (b *EthereumRPC) erc20BalancesMulticall3(callData string, contractDescs []bchain.AddressDescriptor, blockNumber *big.Int) (balances []*big.Int, holes []int) { + balances = make([]*big.Int, len(contractDescs)) + // Tallied per element and emitted once: this loop runs per held token, and every + // observe* call would otherwise rebuild a label map and re-resolve the child counter. + starved, elemErrors, invalid := 0, 0, 0 + valid := make([]int, 0, len(contractDescs)) + for i := range contractDescs { + if len(contractDescs[i]) == EthereumTypeAddressDescriptorLen { + valid = append(valid, i) + } + } + if skipped := len(contractDescs) - len(valid); skipped > 0 { + glog.V(2).Infof("erc20 balances multicall3: excluding %d malformed contract descriptor(s)", skipped) + } + maxCalls := b.multicall3MaxCalls() + for start := 0; start < len(valid); start += maxCalls { + end := start + maxCalls + if end > len(valid) { + end = len(valid) + } + chunk := valid[start:end] + calls := make([]bchain.EthereumMulticallCall, len(chunk)) + for j, i := range chunk { + calls[j] = bchain.EthereumMulticallCall{ + Target: hexutil.Encode(contractDescs[i]), + CallData: callData, + // a reverting balanceOf yields Success=false, not a failed batch + AllowFailure: true, + } + } + results, err := b.EthereumTypeMulticallAggregate3(calls, blockNumber) + if err != nil { + // Chunk failures are usually systemic: stop at the first, keep decoded chunks and + // leave the rest to the caller — one fallback event and one log line per request. + holes = append(holes, valid[start:]...) + b.ObserveChainDataFallback("erc20_multicall", "error") + glog.Warningf("erc20 balances multicall3 failed at chunk [%d:%d), falling back for %d contract(s): %v", start, end, len(valid)-start, err) + break + } + // Starvation usually shows up as a trailing run: a sub-call that exhausts the gas + // leaves aggregate3 only the 1/64 the EVM withholds, rarely enough for the calls + // after it. A heuristic, not an invariant — it assumes comparably priced sub-calls, + // and a mid-chunk gas hog can spare enough for the rest to succeed, leaving its own + // empty failure read as a bare revert and its balance nil. Accepted because treating + // every empty failure as starved costs a metered call per dead token per request. + tailStarved := len(results) > 0 && emptyAggregate3Failure(results[len(results)-1]) + for j := range results { + i := chunk[j] + if !results[j].Success { + elemErrors++ + // Once the tail is starved, earlier empty failures may be starved too. + if tailStarved && emptyAggregate3Failure(results[j]) { + holes = append(holes, i) + starved++ + } + continue + } + // nil on unparseable output (empty/short), matching the batch path + balances[i] = parseSimpleNumericProperty(results[j].Data) + if balances[i] == nil { + invalid++ + } + } + } + b.observeEthCallErrors("multicall", "elem", elemErrors) + b.observeEthCallErrors("multicall", "invalid", invalid) + // element-level holes, counted apart from a failing chunk's one "error" event + b.observeChainDataFallback("erc20_multicall", "elem_fallback", starved) + return balances, holes +} + func (b *EthereumRPC) erc20BalancesBatch(batcher batchCaller, callData string, contractDescs []bchain.AddressDescriptor) ([]*big.Int, error) { return b.erc20BalancesBatchAtBlock(batcher, callData, contractDescs, nil) } @@ -534,7 +958,10 @@ func (b *EthereumRPC) erc20BalancesBatchAtBlock(batcher batchCaller, callData st 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) + // Benign and high-volume: a successful eth_call returning empty/non-32-byte data, typical of + // dead (self-destructed) or non-ERC20-conforming tokens that linger in holders' contract lists. + // Tracked via the "invalid" metric; logged at V(2) to avoid flooding (one line per holder request). + glog.V(2).Infof("erc20 single eth_call invalid result for %s: %q", hexutil.Encode(contractDesc), data) } } return balances, nil @@ -556,7 +983,7 @@ func (b *EthereumRPC) erc20BalancesBatchAtBlock(batcher batchCaller, callData st 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) + glog.V(2).Infof("erc20 single eth_call invalid result for %s: %q", hexutil.Encode(contractDescs[i]), data) } continue } @@ -565,7 +992,9 @@ func (b *EthereumRPC) erc20BalancesBatchAtBlock(batcher batchCaller, callData st 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]) + // Benign and high-volume: see the single-call note above. Same event on the batch success path, + // dominated by widely-airdropped dead/non-conforming tokens. Tracked via the "invalid" metric. + glog.V(2).Infof("erc20 batch eth_call invalid result for %s: %q", hexutil.Encode(contractDescs[i]), results[i]) } } return balances, nil @@ -578,6 +1007,8 @@ func isNonRetriableEthCallError(err error) bool { // 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") || + // Tron's phrasing for no code at the address; a single-call retry cannot succeed either. + strings.Contains(msg, "smart contract is not exist") || strings.Contains(msg, "invalid opcode") || strings.Contains(msg, "out of gas") || strings.Contains(msg, "stack underflow") || diff --git a/bchain/coins/eth/contract_batch_test.go b/bchain/coins/eth/contract_batch_test.go index eef93ab721..2775abf11f 100644 --- a/bchain/coins/eth/contract_batch_test.go +++ b/bchain/coins/eth/contract_batch_test.go @@ -27,6 +27,13 @@ func (m *mockBatchRPC) EthSubscribe(ctx context.Context, channel interface{}, ar } func (m *mockBatchRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + // Probe answers "not deployed" so these tests deterministically exercise the batch fallback. + if method == "eth_getCode" { + if out, ok := result.(*string); ok { + *out = "0x" + } + return nil + } return errors.New("not implemented") } @@ -428,6 +435,70 @@ func TestEthereumTypeGetErc20ContractBalances(t *testing.T) { } } +func TestEthereumTypeGetErc20ContractBalanceReverted(t *testing.T) { + // A deterministic balanceOf revert must map to the benign ErrInvalidErc20Balance, + // matching the batch path, so callers log it at V(2) instead of warning. + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + for _, revertMsg := range []string{ + "execution reverted: division or modulo by zero", + "execution reverted", + "invalid opcode: INVALID", + "out of gas", + } { + contract := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractKey := hexutil.Encode(contract.Bytes()) + mock := &mockBatchCallRPC{ + callErrors: map[string]error{ + contractKey: errors.New(revertMsg), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balance, err := rpcClient.EthereumTypeGetErc20ContractBalance( + bchain.AddressDescriptor(addr.Bytes()), + bchain.AddressDescriptor(contract.Bytes()), + ) + if balance != nil { + t.Fatalf("%q: expected nil balance, got %v", revertMsg, balance) + } + if err != ErrInvalidErc20Balance { + t.Fatalf("%q: expected ErrInvalidErc20Balance, got %v", revertMsg, err) + } + } +} + +func TestEthereumTypeGetErc20ContractBalanceRPCError(t *testing.T) { + // A genuine (non-revert) RPC error must surface unchanged so it stays visible at warning level. + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contract := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractKey := hexutil.Encode(contract.Bytes()) + rpcErr := errors.New("connection refused") + mock := &mockBatchCallRPC{ + callErrors: map[string]error{ + contractKey: rpcErr, + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balance, err := rpcClient.EthereumTypeGetErc20ContractBalance( + bchain.AddressDescriptor(addr.Bytes()), + bchain.AddressDescriptor(contract.Bytes()), + ) + if balance != nil { + t.Fatalf("expected nil balance, got %v", balance) + } + if err != rpcErr { + t.Fatalf("expected raw RPC error to surface unchanged, got %v", err) + } + if err == ErrInvalidErc20Balance { + t.Fatalf("genuine RPC error must not be classified as ErrInvalidErc20Balance") + } +} + func TestEthereumTypeGetErc20ContractBalancesBatchSize(t *testing.T) { addr := common.HexToAddress("0x0000000000000000000000000000000000000011") contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") @@ -499,3 +570,653 @@ func TestEthereumTypeGetErc20ContractBalancesPartialError(t *testing.T) { t.Fatalf("expected balance[1] to be nil, got %v", balances[1]) } } + +// --- Multicall3 balance path --- + +// Multicall3 deployed -> balances served by one aggregate3 call, order + nil-on-failure preserved. +func TestEthereumTypeGetErc20ContractBalancesViaMulticall3(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractC := common.HexToAddress("0x00000000000000000000000000000000000000cc") + // A=100; B and C return empty data -> nil. All Success=true, so no re-resolve. + fixture := fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: true, Data: fmt.Sprintf("0x%064x", 100)}, + {Success: true, Data: "0x"}, + {Success: true, Data: "0x"}, + }) + mock := &mockMulticallRPC{ + handler: func(string) (string, error) { return fixture, nil }, + // nil getCodeHandler -> default "deployed" stub bytecode. + } + 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()), + 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 balances[0] == nil || balances[0].Cmp(big.NewInt(100)) != 0 { + t.Fatalf("balance[0]=%v, want 100", balances[0]) + } + if balances[1] != nil { + t.Fatalf("balance[1]=%v, want nil (empty data)", balances[1]) + } + if balances[2] != nil { + t.Fatalf("balance[2]=%v, want nil (empty data)", balances[2]) + } + // Exactly one probe + one aggregate3 eth_call; no per-token calls. + ethCall, getCode := mock.callCounts() + if getCode != 1 { + t.Fatalf("expected 1 eth_getCode probe, got %d", getCode) + } + if ethCall != 1 { + t.Fatalf("expected 1 aggregate3 eth_call (all balances in one call), got %d", ethCall) + } +} + +// mockMulticallThenBatchRPC: probe deployed; aggregate3 returns aggregate3Resp when set, else +// aggregate3Err. Re-resolve/fallback batch served by the embedded mockBatchRPC. +type mockMulticallThenBatchRPC struct { + *mockBatchRPC + aggregate3Err error + aggregate3Resp string +} + +func (m *mockMulticallThenBatchRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + switch method { + case "eth_getCode": + if out, ok := result.(*string); ok { + *out = "0x6080" // deployed + } + return nil + case "eth_call": + argMap, _ := args[0].(map[string]interface{}) + to, _ := argMap["to"].(string) + if strings.EqualFold(to, multicall3Address) { + if m.aggregate3Resp != "" { + *result.(*string) = m.aggregate3Resp + return nil + } + return m.aggregate3Err + } + return fmt.Errorf("unexpected single eth_call to %s", to) + default: + return errors.New("unexpected method") + } +} + +// Transient aggregate3 failure -> JSON-RPC batch fallback, balances still correct + ordered. +func TestEthereumTypeGetErc20ContractBalancesFallsBackToBatchOnMulticallError(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + inner := &mockBatchRPC{ + results: map[string]string{ + hexutil.Encode(contractA.Bytes()): fmt.Sprintf("0x%064x", 7), + hexutil.Encode(contractB.Bytes()): fmt.Sprintf("0x%064x", 9), + }, + } + mock := &mockMulticallThenBatchRPC{mockBatchRPC: inner, aggregate3Err: errors.New("aggregate3 transport 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(7)) != 0 { + t.Fatalf("balance[0]=%v, want 7", balances[0]) + } + if balances[1] == nil || balances[1].Cmp(big.NewInt(9)) != 0 { + t.Fatalf("balance[1]=%v, want 9", balances[1]) + } + if len(inner.batchSizes) != 1 || inner.batchSizes[0] != 2 { + t.Fatalf("expected one JSON-RPC batch of size 2, got %v", inner.batchSizes) + } +} + +// Wrong aggregate3 result count leaves the chunk's elements unresolved (caller falls back for +// them), never a silent misalign. +func TestErc20BalancesMulticall3LengthMismatch(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + // One result returned for two calls. + fixture := fixtureAggregate3Result([]bchain.EthereumMulticallResult{{Success: true, Data: "0x01"}}) + mock := &mockMulticallRPC{ + handler: func(string) (string, error) { return fixture, nil }, + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + callData := erc20BalanceOfCallData(bchain.AddressDescriptor(addr.Bytes())) + _, holes := rpcClient.erc20BalancesMulticall3(callData, []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }, nil) + if len(holes) != 2 || holes[0] != 0 || holes[1] != 1 { + t.Fatalf("expected both elements unresolved on length mismatch, got %v", holes) + } +} + +// aggregate3 is chunked at multicall3MaxCallsPerAggregate (gas bound), independent of a larger +// erc20_batch_size (which sizes the JSON-RPC batch by request count). +func TestEthereumTypeGetErc20ContractBalancesMulticallChunkBounded(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + n := multicall3MaxCallsPerAggregate + 20 // spills into a second aggregate3 chunk + contracts := erc20TestContracts(n) + // Return one Success=true result per call in the received aggregate3, so each sub-chunk + // decodes cleanly. + mock := &mockMulticallRPC{ + handler: func(callData string) (string, error) { + cnt, err := aggregate3SubCallCount(callData) + if err != nil { + return "", err + } + res := make([]bchain.EthereumMulticallResult, cnt) + for i := range res { + res[i] = bchain.EthereumMulticallResult{Success: true, Data: fmt.Sprintf("0x%064x", 1)} + } + return fixtureAggregate3Result(res), nil + }, + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second, ChainConfig: &Configuration{Erc20BatchSize: n}} + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(balances) != n { + t.Fatalf("expected %d balances, got %d", n, len(balances)) + } + for i, bal := range balances { + if bal == nil || bal.Sign() != 1 { + t.Fatalf("balance[%d]=%v, want 1", i, bal) + } + } + ethCall, getCode := mock.callCounts() + if getCode != 1 { + t.Fatalf("expected 1 probe, got %d", getCode) + } + if ethCall != 2 { + t.Fatalf("expected 2 aggregate3 calls (chunked at %d despite erc20_batch_size=%d), got %d", multicall3MaxCallsPerAggregate, n, ethCall) + } +} + +// A Success=false aggregate3 element (e.g. gas-starved under the shared budget) must be +// re-resolved via an independent eth_call, not silently reported as nil. +func TestEthereumTypeGetErc20ContractBalancesReresolvesFailedElement(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractC := common.HexToAddress("0x00000000000000000000000000000000000000cc") + // aggregate3: A ok (=50), B Success=false (re-resolved -> 77), C Success=false + genuine revert (-> nil). + agg := fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: true, Data: fmt.Sprintf("0x%064x", 50)}, + {Success: false, Data: "0x"}, + {Success: false, Data: "0x"}, + }) + inner := &mockBatchRPC{ + results: map[string]string{hexutil.Encode(contractB.Bytes()): fmt.Sprintf("0x%064x", 77)}, + perErr: map[string]error{hexutil.Encode(contractC.Bytes()): errors.New("execution reverted")}, + } + mock := &mockMulticallThenBatchRPC{mockBatchRPC: inner, aggregate3Resp: agg} + 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()), + bchain.AddressDescriptor(contractC.Bytes()), + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(50)) != 0 { + t.Fatalf("balance[0]=%v, want 50 (from multicall)", balances[0]) + } + if balances[1] == nil || balances[1].Cmp(big.NewInt(77)) != 0 { + t.Fatalf("balance[1]=%v, want 77 (recovered via re-resolve, not nil)", balances[1]) + } + if balances[2] != nil { + t.Fatalf("balance[2]=%v, want nil (genuine revert stays nil)", balances[2]) + } + // Re-resolve batch must cover only the two Success=false elements. + if len(inner.batchSizes) != 1 || inner.batchSizes[0] != 2 { + t.Fatalf("expected one re-resolve batch of size 2 (the failed subset), got %v", inner.batchSizes) + } +} + +// A Success=false element carrying revert returndata is a genuine revert: it stays nil and is +// not re-resolved; only empty-returndata failures are. +func TestEthereumTypeGetErc20ContractBalancesGenuineRevertNotReresolved(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractC := common.HexToAddress("0x00000000000000000000000000000000000000cc") + // A ok (=50), B empty revert, C Panic(0x11) with returndata. C did not fail empty, so + // the chunk shows no starvation signal and B is read as a bare revert() — neither is + // re-resolved. + panicData := "0x4e487b71" + fmt.Sprintf("%064x", 0x11) + agg := fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: true, Data: fmt.Sprintf("0x%064x", 50)}, + {Success: false, Data: "0x"}, + {Success: false, Data: panicData}, + }) + inner := &mockBatchRPC{ + results: map[string]string{hexutil.Encode(contractB.Bytes()): fmt.Sprintf("0x%064x", 88)}, + } + mock := &mockMulticallThenBatchRPC{mockBatchRPC: inner, aggregate3Resp: agg} + 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()), + bchain.AddressDescriptor(contractC.Bytes()), + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(50)) != 0 { + t.Fatalf("balance[0]=%v, want 50", balances[0]) + } + if balances[1] != nil { + t.Fatalf("balance[1]=%v, want nil (isolated empty revert, clean tail)", balances[1]) + } + if balances[2] != nil { + t.Fatalf("balance[2]=%v, want nil (genuine revert not re-resolved)", balances[2]) + } + // The regression this guards: one metered eth_call per dead token, on every request. + if len(inner.batchSizes) != 0 { + t.Fatalf("expected no re-resolve batch when the tail is not starved, got %v", inner.batchSizes) + } +} + +// Gas starvation is a trailing run, so a chunk whose tail failed empty re-reads its earlier +// empty failures too — but a revert carrying returndata is deterministic and stays nil. +func TestEthereumTypeGetErc20ContractBalancesStarvedTailReresolvesEarlierEmptyFailures(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractC := common.HexToAddress("0x00000000000000000000000000000000000000cc") + contractD := common.HexToAddress("0x00000000000000000000000000000000000000dd") + // A empty failure, B Panic(0x11) with returndata, C ok, D empty failure (the starved tail). + panicData := "0x4e487b71" + fmt.Sprintf("%064x", 0x11) + agg := fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: false, Data: "0x"}, + {Success: false, Data: panicData}, + {Success: true, Data: fmt.Sprintf("0x%064x", 50)}, + {Success: false, Data: "0x"}, + }) + inner := &mockBatchRPC{results: map[string]string{ + hexutil.Encode(contractA.Bytes()): fmt.Sprintf("0x%064x", 11), + hexutil.Encode(contractD.Bytes()): fmt.Sprintf("0x%064x", 44), + }} + mock := &mockMulticallThenBatchRPC{mockBatchRPC: inner, aggregate3Resp: agg} + 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()), + bchain.AddressDescriptor(contractC.Bytes()), + bchain.AddressDescriptor(contractD.Bytes()), + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(11)) != 0 { + t.Fatalf("balance[0]=%v, want 11 (earlier empty failure re-read under a starved tail)", balances[0]) + } + if balances[1] != nil { + t.Fatalf("balance[1]=%v, want nil (revert with returndata is deterministic)", balances[1]) + } + if balances[2] == nil || balances[2].Cmp(big.NewInt(50)) != 0 { + t.Fatalf("balance[2]=%v, want 50 (from aggregate3)", balances[2]) + } + if balances[3] == nil || balances[3].Cmp(big.NewInt(44)) != 0 { + t.Fatalf("balance[3]=%v, want 44 (the starved tail element)", balances[3]) + } + // Only the two empty failures are re-read, not the returndata-carrying revert. + if len(inner.batchSizes) != 1 || inner.batchSizes[0] != 2 { + t.Fatalf("expected one re-resolve batch of size 2 (the empty failures only), got %v", inner.batchSizes) + } +} + +// Only the failed chunk's contracts fall back to the batch; successful chunks keep their balances. +func TestEthereumTypeGetErc20ContractBalancesPartialChunkFailureFallsBackForThatChunkOnly(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + n := multicall3MaxCallsPerAggregate + 20 // chunk 1 = 100 contracts, chunk 2 = 20 + contracts := erc20TestContracts(n) + // only the failed chunk is resolvable via batch: a whole-list fallback would show as nils + inner := &mockBatchRPC{results: map[string]string{}} + for i := multicall3MaxCallsPerAggregate; i < n; i++ { + inner.results[hexutil.Encode(contracts[i])] = fmt.Sprintf("0x%064x", erc20BatchTestValue(i)) + } + mock := &mockMulticallMixedRPC{mockBatchRPC: inner, holeAt: -1, failFrom: multicall3MaxCallsPerAggregate} + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(balances) != n { + t.Fatalf("expected %d balances, got %d", n, len(balances)) + } + // The successful chunk keeps its aggregate3 results. + for i := 0; i < multicall3MaxCallsPerAggregate; i++ { + if balances[i] == nil || balances[i].Cmp(big.NewInt(erc20MulticallTestValue(i))) != 0 { + t.Fatalf("balance[%d]=%v, want %d from the successful aggregate3 chunk", i, balances[i], erc20MulticallTestValue(i)) + } + } + // The failed chunk is recovered through the batch. + for i := multicall3MaxCallsPerAggregate; i < n; i++ { + if balances[i] == nil || balances[i].Cmp(big.NewInt(erc20BatchTestValue(i))) != 0 { + t.Fatalf("balance[%d]=%v, want %d from the batch fallback", i, balances[i], erc20BatchTestValue(i)) + } + } + if want := n - multicall3MaxCallsPerAggregate; len(inner.batchSizes) != 1 || inner.batchSizes[0] != want { + t.Fatalf("expected one fallback batch of %d (the failed chunk only), got %v", want, inner.batchSizes) + } + if mock.ethCalls != 2 { + t.Fatalf("expected 2 aggregate3 calls, got %d", mock.ethCalls) + } +} + +// Distinct per-index balances, so a fallback result written back to the wrong contract is visible. +func erc20MulticallTestValue(i int) int64 { return int64(1000 + i) } +func erc20BatchTestValue(i int) int64 { return int64(500000 + i) } + +func erc20TestContracts(n int) []bchain.AddressDescriptor { + contracts := make([]bchain.AddressDescriptor, n) + for i := range contracts { + var a common.Address + a[19] = byte((i + 1) & 0xff) + a[18] = byte(((i + 1) >> 8) & 0xff) + contracts[i] = bchain.AddressDescriptor(a.Bytes()) + } + return contracts +} + +// aggregate3SubCallCount reads how many sub-calls an aggregate3 request carries: the array +// length sits at selector(4) + outer-offset(1 word). +func aggregate3SubCallCount(callData string) (int, error) { + raw, err := hexutil.Decode(callData) + if err != nil { + return 0, err + } + cnt, ok := wordAsIndex(raw, 4+evmWordBytes) + if !ok { + return 0, fmt.Errorf("aggregate3 sub-call count out of range") + } + return cnt, nil +} + +// A systemic aggregate3 failure must cost one doomed eth_call, not one per remaining chunk. +func TestEthereumTypeGetErc20ContractBalancesSystemicChunkFailureStopsAtFirstChunk(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + n := 3 * multicall3MaxCallsPerAggregate // 3 aggregate3 chunks + contracts := erc20TestContracts(n) + inner := &mockBatchRPC{results: map[string]string{}} + for i, c := range contracts { + inner.results[hexutil.Encode(c)] = fmt.Sprintf("0x%064x", erc20BatchTestValue(i)) + } + // failFrom 0: every aggregate3 call fails, as on a chain whose eth_call gas cap is too low. + mock := &mockMulticallMixedRPC{mockBatchRPC: inner, holeAt: -1, failFrom: 0} + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for i := range contracts { + if balances[i] == nil || balances[i].Cmp(big.NewInt(erc20BatchTestValue(i))) != 0 { + t.Fatalf("balance[%d]=%v, want %d from the batch fallback", i, balances[i], erc20BatchTestValue(i)) + } + } + // The regression: 1 failing aggregate3, not one per chunk. + if mock.ethCalls != 1 { + t.Fatalf("expected 1 aggregate3 call before giving up, got %d", mock.ethCalls) + } + // Nothing resolved, so the whole list goes through the normal chunked batch path. + if len(inner.batchSizes) != 3 { + t.Fatalf("expected 3 batch chunks covering the whole list, got %v", inner.batchSizes) + } +} + +// mockMulticallMixedRPC: probe deployed; aggregate3 answers carry per-global-index values (a +// mis-mapped write-back shows as a wrong balance), the element at holeAt returns Success=false +// with empty returndata (-1 for none; put it last in a chunk to model a starved tail), and the +// chunk covering failFrom on fails. Fallback batch served by mockBatchRPC. +type mockMulticallMixedRPC struct { + *mockBatchRPC + holeAt int + failFrom int + offset int + ethCalls int +} + +func (m *mockMulticallMixedRPC) 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": + *out = "0x6080" // deployed + return nil + case "eth_call": + argMap, _ := args[0].(map[string]interface{}) + to, _ := argMap["to"].(string) + if !strings.EqualFold(to, multicall3Address) { + return fmt.Errorf("unexpected single eth_call to %s", to) + } + m.ethCalls++ + if m.offset >= m.failFrom { + return errors.New("aggregate3 chunk out of gas") + } + data, _ := argMap["data"].(string) + cnt, err := aggregate3SubCallCount(data) + if err != nil { + return err + } + res := make([]bchain.EthereumMulticallResult, cnt) + for i := range res { + if g := m.offset + i; g == m.holeAt { + res[i] = bchain.EthereumMulticallResult{Success: false, Data: "0x"} + } else { + res[i] = bchain.EthereumMulticallResult{Success: true, Data: fmt.Sprintf("0x%064x", erc20MulticallTestValue(g))} + } + } + m.offset += cnt + *out = fixtureAggregate3Result(res) + return nil + default: + return errors.New("unexpected method") + } +} + +// A request carrying both an empty-revert hole and a failed chunk must settle both in one +// fallback batch and write every result back to its own contract. +func TestEthereumTypeGetErc20ContractBalancesMixedHolesSettledInOneBatch(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + n := multicall3MaxCallsPerAggregate + 20 // chunk 1 succeeds with a hole, chunk 2 fails + holeAt := multicall3MaxCallsPerAggregate - 1 // last element of chunk 1: a starved tail + contracts := erc20TestContracts(n) + // The batch may only answer for the hole and the failed chunk; anything else means the + // fallback subset was built wrong. + inner := &mockBatchRPC{results: map[string]string{ + hexutil.Encode(contracts[holeAt]): fmt.Sprintf("0x%064x", erc20BatchTestValue(holeAt)), + }} + for i := multicall3MaxCallsPerAggregate; i < n; i++ { + inner.results[hexutil.Encode(contracts[i])] = fmt.Sprintf("0x%064x", erc20BatchTestValue(i)) + } + mock := &mockMulticallMixedRPC{mockBatchRPC: inner, holeAt: holeAt, failFrom: multicall3MaxCallsPerAggregate} + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(balances) != n { + t.Fatalf("expected %d balances, got %d", n, len(balances)) + } + for i := range contracts { + want := erc20MulticallTestValue(i) + source := "aggregate3" + if i == holeAt || i >= multicall3MaxCallsPerAggregate { + want, source = erc20BatchTestValue(i), "fallback batch" + } + if balances[i] == nil || balances[i].Cmp(big.NewInt(want)) != 0 { + t.Fatalf("balance[%d]=%v, want %d from the %s", i, balances[i], want, source) + } + } + // One batch covering the empty-revert hole plus the failed chunk, nothing else. + if want := 1 + n - multicall3MaxCallsPerAggregate; len(inner.batchSizes) != 1 || inner.batchSizes[0] != want { + t.Fatalf("expected one fallback batch of %d (hole + failed chunk), got %v", want, inner.batchSizes) + } + if mock.ethCalls != 2 { + t.Fatalf("expected 2 aggregate3 calls, got %d", mock.ethCalls) + } +} + +// A malformed descriptor is excluded up front and stays nil (as in the batch path); the valid +// contracts still resolve in one aggregate3 call with no batch fallback. +func TestEthereumTypeGetErc20ContractBalancesMalformedDescriptorExcluded(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractC := common.HexToAddress("0x00000000000000000000000000000000000000cc") + badDesc := make(bchain.AddressDescriptor, 21) // 21 bytes: not an address + var subCallCounts []int + mock := &mockMulticallRPC{ + handler: func(callData string) (string, error) { + cnt, err := aggregate3SubCallCount(callData) + if err != nil { + return "", err + } + subCallCounts = append(subCallCounts, cnt) + res := make([]bchain.EthereumMulticallResult, cnt) + for i := range res { + res[i] = bchain.EthereumMulticallResult{Success: true, Data: fmt.Sprintf("0x%064x", 100*(i+1))} + } + return fixtureAggregate3Result(res), nil + }, + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances( + bchain.AddressDescriptor(addr.Bytes()), + []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + badDesc, + 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 balances[0] == nil || balances[0].Cmp(big.NewInt(100)) != 0 { + t.Fatalf("balance[0]=%v, want 100 (first valid contract)", balances[0]) + } + if balances[1] != nil { + t.Fatalf("balance[1]=%v, want nil (malformed descriptor)", balances[1]) + } + if balances[2] == nil || balances[2].Cmp(big.NewInt(200)) != 0 { + t.Fatalf("balance[2]=%v, want 200 (second valid contract)", balances[2]) + } + // one aggregate3 covering only the valid contracts; no fallback + if len(subCallCounts) != 1 || subCallCounts[0] != 2 { + t.Fatalf("expected one aggregate3 with 2 sub-calls (the valid contracts), got %v", subCallCounts) + } + ethCall, _ := mock.callCounts() + if ethCall != 1 { + t.Fatalf("expected 1 aggregate3 eth_call and no fallback, got %d", ethCall) + } +} + +// A per-chain cap (Tron's node aborts a long constant call) overrides the default chunk bound. +func TestEthereumTypeGetErc20ContractBalancesMulticallChunkOverride(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + const override = 8 + n := override*2 + 3 // three chunks: 8, 8, 3 + contracts := erc20TestContracts(n) + var sizes []int + mock := &mockMulticallRPC{ + handler: func(callData string) (string, error) { + cnt, err := aggregate3SubCallCount(callData) + if err != nil { + return "", err + } + sizes = append(sizes, cnt) + res := make([]bchain.EthereumMulticallResult, cnt) + for i := range res { + res[i] = bchain.EthereumMulticallResult{Success: true, Data: fmt.Sprintf("0x%064x", 1)} + } + return fixtureAggregate3Result(res), nil + }, + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second, ChainConfig: &Configuration{Multicall3MaxCalls: override}} + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(balances) != n { + t.Fatalf("expected %d balances, got %d", n, len(balances)) + } + for i, bal := range balances { + if bal == nil || bal.Sign() != 1 { + t.Fatalf("balance[%d]=%v, want 1", i, bal) + } + } + want := []int{override, override, 3} + if len(sizes) != len(want) { + t.Fatalf("aggregate3 chunk sizes = %v, want %v", sizes, want) + } + for i := range want { + if sizes[i] != want[i] { + t.Fatalf("aggregate3 chunk sizes = %v, want %v", sizes, want) + } + } +} + +// multicall3_max_calls defaults to the built-in bound, so an unset or invalid value cannot +// silently shrink the aggregate3 batch. +func TestMulticall3MaxCallsConfig(t *testing.T) { + for _, tt := range []struct { + name string + client *EthereumRPC + want int + }{ + {"no chain config", &EthereumRPC{}, multicall3MaxCallsPerAggregate}, + {"unset", &EthereumRPC{ChainConfig: &Configuration{}}, multicall3MaxCallsPerAggregate}, + {"negative", &EthereumRPC{ChainConfig: &Configuration{Multicall3MaxCalls: -1}}, multicall3MaxCallsPerAggregate}, + {"configured", &EthereumRPC{ChainConfig: &Configuration{Multicall3MaxCalls: 7}}, 7}, + } { + if got := tt.client.multicall3MaxCalls(); got != tt.want { + t.Fatalf("%s: multicall3MaxCalls()=%d, want %d", tt.name, got, tt.want) + } + } +} + +// Tron reports a missing contract instead of a revert; retrying it as a single call cannot help. +func TestIsNonRetriableEthCallErrorTronMissingContract(t *testing.T) { + if !isNonRetriableEthCallError(errors.New("Smart contract is not exist.")) { + t.Fatal("expected Tron's missing-contract error to be non-retriable") + } + if isNonRetriableEthCallError(errors.New("CPU timeout for 'DUP2' operation executing")) { + t.Fatal("expected a CPU timeout to stay retriable") + } +} diff --git a/bchain/coins/eth/contract_bench_test.go b/bchain/coins/eth/contract_bench_test.go new file mode 100644 index 0000000000..530c546e68 --- /dev/null +++ b/bchain/coins/eth/contract_bench_test.go @@ -0,0 +1,99 @@ +//go:build unittest + +package eth + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +const benchPaddedTopic = "0x0000000000000000000000002aacf811ac1a60081ea39f7783c0d26c500871a8" + +func randomPaddedTopic() string { + var b [20]byte + _, _ = rand.Read(b[:]) + return "0x000000000000000000000000" + hex.EncodeToString(b[:]) +} + +// syntheticTransferLogs builds n ERC-20 Transfer logs with distinct random +// addresses so the parse cost is not skewed by allocator or cache reuse. +func syntheticTransferLogs(n int) []*bchain.RpcLog { + logs := make([]*bchain.RpcLog, n) + for i := range logs { + logs[i] = &bchain.RpcLog{ + Address: randomPaddedTopic()[26:], + Topics: []string{tokenTransferEventSignature, randomPaddedTopic(), randomPaddedTopic()}, + Data: "0x00000000000000000000000000000000000000000000000000000000000f4240", + } + } + return logs +} + +// loadBenchReceipts reads an eth_getBlockReceipts response saved to the path in +// BLOCKBOOK_BENCH_RECEIPTS so the block-level benchmark can run on a real block. +func loadBenchReceipts(b *testing.B) []*bchain.RpcReceipt { + path := os.Getenv("BLOCKBOOK_BENCH_RECEIPTS") + if path == "" { + b.Skip("BLOCKBOOK_BENCH_RECEIPTS not set") + } + raw, err := os.ReadFile(path) + if err != nil { + b.Fatal(err) + } + var resp struct { + Result []*bchain.RpcReceipt `json:"result"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + b.Fatal(err) + } + return resp.Result +} + +func BenchmarkAddressFromPaddedHex(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := addressFromPaddedHex(benchPaddedTopic); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkProcessTransferEvent(b *testing.B) { + logs := syntheticTransferLogs(1024) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := processTransferEvent(logs[i%len(logs)]); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkContractGetTransfersFromLogSynthetic measures a synthetic block of +// 1000 ERC-20 transfers, roughly a busy BSC block. +func BenchmarkContractGetTransfersFromLogSynthetic(b *testing.B) { + logs := syntheticTransferLogs(1000) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + contractGetTransfersFromLog(logs, "bench", nil) + } +} + +// BenchmarkContractGetTransfersFromLogBlock measures the log-parsing work the +// indexer does for one real block, receipt by receipt as during sync. +func BenchmarkContractGetTransfersFromLogBlock(b *testing.B) { + receipts := loadBenchReceipts(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, r := range receipts { + contractGetTransfersFromLog(r.Logs, "bench", nil) + } + } +} diff --git a/bchain/coins/eth/contract_info_batch_test.go b/bchain/coins/eth/contract_info_batch_test.go new file mode 100644 index 0000000000..d2c176e1e0 --- /dev/null +++ b/bchain/coins/eth/contract_info_batch_test.go @@ -0,0 +1,453 @@ +//go:build unittest + +package eth + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + "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" +) + +// abiString encodes s the way a solidity `returns (string)` getter would. +func abiString(s string) string { + data := hexutil.Encode([]byte(s))[2:] + for len(data)%64 != 0 { + data += "00" + } + return "0x" + padHex32("20") + padHex32(fmt.Sprintf("%x", len(s))) + data +} + +func selectorOf(callData string) string { + if len(callData) < 10 { + return callData + } + return callData[:10] +} + +// mockContractInfoRPC answers the deployment probe, aggregate3 calls to the Multicall3 +// address and single eth_calls to individual contracts, recording each of them. +type mockContractInfoRPC struct { + mu sync.Mutex + // code is the eth_getCode answer for the probe; "0x" reports Multicall3 as not deployed. + code string + aggregate3 func(callData string) (string, error) + single func(to, selector string) (string, error) + aggregate3Calls int + // singles are the per-contract eth_calls as "to|selector", in call order. + singles []string +} + +func (m *mockContractInfoRPC) singleCalls() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.singles...) +} + +func (m *mockContractInfoRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} +func (m *mockContractInfoRPC) Close() {} +func (m *mockContractInfoRPC) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + return errors.New("not implemented") +} + +func (m *mockContractInfoRPC) 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": + code := m.code + if code == "" { + code = "0x6080604052" + } + *out = code + return nil + case "eth_call": + argMap, _ := args[0].(map[string]interface{}) + to, _ := argMap["to"].(string) + data, _ := argMap["data"].(string) + if strings.EqualFold(to, multicall3Address) { + m.mu.Lock() + m.aggregate3Calls++ + m.mu.Unlock() + if m.aggregate3 == nil { + return errors.New("no aggregate3 handler installed") + } + resp, err := m.aggregate3(data) + if err != nil { + return err + } + *out = resp + return nil + } + m.mu.Lock() + m.singles = append(m.singles, to+"|"+selectorOf(data)) + m.mu.Unlock() + if m.single == nil { + return errors.New("no single eth_call handler installed") + } + resp, err := m.single(to, selectorOf(data)) + if err != nil { + return err + } + *out = resp + return nil + default: + return fmt.Errorf("unexpected method: %s", method) + } +} + +// tokenMetadataSingle answers the sequential probe; a contract not in tokens reverts on name(), the +// way a non-token contract does. +func tokenMetadataSingle(tokens map[string]string) func(to, selector string) (string, error) { + return func(to, selector string) (string, error) { + name, ok := tokens[strings.ToLower(to)] + if !ok { + return "", errors.New("execution reverted") + } + switch selector { + case contractNameSignature: + return abiString(name), nil + case contractSymbolSignature: + return abiString(strings.ToUpper(name[:1])), nil + case contractDecimalsSignature: + return abiWord(18), nil + } + return "", fmt.Errorf("unexpected selector %s", selector) + } +} + +var ( + contractInfoA = common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractInfoB = common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractInfoC = common.HexToAddress("0x00000000000000000000000000000000000000cc") +) + +func descs(addrs ...common.Address) []bchain.AddressDescriptor { + out := make([]bchain.AddressDescriptor, len(addrs)) + for i, a := range addrs { + out[i] = bchain.AddressDescriptor(a.Bytes()) + } + return out +} + +// One aggregate3 call resolves every contract; a reverting name() comes back as a conclusive +// "not a token" (nil info, nil error) rather than an error. +func TestEthereumTypeGetContractInfos_OneMulticall(t *testing.T) { + fixture := fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: true, Data: abiString("Token A")}, + {Success: true, Data: abiString("TKA")}, + {Success: true, Data: abiWord(6)}, + // B implements none of the three - the ERC1155 / dead contract shape + {Success: false, Data: "0x"}, + {Success: false, Data: "0x"}, + {Success: false, Data: "0x"}, + // canary + {Success: true, Data: "0x"}, + }) + mock := &mockContractInfoRPC{aggregate3: func(string) (string, error) { return fixture, nil }} + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + + got := rpcClient.EthereumTypeGetContractInfos(descs(contractInfoA, contractInfoB)) + + if len(got) != 2 { + t.Fatalf("got %d results, want 2", len(got)) + } + if got[0].Err != nil || got[0].Info == nil { + t.Fatalf("result[0] = %+v, want a resolved token", got[0]) + } + want := bchain.ContractInfo{ + Contract: EIP55Address(contractInfoA.Bytes()), + Name: "Token A", + Symbol: "TKA", + Decimals: 6, + } + if *got[0].Info != want { + t.Fatalf("result[0].Info = %+v, want %+v", *got[0].Info, want) + } + if got[1].Info != nil || got[1].Err != nil { + t.Fatalf("result[1] = %+v, want a conclusive not-a-token", got[1]) + } + if mock.aggregate3Calls != 1 { + t.Fatalf("aggregate3 calls = %d, want 1", mock.aggregate3Calls) + } + if calls := mock.singleCalls(); len(calls) != 0 { + t.Fatalf("single eth_calls = %v, want none", calls) + } +} + +// Without Multicall3 the sequential probe still short-circuits: a non-token costs one eth_call. +func TestEthereumTypeGetContractInfos_SequentialWithoutMulticall3(t *testing.T) { + mock := &mockContractInfoRPC{ + code: "0x", + single: tokenMetadataSingle(map[string]string{strings.ToLower(contractInfoA.Hex()): "alpha"}), + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + + got := rpcClient.EthereumTypeGetContractInfos(descs(contractInfoA, contractInfoB)) + + if got[0].Info == nil || got[0].Info.Name != "alpha" { + t.Fatalf("result[0] = %+v, want the resolved token", got[0]) + } + if got[1].Info != nil || got[1].Err != nil { + t.Fatalf("result[1] = %+v, want a conclusive not-a-token", got[1]) + } + if mock.aggregate3Calls != 0 { + t.Fatalf("aggregate3 calls = %d, want 0", mock.aggregate3Calls) + } + wantCalls := []string{ + contractInfoA.Hex() + "|" + contractNameSignature, + contractInfoA.Hex() + "|" + contractSymbolSignature, + contractInfoA.Hex() + "|" + contractDecimalsSignature, + contractInfoB.Hex() + "|" + contractNameSignature, + } + if got := mock.singleCalls(); !slices.Equal(got, wantCalls) { + t.Fatalf("single eth_calls = %v, want %v", got, wantCalls) + } +} + +// A failing canary means the chunk ran out of gas, so an empty failure may be starvation rather than +// a revert; those contracts are re-read instead of being called not-a-token. +func TestEthereumTypeGetContractInfos_StarvedChunkReReads(t *testing.T) { + fixture := fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: false, Data: "0x"}, + {Success: false, Data: "0x"}, + {Success: false, Data: "0x"}, + {Success: true, Data: abiString("Token B")}, + {Success: true, Data: abiString("TKB")}, + {Success: true, Data: abiWord(8)}, + // canary out of gas + {Success: false, Data: "0x"}, + }) + mock := &mockContractInfoRPC{ + aggregate3: func(string) (string, error) { return fixture, nil }, + single: tokenMetadataSingle(map[string]string{strings.ToLower(contractInfoA.Hex()): "alpha"}), + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + + got := rpcClient.EthereumTypeGetContractInfos(descs(contractInfoA, contractInfoB)) + + if got[0].Info == nil || got[0].Info.Name != "alpha" { + t.Fatalf("result[0] = %+v, want the starved element re-read as a token", got[0]) + } + // the settled element keeps the batched answer, no second read + if got[1].Info == nil || got[1].Info.Name != "Token B" || got[1].Info.Decimals != 8 { + t.Fatalf("result[1] = %+v, want the batched token", got[1]) + } + if calls := mock.singleCalls(); len(calls) != 3 { + t.Fatalf("single eth_calls = %v, want the three reads of contract A only", calls) + } +} + +// A whole-chunk failure is systemic: the contracts it covered are re-read sequentially. +func TestEthereumTypeGetContractInfos_ChunkErrorFallsBack(t *testing.T) { + mock := &mockContractInfoRPC{ + aggregate3: func(string) (string, error) { return "", errors.New("aggregate3 transport boom") }, + single: tokenMetadataSingle(map[string]string{strings.ToLower(contractInfoB.Hex()): "beta"}), + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + + got := rpcClient.EthereumTypeGetContractInfos(descs(contractInfoA, contractInfoB)) + + if got[0].Info != nil || got[0].Err != nil { + t.Fatalf("result[0] = %+v, want a conclusive not-a-token", got[0]) + } + if got[1].Info == nil || got[1].Info.Name != "beta" { + t.Fatalf("result[1] = %+v, want the resolved token", got[1]) + } +} + +// Multicall3MaxCalls bounds sub-calls, so a chunk holds (max-1)/3 contracts: three metadata reads +// each, plus the canary. +func TestEthereumTypeGetContractInfos_ChunksByConfiguredMaxCalls(t *testing.T) { + full := func(name string) []bchain.EthereumMulticallResult { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: abiString(name)}, + {Success: true, Data: abiString("SYM")}, + {Success: true, Data: abiWord(18)}, + } + } + canary := bchain.EthereumMulticallResult{Success: true, Data: "0x"} + responses := []string{ + fixtureAggregate3Result(append(append(full("one"), full("two")...), canary)), + fixtureAggregate3Result(append(full("three"), canary)), + } + mock := &mockContractInfoRPC{} + mock.aggregate3 = func(string) (string, error) { + resp := responses[mock.aggregate3Calls-1] + return resp, nil + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + // two contracts (three reads each) plus the canary + ChainConfig: &Configuration{Multicall3MaxCalls: 7}, + } + + got := rpcClient.EthereumTypeGetContractInfos(descs(contractInfoA, contractInfoB, contractInfoC)) + + if mock.aggregate3Calls != 2 { + t.Fatalf("aggregate3 calls = %d, want 2 (two contracts per chunk)", mock.aggregate3Calls) + } + for i, wantName := range []string{"one", "two", "three"} { + if got[i].Info == nil || got[i].Info.Name != wantName { + t.Fatalf("result[%d] = %+v, want name %q", i, got[i], wantName) + } + } +} + +// The batched decode has to reach the same verdict the three sequential eth_calls would. +func Test_contractInfoFromAggregate3(t *testing.T) { + ok := func(data string) bchain.EthereumMulticallResult { + return bchain.EthereumMulticallResult{Success: true, Data: data} + } + failed := bchain.EthereumMulticallResult{Success: false, Data: "0x"} + bytes32Name := "0x" + padHex32(hexutil.Encode([]byte("MKR"))[2:]+strings.Repeat("00", 29)) + + tests := []struct { + name string + slots []bchain.EthereumMulticallResult + want *bchain.ContractInfo + }{ + { + name: "resolved token", + slots: []bchain.EthereumMulticallResult{ok(abiString("Token")), ok(abiString("TKN")), ok(abiWord(6))}, + want: &bchain.ContractInfo{Contract: "0xC", Name: "Token", Symbol: "TKN", Decimals: 6}, + }, + { + name: "bytes32 name and symbol", + slots: []bchain.EthereumMulticallResult{ok(bytes32Name), ok(bytes32Name), ok(abiWord(18))}, + want: &bchain.ContractInfo{Contract: "0xC", Name: "MKR", Symbol: "MKR", Decimals: 18}, + }, + { + name: "empty name is not a token", + slots: []bchain.EthereumMulticallResult{ok("0x"), ok(abiString("TKN")), ok(abiWord(6))}, + want: nil, + }, + { + name: "reverting name is not a token", + slots: []bchain.EthereumMulticallResult{failed, ok(abiString("TKN")), ok(abiWord(6))}, + want: nil, + }, + { + name: "reverting symbol is not a token", + slots: []bchain.EthereumMulticallResult{ok(abiString("Token")), failed, ok(abiWord(6))}, + want: nil, + }, + { + name: "reverting decimals falls back to the coin default", + slots: []bchain.EthereumMulticallResult{ok(abiString("Token")), ok(abiString("TKN")), failed}, + want: &bchain.ContractInfo{Contract: "0xC", Name: "Token", Symbol: "TKN", Decimals: EtherAmountDecimalPoint}, + }, + { + name: "unparseable decimals falls back to the coin default", + slots: []bchain.EthereumMulticallResult{ok(abiString("Token")), ok(abiString("TKN")), ok("0x")}, + want: &bchain.ContractInfo{Contract: "0xC", Name: "Token", Symbol: "TKN", Decimals: EtherAmountDecimalPoint}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := contractInfoFromAggregate3("0xC", tt.slots) + if got.Err != nil { + t.Fatalf("unexpected error %v", got.Err) + } + if tt.want == nil { + if got.Info != nil { + t.Fatalf("got %+v, want a conclusive not-a-token", *got.Info) + } + return + } + if got.Info == nil || *got.Info != *tt.want { + t.Fatalf("got %+v, want %+v", got.Info, *tt.want) + } + }) + } +} + +// The (info, err) pair is what tells a cacheable "not a token" from a read that simply failed, so +// every eth_call outcome has to land on the right side. +func Test_fetchContractInfo_Verdicts(t *testing.T) { + tests := []struct { + name string + errors map[string]error + wantInfo *bchain.ContractInfo + wantErr bool + wantCalls int + }{ + { + name: "reverting name is a conclusive not-a-token", + errors: map[string]error{contractNameSignature: errors.New("execution reverted")}, + wantCalls: 1, + }, + { + name: "unreachable node says nothing about the contract", + errors: map[string]error{contractNameSignature: errors.New("dial tcp: connection refused")}, + wantErr: true, + wantCalls: 1, + }, + { + name: "reverting symbol keeps the pre-batch not-a-token verdict", + errors: map[string]error{contractSymbolSignature: errors.New("execution reverted")}, + wantCalls: 2, + }, + { + name: "failed symbol read is not a verdict", + errors: map[string]error{contractSymbolSignature: errors.New("context deadline exceeded")}, + wantErr: true, + wantCalls: 2, + }, + { + name: "unreadable decimals falls back to the coin default", + errors: map[string]error{contractDecimalsSignature: errors.New("execution reverted")}, + wantInfo: &bchain.ContractInfo{ + Contract: contractInfoA.Hex(), + Name: "alpha", + Symbol: "A", + Decimals: EtherAmountDecimalPoint, + }, + wantCalls: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + answer := tokenMetadataSingle(map[string]string{strings.ToLower(contractInfoA.Hex()): "alpha"}) + mock := &mockContractInfoRPC{ + single: func(to, selector string) (string, error) { + if err, ok := tt.errors[selector]; ok { + return "", err + } + return answer(to, selector) + }, + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + + info, err := rpcClient.fetchContractInfo(contractInfoA.Hex()) + + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantInfo == nil { + if info != nil { + t.Fatalf("info = %+v, want nil", *info) + } + } else if info == nil || *info != *tt.wantInfo { + t.Fatalf("info = %+v, want %+v", info, *tt.wantInfo) + } + if calls := mock.singleCalls(); len(calls) != tt.wantCalls { + t.Fatalf("eth_calls = %v, want %d", calls, tt.wantCalls) + } + }) + } +} diff --git a/bchain/coins/eth/contract_multicall_metrics_test.go b/bchain/coins/eth/contract_multicall_metrics_test.go new file mode 100644 index 0000000000..a7c097ab4b --- /dev/null +++ b/bchain/coins/eth/contract_multicall_metrics_test.go @@ -0,0 +1,110 @@ +package eth + +import ( + "fmt" + "testing" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/prometheus/client_golang/prometheus" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// newFallbackTestMetrics builds a common.Metrics holding the collectors the erc20 balance path +// touches, left unregistered so each test owns fresh collectors and needs no global registry. +func newFallbackTestMetrics() *common.Metrics { + return &common.Metrics{ + ChainDataFallbacks: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_chain_data_fallbacks"}, []string{"component", "reason"}), + EthCallRequests: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_eth_call_requests"}, []string{"mode"}), + EthCallErrors: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_eth_call_errors"}, []string{"mode", "type"}), + EthCallBatchSize: prometheus.NewHistogram( + prometheus.HistogramOpts{Name: "test_eth_call_batch_size", Buckets: []float64{1, 10, 100}}), + EthCallMulticallRequests: prometheus.NewCounter( + prometheus.CounterOpts{Name: "test_eth_call_multicall_requests"}), + } +} + +// elem_fallback counts the elements aggregate3 could not settle, not the requests that hit +// one: two holes in a single request must move the counter by two. +func TestEthereumTypeGetErc20ContractBalancesFallbackMetricsCountElements(t *testing.T) { + metrics := newFallbackTestMetrics() + addr := ethcommon.HexToAddress("0x0000000000000000000000000000000000000011") + contracts := erc20TestContracts(3) + // A resolves; B and C come back Success=false with empty returndata (possibly gas-starved). + agg := fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: true, Data: fmt.Sprintf("0x%064x", erc20MulticallTestValue(0))}, + {Success: false, Data: "0x"}, + {Success: false, Data: "0x"}, + }) + inner := &mockBatchRPC{results: map[string]string{ + hexutil.Encode(contracts[1]): fmt.Sprintf("0x%064x", erc20BatchTestValue(1)), + hexutil.Encode(contracts[2]): fmt.Sprintf("0x%064x", erc20BatchTestValue(2)), + }} + mock := &mockMulticallThenBatchRPC{mockBatchRPC: inner, aggregate3Resp: agg} + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second, metrics: metrics} + if _, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := counterVecValue(t, metrics.ChainDataFallbacks, "reason", "elem_fallback"); got != 2 { + t.Fatalf("elem_fallback = %v, want 2 (one per unsettled element, not one per request)", got) + } + if got := counterVecValue(t, metrics.ChainDataFallbacks, "reason", "error"); got != 0 { + t.Fatalf("error fallback = %v, want 0 (aggregate3 itself did not fail)", got) + } +} + +// A failing chunk is one request-level "error" event; only element-level holes reach +// elem_fallback, so the two reasons never count the same failure twice. +func TestEthereumTypeGetErc20ContractBalancesFallbackMetricsSeparateChunkErrorsFromHoles(t *testing.T) { + metrics := newFallbackTestMetrics() + addr := ethcommon.HexToAddress("0x0000000000000000000000000000000000000011") + n := multicall3MaxCallsPerAggregate + 20 // chunk 1 succeeds with a hole, chunk 2 fails + holeAt := multicall3MaxCallsPerAggregate - 1 // last element of chunk 1: a starved tail + contracts := erc20TestContracts(n) + inner := &mockBatchRPC{results: map[string]string{ + hexutil.Encode(contracts[holeAt]): fmt.Sprintf("0x%064x", erc20BatchTestValue(holeAt)), + }} + for i := multicall3MaxCallsPerAggregate; i < n; i++ { + inner.results[hexutil.Encode(contracts[i])] = fmt.Sprintf("0x%064x", erc20BatchTestValue(i)) + } + mock := &mockMulticallMixedRPC{mockBatchRPC: inner, holeAt: holeAt, failFrom: multicall3MaxCallsPerAggregate} + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second, metrics: metrics} + if _, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := counterVecValue(t, metrics.ChainDataFallbacks, "reason", "elem_fallback"); got != 1 { + t.Fatalf("elem_fallback = %v, want 1 (the element hole only; the failed chunk is one error event)", got) + } + if got := counterVecValue(t, metrics.ChainDataFallbacks, "reason", "error"); got != 1 { + t.Fatalf("error fallback = %v, want 1 per request that abandoned multicall", got) + } +} + +// A systemic aggregate3 failure emits exactly one request-level fallback event. +func TestEthereumTypeGetErc20ContractBalancesSystemicFailureEmitsOneFallbackEvent(t *testing.T) { + metrics := newFallbackTestMetrics() + addr := ethcommon.HexToAddress("0x0000000000000000000000000000000000000011") + n := 3 * multicall3MaxCallsPerAggregate // 3 chunks, every aggregate3 fails + contracts := erc20TestContracts(n) + inner := &mockBatchRPC{results: map[string]string{}} + for i, c := range contracts { + inner.results[hexutil.Encode(c)] = fmt.Sprintf("0x%064x", erc20BatchTestValue(i)) + } + mock := &mockMulticallMixedRPC{mockBatchRPC: inner, holeAt: -1, failFrom: 0} + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second, metrics: metrics} + if _, err := rpcClient.EthereumTypeGetErc20ContractBalances(bchain.AddressDescriptor(addr.Bytes()), contracts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := counterVecValue(t, metrics.ChainDataFallbacks, "reason", "error"); got != 1 { + t.Fatalf("error fallback = %v, want 1 (one event per request, not one per chunk)", got) + } + // Nothing was resolved, so the whole list took the plain batch path, not the merge fallback. + if got := counterVecValue(t, metrics.ChainDataFallbacks, "reason", "elem_fallback"); got != 0 { + t.Fatalf("elem_fallback = %v, want 0 (no partial result to patch up)", got) + } +} diff --git a/bchain/coins/eth/contract_test.go b/bchain/coins/eth/contract_test.go index ca70c85878..7b0d711c5b 100644 --- a/bchain/coins/eth/contract_test.go +++ b/bchain/coins/eth/contract_test.go @@ -5,6 +5,7 @@ package eth import ( "fmt" "math/big" + "runtime" "strings" "testing" @@ -12,12 +13,116 @@ import ( "github.com/trezor/blockbook/tests/dbtestdata" ) -func Test_contractGetTransfersFromLog(t *testing.T) { +func Test_addressFromPaddedHex(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "padded topic", input: "0x0000000000000000000000002aacf811ac1a60081ea39f7783c0d26c500871a8", want: "0x2aaCF811aC1A60081EA39F7783c0D26c500871a8"}, + {name: "padded no prefix", input: "0000000000000000000000002aacf811ac1a60081ea39f7783c0d26c500871a8", want: "0x2aaCF811aC1A60081EA39F7783c0D26c500871a8"}, + {name: "uppercase 0X prefix", input: "0X0000000000000000000000005DC6288B35E0807A3D6FEB89B3A2FF4AB773168E", want: "0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e"}, + {name: "bare address", input: "0x5dc6288b35e0807a3d6feb89b3a2ff4ab773168e", want: "0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e"}, + {name: "odd length over 20 bytes keeps low 20 bytes", input: "f5dc6288b35e0807a3d6feb89b3a2ff4ab773168e", want: "0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e"}, + {name: "zero word", input: "0x0000000000000000000000000000000000000000000000000000000000000000", want: "0x0000000000000000000000000000000000000000"}, + {name: "short value is left-padded", input: "0x1a2b3c", want: "0x00000000000000000000000000000000001A2b3c"}, + {name: "padding is not validated", input: "0xzzzzzzzzzzzzzzzzzzzzzzzz2aacf811ac1a60081ea39f7783c0d26c500871a8", want: "0x2aaCF811aC1A60081EA39F7783c0D26c500871a8"}, + {name: "invalid hex in address bytes", input: "0x0000000000000000000000002aacf811ac1a60081ea39f7783c0d26c500871zz", wantErr: true}, + {name: "invalid short", input: "0xzz", wantErr: true}, + {name: "prefix only", input: "0x", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := addressFromPaddedHex(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("addressFromPaddedHex(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("addressFromPaddedHex(%q) = %q, want %q", tt.input, got, tt.want) + } + // callers store the result unmodified, so it must already be EIP-55 + if got != "" && got != EIP55AddressFromAddress(got) { + t.Errorf("addressFromPaddedHex(%q) = %q is not EIP-55 checksummed", tt.input, got) + } + }) + } +} + +func Test_setBigFromHexWord(t *testing.T) { + const word = "00000000000000000000000000000000000000000000000000000000000f4240" + tests := []struct { + name string + input string + base int + want string + ok bool + }{ + {name: "word with prefix", input: "0x" + word, base: 0, want: "1000000", ok: true}, + {name: "word without prefix", input: word, base: 16, want: "1000000", ok: true}, + {name: "uppercase word", input: "0X" + strings.ToUpper(word), base: 0, want: "1000000", ok: true}, + {name: "max word", input: strings.Repeat("f", 64), base: 16, want: new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)).String(), ok: true}, + {name: "zero word", input: "0x" + strings.Repeat("0", 64), base: 0, want: "0", ok: true}, + {name: "short value falls back to SetString base 0", input: "0x1a2b", base: 0, want: "6699", ok: true}, + {name: "short value falls back to SetString base 16", input: "1a2b", base: 16, want: "6699", ok: true}, + {name: "long value falls back to SetString", input: "0x1" + word, base: 0, want: new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1000000)).String(), ok: true}, + {name: "invalid hex in word", input: "0x" + word[:62] + "zz", base: 0, ok: false}, + {name: "prefix only", input: "0x", base: 0, ok: false}, + {name: "empty", input: "", base: 16, ok: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var v big.Int + ok := setBigFromHexWord(&v, tt.input, tt.base) + if ok != tt.ok { + t.Fatalf("setBigFromHexWord(%q) ok = %v, want %v", tt.input, ok, tt.ok) + } + if ok && v.String() != tt.want { + t.Errorf("setBigFromHexWord(%q) = %s, want %s", tt.input, v.String(), tt.want) + } + // must agree with the generic big.Int parser on every accepted input + var ref big.Int + if _, refOk := ref.SetString(tt.input, tt.base); refOk != tt.ok || (ok && ref.Cmp(&v) != 0) { + t.Errorf("setBigFromHexWord(%q) disagrees with big.Int.SetString: %v/%s vs %v/%s", tt.input, ok, v.String(), refOk, ref.String()) + } + }) + } +} + +func Test_parseEVMLogWordUint64(t *testing.T) { tests := []struct { name string - args []*bchain.RpcLog - want bchain.TokenTransfers + data string + offset int + want uint64 wantErr bool + }{ + {name: "small", data: strings.Repeat("0", 63) + "3", want: 3}, + {name: "max uint64", data: strings.Repeat("0", 48) + strings.Repeat("f", 16), want: ^uint64(0)}, + {name: "overflow", data: strings.Repeat("0", 47) + "1" + strings.Repeat("0", 16), wantErr: true}, + {name: "second word", data: strings.Repeat("0", 64) + strings.Repeat("0", 62) + "40", offset: 64, want: 64}, + {name: "invalid hex", data: strings.Repeat("0", 62) + "zz", wantErr: true}, + {name: "too short", data: strings.Repeat("0", 63), wantErr: true}, + {name: "negative offset", data: strings.Repeat("0", 64), offset: -1, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseEVMLogWordUint64(tt.data, tt.offset) + if (err != nil) != tt.wantErr { + t.Fatalf("parseEVMLogWordUint64(%q, %d) error = %v, wantErr %v", tt.data, tt.offset, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("parseEVMLogWordUint64(%q, %d) = %d, want %d", tt.data, tt.offset, got, tt.want) + } + }) + } +} + +func Test_contractGetTransfersFromLog(t *testing.T) { + tests := []struct { + name string + args []*bchain.RpcLog + want bchain.TokenTransfers }{ { name: "ERC20 transfer 1", @@ -208,11 +313,7 @@ func Test_contractGetTransfersFromLog(t *testing.T) { }} 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 - } + got := contractGetTransfersFromLog(tt.args, "0xtxid", nil) if len(got) != len(tt.want) { t.Errorf("contractGetTransfersFromLog len not same, %+v, want %+v", got, tt.want) } @@ -227,6 +328,71 @@ func Test_contractGetTransfersFromLog(t *testing.T) { } } +func erc1155BatchWord(n uint64) string { + return fmt.Sprintf("%064x", n) +} + +func malformedERC1155TransferBatchLog(count uint64) *bchain.RpcLog { + return &bchain.RpcLog{ + Address: "0x1111111111111111111111111111111111111111", + Topics: []string{ + tokenERC1155TransferBatchEventSignature, + "0x0000000000000000000000002222222222222222222222222222222222222222", + "0x0000000000000000000000003333333333333333333333333333333333333333", + "0x0000000000000000000000004444444444444444444444444444444444444444", + }, + Data: "0x" + + erc1155BatchWord(0x40) + + erc1155BatchWord(0x60) + + erc1155BatchWord(count) + + erc1155BatchWord(count), + } +} + +func Test_processERC1155TransferBatchEventRejectsMalformedLogBeforeAllocation(t *testing.T) { + const count = 200000 + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + transfer, err := processERC1155TransferBatchEvent(malformedERC1155TransferBatchLog(count)) + runtime.ReadMemStats(&after) + if err == nil { + t.Fatal("processERC1155TransferBatchEvent returned nil error for malformed ERC1155 TransferBatch") + } + if transfer != nil { + t.Fatalf("processERC1155TransferBatchEvent transfer = %+v, want nil", transfer) + } + if strings.Contains(err.Error(), "recovered from panic") { + t.Fatalf("processERC1155TransferBatchEvent recovered from panic instead of validating input: %v", err) + } + if delta := after.TotalAlloc - before.TotalAlloc; delta > 1_000_000 { + t.Fatalf("processERC1155TransferBatchEvent allocated %d bytes for malformed ERC1155 TransferBatch count %d", delta, count) + } +} + +func Test_contractGetTransfersFromLogSkipsMalformedERC1155TransferBatch(t *testing.T) { + logs := []*bchain.RpcLog{ + malformedERC1155TransferBatchLog(200000), + { // valid ERC20 Transfer in the same receipt + Address: "0x76a45e8976499ab9ae223cc584019341d5a84e96", + Topics: []string{ + tokenTransferEventSignature, + "0x0000000000000000000000002aacf811ac1a60081ea39f7783c0d26c500871a8", + "0x000000000000000000000000e9a5216ff992cfa01594d43501a56e12769eb9d2", + }, + Data: "0x0000000000000000000000000000000000000000000000000000000000000123", + }, + } + transfers := contractGetTransfersFromLog(logs, "0xtxid", nil) + if len(transfers) != 1 { + t.Fatalf("contractGetTransfersFromLog transfers = %+v, want the valid transfer kept", transfers) + } + // the address could have different case + if strings.ToLower(transfers[0].Contract) != "0x76a45e8976499ab9ae223cc584019341d5a84e96" { + t.Fatalf("contractGetTransfersFromLog kept transfer of contract %s, want the valid ERC20 transfer", transfers[0].Contract) + } +} + func Test_contractGetTransfersFromTx(t *testing.T) { p := NewEthereumParser(1, false) b1 := dbtestdata.GetTestEthereumTypeBlock1(p) @@ -256,8 +422,21 @@ func Test_contractGetTransfersFromTx(t *testing.T) { }, }, { - name: "ERC721 transferFrom", + // 0x23b872dd is shared by ERC-20 and ERC-721, so no standard can be derived from calldata + name: "transferFrom with the shared ERC-20/ERC-721 selector yields nothing", args: (b2.Txs[2].CoinSpecificData.(bchain.EthereumSpecificData)).Tx, + want: bchain.TokenTransfers{}, + }, + { + name: "ERC721 safeTransferFrom", + args: &bchain.RpcTransaction{ + From: "0x837e3f699d85a4b0b99894567e9233dfb1dcb081", + To: "0xcda9fc258358ecaa88845f19af595e908bb7efe9", + Payload: erc721SafeTransferFromMethodSignature + + "000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb081" + + "0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b" + + "0000000000000000000000000000000000000000000000000000000000000001", + }, want: bchain.TokenTransfers{ { Standard: bchain.NonFungibleToken, @@ -268,6 +447,28 @@ func Test_contractGetTransfersFromTx(t *testing.T) { }, }, }, + { + name: "ERC721 safeTransferFrom with data", + args: &bchain.RpcTransaction{ + From: "0x837e3f699d85a4b0b99894567e9233dfb1dcb081", + To: "0xcda9fc258358ecaa88845f19af595e908bb7efe9", + Payload: erc721SafeTransferFromWithDataMethodSignature + + "000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb081" + + "0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b" + + "0000000000000000000000000000000000000000000000000000000000000002" + + "0000000000000000000000000000000000000000000000000000000000000080" + + "0000000000000000000000000000000000000000000000000000000000000000", + }, + want: bchain.TokenTransfers{ + { + Standard: bchain.NonFungibleToken, + Contract: "0xcda9fc258358ecaa88845f19af595e908bb7efe9", + From: "0x837e3f699d85a4b0b99894567e9233dfb1dcb081", + To: "0x7b62eb7fe80350dc7ec945c0b73242cb9877fb1b", + Value: *big.NewInt(2), + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/bchain/coins/eth/dataparser.go b/bchain/coins/eth/dataparser.go index b3f641622b..ea071b5003 100644 --- a/bchain/coins/eth/dataparser.go +++ b/bchain/coins/eth/dataparser.go @@ -32,6 +32,11 @@ func parseSimpleNumericProperty(data string) *big.Int { return nil } +// maxParsedStringBytes caps how many bytes any decoded contract string (name/symbol/tokenURI) may +// hold. A malicious contract can make a free eth_call return an arbitrarily large string; without +// this ceiling the live, unstored tokenURI path would decode the whole blob on every request. +const maxParsedStringBytes = 64 << 10 + func parseSimpleStringProperty(data string) string { if has0xPrefix(data) { data = data[2:] @@ -41,6 +46,9 @@ func parseSimpleStringProperty(data string) string { if n != nil { l := n.Int64() if l > 0 && int(l) <= ((len(data)-128)>>1) { + if l > maxParsedStringBytes { + l = maxParsedStringBytes + } b, err := hex.DecodeString(data[128 : 128+2*l]) if err == nil { return string(b) @@ -49,7 +57,7 @@ func parseSimpleStringProperty(data string) string { } } // allow string properties as UTF-8 data - b, err := hex.DecodeString(data) + b, err := hex.DecodeString(data[:min(len(data), 2*maxParsedStringBytes)]) if err == nil { i := min(bytes.Index(b, []byte{0}), 32) if i > 0 { @@ -96,6 +104,10 @@ func GetSignatureFromData(data string) uint32 { const ErrorTy byte = 255 +// maxParseInputDataLen bounds the hex length of calldata that ParseInputData will decode; +// real calls rarely exceed a few KB, so ~64 KB keeps parse work off the chain gas limit. +const maxParseInputDataLen = 128 * 1024 + func processParam(data string, index int, dataOffset int, t *abi.Type, processed []bool) ([]string, int, bool) { var retval []string d := index << 6 @@ -156,7 +168,7 @@ func processParam(data string, index int, dataOffset int, t *abi.Type, processed offset <<= 1 d = int(offset) + dataOffset dynIndex := d >> 6 - if d+64 > len(data) || d < 0 { + if d+64 > len(data) || d < 0 || d+64 < d { return nil, 0, false } // get element count of dynamic type @@ -169,8 +181,12 @@ func processParam(data string, index int, dataOffset int, t *abi.Type, processed dynIndex++ if t.T == abi.StringTy || t.T == abi.BytesTy { d += 64 + // count<<1 can overflow and wrap below d (same class as the + // getEnsRecord bug); the lower bound must be the slice start d, not 0, + // so that d <= de <= len(data) and data[d:de] below can never panic. + // count==0 gives de==d, which is still accepted. de := d + (count << 1) - if de > len(data) || de < 0 { + if de > len(data) || de < d { return nil, 0, false } if count == 0 { @@ -188,6 +204,13 @@ func processParam(data string, index int, dataOffset int, t *abi.Type, processed } count = ((count - 1) >> 5) + 1 for i := 0; i < count; i++ { + // A dynamic field whose declared length runs past the data + // (malformed or non-32-byte-aligned input) would index beyond + // processed, which is sized len(data)/64; treat it as a + // non-matching signature instead of panicking. + if dynIndex >= len(processed) { + return nil, 0, false + } processed[dynIndex] = true dynIndex++ } @@ -249,6 +272,11 @@ func (p *EthereumParser) ParseInputData(signatures *[]bchain.FourByteSignature, parsed := bchain.EthereumParsedInputData{ MethodId: data[:10], } + // skip decoding oversized calldata; see maxParseInputDataLen for the budget rationale + if len(data) > maxParseInputDataLen { + parsed.Truncated = true + return &parsed + } defer func() { if r := recover(); r != nil { glog.Error("ParseInputData recovered from panic: ", r, ", ", data, ",signatures ", signatures) @@ -292,26 +320,4 @@ func (p *EthereumParser) ParseInputData(signatures *[]bchain.FourByteSignature, 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 -} +// ENS parsing lives in ens.go. diff --git a/bchain/coins/eth/dataparser_test.go b/bchain/coins/eth/dataparser_test.go index 34cbda8ea1..6a6fac64dc 100644 --- a/bchain/coins/eth/dataparser_test.go +++ b/bchain/coins/eth/dataparser_test.go @@ -3,7 +3,9 @@ package eth import ( + "fmt" "reflect" + "strings" "testing" "github.com/trezor/blockbook/bchain" @@ -62,6 +64,24 @@ func Test_parseSimpleStringProperty(t *testing.T) { } } +// A malicious contract can make a free eth_call return an arbitrarily large ABI string; +// parseSimpleStringProperty must cap the decode at maxParsedStringBytes instead of honoring the +// attacker-declared length. +func Test_parseSimpleStringProperty_CapsOversizedString(t *testing.T) { + const declared = maxParsedStringBytes * 4 + offset := "0000000000000000000000000000000000000000000000000000000000000020" + lengthWord := fmt.Sprintf("%064x", declared) + payload := strings.Repeat("41", declared) // 'A' repeated `declared` times + + got := parseSimpleStringProperty("0x" + offset + lengthWord + payload) + if len(got) != maxParsedStringBytes { + t.Fatalf("decoded length = %d, want ceiling %d", len(got), maxParsedStringBytes) + } + if strings.Trim(got, "A") != "" { + t.Errorf("decoded string contains unexpected bytes, want all 'A'") + } +} + func TestGetSignatureFromData(t *testing.T) { tests := []struct { name string @@ -430,6 +450,54 @@ func TestParseInputData(t *testing.T) { } } +// TestParseInputDataMisalignedDynamicData is a regression test for a panic +// ("index out of range [N] with length N") when a transaction's input data is +// not 32-byte aligned. Standard ABI-encoded data is always word-aligned, but a +// malformed/non-standard tx is not, and marking the words of a dynamic field +// could step one slot past `processed` (sized len(data)/64). ParseInputData must +// instead reject the signature and fall back to just the method id, without +// panicking (and without the per-tx panic-recover log spam that masked it). +func TestParseInputDataMisalignedDynamicData(t *testing.T) { + parser := NewEthereumParser(1, false) + signatures := []bchain.FourByteSignature{ + {Name: "store", Parameters: []string{"bytes"}}, + } + // 0x + 4-byte selector, then a "bytes" field: offset word (0x20), length + // word (0x21 = 33 bytes), and 66 hex chars of content. The post-selector + // data is 194 hex chars — not a multiple of 64 — so `processed` has only 3 + // entries while the 33-byte field rounds up to a word at index 3. + data := "0xaabbccdd" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000021" + + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2021" + got := parser.ParseInputData(&signatures, data) + want := &bchain.EthereumParsedInputData{MethodId: "0xaabbccdd"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseInputData() = %#v, want %#v", got, want) + } +} + +func TestParseInputDataOverflowDynamicLength(t *testing.T) { + parser := NewEthereumParser(1, false) + signatures := []bchain.FourByteSignature{ + {Name: "store", Parameters: []string{"bytes"}}, + } + // A "bytes" argument whose declared length word is 0x7FFFFFFFFFFFFFC0. + // In processParam count<<1 overflows int64 and wraps to -128, so + // de = d + (count<<1) = 128 - 128 = 0 while d = 128. Before the fix the old + // `de < 0` guard did not catch de==0 and data[128:0] panicked (recovered by + // ParseInputData, which then returned nil); with the `de < d` guard the field + // is rejected cleanly and ParseInputData returns the method id only. + data := "0xaabbccdd" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000007fffffffffffffc0" + got := parser.ParseInputData(&signatures, data) + want := &bchain.EthereumParsedInputData{MethodId: "0xaabbccdd"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseInputData() = %#v, want %#v", got, want) + } +} + func Test_getEnsRecord(t *testing.T) { tests := []struct { name string @@ -496,6 +564,43 @@ func Test_getEnsRecord(t *testing.T) { }, want: nil, }, + { + // Regression: c = 0x7FFFFFFFFFFFFF7F makes int(c)<<1 wrap to -258, so + // de = 194+64+(-258) = 0. Before the fix this slipped past the old + // `de < 0` guard (0 is not < 0) and l.Data[258:0] panicked, crashing the + // sync goroutine. Must be rejected cleanly. + name: "overflow name length wrapping to de=0", + log: rpcLogWithTxHash{ + RpcLog: bchain.RpcLog{ + Address: "0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5", + Topics: []string{ + "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f", + "0x40ce2aa8cd9ee9fef4bf3a68abab7fbcceb6bac89370518caf6a602cefe836bd", + "0x0000000000000000000000002c630b16aa53ae0189880e15c23323688acb607c", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000017629245f5a86f0000000000000000000000000000000000000000000000000000000069dbb21d0000000000000000000000000000000000000000000000007fffffffffffff7f756e726176656c65640000000000000000000000000000000000000000000000", + }, + }, + want: nil, + }, + { + // Regression: c = 0x7FFFFFFFFFFFFFFF (MaxInt64) makes int(c)<<1 wrap to + // -2, so de = 256. Before the fix this passed both old guards and + // l.Data[258:256] panicked (start > end). Must be rejected cleanly. + name: "overflow name length wrapping to de=256", + log: rpcLogWithTxHash{ + RpcLog: bchain.RpcLog{ + Address: "0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5", + Topics: []string{ + "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f", + "0x40ce2aa8cd9ee9fef4bf3a68abab7fbcceb6bac89370518caf6a602cefe836bd", + "0x0000000000000000000000002c630b16aa53ae0189880e15c23323688acb607c", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000017629245f5a86f0000000000000000000000000000000000000000000000000000000069dbb21d0000000000000000000000000000000000000000000000007fffffffffffffff756e726176656c65640000000000000000000000000000000000000000000000", + }, + }, + want: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -505,3 +610,34 @@ func Test_getEnsRecord(t *testing.T) { }) } } + +func TestParseInputDataTruncatesOversizedCalldata(t *testing.T) { + parser := NewEthereumParser(1, false) + signatures := []bchain.FourByteSignature{ + {Name: "cancelMultipleMakerOrders", Parameters: []string{"uint256[]"}}, + } + // Canonical ABI calldata for a uint256[] of zero elements: selector, + // offset word (0x20), count word, then `count` zero-value words. + buildCalldata := func(count int) string { + return fmt.Sprintf("0x9e53a69a%064x%064x%s", 0x20, count, strings.Repeat("0", 64*count)) + } + + oversized := buildCalldata(3000) + if len(oversized) <= maxParseInputDataLen { + t.Fatalf("test calldata not oversized: len=%d, limit=%d", len(oversized), maxParseInputDataLen) + } + got := parser.ParseInputData(&signatures, oversized) + want := &bchain.EthereumParsedInputData{MethodId: "0x9e53a69a", Truncated: true} + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseInputData(oversized) = %#v, want %#v", got, want) + } + + // Negative control: a small array under the budget still decodes every value. + small := parser.ParseInputData(&signatures, buildCalldata(2)) + if small.Truncated { + t.Errorf("ParseInputData(small) unexpectedly truncated: %#v", small) + } + if len(small.Params) != 1 || len(small.Params[0].Values) != 2 { + t.Errorf("ParseInputData(small) = %#v, want 1 param with 2 values", small) + } +} diff --git a/bchain/coins/eth/eip1559_blockgas_test.go b/bchain/coins/eth/eip1559_blockgas_test.go new file mode 100644 index 0000000000..36599fe8f6 --- /dev/null +++ b/bchain/coins/eth/eip1559_blockgas_test.go @@ -0,0 +1,306 @@ +//go:build unittest + +package eth + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// feeHistoryRPCStub serves a canned eth_feeHistory response; any other method errors so the test +// also asserts the on-chain path no longer issues eth_maxPriorityFeePerGas. +type feeHistoryRPCStub struct { + raw string +} + +func (s *feeHistoryRPCStub) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (s *feeHistoryRPCStub) Close() {} + +func (s *feeHistoryRPCStub) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + if method != "eth_feeHistory" { + return errors.New("unexpected RPC method: " + method) + } + return json.Unmarshal([]byte(s.raw), result) +} + +func TestEthereumTypeGetEip1559FeesOnChain(t *testing.T) { + // baseFeePerGas[blocks-1]=[3]=0x64=100 is the projected next-block base fee (4-element array, as a + // no-distinct-pending-block backend returns). Per-tier reward percentiles over 2 blocks. + raw := `{"oldestBlock":"0x1",` + + `"reward":[["0x1","0x2","0x3","0x4"],["0x3","0x4","0x5","0x6"]],` + + `"baseFeePerGas":["0x10","0x20","0x30","0x64"],` + + `"gasUsedRatio":[0.5,0.5,0.5]}` + b := &EthereumRPC{ + RPC: &feeHistoryRPCStub{raw: raw}, + Timeout: time.Second, + ChainConfig: &Configuration{Eip1559Fees: true}, + } + fees, err := b.EthereumTypeGetEip1559Fees() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fees == nil || fees.BaseFeePerGas == nil { + t.Fatal("expected fees with baseFeePerGas") + } + if fees.BaseFeePerGas.Int64() != 100 { + t.Errorf("BaseFeePerGas = %v, want 100", fees.BaseFeePerGas) + } + // tip = average of the tier's reward percentile; maxFeePerGas = 2*baseFee + tip. + cases := []struct { + name string + fee *bchain.Eip1559Fee + wantTip int64 + }{ + {"low", fees.Low, 2}, // avg(1,3) + {"medium", fees.Medium, 3}, // avg(2,4) + {"high", fees.High, 4}, // avg(3,5) + {"instant", fees.Instant, 5}, // avg(4,6) + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.fee == nil { + t.Fatal("nil tier") + } + if c.fee.MaxPriorityFeePerGas.Int64() != c.wantTip { + t.Errorf("MaxPriorityFeePerGas = %v, want %d", c.fee.MaxPriorityFeePerGas, c.wantTip) + } + wantMax := int64(eip1559BaseFeeMultiplier)*100 + c.wantTip + if c.fee.MaxFeePerGas.Int64() != wantMax { + t.Errorf("MaxFeePerGas = %v, want %d", c.fee.MaxFeePerGas, wantMax) + } + // core invariant the previous code violated: maxFeePerGas must cover the base fee. + if c.fee.MaxFeePerGas.Cmp(fees.BaseFeePerGas) <= 0 { + t.Errorf("MaxFeePerGas %v must exceed baseFee %v", c.fee.MaxFeePerGas, fees.BaseFeePerGas) + } + }) + } +} + +// TestEthereumTypeGetEip1559FeesOnChainShortRewardRow asserts the on-chain tier loop tolerates a +// non-compliant eth_feeHistory whose reward rows are shorter than the requested percentile count: it +// must not panic on h.Reward[j][i], and the per-tier average must divide only by the rows that +// actually contributed a value (so a skipped short row does not deflate the tip). +func TestEthereumTypeGetEip1559FeesOnChainShortRewardRow(t *testing.T) { + // Row 0 has all 4 percentiles; row 1 has only 2. For tiers high(i=2) and instant(i=3) row 1 is + // short and must be skipped, leaving the average over row 0 alone. + raw := `{"oldestBlock":"0x1",` + + `"reward":[["0x1","0x2","0x3","0x4"],["0x3","0x4"]],` + + `"baseFeePerGas":["0x10","0x20","0x30","0x64"],` + + `"gasUsedRatio":[0.5,0.5,0.5]}` + b := &EthereumRPC{ + RPC: &feeHistoryRPCStub{raw: raw}, + Timeout: time.Second, + ChainConfig: &Configuration{Eip1559Fees: true}, + } + fees, err := b.EthereumTypeGetEip1559Fees() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fees == nil { + t.Fatal("expected fees") + } + cases := []struct { + name string + fee *bchain.Eip1559Fee + wantTip int64 + }{ + {"low", fees.Low, 2}, // avg(1,3) over both rows + {"medium", fees.Medium, 3}, // avg(2,4) over both rows + {"high", fees.High, 3}, // row 1 short -> avg(3) over row 0 only + {"instant", fees.Instant, 4}, // row 1 short -> avg(4) over row 0 only + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.fee == nil { + t.Fatal("nil tier") + } + if c.fee.MaxPriorityFeePerGas.Int64() != c.wantTip { + t.Errorf("MaxPriorityFeePerGas = %v, want %d", c.fee.MaxPriorityFeePerGas, c.wantTip) + } + }) + } +} + +// gaugeVecSeriesCount reports how many label series a GaugeVec currently holds, using a throwaway +// registry (same approach as gaugeValue, no prometheus/testutil dependency). +func gaugeVecSeriesCount(t *testing.T, gv *prometheus.GaugeVec) int { + t.Helper() + reg := prometheus.NewRegistry() + if err := reg.Register(gv); err != nil { + t.Fatalf("register gauge vec: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Fatalf("gather gauge vec: %v", err) + } + n := 0 + for _, mf := range families { + n += len(mf.GetMetric()) + } + return n +} + +func TestObserveEip1559Fees(t *testing.T) { + m := &common.Metrics{ + EthEip1559Fee: prometheus.NewGaugeVec( + prometheus.GaugeOpts{Name: "test_eth_eip1559_fee"}, []string{"tier", "kind"}), + EthEip1559BaseFee: prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_eth_eip1559_base_fee"}), + } + b := &EthereumRPC{} + b.metrics = m + // only low and high tiers populated; medium/instant left nil + b.observeEip1559Fees(&bchain.Eip1559Fees{ + BaseFeePerGas: big.NewInt(100), + Low: &bchain.Eip1559Fee{MaxFeePerGas: big.NewInt(250), MaxPriorityFeePerGas: big.NewInt(2)}, + High: &bchain.Eip1559Fee{MaxFeePerGas: big.NewInt(260), MaxPriorityFeePerGas: big.NewInt(4)}, + }) + + if got := gaugeValue(t, m.EthEip1559BaseFee); got != 100 { + t.Errorf("base fee gauge = %v, want 100", got) + } + for _, c := range []struct { + tier, kind string + want float64 + }{ + {"low", "max_fee", 250}, {"low", "priority_fee", 2}, + {"high", "max_fee", 260}, {"high", "priority_fee", 4}, + } { + got := gaugeValue(t, m.EthEip1559Fee.With(common.Labels{"tier": c.tier, "kind": c.kind})) + if got != c.want { + t.Errorf("eip1559_fee{tier=%s,kind=%s} = %v, want %v", c.tier, c.kind, got, c.want) + } + } + // nil tiers (medium, instant) must not create series: exactly 4 (=2 tiers x 2 kinds) expected + if n := gaugeVecSeriesCount(t, m.EthEip1559Fee); n != 4 { + t.Errorf("expected 4 tier series, got %d (nil tiers must be skipped)", n) + } + + // nil metrics must be a no-op (matches the unit-test path with no metrics wired) + (&EthereumRPC{}).observeEip1559Fees(&bchain.Eip1559Fees{BaseFeePerGas: big.NewInt(1)}) +} + +func TestObserveSyncMetric(t *testing.T) { + gv := prometheus.NewGaugeVec( + prometheus.GaugeOpts{Name: "test_alt_fee_provider_last_sync"}, []string{"provider"}) + p := &alternativeFeeProvider{ + metrics: &common.Metrics{AlternativeFeeProviderLastSync: gv}, + name: "infura", + } + ts := time.Unix(1700000000, 0) + p.observeSync(ts) + + if !p.lastSync.Equal(ts) { + t.Errorf("lastSync = %v, want %v (must use the same instant as the metric)", p.lastSync, ts) + } + if got := gaugeValue(t, gv.With(common.Labels{"provider": "infura"})); got != 1700000000 { + t.Errorf("last_sync gauge = %v, want 1700000000", got) + } + + // nil metrics must still advance lastSync without panicking + p2 := &alternativeFeeProvider{name: "1inch"} + p2.observeSync(ts) + if !p2.lastSync.Equal(ts) { + t.Errorf("nil-metrics observeSync must still set lastSync") + } +} + +func TestEip1559FeeSourceMetric(t *testing.T) { + m := &common.Metrics{ + EthEip1559FeeSource: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_eth_eip1559_fee_source_total"}, []string{"source"}), + EthEip1559Fee: prometheus.NewGaugeVec( + prometheus.GaugeOpts{Name: "test_eth_eip1559_fee_src"}, []string{"tier", "kind"}), + EthEip1559BaseFee: prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_eth_eip1559_base_fee_src"}), + } + raw := `{"oldestBlock":"0x1",` + + `"reward":[["0x1","0x2","0x3","0x4"]],` + + `"baseFeePerGas":["0x10","0x20","0x30","0x64"],` + + `"gasUsedRatio":[0.5,0.5,0.5]}` + b := &EthereumRPC{ + RPC: &feeHistoryRPCStub{raw: raw}, + Timeout: time.Second, + ChainConfig: &Configuration{Eip1559Fees: true}, + } + b.metrics = m + if _, err := b.EthereumTypeGetEip1559Fees(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // no alternative provider configured -> the on-chain estimate is source=onchain (not a fallback) + if got := counterVecValue(t, m.EthEip1559FeeSource, "source", "onchain"); got != 1 { + t.Errorf("source=onchain counter = %v, want 1", got) + } + if got := counterVecValue(t, m.EthEip1559FeeSource, "source", "onchain_fallback"); got != 0 { + t.Errorf("source=onchain_fallback counter = %v, want 0", got) + } +} + +func equalBigInt(a, b *big.Int) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.Cmp(b) == 0 +} + +func TestAttachBlockGas(t *testing.T) { + t.Run("post-London allocates and sets", func(t *testing.T) { + bsd := attachBlockGas(&rpcHeader{GasUsed: "0x5208", GasLimit: "0x3938700", BaseFeePerGas: "0x7"}, nil) + if bsd == nil { + t.Fatal("expected allocated EthereumBlockSpecificData, got nil") + } + if !equalBigInt(bsd.BaseFeePerGas, big.NewInt(7)) { + t.Errorf("BaseFeePerGas = %v, want 7", bsd.BaseFeePerGas) + } + if !equalBigInt(bsd.GasUsed, big.NewInt(21000)) { + t.Errorf("GasUsed = %v, want 21000", bsd.GasUsed) + } + if !equalBigInt(bsd.GasLimit, big.NewInt(60000000)) { + t.Errorf("GasLimit = %v, want 60000000", bsd.GasLimit) + } + }) + + t.Run("pre-London with nil existing returns nil", func(t *testing.T) { + if got := attachBlockGas(&rpcHeader{GasUsed: "0x5208", GasLimit: "0x1c9c380"}, nil); got != nil { + t.Errorf("expected nil, got %+v", got) + } + }) + + t.Run("pre-London preserves existing struct unchanged", func(t *testing.T) { + existing := &bchain.EthereumBlockSpecificData{InternalDataError: "boom"} + got := attachBlockGas(&rpcHeader{}, existing) + if got != existing { + t.Fatal("expected existing struct returned unchanged") + } + + if got.BaseFeePerGas != nil { + t.Errorf("expected no gas set pre-London, got %v", got.BaseFeePerGas) + } + }) + + t.Run("post-London preserves existing fields and adds gas", func(t *testing.T) { + existing := &bchain.EthereumBlockSpecificData{InternalDataError: "err"} + got := attachBlockGas(&rpcHeader{GasUsed: "0x1", GasLimit: "0x2", BaseFeePerGas: "0x7"}, existing) + if got.InternalDataError != "err" { + t.Errorf("InternalDataError = %q, want preserved \"err\"", got.InternalDataError) + } + if !equalBigInt(got.BaseFeePerGas, big.NewInt(7)) { + t.Errorf("BaseFeePerGas = %v, want 7", got.BaseFeePerGas) + } + if !equalBigInt(got.GasUsed, big.NewInt(1)) { + t.Errorf("GasUsed = %v, want 1", got.GasUsed) + } + if !equalBigInt(got.GasLimit, big.NewInt(2)) { + t.Errorf("GasLimit = %v, want 2", got.GasLimit) + } + }) +} diff --git a/bchain/coins/eth/ens.go b/bchain/coins/eth/ens.go new file mode 100644 index 0000000000..fa0482d951 --- /dev/null +++ b/bchain/coins/eth/ens.go @@ -0,0 +1,272 @@ +package eth + +import ( + "encoding/hex" + "math/big" + "strconv" + "strings" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "golang.org/x/crypto/sha3" +) + +// nameRegisteredEventSignature is the topic0 of the ENS NameRegistered event. +const nameRegisteredEventSignature = "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f" + +// ENS contract addresses and function selectors (Ethereum mainnet). +const ( + // ENSRegistryAddress is the mainnet ENS registry contract address. + ENSRegistryAddress = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" + // ENSBaseRegistrarAddress is the .eth Base Registrar, used to check expiration. + ENSBaseRegistrarAddress = "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85" + // ENSResolverFunctionSelector is the ENS registry's resolver(bytes32) selector. + ENSResolverFunctionSelector = "0x0178b8bf" + // ENSAddrFunctionSelector is the resolver's addr(bytes32) selector. + ENSAddrFunctionSelector = "0x3b3b57de" + // ENSExpirationFunctionSelector is the Base Registrar's nameExpires(uint256) selector. + ENSExpirationFunctionSelector = "0xd6e4fa86" +) + +// SetEnsSuffix sets the suffix appended to a decoded ENS name when formatting. +func (p *EthereumParser) SetEnsSuffix(suffix string) { + p.EnsSuffix = suffix +} + +// UseEnsReverseAliases reports whether ENS reverse aliases are recorded and served. +// Opt-in: a chain gets them only with both address_aliases and +// enable_ens_reverse_aliases set. Gating the recording side means toggling this on later +// does not recover labels for blocks already synced without it (no backfill). +func (p *EthereumParser) UseEnsReverseAliases() bool { + return p.AddressAliases && p.EnableEnsReverseAliases +} + +// getEnsRecord parses an ENS record from a transaction log entry. +// +// WARNING: not production-ready, disabled by default (see UseEnsReverseAliases). +// Trusts any log emitter (spoofable labels), does not validate names, and ignores +// expiry/ownership (stale/duplicate labels). Use forward ResolveENS instead. +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 + } + const nameStart = 194 + 64 + // int(c)<<1 can overflow: c is attacker-controlled (up to 2^63-1) and Go's + // signed left shift wraps silently, so de can land anywhere in [0, 257] + // below nameStart. The lower bound must be the slice + // start index, not 0 — a de below the start makes l.Data[nameStart:de] a + // low>high slice expression that panics. Checking de < nameStart guarantees + // nameStart <= de <= len(l.Data), so the slice below can never panic. + de := nameStart + (int(c) << 1) + if de > len(l.Data) || de < nameStart { + return nil + } + b, err := hex.DecodeString(l.Data[nameStart:de]) + if err != nil { + return nil + } + return &bchain.AddressAliasRecord{Address: address, Name: string(b)} + } + return nil +} + +// ensNameHash computes the ENS namehash of name per the 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 { + // A revert or missing state is expected for unregistered names and on + // non-archive backends. Skip expiration and let ResolveENS decide the + // name's fate. + glog.Warningf("CheckENSExpiration: RPC call failed for %s, skipping expiration: %v", name, err) + return false, nil + } + + // Parse the expiration timestamp from the result + if len(result) < 2 || result[:2] != "0x" { + glog.Warningf("CheckENSExpiration: invalid hex result for %s, skipping expiration: %s", name, result) + return false, nil + } + + // nameExpires returns an ABI-encoded uint256: a 32-byte word, big-endian, + // left-padded with zeros. Parse it as raw bytes rather than as a hex + // quantity — hexutil.DecodeBig rejects the leading zeros of a padded word + // and would fail to decode every real result. + expiration := new(big.Int).SetBytes(ethcommon.FromHex(result)) + + // If nameExpires returns 0 the label is not registered on the Base + // Registrar (unknown token). Skip expiration and let ResolveENS decide the + // name's fate rather than treating the zero as "expired". + if expiration.Sign() == 0 { + glog.Warningf("CheckENSExpiration: %s has zero expiration, skipping check", name) + return false, nil + } + + // 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/ethparser.go b/bchain/coins/eth/ethparser.go index 30085cd8c0..b52090f917 100644 --- a/bchain/coins/eth/ethparser.go +++ b/bchain/coins/eth/ethparser.go @@ -1,6 +1,7 @@ package eth import ( + "bytes" "encoding/hex" "math/big" "strconv" @@ -55,8 +56,17 @@ type EthereumParser struct { AddrContractsCacheBulkMaxBytes int64 FormatAddressFunc func(addr string) string FromDescToAddressFunc func(addrDesc bchain.AddressDescriptor) string + // EnableEnsReverseAliases opts this chain into ENS reverse aliasing, off by default. + EnableEnsReverseAliases bool + ignoredContracts []bchain.AddressDescriptor } +// eip7708SystemAddress emits an ERC-20 shaped Transfer log for every ETH move since Glamsterdam. +// It has no code, so it cannot emit logs on a chain without EIP-7708 and is ignored everywhere. +const eip7708SystemAddress = "0xfffffffffffffffffffffffffffffffffffffffe" + +var eip7708SystemAddressDesc = mustAddrDescFromAddress(eip7708SystemAddress) + // NewEthereumParser returns new EthereumParser instance func NewEthereumParser(b int, addressAliases bool) *EthereumParser { return &EthereumParser{ @@ -74,7 +84,38 @@ func NewEthereumParser(b int, addressAliases bool) *EthereumParser { AddrContractsCacheBulkMaxBytes: defaultAddressContractsCacheBulkMaxBytes, FormatAddressFunc: EIP55AddressFromAddress, FromDescToAddressFunc: EIP55Address, + ignoredContracts: []bchain.AddressDescriptor{eip7708SystemAddressDesc}, + } +} + +func mustAddrDescFromAddress(address string) bchain.AddressDescriptor { + desc, err := (&EthereumParser{}).GetAddrDescFromAddress(address) + if err != nil { + panic(err) } + return desc +} + +// IgnoreContract makes the contract's Transfer logs not count as token transfers, for protocol +// contracts mirroring native coin moves that are already indexed as internal transfers. +// It must be called before the parser is in use. +func (p *EthereumParser) IgnoreContract(address string) error { + desc, err := p.GetAddrDescFromAddress(address) + if err != nil { + return err + } + p.ignoredContracts = append(p.ignoredContracts, desc) + return nil +} + +// EthereumTypeIsIgnoredContract reports a contract whose already indexed holdings must not be served as a token +func (p *EthereumParser) EthereumTypeIsIgnoredContract(contract bchain.AddressDescriptor) bool { + for _, c := range p.ignoredContracts { + if bytes.Equal(c, contract) { + return true + } + } + return false } func (p *EthereumParser) HotAddressConfig() (minContracts, lruSize, minHits int) { @@ -90,13 +131,16 @@ func (p *EthereumParser) AddressContractsCacheConfig() AddressContractsCacheConf } type rpcHeader struct { - Hash string `json:"hash"` - ParentHash string `json:"parentHash"` - Difficulty string `json:"difficulty"` - Number string `json:"number"` - Time string `json:"timestamp"` - Size string `json:"size"` - Nonce string `json:"nonce"` + Hash string `json:"hash"` + ParentHash string `json:"parentHash"` + Difficulty string `json:"difficulty"` + Number string `json:"number"` + Time string `json:"timestamp"` + Size string `json:"size"` + Nonce string `json:"nonce"` + GasUsed string `json:"gasUsed"` + GasLimit string `json:"gasLimit"` + BaseFeePerGas string `json:"baseFeePerGas"` } type rpcLogWithTxHash struct { @@ -112,10 +156,6 @@ 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) @@ -421,6 +461,11 @@ func (p *EthereumParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ( return nil, errors.Annotatef(err, "L1GasUsed %v", r.Receipt.L1GasUsed) } } + if r.Receipt.EffectiveGasPrice != "" { + if pt.Receipt.EffectiveGasPrice, err = hexDecodeBig(r.Receipt.EffectiveGasPrice); err != nil { + return nil, errors.Annotatef(err, "EffectiveGasPrice %v", r.Receipt.EffectiveGasPrice) + } + } } if len(r.ChainExtraData) > 0 { pt.ChainExtraData = r.ChainExtraData @@ -493,6 +538,9 @@ func (p *EthereumParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { if len(pt.Receipt.L1GasUsed) > 0 { rr.L1GasUsed = hexEncodeBig(pt.Receipt.L1GasUsed) } + if len(pt.Receipt.EffectiveGasPrice) > 0 { + rr.EffectiveGasPrice = hexEncodeBig(pt.Receipt.EffectiveGasPrice) + } } // TODO handle internal transactions tx, err := p.EthTxToTx(&rt, rr, nil, int64(pt.BlockTime), 0, false) @@ -568,12 +616,12 @@ func (p *EthereumParser) EthereumTypeGetTokenTransfersFromTx(tx *bchain.Tx) (bch csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) if ok { if csd.Receipt != nil { - r, err = contractGetTransfersFromLog(csd.Receipt.Logs) - } else { + r = contractGetTransfersFromLog(csd.Receipt.Logs, tx.Txid, p.ignoredContracts) + } else if !isIgnoredContract(p.ignoredContracts, csd.Tx.To) { r, err = contractGetTransfersFromTx(csd.Tx) - } - if err != nil { - return nil, err + if err != nil { + return nil, err + } } } return r, nil @@ -608,6 +656,7 @@ func GetEthereumTxDataFromSpecificData(coinSpecificData interface{}) *bchain.Eth etd.Status = bchain.TxStatusFailure } etd.GasUsed, _ = hexutil.DecodeBig(csd.Receipt.GasUsed) + etd.EffectiveGasPrice, _ = hexutil.DecodeBig(csd.Receipt.EffectiveGasPrice) etd.L1Fee, _ = hexutil.DecodeBig(csd.Receipt.L1Fee) etd.L1GasPrice, _ = hexutil.DecodeBig(csd.Receipt.L1GasPrice) etd.L1GasUsed, _ = hexutil.DecodeBig(csd.Receipt.L1GasUsed) diff --git a/bchain/coins/eth/ethparser_test.go b/bchain/coins/eth/ethparser_test.go index e038cf00a8..e4508e92d1 100644 --- a/bchain/coins/eth/ethparser_test.go +++ b/bchain/coins/eth/ethparser_test.go @@ -425,6 +425,57 @@ func TestEthereumParser_PackUnpackChainExtraData(t *testing.T) { } } +// TestEthereumParser_PackUnpackEffectiveGasPrice verifies that the receipt +// effectiveGasPrice (used to compute L2 fees, issue #1227) survives a +// PackTx/UnpackTx round trip through the database representation and is exposed +// by GetEthereumTxData. +func TestEthereumParser_PackUnpackEffectiveGasPrice(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", + EffectiveGasPrice: "0x3b9aca00", // 1_000_000_000 + Status: "0x1", + Logs: []*bchain.RpcLog{}, + }, + }, + } + + 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 csd.Receipt.EffectiveGasPrice != "0x3b9aca00" { + t.Fatalf("EffectiveGasPrice mismatch, got %s, want 0x3b9aca00", csd.Receipt.EffectiveGasPrice) + } + + etd := p.GetEthereumTxData(unpacked) + if etd.EffectiveGasPrice == nil || etd.EffectiveGasPrice.Int64() != 1000000000 { + t.Fatalf("GetEthereumTxData EffectiveGasPrice mismatch, got %v, want 1000000000", etd.EffectiveGasPrice) + } +} + func TestEthereumParser_GetEthereumTxData(t *testing.T) { p := NewEthereumParser(1, false) tests := []struct { diff --git a/bchain/coins/eth/ethrpc.go b/bchain/coins/eth/ethrpc.go index eac2a5dabe..b8ab788c08 100644 --- a/bchain/coins/eth/ethrpc.go +++ b/bchain/coins/eth/ethrpc.go @@ -2,7 +2,6 @@ package eth import ( "context" - "encoding/hex" "encoding/json" stdErrors "errors" "fmt" @@ -10,6 +9,7 @@ import ( "math/big" "net/http" "net/url" + "runtime/debug" "strconv" "strings" "sync" @@ -19,15 +19,12 @@ import ( "github.com/ethereum/go-ethereum" ethcommon "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/golang/glog" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" - "golang.org/x/crypto/sha3" - "golang.org/x/sync/singleflight" ) // Network type specifies the type of ethereum network @@ -38,8 +35,6 @@ const ( 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 ) @@ -54,10 +49,27 @@ const ( // 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 + // defaultAlternativePendingTxWindow is how long a transaction broadcast through a private relay is + // treated as still pending: the window in which builders can still include it, which Blinklabs puts + // at around three hours. The relay's eth_getTransactionByHash and pending eth_getTransactionCount + // answers now cover that same window (they used to stop after about a minute, the root cause of + // #1573). Sizing pending state below the window made Blockbook stop showing a live transaction and + // hand its nonce back out (#1573, #1675). + defaultAlternativePendingTxWindow = 3 * time.Hour + // mempoolRetentionMarginOverPendingWindow keeps the wrapped Blockbook mempool alive longer than the + // alternative cache, an order that is load-bearing (see mempoolRetentionInverted). It only has to + // cover the mempool's own sweep granularity of at most 10 minutes, and deriving the mempool default + // from the cache retention means changing the window cannot invert the pair. + mempoolRetentionMarginOverPendingWindow = 30 * time.Minute + // defaultAlternativeMissingTxTimeout is how long a cached private transaction may stay missing from + // the relay - consecutive null eth_getTransactionByHash answers - before reconcile evicts it. The + // relay answers from one consistent store over the whole pending window above, so a null means the + // transaction is gone - dropped or cancelled (a drop-mode cancel leaves no replacement behind to + // retire its predecessor) - and the short run requirement only absorbs a transient relay fluke + // rather than any node-to-node lag. For a relay without that consistency, configure + // alternativeMissingTxTimeout at or above the cache retention, which restores eviction by cache + // timeout only. + defaultAlternativeMissingTxTimeout = 2 * time.Minute ) // Ethereum address constants @@ -66,14 +78,6 @@ const ( 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 @@ -86,6 +90,7 @@ type Configuration struct { RPCTimeout int `json:"rpc_timeout"` TraceTimeout string `json:"trace_timeout,omitempty"` Erc20BatchSize int `json:"erc20_batch_size,omitempty"` + Multicall3MaxCalls int `json:"multicall3_max_calls,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"` @@ -94,17 +99,26 @@ type Configuration struct { 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"` + // EnableEnsReverseAliases opts a chain into ENS reverse aliasing (address->name + // labels), which is off by default; see the warning on getEnsRecord. Contract/ + // token-name aliases and forward ResolveENS are unaffected either way. + // Labels are only recorded as blocks are synced and there is no backfill, so enabling + // this later covers new blocks only - names registered while it was off stay unlabeled + // until the chain is reindexed. + EnableEnsReverseAliases bool `json:"enable_ens_reverse_aliases,omitempty"` + MempoolTxTimeoutHours int `json:"mempoolTxTimeoutHours"` + MempoolTxTimeout string `json:"mempoolTxTimeout,omitempty"` + AlternativePendingTxWindow string `json:"alternativePendingTxWindow,omitempty"` + AlternativeMempoolTxTimeout string `json:"alternativeMempoolTxTimeout,omitempty"` + AlternativeMissingTxTimeout string `json:"alternativeMissingTxTimeout,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"` @@ -135,24 +149,68 @@ func parsePositiveDuration(name string, value string) (time.Duration, error) { return d, nil } -// MempoolTxTimeoutDuration returns the Blockbook-side EVM mempool retention. +// MempoolTxTimeoutDuration returns the Blockbook-side EVM mempool retention; with a relay configured it +// is the alternative cache retention plus a margin, so the mempool always outlives the store whose +// exits are what clear it. 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 + alternative, err := c.AlternativeMempoolTxTimeoutDuration() + if err != nil { + return 0, err + } + return alternative + mempoolRetentionMarginOverPendingWindow, nil } return time.Duration(c.MempoolTxTimeoutHours) * time.Hour, nil } -// AlternativeMempoolTxTimeoutDuration returns the alternative-provider cache retention. +// AlternativePendingTxWindowDuration returns how long a transaction broadcast through the alternative +// provider is treated as still pending, i.e. can still be built into a block. +func (c *Configuration) AlternativePendingTxWindowDuration() (time.Duration, error) { + if c.AlternativePendingTxWindow != "" { + return parsePositiveDuration("alternativePendingTxWindow", c.AlternativePendingTxWindow) + } + return defaultAlternativePendingTxWindow, nil +} + +// AlternativeMempoolTxTimeoutDuration returns the alternative-provider cache retention: the pending +// window, unless a deployment holds transactions for a different span than they can still land in. func (c *Configuration) AlternativeMempoolTxTimeoutDuration() (time.Duration, error) { if c.AlternativeMempoolTxTimeout != "" { return parsePositiveDuration("alternativeMempoolTxTimeout", c.AlternativeMempoolTxTimeout) } - return defaultAlternativeMempoolTxTimeout, nil + return c.AlternativePendingTxWindowDuration() +} + +// AlternativeMissingTxTimeoutDuration returns how long a cached private transaction may stay missing +// from the relay's eth_getTransactionByHash before the reconcile loop evicts it (see +// defaultAlternativeMissingTxTimeout for what a sustained run of nulls means and how to disable the +// early eviction for a relay that does not surface transactions for as long as they stay cached). +func (c *Configuration) AlternativeMissingTxTimeoutDuration() (time.Duration, error) { + if c.AlternativeMissingTxTimeout != "" { + return parsePositiveDuration("alternativeMissingTxTimeout", c.AlternativeMissingTxTimeout) + } + return defaultAlternativeMissingTxTimeout, nil +} + +// mempoolRetentionInverted reports whether the alternative-provider cache is configured to outlive the +// wrapped Blockbook mempool. Every cache exit clears the mempool too, but the mempool's own timeout +// sweep is the one exit that does NOT clear the cache: inverted, it drops a private transaction's +// address index while the cache keeps serving its body as pending, and nothing reconciles the two. Only +// an explicit mempoolTxTimeout can invert the pair, which is why that is rejected rather than warned +// about. Equal timeouts are not inverted: every cache write restamps the wrapped mempool entry +// (AddOrRefreshTransactionInMempool), so the cache entry is never the younger one. +func mempoolRetentionInverted(alternativeTimeout, mempoolTimeout time.Duration) bool { + return alternativeTimeout > mempoolTimeout +} + +// mempoolRetentionDrifted reports whether an explicit mempoolTxTimeout leaves the cache retention +// behind - safe, but the shipped configs keep the pair equal, so a gap usually means a later edit moved +// one side; a derived mempool retention is the cache's plus a margin and never drifts. +func mempoolRetentionDrifted(alternativeTimeout, mempoolTimeout time.Duration, mempoolExplicit bool) bool { + return mempoolExplicit && alternativeTimeout < mempoolTimeout } // AverageBlockTimeDuration returns AverageBlockTimeMs as a time.Duration. @@ -163,6 +221,23 @@ func (c *Configuration) AverageBlockTimeDuration() (time.Duration, error) { return time.Duration(c.AverageBlockTimeMs) * time.Millisecond, nil } +// ApplyToParser copies every config-derived field onto p. Coin packages that build their own +// EthereumParser instead of reusing the one NewEthereumRPC configured (Tron) call this as well, +// so a new config-derived parser field is added in one place rather than in each constructor - +// forgetting one there is silent, and previously dropped disable_ens_aliases on Tron. +// Fields a coin owns (EnsSuffix, AmountDecimalPoint, the address format funcs) are left alone. +func (c *Configuration) ApplyToParser(p *EthereumParser) { + p.BlockAddressesToKeep = c.BlockAddressesToKeep + p.AddressAliases = c.AddressAliases + p.EnableEnsReverseAliases = c.EnableEnsReverseAliases + p.HotAddressMinContracts = c.HotAddressMinContracts + p.HotAddressLRUCacheSize = c.HotAddressLRUCacheSize + p.HotAddressMinHits = c.HotAddressMinHits + p.AddrContractsCacheMinSize = c.AddressContractsCacheMinSize + p.AddrContractsCacheMaxBytes = c.AddressContractsCacheMaxBytes + p.AddrContractsCacheBulkMaxBytes = c.AddressContractsCacheBulkMaxBytes +} + // EthereumRPC is an interface to JSON-RPC eth service. type EthereumRPC struct { *bchain.BaseChain @@ -203,9 +278,15 @@ type EthereumRPC struct { alternativeSendTxProvider *AlternativeSendTxProvider InternalDataProvider bchain.EthereumInternalDataProvider consensusMonitor *consensusVersionMonitor + // Multicall3AddressOverride replaces the canonical Multicall3 address for chains + // that deploy it at a non-canonical address (e.g. Tron). Set in code, not config. + Multicall3AddressOverride string + // net_version / web3_clientVersion snapshot, see backendidentity.go. + identity *common.TTLValue[backendIdentity] + // chain id latched by the first identity fetch, see validateBackendChainID. + validatedChainID atomic.Uint64 // Multicall3 deployment state; lazily probed on first call. See multicall.go. - multicall3Probe atomic.Int32 - multicall3ProbeSF singleflight.Group + multicall3 multicall3Gate } // NewEthereumRPC returns new EthRPC instance. @@ -258,9 +339,20 @@ func NewEthereumRPC(config json.RawMessage, pushHandler func(bchain.Notification if _, err := c.MempoolTxTimeoutDuration(false); err != nil { return nil, err } + // also with a provider: that branch derives from the two settings below, and whether a provider + // ends up configured is an env-var decision this early code cannot see + if _, err := c.MempoolTxTimeoutDuration(true); err != nil { + return nil, err + } + if _, err := c.AlternativePendingTxWindowDuration(); err != nil { + return nil, err + } if _, err := c.AlternativeMempoolTxTimeoutDuration(); err != nil { return nil, err } + if _, err := c.AlternativeMissingTxTimeoutDuration(); err != nil { + return nil, err + } if _, err := c.AverageBlockTimeDuration(); err != nil { return nil, err } @@ -269,6 +361,7 @@ func NewEthereumRPC(config json.RawMessage, pushHandler func(bchain.Notification BaseChain: &bchain.BaseChain{}, ChainConfig: &c, } + s.identity = common.NewTTLValue(backendIdentityTTL, s.fetchBackendIdentity) // 1-slot buffer ensures we only queue one "refresh tip" signal at a time. s.newBlockNotifyCh = make(chan struct{}, 1) @@ -276,12 +369,7 @@ func NewEthereumRPC(config json.RawMessage, pushHandler func(bchain.Notification // always create parser 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 + c.ApplyToParser(parser) s.Parser = parser if c.RPCTimeout <= 0 { glog.Warningf("rpc_timeout=%d is invalid, using default %d seconds", c.RPCTimeout, defaultRPCTimeoutSeconds) @@ -325,17 +413,28 @@ func (b *EthereumRPC) observeEthCall(mode string, count int) { // ObserveChainDataFallback increments a metric for chain-data fallback paths. func (b *EthereumRPC) ObserveChainDataFallback(component, reason string) { - if b.metrics == nil || component == "" || reason == "" { + b.observeChainDataFallback(component, reason, 1) +} + +// observeChainDataFallback adds count to the fallback metric (per-element, not just per-request). +func (b *EthereumRPC) observeChainDataFallback(component, reason string, count int) { + if b.metrics == nil || component == "" || reason == "" || count <= 0 { return } - b.metrics.ChainDataFallbacks.With(common.Labels{"component": component, "reason": reason}).Inc() + b.metrics.ChainDataFallbacks.With(common.Labels{"component": component, "reason": reason}).Add(float64(count)) } func (b *EthereumRPC) observeEthCallError(mode, errType string) { - if b.metrics == nil { + b.observeEthCallErrors(mode, errType, 1) +} + +// observeEthCallErrors adds count at once; per-element loops tally locally and emit here, as +// With() builds a label map and resolves the child counter on every call. +func (b *EthereumRPC) observeEthCallErrors(mode, errType string, count int) { + if b.metrics == nil || count <= 0 { return } - b.metrics.EthCallErrors.With(common.Labels{"mode": mode, "type": errType}).Inc() + b.metrics.EthCallErrors.With(common.Labels{"mode": mode, "type": errType}).Add(float64(count)) } func (b *EthereumRPC) observeEthCallBatch(size int) { @@ -345,6 +444,15 @@ func (b *EthereumRPC) observeEthCallBatch(size int) { b.metrics.EthCallBatchSize.Observe(float64(size)) } +// observeEthCallMulticallRequest counts one physical aggregate3 eth_call; the mode-labeled +// counter reports per-sub-call volume. +func (b *EthereumRPC) observeEthCallMulticallRequest() { + if b.metrics == nil { + return + } + b.metrics.EthCallMulticallRequests.Inc() +} + func (b *EthereumRPC) observeEthCallContractInfo(field string) { if b.metrics == nil { return @@ -352,6 +460,17 @@ func (b *EthereumRPC) observeEthCallContractInfo(field string) { b.metrics.EthCallContractInfo.With(common.Labels{"field": field}).Inc() } +// observeEthCallContractInfos records one read per metadata field for count contracts: the +// aggregate3 path always issues all of them instead of short-circuiting on name(). +func (b *EthereumRPC) observeEthCallContractInfos(count int) { + if b.metrics == nil || count <= 0 { + return + } + for _, f := range contractInfoFields { + b.metrics.EthCallContractInfo.With(common.Labels{"field": f.field}).Add(float64(count)) + } +} + func (b *EthereumRPC) observeEthCallTokenURI(method string) { if b.metrics == nil { return @@ -395,6 +514,17 @@ func (b *EthereumRPC) observeEthSyncRpcError(method string, err error) { b.metrics.EthSyncRpcErrors.With(common.Labels{"method": method, "status": ethSyncRpcErrStatus(err)}).Inc() } +func (b *EthereumRPC) observeSyncRPCLatency(method string, start time.Time, err error) { + if b.metrics == nil { + return + } + errorLabel := "" + if err != nil { + errorLabel = "failure" + } + b.metrics.RPCSyncLatency.With(common.Labels{"method": method, "error": errorLabel}).Observe(float64(time.Since(start)) / 1e6) +} + // EnsureSameRPCHost validates both RPC URLs and logs a warning if hosts differ. func EnsureSameRPCHost(httpURL, wsURL string) error { if httpURL == "" || wsURL == "" { @@ -479,13 +609,9 @@ 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...) + return rpc.DialOptions(ctx, rawURL, RPCDialOptions(rawURL)...) } // OpenRPC opens RPC connection to ETH backend. @@ -524,7 +650,7 @@ func (b *EthereumRPC) Initialize() error { b.Client = ec b.RPC = rc b.MainNetChainID = MainNet - b.NewBlock = &EthereumNewBlock{channel: make(chan *types.Header)} + b.NewBlock = NewEthereumNewBlock() b.NewTx = &EthereumNewTx{channel: make(chan ethcommon.Hash)} ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) @@ -543,9 +669,6 @@ func (b *EthereumRPC) Initialize() error { case TestNetSepolia: b.Testnet = true b.Network = "sepolia" - case TestNetHolesky: - b.Testnet = true - b.Network = "holesky" case TestNetHoodi: b.Testnet = true b.Network = "hoodi" @@ -690,17 +813,41 @@ func (b *EthereumRPC) InitAlternativeProviders() error { if err != nil { return err } - b.alternativeSendTxProvider = NewAlternativeSendTxProvider(network, b.ChainConfig.RPCTimeout, alternativeMempoolTxTimeout, b.metrics) + alternativeMissingTxTimeout, err := b.ChainConfig.AlternativeMissingTxTimeoutDuration() + if err != nil { + return err + } + b.alternativeSendTxProvider = NewAlternativeSendTxProvider(network, b.ChainConfig.RPCTimeout, alternativeMempoolTxTimeout, alternativeMissingTxTimeout, 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 { - mempoolTxTimeout, err := b.ChainConfig.MempoolTxTimeoutDuration(b.alternativeSendTxProvider != nil) + // The retention coupling below exists only when the pending-tx cache does: a URLS-only + // deployment (no *_ALTERNATIVE_FETCH_MEMPOOL_TX) broadcasts through the relay but never + // writes the cache, so there is no private address index to outlive and nothing to invert - + // it keeps the legacy retention and must not be failed for an explicit short timeout. + cacheEnabled := b.alternativeSendTxProvider != nil && b.alternativeSendTxProvider.fetchMempoolTx + mempoolTxTimeout, err := b.ChainConfig.MempoolTxTimeoutDuration(cacheEnabled) if err != nil { return nil, err } + // Checked here, not in Validate: the effective retention depends on the env-configured provider + // existing. Inverted, private transactions silently lose their address index while still being + // served as pending (#1573), so it is rejected rather than warned about - and rejected before the + // mempool is assigned, so a second call cannot hand out a mempool with no provider wired to it. + if cacheEnabled { + cacheRetention := b.alternativeSendTxProvider.mempoolTxsTimeout + if mempoolRetentionInverted(cacheRetention, mempoolTxTimeout) { + // wrapped in ErrConfiguration so startup fails fast: this cannot resolve on a retry, and + // the retry loop would otherwise spend two minutes reporting it once a second as an RPC fault + return nil, fmt.Errorf("%w: mempoolTxTimeout=%s must not be shorter than the alternative-provider cache retention of %s, or the wrapped mempool drops a private transaction's address index while the provider cache still serves it as pending; raise mempoolTxTimeout or lower alternativeMempoolTxTimeout (or alternativePendingTxWindow when no explicit cache retention is set)", bchain.ErrConfiguration, mempoolTxTimeout, cacheRetention) + } + if mempoolRetentionDrifted(cacheRetention, mempoolTxTimeout, b.ChainConfig.MempoolTxTimeout != "") { + glog.Infof("alternative-provider cache retention %s is shorter than mempoolTxTimeout %s: private transactions stop being served as pending before their address index expires", cacheRetention, mempoolTxTimeout) + } + } 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 { @@ -958,24 +1105,18 @@ func (b *EthereumRPC) GetChainInfo() (*bchain.ChainInfo, error) { if err != nil { return nil, err } - ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) - defer cancel() - id, err := b.Client.NetworkID(ctx) + identity, err := b.getBackendIdentity() if err != nil { return nil, err } - 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(), Difficulty: h.Difficulty().String(), - Version: ver, + Version: identity.clientVersion, ConsensusVersion: b.consensusMonitor.get(), } - idi := int(id.Uint64()) + idi := int(identity.chainID) if idi == int(b.MainNetChainID) { rv.Chain = "mainnet" } else { @@ -996,7 +1137,9 @@ func (b *EthereumRPC) getBestHeader() (bchain.EVMHeader, error) { var err error ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() + headerStart := time.Now() b.bestHeader, err = b.Client.HeaderByNumber(ctx, nil) + b.observeSyncRPCLatency("eth_getBlockByNumber", headerStart, err) if err != nil { b.bestHeader = nil return nil, err @@ -1005,6 +1148,20 @@ func (b *EthereumRPC) getBestHeader() (bchain.EVMHeader, error) { return b.bestHeader, nil } +// EthereumTypeGetBestTip snapshots the cached tip header under one lock acquisition, +// so the sync worker never pairs a hash with the height of a different header. +func (b *EthereumRPC) EthereumTypeGetBestTip() (*bchain.EVMTip, error) { + h, err := b.getBestHeader() + if err != nil { + return nil, err + } + return &bchain.EVMTip{ + Hash: h.Hash(), + ParentHash: h.ParentHash(), + Height: uint32(h.Number().Uint64()), + }, 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) { @@ -1286,6 +1443,27 @@ func (b *EthereumRPC) GetBlockHash(height uint32) (string, error) { return h.Hash(), nil } +// returns early for pre-London blocks, populates EthereumBlockSpecificData +func attachBlockGas(h *rpcHeader, existing *bchain.EthereumBlockSpecificData) *bchain.EthereumBlockSpecificData { + if h.BaseFeePerGas == "" { + return existing + } + bsd := existing + if bsd == nil { + bsd = &bchain.EthereumBlockSpecificData{} + } + if baseFee, err := hexutil.DecodeUint64(h.BaseFeePerGas); err == nil { + bsd.BaseFeePerGas = new(big.Int).SetUint64(baseFee) + } + if gasUsed, err := hexutil.DecodeUint64(h.GasUsed); err == nil { + bsd.GasUsed = new(big.Int).SetUint64(gasUsed) + } + if gasLimit, err := hexutil.DecodeUint64(h.GasLimit); err == nil { + bsd.GasLimit = new(big.Int).SetUint64(gasLimit) + } + return bsd +} + func (b *EthereumRPC) ethHeaderToBlockHeader(h *rpcHeader) (*bchain.BlockHeader, error) { height, err := ethNumber(h.Number) if err != nil { @@ -1332,6 +1510,11 @@ func (b *EthereumRPC) computeConfirmations(n uint64) (uint32, error) { return 0, err } bn := bh.Number().Uint64() + // The cached tip is advanced asynchronously, so a block fetched straight from the + // backend can be ahead of it; report the mined minimum instead of underflowing. + if bn < n { + return 1, nil + } // transaction in the best block has 1 confirmation return uint32(bn - n + 1), nil } @@ -1342,6 +1525,7 @@ func (b *EthereumRPC) getBlockRaw(hash string, height uint32, fullTxs bool) (jso var raw json.RawMessage var err error var method string + defer func(s time.Time) { b.observeSyncRPCLatency(method, s, err) }(time.Now()) if hash != "" { if hash == "pending" { method = "eth_getBlockByNumber" @@ -1356,6 +1540,11 @@ func (b *EthereumRPC) getBlockRaw(hash string, height uint32, fullTxs bool) (jso } b.observeEthSyncRpcError(method, err) if err != nil { + // juju's Annotatef has no Unwrap, so annotating a sentinel hides it from + // errors.Is and disables getBlockChain's end-of-chain exit and retry accounting. + if stdErrors.Is(err, bchain.ErrBlockNotFound) { + return nil, err + } return nil, errors.Annotatef(err, "hash %v, height %v", hash, height) } else if len(raw) == 0 || (len(raw) == 4 && string(raw) == "null") { return nil, bchain.ErrBlockNotFound @@ -1374,21 +1563,31 @@ func (b *EthereumRPC) processEventsForBlock(blockNumber string) (map[string][]*b var logs []rpcLogWithTxHash var ensRecords []bchain.AddressAliasRecord var method = "eth_getLogs" - err := b.RPC.CallContext(ctx, &logs, method, map[string]interface{}{ + var err error + defer func(s time.Time) { b.observeSyncRPCLatency(method, s, err) }(time.Now()) + err = b.RPC.CallContext(ctx, &logs, method, map[string]interface{}{ "fromBlock": blockNumber, "toBlock": blockNumber, }) b.observeEthSyncRpcError(method, err) if err != nil { + // juju's Annotatef has no Unwrap; keep the sentinel bare so getBlockChain's + // end-of-chain exit and retry accounting still see ErrBlockNotFound. + if stdErrors.Is(err, bchain.ErrBlockNotFound) { + return nil, nil, err + } return nil, nil, errors.Annotatef(err, "%s blockNumber %v", method, blockNumber) } r := make(map[string][]*bchain.RpcLog) + recordEns := b.Parser.UseEnsReverseAliases() for i := range logs { l := &logs[i] r[l.Hash] = append(r[l.Hash], &l.RpcLog) - ens := getEnsRecord(l) - if ens != nil { - ensRecords = append(ensRecords, *ens) + if recordEns { + ens := getEnsRecord(l) + if ens != nil { + ensRecords = append(ensRecords, *ens) + } } } return r, ensRecords, nil @@ -1407,6 +1606,36 @@ type rpcCallTrace struct { type rpcTraceResult struct { Result rpcCallTrace `json:"result"` + // tracer failure for the whole tx (typically a timeout), distinct from the EVM revert in Result.Error + Error rpcTraceError `json:"error"` +} + +// rpcTraceError tolerates both envelope shapes: geth forks send a plain string, Erigon a {code,message} object +type rpcTraceError string + +func (e *rpcTraceError) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + *e = rpcTraceError(s) + return nil + } + var o struct { + Code int `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(b, &o); err != nil { + return err + } + if o.Message == "" { + o.Message = fmt.Sprintf("error code %d", o.Code) + } + *e = rpcTraceError(o.Message) + return nil +} + +// isBridgingTx identifies the Polygon state-sync tx, which bor traces separately and unreliably +func isBridgingTx(tx *bchain.RpcTransaction) bool { + return tx.To == "0x0000000000000000000000000000000000000000" && tx.From == "0x0000000000000000000000000000000000000000" } func (b *EthereumRPC) getCreationContractInfo(contract string, height uint32) *bchain.ContractInfo { @@ -1425,6 +1654,11 @@ func (b *EthereumRPC) getCreationContractInfo(contract string, height uint32) *b } func (b *EthereumRPC) processCallTrace(call *rpcCallTrace, d *bchain.EthereumInternalData, contracts []bchain.ContractInfo, blockHeight uint32) []bchain.ContractInfo { + if call.Error != "" { + // a failed frame moves no value and its whole subtree is reverted (#1621) + d.Error = call.Error + return contracts + } value, err := hexutil.DecodeBig(call.Value) if err != nil { value = new(big.Int) @@ -1445,18 +1679,20 @@ func (b *EthereumRPC) processCallTrace(call *rpcCallTrace, d *bchain.EthereumInt 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 + } else if call.Type == "DELEGATECALL" || call.Type == "CALLCODE" || call.Type == "STATICCALL" { + // DELEGATECALL and CALLCODE run foreign code in the caller's own context, so their + // traced value never leaves the caller (geth v1.11 tracer change, issues #26726, #1225); + // STATICCALL cannot transfer value at all + } else if call.Type == "CALL" { + if err == nil && (value.BitLen() > 0 || b.ChainConfig.ProcessZeroInternalTransactions) { + d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{ + Value: *value, + From: call.From, + To: call.To, + }) + } + } else if err == nil && value.BitLen() > 0 { + glog.Warningf("processCallTrace: unknown call type %q with value in block %d, not indexed", call.Type, blockHeight) } for i := range call.Calls { contracts = b.processCallTrace(&call.Calls[i], d, contracts, blockHeight) @@ -1478,7 +1714,9 @@ func (b *EthereumRPC) getInternalDataForBlock(ctx context.Context, blockHash str if b.ChainConfig.TraceTimeout != "" { traceConfig["timeout"] = b.ChainConfig.TraceTimeout } + traceStart := time.Now() err := b.RPC.CallContext(ctx, &trace, "debug_traceBlockByHash", blockHash, traceConfig) // Use caller-provided ctx for timeout/cancel. + b.observeSyncRPCLatency("debug_traceBlockByHash", traceStart, err) b.observeEthSyncRpcError("debug_traceBlockByHash", err) if err != nil { glog.Error("debug_traceBlockByHash block ", blockHash, ", error ", err) @@ -1489,8 +1727,7 @@ func (b *EthereumRPC) getInternalDataForBlock(ctx context.Context, blockHash str 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 isBridgingTx(tx) { if i >= len(trace) { trace = append(trace, rpcTraceResult{}) } else { @@ -1509,6 +1746,12 @@ func (b *EthereumRPC) getInternalDataForBlock(ctx context.Context, blockHash str } } for i, result := range trace { + // a failed per-tx trace must not be committed as an empty one; the error queues the block for HealInternalData + if result.Error != "" && !isBridgingTx(&transactions[i]) { + e := fmt.Sprintf("trace of tx %s failed: %s", transactions[i].Hash, result.Error) + glog.Error("debug_traceBlockByHash block ", blockHash, ", error: ", e) + return data, contracts, errors.New(e) + } r := &result.Result d := &data[i] if r.Type == "CREATE" || r.Type == "CREATE2" { @@ -1518,10 +1761,12 @@ func (b *EthereumRPC) getInternalDataForBlock(ctx context.Context, blockHash str } 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 != "" { + if r.Error == "" { + for j := range r.Calls { + contracts = b.processCallTrace(&r.Calls[j], d, contracts, blockHeight) + } + } else { + // a reverted root undoes every child frame, so none of them is a real transfer baseError := PackInternalTransactionError(r.Error) if len(baseError) > 1 { // n, _ := ethNumber(transactions[i].BlockNumber) @@ -1547,6 +1792,66 @@ func (b *EthereumRPC) getInternalDataForBlock(ctx context.Context, blockHash str return data, contracts, nil } +// blockAuxData is what GetBlock needs beyond the block body: logs (with ENS records) and +// internal transfers. internalErr is stored with the block instead of failing it. +type blockAuxData struct { + logs map[string][]*bchain.RpcLog + ens []bchain.AddressAliasRecord + internalData []bchain.EthereumInternalData + contracts []bchain.ContractInfo + internalErr error +} + +// getBlockAuxData runs the log fetch and the internal-data trace in parallel; a log +// failure aborts the block and cancels the trace. +func (b *EthereumRPC) getBlockAuxData(head *rpcHeader, height uint32, transactions []bchain.RpcTransaction) (*blockAuxData, error) { + ctxInternal, cancelInternal := context.WithTimeout(context.Background(), b.Timeout) + defer cancelInternal() + type logsResult struct { + logs map[string][]*bchain.RpcLog + ens []bchain.AddressAliasRecord + err error + } + type internalResult struct { + data []bchain.EthereumInternalData + contracts []bchain.ContractInfo + err error + } + // Buffered so neither goroutine leaks when the log failure returns early. + logsCh := make(chan logsResult, 1) + internalCh := make(chan internalResult, 1) + go func() { + // processEventsForBlock/getEnsRecord parse attacker-controlled on-chain log data and + // nothing above this goroutine recovers, so a panic here would crash-loop the process + // on an uncommitted block. Turn it into an error the sync loop can handle. + defer func() { + if r := recover(); r != nil { + glog.Error("GetBlock: recovered from panic in processEventsForBlock: ", r) + debug.PrintStack() + logsCh <- logsResult{err: fmt.Errorf("recovered from panic in processEventsForBlock: %v", r)} + } + }() + logs, ens, err := b.processEventsForBlock(head.Number) + logsCh <- logsResult{logs: logs, ens: ens, err: err} + }() + go func() { + data, contracts, err := b.getInternalDataForBlock(ctxInternal, head.Hash, height, transactions) + internalCh <- internalResult{data: data, contracts: contracts, err: err} + }() + logsRes := <-logsCh + if logsRes.err != nil { + return nil, logsRes.err + } + internalRes := <-internalCh + return &blockAuxData{ + logs: logsRes.logs, + ens: logsRes.ens, + internalData: internalRes.data, + contracts: internalRes.contracts, + internalErr: internalRes.err, + }, nil +} + // GetBlock returns block with given hash or height, hash has precedence if both passed func (b *EthereumRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { raw, err := b.getBlockRaw(hash, height, true) @@ -1566,69 +1871,43 @@ func (b *EthereumRPC) GetBlock(hash string, height uint32) (*bchain.Block, error if err != nil { return nil, errors.Annotatef(err, "hash %v, height %v", hash, height) } - // 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 + // Logs and internal transfers only come from transactions, so an empty block + // skips both of those RPCs. + aux := &blockAuxData{} + if len(body.Transactions) > 0 { + aux, err = b.getBlockAuxData(&head, bbh.Height, body.Transactions) + if err != nil { + return nil, 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 { + if aux.internalErr != nil || len(aux.ens) > 0 || len(aux.contracts) > 0 { blockSpecificData = &bchain.EthereumBlockSpecificData{} - if internalErr != nil { - blockSpecificData.InternalDataError = internalErr.Error() - // glog.Info("InternalDataError ", bbh.Height, ": ", internalErr.Error()) + if aux.internalErr != nil { + blockSpecificData.InternalDataError = aux.internalErr.Error() } - if len(ens) > 0 { - blockSpecificData.AddressAliasRecords = ens - // glog.Info("ENS", ens) + if len(aux.ens) > 0 { + blockSpecificData.AddressAliasRecords = aux.ens } - if len(contracts) > 0 { - blockSpecificData.Contracts = contracts - // glog.Info("Contracts", contracts) + if len(aux.contracts) > 0 { + blockSpecificData.Contracts = aux.contracts } } + blockSpecificData = attachBlockGas(&head, blockSpecificData) + btxs := make([]bchain.Tx, len(body.Transactions)) for i := range body.Transactions { tx := &body.Transactions[i] - btx, err := b.Parser.EthTxToTx(tx, &bchain.RpcReceipt{Logs: logs[tx.Hash]}, &internalData[i], bbh.Time, uint32(bbh.Confirmations), true) + btx, err := b.Parser.EthTxToTx(tx, &bchain.RpcReceipt{Logs: aux.logs[tx.Hash]}, &aux.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 b.removeTransactionFromMempool(tx.Hash) + b.removeSupersededFromMempool(tx) } bbk := bchain.Block{ BlockHeader: *bbh, @@ -1681,6 +1960,97 @@ func (b *EthereumRPC) removeTransactionFromMempool(txid string) { } } +// removeSupersededFromMempool retires the pending entries a mined transaction's nonce +// invalidates: the sender's slots at or below it can never mine (#1709). +func (b *EthereumRPC) removeSupersededFromMempool(tx *bchain.RpcTransaction) { + if !b.mempoolInitialized || tx.From == "" || tx.AccountNonce == "" { + return + } + nonce, err := ethNumber(tx.AccountNonce) + if err != nil || nonce < 0 { + return + } + from, err := b.Parser.GetAddrDescFromAddress(tx.From) + if err != nil { + return + } + for _, txid := range b.Mempool.RemoveSenderTransactionsUpToNonce(from, uint64(nonce)) { + // also clears the alternative provider's copy + b.removeTransactionFromMempool(txid) + } +} + +// callContextWithTimeout issues a single JSON-RPC call under its own fresh b.Timeout +// deadline, so sequential calls in a recovery sequence do not share (and progressively +// shrink) one deadline budget. +func (b *EthereumRPC) callContextWithTimeout(result interface{}, method string, args ...interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + return b.RPC.CallContext(ctx, result, method, args...) +} + +// txFromBlockBody fetches the full block body and returns the transaction matching txid, or +// nil if the fetch fails or the transaction is not present. This is the recovery fallback +// used when the O(1) positional lookup is unavailable. +func (b *EthereumRPC) txFromBlockBody(txid, blockHash string) *bchain.RpcTransaction { + raw, err := b.getBlockRaw(blockHash, 0, true) + if err != nil { + glog.Warningf("recoverMinedTransaction %s: getBlockRaw %s failed: %v", txid, blockHash, err) + return nil + } + var body rpcBlockTransactions + if err := json.Unmarshal(raw, &body); err != nil { + glog.Warningf("recoverMinedTransaction %s: decode block %s failed: %v", txid, blockHash, err) + return nil + } + for i := range body.Transactions { + if strings.EqualFold(body.Transactions[i].Hash, txid) { + return &body.Transactions[i] + } + } + return nil +} + +// recoverMinedTransaction reconstructs a mined transaction that eth_getTransactionByHash +// returned null because the backend pruned its tx-by-hash index (observed on QuikNode Base). +// It looks the tx up by the receipt's (blockHash, transactionIndex) and returns it with the +// receipt for reuse. Returns (nil, nil) when the tx is genuinely unknown or recovery fails, +// so the caller yields ErrTxNotFound; recovery-lookup failures are logged, not propagated. +func (b *EthereumRPC) recoverMinedTransaction(txid string) (*bchain.RpcTransaction, *bchain.RpcReceipt) { + // The receipt still works on such backends and carries the block hash and tx index; + // decode it in one pass (embedding RpcReceipt) so it can be reused for EthTxToTx. + var receipt struct { + bchain.RpcReceipt + BlockHash string `json:"blockHash"` + TransactionIndex string `json:"transactionIndex"` + } + if err := b.callContextWithTimeout(&receipt, "eth_getTransactionReceipt", ethcommon.HexToHash(txid)); err != nil { + glog.Warningf("recoverMinedTransaction %s: eth_getTransactionReceipt failed: %v", txid, err) + return nil, nil + } + if receipt.BlockHash == "" { + // No receipt: not mined - pending or unknown to the backend - so nothing to recover. + return nil, nil + } + // Fast path: fetch the single tx by (blockHash, index) - an O(1), ~900x smaller lookup + // than scanning the whole block body. transactionIndex is already a hex quantity. + tx := &bchain.RpcTransaction{} + err := b.callContextWithTimeout(tx, "eth_getTransactionByBlockHashAndIndex", ethcommon.HexToHash(receipt.BlockHash), receipt.TransactionIndex) + if err == nil && strings.EqualFold(tx.Hash, txid) { + return tx, &receipt.RpcReceipt + } + if err != nil { + glog.Warningf("recoverMinedTransaction %s: eth_getTransactionByBlockHashAndIndex %s/%s failed, falling back to block body: %v", txid, receipt.BlockHash, receipt.TransactionIndex, err) + } + // Fallback for backends that prune tx-by-hash but do not serve the positional lookup (or + // returned an empty/mismatched result): scan the block body, as before the optimization. + if scanned := b.txFromBlockBody(txid, receipt.BlockHash); scanned != nil { + return scanned, &receipt.RpcReceipt + } + glog.Warningf("recoverMinedTransaction %s: not recoverable from block %s (index %s)", txid, receipt.BlockHash, receipt.TransactionIndex) + return nil, nil +} + // 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) @@ -1699,9 +2069,25 @@ func (b *EthereumRPC) GetTransaction(txid string) (*bchain.Tx, error) { return nil, err } } + // recoveredReceipt is set only when the transaction was reconstructed via the pruned-index + // fallback below; the mined branch reuses it instead of fetching the receipt again. + var recoveredReceipt *bchain.RpcReceipt if *tx == (bchain.RpcTransaction{}) { - b.removeTransactionFromMempool(txid) - return nil, bchain.ErrTxNotFound + // eth_getTransactionByHash returned null. Some archive backends (observed on + // QuikNode Base) prune the transaction-by-hash index beyond a recent window + // while still serving block bodies and receipts, so a mined transaction older + // than that window is invisible to this call even though it is fully retained. + // Recover it from its receipt (which carries the block hash and index) before + // treating it as not found. + // A null is not evidence the transaction is gone (queued sub-pool, another node behind a + // balancer, private relay), so it must not evict the mempool entry; mined nonces and the + // timeout retire entries instead (#1709). + if recovered, receipt := b.recoverMinedTransaction(txid); recovered != nil { + tx = recovered + recoveredReceipt = receipt + } else { + return nil, bchain.ErrTxNotFound + } } var btx *bchain.Tx if tx.BlockNumber == "" { @@ -1728,9 +2114,13 @@ func (b *EthereumRPC) GetTransaction(txid string) (*bchain.Tx, error) { return nil, errors.Annotatef(err, "txid %v", txid) } tx.BaseFeePerGas = ht.BaseFeePerGas - receipt, err := b.EthereumTypeGetTransactionReceipt(txid) - if err != nil { - return nil, errors.Annotatef(err, "txid %v", txid) + // Reuse the receipt already fetched during pruned-index recovery; otherwise fetch it. + receipt := recoveredReceipt + if receipt == nil { + receipt, err = b.EthereumTypeGetTransactionReceipt(txid) + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } } n, err := ethNumber(tx.BlockNumber) if err != nil { @@ -1811,8 +2201,7 @@ 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) - defer cancel() + started := time.Now() msg := ethereum.CallMsg{} if s, ok := GetStringFromMap("from", params); ok && len(s) > 0 { msg.From = ethcommon.HexToAddress(s) @@ -1834,31 +2223,227 @@ func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (ui msg.GasPrice, _ = hexutil.DecodeBig(s) } - if b.alternativeSendTxProvider != nil { + // Route eth_estimateGas through the provider when the request declares in-flight private txs for + // the sender, or when useForNonces says it sent privately through this instance recently. Both + // mean the estimate must run against pending private state the primary RPC cannot see - a + // privately submitted approve that a following swap's gas depends on. The declared signal does + // not depend on this instance having accepted the send, so it covers the restart and + // load-balanced-replica gaps the heuristic leaves. Every other estimate - the bulk of them, since + // the wallet calls estimateFee on each send-form keystroke - goes straight to the primary and no + // longer burns the provider's quota (#1629); so does a missing `from`. Unlike a declared nonce, + // which joins the answer's floor walk, a declared estimate only picks the backend: Blockbook still + // simulates. + // The hint is an unauthenticated per-request client signal: it can route only the caller's own + // request to the relay and never touches shared state (see docs/evm-send.md, "Trust boundary"). + declaredPrivatePending := estimatePrivatePendingDeclared(params) + if b.alternativeSendTxProvider != nil && msg.From != (ethcommon.Address{}) && + (declaredPrivatePending || b.alternativeSendTxProvider.useForNonces(msg.From)) { result, err := b.alternativeSendTxProvider.callHttpStringResult( - b.alternativeSendTxProvider.urls[0], + b.alternativeSendTxProvider.nonceURL(msg.From), "eth_estimateGas", - params, + estimateParamsWithoutPrivatePending(params), ) if err == nil { - return hexutil.DecodeUint64(result) + // Count success only once the result decodes: a malformed hex quantity is a provider + // failure, so fall through to the primary rather than return the decode error to the caller. + if gas, decErr := hexutil.DecodeUint64(result); decErr == nil { + b.observeAlternativeEstimateGasRequest("success") + return gas, nil + } else { + err = decErr + } } - } + // Logged rather than swallowed: a silent provider error hides quota exhaustion (#1629). + b.observeAlternativeEstimateGasRequest("error") + glog.Warningf("Alternative provider failed for eth_estimateGas: %v, falling back to primary RPC", err) + } + // Deadline built here rather than at function entry: the provider round-trip above runs on its own + // context and a slow or rate-limited relay can burn most of b.Timeout, so a context created at entry + // could already be expired by the time the fallback reaches the healthy primary (#1629). What it + // gets is the rest of the request's budget, not a fresh one - both legs are configured from + // rpc_timeout, so a full one let a blackholing relay double the request's wall time and hold a + // websocket pending-request slot for twice as long, on the endpoint the wallet calls per keystroke. + ctx, cancel := context.WithTimeout(context.Background(), b.remainingEstimateTimeout(started)) + defer cancel() return b.Client.EstimateGas(ctx, msg) } +// minEstimateFallbackTimeout is what the primary estimate keeps even when the relay leg consumed the +// whole request budget - clamped to the budget itself for coins whose rpc_timeout sits below it (see +// remainingEstimateTimeout). Without a floor a slow relay would leave the fallback pre-expired, which +// is the failure the deadline placement above exists to avoid. +const minEstimateFallbackTimeout = 5 * time.Second + +// remainingEstimateTimeout returns what is left of the request's budget since started, floored at +// minEstimateFallbackTimeout - clamped to the budget itself, so a coin configured with rpc_timeout +// below the floor is not handed a fallback larger than its whole budget (at rpc_timeout 2s an +// unclamped floor let the request run 3.5x its budget). +func (b *EthereumRPC) remainingEstimateTimeout(started time.Time) time.Duration { + floor := minEstimateFallbackTimeout + if b.Timeout < floor { + floor = b.Timeout + } + if remaining := b.Timeout - time.Since(started); remaining > floor { + return remaining + } + + return floor +} + +// observeAlternativeEstimateGasRequest records an eth_estimateGas call routed to the alternative +// send-tx provider, labeled success (provider answered) or error (fell back to the primary RPC). Only +// senders that declared private-pending state or sent privately recently (see useForNonces) are routed +// here, a gated subset of estimateFee traffic. +func (b *EthereumRPC) observeAlternativeEstimateGasRequest(result string) { + if b.metrics == nil || b.metrics.EthAlternativeEstimateGasRequests == nil { + return + } + b.metrics.EthAlternativeEstimateGasRequests.With(common.Labels{"result": result}).Inc() +} + +// estimatePrivatePendingDeclared reports whether an estimateFee request declared in-flight private +// transactions for the sender via privatePending.nonces in its specific params (see +// server.WsPrivatePending). It is only a routing signal - unlike a nonce, the wallet cannot compute +// gas itself, so Blockbook must still simulate the call; the declaration just says "estimate against +// the relay's pending-private state". Only presence matters, not the values. params is a decoded +// JSON object, so nested objects/arrays are map[string]interface{} / []interface{}. +func estimatePrivatePendingDeclared(params map[string]interface{}) bool { + pp, ok := params["privatePending"].(map[string]interface{}) + if !ok { + return false + } + nonces, ok := pp["nonces"].([]interface{}) + return ok && len(nonces) > 0 +} + +// estimateParamsWithoutPrivatePending returns params with the privatePending key removed, so the +// wallet's bookkeeping is not forwarded as part of the eth_estimateGas call object. It copies only +// when the key is present, so the common (no-hint) path is zero-cost and never mutates the caller's +// map. +func estimateParamsWithoutPrivatePending(params map[string]interface{}) map[string]interface{} { + if _, ok := params["privatePending"]; !ok { + return params + } + out := make(map[string]interface{}, len(params)) + for k, v := range params { + if k == "privatePending" { + continue + } + out[k] = v + } + return out +} + +// bigIntToFloat converts a wei amount to float64 for gauge export. float64 holds integers +// exactly up to 2^53 (~9e15 wei), far above any realistic gas price, so no precision is lost; +// keeping the metric in raw wei (base units) matches the repo convention and Grafana divides +// by 1e9 to display Gwei. +func bigIntToFloat(v *big.Int) float64 { + if v == nil { + return 0 + } + f, _ := new(big.Float).SetInt(v).Float64() + return f +} + +// observeEip1559Fees records the EIP-1559 fees the pull path just produced: per-tier +// maxFeePerGas/maxPriorityFeePerGas and the underlying next-block base fee. Called only on the +// two successful return paths (provider cache hit and on-chain estimate) so the gauges never +// carry zeros from the error/disabled returns. Nil-guards mirror observeRequest. +func (b *EthereumRPC) observeEip1559Fees(fees *bchain.Eip1559Fees) { + if b.metrics == nil || fees == nil { + return + } + if b.metrics.EthEip1559BaseFee != nil && fees.BaseFeePerGas != nil { + b.metrics.EthEip1559BaseFee.Set(bigIntToFloat(fees.BaseFeePerGas)) + } + if b.metrics.EthEip1559Fee == nil { + return + } + for _, t := range []struct { + tier string + fee *bchain.Eip1559Fee + }{ + {"low", fees.Low}, {"medium", fees.Medium}, {"high", fees.High}, {"instant", fees.Instant}, + } { + if t.fee == nil { + continue + } + if t.fee.MaxFeePerGas != nil { + b.metrics.EthEip1559Fee.With(common.Labels{"tier": t.tier, "kind": "max_fee"}).Set(bigIntToFloat(t.fee.MaxFeePerGas)) + } + if t.fee.MaxPriorityFeePerGas != nil { + b.metrics.EthEip1559Fee.With(common.Labels{"tier": t.tier, "kind": "priority_fee"}).Set(bigIntToFloat(t.fee.MaxPriorityFeePerGas)) + } + } +} + +// observeEip1559FeeSource records which source served a pull-path estimate, observed at the serve +// boundary: the alternative provider cache (provider), the on-chain estimate after a stale/unready +// provider (onchain_fallback), or the on-chain estimate with no provider configured (onchain). +func (b *EthereumRPC) observeEip1559FeeSource(source string) { + if b.metrics == nil || b.metrics.EthEip1559FeeSource == nil { + return + } + b.metrics.EthEip1559FeeSource.With(common.Labels{"source": source}).Inc() +} + +// observeAlternativeNonceRequest records an eth_getTransactionCount lookup routed to the alternative +// send-tx provider, labeled by result: success (provider answered) or error (provider failed and the +// lookup fell back to the primary RPC). Only requests that declared private-pending state or whose +// sender sent privately recently (see useForNonces) are routed here, a gated subset rather than every +// address request. +func (b *EthereumRPC) observeAlternativeNonceRequest(result string) { + if b.metrics == nil || b.metrics.EthAlternativeNonceRequests == nil { + return + } + b.metrics.EthAlternativeNonceRequests.With(common.Labels{"result": result}).Inc() +} + +// observePendingFloorRaised records that raiseToPendingFloor lifted a getTransactionCount answer above +// the backend's own pending nonce, by source: provider (the relay's pending count lagged an in-flight +// tx - near zero now that the relay counts over its whole window, a sustained rate means it regressed) +// or primary (the fallback RPC never knew the private tx - expected while a private send is in flight, +// not a fault). +func (b *EthereumRPC) observePendingFloorRaised(source string) { + if b.metrics == nil || b.metrics.EthAlternativePendingFloorRaised == nil { + return + } + b.metrics.EthAlternativePendingFloorRaised.With(common.Labels{"source": source}).Inc() +} + +// observePendingFloorStranded records that the cache held a private tx for a nonce ABOVE the contiguous +// run the floor could advance over: a slot below it is filled by nothing Blockbook knows of, and the +// floor deliberately stops under that hole (see raiseToPendingFloor), so the tx cannot mine until the +// hole is filled. Source labels as in observePendingFloorRaised; a sustained provider-source rate is the +// actionable #1675 signal that relay-accepted sends are going missing from the cache. +func (b *EthereumRPC) observePendingFloorStranded(source string) { + if b.metrics == nil || b.metrics.EthAlternativePendingFloorStranded == nil { + return + } + b.metrics.EthAlternativePendingFloorStranded.With(common.Labels{"source": source}).Inc() +} + +// eip1559BaseFeeMultiplier is the headroom applied to the projected base fee when deriving +// maxFeePerGas for the on-chain EIP-1559 estimate (maxFeePerGas = multiplier*baseFee + tip). +// 2x is the EIP-1559-standard buffer: it keeps a transaction mineable across ~6 consecutive full +// blocks, since the base fee can rise at most 12.5% per block (1.125^6 ≈ 2). Tunable. +const eip1559BaseFeeMultiplier = 2 + // 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 there is an alternative provider, use it (fetches on demand, cached for periodSeconds) if b.alternativeFeeProvider != nil { fees, err := b.alternativeFeeProvider.GetEip1559Fees() if err != nil { return nil, err } if fees != nil { + b.observeEip1559FeeSource("provider") + b.observeEip1559Fees(fees) return fees, nil } // Fall back to on-chain estimation when the alternative provider is unsupported/stale/unready, @@ -1869,12 +2454,6 @@ func (b *EthereumRPC) EthereumTypeGetEip1559Fees() (*bchain.Eip1559Fees, error) 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 { @@ -1892,7 +2471,7 @@ func (b *EthereumRPC) EthereumTypeGetEip1559Fees() (*bchain.Eip1559Fees, error) } blocks := 4 - err = b.RPC.CallContext(ctx, &h, "eth_feeHistory", blocks, "pending", percentiles) + err := b.RPC.CallContext(ctx, &h, "eth_feeHistory", blocks, "pending", percentiles) if err != nil { return nil, err } @@ -1902,22 +2481,53 @@ func (b *EthereumRPC) EthereumTypeGetEip1559Fees() (*bchain.Eip1559Fees, error) 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) + // SetUint64, not big.NewInt(int64(...)): base fee is wei and a value above math.MaxInt64 + // (~9.22e18) would wrap negative on the int64 cast. Unreachable on mainnet today but possible + // on high-fee L2s. Matches the header path in attachBlockGas. + fees.BaseFeePerGas = new(big.Int).SetUint64(baseFee) + // We expose only baseFeePerGas here and deliberately do NOT add a separate "next block" base-fee + // field. eth_feeHistory returns one extra projected element beyond the requested range, but its + // meaning is backend-dependent: nodes with no distinct pending block (e.g. Erigon, which ethereum + // mainnet uses) drop the extra element, so baseFeePerGas[blocks-1] is already the next block's + // projected fee; other backends (some L2s) keep it and shift the indices, making the extra element + // either N+1 or an N+2 estimate computed off an incomplete pending block. No single field can + // describe all of these. Clients that need an exact next-block base fee should use the + // subscribeNewBlock evmData push, which carries the real previous-block header. glog.Info("eth_feeHistory ", string(hs)) for i := 0; i < 4; i++ { var f bchain.Eip1559Fee + // Per-tier tip: average of the requested reward percentile (low=20th .. instant=99th) over the window. + // A compliant eth_feeHistory row has one reward per requested percentile, but guard the column index + // so a non-conforming backend returning a short row skips that row instead of panicking; the divisor + // counts only the rows actually summed so skipped rows don't deflate the average. priorityFee := int64(0) + rows := int64(0) for j := 0; j < len(h.Reward); j++ { + if len(h.Reward[j]) <= i { + continue + } p, _ := hexutil.DecodeUint64(h.Reward[j][i]) priorityFee += int64(p) + rows++ } - priorityFee = priorityFee / int64(len(h.Reward)) - f.MaxFeePerGas = big.NewInt(priorityFee) - f.MaxPriorityFeePerGas = big.NewInt(maxBasePriorityFee) - maxBasePriorityFee *= 2 + if rows > 0 { + priorityFee /= rows + } + // A zero tip is a deliberate, accepted outcome on idle chains: when eth_feeHistory reports empty or + // all-zero reward percentiles (quiet testnets such as Sepolia, or a backend that omits + // rewards) there is no priority competition to price, so maxPriorityFeePerGas is 0. maxFeePerGas + // still covers eip1559BaseFeeMultiplier*baseFee below, so the tx stays mineable. + tip := big.NewInt(priorityFee) + f.MaxPriorityFeePerGas = tip + // maxFeePerGas must cover the next-block base fee plus the tip, with headroom for base-fee + // growth while the tx waits: maxFeePerGas = eip1559BaseFeeMultiplier*baseFee + tip. The previous + // code put only the tip here (omitting the base fee), which is below the base fee and therefore + // not mineable; clients such as Trezor Suite use maxFeePerGas directly. + f.MaxFeePerGas = new(big.Int).Add( + new(big.Int).Mul(fees.BaseFeePerGas, big.NewInt(eip1559BaseFeeMultiplier)), + tip, + ) switch i { case 0: fees.Low = &f @@ -1929,6 +2539,14 @@ func (b *EthereumRPC) EthereumTypeGetEip1559Fees() (*bchain.Eip1559Fees, error) fees.Instant = &f } } + // Reaching here with a provider configured means its cache was stale/unready (a hit would have + // returned above), so this on-chain estimate is a fallback. + source := "onchain" + if b.alternativeFeeProvider != nil { + source = "onchain_fallback" + } + b.observeEip1559FeeSource(source) + b.observeEip1559Fees(&fees) return &fees, err } @@ -1940,19 +2558,55 @@ func (b *EthereumRPC) SendRawTransaction(hex string, disableAlternativeRPC bool) if !disableAlternativeRPC && b.alternativeSendTxProvider != nil { txid, retErr = b.alternativeSendTxProvider.SendRawTransaction(hex) if retErr == nil { + b.observeSendTxPath("alternative", nil) return txid, nil } if b.alternativeSendTxProvider.UseOnlyAlternativeProvider() { + b.observeSendTxPath("alternative", retErr) return txid, retErr } } + // decoded once for the log lines - a send is the only moment Blockbook sees the sender and + // nonce of a transaction it has not indexed yet, and both are what a later "my tx vanished" + // or nonce-gap report has to be reconciled against + decoded := decodeRawTx(hex) + // retErr can only have been set by the alternative providers above, so a non-nil one here means + // they all rejected and the transaction is about to go to the public mempool although the + // deployment prefers a private relay - a switch of destination that is otherwise invisible + path := "primary" + if retErr != nil { + path = "primary_fallback" + glog.Warningf("eth_sendRawTransaction falling back to the primary RPC, %s, alternative providers error: %v", decoded, retErr) + } + + start := time.Now() txid, retErr = b.callRpcStringResult("eth_sendRawTransaction", hex) + duration := time.Since(start).Round(time.Millisecond) + b.observeSendTxPath(path, retErr) + if retErr != nil { + glog.Errorf("eth_sendRawTransaction to the primary RPC rejected %s after %v: %v", decoded, duration, retErr) + return txid, retErr + } + glog.Infof("eth_sendRawTransaction to the primary RPC accepted %s as txid %s in %v", decoded, txid, duration) if b.ChainConfig.DisableMempoolSync { - // add transactions submitted by us to mempool if sync is disabled + // With no newPendingTransactions feed this add is the only thing that makes an own send visible + // for its addresses. Reached only on success (the early return above): an empty txid indexed the zero hash instead, spending two + // more b.Timeout round-trips (eth_getTransactionByHash, then the pruned-index recovery's + // eth_getTransactionReceipt) on a path where the wallet has already waited out two. b.Mempool.AddTransactionToMempool(txid) } - return txid, retErr + return txid, nil +} + +// observeSendTxPath records which broadcast path served a send and how it ended. The path is the +// piece the coin-agnostic send metric cannot see, and it is what separates "private broadcast +// working" from "transactions quietly leaking to the public mempool" (send_path=primary_fallback). +func (b *EthereumRPC) observeSendTxPath(path string, err error) { + if b.metrics == nil || b.metrics.EthSendTxPath == nil { + return + } + b.metrics.EthSendTxPath.With(common.Labels{"send_path": path, "status": bchain.SendTxStatus(err)}).Inc() } // EthereumTypeGetRawTransaction gets raw transaction in hex format @@ -2000,6 +2654,18 @@ func (b *EthereumRPC) EthereumTypeGetBalance(addrDesc bchain.AddressDescriptor) // EthereumTypeGetNonces returns the pending account nonce and, only when withConfirmed // is set, the confirmed (latest) nonce. // +// When an alternative send-tx provider is configured, the lookup is routed through it only +// for requests that declare in-flight private transactions (privatePendingNonces, see +// server.WsPrivatePending) and for addresses that recently sent a transaction via that +// provider (see useForNonces) — both may have a pending transaction the primary RPC does +// not know about. All other addresses go straight to the primary RPC so that the hottest +// API endpoint does not burn the provider's rate-limit quota. Whenever a provider is +// configured, the pending answer - whether from the provider or from the primary RPC - is +// advanced across the contiguous run of nonce slots held by cached private transactions +// and by the declared nonces (see raiseToPendingFloor), so it never contradicts Blockbook's +// own pending view and never runs past a slot nothing fills: +// pending <= answer <= pending + cached + declared. +// // 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 @@ -2010,15 +2676,42 @@ func (b *EthereumRPC) EthereumTypeGetBalance(addrDesc bchain.AddressDescriptor) // 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) { +func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { ethAddress := ethcommon.BytesToAddress(addrDesc) + // The caller may declare the account nonces of its own in-flight private transactions (see + // server.WsPrivatePending). They join the cached ones in the floor below; their presence alone + // is what routes the lookup, so no separate declared floor is computed. + declared := len(privatePendingNonces) > 0 if b.alternativeSendTxProvider != nil { - pending, confirmed, confirmedOK, err := b.alternativeSendTxProvider.getNonces(ethAddress, withConfirmed) - if err == nil { - return pending, confirmed, confirmedOK, nil + // Route to the provider when the caller declared in-flight private txs - a wallet knows its + // own submitted nonces authoritatively, where the recentSenders heuristic is defeated by a + // restart or by a load-balanced replica that did not accept the send - or when that + // heuristic says this sender sent privately through this instance recently. Replacing the + // primary's answer is safe because the relay's pending count is a public-mempool superset + // (see docs/evm-send.md). + if declared || b.alternativeSendTxProvider.useForNonces(ethAddress) { + pending, confirmed, confirmedOK, err := b.alternativeSendTxProvider.getNonces(ethAddress, withConfirmed) + if err == nil { + b.observeAlternativeNonceRequest("success") + // Even the provider's own answer can fall below Blockbook's advertised pending view. + // The relay counts a pending tx over the whole pending window, so this floor is a + // safety net for a lagging or misbehaving relay node rather than the routine case it + // was before that alignment; a sustained floor_raised{source=provider} rate means the + // relay's pending count regressed below its window again. A declared nonce is folded + // in as the same safety net, for a transaction the answering node never saw. + raised, stranded := b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending, privatePendingNonces) + if raised > pending { + b.observePendingFloorRaised("provider") + } + if stranded { + b.observePendingFloorStranded("provider") + } + return raised, confirmed, confirmedOK, nil + } + b.observeAlternativeNonceRequest("error") + glog.Warningf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err) } - glog.Errorf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err) } pending, confirmed, confirmedOK, err := b.getNoncesRPC(ethAddress, withConfirmed) @@ -2026,6 +2719,24 @@ func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, w glog.Errorf("Primary RPC failed for eth_getTransactionCount: %v", err) return 0, 0, false, err } + if b.alternativeSendTxProvider != nil { + // Applied whenever a provider is configured, not only for gated senders: the routing + // entry expires at send time + timeout while the cached tx stays exposed as pending + // until fetch-back time + timeout (plus reconcile granularity), and in that window a + // primary answer below the floor would contradict the pending tx Blockbook still + // displays. The scan is over the private sends still pending for the whole window, and + // decodes nothing per entry (see storedTx.slot), so it stays cheap on this hot path. + // Declared nonces are folded in for the same reason. Without a provider there is no + // private mempool to contradict, so a declared nonce is deliberately a no-op. + raised, stranded := b.alternativeSendTxProvider.raiseToPendingFloor(ethAddress, pending, privatePendingNonces) + if raised > pending { + b.observePendingFloorRaised("primary") + } + if stranded { + b.observePendingFloorStranded("primary") + } + pending = raised + } return pending, confirmed, confirmedOK, nil } @@ -2112,183 +2823,3 @@ func decodeConfirmedNonce(addr ethcommon.Address, confirmedHex string, lookupErr 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_blocknotfound_test.go b/bchain/coins/eth/ethrpc_blocknotfound_test.go new file mode 100644 index 0000000000..8fa6b60f0c --- /dev/null +++ b/bchain/coins/eth/ethrpc_blocknotfound_test.go @@ -0,0 +1,68 @@ +//go:build unittest + +package eth + +import ( + "context" + stdErrors "errors" + "strings" + "testing" + "time" + + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" +) + +// mockBlockRawRPC returns a fixed error from CallContext, standing in for a backend +// that rejects a tip read (Avalanche's "cannot query unfinalized data" is mapped to +// bchain.ErrBlockNotFound by the avalanche client before it reaches getBlockRaw). +type mockBlockRawRPC struct { + err error +} + +func (m *mockBlockRawRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, stdErrors.New("not implemented") +} + +func (m *mockBlockRawRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + return m.err +} + +func (m *mockBlockRawRPC) Close() {} + +// Documents why getBlockRaw must not annotate a sentinel: the juju/errors pin has no +// Unwrap, so Annotatef is an errors.Is barrier. If this ever starts passing, juju was +// upgraded and the special case in getBlockRaw can be dropped. +func TestJujuAnnotatefHidesSentinelFromErrorsIs(t *testing.T) { + annotated := errors.Annotatef(bchain.ErrBlockNotFound, "hash %v, height %v", "", 1) + if stdErrors.Is(annotated, bchain.ErrBlockNotFound) { + t.Fatal("Annotatef now preserves errors.Is; drop the sentinel special case in getBlockRaw") + } +} + +func TestGetBlockRawKeepsBlockNotFoundUnwrappable(t *testing.T) { + b := &EthereumRPC{ + RPC: &mockBlockRawRPC{err: bchain.ErrBlockNotFound}, + Timeout: time.Second, + } + + _, err := b.getBlockRaw("", 93244979, true) + if !stdErrors.Is(err, bchain.ErrBlockNotFound) { + t.Fatalf("getBlockRaw error = %v, want it to satisfy errors.Is(ErrBlockNotFound)", err) + } +} + +func TestGetBlockRawAnnotatesOtherErrors(t *testing.T) { + b := &EthereumRPC{ + RPC: &mockBlockRawRPC{err: stdErrors.New("boom")}, + Timeout: time.Second, + } + + _, err := b.getBlockRaw("", 93244979, true) + if err == nil { + t.Fatal("getBlockRaw error = nil, want an annotated error") + } + if !strings.Contains(err.Error(), "height 93244979") { + t.Fatalf("getBlockRaw error = %q, want it annotated with the height", err.Error()) + } +} diff --git a/bchain/coins/eth/ethrpc_confirmations_test.go b/bchain/coins/eth/ethrpc_confirmations_test.go new file mode 100644 index 0000000000..f8230c5eea --- /dev/null +++ b/bchain/coins/eth/ethrpc_confirmations_test.go @@ -0,0 +1,33 @@ +package eth + +import "testing" + +// The cached tip is advanced by the newHeads subscription and the watchdog poll, while the +// block height passed in is read straight from the backend, so the height can be ahead of +// the tip. That must not underflow the unsigned subtraction into a bogus finality count. +func TestComputeConfirmations_TipBehindHeight(t *testing.T) { + const tip = 18000000 + tests := []struct { + name string + height uint64 + want uint32 + }{ + {"height below tip", tip - 1, 2}, + {"height at tip", tip, 1}, + {"tip behind by 1 block", tip + 1, 1}, + {"tip behind by 2 blocks", tip + 2, 1}, + {"tip behind by many blocks", tip + 300, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &EthereumRPC{bestHeader: stubHeader{n: tip}} + got, err := b.computeConfirmations(tt.height) + if err != nil { + t.Fatalf("computeConfirmations(%d) error %v", tt.height, err) + } + if got != tt.want { + t.Errorf("computeConfirmations(%d) with tip %d = %d, want %d", tt.height, tip, got, tt.want) + } + }) + } +} diff --git a/bchain/coins/eth/ethrpc_estimate_gas_hint_test.go b/bchain/coins/eth/ethrpc_estimate_gas_hint_test.go new file mode 100644 index 0000000000..c06fed67d7 --- /dev/null +++ b/bchain/coins/eth/ethrpc_estimate_gas_hint_test.go @@ -0,0 +1,122 @@ +package eth + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "sync/atomic" + "testing" +) + +// TestEthereumTypeEstimateGasRoutesOnPrivatePendingHint verifies the declared privatePending hint +// short-circuits routing: a sender that is NOT a recent private sender (so useForNonces is false and +// the estimate would otherwise go to the primary backend) is routed to the alternative provider +// purely because the request declared an in-flight private nonce. +func TestEthereumTypeEstimateGasRoutesOnPrivatePendingHint(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + provider, providerHits := countingEstimateGasServer(t, "0x9999") + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{ + "from": "0x2222222222222222222222222222222222222222", + "to": "0x3333333333333333333333333333333333333333", + "privatePending": map[string]interface{}{"nonces": []interface{}{float64(42)}}, + }) + if err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gas != 0x9999 { + t.Fatalf("gas = %#x, want 0x9999 (provider value)", gas) + } + if got := atomic.LoadInt32(providerHits); got != 1 { + t.Errorf("provider hits = %d, want 1 (hint must route despite no recent send)", got) + } + if got := atomic.LoadInt32(primaryHits); got != 0 { + t.Errorf("primary hits = %d, want 0", got) + } +} + +// TestEthereumTypeEstimateGasHintStripsPrivatePendingFromRelayCall confirms the wallet's +// privatePending bookkeeping is not forwarded as part of the eth_estimateGas call object sent to the +// relay - only the real tx-call fields (from/to/…) are. +func TestEthereumTypeEstimateGasHintStripsPrivatePendingFromRelayCall(t *testing.T) { + primary, _ := countingEstimateGasServer(t, "0x5208") + + var gotParams map[string]interface{} + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + Params []map[string]interface{} `json:"params"` + } + _ = json.Unmarshal(body, &req) + if len(req.Params) > 0 { + gotParams = req.Params[0] + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x9999"}`)) + })) + t.Cleanup(provider.Close) + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + if _, err := b.EthereumTypeEstimateGas(map[string]interface{}{ + "from": "0x2222222222222222222222222222222222222222", + "privatePending": map[string]interface{}{"nonces": []interface{}{float64(42)}, "txids": []interface{}{"0xdead"}}, + }); err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gotParams == nil { + t.Fatal("provider was not called") + } + if _, present := gotParams["privatePending"]; present { + t.Errorf("privatePending was forwarded to eth_estimateGas: %v", gotParams) + } + if gotParams["from"] != "0x2222222222222222222222222222222222222222" { + t.Errorf("from not forwarded to the relay call: %v", gotParams["from"]) + } +} + +func TestEstimatePrivatePendingDeclared(t *testing.T) { + cases := []struct { + name string + params map[string]interface{} + want bool + }{ + {"absent", map[string]interface{}{"from": "0x1"}, false}, + {"empty object", map[string]interface{}{"privatePending": map[string]interface{}{}}, false}, + {"empty nonces", map[string]interface{}{"privatePending": map[string]interface{}{"nonces": []interface{}{}}}, false}, + {"declared", map[string]interface{}{"privatePending": map[string]interface{}{"nonces": []interface{}{float64(42)}}}, true}, + {"wrong type", map[string]interface{}{"privatePending": "nope"}, false}, + {"nonces wrong type", map[string]interface{}{"privatePending": map[string]interface{}{"nonces": "nope"}}, false}, + } + for _, c := range cases { + if got := estimatePrivatePendingDeclared(c.params); got != c.want { + t.Errorf("%s: estimatePrivatePendingDeclared = %v, want %v", c.name, got, c.want) + } + } +} + +func TestEstimateParamsWithoutPrivatePending(t *testing.T) { + // absent: returns the same map, no copy + noHint := map[string]interface{}{"from": "0x1", "to": "0x2"} + if got := estimateParamsWithoutPrivatePending(noHint); !reflect.DeepEqual(got, noHint) { + t.Errorf("no-hint result = %v, want unchanged %v", got, noHint) + } + + // present: privatePending removed, other fields kept, input not mutated + withHint := map[string]interface{}{ + "from": "0x1", + "privatePending": map[string]interface{}{"nonces": []interface{}{float64(1)}}, + } + got := estimateParamsWithoutPrivatePending(withHint) + if _, present := got["privatePending"]; present { + t.Error("privatePending not removed from returned params") + } + if got["from"] != "0x1" { + t.Errorf("from = %v, want 0x1 (other fields must be kept)", got["from"]) + } + if _, present := withHint["privatePending"]; !present { + t.Error("input map was mutated (privatePending removed from caller's map)") + } +} diff --git a/bchain/coins/eth/ethrpc_estimate_gas_test.go b/bchain/coins/eth/ethrpc_estimate_gas_test.go new file mode 100644 index 0000000000..18c9c3aac0 --- /dev/null +++ b/bchain/coins/eth/ethrpc_estimate_gas_test.go @@ -0,0 +1,261 @@ +package eth + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" +) + +// countingEstimateGasServer answers eth_estimateGas with a fixed hex gas value and counts hits, so a +// test can tell which path - provider or primary - was consulted. +func countingEstimateGasServer(t *testing.T, gasHex string) (*httptest.Server, *int32) { + t.Helper() + var hits int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"` + gasHex + `"}`)); err != nil { + t.Errorf("Write() error = %v", err) + } + })) + t.Cleanup(server.Close) + return server, &hits +} + +// newEstimateGasTestRPC points the primary Client at primaryURL and the alternative send-tx provider +// at providerURL, so the routing decision shows up as which server receives the hit. +func newEstimateGasTestRPC(t *testing.T, primaryURL, providerURL string) *EthereumRPC { + t.Helper() + primaryRPC, err := rpc.DialContext(context.Background(), primaryURL) + if err != nil { + t.Fatalf("dial primary: %v", err) + } + t.Cleanup(primaryRPC.Close) + return &EthereumRPC{ + Client: &EthereumClient{Client: ethclient.NewClient(primaryRPC)}, + Timeout: 2 * time.Second, + alternativeSendTxProvider: &AlternativeSendTxProvider{ + urls: []string{providerURL}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: 2 * time.Second, + recentSenders: map[ethcommon.Address]recentSender{}, + }, + } +} + +// TestEthereumTypeEstimateGasSkipsProviderForNonRecentSender is the core of #1629: a sender with no +// recent private transaction must not have its estimate routed to the provider, so the hot estimateFee +// endpoint does not burn the provider's rate-limit quota. +func TestEthereumTypeEstimateGasSkipsProviderForNonRecentSender(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + provider, providerHits := countingEstimateGasServer(t, "0x9999") + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{ + "from": "0x2222222222222222222222222222222222222222", + "to": "0x3333333333333333333333333333333333333333", + }) + if err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gas != 0x5208 { + t.Fatalf("gas = %#x, want 0x5208 (primary backend value)", gas) + } + if got := atomic.LoadInt32(providerHits); got != 0 { + t.Fatalf("provider hits = %d, want 0 (non-recent sender must not touch the provider)", got) + } + if got := atomic.LoadInt32(primaryHits); got != 1 { + t.Fatalf("primary hits = %d, want 1", got) + } +} + +// TestEthereumTypeEstimateGasRoutesRecentSenderToProvider keeps the provider path for the case it +// exists for: a recent private sender goes to the URL that accepted its send (see nonceURL), which +// may know a pending tx the primary does not. +func TestEthereumTypeEstimateGasRoutesRecentSenderToProvider(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + provider, providerHits := countingEstimateGasServer(t, "0x9999") + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + b.alternativeSendTxProvider.recentSenders[sender] = recentSender{ + time: time.Now(), + url: provider.URL, + gen: 1, + } + + gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{ + "from": sender.Hex(), + "to": "0x3333333333333333333333333333333333333333", + }) + if err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gas != 0x9999 { + t.Fatalf("gas = %#x, want 0x9999 (provider value)", gas) + } + if got := atomic.LoadInt32(providerHits); got != 1 { + t.Fatalf("provider hits = %d, want 1 (recent sender must be routed to the provider)", got) + } + if got := atomic.LoadInt32(primaryHits); got != 0 { + t.Fatalf("primary hits = %d, want 0", got) + } +} + +// TestEthereumTypeEstimateGasFallsBackWhenProviderFails pins that a provider error is not fatal to the +// estimate. +func TestEthereumTypeEstimateGasFallsBackWhenProviderFails(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + // a provider server that always errors the JSON-RPC call + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"rate limited"}}`)) + })) + t.Cleanup(provider.Close) + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + b.alternativeSendTxProvider.recentSenders[sender] = recentSender{time: time.Now(), url: provider.URL, gen: 1} + + gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{"from": sender.Hex()}) + if err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gas != 0x5208 { + t.Fatalf("gas = %#x, want 0x5208 (primary fallback value)", gas) + } + if got := atomic.LoadInt32(primaryHits); got != 1 { + t.Fatalf("primary hits = %d, want 1 (must fall back after provider error)", got) + } +} + +// TestEthereumTypeEstimateGasFallsBackWhenProviderReturnsMalformedResult treats a successful response +// carrying a non-decodable gas value as a provider failure: fall back, do not surface the decode error. +func TestEthereumTypeEstimateGasFallsBackWhenProviderReturnsMalformedResult(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + // a provider that returns a non-hex string result for eth_estimateGas + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"not-a-hex-quantity"}`)) + })) + t.Cleanup(provider.Close) + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + sender := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + b.alternativeSendTxProvider.recentSenders[sender] = recentSender{time: time.Now(), url: provider.URL, gen: 1} + + gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{"from": sender.Hex()}) + if err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v, want fallback to primary", err) + } + if gas != 0x5208 { + t.Fatalf("gas = %#x, want 0x5208 (primary fallback value)", gas) + } + if got := atomic.LoadInt32(primaryHits); got != 1 { + t.Fatalf("primary hits = %d, want 1 (malformed provider result must fall back)", got) + } +} + +// TestEthereumTypeEstimateGasNoFromUsesPrimary: without a from address the gate cannot apply, so the +// estimate takes the primary path. +func TestEthereumTypeEstimateGasNoFromUsesPrimary(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + provider, providerHits := countingEstimateGasServer(t, "0x9999") + b := newEstimateGasTestRPC(t, primary.URL, provider.URL) + + if _, err := b.EthereumTypeEstimateGas(map[string]interface{}{ + "to": "0x3333333333333333333333333333333333333333", + }); err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if got := atomic.LoadInt32(providerHits); got != 0 { + t.Fatalf("provider hits = %d, want 0", got) + } + if got := atomic.LoadInt32(primaryHits); got != 1 { + t.Fatalf("primary hits = %d, want 1", got) + } +} + +// TestEthereumTypeEstimateGasBoundsFallbackToRequestBudget pins that a slow relay cannot double the +// request's wall time. Both legs are configured from rpc_timeout, so a fallback given a fresh full +// budget makes the worst case 2x - and on this endpoint that holds a websocket pending-request slot +// for twice as long, per send-form keystroke, exactly while the relay is already degraded. +// +// The two existing fallback tests use instant-error providers, so they pass whether the deadline is +// built at function entry or after the provider leg. This one needs a provider that is slow rather +// than broken. +func TestEthereumTypeEstimateGasBoundsFallbackToRequestBudget(t *testing.T) { + primary, primaryHits := countingEstimateGasServer(t, "0x5208") + slowProvider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) // outlives the provider leg's own timeout below + })) + t.Cleanup(slowProvider.Close) + + b := newEstimateGasTestRPC(t, primary.URL, slowProvider.URL) + b.Timeout = 200 * time.Millisecond + b.alternativeSendTxProvider.rpcTimeout = 200 * time.Millisecond + sender := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + b.alternativeSendTxProvider.recentSenders[sender] = recentSender{time: time.Now(), url: slowProvider.URL} + + started := time.Now() + gas, err := b.EthereumTypeEstimateGas(map[string]interface{}{"from": sender.Hex(), "to": sender.Hex()}) + elapsed := time.Since(started) + if err != nil { + t.Fatalf("EthereumTypeEstimateGas() error = %v", err) + } + if gas != 0x5208 { + t.Errorf("gas = %d, want the primary's answer after the slow provider", gas) + } + if atomic.LoadInt32(primaryHits) != 1 { + t.Errorf("primary hits = %d, want 1", atomic.LoadInt32(primaryHits)) + } + // The provider leg burned the whole budget; with b.Timeout below the 5s floor the clamp hands the + // fallback exactly one more budget, so the worst case is two budgets - not budget plus a fixed 5s. + // The primary answers instantly here, so the generous slack only absorbs CI scheduling. + if elapsed > 2*b.Timeout+time.Second { + t.Errorf("elapsed %s exceeds two request budgets of %s plus slack", elapsed, b.Timeout) + } +} + +// TestRemainingEstimateTimeout covers the budget arithmetic directly: what is left of the request, but +// never so little that the fallback is pre-expired. +func TestRemainingEstimateTimeout(t *testing.T) { + b := &EthereumRPC{Timeout: 25 * time.Second} + for _, tt := range []struct { + name string + elapsed time.Duration + wantMin time.Duration + wantMax time.Duration + }{ + {name: "nothing spent yet", elapsed: 0, wantMin: 24 * time.Second, wantMax: 25 * time.Second}, + {name: "half spent", elapsed: 12 * time.Second, wantMin: 12*time.Second - time.Second, wantMax: 13 * time.Second}, + {name: "budget exhausted falls to the floor", elapsed: 25 * time.Second, wantMin: minEstimateFallbackTimeout, wantMax: minEstimateFallbackTimeout}, + {name: "budget overrun still gets the floor", elapsed: time.Minute, wantMin: minEstimateFallbackTimeout, wantMax: minEstimateFallbackTimeout}, + } { + t.Run(tt.name, func(t *testing.T) { + got := b.remainingEstimateTimeout(time.Now().Add(-tt.elapsed)) + if got < tt.wantMin || got > tt.wantMax { + t.Errorf("remainingEstimateTimeout() = %s, want within [%s, %s]", got, tt.wantMin, tt.wantMax) + } + }) + } + + t.Run("floor clamps to a budget below it", func(t *testing.T) { + // With rpc_timeout under the floor, an unclamped floor would hand the fallback more than the + // whole request budget - at 2s the request could run 3.5x its budget. + small := &EthereumRPC{Timeout: 2 * time.Second} + if got := small.remainingEstimateTimeout(time.Now().Add(-2 * time.Second)); got != 2*time.Second { + t.Errorf("remainingEstimateTimeout() = %s, want the whole 2s budget as the clamped floor", got) + } + if got := small.remainingEstimateTimeout(time.Now()); got > 2*time.Second { + t.Errorf("remainingEstimateTimeout() = %s, want at most the 2s budget", got) + } + }) +} diff --git a/bchain/coins/eth/ethrpc_mempool_timeout_test.go b/bchain/coins/eth/ethrpc_mempool_timeout_test.go index afb2bc9e0e..6566cdf3af 100644 --- a/bchain/coins/eth/ethrpc_mempool_timeout_test.go +++ b/bchain/coins/eth/ethrpc_mempool_timeout_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "testing" "time" + + "github.com/trezor/blockbook/bchain" ) func TestConfigurationMempoolTxTimeoutDuration(t *testing.T) { @@ -21,12 +23,31 @@ func TestConfigurationMempoolTxTimeoutDuration(t *testing.T) { want: 12 * time.Hour, }, { - name: "alternative provider default", + name: "alternative provider default is the pending window plus the sweep margin", config: Configuration{ MempoolTxTimeoutHours: 12, }, alternativeProviderEnabled: true, - want: 10 * time.Minute, + want: defaultAlternativePendingTxWindow + mempoolRetentionMarginOverPendingWindow, + }, + { + name: "a configured pending window carries into the mempool default", + config: Configuration{ + MempoolTxTimeoutHours: 12, + AlternativePendingTxWindow: "1h", + }, + alternativeProviderEnabled: true, + want: time.Hour + mempoolRetentionMarginOverPendingWindow, + }, + { + name: "a cache-only override carries into the mempool default too", + config: Configuration{ + MempoolTxTimeoutHours: 12, + AlternativePendingTxWindow: "3h", + AlternativeMempoolTxTimeout: "20m", + }, + alternativeProviderEnabled: true, + want: 20*time.Minute + mempoolRetentionMarginOverPendingWindow, }, { name: "explicit duration overrides alternative provider default", @@ -74,12 +95,20 @@ func TestConfigurationAlternativeMempoolTxTimeoutDuration(t *testing.T) { want time.Duration }{ { - name: "default", - want: 5 * time.Minute, + name: "default is the pending window", + want: defaultAlternativePendingTxWindow, + }, + { + name: "follows a configured pending window", + config: Configuration{ + AlternativePendingTxWindow: "90m", + }, + want: 90 * time.Minute, }, { - name: "explicit duration", + name: "an explicit cache retention overrides the window", config: Configuration{ + AlternativePendingTxWindow: "3h", AlternativeMempoolTxTimeout: "7m", }, want: 7 * time.Minute, @@ -99,6 +128,133 @@ func TestConfigurationAlternativeMempoolTxTimeoutDuration(t *testing.T) { } } +func TestConfigurationAlternativeMissingTxTimeoutDuration(t *testing.T) { + tests := []struct { + name string + config Configuration + want time.Duration + }{ + { + name: "default", + want: defaultAlternativeMissingTxTimeout, + }, + { + name: "explicit missing timeout", + config: Configuration{ + AlternativeMissingTxTimeout: "30m", + }, + want: 30 * time.Minute, + }, + { + // at or above the pending window the cache timeout fires first, which is the documented + // way to restore timeout-only eviction for a relay that does not surface over the window + name: "missing timeout at the pending window disables the early eviction", + config: Configuration{ + AlternativePendingTxWindow: "3h", + AlternativeMissingTxTimeout: "3h", + }, + want: 3 * time.Hour, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.config.AlternativeMissingTxTimeoutDuration() + if err != nil { + t.Fatalf("AlternativeMissingTxTimeoutDuration() error = %v", err) + } + if got != tt.want { + t.Fatalf("AlternativeMissingTxTimeoutDuration() = %s, want %s", got, tt.want) + } + }) + } +} + +// TestMempoolRetentionInverted covers the retention-order check: the provider cache must not outlive +// the wrapped mempool, whose timeout sweep is the only exit that does not clear the cache. Equal +// retentions are the shipped coin-config setting and must stay accepted. +func TestMempoolRetentionInverted(t *testing.T) { + tests := []struct { + name string + alternative time.Duration + mempool time.Duration + want bool + }{ + { + name: "defaults are ordered correctly", + alternative: defaultAlternativePendingTxWindow, + mempool: defaultAlternativePendingTxWindow + mempoolRetentionMarginOverPendingWindow, + want: false, + }, + { + name: "cache outliving the mempool is inverted", + alternative: 30 * time.Minute, + mempool: 10 * time.Minute, + want: true, + }, + { + name: "equal retentions are not inverted", + alternative: 10 * time.Minute, + mempool: 10 * time.Minute, + want: false, + }, + { + name: "a zero mempool retention is inverted", + alternative: 5 * time.Minute, + mempool: 0, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mempoolRetentionInverted(tt.alternative, tt.mempool); got != tt.want { + t.Fatalf("mempoolRetentionInverted(%s, %s) = %v, want %v", tt.alternative, tt.mempool, got, tt.want) + } + }) + } +} + +func TestMempoolRetentionDrifted(t *testing.T) { + tests := []struct { + name string + alternative time.Duration + mempool time.Duration + mempoolExplicit bool + want bool + }{ + { + name: "aligned explicit pair has not drifted", + alternative: 48 * time.Hour, + mempool: 48 * time.Hour, + mempoolExplicit: true, + want: false, + }, + { + name: "explicit mempool retention outgrowing the cache is drift", + alternative: 48 * time.Hour, + mempool: 72 * time.Hour, + mempoolExplicit: true, + want: true, + }, + { + name: "the derived margin over the cache retention is not drift", + alternative: defaultAlternativePendingTxWindow, + mempool: defaultAlternativePendingTxWindow + mempoolRetentionMarginOverPendingWindow, + mempoolExplicit: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mempoolRetentionDrifted(tt.alternative, tt.mempool, tt.mempoolExplicit); got != tt.want { + t.Fatalf("mempoolRetentionDrifted(%s, %s, %v) = %v, want %v", tt.alternative, tt.mempool, tt.mempoolExplicit, got, tt.want) + } + }) + } +} + func TestNewEthereumRPCRejectsInvalidMempoolTimeouts(t *testing.T) { tests := []struct { name string @@ -124,6 +280,26 @@ func TestNewEthereumRPCRejectsInvalidMempoolTimeouts(t *testing.T) { "block_addresses_to_keep":600 }`, }, + { + name: "zero alternative pending window", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "alternativePendingTxWindow":"0s", + "block_addresses_to_keep":600 + }`, + }, + { + name: "unparsable alternative pending window", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "alternativePendingTxWindow":"three hours", + "block_addresses_to_keep":600 + }`, + }, { name: "negative blockbook mempool timeout", config: `{ @@ -134,6 +310,16 @@ func TestNewEthereumRPCRejectsInvalidMempoolTimeouts(t *testing.T) { "block_addresses_to_keep":600 }`, }, + { + name: "zero alternative missing timeout", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "alternativeMissingTxTimeout":"0s", + "block_addresses_to_keep":600 + }`, + }, } for _, tt := range tests { @@ -146,6 +332,105 @@ func TestNewEthereumRPCRejectsInvalidMempoolTimeouts(t *testing.T) { } } +// TestCreateMempoolRejectsInvertedRetention pins that an inverted pair fails startup rather than +// warning: inverted, the mempool's timeout sweep drops a private transaction's address index while the +// cache keeps serving its body as pending, the #1573 symptom arrived at silently. Only an explicit +// mempoolTxTimeout can get there, since the default is derived from the cache retention. A pair set +// equal is not inverted and must start, since that is what the coin configs ship. +func TestCreateMempoolRejectsInvertedRetention(t *testing.T) { + for _, tt := range []struct { + name string + config Configuration + wantError bool + }{ + { + name: "explicit mempool timeout below the pending window", + config: Configuration{MempoolTxTimeout: "10m", AlternativePendingTxWindow: "3h"}, + wantError: true, + }, + { + name: "explicit mempool timeout equal to the pending window", + config: Configuration{MempoolTxTimeout: "3h", AlternativePendingTxWindow: "3h"}, + }, + { + name: "explicit mempool timeout equal to an explicit cache retention", + config: Configuration{MempoolTxTimeout: "12h", AlternativeMempoolTxTimeout: "12h"}, + }, + { + name: "explicit mempool timeout above the pending window", + config: Configuration{MempoolTxTimeout: "4h", AlternativePendingTxWindow: "3h"}, + }, + { + name: "derived defaults are never inverted", + config: Configuration{AlternativePendingTxWindow: "3h"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + alternative, err := tt.config.AlternativeMempoolTxTimeoutDuration() + if err != nil { + t.Fatalf("AlternativeMempoolTxTimeoutDuration() error = %v", err) + } + b := &EthereumRPC{ + ChainConfig: &tt.config, + alternativeSendTxProvider: &AlternativeSendTxProvider{mempoolTxsTimeout: alternative, fetchMempoolTx: true}, + } + _, err = b.CreateMempool(nil) + if tt.wantError && err == nil { + t.Fatal("inverted retention pair was accepted") + } + if !tt.wantError && err != nil { + t.Fatalf("CreateMempool() error = %v", err) + } + if tt.wantError { + // a static config error must be recognizable, or startup retries it once a second for + // two minutes and reports it as an RPC fault + if !bchain.IsConfigurationError(err) { + t.Fatalf("CreateMempool() error = %v, want one startup can recognize as a configuration error", err) + } + // a rejected pair must not leave a mempool behind, or a second call hands it out with + // the provider never wired to it + if b.Mempool != nil { + t.Fatal("rejected configuration left a mempool behind") + } + if _, err := b.CreateMempool(nil); err == nil { + t.Fatal("a second CreateMempool accepted the same inverted pair") + } + } + }) + } +} + +// TestCreateMempoolWithoutFetchMempoolTxKeepsLegacyRetention pins that a URLS-only deployment +// (broadcast through the relay, *_ALTERNATIVE_FETCH_MEMPOOL_TX unset) is outside the retention +// coupling: the pending-tx cache is never written or read, so there is no private address index to +// outlive - an explicit short mempoolTxTimeout must not fail startup, and the derived default must +// stay the legacy hour-based value rather than the cache retention plus margin. +func TestCreateMempoolWithoutFetchMempoolTxKeepsLegacyRetention(t *testing.T) { + t.Run("explicit short timeout is accepted", func(t *testing.T) { + b := &EthereumRPC{ + ChainConfig: &Configuration{MempoolTxTimeout: "10m", AlternativePendingTxWindow: "3h"}, + alternativeSendTxProvider: &AlternativeSendTxProvider{mempoolTxsTimeout: 3 * time.Hour}, + } + if _, err := b.CreateMempool(nil); err != nil { + t.Fatalf("CreateMempool() error = %v, want a URLS-only deployment with a short explicit timeout accepted", err) + } + }) + t.Run("derived default stays legacy", func(t *testing.T) { + // The derivation consults the alternative window only when the cache exists (see + // TestConfigurationMempoolTxTimeoutDuration for the two derivations themselves); a window + // this config cannot parse proves CreateMempool takes the legacy branch for a URLS-only + // provider rather than keying on the provider existing. + config := &Configuration{MempoolTxTimeoutHours: 12, AlternativePendingTxWindow: "not-a-duration"} + b := &EthereumRPC{ + ChainConfig: config, + alternativeSendTxProvider: &AlternativeSendTxProvider{mempoolTxsTimeout: 3 * time.Hour}, + } + if _, err := b.CreateMempool(nil); err != nil { + t.Fatalf("CreateMempool() error = %v, want the URLS-only path to never read the alternative window", err) + } + }) +} + func TestInitAlternativeProvidersUsesAlternativeMempoolTxTimeout(t *testing.T) { t.Setenv("ETH_ALTERNATIVE_SENDTX_URLS", "http://localhost:8545") @@ -160,10 +445,19 @@ func TestInitAlternativeProvidersUsesAlternativeMempoolTxTimeout(t *testing.T) { CoinShortcut: "eth", RPCTimeout: 1, }, - want: 5 * time.Minute, + want: defaultAlternativePendingTxWindow, }, { - name: "explicit duration", + name: "configured pending window", + config: Configuration{ + CoinShortcut: "eth", + RPCTimeout: 1, + AlternativePendingTxWindow: "90m", + }, + want: 90 * time.Minute, + }, + { + name: "explicit cache retention", config: Configuration{ CoinShortcut: "eth", RPCTimeout: 1, @@ -191,3 +485,49 @@ func TestInitAlternativeProvidersUsesAlternativeMempoolTxTimeout(t *testing.T) { }) } } + +func TestInitAlternativeProvidersWiresMissingTxTimeout(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: defaultAlternativeMissingTxTimeout, + }, + { + name: "configured missing timeout", + config: Configuration{ + CoinShortcut: "eth", + RPCTimeout: 1, + AlternativeMissingTxTimeout: "25m", + }, + want: 25 * 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.missingTimeout(); got != tt.want { + t.Fatalf("missingTimeout() = %s, want %s", got, tt.want) + } + }) + } +} diff --git a/bchain/coins/eth/ethrpc_test.go b/bchain/coins/eth/ethrpc_test.go new file mode 100644 index 0000000000..8f65019df2 --- /dev/null +++ b/bchain/coins/eth/ethrpc_test.go @@ -0,0 +1,434 @@ +//go:build unittest + +package eth + +import ( + "context" + "encoding/hex" + "encoding/json" + stdErrors "errors" + "fmt" + "reflect" + "testing" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain" + "golang.org/x/crypto/sha3" +) + +func keccak256Selector(sig string) string { + h := sha3.NewLegacyKeccak256() + h.Write([]byte(sig)) + return "0x" + hex.EncodeToString(h.Sum(nil)[:4]) +} + +// TestENSFunctionSelectors verifies that all ENS function selector constants +// match the keccak256 hash of their documented Solidity signatures. A +// mismatch means eth_call will revert against any real backend. +func TestENSFunctionSelectors(t *testing.T) { + tests := []struct { + constant string + got string + sig string + }{ + {"ENSResolverFunctionSelector", ENSResolverFunctionSelector, "resolver(bytes32)"}, + {"ENSAddrFunctionSelector", ENSAddrFunctionSelector, "addr(bytes32)"}, + {"ENSExpirationFunctionSelector", ENSExpirationFunctionSelector, "nameExpires(uint256)"}, + } + for _, tc := range tests { + want := keccak256Selector(tc.sig) + if tc.got != want { + t.Errorf("%s = %q, want keccak256(%q)[:4] = %q", tc.constant, tc.got, tc.sig, want) + } + } +} + +// recoveryStub is a method-aware bchain.EVMRPCClient fake for exercising the pruned-index +// recovery path in recoverMinedTransaction / GetTransaction. It serves canned JSON per +// JSON-RPC method, can force the positional lookup to error (to drive the block-body +// fallback), and records call counts plus arguments so tests can assert exactly which RPCs +// were made (single receipt fetch, block body only when needed). +type recoveryStub struct { + byHashJSON string // eth_getTransactionByHash result; "" leaves the target zero (null) + receiptJSON string // eth_getTransactionReceipt result ("null" => unknown tx) + byIndexJSON string // eth_getTransactionByBlockHashAndIndex result + byIndexErr error // when set, the positional lookup errors (drives the fallback) + blockJSON string // eth_getBlockByHash raw result (header fields and/or transactions) + calls map[string]int + byIndexArgs []interface{} + blockFullTxs []bool // fullTxs arg recorded per eth_getBlockByHash call +} + +func newRecoveryStub() *recoveryStub { return &recoveryStub{calls: map[string]int{}} } + +func (s *recoveryStub) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, stdErrors.New("not implemented") +} + +func (s *recoveryStub) Close() {} + +func (s *recoveryStub) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + s.calls[method]++ + switch method { + case "eth_getTransactionByHash": + if s.byHashJSON == "" { + return nil // null result: leaves the caller's zero-value tx untouched + } + return json.Unmarshal([]byte(s.byHashJSON), result) + case "eth_getTransactionReceipt": + return json.Unmarshal([]byte(s.receiptJSON), result) + case "eth_getTransactionByBlockHashAndIndex": + s.byIndexArgs = args + if s.byIndexErr != nil { + return s.byIndexErr + } + return json.Unmarshal([]byte(s.byIndexJSON), result) + case "eth_getBlockByHash": + if len(args) >= 2 { + if full, ok := args[1].(bool); ok { + s.blockFullTxs = append(s.blockFullTxs, full) + } + } + if p, ok := result.(*json.RawMessage); ok { + *p = json.RawMessage(s.blockJSON) + } + return nil + default: + return stdErrors.New("unexpected method: " + method) + } +} + +const ( + recTxid = "0xd20ea523eee594f82d481b4da6a8c2f1ce1e7fee34cd6369ee0ba6093c1d19bb" + recBlockHash = "0x4437c2e020a9c940532e2431babb3550cfdd16f498d2c7b5bb6c0f728567d69d" + recTxIndex = "0x21" + recReceipt = `{"blockHash":"` + recBlockHash + `","transactionIndex":"` + recTxIndex + `","status":"0x1","gasUsed":"0x5208","logs":[]}` + recTxObject = `{"blockHash":"` + recBlockHash + `","blockNumber":"0x2b83e54","from":"0xc68eff0a07180ce4b6e490ddb080b6b8f3867024","gas":"0x5208","gasPrice":"0xf4240","hash":"` + recTxid + `","input":"0x","nonce":"0x5","to":"0x22b51ee43ccab63ec03c50794a841c9189d94ed2","transactionIndex":"` + recTxIndex + `","value":"0x2386f26fc10000"}` + // block header fields (timestamp, baseFeePerGas) plus the tx body, so one JSON serves + // both the mined-branch header read (fullTxs=false) and the block-body fallback scan. + recBlockJSON = `{"timestamp":"0x6819a2e0","baseFeePerGas":"0x5f5e100","transactions":[` + recTxObject + `]}` +) + +// Fast path: a pruned-but-retained tx is recovered via receipt + +// eth_getTransactionByBlockHashAndIndex, the block body is never fetched, the receipt is +// fetched exactly once and returned for reuse, and the index lookup is addressed by the +// receipt's block hash and index. +func TestRecoverMinedTransaction_UsesByBlockHashAndIndex(t *testing.T) { + stub := newRecoveryStub() + stub.receiptJSON, stub.byIndexJSON = recReceipt, recTxObject + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + tx, receipt := b.recoverMinedTransaction(recTxid) + + if tx == nil || tx.Hash != recTxid { + t.Fatalf("recovered tx = %+v, want hash %s", tx, recTxid) + } + if tx.BlockNumber == "" { + t.Error("recovered tx has empty BlockNumber; would be misrouted as a mempool tx") + } + if receipt == nil || receipt.Status != "0x1" || receipt.GasUsed != "0x5208" { + t.Fatalf("reused receipt = %+v, want Status 0x1 / GasUsed 0x5208", receipt) + } + if got := stub.calls["eth_getBlockByHash"]; got != 0 { + t.Errorf("eth_getBlockByHash called %d times, want 0 (no block-body fetch on fast path)", got) + } + if got := stub.calls["eth_getTransactionReceipt"]; got != 1 { + t.Errorf("eth_getTransactionReceipt called %d times, want exactly 1", got) + } + if got := stub.calls["eth_getTransactionByBlockHashAndIndex"]; got != 1 { + t.Errorf("eth_getTransactionByBlockHashAndIndex called %d times, want 1", got) + } + if len(stub.byIndexArgs) != 2 { + t.Fatalf("eth_getTransactionByBlockHashAndIndex got %d args, want 2", len(stub.byIndexArgs)) + } + if bh, ok := stub.byIndexArgs[0].(ethcommon.Hash); !ok || bh != ethcommon.HexToHash(recBlockHash) { + t.Errorf("byIndex arg[0] = %v, want block hash %s", stub.byIndexArgs[0], recBlockHash) + } + if idx, ok := stub.byIndexArgs[1].(string); !ok || idx != recTxIndex { + t.Errorf("byIndex arg[1] = %v, want index %s", stub.byIndexArgs[1], recTxIndex) + } +} + +// A genuinely unknown tx has a null receipt: recovery returns (nil, nil) so the caller +// yields ErrTxNotFound, and neither the index lookup nor the block body is fetched. +func TestRecoverMinedTransaction_UnknownReturnsNil(t *testing.T) { + stub := newRecoveryStub() + stub.receiptJSON = "null" + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + tx, receipt := b.recoverMinedTransaction(recTxid) + + if tx != nil || receipt != nil { + t.Errorf("recoverMinedTransaction(unknown) = (%v, %v), want (nil, nil)", tx, receipt) + } + if got := stub.calls["eth_getTransactionByBlockHashAndIndex"] + stub.calls["eth_getBlockByHash"]; got != 0 { + t.Errorf("made %d lookup calls for unknown tx, want 0", got) + } +} + +// Fallback: when eth_getTransactionByBlockHashAndIndex is unavailable (errors), recovery +// falls back to scanning the block body, still recovering the tx and returning the receipt. +func TestRecoverMinedTransaction_FallsBackToBlockBody(t *testing.T) { + stub := newRecoveryStub() + stub.receiptJSON = recReceipt + stub.byIndexErr = stdErrors.New("the method eth_getTransactionByBlockHashAndIndex does not exist") + stub.blockJSON = recBlockJSON + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + tx, receipt := b.recoverMinedTransaction(recTxid) + + if tx == nil || tx.Hash != recTxid { + t.Fatalf("fallback recovered tx = %+v, want hash %s", tx, recTxid) + } + if receipt == nil || receipt.Status != "0x1" { + t.Fatalf("fallback receipt = %+v, want Status 0x1", receipt) + } + if len(stub.blockFullTxs) != 1 || !stub.blockFullTxs[0] { + t.Errorf("block-body fallback should fetch eth_getBlockByHash once with fullTxs=true, got %v", stub.blockFullTxs) + } +} + +// If both the positional lookup fails and the block body lacks the tx, recovery returns +// (nil, nil). +func TestRecoverMinedTransaction_FallbackMissReturnsNil(t *testing.T) { + stub := newRecoveryStub() + stub.receiptJSON = recReceipt + stub.byIndexErr = stdErrors.New("unsupported") + stub.blockJSON = `{"timestamp":"0x1","transactions":[]}` + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + tx, receipt := b.recoverMinedTransaction(recTxid) + + if tx != nil || receipt != nil { + t.Errorf("recoverMinedTransaction(fallback miss) = (%v, %v), want (nil, nil)", tx, receipt) + } +} + +// End-to-end GetTransaction on a pruned tx: eth_getTransactionByHash returns null, the tx is +// recovered, and the recovered receipt is reused for EthTxToTx instead of being fetched a +// second time - so eth_getTransactionReceipt is called exactly once and no block body is read. +func TestGetTransaction_RecoveryReusesReceipt(t *testing.T) { + stub := newRecoveryStub() + stub.receiptJSON, stub.byIndexJSON, stub.blockJSON = recReceipt, recTxObject, recBlockJSON + b := &EthereumRPC{ + RPC: stub, + Timeout: time.Second, + Parser: NewEthereumParser(1, false), + bestHeader: stubHeader{n: 45629030}, // > tx block 0x2b83e54, for computeConfirmations + } + + got, err := b.GetTransaction(recTxid) + if err != nil { + t.Fatalf("GetTransaction returned error: %v", err) + } + if got == nil || got.Txid != recTxid { + t.Fatalf("GetTransaction = %+v, want Txid %s", got, recTxid) + } + if c := stub.calls["eth_getTransactionReceipt"]; c != 1 { + t.Errorf("eth_getTransactionReceipt called %d times, want exactly 1 (receipt must be reused, not re-fetched)", c) + } + if c := stub.calls["eth_getTransactionByHash"]; c != 1 { + t.Errorf("eth_getTransactionByHash called %d times, want 1", c) + } + // Only the mined-branch header read (fullTxs=false); no block-body fetch. + for _, full := range stub.blockFullTxs { + if full { + t.Errorf("GetTransaction fetched a full block body during recovery, want header-only reads: %v", stub.blockFullTxs) + } + } + if got.Confirmations <= 0 { + t.Errorf("Confirmations = %d, want > 0", got.Confirmations) + } +} + +// ensCallStub is a minimal bchain.EVMRPCClient fake that serves a single canned +// eth_call result (or forces an error), for exercising CheckENSExpiration's +// result-parsing path without a real backend. +type ensCallStub struct { + result string // eth_call result string, e.g. an ABI-encoded uint256 word + err error // when set, CallContext returns it (simulates a revert) +} + +func (s *ensCallStub) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, stdErrors.New("not implemented") +} + +func (s *ensCallStub) Close() {} + +func (s *ensCallStub) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + if method != "eth_call" { + return stdErrors.New("unexpected method: " + method) + } + if s.err != nil { + return s.err + } + p, ok := result.(*json.RawMessage) + if !ok { + return stdErrors.New("unexpected result type") + } + encoded, err := json.Marshal(s.result) + if err != nil { + return err + } + *p = json.RawMessage(encoded) + return nil +} + +// abiWord left-pads an unsigned integer into a full 32-byte ABI-encoded word, +// mirroring how nameExpires(uint256) is returned on the wire. +func abiWord(v int64) string { return "0x" + fmt.Sprintf("%064x", v) } + +// TestCheckENSExpiration_ParsesPaddedResult locks in the result-parsing path of +// CheckENSExpiration — the exact place the historical bug lived. nameExpires +// returns an ABI-encoded uint256 (a 32-byte word left-padded with zeros); +// hexutil.DecodeBig rejects those leading zeros, so the parse must treat the +// word as raw bytes. Without that fix every real lookup silently skips the +// expiration check. +func TestCheckENSExpiration_ParsesPaddedResult(t *testing.T) { + past := time.Now().Add(-24 * time.Hour).Unix() + future := time.Now().Add(24 * time.Hour).Unix() + + tests := []struct { + name string + result string + callErr error + wantExpired bool + }{ + {"future expiration (registered, valid)", abiWord(future), nil, false}, + {"past expiration (registered, expired)", abiWord(past), nil, true}, + {"zero word (unregistered label)", abiWord(0), nil, false}, + {"empty result", "0x", nil, false}, + {"rpc revert", "", stdErrors.New("execution reverted"), false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b := &EthereumRPC{ + BaseChain: &bchain.BaseChain{}, // Testnet defaults to false, so ensContracts resolves + RPC: &ensCallStub{result: tc.result, err: tc.callErr}, + Timeout: time.Second, + MainNetChainID: MainNet, + } + expired, err := b.CheckENSExpiration("vitalik.eth") + if err != nil { + t.Fatalf("CheckENSExpiration returned error: %v", err) + } + if expired != tc.wantExpired { + t.Errorf("CheckENSExpiration(%q) expired = %v, want %v", tc.result, expired, tc.wantExpired) + } + }) + } +} + +// TestNewEthereumRPC_PropagatesEnsReverseOptIn pins the config->parser wiring that both +// gates read: processEventsForBlock (write path) and api.NewWorker (read path) both call +// UseEnsReverseAliases on this parser, so ENS reverse aliasing must stay off unless a chain +// sets enable_ens_reverse_aliases alongside address_aliases. +func TestNewEthereumRPC_PropagatesEnsReverseOptIn(t *testing.T) { + tests := []struct { + name string + params string + want bool + }{ + {name: "aliases on, opt-in absent", params: `"address_aliases": true`}, + {name: "aliases on, opt-in false", params: `"address_aliases": true, "enable_ens_reverse_aliases": false`}, + {name: "aliases on, opt-in true", params: `"address_aliases": true, "enable_ens_reverse_aliases": true`, want: true}, + {name: "opt-in without address_aliases", params: `"enable_ens_reverse_aliases": true`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // NewEthereumRPC writes this process-wide; keep the test order-independent. + orig := bchain.ProcessInternalTransactions + defer func() { bchain.ProcessInternalTransactions = orig }() + + config := json.RawMessage(`{ + "coin_name": "Ethereum", + "rpc_url": "http://127.0.0.1:8545", + "rpc_timeout": 25, + "block_addresses_to_keep": 300, + ` + tt.params + `, + "averageBlockTimeMs": 12000, + "mempoolTxTimeoutHours": 48 + }`) + + chain, err := NewEthereumRPC(config, func(bchain.NotificationType) {}) + if err != nil { + t.Fatalf("NewEthereumRPC: %v", err) + } + if got := chain.(*EthereumRPC).Parser.UseEnsReverseAliases(); got != tt.want { + t.Errorf("UseEnsReverseAliases() = %v, want %v", got, tt.want) + } + }) + } +} + +// getLogsStub serves one canned eth_getLogs response. +type getLogsStub struct{ logsJSON string } + +func (s *getLogsStub) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, stdErrors.New("not implemented") +} + +func (s *getLogsStub) Close() {} + +func (s *getLogsStub) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + if method != "eth_getLogs" { + return stdErrors.New("unexpected method: " + method) + } + return json.Unmarshal([]byte(s.logsJSON), result) +} + +// TestProcessEventsForBlock_EnsReverseGate covers the write-path half of the opt-out: a +// NameRegistered log must yield no AddressAliasRecord (so nothing reaches cfAddressAliases) +// unless the chain opted in, while the tx logs themselves are returned either way. +func TestProcessEventsForBlock_EnsReverseGate(t *testing.T) { + const txHash = "0x1a2b3c4d5e6f70000000000000000000000000000000000000000000000000ab" + // The "unraveled" NameRegistered fixture from Test_getEnsRecord. + logsJSON := `[{ + "transactionHash": "` + txHash + `", + "address": "0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5", + "topics": [ + "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f", + "0x40ce2aa8cd9ee9fef4bf3a68abab7fbcceb6bac89370518caf6a602cefe836bd", + "0x0000000000000000000000002c630b16aa53ae0189880e15c23323688acb607c" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000017629245f5a86f0000000000000000000000000000000000000000000000000000000069dbb21d0000000000000000000000000000000000000000000000000000000000000009756e726176656c65640000000000000000000000000000000000000000000000" + }]` + + tests := []struct { + name string + enableEns bool + wantEns []bchain.AddressAliasRecord + }{ + {name: "gate closed records no ENS label"}, + { + name: "gate open records the ENS label", + enableEns: true, + wantEns: []bchain.AddressAliasRecord{{Address: "0x2C630b16Aa53ae0189880e15C23323688acb607c", Name: "unraveled"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewEthereumParser(1, true) + parser.EnableEnsReverseAliases = tt.enableEns + b := &EthereumRPC{ + BaseChain: &bchain.BaseChain{}, + Parser: parser, + RPC: &getLogsStub{logsJSON: logsJSON}, + Timeout: time.Second, + } + + logs, ens, err := b.processEventsForBlock("0x1") + if err != nil { + t.Fatalf("processEventsForBlock: %v", err) + } + // The gate must not affect the tx logs themselves. + if len(logs[txHash]) != 1 { + t.Errorf("logs[%s] = %d entries, want 1", txHash, len(logs[txHash])) + } + if !reflect.DeepEqual(ens, tt.wantEns) { + t.Errorf("ensRecords = %+v, want %+v", ens, tt.wantEns) + } + }) + } +} diff --git a/bchain/coins/eth/ethrpc_tip_watchdog_test.go b/bchain/coins/eth/ethrpc_tip_watchdog_test.go index fe1e54ac56..5d02aaa525 100644 --- a/bchain/coins/eth/ethrpc_tip_watchdog_test.go +++ b/bchain/coins/eth/ethrpc_tip_watchdog_test.go @@ -64,6 +64,7 @@ func (h stubHeader) Hash() string { } return string(rune(h.n)) } +func (h stubHeader) ParentHash() string { return "" } func (h stubHeader) Number() *big.Int { return big.NewInt(h.n) } func (h stubHeader) Difficulty() *big.Int { return big.NewInt(0) } diff --git a/bchain/coins/eth/ethrpc_trace_test.go b/bchain/coins/eth/ethrpc_trace_test.go index 72c1d50ce2..e00562754d 100644 --- a/bchain/coins/eth/ethrpc_trace_test.go +++ b/bchain/coins/eth/ethrpc_trace_test.go @@ -4,14 +4,17 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/trezor/blockbook/bchain" ) type mockTraceRPC struct { method string args []interface{} + trace []rpcTraceResult // returned from debug_traceBlockByHash } func (m *mockTraceRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { @@ -22,7 +25,7 @@ func (m *mockTraceRPC) CallContext(ctx context.Context, result interface{}, meth m.method = method m.args = append([]interface{}{}, args...) if out, ok := result.(*[]rpcTraceResult); ok { - *out = []rpcTraceResult{} + *out = append([]rpcTraceResult{}, m.trace...) } return nil } @@ -103,3 +106,293 @@ func TestGetInternalDataForBlockOmitsTraceTimeoutWhenUnset(t *testing.T) { t.Fatalf("timeout should be omitted when unset, config = %#v", traceConfig) } } + +// Trace subtree of mainnet tx 0xb247a9589bc1638dbd157937b121b20105d5d613d802ec0dab98017c861bfe39, +// where a contract fakes 3 ETH transfers to arbitrary addresses via CALLCODE (issue #1225). +func TestProcessCallTraceIgnoresFakeCallcodeTransfers(t *testing.T) { + const ( + attacker = "0x66a0e978c0b91034a27d0da7207d5f80f11e86dc" + sender = "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43" + library = "0x60f760bb7068e5ae61af835b10076d40d0a3d958" + victim1 = "0xa3b36b1ee03f71926957194abad51a7c652e77d6" + victim2 = "0x03533db5ac95abe2164ffd9199e96524a2207a1a" + threeEth = "0x29a2241af62c0000" + halfEther = "0x6f05b59d3b20000" + ) + trace := &rpcCallTrace{ + Type: "CALL", From: sender, To: attacker, Value: threeEth, + Calls: []rpcCallTrace{ + { + Type: "DELEGATECALL", From: attacker, To: library, Value: threeEth, + Calls: []rpcCallTrace{ + {Type: "CALLCODE", From: attacker, To: victim1, Value: threeEth}, + }, + }, + { + Type: "DELEGATECALL", From: attacker, To: library, Value: threeEth, + Calls: []rpcCallTrace{ + {Type: "CALLCODE", From: attacker, To: victim2, Value: threeEth}, + }, + }, + {Type: "CALL", From: attacker, To: victim1, Value: halfEther}, + }, + } + + b := &EthereumRPC{ChainConfig: &Configuration{}} + d := &bchain.EthereumInternalData{} + b.processCallTrace(trace, d, nil, 1) + + want := []bchain.EthereumInternalTransfer{ + {Value: *hexutil.MustDecodeBig(threeEth), From: sender, To: attacker}, + {Value: *hexutil.MustDecodeBig(halfEther), From: attacker, To: victim1}, + } + if len(d.Transfers) != len(want) { + t.Fatalf("transfers = %+v, want only the real CALL transfers %+v", d.Transfers, want) + } + for i := range want { + if d.Transfers[i].From != want[i].From || d.Transfers[i].To != want[i].To || + d.Transfers[i].Value.Cmp(&want[i].Value) != 0 || d.Transfers[i].Type != want[i].Type { + t.Errorf("transfer[%d] = %+v, want %+v", i, d.Transfers[i], want[i]) + } + } +} + +// Failed frames are reverted in their entirety, so neither the frame itself nor anything +// beneath it may be indexed as a transfer or contract lifecycle event (issue #1621). +func TestProcessCallTraceSkipsFailedFramesAndTheirSubtrees(t *testing.T) { + const ( + sender = "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43" + router = "0x66a0e978c0b91034a27d0da7207d5f80f11e86dc" + target = "0xa3b36b1ee03f71926957194abad51a7c652e77d6" + nested = "0x03533db5ac95abe2164ffd9199e96524a2207a1a" + created = "0x60f760bb7068e5ae61af835b10076d40d0a3d958" + oneEth = "0xde0b6b3a7640000" + twoEth = "0x1bc16d674ec80000" + threeEth = "0x29a2241af62c0000" + ) + tests := []struct { + name string + trace *rpcCallTrace + wantTransfers []bchain.EthereumInternalTransfer + wantContracts int + wantError string + }{ + { + name: "successful root with insufficient-balance child", + trace: &rpcCallTrace{ + Type: "CALL", From: sender, To: router, Value: oneEth, + Calls: []rpcCallTrace{ + {Type: "CALL", From: router, To: target, Value: threeEth, Error: "insufficient balance for transfer"}, + }, + }, + wantTransfers: []bchain.EthereumInternalTransfer{ + {Value: *hexutil.MustDecodeBig(oneEth), From: sender, To: router}, + }, + wantError: "insufficient balance for transfer", + }, + { + name: "reverted parent hides successful descendants, sibling survives", + trace: &rpcCallTrace{ + Type: "CALL", From: sender, To: router, Value: oneEth, + Calls: []rpcCallTrace{ + { + Type: "CALL", From: router, To: target, Value: twoEth, Error: "execution reverted", + Calls: []rpcCallTrace{ + {Type: "CALL", From: target, To: nested, Value: oneEth}, + }, + }, + {Type: "CALL", From: router, To: nested, Value: oneEth}, + }, + }, + wantTransfers: []bchain.EthereumInternalTransfer{ + {Value: *hexutil.MustDecodeBig(oneEth), From: sender, To: router}, + {Value: *hexutil.MustDecodeBig(oneEth), From: router, To: nested}, + }, + wantError: "execution reverted", + }, + { + name: "failed CREATE and SELFDESTRUCT leave no lifecycle records", + trace: &rpcCallTrace{ + Type: "CALL", From: sender, To: router, + Calls: []rpcCallTrace{ + {Type: "CREATE", From: router, To: created, Value: oneEth, Error: "out of gas"}, + {Type: "SELFDESTRUCT", From: router, To: target, Value: twoEth, Error: "execution reverted"}, + {Type: "CREATE2", From: router, To: nested}, + }, + }, + wantTransfers: []bchain.EthereumInternalTransfer{ + {Type: bchain.CREATE, From: router, To: nested}, + }, + wantContracts: 1, + wantError: "execution reverted", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &EthereumRPC{ChainConfig: &Configuration{}} + d := &bchain.EthereumInternalData{} + contracts := b.processCallTrace(tt.trace, d, nil, 1) + assertInternalTransfers(t, d.Transfers, tt.wantTransfers) + if len(contracts) != tt.wantContracts { + t.Errorf("contracts = %+v, want %d records", contracts, tt.wantContracts) + } + if d.Error != tt.wantError { + t.Errorf("error = %q, want %q", d.Error, tt.wantError) + } + }) + } +} + +// A reverted root transaction undoes every child frame, so a failed tx must not carry +// internal transfers into the index even when its children succeeded locally (issue #1621). +func TestGetInternalDataForBlockDropsChildrenOfRevertedRoot(t *testing.T) { + const ( + sender = "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43" + router = "0x66a0e978c0b91034a27d0da7207d5f80f11e86dc" + target = "0xa3b36b1ee03f71926957194abad51a7c652e77d6" + oneEth = "0xde0b6b3a7640000" + ) + rpcClient := &mockTraceRPC{trace: []rpcTraceResult{ + {Result: rpcCallTrace{ + Type: "CALL", From: sender, To: router, Error: "execution reverted", + Calls: []rpcCallTrace{{Type: "CALL", From: router, To: target, Value: oneEth}}, + }}, + {Result: rpcCallTrace{ + Type: "CALL", From: sender, To: router, + Calls: []rpcCallTrace{{Type: "CALL", From: router, To: target, Value: oneEth}}, + }}, + }} + b := &EthereumRPC{RPC: rpcClient, ChainConfig: &Configuration{ProcessInternalTransactions: true}} + bchain.ProcessInternalTransactions = true + t.Cleanup(func() { + bchain.ProcessInternalTransactions = false + }) + + data, _, err := b.getInternalDataForBlock(context.Background(), "0xabc", 1, make([]bchain.RpcTransaction, 2)) + if err != nil { + t.Fatalf("getInternalDataForBlock() error = %v", err) + } + assertInternalTransfers(t, data[0].Transfers, nil) + if data[0].Error == "" { + t.Errorf("reverted root should keep its error, got empty") + } + assertInternalTransfers(t, data[1].Transfers, []bchain.EthereumInternalTransfer{ + {Value: *hexutil.MustDecodeBig(oneEth), From: router, To: target}, + }) +} + +func assertInternalTransfers(t *testing.T, got, want []bchain.EthereumInternalTransfer) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("transfers = %+v, want %+v", got, want) + } + for i := range want { + if got[i].From != want[i].From || got[i].To != want[i].To || + got[i].Value.Cmp(&want[i].Value) != 0 || got[i].Type != want[i].Type { + t.Errorf("transfer[%d] = %+v, want %+v", i, got[i], want[i]) + } + } +} + +// Erigon reports a per-tx tracer failure (e.g. timeout) as an envelope carrying "error" instead of +// "result" and keeps tracing the rest of the block (issue #1760). +func TestGetInternalDataForBlockFailsOnPerTxTraceError(t *testing.T) { + txs := []bchain.RpcTransaction{ + {Hash: "0x01", From: "0xaaaa", To: "0xbbbb"}, + {Hash: "0x02", From: "0xcccc", To: "0xdddd"}, + } + rpcClient := &mockTraceRPC{trace: []rpcTraceResult{ + {Result: rpcCallTrace{Type: "CALL", From: "0xaaaa", To: "0xbbbb", Value: "0x1"}}, + {Error: "execution timeout"}, + }} + b := &EthereumRPC{RPC: rpcClient, ChainConfig: &Configuration{ProcessInternalTransactions: true}} + bchain.ProcessInternalTransactions = true + t.Cleanup(func() { bchain.ProcessInternalTransactions = false }) + + _, _, err := b.getInternalDataForBlock(context.Background(), "0xabc", 1, txs) + if err == nil { + t.Fatal("expected error for failed per-tx trace, got nil") + } + for _, want := range []string{"0x02", "execution timeout"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not mention %q", err, want) + } + } +} + +// The Polygon state-sync tx is already exempt from the trace-length check; a failed trace of it must +// not queue every block that contains one for healing. +func TestGetInternalDataForBlockIgnoresBridgingTxTraceError(t *testing.T) { + const zero = "0x0000000000000000000000000000000000000000" + txs := []bchain.RpcTransaction{ + {Hash: "0x01", From: "0xaaaa", To: "0xbbbb"}, + {Hash: "0x02", From: zero, To: zero}, + } + rpcClient := &mockTraceRPC{trace: []rpcTraceResult{ + {Result: rpcCallTrace{Type: "CALL", From: "0xaaaa", To: "0xbbbb", Value: "0x1"}}, + {Error: "state sync trace failed"}, + }} + b := &EthereumRPC{RPC: rpcClient, ChainConfig: &Configuration{ProcessInternalTransactions: true}} + bchain.ProcessInternalTransactions = true + t.Cleanup(func() { bchain.ProcessInternalTransactions = false }) + + data, _, err := b.getInternalDataForBlock(context.Background(), "0xabc", 1, txs) + if err != nil { + t.Fatalf("getInternalDataForBlock() error = %v", err) + } + if len(data) != 2 || data[1].Error != "" || len(data[1].Transfers) != 0 { + t.Fatalf("bridging tx should decode to empty internal data, got %+v", data) + } +} + +func TestRpcTraceErrorUnmarshalAcceptsStringAndObject(t *testing.T) { + tests := []struct { + name, raw, want string + }{ + {"geth string", `{"error":"execution timeout"}`, "execution timeout"}, + {"erigon object", `{"error":{"code":-32000,"message":"execution timeout"}}`, "execution timeout"}, + {"erigon object without message", `{"error":{"code":-32000}}`, "error code -32000"}, + {"null", `{"error":null}`, ""}, + {"absent", `{"result":{"type":"CALL"}}`, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r rpcTraceResult + if err := json.Unmarshal([]byte(tt.raw), &r); err != nil { + t.Fatalf("Unmarshal(%s) error = %v", tt.raw, err) + } + if string(r.Error) != tt.want { + t.Fatalf("Error = %q, want %q", r.Error, tt.want) + } + }) + } +} + +// Erigon's actual wire shape for a per-tx tracer failure, as written by rpc.HandleError. +func TestGetInternalDataForBlockFailsOnErigonTraceErrorObject(t *testing.T) { + const raw = `[ + {"txHash":"0x01","result":{"type":"CALL","from":"0xaaaa","to":"0xbbbb","value":"0x1"}}, + {"txHash":"0x02","result":null,"error":{"code":-32000,"message":"execution timeout"}} + ]` + rpcClient := &mockTraceRPC{} + if err := json.Unmarshal([]byte(raw), &rpcClient.trace); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + txs := []bchain.RpcTransaction{ + {Hash: "0x01", From: "0xaaaa", To: "0xbbbb"}, + {Hash: "0x02", From: "0xcccc", To: "0xdddd"}, + } + b := &EthereumRPC{RPC: rpcClient, ChainConfig: &Configuration{ProcessInternalTransactions: true}} + bchain.ProcessInternalTransactions = true + t.Cleanup(func() { bchain.ProcessInternalTransactions = false }) + + _, _, err := b.getInternalDataForBlock(context.Background(), "0xabc", 1, txs) + if err == nil { + t.Fatal("expected error for failed per-tx trace, got nil") + } + for _, want := range []string{"0x02", "execution timeout"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not mention %q", err, want) + } + } +} diff --git a/bchain/coins/eth/ethtx.pb.go b/bchain/coins/eth/ethtx.pb.go index 17aed2493e..4a05ad4100 100644 --- a/bchain/coins/eth/ethtx.pb.go +++ b/bchain/coins/eth/ethtx.pb.go @@ -230,16 +230,17 @@ func (x *ProtoCompleteTransaction_TxType) GetBaseFeePerGas() []byte { } type ProtoCompleteTransaction_ReceiptType struct { - 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 + 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"` + EffectiveGasPrice []byte `protobuf:"bytes,8,opt,name=EffectiveGasPrice,proto3,oneof" json:"EffectiveGasPrice,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProtoCompleteTransaction_ReceiptType) Reset() { @@ -321,6 +322,13 @@ func (x *ProtoCompleteTransaction_ReceiptType) GetL1GasUsed() []byte { return nil } +func (x *ProtoCompleteTransaction_ReceiptType) GetEffectiveGasPrice() []byte { + if x != nil { + return x.EffectiveGasPrice + } + return nil +} + type ProtoCompleteTransaction_ReceiptType_LogType struct { state protoimpl.MessageState `protogen:"open.v1"` Address []byte `protobuf:"bytes,1,opt,name=Address,proto3" json:"Address,omitempty"` @@ -385,8 +393,8 @@ 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, + 0x74, 0x68, 0x2f, 0x65, 0x74, 0x68, 0x74, 0x78, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x97, + 0x09, 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, @@ -429,7 +437,7 @@ var file_bchain_coins_eth_ethtx_proto_rawDesc = string([]byte{ 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, 0x46, 0x65, 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x1a, 0xdb, 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, @@ -446,17 +454,22 @@ var file_bchain_coins_eth_ethtx_proto_rawDesc = string([]byte{ 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, + 0x55, 0x73, 0x65, 0x64, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x45, 0x66, 0x66, 0x65, 0x63, + 0x74, 0x69, 0x76, 0x65, 0x47, 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0c, 0x48, 0x04, 0x52, 0x11, 0x45, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x47, + 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, 0x65, 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, 0x14, 0x0a, 0x12, 0x5f, 0x45, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, + 0x47, 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, 0x65, 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 ( diff --git a/bchain/coins/eth/ethtx.proto b/bchain/coins/eth/ethtx.proto index fabd421030..d5aa559b04 100644 --- a/bchain/coins/eth/ethtx.proto +++ b/bchain/coins/eth/ethtx.proto @@ -29,6 +29,7 @@ message ProtoCompleteTransaction { optional bytes L1FeeScalar = 5; optional bytes L1GasPrice = 6; optional bytes L1GasUsed = 7; + optional bytes EffectiveGasPrice = 8; } uint32 BlockNumber = 1; uint64 BlockTime = 2; diff --git a/bchain/coins/eth/evm.go b/bchain/coins/eth/evm.go index 3accfa8956..77b21fc617 100644 --- a/bchain/coins/eth/evm.go +++ b/bchain/coins/eth/evm.go @@ -2,10 +2,13 @@ package eth import ( "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" @@ -19,12 +22,30 @@ type EthereumClient struct { // 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) + // ethclient's header drops the node's hash, so decode the same call into EthereumHeader + var h *EthereumHeader + err := c.Client.Client().CallContext(ctx, &h, "eth_getBlockByNumber", ToBlockNumArg(number), false) + if err == nil && h == nil { + err = ethereum.NotFound + } if err != nil { return nil, err } + return h, nil +} - return &EthereumHeader{Header: h}, nil +// ToBlockNumArg formats a block number as an eth_getBlockByNumber argument, nil meaning latest +func ToBlockNumArg(number *big.Int) string { + if number == nil { + return "latest" + } + if number.Sign() >= 0 { + return hexutil.EncodeBig(number) + } + if number.IsInt64() { + return rpc.BlockNumber(number.Int64()).String() + } + return fmt.Sprintf("", number) } // EstimateGas returns the current estimated gas cost for executing a transaction @@ -95,11 +116,37 @@ func (c *EthereumRPCClient) EthSubscribe(ctx context.Context, channel interface{ // EthereumHeader wraps a block header to implement the EVMHeader interface type EthereumHeader struct { *types.Header + // hash reported by the node; types.Header.Hash() misses header fields unknown to go-ethereum + BlockHash common.Hash } -// Hash returns the block hash as a hex string +// Hash returns the node-reported block hash func (h *EthereumHeader) Hash() string { - return h.Header.Hash().Hex() + return h.BlockHash.Hex() +} + +// UnmarshalJSON decodes the header and requires the node-reported hash +func (h *EthereumHeader) UnmarshalJSON(data []byte) error { + var head types.Header + if err := json.Unmarshal(data, &head); err != nil { + return err + } + var hash struct { + Hash common.Hash `json:"hash"` + } + if err := json.Unmarshal(data, &hash); err != nil { + return err + } + if hash.Hash == (common.Hash{}) { + return fmt.Errorf("header %v without hash", head.Number) + } + h.Header, h.BlockHash = &head, hash.Hash + return nil +} + +// ParentHash returns the parent block hash as a hex string +func (h *EthereumHeader) ParentHash() string { + return h.Header.ParentHash.Hex() } // Number returns the block number @@ -124,12 +171,12 @@ type EthereumClientSubscription struct { // EthereumNewBlock wraps a block header channel to implement the EVMNewBlockSubscriber interface type EthereumNewBlock struct { - channel chan *types.Header + channel chan *EthereumHeader } // NewEthereumNewBlock returns an initialized EthereumNewBlock struct func NewEthereumNewBlock() *EthereumNewBlock { - return &EthereumNewBlock{channel: make(chan *types.Header)} + return &EthereumNewBlock{channel: make(chan *EthereumHeader)} } // Channel returns the underlying channel as an empty interface @@ -140,7 +187,10 @@ func (s *EthereumNewBlock) Channel() interface{} { // 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 + if !ok { + return nil, false + } + return h, true } // Close the underlying channel diff --git a/bchain/coins/eth/evm_test.go b/bchain/coins/eth/evm_test.go new file mode 100644 index 0000000000..4928e1dae4 --- /dev/null +++ b/bchain/coins/eth/evm_test.go @@ -0,0 +1,84 @@ +package eth + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" +) + +// Sepolia block 11856337, the first Glamsterdam block (adds blockAccessListHash and slotNumber) +const sepoliaGlamsterdamHeader = `{"hash":"0xa03f956aeb69d3fa234d9894c4309f4bb089cca9b6440cb45066c9ba39222588","parentHash":"0x862fbae116b7dca00b550ad371540de5ec8f627065bead949809ee6228b8f3eb","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x3826539cbd8d68dcf119e80b994557b4278cec9f","stateRoot":"0xfa3dce0649b084038be0d2e88b0740fdd50631fc32109c0f7766fa242baf2c39","transactionsRoot":"0x4370e25f6d627e5e9efca9cb5405f38c5ae86043e5b400ea92992d51aba12b6c","receiptsRoot":"0x636dbd7afafef168dee179457d140e251ddb3c203596d148338d1ee813f1f034","logsBloom":"0x8014a800082084804806100090009a2028220011620000168200608488100546c288100801c811022248210a01200222421950128ec100000c2892242226031a711c0551a00a220002602a58404900c0e00002c06600081121030400948540010a20000122284012608548c102604c1830800040e00c22483468a51004090118a500008009344a0009a080504206014121402041100a20081084004504201641028000810000410a803001480001041e00005000a0d0c1308a40020a02044001490f008601010220003d04004ba2000252120200040880384000800001c0694000d0101900b01810ac4814002ca0800450001800884500400207080200010208","difficulty":"0x0","number":"0xb4e9d1","gasLimit":"0x3938700","gasUsed":"0xd1dda4","timestamp":"0x6ac4fd60","extraData":"0x626573752032362e392e31","mixHash":"0x4ba9dfc9ce033a38b9f2d6d4b549e4b3a398dbcd8dd7c514d944f42c9a1a4405","nonce":"0x0000000000000000","baseFeePerGas":"0x3d7386e5","withdrawalsRoot":"0x5127063b341e2ad8b2236316204563d3025b6b9b61290fec666a0a0bba4286a7","blobGasUsed":"0x80000","excessBlobGas":"0xc6e7614","parentBeaconBlockRoot":"0xa982af12aa814307469427e140dd62cf6b6cbbcf0539fbab10336132e47b0743","requestsHash":"0xec88bf0d3fe6b86b583cf638c5635cb64bc842fee1e220f0e8be964a4d368c15","blockAccessListHash":"0xe6048b5d71a01b46691aa7d24238ba7880b789ae42173cab10a041b72e768a55","slotNumber":"0xac6000"}` + +const sepoliaGlamsterdamHash = "0xa03f956aeb69d3fa234d9894c4309f4bb089cca9b6440cb45066c9ba39222588" + +type headerService struct{} + +func (headerService) GetBlockByNumber(number string, full bool) (json.RawMessage, error) { + return json.RawMessage(sepoliaGlamsterdamHeader), nil +} + +func (headerService) NewHeads(ctx context.Context) (*rpc.Subscription, error) { + n, _ := rpc.NotifierFromContext(ctx) + sub := n.CreateSubscription() + go n.Notify(sub.ID, json.RawMessage(sepoliaGlamsterdamHeader)) + return sub, nil +} + +func newHeaderRPC(t *testing.T) *rpc.Client { + srv := rpc.NewServer() + t.Cleanup(srv.Stop) + if err := srv.RegisterName("eth", headerService{}); err != nil { + t.Fatal(err) + } + return rpc.DialInProc(srv) +} + +// header with fields unknown to go-ethereum -> node-reported hash +func TestEthereumClientHeaderByNumberKeepsNodeHash(t *testing.T) { + c := &EthereumClient{Client: ethclient.NewClient(newHeaderRPC(t))} + h, err := c.HeaderByNumber(context.Background(), big.NewInt(11856337)) + if err != nil { + t.Fatal(err) + } + if got := h.Hash(); got != sepoliaGlamsterdamHash { + t.Errorf("Hash() = %s, want %s", got, sepoliaGlamsterdamHash) + } + if got := h.Number().Uint64(); got != 11856337 { + t.Errorf("Number() = %d, want 11856337", got) + } +} + +// newHeads header -> node-reported hash +func TestEthereumNewBlockKeepsNodeHash(t *testing.T) { + nb := NewEthereumNewBlock() + sub, err := newHeaderRPC(t).EthSubscribe(context.Background(), nb.Channel(), "newHeads") + if err != nil { + t.Fatal(err) + } + defer sub.Unsubscribe() + h, ok := nb.Read() + if !ok { + t.Fatal("channel closed") + } + if got := h.Hash(); got != sepoliaGlamsterdamHash { + t.Errorf("Hash() = %s, want %s", got, sepoliaGlamsterdamHash) + } +} + +// header without a hash field -> decode error +func TestEthereumHeaderWithoutHash(t *testing.T) { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(sepoliaGlamsterdamHeader), &fields); err != nil { + t.Fatal(err) + } + delete(fields, "hash") + data, _ := json.Marshal(fields) + var h EthereumHeader + if err := json.Unmarshal(data, &h); err == nil { + t.Errorf("Unmarshal succeeded, Hash() = %s", h.Hash()) + } +} diff --git a/bchain/coins/eth/getblock_aux_test.go b/bchain/coins/eth/getblock_aux_test.go new file mode 100644 index 0000000000..441ae0e8b2 --- /dev/null +++ b/bchain/coins/eth/getblock_aux_test.go @@ -0,0 +1,135 @@ +//go:build unittest + +package eth + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +// recordingBlockRPC serves one block and records every JSON-RPC method GetBlock issues. +type recordingBlockRPC struct { + mu sync.Mutex + block json.RawMessage + txCount int + methods []string +} + +func (m *recordingBlockRPC) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (m *recordingBlockRPC) Close() {} + +func (m *recordingBlockRPC) BatchCallContext(context.Context, []rpc.BatchElem) error { + return errors.New("not implemented") +} + +func (m *recordingBlockRPC) CallContext(_ context.Context, result interface{}, method string, _ ...interface{}) error { + m.mu.Lock() + m.methods = append(m.methods, method) + m.mu.Unlock() + switch method { + case "eth_getBlockByHash": + *(result.(*json.RawMessage)) = m.block + case "debug_traceBlockByHash": + *(result.(*[]rpcTraceResult)) = make([]rpcTraceResult, m.txCount) + } + return nil +} + +func (m *recordingBlockRPC) calls() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.methods...) +} + +func newGetBlockTestRPC(t *testing.T, txs []bchain.RpcTransaction) (*EthereumRPC, *recordingBlockRPC) { + t.Helper() + block := struct { + rpcHeader + rpcBlockTransactions + }{ + rpcHeader: rpcHeader{ + Hash: "0x" + strings.Repeat("aa", 32), + ParentHash: "0x" + strings.Repeat("bb", 32), + Difficulty: "0x1", + Number: "0x41eee8", + Time: "0x5b7c9f26", + Size: "0x200", + }, + rpcBlockTransactions: rpcBlockTransactions{Transactions: txs}, + } + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + mock := &recordingBlockRPC{block: raw, txCount: len(txs)} + b := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + Parser: NewEthereumParser(1, false), + ChainConfig: &Configuration{}, + bestHeader: stubHeader{n: 0x41eee8}, + } + return b, mock +} + +func countCalls(calls []string, method string) int { + n := 0 + for _, c := range calls { + if c == method { + n++ + } + } + return n +} + +func TestGetBlockEmptyBlockSkipsLogsAndTrace(t *testing.T) { + prev := bchain.ProcessInternalTransactions + bchain.ProcessInternalTransactions = true + defer func() { bchain.ProcessInternalTransactions = prev }() + + b, mock := newGetBlockTestRPC(t, nil) + block, err := b.GetBlock("0x"+strings.Repeat("aa", 32), 0) + if err != nil { + t.Fatalf("GetBlock: %v", err) + } + if len(block.Txs) != 0 || block.Height != 0x41eee8 { + t.Fatalf("unexpected block: height %d, %d txs", block.Height, len(block.Txs)) + } + calls := mock.calls() + if len(calls) != 1 || calls[0] != "eth_getBlockByHash" { + t.Fatalf("RPC calls = %v, want only eth_getBlockByHash for an empty block", calls) + } +} + +func TestGetBlockWithTransactionsFetchesLogsAndTrace(t *testing.T) { + prev := bchain.ProcessInternalTransactions + bchain.ProcessInternalTransactions = true + defer func() { bchain.ProcessInternalTransactions = prev }() + + tx := *testTx1.CoinSpecificData.(bchain.EthereumSpecificData).Tx + b, mock := newGetBlockTestRPC(t, []bchain.RpcTransaction{tx}) + block, err := b.GetBlock("0x"+strings.Repeat("aa", 32), 0) + if err != nil { + t.Fatalf("GetBlock: %v", err) + } + if len(block.Txs) != 1 { + t.Fatalf("got %d txs, want 1", len(block.Txs)) + } + calls := mock.calls() + for _, method := range []string{"eth_getBlockByHash", "eth_getLogs", "debug_traceBlockByHash"} { + if countCalls(calls, method) != 1 { + t.Fatalf("RPC calls = %v, want exactly one %s", calls, method) + } + } +} diff --git a/bchain/coins/eth/ignored_contract_test.go b/bchain/coins/eth/ignored_contract_test.go new file mode 100644 index 0000000000..1fbfc99683 --- /dev/null +++ b/bchain/coins/eth/ignored_contract_test.go @@ -0,0 +1,245 @@ +//go:build unittest + +package eth + +import ( + "math/big" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +const testIgnoredContract = "0x0000000000000000000000000000000000001010" + +func ignoredTestTransferLog(contract string, value int64) *bchain.RpcLog { + return &bchain.RpcLog{ + Address: contract, + Topics: []string{ + tokenTransferEventSignature, + "0x0000000000000000000000002aacf811ac1a60081ea39f7783c0d26c500871a8", + "0x000000000000000000000000e9a5216ff992cfa01594d43501a56e12769eb9d2", + }, + Data: "0x" + big.NewInt(value).Text(16), + } +} + +func ignoredTestTransferSingleLog(contract string) *bchain.RpcLog { + return &bchain.RpcLog{ + Address: contract, + Topics: []string{ + tokenERC1155TransferSingleEventSignature, + "0x0000000000000000000000009248a6048a58db9f0212dc7cd85ee8741128be72", + "0x000000000000000000000000a3950b823cb063dd9afc0d27f35008b805b3ed53", + "0x0000000000000000000000004392faf3bb96b5694ecc6ef64726f61cdd4bb0ec", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000960000000000000000000000000000000000000000000000000000000000000011", + } +} + +func txWithReceiptLogs(logs ...*bchain.RpcLog) *bchain.Tx { + return &bchain.Tx{ + Txid: "0xtxid", + CoinSpecificData: bchain.EthereumSpecificData{Receipt: &bchain.RpcReceipt{Logs: logs}}, + } +} + +func newIgnoringTestParser(t *testing.T) *EthereumParser { + p := NewEthereumParser(1, false) + if err := p.IgnoreContract(testIgnoredContract); err != nil { + t.Fatal(err) + } + return p +} + +func requireTransferContracts(t *testing.T, got bchain.TokenTransfers, want ...string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d transfers %+v, want %d", len(got), got, len(want)) + } + for i, tr := range got { + if tr.Contract != EIP55AddressFromAddress(want[i]) { + t.Errorf("transfer %d contract = %s, want %s", i, tr.Contract, want[i]) + } + } +} + +// Post-Glamsterdam ETH -> token swap on Sepolia (tx 0x83b1a2e8…, block 11856400): the two +// EIP-7708 logs mirror the ETH paid into the router and wrapped into WETH, only the WETH and +// the bought token transfers are token transfers. +func TestEthereumParser_SkipsEIP7708TransferLogs(t *testing.T) { + const ( + weth = "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9" + bought = "0x5f2E83cCDEa73D60aF400e03F1Cd8Fb9eaB07b20" + ) + tx := txWithReceiptLogs( + &bchain.RpcLog{ + Address: "0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE", + Topics: []string{ + tokenTransferEventSignature, + "0x0000000000000000000000007757d12199d98cf46b9af7be5072495fca4c983c", + "0x0000000000000000000000008f6eb7870334b1fd8006fd52413f01689f4e57e9", + }, + Data: "0x0000000000000000000000000000000000000000000000000000033ce0891000", + }, + &bchain.RpcLog{ + Address: "0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE", + Topics: []string{ + tokenTransferEventSignature, + "0x0000000000000000000000008f6eb7870334b1fd8006fd52413f01689f4e57e9", + "0x0000000000000000000000007b79995e5f793a07bc00c21412e50ecae098e7f9", + }, + Data: "0x0000000000000000000000000000000000000000000000000000033ce0891000", + }, + &bchain.RpcLog{ // WETH Deposit + Address: weth, + Topics: []string{ + "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c", + "0x0000000000000000000000008f6eb7870334b1fd8006fd52413f01689f4e57e9", + }, + Data: "0x0000000000000000000000000000000000000000000000000000033ce0891000", + }, + &bchain.RpcLog{ + Address: weth, + Topics: []string{ + tokenTransferEventSignature, + "0x0000000000000000000000008f6eb7870334b1fd8006fd52413f01689f4e57e9", + "0x000000000000000000000000f32e24b7f739c7c17544cb972833ab551121a72b", + }, + Data: "0x0000000000000000000000000000000000000000000000000000033ce0891000", + }, + &bchain.RpcLog{ + Address: bought, + Topics: []string{ + tokenTransferEventSignature, + "0x000000000000000000000000f32e24b7f739c7c17544cb972833ab551121a72b", + "0x0000000000000000000000007757d12199d98cf46b9af7be5072495fca4c983c", + }, + Data: "0x0000000000000000000000000000000000000000000000000000000014318826", + }, + ) + got, err := NewEthereumParser(1, false).EthereumTypeGetTokenTransfersFromTx(tx) + if err != nil { + t.Fatal(err) + } + requireTransferContracts(t, got, weth, bought) + if got[1].Value.Int64() != 0x14318826 { + t.Errorf("bought token value = %s, want %d", got[1].Value.String(), 0x14318826) + } +} + +func TestEthereumParser_SkipsIgnoredContractLogs(t *testing.T) { + const erc20 = "0x76a45e8976499ab9ae223cc584019341d5a84e96" + const erc1155 = "0x6Fd712E3A5B556654044608F9129040A4839E36c" + tests := []struct { + name string + logs []*bchain.RpcLog + want []string + }{ + {name: "ignored only", logs: []*bchain.RpcLog{ignoredTestTransferLog(testIgnoredContract, 1)}}, + {name: "ignored between tokens", logs: []*bchain.RpcLog{ignoredTestTransferLog(erc20, 1), ignoredTestTransferLog(testIgnoredContract, 2), ignoredTestTransferLog(erc20, 3)}, want: []string{erc20, erc20}}, + {name: "ignored first", logs: []*bchain.RpcLog{ignoredTestTransferLog(testIgnoredContract, 1), ignoredTestTransferLog(erc20, 2)}, want: []string{erc20}}, + {name: "ignored ERC1155", logs: []*bchain.RpcLog{ignoredTestTransferSingleLog(testIgnoredContract), ignoredTestTransferSingleLog(erc1155)}, want: []string{erc1155}}, + {name: "ignored in mixed case", logs: []*bchain.RpcLog{ignoredTestTransferLog("0X0000000000000000000000000000000000001010", 1), ignoredTestTransferLog(erc20, 2)}, want: []string{erc20}}, + {name: "nothing ignored", logs: []*bchain.RpcLog{ignoredTestTransferLog(erc20, 1), ignoredTestTransferLog(erc20, 2)}, want: []string{erc20, erc20}}, + } + p := newIgnoringTestParser(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := p.EthereumTypeGetTokenTransfersFromTx(txWithReceiptLogs(tt.logs...)) + if err != nil { + t.Fatal(err) + } + requireTransferContracts(t, got, tt.want...) + }) + } +} + +// Without a receipt the transfer comes from the call payload; a transfer() call to an ignored +// contract moves the native coin as well. +func TestEthereumParser_SkipsIgnoredContractPayload(t *testing.T) { + const payload = "0xa9059cbb000000000000000000000000e9a5216ff992cfa01594d43501a56e12769eb9d20000000000000000000000000000000000000000000000000000000000000123" + pendingTx := func(to string) *bchain.Tx { + return &bchain.Tx{ + Txid: "0xtxid", + CoinSpecificData: bchain.EthereumSpecificData{Tx: &bchain.RpcTransaction{ + From: "0x2aacf811ac1a60081ea39f7783c0d26c500871a8", + To: to, + Payload: payload, + }}, + } + } + p := newIgnoringTestParser(t) + got, err := p.EthereumTypeGetTokenTransfersFromTx(pendingTx(testIgnoredContract)) + if err != nil { + t.Fatal(err) + } + requireTransferContracts(t, got) + + const erc20 = "0x76a45e8976499ab9ae223cc584019341d5a84e96" + got, err = p.EthereumTypeGetTokenTransfersFromTx(pendingTx(erc20)) + if err != nil { + t.Fatal(err) + } + requireTransferContracts(t, got, erc20) +} + +func TestEthereumParser_EthereumTypeIsIgnoredContract(t *testing.T) { + p := NewEthereumParser(1, false) + desc := func(address string) bchain.AddressDescriptor { + d, err := p.GetAddrDescFromAddress(address) + if err != nil { + t.Fatal(err) + } + return d + } + if !p.EthereumTypeIsIgnoredContract(desc(eip7708SystemAddress)) { + t.Error("EIP-7708 system address not ignored by default") + } + if p.EthereumTypeIsIgnoredContract(desc("0xffffffffffffffffffffffffffffffffffffffff")) { + t.Error("neighbouring address ignored") + } + if p.EthereumTypeIsIgnoredContract(nil) { + t.Error("nil descriptor ignored") + } + if p.EthereumTypeIsIgnoredContract(desc(testIgnoredContract)) { + t.Error("contract ignored before IgnoreContract") + } + if err := p.IgnoreContract("0x1010"); err == nil { + t.Error("malformed address accepted") + } + if err := p.IgnoreContract(testIgnoredContract); err != nil { + t.Fatal(err) + } + if !p.EthereumTypeIsIgnoredContract(desc(testIgnoredContract)) { + t.Error("contract not ignored after IgnoreContract") + } + if !p.EthereumTypeIsIgnoredContract(desc(eip7708SystemAddress)) { + t.Error("EIP-7708 system address no longer ignored after IgnoreContract") + } + // each parser has its own set + if NewEthereumParser(1, false).EthereumTypeIsIgnoredContract(desc(testIgnoredContract)) { + t.Error("IgnoreContract leaked into another parser") + } +} + +func Test_isIgnoredContract(t *testing.T) { + ignored := newIgnoringTestParser(t).ignoredContracts + tests := []struct { + address string + want bool + }{ + {address: testIgnoredContract, want: true}, + {address: "0000000000000000000000000000000000001010"}, + {address: "0x1010"}, + {address: "0x000000000000000000000000000000000000101g"}, + {address: ""}, + } + for _, tt := range tests { + if got := isIgnoredContract(ignored, tt.address); got != tt.want { + t.Errorf("isIgnoredContract(%q) = %v, want %v", tt.address, got, tt.want) + } + } + if isIgnoredContract(nil, testIgnoredContract) { + t.Error("empty set ignores a contract") + } +} diff --git a/bchain/coins/eth/infurafees.go b/bchain/coins/eth/infurafees.go index a81b5940aa..ae02cc2481 100644 --- a/bchain/coins/eth/infurafees.go +++ b/bchain/coins/eth/infurafees.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/golang/glog" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" @@ -75,19 +74,12 @@ type infuraFeesResult struct { BaseFeeTrend string `json:"baseFeeTrend"` } -type infuraFeeParams struct { - URL string `json:"url"` - PeriodSeconds int `json:"periodSeconds"` -} - type infuraFeeProvider struct { *alternativeFeeProvider - params infuraFeeParams + params feeProviderParams 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"}} @@ -104,31 +96,21 @@ func NewInfuraFeesProvider(chain bchain.BlockChain, params string, metrics *comm } 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() + // Fees are fetched on demand: cached for periodSeconds, kept through + // throttling bursts for the configured stale window (defaults to 10 minutes). + p.ttl = time.Duration(p.params.PeriodSeconds) * time.Second + p.staleSyncDuration = feeStaleDuration(p.params.PeriodSeconds, p.params.StaleSeconds) + p.fetch = p.fetchFees + go p.warmUp() 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 (p *infuraFeeProvider) fetchFees() (*bchain.Eip1559Fees, error) { + var data infuraFeesResult + if err := p.getData(&data); err != nil { + return nil, err } + return infuraFeesFromData(&data), nil } func bigIntFromFloatString(s string) *big.Int { @@ -159,7 +141,7 @@ func rangeFromString(feeRange []string) []*big.Int { return result } -func (p *infuraFeeProvider) processData(data *infuraFeesResult) bool { +func infuraFeesFromData(data *infuraFeesResult) *bchain.Eip1559Fees { fees := bchain.Eip1559Fees{} fees.BaseFeePerGas = bigIntFromFloatString(data.BaseFee) fees.High = infuraFeesFromResult(&data.High) @@ -171,11 +153,7 @@ func (p *infuraFeeProvider) processData(data *infuraFeesResult) bool { 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 + return &fees } func (p *infuraFeeProvider) getData(res interface{}) error { @@ -185,7 +163,7 @@ func (p *infuraFeeProvider) getData(res interface{}) error { return err } httpReq.Header.Set("Content-Type", "application/json") - httpRes, err := http.DefaultClient.Do(httpReq) + httpRes, err := feeHTTPClient.Do(httpReq) if httpRes != nil { defer httpRes.Body.Close() } diff --git a/bchain/coins/eth/infurafees_test.go b/bchain/coins/eth/infurafees_test.go index f1a51fa5c7..e0bfdc3183 100644 --- a/bchain/coins/eth/infurafees_test.go +++ b/bchain/coins/eth/infurafees_test.go @@ -3,24 +3,47 @@ package eth import ( "testing" "time" + + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" ) -func TestInfuraFeeStaleDuration(t *testing.T) { - got := infuraFeeStaleDuration(60) - want := 30 * time.Minute +func TestFeeStaleDurationDefaultsToTenMinutes(t *testing.T) { + got := feeStaleDuration(10, 0) + want := 10 * time.Minute + if got != want { + t.Fatalf("feeStaleDuration(10, 0) = %s, want %s", got, want) + } +} + +func TestFeeStaleDurationHonorsConfig(t *testing.T) { + got := feeStaleDuration(10, 90) + want := 90 * time.Second + if got != want { + t.Fatalf("feeStaleDuration(10, 90) = %s, want %s", got, want) + } +} + +func TestFeeStaleDurationClampsToPeriod(t *testing.T) { + got := feeStaleDuration(60, 30) + want := 60 * time.Second if got != want { - t.Fatalf("infuraFeeStaleDuration(60) = %s, want %s", got, want) + t.Fatalf("feeStaleDuration(60, 30) = %s, want %s (must not be shorter than the poll period)", got, want) } } func TestInfuraFeeProviderUsesCachedFeesDuringStaleWindow(t *testing.T) { provider := &infuraFeeProvider{ alternativeFeeProvider: &alternativeFeeProvider{ - staleSyncDuration: infuraFeeStaleDuration(60), + ttl: 10 * time.Second, + staleSyncDuration: feeStaleDuration(10, 0), + fetch: func() (*bchain.Eip1559Fees, error) { + return nil, errors.New("provider down") + }, }, } - provider.processData(&infuraFeesResult{ + provider.eip1559Fees = infuraFeesFromData(&infuraFeesResult{ BaseFee: "10", Low: infuraFeeResult{ MaxPriorityFeePerGas: "1", @@ -37,7 +60,7 @@ func TestInfuraFeeProviderUsesCachedFeesDuringStaleWindow(t *testing.T) { }) provider.mux.Lock() - provider.lastSync = time.Now().Add(-29 * time.Minute) + provider.lastSync = time.Now().Add(-9 * time.Minute) provider.mux.Unlock() fees, err := provider.GetEip1559Fees() @@ -49,7 +72,7 @@ func TestInfuraFeeProviderUsesCachedFeesDuringStaleWindow(t *testing.T) { } provider.mux.Lock() - provider.lastSync = time.Now().Add(-31 * time.Minute) + provider.lastSync = time.Now().Add(-11 * time.Minute) provider.mux.Unlock() fees, err = provider.GetEip1559Fees() diff --git a/bchain/coins/eth/mempool_nonce_watermark_test.go b/bchain/coins/eth/mempool_nonce_watermark_test.go new file mode 100644 index 0000000000..f5a64f9dfc --- /dev/null +++ b/bchain/coins/eth/mempool_nonce_watermark_test.go @@ -0,0 +1,136 @@ +package eth + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/trezor/blockbook/bchain" +) + +// pendingTxRPC serves eth_getTransactionByHash from a map and answers null for anything else, +// including every receipt - the shape of a backend that only holds pending transactions. +type pendingTxRPC struct { + txs map[string]*bchain.RpcTransaction +} + +func (m *pendingTxRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (m *pendingTxRPC) Close() {} + +func (m *pendingTxRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + raw := []byte("null") + if method == "eth_getTransactionByHash" { + if tx, ok := m.txs[args[0].(interface{ String() string }).String()]; ok { + raw, _ = json.Marshal(tx) + } + } + return json.Unmarshal(raw, result) +} + +const ( + watermarkSender = "0x1111111111111111111111111111111111111111" + watermarkRecipient = "0x2222222222222222222222222222222222222222" +) + +func pendingTx(hash, from, to, nonce string) *bchain.RpcTransaction { + return &bchain.RpcTransaction{Hash: hash, From: from, To: to, AccountNonce: nonce, Value: "0x0", GasLimit: "0x5208", GasPrice: "0x1"} +} + +// newWatermarkTestRPC wires a real MempoolEthereumType over the fake backend and indexes every +// transaction the backend serves, as the pending-tx subscription would. +func newWatermarkTestRPC(t *testing.T, txs ...*bchain.RpcTransaction) (*EthereumRPC, *pendingTxRPC) { + t.Helper() + rpc := &pendingTxRPC{txs: map[string]*bchain.RpcTransaction{}} + b := &EthereumRPC{RPC: rpc, Parser: NewEthereumParser(1, false), Timeout: time.Second, mempoolInitialized: true} + b.Mempool = bchain.NewMempoolEthereumType(b, time.Hour, false) + for _, tx := range txs { + rpc.txs[tx.Hash] = tx + if !b.Mempool.AddTransactionToMempool(tx.Hash) { + t.Fatalf("AddTransactionToMempool(%s) = false", tx.Hash) + } + } + return b, rpc +} + +func mempoolTxids(t *testing.T, b *EthereumRPC, address string) []string { + t.Helper() + outpoints, err := b.Mempool.GetTransactions(address) + if err != nil { + t.Fatalf("GetTransactions(%s) error = %v", address, err) + } + txids := make([]string, 0, len(outpoints)) + for _, o := range outpoints { + txids = append(txids, o.Txid) + } + return txids +} + +func sameTxids(got []string, want ...string) bool { + if len(got) != len(want) { + return false + } + set := map[string]struct{}{} + for _, txid := range got { + set[txid] = struct{}{} + } + for _, txid := range want { + if _, ok := set[txid]; !ok { + return false + } + } + return true +} + +// A null eth_getTransactionByHash is not proof the transaction is gone, so GetTransaction must report +// ErrTxNotFound without touching the mempool index (#1709). +func TestGetTransactionNullKeepsMempoolEntry(t *testing.T) { + const hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + b, rpc := newWatermarkTestRPC(t, pendingTx(hash, watermarkSender, watermarkRecipient, "0x5")) + delete(rpc.txs, hash) + + if _, err := b.GetTransaction(hash); err != bchain.ErrTxNotFound { + t.Fatalf("GetTransaction() error = %v, want ErrTxNotFound", err) + } + if got := mempoolTxids(t, b, watermarkSender); len(got) != 1 || got[0] != hash { + t.Fatalf("sender mempool txids = %v, want [%s] (a null answer must not evict)", got, hash) + } +} + +// A mined transaction retires the sender's pending entries at or below its nonce - including the +// alternative provider's copy - and leaves higher nonces and other senders alone. +func TestRemoveSupersededFromMempoolRetiresLowerNonces(t *testing.T) { + const ( + stale = "0x0000000000000000000000000000000000000000000000000000000000000005" + replaced = "0x0000000000000000000000000000000000000000000000000000000000000006" + next = "0x0000000000000000000000000000000000000000000000000000000000000007" + incoming = "0x0000000000000000000000000000000000000000000000000000000000000106" + ) + b, _ := newWatermarkTestRPC(t, + pendingTx(stale, watermarkSender, watermarkRecipient, "0x5"), + pendingTx(replaced, watermarkSender, watermarkRecipient, "0x6"), + pendingTx(next, watermarkSender, watermarkRecipient, "0x7"), + pendingTx(incoming, watermarkRecipient, watermarkSender, "0x6"), + ) + b.alternativeSendTxProvider = &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: map[string]storedTx{stale: {tx: pendingTx(stale, watermarkSender, watermarkRecipient, "0x5")}}, + } + + // the mined replacement of nonce 6 is a different hash - the RBF/cancel case + b.removeSupersededFromMempool(pendingTx("0xfeed", watermarkSender, watermarkRecipient, "0x6")) + + if got := mempoolTxids(t, b, watermarkSender); !sameTxids(got, next, incoming) { + t.Fatalf("sender mempool txids = %v, want next and incoming", got) + } + if got := mempoolTxids(t, b, watermarkRecipient); len(got) != 2 { + t.Fatalf("recipient mempool txids = %v, want next and incoming", got) + } + if _, found := b.alternativeSendTxProvider.mempoolTxs[stale]; found { + t.Fatal("alternative provider still caches the retired transaction") + } +} diff --git a/bchain/coins/eth/multicall.go b/bchain/coins/eth/multicall.go index 3e00e6ebbc..a4d1a0d9c3 100644 --- a/bchain/coins/eth/multicall.go +++ b/bchain/coins/eth/multicall.go @@ -2,15 +2,19 @@ package eth import ( "context" + "encoding/binary" "encoding/hex" "errors" "fmt" "math/big" "strings" + "sync/atomic" + "time" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/golang/glog" "github.com/trezor/blockbook/bchain" + "golang.org/x/sync/singleflight" ) // Canonical Multicall3 deployment, identical address on every major EVM chain. @@ -19,18 +23,57 @@ const multicall3Address = "0xcA11bde05977b3631167028862bE2a173976CA11" // Function selector for aggregate3((address,bool,bytes)[]). // Verified: keccak256("aggregate3((address,bool,bytes)[])")[:4]. -const multicall3Aggregate3Signature = "0x82ad56cb" +var multicall3Aggregate3Selector = []byte{0x82, 0xad, 0x56, 0xcb} -// multicall3Probe states; Unprobed is the zero value. +// multicall3Gate.state values; 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") +// multicall3MaxProbeFailures is the consecutive transient probe failures before probing pauses. +const multicall3MaxProbeFailures = 5 + +// multicall3ProbeSuspendInterval is the probing pause: no per-request probes on a restricted +// provider, yet multicall is never disabled for good. +const multicall3ProbeSuspendInterval = 10 * time.Minute + +// multicall3MaxCallsPerAggregate bounds one aggregate3 eth_call by its shared gas budget; +// deliberately independent of erc20BatchSize, which exists for JSON-RPC request-count limits. +const multicall3MaxCallsPerAggregate = 100 + +// errMulticall3NotDeployed is returned on chains where Multicall3 is not deployed +// at the probed address (canonical, or a per-chain override); cached for the process lifetime. +var errMulticall3NotDeployed = errors.New("multicall3 not deployed on this chain") + +// multicall3Gate holds whether Multicall3 is usable on this chain: the probe verdict plus the +// consecutive-failure count and suspension deadline that keep a provider rejecting eth_getCode +// from being re-probed on every request. One field on EthereumRPC instead of four. +type multicall3Gate struct { + state atomic.Int32 + failures atomic.Int32 + suspendedUntil atomic.Int64 // unix nanos; 0 means not suspended + sf singleflight.Group +} + +// multicall3ContractAddress returns Multicall3AddressOverride when set (e.g. Tron's +// non-canonical deployment), otherwise the canonical const. +func (b *EthereumRPC) multicall3ContractAddress() string { + if b.Multicall3AddressOverride != "" { + return b.Multicall3AddressOverride + } + return multicall3Address +} + +// multicall3MaxCalls returns the configured sub-calls per aggregate3, else the default. +// Lower it for a node that aborts a long constant call before it runs out of gas. +func (b *EthereumRPC) multicall3MaxCalls() int { + if b.ChainConfig != nil && b.ChainConfig.Multicall3MaxCalls > 0 { + return b.ChainConfig.Multicall3MaxCalls + } + return multicall3MaxCallsPerAggregate +} // EthereumTypeMulticallAggregate3 issues an aggregate3 batch as one eth_call, // observing all sub-calls at the same block (pinned to blockNumber, or @@ -53,18 +96,26 @@ func (b *EthereumRPC) EthereumTypeMulticallAggregate3(calls []bchain.EthereumMul if err != nil { return nil, fmt.Errorf("multicall3 encode: %w", err) } - resp, err := b.EthereumTypeRpcCallAtBlock(encoded, multicall3Address, "", blockNumber) + // coalescing metrics: one physical request carrying len(calls) reads + b.observeEthCallBatch(len(calls)) + b.observeEthCallMulticallRequest() + resp, err := b.ethCallAtBlock(encoded, b.multicall3ContractAddress(), "", blockNumber, "multicall", len(calls)) if err != nil { return nil, err } - return decodeAggregate3Result(resp) + results, err := decodeAggregate3Result(resp, len(calls)) + if err != nil { + return nil, err + } + return results, nil } -// probeMulticall3 reports whether Multicall3 is deployed at the canonical -// address. Three outcomes: +// probeMulticall3 reports whether Multicall3 is deployed at the probed address +// (canonical, or a per-chain override). Three outcomes: // // - (true, nil) — deployed; deterministic, cached for the process lifetime. -// - (false, nil) — not deployed; deterministic, cached. +// - (false, nil) — not deployed; deterministic, cached. Also returned while +// probing is suspended after repeated transient failures. // - (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." @@ -72,42 +123,52 @@ func (b *EthereumRPC) EthereumTypeMulticallAggregate3(calls []bchain.EthereumMul // 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() { + switch b.multicall3.state.Load() { case multicall3Deployed: return true, nil case multicall3NotDeployed: return false, nil } + // while suspended, report unavailable (not an error); retried after the window + if until := b.multicall3.suspendedUntil.Load(); until != 0 && time.Now().UnixNano() < until { + return false, nil + } + type probeResult struct { deployed bool err error } - v, _, _ := b.multicall3ProbeSF.Do("multicall3", func() (interface{}, error) { + v, _, _ := b.multicall3.sf.Do("multicall3", func() (interface{}, error) { // Re-check: a peer may have completed before we entered Do. - if state := b.multicall3Probe.Load(); state != multicall3Unprobed { + if state := b.multicall3.state.Load(); state != multicall3Unprobed { return probeResult{deployed: state == multicall3Deployed}, nil } ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() + addr := b.multicall3ContractAddress() + // Probe at "latest": below the deployment height aggregate3 returns "0x", which + // decodes as an error and falls back safely. 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) + if err := b.RPC.CallContext(ctx, &code, "eth_getCode", addr, "latest"); err != nil { + // repeated failures: pause probing for a while instead of disabling multicall for good + if b.multicall3.failures.Add(1) >= multicall3MaxProbeFailures { + glog.Warningf("multicall3 probe at %s failed %d times; suspending probing for %s, using JSON-RPC batch: %v", addr, multicall3MaxProbeFailures, multicall3ProbeSuspendInterval, err) + b.multicall3.suspendedUntil.Store(time.Now().Add(multicall3ProbeSuspendInterval).UnixNano()) + b.multicall3.failures.Store(0) + return probeResult{}, nil + } + glog.Warningf("multicall3 probe at %s failed: %v (will retry on next call)", addr, 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) + glog.Infof("multicall3 not deployed at %s on this chain; multicall enrichments will be disabled", addr) + b.multicall3.state.Store(multicall3NotDeployed) return probeResult{}, nil } - b.multicall3Probe.Store(multicall3Deployed) + b.multicall3.state.Store(multicall3Deployed) return probeResult{deployed: true}, nil }) r := v.(probeResult) @@ -152,60 +213,48 @@ func encodeAggregate3(calls []bchain.EthereumMulticallCall) (string, error) { 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). + // Compute offset words first (relative to the start of the heads block). Each tuple encodes + // as 3 head words (address, bool, bytes-offset) + 1 length word + padded data. n := len(tuples) - headBytes := n * 32 offsets := make([]int, n) - cursor := headBytes + cursor := n * 32 for i, t := range tuples { offsets[i] = cursor - cursor += tupleSize(t) + cursor += 32*4 + paddedLen(len(t.payload)) } // 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...) + out = append(out, multicall3Aggregate3Selector...) // Outer offset: array starts immediately after this word. - out = append(out, padLeftWord(big.NewInt(0x20))...) + out = appendWord(out, 0x20) // Array length. - out = append(out, padLeftWord(big.NewInt(int64(n)))...) + out = appendWord(out, uint64(n)) // Heads. for _, off := range offsets { - out = append(out, padLeftWord(big.NewInt(int64(off)))...) + out = appendWord(out, uint64(off)) } // Tails. for _, t := range tuples { - // address - word := make([]byte, 32) + var word [32]byte copy(word[12:], t.target) - out = append(out, word...) - // bool - word = make([]byte, 32) + out = append(out, word[:]...) // address + word = [32]byte{} word[31] = t.bool32 - out = append(out, word...) + out = append(out, word[:]...) // bool // 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...) + out = appendWord(out, 0x60) + out = appendWord(out, uint64(len(t.payload))) + // bytes data, zero-padded up to the word boundary + out = append(out, t.payload...) + if pad := paddedLen(len(t.payload)) - len(t.payload); pad > 0 { + out = append(out, make([]byte, pad)...) + } } - return "0x" + hex.EncodeToString(out), nil + return hexutil.Encode(out), nil } // decodeAggregate3Result inverts encodeAggregate3's return encoding for (bool,bytes)[]. @@ -215,7 +264,10 @@ func encodeAggregate3(calls []bchain.EthereumMulticallCall) (string, error) { // 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) { +// +// expectedCalls is the number of sub-calls sent; a response claiming any other count is +// rejected before any returndata is materialized. +func decodeAggregate3Result(data string, expectedCalls int) ([]bchain.EthereumMulticallResult, error) { raw, err := hexToBytes(data) if err != nil { return nil, fmt.Errorf("decode hex: %w", err) @@ -224,58 +276,61 @@ func decodeAggregate3Result(data string) ([]bchain.EthereumMulticallResult, erro 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) + if v, ok := wordAsIndex(raw, 0); !ok || v != 0x20 { + return nil, fmt.Errorf("multicall3 unexpected outer offset") } headsStart := 64 - length := bigUintAt(raw, 32) - if !length.IsUint64() { + n, ok := wordAsIndex(raw, 32) + if !ok { 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 + // Checked before the tail loop: a response is otherwise free to claim len(raw)/32 + // elements and have every one of them decode a large tuple. + if n != expectedCalls { + return nil, fmt.Errorf("multicall3 returned %d results for %d calls", n, expectedCalls) } if len(raw) < headsStart+n*32 { return nil, fmt.Errorf("multicall3 response truncated in heads") } + // Head offsets may alias, so the per-element bounds below do not limit the total. + // A canonical response lays the tuples out disjointly, so their returndata can never + // sum past the response itself; that keeps the decode linear in len(raw). + decoded := 0 results := make([]bchain.EthereumMulticallResult, n) for i := 0; i < n; i++ { - offset := bigUintAt(raw, headsStart+i*32) - if !offset.IsUint64() { + offset, ok := wordAsIndex(raw, headsStart+i*32) + if !ok { return nil, fmt.Errorf("multicall3 element %d offset out of range", i) } - tupleStart := headsStart + int(offset.Uint64()) + tupleStart := headsStart + offset // 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 + results[i].Success = raw[tupleStart+31] == 1 - bytesOffset := bigUintAt(raw, tupleStart+32) - if !bytesOffset.IsUint64() { + bytesOffset, ok := wordAsIndex(raw, tupleStart+32) + if !ok { return nil, fmt.Errorf("multicall3 element %d bytes offset out of range", i) } - bytesPos := tupleStart + int(bytesOffset.Uint64()) + bytesPos := tupleStart + bytesOffset if len(raw) < bytesPos+32 { return nil, fmt.Errorf("multicall3 element %d truncated at bytes length", i) } - bytesLen := bigUintAt(raw, bytesPos) - if !bytesLen.IsUint64() { + bl, ok := wordAsIndex(raw, bytesPos) + if !ok { 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]) + decoded += bl + if decoded > len(raw) { + return nil, fmt.Errorf("multicall3 returndata exceeds the response at element %d", i) + } + results[i].Data = hexutil.Encode(raw[bytesPos+32 : bytesPos+32+bl]) } return results, nil } @@ -305,20 +360,30 @@ func hexToAddressBytes(s string) ([]byte, error) { return addr, nil } -func padLeftWord(v *big.Int) []byte { - word := make([]byte, 32) - v.FillBytes(word) - return word +// appendWord appends v as a 32-byte big-endian ABI word. +func appendWord(out []byte, v uint64) []byte { + var word [32]byte + binary.BigEndian.PutUint64(word[24:], v) + return append(out, word[:]...) } -func bigUintAt(buf []byte, offset int) *big.Int { - return new(big.Int).SetBytes(buf[offset : offset+32]) +// wordAsIndex reads the ABI word at offset as a length or offset into buf. Anything that +// cannot index buf is rejected, so callers never narrow a word that would wrap negative. +func wordAsIndex(buf []byte, offset int) (int, bool) { + word := buf[offset : offset+32] + for _, c := range word[:24] { + if c != 0 { + return 0, false + } + } + v := binary.BigEndian.Uint64(word[24:]) + if v > uint64(len(buf)) { + return 0, false + } + return int(v), true } // 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 index 3d5a615491..616c7f968f 100644 --- a/bchain/coins/eth/multicall_test.go +++ b/bchain/coins/eth/multicall_test.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "errors" "fmt" - "math/big" "strings" "sync" "sync/atomic" @@ -16,6 +15,9 @@ import ( "github.com/trezor/blockbook/bchain" ) +// testMulticall3OverrideAddress is an arbitrary override address matching no real deployment. +const testMulticall3OverrideAddress = "0x00000000000000000000000000000000000000fe" + // 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 { @@ -53,20 +55,20 @@ func TestEncodeAggregate3KnownLayout(t *testing.T) { 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) + if v, _ := wordAsIndex(raw, 4); v != 0x20 { + t.Fatalf("outer offset wrong: %d", 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) + if v, _ := wordAsIndex(raw, 4+32); v != 2 { + t.Fatalf("array length wrong: %d", 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) + if v, _ := wordAsIndex(raw, 4+64); v != 64 { + t.Fatalf("first head offset wrong: %d, 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) + if v, _ := wordAsIndex(raw, 4+96); v != 64+160 { + t.Fatalf("second head offset wrong: %d, 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 { @@ -214,7 +216,7 @@ func TestDecodeAggregate3RoundTripFixture(t *testing.T) { {Success: false, Data: "0x"}, {Success: true, Data: "0x" + strings.Repeat("ab", 64)}, // 64 bytes, exactly two padded words } - got, err := decodeAggregate3Result(fixtureAggregate3Result(expected)) + got, err := decodeAggregate3Result(fixtureAggregate3Result(expected), len(expected)) if err != nil { t.Fatalf("decode: %v", err) } @@ -233,17 +235,38 @@ func TestDecodeAggregate3RoundTripFixture(t *testing.T) { func TestDecodeAggregate3Rejects(t *testing.T) { cases := []struct { - name string - hex string + name string + hex string + calls int // sub-calls the request carried, so each case reaches its own guard }{ - {"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 + {"empty", "0x", 1}, + {"too short for header", "0x" + padHex32("20"), 1}, + {"bad outer offset", "0x" + padHex32("21") + padHex32("0"), 1}, + {"truncated heads", "0x" + padHex32("20") + padHex32("2") + padHex32("40"), 2}, // declares 2 elements but only 1 head word + // Words ≥ 2^63 wrap negative when narrowed to int; each must be rejected, never sliced with. + {"array length 2^63", "0x" + padHex32("20") + padHex32("8000000000000000"), 1}, + {"head offset 2^63", "0x" + padHex32("20") + padHex32("1") + padHex32("8000000000000000"), 1}, + {"bytes length 2^64-1", "0x" + padHex32("20") + padHex32("1") + padHex32("20") + // 1 element at heads+0x20 + padHex32("1") + padHex32("40") + padHex32("ffffffffffffffff"), 1}, // bool, bytes offset, absurd bytes length + // A count other than the one we sent must be refused before any returndata is built, + // or the response alone decides how many elements to allocate. + {"more results than calls", fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: true, Data: "0x01"}, {Success: true, Data: "0x02"}, + }), 1}, + {"fewer results than calls", fixtureAggregate3Result([]bchain.EthereumMulticallResult{ + {Success: true, Data: "0x01"}, + }), 2}, + {"empty array for a non-empty request", "0x" + padHex32("20") + padHex32("0"), 1}, + // Two heads aliasing one 256-byte tuple: each element passes its own bounds check + // (data ends exactly at the response end) but together they decode 512 of 480 bytes. + // Left unbounded, N aliased heads amplify one response into N copies of the tuple. + {"aliased heads exceed the response", "0x" + + padHex32("20") + padHex32("2") + padHex32("40") + padHex32("40") + + padHex32("1") + padHex32("40") + padHex32("100") + strings.Repeat("00", 256), 2}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if _, err := decodeAggregate3Result(tc.hex); err == nil { + if _, err := decodeAggregate3Result(tc.hex, tc.calls); err == nil { t.Fatalf("expected error for %q", tc.name) } }) @@ -261,6 +284,8 @@ type mockMulticallRPC struct { // answered with a stub "deployed" bytecode so existing multicall tests // don't need to care about the probe. getCodeHandler func(address string) (string, error) + // address both the probe and aggregate3 must target; empty means the canonical one. + target string ethCallCalls int getCodeCalls int @@ -272,6 +297,14 @@ func (m *mockMulticallRPC) callCounts() (ethCall, getCode int) { return m.ethCallCalls, m.getCodeCalls } +// expectedTarget is the address this mock requires the probe and aggregate3 to call. +func (m *mockMulticallRPC) expectedTarget() string { + if m.target != "" { + return m.target + } + return multicall3Address +} + func (m *mockMulticallRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { return nil, errors.New("not implemented") } @@ -293,7 +326,7 @@ func (m *mockMulticallRPC) CallContext(ctx context.Context, result interface{}, return errors.New("eth_getCode: missing args") } addr, _ := args[0].(string) - if !strings.EqualFold(addr, multicall3Address) { + if !strings.EqualFold(addr, m.expectedTarget()) { return fmt.Errorf("unexpected eth_getCode target: %s", addr) } if m.getCodeHandler == nil { @@ -320,7 +353,7 @@ func (m *mockMulticallRPC) CallContext(ctx context.Context, result interface{}, return errors.New("eth_call: bad args") } to, _ := argMap["to"].(string) - if !strings.EqualFold(to, multicall3Address) { + if !strings.EqualFold(to, m.expectedTarget()) { return fmt.Errorf("unexpected eth_call target: %s", to) } data, _ := argMap["data"].(string) @@ -338,6 +371,40 @@ func (m *mockMulticallRPC) CallContext(ctx context.Context, result interface{}, } } +// --- Per-chain Multicall3 address override --- + +func TestMulticall3ContractAddress(t *testing.T) { + // no override -> canonical const + if got := (&EthereumRPC{}).multicall3ContractAddress(); !strings.EqualFold(got, multicall3Address) { + t.Fatalf("no override: got %s, want canonical %s", got, multicall3Address) + } + // override wins + rpc := &EthereumRPC{Multicall3AddressOverride: testMulticall3OverrideAddress} + if got := rpc.multicall3ContractAddress(); !strings.EqualFold(got, testMulticall3OverrideAddress) { + t.Fatalf("override: got %s, want %s", got, testMulticall3OverrideAddress) + } +} + +// The override must steer both the probe (eth_getCode) and the aggregate3 call: the mock +// rejects any other target. +func TestMulticallAggregate3_UsesConfiguredAddressOverride(t *testing.T) { + expected := []bchain.EthereumMulticallResult{{Success: true, Data: "0xdead"}} + mock := &mockMulticallRPC{ + target: testMulticall3OverrideAddress, + handler: func(string) (string, error) { return fixtureAggregate3Result(expected), nil }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second, Multicall3AddressOverride: testMulticall3OverrideAddress} + got, err := rpc.EthereumTypeMulticallAggregate3([]bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03"}, + }, nil) + if err != nil { + t.Fatalf("aggregate3 error: %v", err) + } + if len(got) != 1 || !strings.EqualFold(got[0].Data, "0xdead") { + t.Fatalf("unexpected result: %+v", got) + } +} + func TestMulticallAggregate3EndToEnd(t *testing.T) { expected := []bchain.EthereumMulticallResult{ {Success: true, Data: "0xdeadbeef"}, @@ -406,7 +473,7 @@ func TestProbeMulticall3_DetectsDeployedAndCachesResult(t *testing.T) { 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 { + if state := rpc.multicall3.state.Load(); state != multicall3Deployed { t.Fatalf("expected state=Deployed, got %d", state) } } @@ -426,7 +493,7 @@ func TestProbeMulticall3_DetectsNotDeployedAndCachesResult(t *testing.T) { 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 { + if state := rpc.multicall3.state.Load(); state != multicall3NotDeployed { t.Fatalf("expected state=NotDeployed, got %d", state) } } @@ -453,7 +520,7 @@ func TestProbeMulticall3_TransientErrorRetriesNextCall(t *testing.T) { if got { t.Fatalf("first probe should report deployed=false on transient error, got=%v", got) } - if state := rpc.multicall3Probe.Load(); state != multicall3Unprobed { + if state := rpc.multicall3.state.Load(); state != multicall3Unprobed { t.Fatalf("transient error must NOT cache state, got %d", state) } if got, err := rpc.probeMulticall3(); err != nil || !got { @@ -462,7 +529,7 @@ func TestProbeMulticall3_TransientErrorRetriesNextCall(t *testing.T) { 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 { + if state := rpc.multicall3.state.Load(); state != multicall3Deployed { t.Fatalf("expected state=Deployed after recovery, got %d", state) } } @@ -536,7 +603,7 @@ func TestEthereumTypeMulticallAggregate3_NotDeployed_ShortCircuits(t *testing.T) }, } rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} - rpc.multicall3Probe.Store(multicall3NotDeployed) + rpc.multicall3.state.Store(multicall3NotDeployed) got, err := rpc.EthereumTypeMulticallAggregate3([]bchain.EthereumMulticallCall{ {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03"}, @@ -580,11 +647,70 @@ func TestEthereumTypeMulticallAggregate3_TransientProbeError_PropagatesAndIsDist 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 { + if state := rpc.multicall3.state.Load(); state != multicall3Unprobed { t.Fatalf("transient probe failure must not cache state, got %d", state) } } +// Repeated transient probe errors suspend probing (never latch not-deployed); +// probing resumes once the window elapses. +func TestProbeMulticall3_SuspendsAfterRepeatedFailures(t *testing.T) { + var failing atomic.Bool + failing.Store(true) + mock := &mockMulticallRPC{ + handler: func(string) (string, error) { + t.Fatal("eth_call must not be issued while probing fails") + return "", nil + }, + getCodeHandler: func(string) (string, error) { + if failing.Load() { + return "", errors.New("rpc down") + } + return "0x6080604052", nil + }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + + // The first K-1 attempts stay transient (unprobed, retry on next call). + for i := 1; i < multicall3MaxProbeFailures; i++ { + if deployed, err := rpc.probeMulticall3(); deployed || err == nil { + t.Fatalf("attempt %d: want transient (false, err), got (%v, %v)", i, deployed, err) + } + if state := rpc.multicall3.state.Load(); state != multicall3Unprobed { + t.Fatalf("attempt %d: must not cache state yet, got %d", i, state) + } + } + // The K-th failure suspends probing: (false, nil), no deployment verdict cached. + if deployed, err := rpc.probeMulticall3(); deployed || err != nil { + t.Fatalf("suspending attempt: want (false, nil), got (%v, %v)", deployed, err) + } + if state := rpc.multicall3.state.Load(); state != multicall3Unprobed { + t.Fatalf("suspension must not cache a deployment verdict, got %d", state) + } + if rpc.multicall3.suspendedUntil.Load() <= 0 { + t.Fatal("want a suspension deadline stored") + } + // While suspended, further calls report unavailable without any eth_getCode. + if deployed, err := rpc.probeMulticall3(); deployed || err != nil { + t.Fatalf("suspended probe: want (false, nil), got (%v, %v)", deployed, err) + } + if _, getCode := mock.callCounts(); getCode != multicall3MaxProbeFailures { + t.Fatalf("want exactly %d eth_getCode probes (suspended after), got %d", multicall3MaxProbeFailures, getCode) + } + // Once the window elapses and the node recovers, the next probe retries and caches Deployed. + rpc.multicall3.suspendedUntil.Store(time.Now().Add(-time.Second).UnixNano()) + failing.Store(false) + if deployed, err := rpc.probeMulticall3(); !deployed || err != nil { + t.Fatalf("post-suspension probe: want (true, nil), got (%v, %v)", deployed, err) + } + if _, getCode := mock.callCounts(); getCode != multicall3MaxProbeFailures+1 { + t.Fatalf("want %d eth_getCode probes after the window elapses, got %d", multicall3MaxProbeFailures+1, getCode) + } + if state := rpc.multicall3.state.Load(); state != multicall3Deployed { + t.Fatalf("want cached multicall3Deployed after recovery, 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. diff --git a/bchain/coins/eth/nonce_hint_test.go b/bchain/coins/eth/nonce_hint_test.go new file mode 100644 index 0000000000..54cee25f2f --- /dev/null +++ b/bchain/coins/eth/nonce_hint_test.go @@ -0,0 +1,201 @@ +//go:build unittest + +package eth + +import ( + "testing" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain" +) + +// TestEthereumTypeGetNonces_PrivatePendingHint_RoutesUnknownAddress verifies the declared +// private-pending hint short-circuits routing: an address that is NOT a recent private sender (so +// useForNonces is false and it would otherwise go to the primary RPC) is routed to the alternative +// provider purely because the request declared an in-flight private nonce. +func TestEthereumTypeGetNonces_PrivatePendingHint_RoutesUnknownAddress(t *testing.T) { + // the answering relay node does not count the declared tx at its pending tag - lagging, regressed + // below its window, or never given the tx - so it answers 42 for a tx occupying exactly that + // slot; the declared floor is the safety net that closes it + server := newNonceRPCServer(t, map[string]string{"pending": "0x2a"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + // no recent senders → without the hint this address would be served by the primary RPC + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 43 { + t.Errorf("pending = %d, want 43 (the declared nonce 42 is in flight, so it is not handed out again)", pending) + } + if got := server.callCount("pending"); got != 1 { + t.Errorf("alternative provider queried %d times, want 1 (hint must route despite no recent send)", got) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_ProviderAnswerWins confirms the floor only ever +// raises: a provider answer already past the declared nonce stands unchanged. +func TestEthereumTypeGetNonces_PrivatePendingHint_ProviderAnswerWins(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x64"}, nil) // 100 + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 100 { + t.Errorf("pending = %d, want 100 (the declared nonce is long consumed, the provider answer stands)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_RaisesPrimaryFallback confirms the declared nonce is +// honored on the primary fallback path too: the hint routes to the provider, the provider fails, and +// the primary answer - which never knew the private tx - is raised past the declared slot so the +// wallet cannot reuse the nonce of the transaction it just declared. +func TestEthereumTypeGetNonces_PrivatePendingHint_RaisesPrimaryFallback(t *testing.T) { + server := newNonceRPCServer(t, nil, map[string]bool{"pending": true}) // provider errors + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 4) + if err != nil { + t.Fatalf("provider failure must fall back to the primary RPC, got error: %v", err) + } + if pending != 5 { + t.Errorf("pending = %d, want 5 (declared nonce 4 held over the primary fallback answer)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_WithConfirmedNonce exercises the exact production +// combination (api/worker.go passes WithConfirmedNonce together with PrivatePendingNonces...): the +// declaration must raise only the PENDING nonce and leave the confirmed (latest) nonce untouched. +func TestEthereumTypeGetNonces_PrivatePendingHint_WithConfirmedNonce(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x9", "latest": "0x5"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4", "latest": "0x2"}} + // no recent senders → routed purely by the declaration + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + // declared nonce 9 sits exactly at the provider's pending answer, so the walk advances to 10 + pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true, 9) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 10 { + t.Errorf("pending = %d, want 10 (declared nonce 9 is in flight)", pending) + } + if confirmed != 5 || !confirmedOK { + t.Errorf("confirmed = (%d, ok=%v), want (5, true) — the floor must not touch the confirmed nonce", confirmed, confirmedOK) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_RoutesOnDeclaredZero confirms a declared nonce of 0 +// (a wallet's very first tx) routes and advances the answer to 1 - the boundary the routing tests +// above (nonce 42) do not exercise. Both halves depend on 0 being read as a literal rather than a +// zero value: the gate is the declaration being non-empty, and the walk treats 0 as an occupied slot. +func TestEthereumTypeGetNonces_PrivatePendingHint_RoutesOnDeclaredZero(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x0"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x0"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 1 { + t.Errorf("pending = %d, want 1 (nonce 0 is in flight, so the walk advances past it)", pending) + } + if got := server.callCount("pending"); got != 1 { + t.Errorf("alternative provider queried %d times, want 1 (declared 0 must still route)", got) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none once routed to the provider", stub.queried) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_IgnoredWithoutProvider confirms the hint is a +// relay-deployment feature: with no alternative provider configured there is no private mempool for +// it to describe, so it is ignored and the primary answer stands unchanged. +func TestEthereumTypeGetNonces_PrivatePendingHint_IgnoredWithoutProvider(t *testing.T) { + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} // no alternativeSendTxProvider + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4 (hint ignored without a provider)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_WalksCachedAndDeclaredTogether pins that a declared +// nonce and a cached one hold a slot identically: the answer walks across both in one run. Here the +// relay has forgotten the tx it cached at 7 and answers 7, the cache still holds it, and the wallet +// declares the 8 it sent afterwards through another replica - so the next free slot is 9. +func TestEthereumTypeGetNonces_PrivatePendingHint_WalksCachedAndDeclaredTogether(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x7"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x1"}} + sender := ethcommon.BytesToAddress(nonceTestAddr) + provider := newRecentSenderProvider(server, sender) + provider.fetchMempoolTx = true + provider.mempoolTxs = map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: sender.Hex(), AccountNonce: "0x7"}, + time: uint32(time.Now().Unix()), + }, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: provider} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 8) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 9 { + t.Errorf("pending = %d, want 9 (cached 7 then declared 8, walked as one run)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_DoesNotJumpAHole pins that a declared nonce is held +// to the same contiguity rule as a cached one (#1675). The wallet declares a transaction at 42 while +// the backend still reports 4, so nothing fills 4..41: answering 43 would queue every later send +// behind slots nothing is filling. The answer stays at the backend's, which is a nonce the wallet can +// actually use, and 42 is still never handed out. +func TestEthereumTypeGetNonces_PrivatePendingHint_DoesNotJumpAHole(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x4"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x1"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4 (a declared nonce above a hole strands, it does not lift the answer over it)", pending) + } +} + +// TestEthereumTypeGetNonces_PrivatePendingHint_DeclaredSetFillsItsOwnRun covers what Suite actually +// sends: its whole pending-nonce set, sorted. A contiguous declared run walks to just past its end in +// one request, even though this instance cached none of those transactions. +func TestEthereumTypeGetNonces_PrivatePendingHint_DeclaredSetFillsItsOwnRun(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x4"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x1"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false, 4, 5, 6) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 7 { + t.Errorf("pending = %d, want 7 (declared 4,5,6 are all in flight)", pending) + } +} diff --git a/bchain/coins/eth/nonce_test.go b/bchain/coins/eth/nonce_test.go index 33d5a5942a..b85e4a3732 100644 --- a/bchain/coins/eth/nonce_test.go +++ b/bchain/coins/eth/nonce_test.go @@ -10,7 +10,9 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rpc" + "github.com/prometheus/client_golang/prometheus" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) // nonceTagFromArgs extracts the block tag ("pending"/"latest") from eth_getTransactionCount args. @@ -232,6 +234,304 @@ func TestEthereumTypeGetNonces_SequentialFallback_ConfirmedFailureIsBestEffort(t } } +// newRecentSenderProvider returns an alternative provider backed by server whose recentSenders +// map holds the given addresses, i.e. useForNonces routes exactly those to the provider. +func newRecentSenderProvider(server *nonceRPCServer, senders ...ethcommon.Address) *AlternativeSendTxProvider { + recentSenders := make(map[ethcommon.Address]recentSender) + for _, sender := range senders { + recentSenders[sender] = recentSender{time: time.Now(), url: server.URL} + } + return &AlternativeSendTxProvider{ + urls: []string{server.URL}, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + recentSenders: recentSenders, + } +} + +// TestEthereumTypeGetNonces_AlternativeProvider_SkippedForUnknownAddress is the regression test +// for #1627: an address without a recently privately-submitted tx must be served by the primary +// RPC without any round-trip to the alternative provider. +func TestEthereumTypeGetNonces_AlternativeProvider_SkippedForUnknownAddress(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x9"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4 from the primary RPC", pending) + } + if got := server.callCount("pending"); got != 0 { + t.Errorf("alternative provider queried %d times, want 0 for an address that did not send through it", got) + } +} + +func TestEthereumTypeGetNonces_AlternativeProvider_UsedForRecentSender(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x9"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + sender := ethcommon.BytesToAddress(nonceTestAddr) + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server, sender)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 9 { + t.Errorf("pending = %d, want 9 from the alternative provider", pending) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none for a recent private sender", stub.queried) + } +} + +func TestEthereumTypeGetNonces_AlternativeProvider_FallbackToPrimaryOnProviderError(t *testing.T) { + // without any cached private tx there is no floor to apply, so the primary answer stands + server := newNonceRPCServer(t, nil, map[string]bool{"pending": true}) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + sender := ethcommon.BytesToAddress(nonceTestAddr) + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: newRecentSenderProvider(server, sender)} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("provider failure must fall back to the primary RPC, got error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4 from the primary RPC fallback", pending) + } +} + +func TestEthereumTypeGetNonces_AlternativeProvider_FallbackRaisedToCacheFloor(t *testing.T) { + // the provider lookup fails and the primary answers 4: with the cache holding the sender's tx at + // nonce 4 the fallback must report 5, or a wallet building on the primary answer replaces that + // in-flight tx. The gap case is the other half of #1675 - nothing fills nonce 4, so the floor must + // NOT jump over the hole to the cached 0x7, or every later send queues behind it. + for _, tt := range []struct { + name string + cachedNonce string + want uint64 + }{ + {name: "contiguous with the primary answer", cachedNonce: "0x4", want: 5}, + {name: "gap below the cached tx", cachedNonce: "0x7", want: 4}, + } { + t.Run(tt.name, func(t *testing.T) { + server := newNonceRPCServer(t, nil, map[string]bool{"pending": true}) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + sender := ethcommon.BytesToAddress(nonceTestAddr) + provider := newRecentSenderProvider(server, sender) + provider.fetchMempoolTx = true + provider.mempoolTxs = map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: sender.Hex(), AccountNonce: tt.cachedNonce}, + time: uint32(time.Now().Unix()), + }, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: provider} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("provider failure must fall back to the primary RPC, got error: %v", err) + } + if pending != tt.want { + t.Errorf("pending = %d, want %d", pending, tt.want) + } + }) + } +} + +func TestEthereumTypeGetNonces_AlternativeProvider_ProviderAnswerRaisedToCacheFloor(t *testing.T) { + // a relay stops counting a still-pending tx once past its own pending window while Blockbook + // exposes it until the cache timeout: the provider answers 4 with the cache holding nonce 4, so + // the answer must become 5 and never contradict Blockbook's pending view. A gap stays at 4 (#1675). + for _, tt := range []struct { + name string + cachedNonce string + want uint64 + }{ + {name: "contiguous with the provider answer", cachedNonce: "0x4", want: 5}, + {name: "gap below the cached tx", cachedNonce: "0x7", want: 4}, + } { + t.Run(tt.name, func(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x4"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x2"}} + sender := ethcommon.BytesToAddress(nonceTestAddr) + provider := newRecentSenderProvider(server, sender) + provider.fetchMempoolTx = true + provider.mempoolTxs = map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: sender.Hex(), AccountNonce: tt.cachedNonce}, + time: uint32(time.Now().Unix()), + }, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: provider} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != tt.want { + t.Errorf("pending = %d, want %d", pending, tt.want) + } + if len(stub.queried) != 0 { + t.Errorf("primary RPC queried tags %v, want none for a successful provider lookup", stub.queried) + } + }) + } +} + +// TestEthereumTypeGetNonces_AlternativeProvider_ObservesNonceRequests asserts the +// eth_alternative_nonce_requests_total counter is incremented only for lookups actually routed to the +// alternative provider (labeled success/error), and never for a gated-out address served by the primary +// RPC - so the metric measures the small gated subset, not the hot eth_getTransactionCount endpoint. +func TestEthereumTypeGetNonces_AlternativeProvider_ObservesNonceRequests(t *testing.T) { + newMetrics := func() *common.Metrics { + return &common.Metrics{ + EthAlternativeNonceRequests: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_alt_nonce_requests_total"}, []string{"result"}), + } + } + sender := ethcommon.BytesToAddress(nonceTestAddr) + + t.Run("routed provider success increments result=success", func(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x9"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + m := newMetrics() + b := &EthereumRPC{RPC: stub, Timeout: time.Second, metrics: m, alternativeSendTxProvider: newRecentSenderProvider(server, sender)} + + if _, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := counterVecValue(t, m.EthAlternativeNonceRequests, "result", "success"); got != 1 { + t.Errorf("result=success = %v, want 1", got) + } + if got := counterVecValue(t, m.EthAlternativeNonceRequests, "result", "error"); got != 0 { + t.Errorf("result=error = %v, want 0", got) + } + }) + + t.Run("routed provider error increments result=error", func(t *testing.T) { + server := newNonceRPCServer(t, nil, map[string]bool{"pending": true}) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + m := newMetrics() + b := &EthereumRPC{RPC: stub, Timeout: time.Second, metrics: m, alternativeSendTxProvider: newRecentSenderProvider(server, sender)} + + if _, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false); err != nil { + t.Fatalf("provider failure must fall back to the primary RPC, got error: %v", err) + } + if got := counterVecValue(t, m.EthAlternativeNonceRequests, "result", "error"); got != 1 { + t.Errorf("result=error = %v, want 1", got) + } + if got := counterVecValue(t, m.EthAlternativeNonceRequests, "result", "success"); got != 0 { + t.Errorf("result=success = %v, want 0", got) + } + }) + + t.Run("gated-out address records nothing", func(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x9"}, nil) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + m := newMetrics() + // no recent senders: the address is served by the primary RPC and must not be counted + b := &EthereumRPC{RPC: stub, Timeout: time.Second, metrics: m, alternativeSendTxProvider: newRecentSenderProvider(server)} + + if _, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + total := counterVecValue(t, m.EthAlternativeNonceRequests, "result", "success") + + counterVecValue(t, m.EthAlternativeNonceRequests, "result", "error") + if total != 0 { + t.Errorf("nonce-request counter = %v, want 0 for a gated-out address", total) + } + }) +} + +func TestEthereumTypeGetNonces_AlternativeProvider_FloorAppliedWithoutRouting(t *testing.T) { + // the routing entry can expire before the cached tx stops being exposed as pending + // (recentSender.time is stamped at send time, storedTx.time at the later fetch-back time, + // and the reconcile ticker adds up to a minute of granularity) - the floor must hold even + // when the gate no longer routes the sender to the provider + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + sender := ethcommon.BytesToAddress(nonceTestAddr) + provider := &AlternativeSendTxProvider{ + urls: []string{"http://127.0.0.1:1"}, + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: sender.Hex(), AccountNonce: "0x4"}, + time: uint32(time.Now().Unix()), + }, + }, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second, alternativeSendTxProvider: provider} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 5 { + t.Errorf("pending = %d, want 5 (floor applied although the sender is not routed)", pending) + } +} + +// TestEthereumTypeGetNonces_AlternativeProvider_ObservesFloorMetrics pins which floor counter a request +// lands on: raised when the cache fills the slots above the backend's answer, stranded when it holds one +// above a slot nothing fills. A gap is BOTH not-raised and stranded, the answer staying the backend's - +// the point of the #1675 clamp. +func TestEthereumTypeGetNonces_AlternativeProvider_ObservesFloorMetrics(t *testing.T) { + for _, tt := range []struct { + name string + cachedNonce string + wantPending uint64 + wantRaised float64 + wantStranded float64 + }{ + {name: "contiguous raises the floor", cachedNonce: "0x4", wantPending: 5, wantRaised: 1}, + {name: "gap strands the cached tx", cachedNonce: "0x7", wantPending: 4, wantStranded: 1}, + {name: "already consumed nonce does neither", cachedNonce: "0x2", wantPending: 4}, + } { + t.Run(tt.name, func(t *testing.T) { + sender := ethcommon.BytesToAddress(nonceTestAddr) + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4"}} + m := &common.Metrics{ + EthAlternativePendingFloorRaised: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_alt_floor_raised_total"}, []string{"source"}), + EthAlternativePendingFloorStranded: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_alt_floor_stranded_total"}, []string{"source"}), + } + provider := &AlternativeSendTxProvider{ + urls: []string{"http://127.0.0.1:1"}, + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID, From: sender.Hex(), AccountNonce: tt.cachedNonce}, + time: uint32(time.Now().Unix()), + }, + }, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second, metrics: m, alternativeSendTxProvider: provider} + + pending, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != tt.wantPending { + t.Errorf("pending = %d, want %d", pending, tt.wantPending) + } + if got := counterVecValue(t, m.EthAlternativePendingFloorRaised, "source", "primary"); got != tt.wantRaised { + t.Errorf("floor_raised{primary} = %v, want %v", got, tt.wantRaised) + } + if got := counterVecValue(t, m.EthAlternativePendingFloorStranded, "source", "primary"); got != tt.wantStranded { + t.Errorf("floor_stranded{primary} = %v, want %v", got, tt.wantStranded) + } + }) + } +} + 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) diff --git a/bchain/coins/eth/oneinchfees.go b/bchain/coins/eth/oneinchfees.go index e42f5a6652..4b7243b863 100644 --- a/bchain/coins/eth/oneinchfees.go +++ b/bchain/coins/eth/oneinchfees.go @@ -9,7 +9,6 @@ import ( "strconv" "time" - "github.com/golang/glog" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" @@ -49,14 +48,9 @@ type oneInchFeeFeesResult struct { Instant oneInchFeeFeeResult `json:"instant"` } -type oneInchFeeParams struct { - URL string `json:"url"` - PeriodSeconds int `json:"periodSeconds"` -} - type oneInchFeeProvider struct { *alternativeFeeProvider - params oneInchFeeParams + params feeProviderParams apiKey string } @@ -67,7 +61,7 @@ func NewOneInchFeesProvider(chain bchain.BlockChain, params string, metrics *com if err != nil { return nil, err } - if p.params.URL == "" || p.params.PeriodSeconds == 0 { + 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") @@ -75,24 +69,21 @@ func NewOneInchFeesProvider(chain bchain.BlockChain, params string, metrics *com return nil, errors.New("NewOneInchFeesProvider: missing ONE_INCH_API_KEY env variable.") } p.chain = chain - go p.FeeDownloader() + // Fees are fetched on demand: cached for periodSeconds, kept through + // throttling bursts for the configured stale window (defaults to 10 minutes). + p.ttl = time.Duration(p.params.PeriodSeconds) * time.Second + p.staleSyncDuration = feeStaleDuration(p.params.PeriodSeconds, p.params.StaleSeconds) + p.fetch = p.fetchFees + go p.warmUp() 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 (p *oneInchFeeProvider) fetchFees() (*bchain.Eip1559Fees, error) { + var data oneInchFeeFeesResult + if err := p.getData(&data); err != nil { + return nil, err } + return oneInchFeesFromData(&data), nil } func bigIntFromString(s string) *big.Int { @@ -108,18 +99,19 @@ func oneInchFeesFromResult(result *oneInchFeeFeeResult) *bchain.Eip1559Fee { return &fee } -func (p *oneInchFeeProvider) processData(data *oneInchFeeFeesResult) bool { +func oneInchFeesFromData(data *oneInchFeeFeesResult) *bchain.Eip1559Fees { 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 + // 1inch's tiers are tighter than Infura's. Remap so the suite's low/medium/high map + // to comparable aggressiveness regardless of provider: + // 1inch medium → suite low + // 1inch high → suite medium + // 1inch instant → suite high + // 1inch's low tier is discarded (too conservative). + fees.Low = oneInchFeesFromResult(&data.Medium) + fees.Medium = oneInchFeesFromResult(&data.High) + fees.High = oneInchFeesFromResult(&data.Instant) + return &fees } func (p *oneInchFeeProvider) getData(res interface{}) error { @@ -130,7 +122,7 @@ func (p *oneInchFeeProvider) getData(res interface{}) error { } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", " Bearer "+p.apiKey) - httpRes, err := http.DefaultClient.Do(httpReq) + httpRes, err := feeHTTPClient.Do(httpReq) if httpRes != nil { defer httpRes.Body.Close() } diff --git a/bchain/coins/eth/rpchttp.go b/bchain/coins/eth/rpchttp.go new file mode 100644 index 0000000000..2068c940f4 --- /dev/null +++ b/bchain/coins/eth/rpchttp.go @@ -0,0 +1,53 @@ +package eth + +import ( + "net/http" + "net/url" + "strings" + + "github.com/ethereum/go-ethereum/rpc" +) + +const ( + // go-ethereum's default of 2 idle conns per host burns a socket per extra in-flight call + // and exhausts the ephemeral range; sized above -workers (16) as bitcoinrpc.go already is. + rpcMaxIdleConnsPerHost = 100 + // Bounds the pool across hosts, leaving room for a second endpoint. + rpcMaxIdleConns = 200 +) + +// One client per process: net/http pools per client, so a client per dial would pool nothing. +var rpcHTTPClient = newRPCHTTPClient() + +func newRPCHTTPClient() *http.Client { + // Clone keeps DefaultTransport's proxy, dial/TLS timeouts and HTTP/2; only the pool grows. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = rpcMaxIdleConns + transport.MaxIdleConnsPerHost = rpcMaxIdleConnsPerHost + // No client timeout: per-call rpc_timeout contexts govern, and a blanket one would cut + // long debug_traceBlockByHash reads. + return &http.Client{Transport: transport} +} + +// RPCDialOptions returns the dial options for rawURL, shared by every EVM coin: the pooled +// client for http(s), and an unlimited message size for ws(s), which needs no pooling. +func RPCDialOptions(rawURL string) []rpc.ClientOption { + if isWebsocketRPCURL(rawURL) { + return []rpc.ClientOption{rpc.WithWebsocketMessageSizeLimit(0)} + } + return []rpc.ClientOption{rpc.WithHTTPClient(rpcHTTPClient)} +} + +// isWebsocketRPCURL reports whether rawURL is dialed as a websocket; an unparsable or +// scheme-less URL falls through to HTTP, matching go-ethereum's own dispatch. +func isWebsocketRPCURL(rawURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return false + } + switch strings.ToLower(parsed.Scheme) { + case "ws", "wss": + return true + } + return false +} diff --git a/bchain/coins/eth/rpchttp_test.go b/bchain/coins/eth/rpchttp_test.go new file mode 100644 index 0000000000..f20129caa2 --- /dev/null +++ b/bchain/coins/eth/rpchttp_test.go @@ -0,0 +1,88 @@ +//go:build unittest + +package eth + +import ( + "net/http" + "testing" +) + +// The pool sizing is the whole point of the client, and losing DefaultTransport's proxy +// would break the CI runners that reach backends through an egress proxy. +func TestNewRPCHTTPClientPoolsConnections(t *testing.T) { + client := newRPCHTTPClient() + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", client.Transport) + } + if transport.MaxIdleConnsPerHost != rpcMaxIdleConnsPerHost { + t.Errorf("MaxIdleConnsPerHost = %d, want %d", transport.MaxIdleConnsPerHost, rpcMaxIdleConnsPerHost) + } + if transport.MaxIdleConnsPerHost <= http.DefaultMaxIdleConnsPerHost { + t.Errorf("MaxIdleConnsPerHost = %d does not improve on the default %d", + transport.MaxIdleConnsPerHost, http.DefaultMaxIdleConnsPerHost) + } + if transport.MaxIdleConns != rpcMaxIdleConns { + t.Errorf("MaxIdleConns = %d, want %d", transport.MaxIdleConns, rpcMaxIdleConns) + } + + defaultTransport := http.DefaultTransport.(*http.Transport) + if transport.Proxy == nil { + t.Error("Proxy must be inherited from DefaultTransport") + } + if transport.IdleConnTimeout != defaultTransport.IdleConnTimeout { + t.Errorf("IdleConnTimeout = %s, want inherited %s", transport.IdleConnTimeout, defaultTransport.IdleConnTimeout) + } + if transport.TLSHandshakeTimeout != defaultTransport.TLSHandshakeTimeout { + t.Errorf("TLSHandshakeTimeout = %s, want inherited %s", transport.TLSHandshakeTimeout, defaultTransport.TLSHandshakeTimeout) + } + // A blanket client timeout would cut long debug_traceBlockByHash reads that the + // per-call rpc_timeout context is meant to govern. + if client.Timeout != 0 { + t.Errorf("Timeout = %s, want 0 (per-call contexts govern)", client.Timeout) + } +} + +// One client for the whole process: net/http pools per client, so a fresh client per +// dial would reuse nothing. +func TestRPCHTTPClientIsShared(t *testing.T) { + if rpcHTTPClient == nil { + t.Fatal("rpcHTTPClient must be initialized") + } + if rpcHTTPClient.Transport == http.DefaultTransport { + t.Error("rpcHTTPClient must not fall back to http.DefaultTransport") + } +} + +func TestIsWebsocketRPCURL(t *testing.T) { + tests := []struct { + url string + want bool + }{ + {"ws://backend:8213", true}, + {"wss://backend:8213", true}, + {"WSS://backend:8213", true}, + {" ws://backend:8213 ", true}, + {"http://backend:8313", false}, + {"https://backend:8313", false}, + {"HTTP://backend:8313", false}, + {"backend:8313", false}, + {"", false}, + } + for _, tt := range tests { + if got := isWebsocketRPCURL(tt.url); got != tt.want { + t.Errorf("isWebsocketRPCURL(%q) = %v, want %v", tt.url, got, tt.want) + } + } +} + +// Every dial must carry an option: an empty slice for the HTTP branch would silently +// restore go-ethereum's unpooled default client. +func TestRPCDialOptionsAlwaysConfigureTheDial(t *testing.T) { + for _, rawURL := range []string{"http://backend:8313", "https://backend:8313", "ws://backend:8213", "wss://backend:8213"} { + if got := len(RPCDialOptions(rawURL)); got != 1 { + t.Errorf("RPCDialOptions(%q) returned %d options, want 1", rawURL, got) + } + } +} diff --git a/bchain/coins/grs/grsparser.go b/bchain/coins/grs/grsparser.go index 556fb8d840..e90ac7c12d 100644 --- a/bchain/coins/grs/grsparser.go +++ b/bchain/coins/grs/grsparser.go @@ -56,6 +56,8 @@ func init() { SigNetParams.Net = SignetMagic // Address encoding magics + // chaincfg leaves AddressMagicLen zero in signet params, which breaks base58 decoding + SigNetParams.AddressMagicLen = 1 SigNetParams.PubKeyHashAddrID = []byte{111} SigNetParams.ScriptHashAddrID = []byte{196} SigNetParams.Bech32HRPSegwit = "tgrs" diff --git a/bchain/coins/grs/grsparser_test.go b/bchain/coins/grs/grsparser_test.go index 29ac6c5663..328ecdffc4 100644 --- a/bchain/coins/grs/grsparser_test.go +++ b/bchain/coins/grs/grsparser_test.go @@ -123,6 +123,14 @@ func TestGetAddrDesc(t *testing.T) { parser: NewGroestlcoinParser(GetChainParams("test"), &btc.Configuration{}), }, }, + { + // signet shares the testnet address magics, so the same vectors must decode there + name: "grs-signet", + args: args{ + tx: testTx2, + parser: NewGroestlcoinParser(GetChainParams("signet"), &btc.Configuration{}), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/bchain/coins/hyperevm/hyperevmrpc.go b/bchain/coins/hyperevm/hyperevmrpc.go new file mode 100644 index 0000000000..f622192d2c --- /dev/null +++ b/bchain/coins/hyperevm/hyperevmrpc.go @@ -0,0 +1,104 @@ +package hyperevm + +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 the chain ID of HyperEVM (Hyperliquid) mainnet + MainNet eth.Network = 999 +) + +// HyperevmRPC is an interface to JSON-RPC HyperEVM service. +type HyperevmRPC struct { + *eth.EthereumRPC +} + +// NewHyperevmRPC returns new HyperevmRPC instance. +func NewHyperevmRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &HyperevmRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + + return s, nil +} + +// Initialize hyperevm rpc interface +func (b *HyperevmRPC) 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 MainNet: + b.MainNetChainID = 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 +} + +// GetBlockHash returns the hash reported by the backend, not go-ethereum's RLP +// recomputation of the header used by the base implementation. The two disagree at the +// HyperEVM genesis, which wedged sync at height 0. Blocks are stored under the reported +// hash, so read that field at every height to keep the comparisons consistent. +func (b *HyperevmRPC) GetBlockHash(height uint32) (string, error) { + raw, err := b.GetBlockRawByHashOrHeight("", height, false) + if err != nil { + // Not annotated: callers match bchain.ErrBlockNotFound with errors.Is and the + // pinned juju/errors has no Unwrap. + return "", err + } + var h struct { + Hash string `json:"hash"` + } + if err := json.Unmarshal(raw, &h); err != nil { + return "", errors.Annotatef(err, "height %v", height) + } + if h.Hash == "" { + return "", bchain.ErrBlockNotFound + } + return h.Hash, nil +} + +func (b *HyperevmRPC) ResolveENS(name string) (*bchain.ENSResolution, error) { + return b.EthereumRPC.ResolveENS(name) +} diff --git a/bchain/coins/koto/kotorpc.go b/bchain/coins/koto/kotorpc.go index 8271d459c3..104aa4b35b 100644 --- a/bchain/coins/koto/kotorpc.go +++ b/bchain/coins/koto/kotorpc.go @@ -104,11 +104,6 @@ func (z *KotoRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { return z.GetTransaction(txid) } -// GetMempoolEntry returns mempool data for given transaction -func (z *KotoRPC) 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/liquid/liquidrpc.go b/bchain/coins/liquid/liquidrpc.go index 9c080ffab1..1c7dba64e6 100644 --- a/bchain/coins/liquid/liquidrpc.go +++ b/bchain/coins/liquid/liquidrpc.go @@ -102,8 +102,3 @@ func (b *LiquidRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) func (b *LiquidRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { return b.GetTransaction(txid) } - -// GetMempoolEntry returns mempool data for given transaction -func (b *LiquidRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { - return nil, errors.New("GetMempoolEntry: not implemented") -} diff --git a/bchain/coins/pivx/pivxparser.go b/bchain/coins/pivx/pivxparser.go index 57acff4463..4377c1a9c0 100644 --- a/bchain/coins/pivx/pivxparser.go +++ b/bchain/coins/pivx/pivxparser.go @@ -11,6 +11,7 @@ import ( "github.com/juju/errors" "github.com/martinboehm/btcd/blockchain" + "github.com/martinboehm/btcd/chaincfg/chainhash" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" "github.com/trezor/blockbook/bchain" @@ -106,7 +107,7 @@ func (p *PivXParser) ParseBlock(b []byte) (*bchain.Block, error) { r.Seek(32, io.SeekCurrent) } - err = p.PivxDecodeTransactions(r, 0, &w) + rawTxs, err := p.PivxDecodeTransactions(r, 0, &w, b) if err != nil { return nil, errors.Annotatef(err, "DecodeTransactions") } @@ -114,6 +115,9 @@ func (p *PivXParser) 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) + if int(t.Version)&0xffff >= 3 { + txs[ti].Txid = chainhash.DoubleHashH(rawTxs[ti]).String() + } } return &bchain.Block{ @@ -140,11 +144,29 @@ func (p *PivXParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { func (p *PivXParser) ParseTx(b []byte) (*bchain.Tx, error) { t := wire.MsgTx{} r := bytes.NewReader(b) - if err := t.Deserialize(r); err != nil { + + // read version and choose encoding (same logic as PivxDecodeTransactions) + var version uint32 + if err := binary.Read(r, binary.LittleEndian, &version); err != nil { + return nil, err + } + if _, err := r.Seek(-4, io.SeekCurrent); err != nil { + return nil, err + } + + enc := wire.WitnessEncoding + if int32(version)&0xffff >= 3 { + enc = wire.BaseEncoding + } + + if err := t.BtcDecode(r, 0, enc); err != nil { return nil, err } tx := p.TxFromMsgTx(&t, true) tx.Hex = hex.EncodeToString(b) + if int(t.Version)&0xffff >= 3 { + tx.Txid = chainhash.DoubleHashH(b).String() + } return &tx, nil } @@ -262,12 +284,12 @@ 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 { +func (p *PivXParser) PivxDecodeTransactions(r *bytes.Reader, pver uint32, blk *wire.MsgBlock, blockBytes []byte) ([][]byte, error) { maxTxPerBlock := uint64((wire.MaxBlockPayload / 10) + 1) txCount, err := wire.ReadVarInt(r, pver) if err != nil { - return err + return nil, err } // Prevent more transactions than could possibly fit into a block. @@ -276,20 +298,26 @@ func (p *PivXParser) PivxDecodeTransactions(r *bytes.Reader, pver uint32, blk *w 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} + return nil, &wire.MessageError{Func: "utils.decodeTransactions", Description: str} } blk.Transactions = make([]*wire.MsgTx, 0, txCount) + rawTxs := make([][]byte, 0, txCount) for i := uint64(0); i < txCount; i++ { tx := wire.MsgTx{} + txStart, err := r.Seek(0, io.SeekCurrent) + if err != nil { + return nil, err + } + // read version & seek back to original state var version uint32 = 0 if err = binary.Read(r, binary.LittleEndian, &version); err != nil { - return err + return nil, err } if _, err = r.Seek(-4, io.SeekCurrent); err != nil { - return err + return nil, err } txVersion := version & 0xffff @@ -300,14 +328,19 @@ func (p *PivXParser) PivxDecodeTransactions(r *bytes.Reader, pver uint32, blk *w enc = wire.BaseEncoding } - err := p.PivxDecode(&tx, r, pver, enc) + err = p.PivxDecode(&tx, r, pver, enc) if err != nil { - return err + return nil, err + } + txEnd, err := r.Seek(0, io.SeekCurrent) + if err != nil { + return nil, err } blk.Transactions = append(blk.Transactions, &tx) + rawTxs = append(rawTxs, blockBytes[txStart:txEnd]) } - return nil + return rawTxs, nil } func (p *PivXParser) PivxDecode(MsgTx *wire.MsgTx, r *bytes.Reader, pver uint32, enc wire.MessageEncoding) error { diff --git a/bchain/coins/polygon/polygonrpc.go b/bchain/coins/polygon/polygonrpc.go index b3e103bac4..567636490e 100644 --- a/bchain/coins/polygon/polygonrpc.go +++ b/bchain/coins/polygon/polygonrpc.go @@ -15,6 +15,11 @@ const ( MainNet eth.Network = 137 ) +// nativeTokenContract is the MRC20 system contract through which native POL moves. It emits an +// ERC-20 Transfer for every such move, which Blockbook already indexes as an internal transfer, +// so treating it as a token would count the same POL twice. +const nativeTokenContract = "0x0000000000000000000000000000000000001010" + // PolygonRPC is an interface to JSON-RPC polygon service. type PolygonRPC struct { *eth.EthereumRPC @@ -30,6 +35,9 @@ func NewPolygonRPC(config json.RawMessage, pushHandler func(bchain.NotificationT s := &PolygonRPC{ EthereumRPC: c.(*eth.EthereumRPC), } + if err = s.Parser.(*eth.EthereumParser).IgnoreContract(nativeTokenContract); err != nil { + return nil, err + } return s, nil } diff --git a/bchain/coins/polygon/polygonrpc_test.go b/bchain/coins/polygon/polygonrpc_test.go new file mode 100644 index 0000000000..9bdeefb76e --- /dev/null +++ b/bchain/coins/polygon/polygonrpc_test.go @@ -0,0 +1,37 @@ +//go:build unittest + +package polygon + +import ( + "encoding/json" + "testing" + + "github.com/trezor/blockbook/bchain/coins/eth" +) + +// The MRC20 Transfer mirrors a native POL move that is already an internal transfer. +func TestNewPolygonRPC_IgnoresNativeTokenContract(t *testing.T) { + rpc, err := NewPolygonRPC(json.RawMessage(`{"coin_name":"Polygon","averageBlockTimeMs":1500}`), nil) + if err != nil { + t.Fatal(err) + } + parser := rpc.GetChainParser() + for _, tc := range []struct { + address string + want bool + }{ + {address: nativeTokenContract, want: true}, + {address: "0x0000000000000000000000000000000000001011"}, + } { + desc, err := parser.GetAddrDescFromAddress(tc.address) + if err != nil { + t.Fatal(err) + } + if got := parser.EthereumTypeIsIgnoredContract(desc); got != tc.want { + t.Errorf("EthereumTypeIsIgnoredContract(%s) = %v, want %v", tc.address, got, tc.want) + } + } + if _, ok := parser.(*eth.EthereumParser); !ok { + t.Errorf("parser is %T, want the shared *eth.EthereumParser", parser) + } +} diff --git a/bchain/coins/robinhood/robinhoodrpc.go b/bchain/coins/robinhood/robinhoodrpc.go new file mode 100644 index 0000000000..69dff40713 --- /dev/null +++ b/bchain/coins/robinhood/robinhoodrpc.go @@ -0,0 +1,87 @@ +package robinhood + +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 the chain ID of Robinhood Chain mainnet + MainNet eth.Network = 4663 + // TestNet is the chain ID of Robinhood Chain testnet (settles to Sepolia) + TestNet eth.Network = 46630 +) + +// RobinhoodRPC is an interface to JSON-RPC robinhood service. +type RobinhoodRPC struct { + *eth.EthereumRPC +} + +// NewRobinhoodRPC returns new RobinhoodRPC instance. +func NewRobinhoodRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &RobinhoodRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + + return s, nil +} + +// Initialize robinhood rpc interface +func (b *RobinhoodRPC) 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 MainNet: + b.MainNetChainID = MainNet + b.Testnet = false + b.Network = "livenet" + case TestNet: + b.MainNetChainID = MainNet + b.Testnet = true + b.Network = "testnet" + 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 *RobinhoodRPC) ResolveENS(name string) (*bchain.ENSResolution, error) { + return b.EthereumRPC.ResolveENS(name) +} diff --git a/bchain/coins/snowgem/snowgemrpc.go b/bchain/coins/snowgem/snowgemrpc.go index 9ef0221d60..793e6d3749 100644 --- a/bchain/coins/snowgem/snowgemrpc.go +++ b/bchain/coins/snowgem/snowgemrpc.go @@ -105,11 +105,6 @@ 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/trezarcoin/trezarcoinrpc.go b/bchain/coins/trezarcoin/trezarcoinrpc.go index b011b33bad..c591cb037f 100644 --- a/bchain/coins/trezarcoin/trezarcoinrpc.go +++ b/bchain/coins/trezarcoin/trezarcoinrpc.go @@ -103,11 +103,6 @@ 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 index 4ca44a26dc..955db0342d 100644 --- a/bchain/coins/tron/contract_test.go +++ b/bchain/coins/tron/contract_test.go @@ -186,7 +186,8 @@ func TestTronParser_EthereumTypeGetTokenTransfersFromTx(t *testing.T) { }, }, { - name: "TRC721 transfer", + // pending transferFrom: the selector is shared with TRC-20, so no standard can be derived + name: "pending TRC721 transferFrom yields nothing", tx: &bchain.Tx{ Txid: "0x49ced31cd0fd6d8e1126775f53ade165fe7ca43e9cc968d64a9ce1aff597423c", CoinSpecificData: bchain.EthereumSpecificData{ @@ -197,6 +198,20 @@ func TestTronParser_EthereumTypeGetTokenTransfersFromTx(t *testing.T) { }, }, }, + expected: bchain.TokenTransfers{}, + }, + { + name: "pending TRC721 safeTransferFrom", + tx: &bchain.Tx{ + Txid: "0x49ced31cd0fd6d8e1126775f53ade165fe7ca43e9cc968d64a9ce1aff597423c", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + From: "0x34627862d50389c8d7a1ab5ef074b84ab4ddb9e9", + To: "0x0b17822171ee88e98d4a61029f97c9f8edc15fcd", + Payload: "0x42842e0e00000000000000000000000034627862d50389c8d7a1ab5ef074b84ab4ddb9e90000000000000000000000000cecca0e53477d2b6c562ab68c3452fc99f7817e000000000000000000000000000000000000000000000000000000000000067f", + }, + }, + }, expected: bchain.TokenTransfers{ { Standard: bchain.NonFungibleToken, diff --git a/bchain/coins/tron/evm.go b/bchain/coins/tron/evm.go index bc4e12e173..32709b708c 100644 --- a/bchain/coins/tron/evm.go +++ b/bchain/coins/tron/evm.go @@ -10,10 +10,10 @@ import ( "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" + "github.com/trezor/blockbook/bchain/coins/eth" ) const ( @@ -56,7 +56,7 @@ type TronClientSubscription struct { // TronNewBlock wraps a block header channel to implement the EVMNewBlockSubscriber interface type TronNewBlock struct { - channel chan *types.Header + channel chan *eth.EthereumHeader } // Close the underlying channel @@ -72,7 +72,10 @@ func (s *TronNewBlock) Channel() interface{} { // 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 + if !ok { + return nil, false + } + return h, true } // TronNewTx wraps a transaction hash channel to conform with the EVMNewTxSubscriber interface @@ -96,63 +99,6 @@ 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 @@ -172,13 +118,17 @@ func (c *TronClient) Close() { c.Client.Close() } +// HeaderByNumber goes through rpcClient so fixStateRoot applies func (c *TronClient) HeaderByNumber(ctx context.Context, number *big.Int) (bchain.EVMHeader, error) { - h, err := c.rpcClient.HeaderByNumber(ctx, number) + var head *eth.EthereumHeader + err := c.rpcClient.CallContext(ctx, &head, "eth_getBlockByNumber", eth.ToBlockNumArg(number), false) + if err == nil && head == nil { + err = ethereum.NotFound + } if err != nil { return nil, err } - - return h, nil + return head, nil } // NetworkID returns the network ID for this client. @@ -200,33 +150,6 @@ func (c *TronClient) NetworkID(ctx context.Context) (*big.Int, error) { } } -// 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 diff --git a/bchain/coins/tron/tronInternalDataProvider.go b/bchain/coins/tron/tronInternalDataProvider.go index 94a2b2bb93..f1014d55a8 100644 --- a/bchain/coins/tron/tronInternalDataProvider.go +++ b/bchain/coins/tron/tronInternalDataProvider.go @@ -49,6 +49,9 @@ func NewTronInternalDataProvider(solidityNodeHTTP TronHTTP, timeout time.Duratio } } +// GetInternalDataForBlock is not used in production: TronRPC.GetBlock shadows +// EthereumRPC.GetBlock and calls buildInternalDataFromTronInfos directly with +// the tx infos it already fetched for fees/receipts. func (p *TronInternalDataProvider) GetInternalDataForBlock( blockHash string, blockHeight uint32, @@ -70,7 +73,9 @@ func (p *TronInternalDataProvider) GetInternalDataForBlock( responses, err := p.GetTransactionInfoByBlockNum(ctx, blockHeight) if err != nil { glog.Errorf("GetInternalDataForBlock: error calling gettransactioninfobyblocknum: %v", err) - return nil, nil, err + // the caller indexes into data even on error (eth contract), so the + // slice must keep the size of transactions + return data, contracts, err } infos := tronTxInfosFromResponses(responses) @@ -149,49 +154,85 @@ func buildInternalDataFromTronInfos( 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 != "" { + // A transaction deploys a contract only when its eth-style representation + // has no recipient AND the node reported the deployed address. Neither + // signal alone discriminates: java-tron fills contract_address for + // ordinary TriggerSmartContract calls too, and `to` is null also for + // native non-VM operations (FreezeBalance, WithdrawBalance, ...), which + // have an empty contract_address. A failed deployment created nothing - + // java-tron precomputes contract_address even for those. A missing + // recipient follows EthTxToTx semantics: "" and "0x" both count. + deployedContract := "" + if len(tx.To) <= 2 && info.ContractAddress != "" && tronReceiptResultSuccess(info.Receipt.Result) { + deployedContract = ToTronAddressFromAddress(info.ContractAddress) + d.Type = bchain.CREATE + d.Contract = deployedContract contracts = append(contracts, bchain.ContractInfo{ - Contract: contractAddr, + Contract: deployedContract, 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) + note, err := decodeNoteHex(itx.Note) if err != nil { return data, contracts, err } + // a rejected internal transaction did not execute - it moved no + // value, created no contract and destroyed none + if itx.Rejected { + continue + } + + t, handled := tronNoteToInternalType(note) + if !handled { + // featured frames carry the staked/delegated amount in + // callValue although no TRX moves - never book them + glog.V(1).Infof("Tron: skipping internal transaction note %q in tx %s", note, info.ID) + continue + } + from := ToTronAddressFromAddress(itx.CallerAddress) to := ToTronAddressFromAddress(itx.TransferToAddress) + // registry events are emitted in execution order (parity with eth + // processCallTrace); storeContractInfo merges an ephemeral + // contract's destruction into its same-block creation + switch t { + case bchain.CREATE: + // nested create frames register the child contract but do not + // change the top-level type - factory calls remain CALLs + if to != "" && to != deployedContract { + contracts = append(contracts, bchain.ContractInfo{ + Contract: to, + CreatedInBlock: blockHeight, + Standard: bchain.UnhandledTokenStandard, + }) + } + case bchain.SELFDESTRUCT: + if from != "" { + contracts = append(contracts, bchain.ContractInfo{ + Contract: from, + DestructedInBlock: blockHeight, + }) + } + } + + // java-tron puts at most one TRX entry in callValueInfo (extras + // are TRC-10), so like eth this emits one transfer per frame. + // No root-frame guard here: a hypothetical root create frame's + // value is booked nowhere else, the rebuilt deployment tx + // carries value 0 (tronBuildRpcTransaction has no CREATE case) + transferEmitted := false for _, cv := range itx.CallValueInfo { // skip TRC-10 if cv.CallValue <= 0 || cv.TokenID != "" { continue } + transferEmitted = true val := *big.NewInt(cv.CallValue) d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{ @@ -201,64 +242,25 @@ func buildInternalDataFromTronInfos( 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 + // eth processCallTrace parity: create and suicide frames emit a + // transfer even when no TRX moved, so that the created/destroyed + // contract's own address history contains this transaction - plain + // zero-value calls stay skipped, and the root deployment is already + // indexed through d.Contract + if !transferEmitted && ((t == bchain.CREATE && to != "" && to != deployedContract) || (t == bchain.SELFDESTRUCT && from != "")) { + d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{ + Type: t, + From: from, + To: to, + }) } } - } - - 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 !tronReceiptResultSuccess(info.Receipt.Result) { + d.Error = info.Receipt.Result } } - if createdContract != "" { - return bchain.CREATE, createdContract, nil - } - if destructedContract != "" { - return bchain.SELFDESTRUCT, destructedContract, nil - } - return bchain.CALL, "", nil + return data, contracts, nil } diff --git a/bchain/coins/tron/tronInternalDataProvider_test.go b/bchain/coins/tron/tronInternalDataProvider_test.go index bf222f055e..6fbc1ade67 100644 --- a/bchain/coins/tron/tronInternalDataProvider_test.go +++ b/bchain/coins/tron/tronInternalDataProvider_test.go @@ -70,6 +70,7 @@ func (m *MockTronHTTPClient) SnapshotRequests() ([]string, []interface{}) { func TestTronInternalDataProvider_GetInternalDataForBlock_Simple(t *testing.T) { bchain.ProcessInternalTransactions = true + t.Cleanup(func() { bchain.ProcessInternalTransactions = false }) // fake transaction info returned from the Tron HTTP API fake := []tronTxInfo{ @@ -121,21 +122,37 @@ func TestTronInternalDataProvider_GetInternalDataForBlock_Simple(t *testing.T) { require.Equal(t, "TVtFTiSQmeMkdpusjefUcPcEeTPtqnhz3D", d.Transfers[0].To) } +// contractEvent describes one expected contract registry entry: a creation +// (destroyed=false) or a destruction (destroyed=true), in emission order +type contractEvent struct { + addr string + destroyed bool +} + +// transferEvent describes one expected internal transfer, in emission order +type transferEvent struct { + typ bchain.EthereumInternalTransactionType + from string + to string + value int64 +} + 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 string + infos []tronTxInfo + txs []bchain.RpcTransaction + wantType bchain.EthereumInternalTransactionType + wantTransfers int + wantErrContains string // error return from function + wantDataErr string // exact d.Error (EthereumInternalData.Error) + wantContract string + wantContracts []contractEvent // exact contract registry entries in order + wantTransferList []transferEvent // exact transfers in order; overrides the wantTransfers/wantFrom/wantTo/wantValue shorthand + wantFrom string + wantTo string + wantValue int64 }{ { name: "CALL with TRX transfer", @@ -155,7 +172,7 @@ func TestBuildInternalDataFromTronInfos(t *testing.T) { Receipt: tronReceipt{Result: "SUCCESS"}, }, }, - txs: []bchain.RpcTransaction{{Hash: "0xabcd1234"}}, + txs: []bchain.RpcTransaction{{Hash: "0xabcd1234", To: "0x39dd12a54e2bab7c82aa14a1e158b34263d2d510"}}, wantType: bchain.CALL, wantTransfers: 1, @@ -166,7 +183,82 @@ func TestBuildInternalDataFromTronInfos(t *testing.T) { }, { - name: "CREATE detected by internal note", + // regression for #1660: java-tron fills contract_address for ordinary + // TriggerSmartContract calls; they must not be treated as deployments + name: "TriggerSmartContract with contract_address stays CALL", + infos: []tronTxInfo{ + { + ID: "call1660", + ContractAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41da727d310b98700af4cec797e43991899668d6f3", + Note: "63616c6c", // "call" + CallValueInfo: []tronCallValueInfo{ + {CallValue: 13245012561}, + }, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xcall1660", To: "0x39dd12a54e2bab7c82aa14a1e158b34263d2d510"}}, + + wantType: bchain.CALL, + wantTransfers: 1, + wantValue: 13245012561, + }, + + { + name: "Deployment without internal transactions", + infos: []tronTxInfo{ + { + ID: "deploy1", + ContractAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xdeploy1"}}, + wantType: bchain.CREATE, + wantContract: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", + wantContracts: []contractEvent{{addr: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U"}}, + }, + + { + // "0x" counts as a missing recipient, same as EthTxToTx + name: "Deployment with 0x recipient is CREATE", + infos: []tronTxInfo{ + { + ID: "deploy0x", + ContractAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xdeploy0x", To: "0x"}}, + wantType: bchain.CREATE, + wantContract: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", + wantContracts: []contractEvent{{addr: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U"}}, + }, + + { + // java-tron precomputes contract_address even when the deployment + // failed - nothing was created, nothing may be registered + name: "Failed deployment registers no contract", + infos: []tronTxInfo{ + { + ID: "deployfail", + ContractAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + Receipt: tronReceipt{Result: "OUT_OF_ENERGY"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xdeployfail"}}, + wantType: bchain.CALL, + wantDataErr: "OUT_OF_ENERGY", + }, + + { + name: "Deployment with constructor-created child contract", infos: []tronTxInfo{ { ID: "0544ab15ada7051af68b57ca29d69c753b64e6701cfebe5cdbe53a2a9127a88d", @@ -180,14 +272,50 @@ func TestBuildInternalDataFromTronInfos(t *testing.T) { }, }, }, - txs: []bchain.RpcTransaction{{Hash: "0x0544ab15ada7051af68b57ca29d69c753b64e6701cfebe5cdbe53a2a9127a88d"}}, - wantType: bchain.CREATE, - wantContracts: 1, - wantContract: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn", + txs: []bchain.RpcTransaction{{Hash: "0x0544ab15ada7051af68b57ca29d69c753b64e6701cfebe5cdbe53a2a9127a88d"}}, + wantType: bchain.CREATE, + wantContract: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", + wantContracts: []contractEvent{ + {addr: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U"}, // deployed contract + {addr: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, // constructor-created child + }, + // the zero-value create emits a transfer so that the child's + // address history contains the deploying transaction + wantTransferList: []transferEvent{ + {typ: bchain.CREATE, from: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", to: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, + }, }, { - name: "SELFDESTRUCT detected", + // a call into a factory that internally deploys a contract stays a CALL, + // only the child is registered + name: "Factory call with nested create stays CALL", + infos: []tronTxInfo{ + { + ID: "factory1", + ContractAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + TransferToAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "637265617465", // create + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xfactory1", To: "0x39dd12a54e2bab7c82aa14a1e158b34263d2d510"}}, + wantType: bchain.CALL, + wantContracts: []contractEvent{{addr: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}}, + wantTransferList: []transferEvent{ + {typ: bchain.CREATE, from: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", to: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, + }, + }, + + { + // the top-level type stays CALL: only its low bit survives packing, + // so a top-level SELFDESTRUCT could never round-trip through the DB + name: "Suicide frame registers destruction, top-level type stays CALL", infos: []tronTxInfo{ { ID: "deadbeef", @@ -196,12 +324,105 @@ func TestBuildInternalDataFromTronInfos(t *testing.T) { }, }, }, - txs: []bchain.RpcTransaction{{Hash: "0xdeadbeef"}}, - wantType: bchain.SELFDESTRUCT, + txs: []bchain.RpcTransaction{{Hash: "0xdeadbeef"}}, + wantType: bchain.CALL, + wantContracts: []contractEvent{{addr: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", destroyed: true}}, + // a missing beneficiary is not a shape java-tron emits, but the + // destroyed contract must stay indexed even then + wantTransferList: []transferEvent{ + {typ: bchain.SELFDESTRUCT, from: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", to: ""}, + }, + }, + + { + // an ephemeral (MEV-style) contract created, used and selfdestructed + // within one call must produce both registry events, creation first - + // storeContractInfo merges the destruction into the same-batch creation + name: "Ephemeral contract created and destroyed in one call", + infos: []tronTxInfo{ + { + ID: "ephemeral1", + ContractAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "637265617465", // create + }, + { + CallerAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + TransferToAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + Note: "73756963696465", // suicide - sweeps remaining balance + CallValueInfo: []tronCallValueInfo{ + {CallValue: 5}, + }, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xephemeral1", To: "0x734c2f23ab41c52308d1206c4eb5fe8e124e6898"}}, + wantType: bchain.CALL, + wantContracts: []contractEvent{ + {addr: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, + {addr: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn", destroyed: true}, + }, + wantTransferList: []transferEvent{ + {typ: bchain.CREATE, from: "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", to: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, + {typ: bchain.SELFDESTRUCT, from: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn", to: "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", value: 5}, + }, + }, + + { + // every destroyed contract must be registered, not just the first one + name: "Multiple ephemeral contracts register all creations and destructions", + infos: []tronTxInfo{ + { + ID: "ephemeral2", + ContractAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "637265617465", // create + }, + { + CallerAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "73756963696465", // suicide + }, + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41da727d310b98700af4cec797e43991899668d6f3", + Note: "637265617465", // create + }, + { + CallerAddress: "41da727d310b98700af4cec797e43991899668d6f3", + Note: "73756963696465", // suicide + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xephemeral2", To: "0x734c2f23ab41c52308d1206c4eb5fe8e124e6898"}}, + wantType: bchain.CALL, + wantContracts: []contractEvent{ + {addr: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, + {addr: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn", destroyed: true}, + {addr: "TVtFTiSQmeMkdpusjefUcPcEeTPtqnhz3D"}, + {addr: "TVtFTiSQmeMkdpusjefUcPcEeTPtqnhz3D", destroyed: true}, + }, + wantTransferList: []transferEvent{ + {typ: bchain.CREATE, from: "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", to: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, + {typ: bchain.SELFDESTRUCT, from: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn", to: ""}, + {typ: bchain.CREATE, from: "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", to: "TVtFTiSQmeMkdpusjefUcPcEeTPtqnhz3D"}, + {typ: bchain.SELFDESTRUCT, from: "TVtFTiSQmeMkdpusjefUcPcEeTPtqnhz3D", to: ""}, + }, }, { - name: "Rejected internal call", + // java-tron rejects single frames inside transactions that succeed + // (a nested call reverted); the tx must not carry an error + name: "Rejected internal call in a successful tx sets no error", infos: []tronTxInfo{ { ID: "fail01", @@ -214,9 +435,150 @@ func TestBuildInternalDataFromTronInfos(t *testing.T) { Receipt: tronReceipt{Result: "SUCCESS"}, }, }, - txs: []bchain.RpcTransaction{{Hash: "0xfail01"}}, - wantType: bchain.CALL, - wantDataErrSubstr: "rejected", + txs: []bchain.RpcTransaction{{Hash: "0xfail01"}}, + wantType: bchain.CALL, + wantDataErr: "", + }, + + { + // a failed transaction rejects its internal transactions; their call + // values did not move and must not be booked as transfers + name: "Rejected internal transfers are not booked", + infos: []tronTxInfo{ + { + ID: "fail02", + ContractAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41da727d310b98700af4cec797e43991899668d6f3", + Note: "63616c6c", // call + Rejected: true, + CallValueInfo: []tronCallValueInfo{ + {CallValue: 457584}, + }, + }, + { + CallerAddress: "41da727d310b98700af4cec797e43991899668d6f3", + TransferToAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + Note: "63616c6c", // call + Rejected: true, + CallValueInfo: []tronCallValueInfo{ + {CallValue: 457584}, + }, + }, + }, + Receipt: tronReceipt{Result: "OUT_OF_ENERGY"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xfail02", To: "0x39dd12a54e2bab7c82aa14a1e158b34263d2d510"}}, + wantType: bchain.CALL, + wantTransfers: 0, + wantDataErr: "OUT_OF_ENERGY", + }, + + { + // rejected frames deployed and destroyed nothing - no registry entries + name: "Rejected create and suicide register nothing", + infos: []tronTxInfo{ + { + ID: "fail03", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "637265617465", // create + Rejected: true, + }, + { + CallerAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "73756963696465", // suicide + Rejected: true, + }, + }, + Receipt: tronReceipt{Result: "REVERT"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xfail03", To: "0x734c2f23ab41c52308d1206c4eb5fe8e124e6898"}}, + wantType: bchain.CALL, + wantDataErr: "REVERT", + }, + + { + // featured internal txs (node ran with vm.saveFeaturedInternalTx) put + // the staked/delegated amount into callValue although no TRX moves - + // they must not be booked as transfers + name: "Featured internal transaction is ignored", + infos: []tronTxInfo{ + { + ID: "featured1", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41c64e69acde1c7b16c2a3efcdbbdaa96c3644c2b3", + TransferToAddress: "41c9b586fec130cea232371d7ceecde6ad0d3c2991", + Note: "64656c65676174655265736f757263654f66456e65726779", // delegateResourceOfEnergy + CallValueInfo: []tronCallValueInfo{ + {CallValue: 1255110000000}, + }, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xfeatured1", To: "0x39dd12a54e2bab7c82aa14a1e158b34263d2d510"}}, + wantType: bchain.CALL, + }, + + { + // callValueInfo with a tokenId is a TRC-10 transfer, not TRX + name: "TRC-10 call frame books no transfer", + infos: []tronTxInfo{ + { + ID: "trc10call", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41da727d310b98700af4cec797e43991899668d6f3", + Note: "63616c6c", // call + CallValueInfo: []tronCallValueInfo{ + {}, + {CallValue: 57753367, TokenID: "1002000"}, + }, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xtrc10call", To: "0x39dd12a54e2bab7c82aa14a1e158b34263d2d510"}}, + wantType: bchain.CALL, + }, + + { + // a create frame carrying only TRC-10 value still emits the + // zero-value transfer that links the child's address history + name: "TRC-10-only create frame emits zero-value CREATE transfer", + infos: []tronTxInfo{ + { + ID: "trc10create", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "637265617465", // create + CallValueInfo: []tronCallValueInfo{ + {CallValue: 57753367, TokenID: "1002000"}, + }, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xtrc10create", To: "0x734c2f23ab41c52308d1206c4eb5fe8e124e6898"}}, + wantType: bchain.CALL, + wantContracts: []contractEvent{{addr: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}}, + wantTransferList: []transferEvent{ + {typ: bchain.CREATE, from: "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", to: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn"}, + }, }, { @@ -259,35 +621,49 @@ func TestBuildInternalDataFromTronInfos(t *testing.T) { d := data[0] - if tt.wantType != 0 { - require.Equal(t, tt.wantType, d.Type) - } - - require.Len(t, d.Transfers, tt.wantTransfers) + require.Equal(t, tt.wantType, d.Type) + require.Equal(t, tt.wantContract, d.Contract) + + if tt.wantTransferList != nil { + require.Len(t, d.Transfers, len(tt.wantTransferList)) + for i, want := range tt.wantTransferList { + tr := d.Transfers[i] + require.Equal(t, want.typ, tr.Type) + require.Equal(t, want.from, tr.From) + require.Equal(t, want.to, tr.To) + require.Equal(t, want.value, tr.Value.Int64()) + } + } else { + require.Len(t, d.Transfers, tt.wantTransfers) - if tt.wantTransfers > 0 { - tr := d.Transfers[0] + if tt.wantTransfers > 0 { + tr := d.Transfers[0] - require.Equal(t, tt.wantValue, tr.Value.Int64()) + 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.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) + require.Len(t, contracts, len(tt.wantContracts)) + for i, want := range tt.wantContracts { + require.Equal(t, want.addr, contracts[i].Contract) + if want.destroyed { + require.Equal(t, uint32(12345), contracts[i].DestructedInBlock) + require.Equal(t, uint32(0), contracts[i].CreatedInBlock) + } else { + require.Equal(t, uint32(12345), contracts[i].CreatedInBlock) + require.Equal(t, uint32(0), contracts[i].DestructedInBlock) + require.Equal(t, bchain.UnhandledTokenStandard, contracts[i].Standard) } } - if tt.wantDataErrSubstr != "" { - require.Contains(t, d.Error, tt.wantDataErrSubstr) - } + require.Equal(t, tt.wantDataErr, d.Error) }) } } diff --git a/bchain/coins/tron/tronhttp.go b/bchain/coins/tron/tronhttp.go index 5172e37ce5..91d7258f82 100644 --- a/bchain/coins/tron/tronhttp.go +++ b/bchain/coins/tron/tronhttp.go @@ -5,10 +5,16 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "time" ) +const ( + tronMaxIdleConnsPerHost = 64 + tronMaxIdleConns = 100 +) + type TronHTTP interface { Request(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error } @@ -19,10 +25,15 @@ type TronHTTPClient struct { } func NewTronHTTPClient(baseURL string, timeout time.Duration) *TronHTTPClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = tronMaxIdleConns + transport.MaxIdleConnsPerHost = tronMaxIdleConnsPerHost + return &TronHTTPClient{ baseURL: baseURL, httpClient: &http.Client{ - Timeout: timeout, + Timeout: timeout, + Transport: transport, }, } } @@ -43,7 +54,14 @@ func (c *TronHTTPClient) Request(ctx context.Context, path string, reqBody inter if err != nil { return fmt.Errorf("HTTP error calling Tron API %s: %w", path, err) } - defer resp.Body.Close() + // Drain the body before closing so net/http can return the connection to the + // idle pool for keep-alive reuse. Without this, the >=300 early return (which + // reads nothing) and any bytes the JSON decoder leaves unconsumed would force + // the connection closed, defeating the MaxIdleConns* pooling configured above. + defer func() { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() if resp.StatusCode >= 300 { return fmt.Errorf("Tron API returned status %d at path: %s %s", resp.StatusCode, c.baseURL, path) diff --git a/bchain/coins/tron/tronhttp_endpoints.go b/bchain/coins/tron/tronhttp_endpoints.go index 0e117033fe..b27d90cb4b 100644 --- a/bchain/coins/tron/tronhttp_endpoints.go +++ b/bchain/coins/tron/tronhttp_endpoints.go @@ -6,6 +6,7 @@ import ( "encoding/json" "math/big" "strconv" + "time" "github.com/golang/glog" "github.com/juju/errors" @@ -49,11 +50,12 @@ type tronUnfrozenV2Entry struct { } 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 { + Address string `json:"address,omitempty"` + FrozenV2 []tronFrozenV2Entry `json:"frozenV2,omitempty"` + UnfrozenV2 []tronUnfrozenV2Entry `json:"unfrozenV2,omitempty"` + Votes []tronTxVote `json:"votes,omitempty"` + LatestWithdrawTime *int64 `json:"latest_withdraw_time,omitempty"` + AccountResource struct { DelegatedFrozenV2BalanceForEnergy int64 `json:"delegated_frozenV2_balance_for_energy"` } `json:"account_resource,omitempty"` DelegatedFrozenV2BalanceForBandwidth int64 `json:"delegated_frozenV2_balance_for_bandwidth"` @@ -68,6 +70,7 @@ type tronGetBlockResponse struct { } type tronGetBlockHeaderResponse struct { + BlockID string `json:"blockID"` BlockHeader struct { RawData struct { Number *uint64 `json:"number"` @@ -110,6 +113,10 @@ func (b *TronRPC) GetMempoolTransactions() ([]string, error) { // GetAddressChainExtraData returns normalized Tron-specific account/address data. func (b *TronRPC) GetAddressChainExtraData(addrDesc bchain.AddressDescriptor) (json.RawMessage, error) { + // only a 20-byte account has chain data; a client-supplied ad: descriptor of any length would hit quadratic base58 + if len(addrDesc) != TronTypeAddressDescriptorLen { + return nil, nil + } ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) defer cancel() @@ -257,6 +264,11 @@ func tronBuildStakingInfo(accountResp *tronGetAccountResponse, resourceResp *tro delegatedEnergy := max(accountResp.AccountResource.DelegatedFrozenV2BalanceForEnergy, 0) delegatedBandwidth := max(accountResp.DelegatedFrozenV2BalanceForBandwidth, 0) + latestWithdrawTime := int64(0) + if accountResp.LatestWithdrawTime != nil && *accountResp.LatestWithdrawTime > 0 { + latestWithdrawTime = *accountResp.LatestWithdrawTime / 1000 + } + return &bchain.TronStakingInfo{ StakedBalance: stakedBalance.String(), StakedBalanceEnergy: stakedEnergy.String(), @@ -268,6 +280,7 @@ func tronBuildStakingInfo(accountResp *tronGetAccountResponse, resourceResp *tro UnclaimedReward: strconv.FormatInt(unclaimedReward, 10), DelegatedBalanceEnergy: strconv.FormatInt(delegatedEnergy, 10), DelegatedBalanceBandwidth: strconv.FormatInt(delegatedBandwidth, 10), + LatestWithdrawTime: latestWithdrawTime, } } @@ -275,18 +288,27 @@ func (b *TronRPC) SendRawTransaction(tx string, disableAlternativeRPC bool) (str ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) defer cancel() + // A send is the only moment Blockbook sees a transaction the wallet cares about before it is + // indexed, so log the outcome and txid - a later "my tx vanished" report has nothing else to + // reconcile against on a chain with no pending-tx feed. + start := time.Now() resp, err := b.requestBroadcastHex(ctx, strip0xPrefix(tx)) + duration := time.Since(start).Round(time.Millisecond) if err != nil { + glog.Errorf("tron broadcasthex failed after %v: %v", duration, err) return "", err } if !resp.Result { if resp.Code != "" || resp.Message != "" { + glog.Errorf("tron broadcasthex rejected txid %s after %v: %s %s", strip0xPrefix(resp.TxID), duration, resp.Code, resp.Message) return "", errors.Errorf("Tron broadcasthex failed: %s %s", resp.Code, resp.Message) } + glog.Errorf("tron broadcasthex rejected txid %s after %v without a reason", strip0xPrefix(resp.TxID), duration) return "", errors.New("Tron broadcasthex failed") } txID := strip0xPrefix(resp.TxID) + glog.Infof("tron broadcasthex accepted txid %s in %v", txID, duration) if b.ChainConfig != nil && b.ChainConfig.DisableMempoolSync && b.Mempool != nil { b.Mempool.AddTransactionToMempool(txID) } @@ -467,6 +489,32 @@ func (b *TronRPC) requestBlockByID(ctx context.Context, blockHash string, isSoli return &resp, nil } +// requestBlockHashByNum resolves a block hash by height via the solidity node's +// /walletsolidity/getblock endpoint. It is only called for already-solidified +// heights (see GetBlockHash), so the lookup always targets the solidity node. +func (b *TronRPC) requestBlockHashByNum(ctx context.Context, blockNum uint32) (string, error) { + req := map[string]any{ + "id_or_num": strconv.FormatUint(uint64(blockNum), 10), + "detail": false, + } + http := b.getLookupHTTPClient(true) + var resp tronGetBlockHeaderResponse + if err := http.Request(ctx, "/walletsolidity/getblock", req, &resp); err != nil { + return "", err + } + if resp.BlockHeader.RawData.Number == nil { + return "", errors.Errorf("Tron getblock returned missing block_header.raw_data.number for height %d", blockNum) + } + if *resp.BlockHeader.RawData.Number != uint64(blockNum) { + return "", errors.Errorf("Tron getblock returned height %d, want %d", *resp.BlockHeader.RawData.Number, blockNum) + } + hash := strip0xPrefix(resp.BlockID) + if hash == "" { + return "", errors.Errorf("Tron getblock returned empty blockID for height %d", blockNum) + } + return hash, nil +} + func (b *TronRPC) requestLatestSolidifiedBlockHeight(ctx context.Context) (uint64, error) { http := b.solidityNodeHTTP if http == nil { diff --git a/bchain/coins/tron/tronparser.go b/bchain/coins/tron/tronparser.go index 6c5f306119..26598f6a4b 100644 --- a/bchain/coins/tron/tronparser.go +++ b/bchain/coins/tron/tronparser.go @@ -54,6 +54,12 @@ func (p *TronParser) GetAddrDescFromVout(output *bchain.Vout) (bchain.AddressDes func (p *TronParser) GetAddrDescFromAddress(address string) (bchain.AddressDescriptor, error) { address = strip0xPrefix(address) + // empty address is a normal shape (e.g. contract creation), handled + // quietly by callers - do not log it below + if len(address) == 0 { + return nil, bchain.ErrAddressMissing + } + if len(address) == TronAddressLen { decoded := base58.Decode(address) if len(decoded) != 25 || decoded[0] != 0x41 { @@ -68,7 +74,8 @@ func (p *TronParser) GetAddrDescFromAddress(address string) (bchain.AddressDescr } return payload[1:], nil } else if len(address) != TronTypeAddressDescriptorLen*2 { - glog.Infof("Invalid Tron address length: got %d chars: %q", len(address), address) + // input is client-controlled and may be megabytes, so log only its length + glog.Infof("Invalid Tron address length: got %d chars", len(address)) return nil, bchain.ErrAddressMissing } @@ -332,22 +339,20 @@ func SanitizeHexUint64String(s string) string { return s } -func tronNoteHexToInternalType(noteHex string) (bchain.EthereumInternalTransactionType, error) { - note, err := decodeNoteHex(noteHex) - if err != nil { - return bchain.CALL, err - } - +// tronNoteToInternalType maps an internal transaction note to the eth internal +// transaction type. handled=false marks frames that are no value transfers - +// featured notes (delegateResourceOfEnergy, freezeBalanceV2ForBandwidth, ...) +// report the staked/delegated amount in callValue although no TRX moves. +func tronNoteToInternalType(note string) (t bchain.EthereumInternalTransactionType, handled bool) { switch note { case "create": - return bchain.CREATE, nil + return bchain.CREATE, true case "suicide": - return bchain.SELFDESTRUCT, nil + return bchain.SELFDESTRUCT, true case "call": - return bchain.CALL, nil + return bchain.CALL, true default: - // add others - return bchain.CALL, nil + return bchain.CALL, false } } diff --git a/bchain/coins/tron/tronparser_test.go b/bchain/coins/tron/tronparser_test.go index a199d12534..72ba440a59 100644 --- a/bchain/coins/tron/tronparser_test.go +++ b/bchain/coins/tron/tronparser_test.go @@ -13,6 +13,28 @@ import ( "github.com/trezor/blockbook/bchain" ) +// TestTronParser_UseEnsReverseAliases pins the opt-in truth table on the parser that +// TronParser embeds: both address_aliases and enable_ens_reverse_aliases are required. +func TestTronParser_UseEnsReverseAliases(t *testing.T) { + for _, tt := range []struct { + name string + addressAliases bool + enableEns bool + want bool + }{ + {name: "off by default", addressAliases: true}, + {name: "opted in", addressAliases: true, enableEns: true, want: true}, + {name: "opt-in alone is not enough", enableEns: true}, + {name: "both off", addressAliases: false}, + } { + t.Run(tt.name, func(t *testing.T) { + p := NewTronParser(1, tt.addressAliases) + p.EnableEnsReverseAliases = tt.enableEns + require.Equal(t, tt.want, p.UseEnsReverseAliases()) + }) + } +} + func TestTronParser_GetAddrDescFromAddress(t *testing.T) { type args struct { address string @@ -405,3 +427,13 @@ func TestTronParser_UnpackBlockHash_NoPrefix(t *testing.T) { require.NoError(t, err) require.Equal(t, "000000000348d2a70c64b102b21699f7f561fffbc67d50ed5f540db5ad631913", unpacked) } + +// TestTronParser_ChainExtraPayloadRegistered pins the parser's payloadType to the registry the +// generated client types are built from, so a new payload cannot ship untyped. +func TestTronParser_ChainExtraPayloadRegistered(t *testing.T) { + p := NewTronParser(6, false) + reg, ok := bchain.ChainExtraPayloadByType(p.GetChainExtraPayloadType()) + require.True(t, ok, "payloadType %q is not in bchain.ChainExtraPayloads", p.GetChainExtraPayloadType()) + require.IsType(t, bchain.TronChainExtraData{}, reg.Tx) + require.IsType(t, bchain.TronAccountExtraData{}, reg.Account) +} diff --git a/bchain/coins/tron/tronrpc.go b/bchain/coins/tron/tronrpc.go index 0fc653ffdf..f829fc3548 100644 --- a/bchain/coins/tron/tronrpc.go +++ b/bchain/coins/tron/tronrpc.go @@ -30,6 +30,10 @@ const ( tronDefaultFullNodeHTTPPort = "8090" tronDefaultSolidityHTTPPort = "8091" + // tronMulticall3Address is Tron mainnet's non-canonical Multicall3 deployment + // (base58 TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED); applied in NewTronRPC. + tronMulticall3Address = "0x32a4f47a74a6810bd0bf861cabab99656a75de9e" + TRC10TokenType bchain.TokenStandardName = "TRC10" TRC20TokenType bchain.TokenStandardName = "TRC20" TRC721TokenType bchain.TokenStandardName = "TRC721" @@ -40,9 +44,10 @@ const ( 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"` + MessageQueueBinding string `json:"message_queue_binding"` + MessageQueueCurve *bchain.CurveConfig `json:"message_queue_curve,omitempty"` + FullNodeHTTPURLTemplate string `json:"tron_fullnode_http_url_template"` + SolidityHTTPURLTemplate string `json:"tron_solidity_http_url_template"` } type tronResourceCode int64 @@ -137,12 +142,12 @@ func NewTronRPC(config json.RawMessage, pushHandler func(bchain.NotificationType } 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.Multicall3AddressOverride = tronMulticall3Address + + // NewTronParser discards the parser NewEthereumRPC configured, so the config has to be + // applied again here; ApplyToParser keeps that a single call instead of a per-field list + // that silently drifts from the eth side. + ethChainConfig.ApplyToParser(tronRpc.Parser.EthereumParser) tronRpc.EthereumRPC.Parser = tronRpc.Parser tronRpc.ChainConfig = &cfg @@ -215,10 +220,7 @@ func resolveTronHTTPURL(explicitURL, rpcURL, defaultPort string) (string, error) // 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...) + r, err := rpc.DialOptions(context.Background(), url, eth.RPCDialOptions(url)...) if err != nil { return nil, nil, err } @@ -285,8 +287,32 @@ func (b *TronRPC) GetBestBlockHash() (string, error) { return strip0xPrefix(header.Hash()), nil } +// EthereumTypeGetBestTip returns the cached tip in Tron API format (without 0x prefix), +// matching GetBestBlockHash so the sync worker can compare it with indexed hashes. +func (b *TronRPC) EthereumTypeGetBestTip() (*bchain.EVMTip, error) { + header, err := b.getBestHeader() + if err != nil { + return nil, err + } + return &bchain.EVMTip{ + Hash: strip0xPrefix(header.Hash()), + ParentHash: strip0xPrefix(header.ParentHash()), + Height: uint32(header.Number().Uint64()), + }, nil +} + // GetBlockHash returns block hash in Tron API format (without 0x prefix). func (b *TronRPC) GetBlockHash(height uint32) (string, error) { + if b.isBlockSolidified(uint64(height)) && b.solidityNodeHTTP != nil { + ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) + defer cancel() + hash, err := b.requestBlockHashByNum(ctx, height) + if err == nil { + return hash, nil + } + glog.V(1).Infof("Tron native getblock hash lookup failed for height %d, falling back to JSON-RPC: %v", height, err) + } + hash, err := b.EthereumRPC.GetBlockHash(height) if err != nil { return "", err @@ -347,21 +373,6 @@ func (b *TronRPC) GetBlockInfo(hash string) (*bchain.BlockInfo, error) { } 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 @@ -384,22 +395,6 @@ func (b *TronRPC) getBestHeader() (bchain.EVMHeader, error) { 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 @@ -631,7 +626,7 @@ func (b *TronRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpoi TxReceive: "", } - mq, err := bchain.NewMQ(b.ChainConfig.MessageQueueBinding, b.handleMQNotification, tronTopics) + mq, err := bchain.NewMQ(b.ChainConfig.MessageQueueBinding, b.ChainConfig.MessageQueueCurve, b.handleMQNotification, tronTopics) if err != nil { return err } @@ -697,11 +692,16 @@ func (b *TronRPC) computeBlockConfirmations(blockNumber uint64) (uint32, error) 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) { +func (b *TronRPC) buildTxFromHTTPData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse, blockTime int64, confirmations uint32, internalData *bchain.EthereumInternalData, keepReceipt bool) (*bchain.Tx, error) { + // eth parity (EthTxToTx): drop empty internal data so that no useless + // cfInternalData row is stored for every plain transaction + if internalData != nil && internalData.Type == bchain.CALL && len(internalData.Transfers) == 0 && internalData.Error == "" { + internalData = nil + } csd := tronBuildEthereumSpecificData(txByID, txInfo) csd.InternalData = internalData - if !isSolidified { + if !keepReceipt { csd.Receipt = nil // set to nil so it can be considered as pending } @@ -710,9 +710,13 @@ func (b *TronRPC) buildTxFromHTTPData(txByID *tronGetTransactionByIDResponse, tx return nil, errors.Annotatef(err, "txid %v", txByID.TxID) } + // java-tron precomputes the receipt ContractAddress even for failed + // deployments - only a successful receipt proves the contract exists; + // same success signal as the CREATE classification during indexing if len(tx.Vout) > 0 && tx.Vout[0].ScriptPubKey.Addresses == nil && csd.Receipt != nil && + tronReceiptResultSuccess(txInfo.Receipt.Result) && csd.Receipt.ContractAddress != "" { tx.Vout = []bchain.Vout{{ ValueSat: tx.Vout[0].ValueSat, @@ -948,7 +952,9 @@ func (b *TronRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { txInternalData = &internalData[i] } - rebuiltTx, err := b.buildTxFromHTTPData(txByID, txInfo, bbh.Time, confirmations, txInternalData, isSolidified) + // Keep receipts for block indexing even before solidity. If logs are dropped + // here, token-transfer participants never reach the address/contract indexes. + rebuiltTx, err := b.buildTxFromHTTPData(txByID, txInfo, bbh.Time, confirmations, txInternalData, true) if err != nil { return nil, err } @@ -1180,33 +1186,33 @@ func (b *TronRPC) EthereumTypeRpcCall(data, to, from string) (string, error) { 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) +// EthereumTypeMulticallAggregate3 supports both EVM hex and Tron Base58 targets. +func (b *TronRPC) EthereumTypeMulticallAggregate3(calls []bchain.EthereumMulticallCall, blockNumber *big.Int) ([]bchain.EthereumMulticallResult, error) { + normalizedCalls, err := b.normalizeMulticallCalls(calls) if err != nil { - return 0, 0, false, err + return nil, err } - // the single NonceAt call already yields the latest nonce, so confirmed is - // available whenever it was requested - return n, n, withConfirmed, nil + return b.EthereumRPC.EthereumTypeMulticallAggregate3(normalizedCalls, blockNumber) } -// 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 +func (b *TronRPC) normalizeMulticallCalls(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallCall, error) { + normalizedCalls := make([]bchain.EthereumMulticallCall, len(calls)) + copy(normalizedCalls, calls) + for i := range normalizedCalls { + target, err := b.Parser.FromTronAddressToHex(normalizedCalls[i].Target) + if err != nil { + return nil, errors.Annotatef(err, "normalize multicall target %d", i) + } + normalizedCalls[i].Target = target } - contract.Contract = ToTronAddressFromAddress(contract.Contract) - glog.Infof("Getting contract info for: %s", contract.Contract) - return contract, nil + return normalizedCalls, nil +} + +// EthereumTypeGetNonces: Tron has no account nonces (replay protection is +// ref-block + expiration); pending is reported as 0 to mirror java-tron's +// eth_getTransactionCount stub and a confirmed nonce is never claimed. +func (b *TronRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { + return 0, 0, false, nil } func (b *TronRPC) EthereumTypeGetRawTransaction(txid string) (string, error) { diff --git a/bchain/coins/tron/tronrpc_test.go b/bchain/coins/tron/tronrpc_test.go index a45697822d..e16d87dea2 100644 --- a/bchain/coins/tron/tronrpc_test.go +++ b/bchain/coins/tron/tronrpc_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -35,6 +36,37 @@ func TestResolveTronHTTPURL_InvalidRPCURL(t *testing.T) { require.Error(t, err) } +// NewTronRPC sets Tron's non-canonical Multicall3 address on the embedded eth layer. +func TestNewTronRPC_SetsMulticall3AddressOverride(t *testing.T) { + cfg := json.RawMessage(`{"coin_name":"Tron","averageBlockTimeMs":3000,"rpc_url":"http://127.0.0.1:8545/jsonrpc"}`) + bc, err := NewTronRPC(cfg, func(bchain.NotificationType) {}) + require.NoError(t, err) + tronRPC := bc.(*TronRPC) + require.Equal(t, tronMulticall3Address, tronRPC.EthereumRPC.Multicall3AddressOverride) +} + +func TestTronRPC_NormalizeMulticallCalls(t *testing.T) { + rpc := &TronRPC{ + EthereumRPC: ð.EthereumRPC{}, + Parser: NewTronParser(100, false), + } + calls := []bchain.EthereumMulticallCall{ + {Target: "TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED", CallData: "0x1234", AllowFailure: true}, + {Target: tronMulticall3Address}, + } + + normalized, err := rpc.normalizeMulticallCalls(calls) + require.NoError(t, err) + require.Equal(t, []bchain.EthereumMulticallCall{ + {Target: tronMulticall3Address, CallData: "0x1234", AllowFailure: true}, + {Target: tronMulticall3Address}, + }, normalized) + require.Equal(t, "TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED", calls[0].Target) + + _, err = rpc.normalizeMulticallCalls(append(calls, bchain.EthereumMulticallCall{Target: "invalid"})) + require.ErrorContains(t, err, "normalize multicall target 2") +} + type tronTestMempool struct { txTimes map[string]uint32 } @@ -581,6 +613,7 @@ func TestTronRPC_GetAddressChainExtraData(t *testing.T) { "delegated_frozenV2_balance_for_energy": int64(3210000), }, "delegated_frozenV2_balance_for_bandwidth": int64(654000), + "latest_withdraw_time": int64(1777000000000), }, "/wallet/getReward": map[string]any{ "reward": int64(42767), @@ -622,7 +655,8 @@ func TestTronRPC_GetAddressChainExtraData(t *testing.T) { "votes":[{"address":"TJvaAeFb8Lykt9RQcVyyTFN2iDvGMuyD4M","voteCount":"20"}], "unclaimedReward":"42767", "delegatedBalanceEnergy":"3210000", - "delegatedBalanceBandwidth":"654000" + "delegatedBalanceBandwidth":"654000", + "latestWithdrawTime":1777000000 } }`, string(payload)) paths, bodies := mockHTTP.SnapshotRequests() @@ -786,6 +820,26 @@ func TestTronRPC_GetAddressChainExtraData_SkipsVotesWithoutPositiveCount(t *test }, extra.StakingInfo.Votes) } +func TestTronRPC_GetAddressChainExtraData_RejectsNonAccountDescriptor(t *testing.T) { + mockHTTP := &MockTronHTTPClient{Resp: map[string]any{}} + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + addrDesc, err := bchain.AddressDescriptorFromString("ad:" + strings.Repeat("ab", 4096)) + require.NoError(t, err) + + payload, err := tronRPC.GetAddressChainExtraData(addrDesc) + require.NoError(t, err) + require.Nil(t, payload) + paths, _ := mockHTTP.SnapshotRequests() + require.Empty(t, paths) +} + func TestTronRPC_GetAddressChainExtraData_NonExistentAccount_OmitsStakingInfo(t *testing.T) { mockHTTP := &MockTronHTTPClient{ RespByPath: map[string]interface{}{ @@ -966,3 +1020,53 @@ func TestTronRPC_RequestLatestSolidifiedBlockHeight_MissingNumber(t *testing.T) _, err := tronRPC.requestLatestSolidifiedBlockHeight(context.Background()) require.Error(t, err) } + +// TestNewTronRPC_PropagatesEnsReverseOptIn guards the config->parser wiring, not just the +// parser predicate: NewTronRPC recreates the parser that NewEthereumRPC configured, so a +// dropped re-apply silently leaves Tron serving ENS reverse labels (the original +// regression). Reads the flag back through EthereumLikeParser, the same way +// api.NewWorker does. +func TestNewTronRPC_PropagatesEnsReverseOptIn(t *testing.T) { + for _, tt := range []struct { + name string + configEns string + want bool + }{ + {name: "absent from config leaves it off", configEns: ""}, + {name: "explicit false leaves it off", configEns: `"enable_ens_reverse_aliases": false,`}, + {name: "explicit true opts in", configEns: `"enable_ens_reverse_aliases": true,`, want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + // NewEthereumRPC writes these process-wide; keep the test order-independent. + origProcessInternal := bchain.ProcessInternalTransactions + origStandards := bchain.EthereumTokenStandardMap + t.Cleanup(func() { + bchain.ProcessInternalTransactions = origProcessInternal + bchain.EthereumTokenStandardMap = origStandards + }) + + config := json.RawMessage(`{ + "coin_name": "Tron", + "rpc_url": "http://127.0.0.1:18090", + "rpc_timeout": 25, + "block_addresses_to_keep": 300, + "address_aliases": true, + ` + tt.configEns + ` + "hot_address_min_hits": 7, + "averageBlockTimeMs": 3000, + "mempoolTxTimeoutHours": 4 + }`) + + chain, err := NewTronRPC(config, func(bchain.NotificationType) {}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, chain.Shutdown(context.Background())) }) + + parser := chain.GetChainParser() + require.Equal(t, tt.want, parser.UseEnsReverseAliases()) + // A second config-derived field, to prove NewTronRPC applies the whole config via + // ApplyToParser rather than cherry-picking the ENS flag. + require.Equal(t, 7, chain.(*TronRPC).Parser.HotAddressMinHits) + require.Equal(t, 300, chain.(*TronRPC).Parser.KeepBlockAddresses()) + }) + } +} diff --git a/bchain/coins/tron/tronrpc_tip_watchdog_test.go b/bchain/coins/tron/tronrpc_tip_watchdog_test.go index bf5c535a2c..da76dfa5e6 100644 --- a/bchain/coins/tron/tronrpc_tip_watchdog_test.go +++ b/bchain/coins/tron/tronrpc_tip_watchdog_test.go @@ -39,6 +39,7 @@ func TestTronTipStaleThreshold(t *testing.T) { type stubHeader struct{ n int64 } func (h stubHeader) Hash() string { return string(rune(h.n)) } +func (h stubHeader) ParentHash() string { return "" } func (h stubHeader) Number() *big.Int { return big.NewInt(h.n) } func (h stubHeader) Difficulty() *big.Int { return big.NewInt(0) } diff --git a/bchain/coins/tron/txextra.go b/bchain/coins/tron/txextra.go index 57200568ae..933892c379 100644 --- a/bchain/coins/tron/txextra.go +++ b/bchain/coins/tron/txextra.go @@ -47,6 +47,8 @@ func tronOperationFromContractType(contractType string) string { return "unfreeze" case "WithdrawExpireUnfreezeContract": return "withdraw" + case "CancelAllUnfreezeV2Contract": + return "cancelUnfreeze" case "WithdrawBalanceContract": return "voteRewardAmount" case "DelegateResourceContract": @@ -117,8 +119,6 @@ func tronBuildExtraData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetT 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": @@ -127,6 +127,10 @@ func tronBuildExtraData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetT } } + // Not tied to one contract type: java-tron sweeps matured unstake records into the + // spendable balance on any Stake 2.0 unfreeze or cancel, not just on a withdraw. + extra.WithdrawnUnfreeze = tronInt64PtrToString(txInfo.WithdrawExpireAmount) + extra.AssetIssueID = strings.TrimSpace(txInfo.AssetIssueID) extra.TotalFee = tronInt64PtrToString(txInfo.Fee) extra.EnergyUsage = tronInt64PtrToString(txInfo.Receipt.EnergyUsage) @@ -145,6 +149,12 @@ func tronBuildExtraData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetT return extra } +// tronReceiptResultSuccess reports whether receipt.result marks a successful +// execution - empty is the non-VM shape, java-tron sets SUCCESS only for VM txs +func tronReceiptResultSuccess(result string) bool { + return result == "" || result == "SUCCESS" +} + func tronBuildRpcReceipt(txInfo *tronGetTransactionInfoByIDResponse) *bchain.RpcReceipt { receipt := &bchain.RpcReceipt{ GasUsed: "0x0", diff --git a/bchain/coins/tron/txextra_test.go b/bchain/coins/tron/txextra_test.go index f7ed4de45a..eaecad3ee5 100644 --- a/bchain/coins/tron/txextra_test.go +++ b/bchain/coins/tron/txextra_test.go @@ -141,6 +141,61 @@ func TestTronBuildExtraData_StakeAndDelegateDetails(t *testing.T) { require.Equal(t, "88000000", extra.UnstakeAmount) }) + t.Run("stake 2.0 unfreeze also reports the expired unstake it swept back", func(t *testing.T) { + contract := tronTxContract{Type: "UnfreezeBalanceV2Contract"} + contract.Parameter.Value.UnfreezeBalance = int64Ptr(400000000) + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + + txInfo := &tronGetTransactionInfoByIDResponse{ + WithdrawExpireAmount: int64Ptr(600000000), + } + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "unfreeze", extra.Operation) + require.Equal(t, "400000000", extra.UnstakeAmount) + require.Equal(t, "600000000", extra.WithdrawnUnfreeze) + }) + + t.Run("cancel all unfreeze reports its swept expired unstake", func(t *testing.T) { + contract := tronTxContract{Type: "CancelAllUnfreezeV2Contract"} + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + + txInfo := &tronGetTransactionInfoByIDResponse{ + WithdrawExpireAmount: int64Ptr(12345), + } + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "cancelUnfreeze", extra.Operation) + require.Equal(t, "12345", extra.WithdrawnUnfreeze) + }) + + t.Run("withdraw reports the swept amount only once", func(t *testing.T) { + contract := tronTxContract{Type: "WithdrawExpireUnfreezeContract"} + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + + txInfo := &tronGetTransactionInfoByIDResponse{ + WithdrawExpireAmount: int64Ptr(88000000), + } + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "withdraw", extra.Operation) + require.Equal(t, "88000000", extra.WithdrawnUnfreeze) + require.Empty(t, extra.UnstakeAmount) + }) + + t.Run("plain transfer carries no withdrawn unfreeze", func(t *testing.T) { + contract := tronTxContract{Type: "TransferContract"} + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Empty(t, extra.WithdrawnUnfreeze) + }) + t.Run("delegate amount and receiver", func(t *testing.T) { contract := tronTxContract{Type: "DelegateResourceContract"} contract.Parameter.Value.Balance = int64Ptr(42000000) @@ -512,3 +567,112 @@ func TestTronBuildTxFromHTTPData_WithSynthesizedGenesisData(t *testing.T) { require.NotNil(t, receipt) require.Equal(t, "0x1", receipt.Status) } + +func TestTronBuildTxFromHTTPData_FailedDeploymentIsNotCreate(t *testing.T) { + txByID := &tronGetTransactionByIDResponse{ + TxID: "0544ab15ada7051af68b57ca29d69c753b64e6701cfebe5cdbe53a2a9127a88d", + } + txByID.RawData.Contract = []tronTxContract{{ + Type: "CreateSmartContract", + }} + txByID.RawData.Contract[0].Parameter.Value.OwnerAddress = "410746a05c314538e3e21faae3d702cc7939efc07a" + + // java-tron precomputes contract_address even when the deployment failed + txInfo := &tronGetTransactionInfoByIDResponse{ + ID: txByID.TxID, + ContractAddr: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + Result: "FAILED", + } + txInfo.Receipt.Result = "OUT_OF_ENERGY" + + tronRPC := &TronRPC{ + Parser: NewTronParser(1, false), + } + + // internal data as buildInternalDataFromTronInfos produces it for a failed deployment + internalData := &bchain.EthereumInternalData{Type: bchain.CALL, Error: "OUT_OF_ENERGY"} + tx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 1, internalData, true) + require.NoError(t, err) + + require.Len(t, tx.Vout, 1) + require.Nil(t, tx.Vout[0].ScriptPubKey.Addresses) + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.NotNil(t, csd.InternalData) + require.Equal(t, bchain.CALL, csd.InternalData.Type) + require.Equal(t, "", csd.InternalData.Contract) + require.Equal(t, "OUT_OF_ENERGY", csd.InternalData.Error) + + // the successful deployment keeps flowing through the receipt fallback, + // e.g. on the ad-hoc GetTransaction path where no internal data is passed + txInfo.Result = "" + txInfo.Receipt.Result = "SUCCESS" + tx, err = tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 1, nil, true) + require.NoError(t, err) + + wantContract := ToTronAddressFromAddress("4139dd12a54e2bab7c82aa14a1e158b34263d2d510") + require.Len(t, tx.Vout, 1) + require.Equal(t, []string{wantContract}, tx.Vout[0].ScriptPubKey.Addresses) + csd, ok = tx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.NotNil(t, csd.InternalData) + require.Equal(t, bchain.CREATE, csd.InternalData.Type) + require.Equal(t, wantContract, csd.InternalData.Contract) +} + +func TestTronBuildTxFromHTTPData_KeepReceiptControlsTokenLogs(t *testing.T) { + txByID := &tronGetTransactionByIDResponse{ + TxID: "b7a97862b0c719b714f0cf7e250ebc2dcdf1e5f05c54ddb21ff3a748c1aa45e4", + } + txByID.RawData.Contract = []tronTxContract{{ + Type: "TriggerSmartContract", + }} + txByID.RawData.Contract[0].Parameter.Value.OwnerAddress = "410746a05c314538e3e21faae3d702cc7939efc07a" + txByID.RawData.Contract[0].Parameter.Value.ContractAddress = "4139dd12a54e2bab7c82aa14a1e158b34263d2d510" + txByID.RawData.Contract[0].Parameter.Value.Data = "6f21b898" + + txInfo := &tronGetTransactionInfoByIDResponse{ + ID: txByID.TxID, + Log: []*bchain.RpcLog{ + { + Address: "a614f803b6fd780986a42c78ec9c7f77e6ded13c", + Data: "000000000000000000000000000000000000000000000000000000002d4cae00", + Topics: []string{ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0000000000000000000000000746a05c314538e3e21faae3d702cc7939efc07a", + "000000000000000000000000f14cca91e8d5fff29cbcd42b26a19a7395b1aa84", + }, + }, + }, + } + txInfo.Receipt.Result = "SUCCESS" + + parser := NewTronParser(1, false) + tronRPC := &TronRPC{ + Parser: parser, + } + + pendingTx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 0, nil, false) + require.NoError(t, err) + pendingSpecific, ok := pendingTx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.Nil(t, pendingSpecific.Receipt) + pendingTransfers, err := parser.EthereumTypeGetTokenTransfersFromTx(pendingTx) + require.NoError(t, err) + require.Empty(t, pendingTransfers) + + indexedTx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 1, nil, true) + require.NoError(t, err) + indexedSpecific, ok := indexedTx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.NotNil(t, indexedSpecific.Receipt) + require.Len(t, indexedSpecific.Receipt.Logs, 1) + + indexedTransfers, err := parser.EthereumTypeGetTokenTransfersFromTx(indexedTx) + require.NoError(t, err) + require.Len(t, indexedTransfers, 1) + require.Equal(t, "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", indexedTransfers[0].Contract) + require.Equal(t, "TAdgLcNFZjeF1AspMobkz2bbxs5yDXpwYx", indexedTransfers[0].From) + require.Equal(t, "TXy5qqpAJNykdqsMU2dZM7B7mZbVF2izAN", indexedTransfers[0].To) + require.Equal(t, "760000000", indexedTransfers[0].Value.String()) +} diff --git a/bchain/coins/zec/zcashrpc.go b/bchain/coins/zec/zcashrpc.go index c1952c6c4e..0bf08ef50c 100644 --- a/bchain/coins/zec/zcashrpc.go +++ b/bchain/coins/zec/zcashrpc.go @@ -108,7 +108,7 @@ func (z *ZCashRPC) GetChainInfo() (*bchain.ChainInfo, error) { ProtocolVersion: string(networkInfo.Result.ProtocolVersion), Timeoffset: networkInfo.Result.Timeoffset, Consensus: chainInfo.Result.Consensus, - Warnings: networkInfo.Result.Warnings, + Warnings: string(networkInfo.Result.Warnings), }, nil } @@ -273,11 +273,6 @@ func (z *ZCashRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { return z.GetTransaction(txid) } -// GetMempoolEntry returns mempool data for given transaction -func (z *ZCashRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { - return nil, errors.New("GetMempoolEntry: not implemented") -} - // GetBlockRaw is not supported func (z *ZCashRPC) GetBlockRaw(hash string) (string, error) { return "", errors.New("GetBlockRaw: not supported") diff --git a/bchain/evm_interface.go b/bchain/evm_interface.go index 8eb94f54a1..ee627757e2 100644 --- a/bchain/evm_interface.go +++ b/bchain/evm_interface.go @@ -29,6 +29,7 @@ type EVMRPCClient interface { // EVMHeader provides access to the necessary header data for evm chain sync type EVMHeader interface { Hash() string + ParentHash() string Number() *big.Int Difficulty() *big.Int } diff --git a/bchain/mempool_bitcoin_type.go b/bchain/mempool_bitcoin_type.go index e684006bae..1783355072 100644 --- a/bchain/mempool_bitcoin_type.go +++ b/bchain/mempool_bitcoin_type.go @@ -334,7 +334,7 @@ func (m *MempoolBitcoinType) dispatchResyncPayloads(txids []string, cache map[st select { // store as many processed transactions as possible case tio := <-m.chanAddrIndex: - onNewEntry(tio.txid, txEntry{tio.io, txTime, tio.filter}) + onNewEntry(tio.txid, txEntry{addrIndexes: tio.io, time: txTime, filter: tio.filter}) dispatched-- // send transaction to be processed case m.chanTx <- txPayload{txid: txid, tx: tx}: @@ -345,7 +345,7 @@ func (m *MempoolBitcoinType) dispatchResyncPayloads(txids []string, cache map[st } for i := 0; i < dispatched; i++ { tio := <-m.chanAddrIndex - onNewEntry(tio.txid, txEntry{tio.io, txTime, tio.filter}) + onNewEntry(tio.txid, txEntry{addrIndexes: tio.io, time: txTime, filter: tio.filter}) } } diff --git a/bchain/mempool_ethereum_type.go b/bchain/mempool_ethereum_type.go index 85217880ec..c8f17eb0a5 100644 --- a/bchain/mempool_ethereum_type.go +++ b/bchain/mempool_ethereum_type.go @@ -2,6 +2,8 @@ package bchain import ( "errors" + "strconv" + "strings" "time" "github.com/golang/glog" @@ -87,7 +89,41 @@ func (m *MempoolEthereumType) createTxEntry(txid string, txTime uint32) (txEntry if m.OnNewTx != nil { m.OnNewTx(mtx) } - return txEntry{addrIndexes: addrIndexes, time: txTime}, true + entry := txEntry{addrIndexes: addrIndexes, time: txTime} + if csd, ok := tx.CoinSpecificData.(EthereumSpecificData); ok && csd.Tx != nil && len(mtx.Vin) > 0 { + nonce, err := strconv.ParseUint(strings.TrimPrefix(csd.Tx.AccountNonce, "0x"), 16, 64) + if err != nil { + glog.Warning("cannot parse nonce ", csd.Tx.AccountNonce, " of tx ", txid, ": ", err) + } else { + entry.from = string(mtx.Vin[0].AddrDesc) + entry.nonce = nonce + } + } + return entry, true +} + +// RemoveSenderTransactionsUpToNonce retires every entry sent by from with a nonce at or below the +// mined one - no such transaction can ever mine, whatever the backend says about its pool presence. +// Returns the removed txids. +func (m *MempoolEthereumType) RemoveSenderTransactionsUpToNonce(from AddressDescriptor, nonce uint64) []string { + m.mux.Lock() + defer m.mux.Unlock() + // collect first - removeEntryFromMempool compacts the outpoint slice being scanned + var candidates []string + for _, o := range m.addrDescToTx[string(from)] { + if entry, ok := m.txEntries[o.Txid]; ok && entry.from != "" && entry.from == string(from) && entry.nonce <= nonce { + candidates = append(candidates, o.Txid) + } + } + var removed []string + for _, txid := range candidates { + // a self-transfer lists the txid twice under the same address + if entry, ok := m.txEntries[txid]; ok { + m.removeEntryFromMempool(txid, entry) + removed = append(removed, txid) + } + } + return removed } // Resync ethereum type removes timed out transactions and returns number of transactions in mempool. @@ -157,7 +193,9 @@ func (m *MempoolEthereumType) removeTransactionsMissingFromBackend(backendTxs ma return removed } -// AddTransactionToMempool adds transactions to mempool, returns true if tx added to mempool, false if not added (for example duplicate call) +// AddTransactionToMempool adds transactions to mempool, returns true if tx added to mempool, false if not added (for example duplicate call). +// An existing entry keeps its original time - the resync path re-adds every backend tx and the timeout +// sweep depends on the first-seen stamp; use AddOrRefreshTransactionInMempool to age from the newest add. func (m *MempoolEthereumType) AddTransactionToMempool(txid string) bool { m.mux.Lock() _, exists := m.txEntries[txid] @@ -180,6 +218,23 @@ func (m *MempoolEthereumType) AddTransactionToMempool(txid string) bool { return !exists } +// AddOrRefreshTransactionInMempool is AddTransactionToMempool for a caller whose own store ages from +// the newest submission (the alternative-provider cache): an existing entry is restamped in place, so +// the timeout sweep cannot drop it while that store still serves the transaction as pending. +func (m *MempoolEthereumType) AddOrRefreshTransactionInMempool(txid string) bool { + m.mux.Lock() + entry, exists := m.txEntries[txid] + if exists { + entry.time = uint32(time.Now().Unix()) + m.txEntries[txid] = entry + } + m.mux.Unlock() + if exists { + return true + } + return m.AddTransactionToMempool(txid) +} + // RemoveTransactionFromMempool removes transaction from mempool func (m *MempoolEthereumType) RemoveTransactionFromMempool(txid string) { m.mux.Lock() diff --git a/bchain/mempool_ethereum_type_test.go b/bchain/mempool_ethereum_type_test.go index 9a5ae5067e..e9a4827822 100644 --- a/bchain/mempool_ethereum_type_test.go +++ b/bchain/mempool_ethereum_type_test.go @@ -60,3 +60,60 @@ func TestNewMempoolEthereumTypeUsesDuration(t *testing.T) { t.Fatalf("mempoolTimeoutTime = %s, want %s", m.mempoolTimeoutTime, 10*time.Minute) } } + +// A restamped entry ages from the newest add, so the timeout sweep cannot drop it while the +// alternative-provider cache - which also ages from the newest send - still serves it as pending. +func TestMempoolEthereumTypeAddOrRefreshRestampsExistingEntry(t *testing.T) { + m := NewMempoolEthereumType(nil, 10*time.Minute, false) + old := uint32(time.Now().Add(-time.Hour).Unix()) + m.txEntries["tx1"] = txEntry{addrIndexes: []addrIndex{{addrDesc: "addr1"}}, time: old} + + if !m.AddOrRefreshTransactionInMempool("tx1") { + t.Fatal("AddOrRefreshTransactionInMempool() = false, want true") + } + if got := m.txEntries["tx1"].time; got == old { + t.Fatalf("entry time = %d, want restamped past %d", got, old) + } + if got := m.txEntries["tx1"].addrIndexes; len(got) != 1 { + t.Fatalf("addrIndexes = %+v, want the original index kept", got) + } +} + +// A mined nonce retires the sender's entries at or below it and nothing else: higher nonces, entries +// where the address is only a recipient, and entries without nonce data (pre-upgrade) all stay (#1709). +func TestMempoolEthereumType_RemoveSenderTransactionsUpToNonce(t *testing.T) { + m := NewMempoolEthereumType(nil, 10*time.Minute, false) + add := func(txid, from string, nonce uint64, addrs ...string) { + entry := txEntry{from: from, nonce: nonce} + for _, a := range addrs { + entry.addrIndexes = append(entry.addrIndexes, addrIndex{addrDesc: a}) + m.addrDescToTx[a] = append(m.addrDescToTx[a], Outpoint{Txid: txid}) + } + m.txEntries[txid] = entry + } + add("stale", "sender", 5, "sender", "other") + add("mined", "sender", 6, "sender", "other") + add("next", "sender", 7, "sender", "other") + add("incoming", "other", 6, "other", "sender") + add("legacy", "", 0, "sender") + + removed := m.RemoveSenderTransactionsUpToNonce(AddressDescriptor("sender"), 6) + if len(removed) != 2 || m.txEntries["stale"].from != "" || m.txEntries["mined"].from != "" { + t.Fatalf("removed = %v, want stale and mined", removed) + } + for _, kept := range []string{"next", "incoming", "legacy"} { + if _, found := m.txEntries[kept]; !found { + t.Fatalf("entry %q was removed, want kept", kept) + } + } + wantAddrDescToTx := map[string][]Outpoint{ + "sender": {{Txid: "next"}, {Txid: "incoming"}, {Txid: "legacy"}}, + "other": {{Txid: "next"}, {Txid: "incoming"}}, + } + if !reflect.DeepEqual(m.addrDescToTx, wantAddrDescToTx) { + t.Fatalf("addrDescToTx = %+v, want %+v", m.addrDescToTx, wantAddrDescToTx) + } + if got := m.RemoveSenderTransactionsUpToNonce(AddressDescriptor("nobody"), 100); len(got) != 0 { + t.Fatalf("removed = %v for an unknown sender, want none", got) + } +} diff --git a/bchain/mq.go b/bchain/mq.go index 8f0bd6787b..fe39a4bc4b 100644 --- a/bchain/mq.go +++ b/bchain/mq.go @@ -26,6 +26,12 @@ type MQ struct { subs SubscriptionTopics } +type CurveConfig struct { + PublicKey string `json:"public_key,omitempty"` + SecretKey string `json:"secret_key,omitempty"` + ServerKey string `json:"server_key,omitempty"` +} + // NotificationType is type of notification type NotificationType int @@ -40,7 +46,7 @@ const ( // NewMQ creates new Bitcoind ZeroMQ listener // callback function receives messages -func NewMQ(binding string, callback func(NotificationType), subs SubscriptionTopics) (*MQ, error) { +func NewMQ(binding string, curve *CurveConfig, callback func(NotificationType), subs SubscriptionTopics) (*MQ, error) { context, err := zmq.NewContext() if err != nil { return nil, err @@ -59,6 +65,32 @@ func NewMQ(binding string, callback func(NotificationType), subs SubscriptionTop return nil, err } } + + if curve != nil { + err = socket.SetCurveServerkey(curve.ServerKey) + if err != nil { + return nil, err + } + publicKey := curve.PublicKey + secretKey := curve.SecretKey + if publicKey == "" || secretKey == "" { + // generate new keypair + publicKey, secretKey, err = zmq.NewCurveKeypair() + if err != nil { + return nil, err + } + } + + err = socket.SetCurvePublickey(publicKey) + if err != nil { + return nil, err + } + err = socket.SetCurveSecretkey(secretKey) + if 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 // socket.SetSubscribe("rawblock") diff --git a/bchain/sendtxerror.go b/bchain/sendtxerror.go new file mode 100644 index 0000000000..9d1287fc66 --- /dev/null +++ b/bchain/sendtxerror.go @@ -0,0 +1,158 @@ +package bchain + +import ( + "strconv" + "strings" +) + +// Reason labels for a transaction broadcast outcome. The caller-side classes describe a +// transaction refused on its own merits, the backend-side ones (rate_limited, unauthorized, +// timeout, unavailable) mean the send never got a verdict and are the ones an operator should +// alert on. +const ( + ReasonOK = "ok" + ReasonAlreadyKnown = "already_known" + ReasonNonceTooLow = "nonce_too_low" + ReasonNonceTooHigh = "nonce_too_high" + ReasonUnderpriced = "underpriced" + ReasonReplacementUnderpriced = "replacement_underpriced" + ReasonFeeCap = "fee_cap_exceeded" + ReasonInsufficientFunds = "insufficient_funds" + ReasonGasLimit = "gas_limit" + ReasonIntrinsicGas = "intrinsic_gas_too_low" + ReasonMissingInputs = "missing_inputs" + ReasonConflict = "conflict" + ReasonNonFinal = "non_final" + ReasonInvalidTransaction = "invalid_transaction" + ReasonRateLimited = "rate_limited" + ReasonUnauthorized = "unauthorized" + ReasonTimeout = "timeout" + ReasonUnavailable = "unavailable" + ReasonOther = "other" +) + +// Status labels for a transaction broadcast outcome. success/failure is the domain the rest of the +// metric registry's status labels use. +const ( + SendTxStatusSuccess = "success" + SendTxStatusFailure = "failure" +) + +// sendTxErrorClasses maps a fragment of a backend rejection message to the reason label reported +// for a failed transaction broadcast. The table is deliberately backend-agnostic - EVM nodes, +// private relays and the bitcoind family all reject with free-form strings, and the resulting +// label space is the same either way. Order matters, the first match wins, so more specific +// patterns come before the general ones they contain. +var sendTxErrorClasses = []struct { + fragment string + class string +}{ + // the transaction is already in the mempool or on chain - a retry, not a failure of ours + {"already known", ReasonAlreadyKnown}, + {"already in block chain", ReasonAlreadyKnown}, + {"txn-already-in-mempool", ReasonAlreadyKnown}, + {"txn-already-known", ReasonAlreadyKnown}, + {"transaction already exists", ReasonAlreadyKnown}, + // nonce and fee rejections - the wallet built the transaction from a stale view + {"nonce too low", ReasonNonceTooLow}, + {"nonce too high", ReasonNonceTooHigh}, + {"replacement transaction underpriced", ReasonReplacementUnderpriced}, + {"underpriced", ReasonUnderpriced}, + {"min relay fee not met", ReasonUnderpriced}, + {"mempool min fee not met", ReasonUnderpriced}, + {"insufficient fee", ReasonUnderpriced}, + {"fee too low", ReasonUnderpriced}, + {"tx fee", ReasonFeeCap}, + {"insufficient funds", ReasonInsufficientFunds}, + // the transaction can never be mined as built + {"exceeds block gas limit", ReasonGasLimit}, + {"intrinsic gas too low", ReasonIntrinsicGas}, + {"missingorspent", ReasonMissingInputs}, + {"txn-mempool-conflict", ReasonConflict}, + {"non-final", ReasonNonFinal}, + {"non-bip68-final", ReasonNonFinal}, + {"oversized data", ReasonInvalidTransaction}, + {"invalid sender", ReasonInvalidTransaction}, + {"transaction type not supported", ReasonInvalidTransaction}, + // anchored on the colon geth's rlp errors always carry: a bare "rlp" is short enough to hit a + // provider URL inside the message, which would report every outage of that deployment as a + // caller-side rejection + {"rlp: ", ReasonInvalidTransaction}, + {"invalid transaction", ReasonInvalidTransaction}, + // the backend or provider itself is the problem - these are the classes worth alerting on + {"unauthorized", ReasonUnauthorized}, + {"forbidden", ReasonUnauthorized}, + {"too many requests", ReasonRateLimited}, + {"rate limit", ReasonRateLimited}, + {"context deadline exceeded", ReasonTimeout}, + {"timeout", ReasonTimeout}, + {"timed out", ReasonTimeout}, + {"connection refused", ReasonUnavailable}, + {"connection reset", ReasonUnavailable}, + {"no such host", ReasonUnavailable}, + {"eof", ReasonUnavailable}, + {"bad gateway", ReasonUnavailable}, + {"service unavailable", ReasonUnavailable}, + {"internal server error", ReasonUnavailable}, +} + +// httpStatusReason classifies a failure that carries nothing but an HTTP status. geth renders +// rpc.HTTPError as " [: ]", so a revoked key or a failing provider arrives with +// no backend wording to match on, and a proxy in front of the provider is free to replace the +// standard reason phrase the table above looks for. Without this such a failure counts as "other", +// which the dashboards read as a gap in the classifier rather than as the broadcast outage it is. +// Codes whose meaning depends on the request (400, 404) are left to "other" on purpose. +func httpStatusReason(msg string) (string, bool) { + // the status is the start of the message, which keeps a number inside a rejection body from + // being read as one + if len(msg) < 3 || (len(msg) > 3 && msg[3] != ' ' && msg[3] != ':') { + return "", false + } + code, err := strconv.Atoi(msg[:3]) + if err != nil || code < 100 || code > 599 { + return "", false + } + switch { + case code == 401 || code == 402 || code == 403 || code == 407: + return ReasonUnauthorized, true + case code == 408 || code == 504 || code == 524: // 524 is Cloudflare's origin timeout + return ReasonTimeout, true + case code == 429: + return ReasonRateLimited, true + case code >= 500: + return ReasonUnavailable, true + } + return "", false +} + +// SendTxStatus is the status label of a broadcast outcome. +func SendTxStatus(err error) string { + if err != nil { + return SendTxStatusFailure + } + return SendTxStatusSuccess +} + +// ClassifySendTxError reduces a backend rejection to a bounded metric label. It exists because a +// bare success/failure ratio cannot tell a wallet-side mistake (underpriced, nonce_too_low) from +// backend trouble (timeout, rate_limited) - only the class distinguishes the two, and they call +// for opposite responses. A message carrying only an HTTP status is classified from the status +// itself, so a dead provider cannot hide in "other". Everything still unmatched falls into +// "other", which is the signal to extend the table rather than to alert. +func ClassifySendTxError(err error) string { + if err == nil { + return ReasonOK + } + msg := strings.ToLower(err.Error()) + for _, c := range sendTxErrorClasses { + if strings.Contains(msg, c.fragment) { + return c.class + } + } + // after the table, so the backend's own wording wins: a 400 whose body says "nonce too low" + // is a caller-side rejection, not a provider failure + if class, found := httpStatusReason(msg); found { + return class + } + return ReasonOther +} diff --git a/bchain/sendtxerror_test.go b/bchain/sendtxerror_test.go new file mode 100644 index 0000000000..4c293f36f3 --- /dev/null +++ b/bchain/sendtxerror_test.go @@ -0,0 +1,56 @@ +package bchain + +import ( + "errors" + "testing" +) + +// TestClassifySendTxError pins the classes an operator alerts on: caller-side rejections must +// never be reported as backend trouble and vice versa. +func TestClassifySendTxError(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {"nil error is success", nil, ReasonOK}, + {"geth already known", errors.New("already known"), ReasonAlreadyKnown}, + {"bitcoind already in chain", errors.New("-27: transaction already in block chain"), ReasonAlreadyKnown}, + {"geth nonce too low", errors.New("nonce too low: next nonce 5, tx nonce 3"), ReasonNonceTooLow}, + {"geth nonce too high", errors.New("nonce too high: next nonce 5, tx nonce 9"), ReasonNonceTooHigh}, + // the replacement class must win over the plain underpriced fragment it contains + {"replacement underpriced", errors.New("replacement transaction underpriced"), ReasonReplacementUnderpriced}, + {"geth underpriced", errors.New("transaction underpriced"), ReasonUnderpriced}, + {"bitcoind min relay fee", errors.New("-26: min relay fee not met, 100 < 141"), ReasonUnderpriced}, + {"geth insufficient funds", errors.New("insufficient funds for gas * price + value"), ReasonInsufficientFunds}, + {"geth intrinsic gas", errors.New("intrinsic gas too low: have 1000, want 21000"), ReasonIntrinsicGas}, + {"bitcoind missing inputs", errors.New("-25: bad-txns-inputs-missingorspent"), ReasonMissingInputs}, + {"bitcoind mempool conflict", errors.New("-26: txn-mempool-conflict"), ReasonConflict}, + {"rlp decode failure", errors.New("rlp: expected input list for types.LegacyTx"), ReasonInvalidTransaction}, + // the provider URL travels inside the message, so a fragment must not match a path in it + {"provider timeout with rlp in the URL", errors.New(`Post "https://relay.example.com/rlp-fast/KEY": context deadline exceeded`), ReasonTimeout}, + {"provider rate limit", errors.New("429 Too Many Requests: rate exceeded"), ReasonRateLimited}, + {"rpc timeout", errors.New("context deadline exceeded"), ReasonTimeout}, + {"provider down", errors.New("dial tcp 127.0.0.1:1: connect: connection refused"), ReasonUnavailable}, + // a failure carrying only an HTTP status is an outage, not a classifier gap + {"revoked provider key", errors.New(`403 Forbidden: {"error":"invalid api key"}`), ReasonUnauthorized}, + {"provider rejects the credentials", errors.New("401 Unauthorized"), ReasonUnauthorized}, + {"provider internal error", errors.New("500 Internal Server Error: upstream failure"), ReasonUnavailable}, + // a proxy is free to replace the standard reason phrase, so the code alone has to carry it + {"nonstandard status text", errors.New("503 Backend Fetch Failed"), ReasonUnavailable}, + {"cloudflare origin timeout", errors.New("524 origin took too long"), ReasonTimeout}, + // the backend's own wording outranks the transport status + {"status with a caller-side body", errors.New("400 Bad Request: nonce too low"), ReasonNonceTooLow}, + // request-dependent statuses stay unclassified on purpose + {"not found stays unclassified", errors.New("404 Not Found"), ReasonOther}, + {"bitcoind error code is not a status", errors.New("-26: bad-txns-nonstandard"), ReasonOther}, + {"unknown message falls through", errors.New("something nobody has classified yet"), ReasonOther}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ClassifySendTxError(tt.err); got != tt.want { + t.Errorf("ClassifySendTxError(%v) = %q, want %q", tt.err, got, tt.want) + } + }) + } +} diff --git a/bchain/types.go b/bchain/types.go index acee4a1824..88bd8f577f 100644 --- a/bchain/types.go +++ b/bchain/types.go @@ -34,6 +34,11 @@ var ( // ErrTxidMissing is returned if txid is not specified // for example coinbase transactions in Bitcoin ErrTxidMissing = errors.New("Txid missing") + // ErrConfiguration marks a failure caused by static configuration rather than by an unavailable + // backend. Startup retries an unavailable backend for two minutes; a configuration error cannot + // resolve in that time and must not be reported as an RPC fault, so it is wrapped in this and + // checked with IsConfigurationError. + ErrConfiguration = errors.New("invalid configuration") // ErrTxNotFound is returned if transaction was not found ErrTxNotFound = errors.New("Tx not found") ) @@ -212,24 +217,6 @@ type BlockInfo struct { 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" 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" ts_doc:"Name of the chain (e.g. 'main')."` @@ -512,14 +499,13 @@ type BlockChain interface { EstimateFee(blocks int) (big.Int, 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) - EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool) (pending uint64, confirmed uint64, confirmedOK bool, err error) + EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (pending uint64, confirmed uint64, confirmedOK bool, err error) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) EthereumTypeGetEip1559Fees() (*Eip1559Fees, error) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc AddressDescriptor) (*big.Int, error) @@ -529,9 +515,18 @@ type BlockChain interface { EthereumTypeRpcCall(data, to, from string) (string, error) EthereumTypeGetRawTransaction(txid string) (string, error) EthereumTypeGetTransactionReceipt(txid string) (*RpcReceipt, error) + EthereumTypeGetBestTip() (*EVMTip, error) GetTokenURI(contractDesc AddressDescriptor, tokenID *big.Int) (string, error) } +// EVMTip is one snapshot of the cached EVM tip header, so hash, parent and height +// always describe the same block. +type EVMTip struct { + Hash string + ParentHash string + Height uint32 +} + // BlockChainParser defines common interface to parsing and conversions of block chain data type BlockChainParser interface { // type of the blockchain @@ -543,6 +538,8 @@ type BlockChainParser interface { AmountDecimals() int // UseAddressAliases returns true if address aliases are enabled UseAddressAliases() bool + // UseEnsReverseAliases returns true if ENS reverse labels (address->name) are recorded and served + UseEnsReverseAliases() 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 @@ -578,6 +575,8 @@ type BlockChainParser interface { DeriveAddressDescriptorsFromTo(descriptor *XpubDescriptor, change uint32, fromIndex uint32, toIndex uint32) ([]AddressDescriptor, error) // EthereumType specific EthereumTypeGetTokenTransfersFromTx(tx *Tx) (TokenTransfers, error) + // EthereumTypeIsIgnoredContract reports a contract whose already indexed holdings must not be served as a token + EthereumTypeIsIgnoredContract(contract AddressDescriptor) bool GetEthereumTxData(tx *Tx) *EthereumTxData GetChainExtraPayloadType() ChainExtraPayloadType GetChainExtraData(tx *Tx) (json.RawMessage, error) @@ -616,3 +615,31 @@ type MissingBlockRetry struct { // yields errResync to the outer machinery. MaxStallMs int `json:"maxStallMs,omitempty"` } + +// XpubConfig carries per-chain overrides for xpub address expansion and +// caching. All fields are optional; zero / missing values fall back to the +// api package's built-in defaults. Operators set this under +// `additional_params.xpubConfig` in `configs/coins/*.json`. +type XpubConfig struct { + // MaxCacheExpirationSeconds is the TTL for cached xpub-to-address + // derivations (seconds). Zero / missing uses the default (3600). + MaxCacheExpirationSeconds int `json:"maxCacheExpirationSeconds,omitempty"` + // MaxCacheEntries is the hard cap on the number of cached xpubs. + // Zero / missing uses the default (5000). + MaxCacheEntries int `json:"maxCacheEntries,omitempty"` + // MaxCacheBytes is the hard cap on the estimated memory held by cached + // xpubs. Zero / missing uses the default (1 GiB). + MaxCacheBytes int `json:"maxCacheBytes,omitempty"` + // DefaultAddressesGap is the default BIP44 gap limit when the + // caller does not supply one (zero / missing → 20). + DefaultAddressesGap int `json:"defaultAddressesGap,omitempty"` + // MaxAddressesGap is the maximum user-supplied gap value the + // server will accept (zero / missing → 10000). + MaxAddressesGap int `json:"maxAddressesGap,omitempty"` +} + +// IsConfigurationError reports whether err was caused by static configuration (see ErrConfiguration), +// so a caller can fail fast instead of retrying something that cannot change. +func IsConfigurationError(err error) bool { + return errors.Is(err, ErrConfiguration) +} diff --git a/bchain/types_chainextradata.go b/bchain/types_chainextradata.go index e8f69ea1bd..3c21133f2a 100644 --- a/bchain/types_chainextradata.go +++ b/bchain/types_chainextradata.go @@ -7,3 +7,34 @@ const ( ChainExtraPayloadTypeUnknown ChainExtraPayloadType = "" ChainExtraPayloadTypeTron ChainExtraPayloadType = "tron" ) + +// ChainExtraPayload binds a payloadType to the structs serialized into the chainExtraData payload, +// so generated client types can narrow the payload on payloadType. +type ChainExtraPayload struct { + Type ChainExtraPayloadType + Tx interface{} + Account interface{} +} + +// ChainExtraPayloads lists every payloadType a parser can return from GetChainExtraPayloadType; +// a parser that emits a payload must have an entry here (see the parser's registry test). +var ChainExtraPayloads = []ChainExtraPayload{ + {Type: ChainExtraPayloadTypeTron, Tx: TronChainExtraData{}, Account: TronAccountExtraData{}}, +} + +// ChainExtraPayloadByType returns the registry entry for the payloadType, if any. +func ChainExtraPayloadByType(t ChainExtraPayloadType) (ChainExtraPayload, bool) { + for _, p := range ChainExtraPayloads { + if p.Type == t { + return p, true + } + } + return ChainExtraPayload{}, false +} + +// ChainExtraPayloadWrapper is implemented by the API structs whose Payload carries one of the +// registered structs; the method picks that wrapper's struct out of a registry entry so the +// generated client types can be derived without naming the wrappers anywhere else. +type ChainExtraPayloadWrapper interface { + ChainExtraPayload(ChainExtraPayload) interface{} +} diff --git a/bchain/types_ethereum_type.go b/bchain/types_ethereum_type.go index ceec2121f8..16e710b84d 100644 --- a/bchain/types_ethereum_type.go +++ b/bchain/types_ethereum_type.go @@ -49,6 +49,8 @@ type EthereumParsedInputData struct { 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."` + // Truncated: parse budget exceeded, only MethodId is populated + Truncated bool `json:"parsedDataTruncated,omitempty" ts_doc:"True when input data exceeded the parse-size budget and parameters were not decoded."` } // EthereumInternalTransactionType - type of ethereum transaction from internal data @@ -115,6 +117,13 @@ type EthereumMulticallResult struct { Data string } +// EthereumContractInfoResult is one contract's metadata verdict from a batched probe. Info==nil with +// Err==nil is a conclusive "no token here"; a non-nil Err says nothing, so it must not be cached. +type EthereumContractInfoResult struct { + Info *ContractInfo + Err error +} + // Ethereum token standard names const ( ERC20TokenStandard TokenStandardName = "ERC20" @@ -173,14 +182,15 @@ type RpcLog struct { // 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"` + GasUsed string `json:"gasUsed" ts_doc:"Amount of gas actually used by the transaction."` + EffectiveGasPrice string `json:"effectiveGasPrice,omitempty" ts_doc:"Actual gas price paid per gas unit; on L2 networks this differs from the transaction gasPrice bid."` + 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. @@ -201,6 +211,7 @@ type EthereumTxData struct { GasLimit *big.Int `json:"gaslimit"` GasUsed *big.Int `json:"gasused"` GasPrice *big.Int `json:"gasprice"` + EffectiveGasPrice *big.Int `json:"effectiveGasPrice,omitempty"` MaxPriorityFeePerGas *big.Int `json:"maxPriorityFeePerGas,omitempty"` MaxFeePerGas *big.Int `json:"maxFeePerGas,omitempty"` BaseFeePerGas *big.Int `json:"baseFeePerGas,omitempty"` @@ -231,6 +242,11 @@ 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."` + // GasUsed/GasLimit/BaseFeePerGas are transient block-level EIP-1559 figures used for the new-block + // notification; they are not persisted (storeBlockSpecificDataEthereumType ignores them). + GasUsed *big.Int `json:"-"` + GasLimit *big.Int `json:"-"` + BaseFeePerGas *big.Int `json:"-"` } // StakingPoolData holds data about address participation in a staking pool contract @@ -268,3 +284,51 @@ type Eip1559Fees struct { PriorityFeeTrend string `json:"priorityFeeTrend,omitempty"` BaseFeeTrend string `json:"baseFeeTrend,omitempty"` } + +func copyBigInt(b *big.Int) *big.Int { + if b == nil { + return nil + } + return new(big.Int).Set(b) +} + +func copyBigIntSlice(s []*big.Int) []*big.Int { + if s == nil { + return nil + } + c := make([]*big.Int, len(s)) + for i := range s { + c[i] = copyBigInt(s[i]) + } + return c +} + +// Copy returns a deep copy so callers may adjust a fee tier per request without +// mutating a value shared across requests (e.g. a provider cache). +func (f *Eip1559Fee) Copy() *Eip1559Fee { + if f == nil { + return nil + } + c := *f + c.MaxFeePerGas = copyBigInt(f.MaxFeePerGas) + c.MaxPriorityFeePerGas = copyBigInt(f.MaxPriorityFeePerGas) + return &c +} + +// Copy returns a deep copy so callers may adjust fees per request without +// mutating a value shared across requests (e.g. a provider cache). +func (f *Eip1559Fees) Copy() *Eip1559Fees { + if f == nil { + return nil + } + c := *f + c.BaseFeePerGas = copyBigInt(f.BaseFeePerGas) + c.Low = f.Low.Copy() + c.Medium = f.Medium.Copy() + c.High = f.High.Copy() + c.Instant = f.Instant.Copy() + c.LatestPriorityFeeRange = copyBigIntSlice(f.LatestPriorityFeeRange) + c.HistoricalPriorityFeeRange = copyBigIntSlice(f.HistoricalPriorityFeeRange) + c.HistoricalBaseFeeRange = copyBigIntSlice(f.HistoricalBaseFeeRange) + return &c +} diff --git a/bchain/types_tron_chainextradata.go b/bchain/types_tron_chainextradata.go index f79348e8ac..6415168b3e 100644 --- a/bchain/types_tron_chainextradata.go +++ b/bchain/types_tron_chainextradata.go @@ -8,13 +8,16 @@ type TronVoteExtra struct { // 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"` + 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"` + // WithdrawnUnfreeze is the matured unstake that java-tron swept back into the + // spendable balance in this transaction, on any Stake 2.0 unfreeze or cancel. + WithdrawnUnfreeze string `json:"withdrawnUnfreeze,omitempty"` DelegateAmount string `json:"delegateAmount,omitempty"` DelegateTo string `json:"delegateTo,omitempty"` AssetIssueID string `json:"assetIssueID,omitempty"` @@ -53,6 +56,7 @@ type TronStakingInfo struct { UnclaimedReward string `json:"unclaimedReward"` DelegatedBalanceEnergy string `json:"delegatedBalanceEnergy"` DelegatedBalanceBandwidth string `json:"delegatedBalanceBandwidth"` + LatestWithdrawTime int64 `json:"latestWithdrawTime"` } // TronAccountExtraData contains normalized Tron-specific account resource metadata. diff --git a/blockbook-api.ts b/blockbook-api.ts index 427201f8b9..c748b70231 100644 --- a/blockbook-api.ts +++ b/blockbook-api.ts @@ -1,12 +1,9 @@ /* Do not change, this code is generated from Golang structs */ +/* Regenerate with `make typescriptify` (see build/tools/typescriptify) */ +export type TxChainExtraData = { payloadType: 'tron'; payload?: TronChainExtraData }; +export type AccountChainExtraData = { payloadType: 'tron'; payload?: TronAccountExtraData }; -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; @@ -18,10 +15,13 @@ export interface TronChainExtraData { resource?: string; stakeAmount?: string; unstakeAmount?: string; + claimedVoteReward?: string; + withdrawnUnfreeze?: string; delegateAmount?: string; delegateTo?: string; assetIssueID?: string; totalFee?: string; + feeLimit?: string; energyUsage?: string; energyUsageTotal?: string; energyFee?: string; @@ -49,6 +49,7 @@ export interface TronStakingInfo { unclaimedReward: string; delegatedBalanceEnergy: string; delegatedBalanceBandwidth: string; + latestWithdrawTime: number; } export interface TronAccountExtraData { availableStakedBandwidth: number; @@ -63,8 +64,12 @@ export interface TronAccountExtraData { 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 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 AddressAlias { /** Type of alias, e.g., user-defined name or contract name. */ Type: string; @@ -96,6 +101,8 @@ export interface EthereumParsedInputData { function?: string; /** List of parsed parameters for this function call. */ params?: EthereumParsedInputParam[]; + /** True when input data exceeded the parse-size budget and parameters were not decoded. */ + parsedDataTruncated?: boolean; } export interface EthereumSpecific { /** High-level type of the Ethereum tx (e.g., 'call', 'create'). */ @@ -112,8 +119,10 @@ export interface EthereumSpecific { gasLimit?: number; /** Actual gas consumed by the transaction execution. */ gasUsed?: number; - /** Price (in Wei or base units) per gas unit. */ + /** Price (in Wei or base units) per gas unit bid by the sender. */ gasPrice?: string; + /** Actual gas price paid per gas unit; on L2 networks this differs from gasPrice and is used for the fee. */ + effectiveGasPrice?: string; maxPriorityFeePerGas?: string; maxFeePerGas?: string; baseFeePerGas?: string; @@ -140,8 +149,8 @@ export interface MultiTokenValue { } 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'; + type: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPT'; + standard: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPT'; /** Source address of the token transfer. */ from: string; /** Destination address of the token transfer. */ @@ -158,6 +167,8 @@ export interface TokenTransfer { value?: string; /** List of multiple ID-value pairs for ERC1155 transfers. */ multiTokenValues?: MultiTokenValue[]; + /** Syscoin SPT asset GUID. */ + assetGuid?: string; } export interface Vout { /** Amount (in satoshi or base units) of the output. */ @@ -184,6 +195,18 @@ export interface Vout { isOwn?: boolean; /** Output script type (e.g., 'P2PKH', 'P2SH'). */ type?: string; + /** Syscoin SPT metadata for this output. */ + assetInfo?: AssetInfo; +} +export interface AssetInfo { + /** Syscoin SPT asset GUID. */ + assetGuid?: string; + /** SPT amount in base units. */ + value?: string; + /** SPT amount as a decimal string. */ + valueStr?: string; + /** Syscoin SPT asset symbol, if available. */ + symbol?: string; } export interface Vin { /** ID/hash of the originating transaction (where the UTXO comes from). */ @@ -208,6 +231,8 @@ export interface Vin { asm?: string; /** Data for coinbase inputs (when mining). */ coinbase?: string; + /** Syscoin SPT metadata for this input. */ + assetInfo?: AssetInfo; } export interface Tx { /** Transaction ID (hash). */ @@ -252,7 +277,7 @@ export interface Tx { chainExtraData?: TxChainExtraData; /** List of token transfers that occurred in this transaction. */ tokenTransfers?: TokenTransfer[]; - /** SYSCOIN Transaction direction relative to the queried account, when returned by account summary endpoints. */ + /** Transaction direction relative to the queried account, when returned by account summary endpoints. */ direction?: string; /** Total input value belonging to the queried account, when returned by account summary endpoints. */ addressValueIn?: string; @@ -262,7 +287,7 @@ export interface Tx { accountAssetTransfers?: TokenTransfer[]; /** Syscoin SPT transaction type. */ tokenType?: string; - /** Syscoin SPT memo decoded from OP_RETURN. */ + /** Base64-encoded Syscoin SPT memo decoded from OP_RETURN. */ memo?: string; /** Ethereum-like blockchain specific data (if applicable). */ ethereumSpecific?: EthereumSpecific; @@ -310,7 +335,7 @@ export interface Erc4626TokenMetadata { decimals: number; } export interface Erc4626Token { - /** Metadata of the underlying asset token. */ + /** Metadata of the underlying asset token. Omitted when decimals cannot be resolved. */ asset?: Erc4626TokenMetadata; /** Metadata of the vault share token. */ share?: Erc4626TokenMetadata; @@ -327,10 +352,45 @@ export interface Erc4626Token { /** Error message for partial failures while fetching ERC4626 fields. */ error?: string; } -export interface Token { +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 Token { + /** @deprecated: Use standard instead. */ + type: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPTAllocated'; + standard: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155' | 'SPTAllocated'; /** Readable name of the token. */ name: string; /** Derivation path if this token is derived from an XPUB-based address. */ @@ -413,8 +473,12 @@ export interface Address { confirmedNonce?: string; /** Number of tokens with any historical usage at this address. */ usedTokens?: number; + /** Number of Syscoin SPT assets with any historical usage at this address. */ + usedAssetTokens?: number; /** List of tokens associated with this address. */ tokens?: Token[]; + /** List of Syscoin SPT assets associated with this address. */ + tokensAsset?: Token[]; /** Total value of the address in secondary currency (e.g. fiat). */ secondaryValue?: number; /** Sum of token values in base currency. */ @@ -436,41 +500,6 @@ export interface Address { /** 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; @@ -490,6 +519,8 @@ export interface Utxo { lockTime?: number; /** Indicates if this UTXO originated from a coinbase transaction. */ coinbase?: boolean; + /** Syscoin SPT metadata for this UTXO. */ + assetInfo?: AssetInfo; } export interface BalanceHistory { /** Unix timestamp for this point in the balance history. */ @@ -704,6 +735,12 @@ export interface WsRes { /** Payload of the response, structure depends on the request. */ data: any; } +export interface WsPrivatePending { + /** Account nonces of the wallet's in-flight private transactions for this address. Each entry is a literal in-flight nonce (0 is a valid value, not a sentinel) and is treated as an occupied nonce slot alongside Blockbook's own cached private transactions: the reported pending nonce advances from the backend's own answer across the contiguous run of occupied slots. Send the whole in-flight set, not just its maximum, and do not pad or default the array - a declared nonce above a slot nothing fills does not lift the answer over that slot. */ + nonces?: number[]; + /** Transaction hashes of the in-flight private transactions (reserved for future use). */ + txids?: string[]; +} export interface WsAccountInfoReq { /** Address or XPUB descriptor to query. */ descriptor: string; @@ -729,6 +766,8 @@ export interface WsAccountInfoReq { gap?: number; /** If true, additionally return the confirmed nonce for Ethereum-like addresses (extra backend call). */ confirmedNonce?: boolean; + /** Ethereum-like only: the sender's in-flight private (alternative send-tx / relay) transactions the wallet is tracking for this address. When it declares nonces, Blockbook answers the pending-nonce lookup from this authoritative wallet state instead of inferring it from recently accepted sends (see docs/evm-send.md). */ + privatePending?: WsPrivatePending; } export interface WsContractInfoReq { /** Contract address to query. */ @@ -833,8 +872,8 @@ export interface WsTransactionSpecificReq { 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;}; + /** Additional chain-specific parameters (e.g. for Ethereum). privatePending (Ethereum-like) declares the sender's in-flight private transactions so the gas estimate is routed to the alternative send-tx provider; see WsPrivatePending and docs/evm-send.md. */ + specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: WsPrivatePending;}; } export interface Eip1559Fee { maxFeePerGas?: string; @@ -870,11 +909,31 @@ export interface WsLongTermFeeRateRes { /** Amount of blocks used for the long term fee rate estimation. */ blocks: number; } +export interface EthereumGasData { + /** Base fee per gas of the new block (post-London). */ + baseFeePerGas?: string; + /** Total gas used by the new block (decimal). */ + blockGasUsed?: string; + /** Gas limit of the new block (decimal). */ + blockGasLimit?: string; +} +export interface WsNewBlock { + /** Height of the new block. */ + height: number; + /** Hash of the new block. */ + hash: string; + /** EVM gas data for the EIP-1559 base-fee projection; null on non-EVM chains. */ + evmData: EthereumGasData | null; +} export interface WsSendTransactionReq { /** Hex-encoded transaction data to broadcast (string format). */ hex?: string; /** Use alternative RPC method to broadcast transaction. */ disableAlternativeRpc: boolean; + /** Optional Syscoin Core maxfeerate parameter. */ + maxfeerate?: string; + /** Optional Syscoin Core maxburnamount parameter. */ + maxburnamount?: string; } export interface WsSubscribeAddressesReq { /** List of addresses to subscribe for updates (e.g., new transactions). */ diff --git a/blockbook.go b/blockbook.go index 0b56ce016f..58dab423ba 100644 --- a/blockbook.go +++ b/blockbook.go @@ -37,6 +37,11 @@ const debounceResyncMempoolMs = 1009 // store internal state about once every minute const storeInternalStatePeriodMs = 59699 +// the first internal data healing pass runs soon after the initial sync, while the backend +// cache is still warm from the fetches that failed during the previous run, then hourly +const healInternalDataStartupDelay = time.Minute +const healInternalDataPeriod = time.Hour + // exit codes from the main function const exitCodeOK = 0 const exitCodeFatal = 255 @@ -53,7 +58,8 @@ var ( rollbackHeight = flag.Int("rollback", -1, "rollback to the given height and quit") synchronize = flag.Bool("sync", false, "synchronizes until tip, if together with zeromq, keeps index synchronized") - repair = flag.Bool("repair", false, "repair the database") + repair = flag.Bool("repair", false, "repair physical RocksDB corruption and exit; does NOT recover an interrupted bulk import (use -fixutxo for UTXO index drift)") + forceRepair = flag.Bool("forcerepair", false, "with -repair, force a database left in inconsistent state (interrupted initial/bulk import) into open state; the index may be incomplete and a full resync is strongly advised") fixUtxo = flag.Bool("fixutxo", false, "check and fix utxo db and exit") prof = flag.String("prof", "", "http server binding [address]:port of the interface to profiling data /debug/pprof/ (default no profiling)") @@ -102,6 +108,7 @@ var ( chanSyncIndexDone = make(chan struct{}) chanSyncMempoolDone = make(chan struct{}) chanStoreInternalStateDone = make(chan struct{}) + chanHealInternalDataDone = make(chan struct{}) chain bchain.BlockChain mempool bchain.Mempool index *db.RocksDB @@ -168,8 +175,13 @@ func mainWithExitCode() int { }() } + if *forceRepair && !*repair { + glog.Error("-forcerepair has no effect without -repair; re-run with both flags to force an inconsistent database into open state") + return exitCodeFatal + } + if *repair { - if err := db.RepairRocksDB(*dbPath); err != nil { + if err := db.RepairRocksDB(*dbPath, *forceRepair); err != nil { glog.Errorf("RepairRocksDB %s: %v", *dbPath, err) return exitCodeFatal } @@ -189,7 +201,11 @@ func mainWithExitCode() int { } if chain, mempool, err = getBlockChainWithRetry(config.CoinName, *configFile, pushSynchronizationHandler, metrics, 120); err != nil { - glog.Error("rpc: ", err) + if bchain.IsConfigurationError(err) { + glog.Error("config: ", err) + } else { + glog.Error("rpc: ", err) + } return exitCodeFatal } @@ -358,8 +374,29 @@ func mainWithExitCode() int { if *synchronize { internalState.SyncMode = true - internalState.InitialSync = true - if err := syncWorker.ResyncIndex(nil, true); err != nil { + internalState.SetInitialSync(true) + refreshSyncMetrics() + // Keep the sync gauges live during the initial build/reindex. It can run for hours + // to days, and syncIndexLoop and storeInternalStateLoop - the usual refreshers - + // are only started after it returns, so without this ticker the single sample + // above would be the only one taken during the whole initial sync and a stall + // partway through would be invisible on initial_sync and synchronized. + stopInitialRefresh := make(chan struct{}) + go func() { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-stopInitialRefresh: + return + case <-t.C: + refreshSyncMetrics() + } + } + }() + err := syncWorker.ResyncIndex(nil, true) + close(stopInitialRefresh) + if err != nil { if err != db.ErrOperationInterrupted { glog.Error("resyncIndex ", err) return exitCodeFatal @@ -384,7 +421,9 @@ func mainWithExitCode() int { internalState.FinishedMempoolSync(mempoolCount) go syncIndexLoop() go syncMempoolLoop() - internalState.InitialSync = false + go healInternalDataLoop() + internalState.SetInitialSync(false) + refreshSyncMetrics() } go storeInternalStateLoop() @@ -427,6 +466,9 @@ func mainWithExitCode() int { close(chanSyncMempool) <-chanSyncIndexDone <-chanSyncMempoolDone + // a healing pass writes to rocksdb from its own goroutine, so it has to be joined + // here, before the deferred index.Close destroys the column family handles + <-chanHealInternalDataDone } <-chanStoreInternalStateDone return exitCodeOK @@ -439,6 +481,11 @@ func getBlockChainWithRetry(coin string, configFile string, pushHandler func(bch timer := time.NewTimer(time.Second) for i := 0; ; i++ { if chain, mempool, err = coins.NewBlockChain(coin, configFile, pushHandler, metrics); err != nil { + // a static configuration error cannot resolve on a retry, and reporting it once a second + // for two minutes under an "rpc:" prefix hides what it actually is + if bchain.IsConfigurationError(err) { + return nil, nil, err + } if i < seconds { glog.Error("rpc: ", err, " Retrying...") select { @@ -522,37 +569,67 @@ func performRollback() error { return nil } +// refreshSyncMetrics publishes the sync-state gauges (synchronized, initial_sync and both +// heights) from internal state, with no backend round trip. It is called on every +// storeInternalStateLoop tick (~60s), after every sync iteration, at the two points +// is.InitialSync flips, on a ~30s ticker during the initial build (before those loops +// start), and from the app-info loop, so none of these gauges depends on the ~15-minute +// app-info loop for its cadence. +func refreshSyncMetrics() { + if chain == nil { + return + } + api.RefreshSyncMetrics(internalState, chain, metrics) +} + 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, fiatRates) + // not named `api`: that shadowed the package for the rest of the function. + w, err := api.NewWorker(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return err } - si, err := api.GetSystemInfo(false) + si, err := w.GetSystemInfo(false) if err != nil { return err } - subversion := si.Backend.Subversion + // app_info carries purely local blockbook_* labels (version/commit/buildtime) that + // identify the instance and need no backend, so the series must be published even when + // the backend is unreachable - that is exactly the instance an operator needs to find. + // On a backend error the backend_* labels and the height fall back to the last known + // good BackendInfo this build retains, instead of blanking the whole series to zero. + backend := *si.Backend // value copy: si.Backend is a pointer, don't mutate it in place + if backend.BackendError != "" { + bi := is.GetBackendInfo() + backend.Version = bi.Version + backend.Subversion = bi.Subversion + backend.ConsensusVersion = bi.ConsensusVersion + backend.ProtocolVersion = bi.ProtocolVersion + backend.Blocks = bi.Blocks + } + subversion := backend.Subversion if subversion == "" { // for coins without subversion (ETH) use ConsensusVersion as subversion in metrics - subversion = si.Backend.ConsensusVersion + subversion = 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_version": backend.Version, "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 + "backend_protocol_version": backend.ProtocolVersion}).Set(float64(0)) + if backend.Blocks > 0 { + metrics.BackendBestHeight.Set(float64(backend.Blocks)) } - metrics.Synchronized.Set(synchronized) + metrics.BackendTipAgeSeconds.Set(time.Since(is.GetBackendTipLastAdvance()).Seconds()) + // blockbook_synchronized, blockbook_best_height and blockbook_backend_best_height are + // owned by RefreshSyncMetrics (called from the sync loop and the ~60s state loop). + // Writing blockbook_synchronized here as well made two writers disagree - this path + // derived it from a live GetChainInfo tip while RefreshSyncMetrics uses the cached one + // - so the gauge oscillated on the app-info period. Publish it from the single shared + // path instead of recomputing it here. + api.RefreshSyncMetrics(is, chain, metrics) return nil } @@ -573,7 +650,12 @@ func newInternalState(config *common.Config, d *db.RocksDB, enableSubNewTx bool) is.Host = name } - is.WsGetAccountInfoLimit, _ = strconv.Atoi(os.Getenv(strings.ToUpper(is.GetNetwork()) + "_WS_GETACCOUNTINFO_LIMIT")) + limitStr := os.Getenv(strings.ToUpper(is.GetNetwork()) + "_WS_GETACCOUNTINFO_LIMIT") + if limitStr == "" { + is.WsGetAccountInfoLimit = 42 + } else { + is.WsGetAccountInfoLimit, _ = strconv.Atoi(limitStr) + } if is.WsGetAccountInfoLimit > 0 { glog.Info("WsGetAccountInfoLimit enabled with limit ", is.WsGetAccountInfoLimit) is.WsLimitExceedingIPs = make(map[string]int) @@ -624,6 +706,12 @@ func syncIndexLoop() { glog.Info("syncIndexLoop starting") // resync index about every 15 minutes if there are no chanSyncIndex requests, with debounce 1 second common.TickAndDebounce(time.Duration(*resyncIndexPeriodMs)*time.Millisecond, time.Duration(*resyncIndexDebounceMs)*time.Millisecond, chanSyncIndex, func() { + // Publish the sync-state gauges after every iteration whatever its outcome. The + // early returns below make a defer the only placement that covers all of them, + // and the interesting case is precisely the quiet one: during a silent stall + // resyncIndex keeps returning syncNotNeeded, so nothing else would move the + // gauges until the ~60s storeInternalStateLoop tick. + defer refreshSyncMetrics() if err := syncWorker.ResyncIndex(onNewBlock, false); err != nil { if err == db.ErrOperationInterrupted || common.IsInShutdown() { return @@ -681,6 +769,39 @@ func syncMempoolLoop() { glog.Info("syncMempoolLoop stopped") } +// healInternalDataLoop drains the internal data error queue without operator action: +// blocks whose internal data failed during sync are refetched and reconnected to the +// index. It returns on shutdown; the caller joins it before the database is closed. +func healInternalDataLoop() { + defer close(chanHealInternalDataDone) + // nothing can be queued unless internal data are processed on an ethereum-type chain + if chain.GetChainParser().GetChainType() != bchain.ChainEthereumType || !bchain.ProcessInternalTransactions { + return + } + w, err := api.NewWorker(index, chain, mempool, txCache, metrics, internalState, fiatRates) + if err != nil { + glog.Error("healInternalDataLoop: NewWorker ", err) + return + } + glog.Info("healInternalDataLoop starting") + defer glog.Info("healInternalDataLoop stopped") + timer := time.NewTimer(healInternalDataStartupDelay) + defer timer.Stop() + for pass := uint64(0); ; pass++ { + select { + // closed on shutdown, so this is a broadcast to every waiting loop + case <-chanOsSignal: + return + case <-timer.C: + } + timer.Reset(healInternalDataPeriod) + if common.IsInShutdown() { + return + } + w.HealInternalData(pass, chanOsSignal) + } +} + func storeInternalStateLoop() { defer func() { close(chanStoreInternalStateDone) @@ -699,6 +820,12 @@ func storeInternalStateLoop() { glog.Info("storeInternalStateLoop starting with db stats compute disabled") } common.TickAndDebounce(storeInternalStatePeriodMs*time.Millisecond, (storeInternalStatePeriodMs-1)*time.Millisecond, chanStoreInternalState, func() { + // Republish the sync-state gauges every tick (~60s) - the guaranteed freshness + // floor. syncIndexLoop also refreshes them, but a blockbook whose tip feed went + // quiet only wakes on the resyncindexperiod backstop (~15.6 min), which is how far + // blockbook_synchronized used to lag reality. RefreshSyncMetrics reads internal + // state only, no backend RPCs, so it is cheap to run unconditionally. + refreshSyncMetrics() if (*dbStatsPeriodHours) > 0 && !computeRunning && lastCompute.Add(computePeriod).Before(time.Now()) { computeRunning = true go func() { diff --git a/build/bb-build-var-prefixes.txt b/build/bb-build-var-prefixes.txt index c3c55090b6..451c199751 100644 --- a/build/bb-build-var-prefixes.txt +++ b/build/bb-build-var-prefixes.txt @@ -16,5 +16,6 @@ BB_PROD_RPC_URL_WS_ BB_PROD_MQ_URL_ BB_RPC_BIND_HOST_ BB_RPC_ALLOW_IP_ +BB_ADDNODE_ BB_DEV_API_URL_HTTP_ BB_DEV_API_URL_WS_ diff --git a/build/docker/bin/Dockerfile b/build/docker/bin/Dockerfile index 11c27e4e00..41aa2c4434 100644 --- a/build/docker/bin/Dockerfile +++ b/build/docker/bin/Dockerfile @@ -19,6 +19,11 @@ ENV CGO_CFLAGS="-I/opt/rocksdb/include" ENV CGO_LDFLAGS="-L/opt/rocksdb -ldl -lrocksdb -lstdc++ -lm -lz -lbz2 -lsnappy -llz4 -lzstd" ARG TCMALLOC +# The Blockbook binary links jemalloc statically (JEMALLOC=0 keeps glibc malloc); see docs/build.md. +ARG JEMALLOC=1 +ENV JEMALLOC=${JEMALLOC} +RUN if [ "${JEMALLOC}" != "0" ]; then apt-get install -y libjemalloc-dev; fi + RUN mkdir /build RUN if [ -n "${TCMALLOC}" ]; then \ @@ -44,7 +49,9 @@ RUN echo -n "GOPATH: " && echo $GOPATH # install rocksdb 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 +# RocksDB must not detect jemalloc: it would link ldb and sst_dump against libjemalloc.so and the package +# would stop installing with a bare dpkg -i. The Blockbook binary links the static archive instead. +RUN cd /opt/rocksdb && ROCKSDB_DISABLE_JEMALLOC=1 CFLAGS=-fPIC CXXFLAGS=-fPIC PORTABLE=$PORTABLE_ROCKSDB DISABLE_WARNING_AS_ERROR=1 make -j 4 release RUN strip /opt/rocksdb/ldb /opt/rocksdb/sst_dump && \ cp /opt/rocksdb/ldb /opt/rocksdb/sst_dump /build diff --git a/build/docker/bin/Makefile b/build/docker/bin/Makefile index 4949d1ea00..331cc77173 100644 --- a/build/docker/bin/Makefile +++ b/build/docker/bin/Makefile @@ -7,6 +7,16 @@ BLOCKBOOK_BASE := $(GOPATH)/src/github.com/trezor BLOCKBOOK_SRC := $(BLOCKBOOK_BASE)/blockbook ARGS ?= +# JEMALLOC is set by the image build arg. The static archive goes in by path because cgo's linker-flag +# allowlist rejects -l:libjemalloc_pic.a; gcc resolves the multiarch directory. +ifneq ($(JEMALLOC),0) +JEMALLOC_LIB := $(shell gcc -print-file-name=libjemalloc_pic.a) +ifeq ($(JEMALLOC_LIB),libjemalloc_pic.a) +$(error libjemalloc_pic.a not found; install libjemalloc-dev or build the image with JEMALLOC=0) +endif +export CGO_LDFLAGS := $(CGO_LDFLAGS) $(JEMALLOC_LIB) +endif + all: build tools build: prepare-sources @@ -39,6 +49,12 @@ test-connectivity: prepare-sources test-all: prepare-sources cd $(BLOCKBOOK_SRC) && go test -tags 'unittest integration' `go list ./... | grep -v '^github.com/trezor/blockbook/contrib'` -timeout 30m $(ARGS) +# Regenerates blockbook-api.ts from the Go structs and hands it back to the mounted checkout. +typescriptify: prepare-sources + cd $(BLOCKBOOK_SRC) && go run ./build/tools/typescriptify + cp $(BLOCKBOOK_SRC)/blockbook-api.ts /src/blockbook-api.ts + chown $(PACKAGER) /src/blockbook-api.ts + prepare-sources: @ [ -n "`ls /src 2> /dev/null`" ] || (echo "/src doesn't exist or is empty" 1>&2 && exit 1) rm -rf $(BLOCKBOOK_SRC) diff --git a/build/templates/backend/config/bitcoin.conf b/build/templates/backend/config/bitcoin.conf index 068284d9f1..1b255c382e 100644 --- a/build/templates/backend/config/bitcoin.conf +++ b/build/templates/backend/config/bitcoin.conf @@ -12,12 +12,9 @@ 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}} diff --git a/build/templates/backend/config/bitcoin_regtest.conf b/build/templates/backend/config/bitcoin_regtest.conf index 15eb979b82..84da3122ff 100644 --- a/build/templates/backend/config/bitcoin_regtest.conf +++ b/build/templates/backend/config/bitcoin_regtest.conf @@ -12,8 +12,6 @@ rpcworkqueue=1100 maxmempool=2000 dbcache=1000 -deprecatedrpc=warnings - {{- if .Backend.AdditionalParams}} # generated from additional_params {{- range $name, $value := .Backend.AdditionalParams}} diff --git a/build/templates/backend/config/bitcoin_signet.conf b/build/templates/backend/config/bitcoin_signet.conf index 11b639664a..b1eaf379d4 100644 --- a/build/templates/backend/config/bitcoin_signet.conf +++ b/build/templates/backend/config/bitcoin_signet.conf @@ -13,8 +13,6 @@ rpcworkqueue=1100 maxmempool=2000 dbcache=1000 -deprecatedrpc=warnings - {{- if .Backend.AdditionalParams}} # generated from additional_params {{- range $name, $value := .Backend.AdditionalParams}} diff --git a/build/templates/backend/config/bitcoin_testnet4.conf b/build/templates/backend/config/bitcoin_testnet4.conf index 10eae98953..46b8bc6af0 100644 --- a/build/templates/backend/config/bitcoin_testnet4.conf +++ b/build/templates/backend/config/bitcoin_testnet4.conf @@ -12,12 +12,9 @@ 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}} diff --git a/build/templates/backend/config/robinhood_genesis.json b/build/templates/backend/config/robinhood_genesis.json new file mode 100644 index 0000000000..a3bf0fe3f7 --- /dev/null +++ b/build/templates/backend/config/robinhood_genesis.json @@ -0,0 +1,269 @@ +{{define "main" -}} +{ + "alloc": { + "0x000000000022D473030F116dDEE9F6B43aC78BA3": { + "balance": 0, + "code": "0x6040608081526004908136101561001557600080fd5b600090813560e01c80630d58b1db1461126c578063137c29fe146110755780632a2d80d114610db75780632b67b57014610bde57806330f28b7a14610ade5780633644e51514610a9d57806336c7851614610a285780633ff9dcb1146109a85780634fe02b441461093f57806365d9723c146107ac57806387517c451461067a578063927da105146105c3578063cc53287f146104a3578063edd9444b1461033a5763fe8ec1a7146100c657600080fd5b346103365760c07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103365767ffffffffffffffff833581811161033257610114903690860161164b565b60243582811161032e5761012b903690870161161a565b6101336114e6565b9160843585811161032a5761014b9036908a016115c1565b98909560a43590811161032657610164913691016115c1565b969095815190610173826113ff565b606b82527f5065726d697442617463685769746e6573735472616e7366657246726f6d285460208301527f6f6b656e5065726d697373696f6e735b5d207065726d69747465642c61646472838301527f657373207370656e6465722c75696e74323536206e6f6e63652c75696e74323560608301527f3620646561646c696e652c000000000000000000000000000000000000000000608083015282519a8b9181610222602085018096611f93565b918237018a8152039961025b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe09b8c8101835282611437565b5190209085515161026b81611ebb565b908a5b8181106102f95750506102f6999a6102ed9183516102a081610294602082018095611f66565b03848101835282611437565b519020602089810151858b015195519182019687526040820192909252336060820152608081019190915260a081019390935260643560c08401528260e081015b03908101835282611437565b51902093611cf7565b80f35b8061031161030b610321938c5161175e565b51612054565b61031b828661175e565b52611f0a565b61026e565b8880fd5b8780fd5b8480fd5b8380fd5b5080fd5b5091346103365760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103365767ffffffffffffffff9080358281116103325761038b903690830161164b565b60243583811161032e576103a2903690840161161a565b9390926103ad6114e6565b9160643590811161049f576103c4913691016115c1565b949093835151976103d489611ebb565b98885b81811061047d5750506102f697988151610425816103f9602082018095611f66565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101835282611437565b5190206020860151828701519083519260208401947ffcf35f5ac6a2c28868dc44c302166470266239195f02b0ee408334829333b7668652840152336060840152608083015260a082015260a081526102ed8161141b565b808b61031b8261049461030b61049a968d5161175e565b9261175e565b6103d7565b8680fd5b5082346105bf57602090817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103325780359067ffffffffffffffff821161032e576104f49136910161161a565b929091845b848110610504578580f35b8061051a610515600193888861196c565b61197c565b61052f84610529848a8a61196c565b0161197c565b3389528385528589209173ffffffffffffffffffffffffffffffffffffffff80911692838b528652868a20911690818a5285528589207fffffffffffffffffffffffff000000000000000000000000000000000000000081541690558551918252848201527f89b1add15eff56b3dfe299ad94e01f2b52fbcb80ae1a3baea6ae8c04cb2b98a4853392a2016104f9565b8280fd5b50346103365760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657610676816105ff6114a0565b936106086114c3565b6106106114e6565b73ffffffffffffffffffffffffffffffffffffffff968716835260016020908152848420928816845291825283832090871683528152919020549251938316845260a083901c65ffffffffffff169084015260d09190911c604083015281906060820190565b0390f35b50346103365760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610336576106b26114a0565b906106bb6114c3565b916106c46114e6565b65ffffffffffff926064358481169081810361032a5779ffffffffffff0000000000000000000000000000000000000000947fda9fa7c1b00402c17d0161b249b1ab8bbec047c5a52207b9c112deffd817036b94338a5260016020527fffffffffffff0000000000000000000000000000000000000000000000000000858b209873ffffffffffffffffffffffffffffffffffffffff809416998a8d5260205283878d209b169a8b8d52602052868c209486156000146107a457504216925b8454921697889360a01b16911617179055815193845260208401523392a480f35b905092610783565b5082346105bf5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf576107e56114a0565b906107ee6114c3565b9265ffffffffffff604435818116939084810361032a57338852602091600183528489209673ffffffffffffffffffffffffffffffffffffffff80911697888b528452858a20981697888a5283528489205460d01c93848711156109175761ffff9085840316116108f05750907f55eb90d810e1700b35a8e7e25395ff7f2b2259abd7415ca2284dfb1c246418f393929133895260018252838920878a528252838920888a5282528389209079ffffffffffffffffffffffffffffffffffffffffffffffffffff7fffffffffffff000000000000000000000000000000000000000000000000000083549260d01b16911617905582519485528401523392a480f35b84517f24d35a26000000000000000000000000000000000000000000000000000000008152fd5b5084517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b503461033657807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610336578060209273ffffffffffffffffffffffffffffffffffffffff61098f6114a0565b1681528084528181206024358252845220549051908152f35b5082346105bf57817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf577f3704902f963766a4e561bbaab6e6cdc1b1dd12f6e9e99648da8843b3f46b918d90359160243533855284602052818520848652602052818520818154179055815193845260208401523392a280f35b8234610a9a5760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610a9a57610a606114a0565b610a686114c3565b610a706114e6565b6064359173ffffffffffffffffffffffffffffffffffffffff8316830361032e576102f6936117a1565b80fd5b503461033657817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657602090610ad7611b1e565b9051908152f35b508290346105bf576101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf57610b1a3661152a565b90807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c36011261033257610b4c611478565b9160e43567ffffffffffffffff8111610bda576102f694610b6f913691016115c1565b939092610b7c8351612054565b6020840151828501519083519260208401947f939c21a48a8dbe3a9a2404a1d46691e4d39f6583d6ec6b35714604c986d801068652840152336060840152608083015260a082015260a08152610bd18161141b565b51902091611c25565b8580fd5b509134610336576101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657610c186114a0565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc360160c08112610332576080855191610c51836113e3565b1261033257845190610c6282611398565b73ffffffffffffffffffffffffffffffffffffffff91602435838116810361049f578152604435838116810361049f57602082015265ffffffffffff606435818116810361032a5788830152608435908116810361049f576060820152815260a435938285168503610bda576020820194855260c4359087830182815260e43567ffffffffffffffff811161032657610cfe90369084016115c1565b929093804211610d88575050918591610d786102f6999a610d7e95610d238851611fbe565b90898c511690519083519260208401947ff3841cd1ff0085026a6327b620b67997ce40f282c88a8e905a7a5626e310f3d086528401526060830152608082015260808152610d70816113ff565b519020611bd9565b916120c7565b519251169161199d565b602492508a51917fcd21db4f000000000000000000000000000000000000000000000000000000008352820152fd5b5091346103365760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc93818536011261033257610df36114a0565b9260249081359267ffffffffffffffff9788851161032a578590853603011261049f578051978589018981108282111761104a578252848301358181116103265785019036602383011215610326578382013591610e50836115ef565b90610e5d85519283611437565b838252602093878584019160071b83010191368311611046578801905b828210610fe9575050508a526044610e93868801611509565b96838c01978852013594838b0191868352604435908111610fe557610ebb90369087016115c1565b959096804211610fba575050508998995151610ed681611ebb565b908b5b818110610f9757505092889492610d7892610f6497958351610f02816103f98682018095611f66565b5190209073ffffffffffffffffffffffffffffffffffffffff9a8b8b51169151928551948501957faf1b0d30d2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f794408638752850152830152608082015260808152610d70816113ff565b51169082515192845b848110610f78578580f35b80610f918585610f8b600195875161175e565b5161199d565b01610f6d565b80610311610fac8e9f9e93610fb2945161175e565b51611fbe565b9b9a9b610ed9565b8551917fcd21db4f000000000000000000000000000000000000000000000000000000008352820152fd5b8a80fd5b6080823603126110465785608091885161100281611398565b61100b85611509565b8152611018838601611509565b838201526110278a8601611607565b8a8201528d611037818701611607565b90820152815201910190610e7a565b8c80fd5b84896041867f4e487b7100000000000000000000000000000000000000000000000000000000835252fd5b5082346105bf576101407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf576110b03661152a565b91807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c360112610332576110e2611478565b67ffffffffffffffff93906101043585811161049f5761110590369086016115c1565b90936101243596871161032a57611125610bd1966102f6983691016115c1565b969095825190611134826113ff565b606482527f5065726d69745769746e6573735472616e7366657246726f6d28546f6b656e5060208301527f65726d697373696f6e73207065726d69747465642c6164647265737320737065848301527f6e6465722c75696e74323536206e6f6e63652c75696e7432353620646561646c60608301527f696e652c0000000000000000000000000000000000000000000000000000000060808301528351948591816111e3602085018096611f93565b918237018b8152039361121c7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe095868101835282611437565b5190209261122a8651612054565b6020878101518589015195519182019687526040820192909252336060820152608081019190915260a081019390935260e43560c08401528260e081016102e1565b5082346105bf576020807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033257813567ffffffffffffffff92838211610bda5736602383011215610bda5781013592831161032e576024906007368386831b8401011161049f57865b8581106112e5578780f35b80821b83019060807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc83360301126103265761139288876001946060835161132c81611398565b611368608461133c8d8601611509565b9485845261134c60448201611509565b809785015261135d60648201611509565b809885015201611509565b918291015273ffffffffffffffffffffffffffffffffffffffff80808093169516931691166117a1565b016112da565b6080810190811067ffffffffffffffff8211176113b457604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6060810190811067ffffffffffffffff8211176113b457604052565b60a0810190811067ffffffffffffffff8211176113b457604052565b60c0810190811067ffffffffffffffff8211176113b457604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211176113b457604052565b60c4359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b600080fd5b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b6044359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc01906080821261149b576040805190611563826113e3565b8082941261149b57805181810181811067ffffffffffffffff8211176113b457825260043573ffffffffffffffffffffffffffffffffffffffff8116810361149b578152602435602082015282526044356020830152606435910152565b9181601f8401121561149b5782359167ffffffffffffffff831161149b576020838186019501011161149b57565b67ffffffffffffffff81116113b45760051b60200190565b359065ffffffffffff8216820361149b57565b9181601f8401121561149b5782359167ffffffffffffffff831161149b576020808501948460061b01011161149b57565b91909160608184031261149b576040805191611666836113e3565b8294813567ffffffffffffffff9081811161149b57830182601f8201121561149b578035611693816115ef565b926116a087519485611437565b818452602094858086019360061b8501019381851161149b579086899897969594939201925b8484106116e3575050505050855280820135908501520135910152565b90919293949596978483031261149b578851908982019082821085831117611730578a928992845261171487611509565b81528287013583820152815201930191908897969594936116c6565b602460007f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b80518210156117725760209160051b010190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b92919273ffffffffffffffffffffffffffffffffffffffff604060008284168152600160205282828220961695868252602052818120338252602052209485549565ffffffffffff8760a01c16804211611884575082871696838803611812575b5050611810955016926118b5565b565b878484161160001461184f57602488604051907ff96fb0710000000000000000000000000000000000000000000000000000000082526004820152fd5b7fffffffffffffffffffffffff000000000000000000000000000000000000000084846118109a031691161790553880611802565b602490604051907fd81b2f2e0000000000000000000000000000000000000000000000000000000082526004820152fd5b9060006064926020958295604051947f23b872dd0000000000000000000000000000000000000000000000000000000086526004860152602485015260448401525af13d15601f3d116001600051141617161561190e57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f5452414e534645525f46524f4d5f4641494c45440000000000000000000000006044820152fd5b91908110156117725760061b0190565b3573ffffffffffffffffffffffffffffffffffffffff8116810361149b5790565b9065ffffffffffff908160608401511673ffffffffffffffffffffffffffffffffffffffff908185511694826020820151169280866040809401511695169560009187835260016020528383208984526020528383209916988983526020528282209184835460d01c03611af5579185611ace94927fc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec98979694508715600014611ad35779ffffffffffff00000000000000000000000000000000000000009042165b60a01b167fffffffffffff00000000000000000000000000000000000000000000000000006001860160d01b1617179055519384938491604091949373ffffffffffffffffffffffffffffffffffffffff606085019616845265ffffffffffff809216602085015216910152565b0390a4565b5079ffffffffffff000000000000000000000000000000000000000087611a60565b600484517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b467f000000000000000000000000000000000000000000000000000000000000123703611b69577f448684463b1f7965c1ec7c249cee11520df24c07242efc2b20f6e54c85614fad90565b60405160208101907f8cad95687ba82c2ce50e74f7b754645e5117c3a5bec8151c0726d5857980a86682527f9ac997416e8ff9d2ff6bebeb7149f65cdae5e32e2b90440b566bb3044041d36a604082015246606082015230608082015260808152611bd3816113ff565b51902090565b611be1611b1e565b906040519060208201927f190100000000000000000000000000000000000000000000000000000000000084526022830152604282015260428152611bd381611398565b9192909360a435936040840151804211611cc65750602084510151808611611c955750918591610d78611c6594611c60602088015186611e47565b611bd9565b73ffffffffffffffffffffffffffffffffffffffff809151511692608435918216820361149b57611810936118b5565b602490604051907f3728b83d0000000000000000000000000000000000000000000000000000000082526004820152fd5b602490604051907fcd21db4f0000000000000000000000000000000000000000000000000000000082526004820152fd5b959093958051519560409283830151804211611e175750848803611dee57611d2e918691610d7860209b611c608d88015186611e47565b60005b868110611d42575050505050505050565b611d4d81835161175e565b5188611d5a83878a61196c565b01359089810151808311611dbe575091818888886001968596611d84575b50505050505001611d31565b611db395611dad9273ffffffffffffffffffffffffffffffffffffffff6105159351169561196c565b916118b5565b803888888883611d78565b6024908651907f3728b83d0000000000000000000000000000000000000000000000000000000082526004820152fd5b600484517fff633a38000000000000000000000000000000000000000000000000000000008152fd5b6024908551907fcd21db4f0000000000000000000000000000000000000000000000000000000082526004820152fd5b9073ffffffffffffffffffffffffffffffffffffffff600160ff83161b9216600052600060205260406000209060081c6000526020526040600020818154188091551615611e9157565b60046040517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b90611ec5826115ef565b611ed26040519182611437565b8281527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0611f0082946115ef565b0190602036910137565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114611f375760010190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b805160208092019160005b828110611f7f575050505090565b835185529381019392810192600101611f71565b9081519160005b838110611fab575050016000815290565b8060208092840101518185015201611f9a565b60405160208101917f65626cad6cb96493bf6f5ebea28756c966f023ab9e8a83a7101849d5573b3678835273ffffffffffffffffffffffffffffffffffffffff8082511660408401526020820151166060830152606065ffffffffffff9182604082015116608085015201511660a082015260a0815260c0810181811067ffffffffffffffff8211176113b45760405251902090565b6040516020808201927f618358ac3db8dc274f0cd8829da7e234bd48cd73c4a740aede1adec9846d06a1845273ffffffffffffffffffffffffffffffffffffffff81511660408401520151606082015260608152611bd381611398565b919082604091031261149b576020823592013590565b6000843b61222e5750604182036121ac576120e4828201826120b1565b939092604010156117725760209360009360ff6040608095013560f81c5b60405194855216868401526040830152606082015282805260015afa156121a05773ffffffffffffffffffffffffffffffffffffffff806000511691821561217657160361214c57565b60046040517f815e1d64000000000000000000000000000000000000000000000000000000008152fd5b60046040517f8baa579f000000000000000000000000000000000000000000000000000000008152fd5b6040513d6000823e3d90fd5b60408203612204576121c0918101906120b1565b91601b7f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff84169360ff1c019060ff8211611f375760209360009360ff608094612102565b60046040517f4be6321b000000000000000000000000000000000000000000000000000000008152fd5b929391601f928173ffffffffffffffffffffffffffffffffffffffff60646020957fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0604051988997889687947f1626ba7e000000000000000000000000000000000000000000000000000000009e8f8752600487015260406024870152816044870152868601378b85828601015201168101030192165afa9081156123a857829161232a575b507fffffffff000000000000000000000000000000000000000000000000000000009150160361230057565b60046040517fb0669cbc000000000000000000000000000000000000000000000000000000008152fd5b90506020813d82116123a0575b8161234460209383611437565b810103126103365751907fffffffff0000000000000000000000000000000000000000000000000000000082168203610a9a57507fffffffff0000000000000000000000000000000000000000000000000000000090386122d4565b3d9150612337565b6040513d84823e3d90fdfea164736f6c6343000811000a", + "nonce": 1, + "storage": {} + }, + "0x0000000071727De22E5E9d8BAf0edAc6f37da032": { + "balance": 0, + "code": "0x60806040526004361015610024575b361561001957600080fd5b61002233612748565b005b60003560e01c806242dc5314611b0057806301ffc9a7146119ae5780630396cb60146116765780630bd28e3b146115fa5780631b2e01b814611566578063205c2878146113d157806322cdde4c1461136b57806335567e1a146112b35780635287ce12146111a557806370a0823114611140578063765e827f14610e82578063850aaf6214610dc35780639b249f6914610c74578063b760faf914610c3a578063bb9fe6bf14610a68578063c23a5cea146107c4578063dbed18e0146101a15763fc7e286d0361000e573461019c5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5773ffffffffffffffffffffffffffffffffffffffff61013a61229f565b16600052600060205260a0604060002065ffffffffffff6001825492015460405192835260ff8116151560208401526dffffffffffffffffffffffffffff8160081c16604084015263ffffffff8160781c16606084015260981c166080820152f35b600080fd5b3461019c576101af36612317565b906101b86129bd565b60009160005b82811061056f57506101d08493612588565b6000805b8481106102fc5750507fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f972600080a16000809360005b81811061024757610240868660007f575ff3acadd5ab348fe1855e217e0f3678f8d767d7494c9f9fefbee2e17cca4d8180a2613ba7565b6001600255005b6102a261025582848a612796565b73ffffffffffffffffffffffffffffffffffffffff6102766020830161282a565b167f575ff3acadd5ab348fe1855e217e0f3678f8d767d7494c9f9fefbee2e17cca4d600080a2806127d6565b906000915b8083106102b957505050600101610209565b909194976102f36102ed6001926102e78c8b6102e0826102da8e8b8d61269d565b9261265a565b5191613597565b90612409565b99612416565b950191906102a7565b6020610309828789612796565b61031f61031682806127d6565b9390920161282a565b9160009273ffffffffffffffffffffffffffffffffffffffff8091165b8285106103505750505050506001016101d4565b909192939561037f83610378610366848c61265a565b516103728b898b61269d565b856129f6565b9290613dd7565b9116840361050a576104a5576103958491613dd7565b9116610440576103b5576103aa600191612416565b96019392919061033c565b60a487604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602160448201527f41413332207061796d61737465722065787069726564206f72206e6f7420647560648201527f65000000000000000000000000000000000000000000000000000000000000006084820152fd5b608488604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601460448201527f41413334207369676e6174757265206572726f720000000000000000000000006064820152fd5b608488604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601760448201527f414132322065787069726564206f72206e6f74206475650000000000000000006064820152fd5b608489604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601460448201527f41413234207369676e6174757265206572726f720000000000000000000000006064820152fd5b61057a818487612796565b9361058585806127d6565b919095602073ffffffffffffffffffffffffffffffffffffffff6105aa82840161282a565b1697600192838a1461076657896105da575b5050505060019293949550906105d191612409565b939291016101be565b8060406105e892019061284b565b918a3b1561019c57929391906040519485937f2dd8113300000000000000000000000000000000000000000000000000000000855288604486016040600488015252606490818601918a60051b8701019680936000915b8c83106106e657505050505050838392610684927ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8560009803016024860152612709565b03818a5afa90816106d7575b506106c657602486604051907f86a9f7500000000000000000000000000000000000000000000000000000000082526004820152fd5b93945084936105d1600189806105bc565b6106e0906121bd565b88610690565b91939596977fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9c908a9294969a0301865288357ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee18336030181121561019c57836107538793858394016128ec565b9a0196019301909189979695949261063f565b606483604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601760248201527f4141393620696e76616c69642061676772656761746f720000000000000000006044820152fd5b3461019c576020807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c576107fc61229f565b33600052600082526001604060002001908154916dffffffffffffffffffffffffffff8360081c16928315610a0a5765ffffffffffff8160981c1680156109ac57421061094e5760009373ffffffffffffffffffffffffffffffffffffffff859485947fffffffffffffff000000000000000000000000000000000000000000000000ff86951690556040517fb7c918e0e249f999e965cafeb6c664271b3f4317d296461500e71da39f0cbda33391806108da8786836020909392919373ffffffffffffffffffffffffffffffffffffffff60408201951681520152565b0390a2165af16108e8612450565b50156108f057005b606490604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601860248201527f6661696c656420746f207769746864726177207374616b6500000000000000006044820152fd5b606485604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601b60248201527f5374616b65207769746864726177616c206973206e6f742064756500000000006044820152fd5b606486604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601d60248201527f6d7573742063616c6c20756e6c6f636b5374616b6528292066697273740000006044820152fd5b606485604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601460248201527f4e6f207374616b6520746f2077697468647261770000000000000000000000006044820152fd5b3461019c5760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c573360005260006020526001604060002001805463ffffffff8160781c16908115610bdc5760ff1615610b7e5765ffffffffffff908142160191818311610b4f5780547fffffffffffffff000000000000ffffffffffffffffffffffffffffffffffff001678ffffffffffff00000000000000000000000000000000000000609885901b161790556040519116815233907ffa9b3c14cc825c412c9ed81b3ba365a5b459439403f18829e572ed53a4180f0a90602090a2005b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f616c726561647920756e7374616b696e670000000000000000000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600a60248201527f6e6f74207374616b6564000000000000000000000000000000000000000000006044820152fd5b60207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c57610022610c6f61229f565b612748565b3461019c5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5760043567ffffffffffffffff811161019c576020610cc8610d1b9236906004016122c2565b919073ffffffffffffffffffffffffffffffffffffffff9260405194859283927f570e1a360000000000000000000000000000000000000000000000000000000084528560048501526024840191612709565b03816000857f000000000000000000000000efc2c1444ebcc4db75e7613d20c6a62ff67a167c165af1908115610db757602492600092610d86575b50604051917f6ca7b806000000000000000000000000000000000000000000000000000000008352166004820152fd5b610da991925060203d602011610db0575b610da181836121ed565b8101906126dd565b9083610d56565b503d610d97565b6040513d6000823e3d90fd5b3461019c5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c57610dfa61229f565b60243567ffffffffffffffff811161019c57600091610e1e839236906004016122c2565b90816040519283928337810184815203915af4610e39612450565b90610e7e6040519283927f99410554000000000000000000000000000000000000000000000000000000008452151560048401526040602484015260448301906123c6565b0390fd5b3461019c57610e9036612317565b610e9b9291926129bd565b610ea483612588565b60005b848110610f1c57506000927fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f972600080a16000915b858310610eec576102408585613ba7565b909193600190610f12610f0087898761269d565b610f0a888661265a565b519088613597565b0194019190610edb565b610f47610f40610f2e8385979561265a565b51610f3a84898761269d565b846129f6565b9190613dd7565b73ffffffffffffffffffffffffffffffffffffffff929183166110db5761107657610f7190613dd7565b911661101157610f8657600101929092610ea7565b60a490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602160448201527f41413332207061796d61737465722065787069726564206f72206e6f7420647560648201527f65000000000000000000000000000000000000000000000000000000000000006084820152fd5b608482604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601460448201527f41413334207369676e6174757265206572726f720000000000000000000000006064820152fd5b608483604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601760448201527f414132322065787069726564206f72206e6f74206475650000000000000000006064820152fd5b608484604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601460448201527f41413234207369676e6174757265206572726f720000000000000000000000006064820152fd5b3461019c5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5773ffffffffffffffffffffffffffffffffffffffff61118c61229f565b1660005260006020526020604060002054604051908152f35b3461019c5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5773ffffffffffffffffffffffffffffffffffffffff6111f161229f565b6000608060405161120181612155565b828152826020820152826040820152826060820152015216600052600060205260a06040600020608060405161123681612155565b6001835493848352015490602081019060ff8316151582526dffffffffffffffffffffffffffff60408201818560081c16815263ffffffff936060840193858760781c16855265ffffffffffff978891019660981c1686526040519788525115156020880152511660408601525116606084015251166080820152f35b3461019c5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5760206112ec61229f565b73ffffffffffffffffffffffffffffffffffffffff6113096122f0565b911660005260018252604060002077ffffffffffffffffffffffffffffffffffffffffffffffff821660005282526040600020547fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000006040519260401b16178152f35b3461019c577ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc60208136011261019c576004359067ffffffffffffffff821161019c5761012090823603011261019c576113c9602091600401612480565b604051908152f35b3461019c5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5761140861229f565b60243590336000526000602052604060002090815491828411611508576000808573ffffffffffffffffffffffffffffffffffffffff8295839561144c848a612443565b90556040805173ffffffffffffffffffffffffffffffffffffffff831681526020810185905233917fd1c19fbcd4551a5edfb66d43d2e337c04837afda3482b42bdf569a8fccdae5fb91a2165af16114a2612450565b50156114aa57005b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f6661696c656420746f20776974686472617700000000000000000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601960248201527f576974686472617720616d6f756e7420746f6f206c61726765000000000000006044820152fd5b3461019c5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5761159d61229f565b73ffffffffffffffffffffffffffffffffffffffff6115ba6122f0565b9116600052600160205277ffffffffffffffffffffffffffffffffffffffffffffffff604060002091166000526020526020604060002054604051908152f35b3461019c5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5760043577ffffffffffffffffffffffffffffffffffffffffffffffff811680910361019c5733600052600160205260406000209060005260205260406000206116728154612416565b9055005b6020807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5760043563ffffffff9182821680920361019c5733600052600081526040600020928215611950576001840154908160781c1683106118f2576116f86dffffffffffffffffffffffffffff9182349160081c16612409565b93841561189457818511611836579065ffffffffffff61180592546040519061172082612155565b8152848101926001845260408201908816815260608201878152600160808401936000855233600052600089526040600020905181550194511515917fffffffffffffffffffffffffff0000000000000000000000000000000000000060ff72ffffffff0000000000000000000000000000006effffffffffffffffffffffffffff008954945160081b16945160781b1694169116171717835551167fffffffffffffff000000000000ffffffffffffffffffffffffffffffffffffff78ffffffffffff0000000000000000000000000000000000000083549260981b169116179055565b6040519283528201527fa5ae833d0bb1dcd632d98a8b70973e8516812898e19bf27b70071ebc8dc52c0160403392a2005b606483604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152600e60248201527f7374616b65206f766572666c6f770000000000000000000000000000000000006044820152fd5b606483604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601260248201527f6e6f207374616b652073706563696669656400000000000000000000000000006044820152fd5b606482604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601c60248201527f63616e6e6f7420646563726561736520756e7374616b652074696d65000000006044820152fd5b606482604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601a60248201527f6d757374207370656369667920756e7374616b652064656c61790000000000006044820152fd5b3461019c5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c576004357fffffffff00000000000000000000000000000000000000000000000000000000811680910361019c57807f60fc6b6e0000000000000000000000000000000000000000000000000000000060209214908115611ad6575b8115611aac575b8115611a82575b8115611a58575b506040519015158152f35b7f01ffc9a70000000000000000000000000000000000000000000000000000000091501482611a4d565b7f3e84f0210000000000000000000000000000000000000000000000000000000081149150611a46565b7fcf28ef970000000000000000000000000000000000000000000000000000000081149150611a3f565b7f915074d80000000000000000000000000000000000000000000000000000000081149150611a38565b3461019c576102007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261019c5767ffffffffffffffff60043581811161019c573660238201121561019c57611b62903690602481600401359101612268565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc36016101c0811261019c5761014060405191611b9e83612155565b1261019c5760405192611bb0846121a0565b60243573ffffffffffffffffffffffffffffffffffffffff8116810361019c578452602093604435858201526064356040820152608435606082015260a435608082015260c43560a082015260e43560c08201526101043573ffffffffffffffffffffffffffffffffffffffff8116810361019c5760e08201526101243561010082015261014435610120820152825261016435848301526101843560408301526101a43560608301526101c43560808301526101e43590811161019c57611c7c9036906004016122c2565b905a3033036120f7578351606081015195603f5a0260061c61271060a0840151890101116120ce5760009681519182611ff0575b5050505090611cca915a9003608085015101923691612268565b925a90600094845193611cdc85613ccc565b9173ffffffffffffffffffffffffffffffffffffffff60e0870151168015600014611ea957505073ffffffffffffffffffffffffffffffffffffffff855116935b5a9003019360a06060820151910151016080860151850390818111611e95575b50508302604085015192818410600014611dce5750506003811015611da157600203611d79576113c99293508093611d7481613d65565b613cf6565b5050507fdeadaa51000000000000000000000000000000000000000000000000000000008152fd5b6024857f4e487b710000000000000000000000000000000000000000000000000000000081526021600452fd5b81611dde92979396940390613c98565b506003841015611e6857507f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f60808683015192519473ffffffffffffffffffffffffffffffffffffffff865116948873ffffffffffffffffffffffffffffffffffffffff60e0890151169701519160405192835215898301528760408301526060820152a46113c9565b807f4e487b7100000000000000000000000000000000000000000000000000000000602492526021600452fd5b6064919003600a0204909301928780611d3d565b8095918051611eba575b5050611d1d565b6003861015611fc1576002860315611eb35760a088015190823b1561019c57600091611f2491836040519586809581947f7c627b210000000000000000000000000000000000000000000000000000000083528d60048401526080602484015260848301906123c6565b8b8b0260448301528b60648301520393f19081611fad575b50611fa65787893d610800808211611f9e575b506040519282828501016040528184528284013e610e7e6040519283927fad7954bc000000000000000000000000000000000000000000000000000000008452600484015260248301906123c6565b905083611f4f565b8980611eb3565b611fb89199506121bd565b6000978a611f3c565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b91600092918380938c73ffffffffffffffffffffffffffffffffffffffff885116910192f115612023575b808080611cb0565b611cca929195503d6108008082116120c6575b5060405190888183010160405280825260008983013e805161205f575b5050600194909161201b565b7f1c4fada7374c0a9ee8841fc38afe82932dc0f8e69012e927f061a8bae611a20188870151918973ffffffffffffffffffffffffffffffffffffffff8551169401516120bc604051928392835260408d84015260408301906123c6565b0390a38680612053565b905088612036565b877fdeaddead000000000000000000000000000000000000000000000000000000006000526000fd5b606486604051907f08c379a00000000000000000000000000000000000000000000000000000000082526004820152601760248201527f4141393220696e7465726e616c2063616c6c206f6e6c790000000000000000006044820152fd5b60a0810190811067ffffffffffffffff82111761217157604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b610140810190811067ffffffffffffffff82111761217157604052565b67ffffffffffffffff811161217157604052565b6060810190811067ffffffffffffffff82111761217157604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff82111761217157604052565b67ffffffffffffffff811161217157601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200190565b9291926122748261222e565b9161228260405193846121ed565b82948184528183011161019c578281602093846000960137010152565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361019c57565b9181601f8401121561019c5782359167ffffffffffffffff831161019c576020838186019501011161019c57565b6024359077ffffffffffffffffffffffffffffffffffffffffffffffff8216820361019c57565b9060407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc83011261019c5760043567ffffffffffffffff9283821161019c578060238301121561019c57816004013593841161019c5760248460051b8301011161019c57602401919060243573ffffffffffffffffffffffffffffffffffffffff8116810361019c5790565b60005b8381106123b65750506000910152565b81810151838201526020016123a6565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f602093612402815180928187528780880191016123a3565b0116010190565b91908201809211610b4f57565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114610b4f5760010190565b91908203918211610b4f57565b3d1561247b573d906124618261222e565b9161246f60405193846121ed565b82523d6000602084013e565b606090565b604061248e8183018361284b565b90818351918237206124a3606084018461284b565b90818451918237209260c06124bb60e083018361284b565b908186519182372091845195602087019473ffffffffffffffffffffffffffffffffffffffff833516865260208301358789015260608801526080870152608081013560a087015260a081013582870152013560e08501526101009081850152835261012083019167ffffffffffffffff918484108385111761217157838252845190206101408501908152306101608601524661018086015260608452936101a00191821183831017612171575251902090565b67ffffffffffffffff81116121715760051b60200190565b9061259282612570565b6040906125a260405191826121ed565b8381527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe06125d08295612570565b019160005b8381106125e25750505050565b60209082516125f081612155565b83516125fb816121a0565b600081526000849181838201528187820152816060818184015260809282848201528260a08201528260c08201528260e082015282610100820152826101208201528652818587015281898701528501528301528286010152016125d5565b805182101561266e5760209160051b010190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b919081101561266e5760051b810135907ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee18136030182121561019c570190565b9081602091031261019c575173ffffffffffffffffffffffffffffffffffffffff8116810361019c5790565b601f82602094937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0938186528686013760008582860101520116010190565b7f2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4602073ffffffffffffffffffffffffffffffffffffffff61278a3485613c98565b936040519485521692a2565b919081101561266e5760051b810135907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa18136030182121561019c570190565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18136030182121561019c570180359067ffffffffffffffff821161019c57602001918160051b3603831361019c57565b3573ffffffffffffffffffffffffffffffffffffffff8116810361019c5790565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18136030182121561019c570180359067ffffffffffffffff821161019c5760200191813603831361019c57565b90357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18236030181121561019c57016020813591019167ffffffffffffffff821161019c57813603831361019c57565b61012091813573ffffffffffffffffffffffffffffffffffffffff811680910361019c576129626129476129ba9561299b93855260208601356020860152612937604087018761289c565b9091806040880152860191612709565b612954606086018661289c565b908583036060870152612709565b6080840135608084015260a084013560a084015260c084013560c084015261298d60e085018561289c565b9084830360e0860152612709565b916129ac610100918281019061289c565b929091818503910152612709565b90565b60028054146129cc5760028055565b60046040517f3ee5aeb5000000000000000000000000000000000000000000000000000000008152fd5b926000905a93805194843573ffffffffffffffffffffffffffffffffffffffff811680910361019c5786526020850135602087015260808501356fffffffffffffffffffffffffffffffff90818116606089015260801c604088015260a086013560c088015260c086013590811661010088015260801c610120870152612a8060e086018661284b565b801561357b576034811061351d578060141161019c578060241161019c5760341161019c57602481013560801c60a0880152601481013560801c60808801523560601c60e08701525b612ad285612480565b60208301526040860151946effffffffffffffffffffffffffffff8660c08901511760608901511760808901511760a0890151176101008901511761012089015117116134bf57604087015160608801510160808801510160a08801510160c0880151016101008801510296835173ffffffffffffffffffffffffffffffffffffffff81511690612b66604085018561284b565b806131e4575b505060e0015173ffffffffffffffffffffffffffffffffffffffff1690600082156131ac575b6020612bd7918b828a01516000868a604051978896879586937f19822f7c00000000000000000000000000000000000000000000000000000000855260048501613db5565b0393f160009181613178575b50612c8b573d8c610800808311612c83575b50604051916020818401016040528083526000602084013e610e7e6040519283927f65c8fd4d000000000000000000000000000000000000000000000000000000008452600484015260606024840152600d60648401527f4141323320726576657274656400000000000000000000000000000000000000608484015260a0604484015260a48301906123c6565b915082612bf5565b9a92939495969798999a91156130f2575b509773ffffffffffffffffffffffffffffffffffffffff835116602084015190600052600160205260406000208160401c60005260205267ffffffffffffffff604060002091825492612cee84612416565b9055160361308d575a8503116130285773ffffffffffffffffffffffffffffffffffffffff60e0606093015116612d42575b509060a09184959697986040608096015260608601520135905a900301910152565b969550505a9683519773ffffffffffffffffffffffffffffffffffffffff60e08a01511680600052600060205260406000208054848110612fc3576080612dcd9a9b9c600093878094039055015192602089015183604051809d819582947f52b7512c0000000000000000000000000000000000000000000000000000000084528c60048501613db5565b039286f1978860009160009a612f36575b50612e86573d8b610800808311612e7e575b50604051916020818401016040528083526000602084013e610e7e6040519283927f65c8fd4d000000000000000000000000000000000000000000000000000000008452600484015260606024840152600d60648401527f4141333320726576657274656400000000000000000000000000000000000000608484015260a0604484015260a48301906123c6565b915082612df0565b9991929394959697989998925a900311612eab57509096959094939291906080612d20565b60a490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602760448201527f41413336206f766572207061796d6173746572566572696669636174696f6e4760648201527f61734c696d6974000000000000000000000000000000000000000000000000006084820152fd5b915098503d90816000823e612f4b82826121ed565b604081838101031261019c5780519067ffffffffffffffff821161019c57828101601f83830101121561019c578181015191612f868361222e565b93612f9460405195866121ed565b838552820160208483850101011161019c57602092612fba9184808701918501016123a3565b01519838612dde565b60848b604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601e60448201527f41413331207061796d6173746572206465706f73697420746f6f206c6f7700006064820152fd5b608490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601e60448201527f41413236206f76657220766572696669636174696f6e4761734c696d697400006064820152fd5b608482604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601a60448201527f4141323520696e76616c6964206163636f756e74206e6f6e63650000000000006064820152fd5b600052600060205260406000208054808c11613113578b9003905538612c9c565b608484604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601760448201527f41413231206469646e2774207061792070726566756e640000000000000000006064820152fd5b9091506020813d6020116131a4575b81613194602093836121ed565b8101031261019c57519038612be3565b3d9150613187565b508060005260006020526040600020548a81116000146131d75750612bd7602060005b915050612b92565b6020612bd7918c036131cf565b833b61345a57604088510151602060405180927f570e1a360000000000000000000000000000000000000000000000000000000082528260048301528160008161323260248201898b612709565b039273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000efc2c1444ebcc4db75e7613d20c6a62ff67a167c1690f1908115610db75760009161343b575b5073ffffffffffffffffffffffffffffffffffffffff811680156133d6578503613371573b1561330c5760141161019c5773ffffffffffffffffffffffffffffffffffffffff9183887fd51a9c61267aa6196961883ecf5ff2da6619c37dac0fa92122513fb32c032d2d604060e0958787602086015195510151168251913560601c82526020820152a391612b6c565b60848d604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602060448201527f4141313520696e6974436f6465206d757374206372656174652073656e6465726064820152fd5b60848e604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602060448201527f4141313420696e6974436f6465206d7573742072657475726e2073656e6465726064820152fd5b60848f604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601b60448201527f4141313320696e6974436f6465206661696c6564206f72204f4f4700000000006064820152fd5b613454915060203d602011610db057610da181836121ed565b3861327c565b60848d604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601f60448201527f414131302073656e64657220616c726561647920636f6e7374727563746564006064820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f41413934206761732076616c756573206f766572666c6f7700000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f4141393320696e76616c6964207061796d6173746572416e64446174610000006044820152fd5b5050600060e087015260006080870152600060a0870152612ac9565b9092915a906060810151916040928351967fffffffff00000000000000000000000000000000000000000000000000000000886135d7606084018461284b565b600060038211613b9f575b7f8dd7712f0000000000000000000000000000000000000000000000000000000094168403613a445750505061379d6000926136b292602088015161363a8a5193849360208501528b602485015260648401906128ec565b90604483015203906136727fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0928381018352826121ed565b61379189519485927e42dc5300000000000000000000000000000000000000000000000000000000602085015261020060248501526102248401906123c6565b613760604484018b60806101a091805173ffffffffffffffffffffffffffffffffffffffff808251168652602082015160208701526040820151604087015260608201516060870152838201518487015260a082015160a087015260c082015160c087015260e08201511660e0860152610100808201519086015261012080910151908501526020810151610140850152604081015161016085015260608101516101808501520151910152565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc83820301610204840152876123c6565b039081018352826121ed565b6020918183809351910182305af1600051988652156137bf575b505050505050565b909192939495965060003d8214613a3a575b7fdeaddead00000000000000000000000000000000000000000000000000000000810361385b57608487878051917f220266b600000000000000000000000000000000000000000000000000000000835260048301526024820152600f60448201527f41413935206f7574206f662067617300000000000000000000000000000000006064820152fd5b7fdeadaa510000000000000000000000000000000000000000000000000000000091929395949650146000146138c55750506138a961389e6138b8935a90612443565b608085015190612409565b9083015183611d748295613d65565b905b3880808080806137b7565b909261395290828601518651907ff62676f440ff169a3a9afdbf812e89e7f95975ee8e5c31214ffdef631c5f479273ffffffffffffffffffffffffffffffffffffffff9580878551169401516139483d610800808211613a32575b508a519084818301018c5280825260008583013e8a805194859485528401528a8301906123c6565b0390a35a90612443565b916139636080860193845190612409565b926000905a94829488519761397789613ccc565b948260e08b0151168015600014613a1857505050875116955b5a9003019560a06060820151910151019051860390818111613a04575b5050840290850151928184106000146139de57505080611e68575090816139d89293611d7481613d65565b906138ba565b6139ee9082849397950390613c98565b50611e68575090826139ff92613cf6565b6139d8565b6064919003600a02049094019338806139ad565b90919892509751613a2a575b50613990565b955038613a24565b905038613920565b8181803e516137d1565b613b97945082935090613a8c917e42dc53000000000000000000000000000000000000000000000000000000006020613b6b9501526102006024860152610224850191612709565b613b3a604484018860806101a091805173ffffffffffffffffffffffffffffffffffffffff808251168652602082015160208701526040820151604087015260608201516060870152838201518487015260a082015160a087015260c082015160c087015260e08201511660e0860152610100808201519086015261012080910151908501526020810151610140850152604081015161016085015260608101516101808501520151910152565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc83820301610204840152846123c6565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081018952886121ed565b60008761379d565b5081356135e2565b73ffffffffffffffffffffffffffffffffffffffff168015613c3a57600080809381935af1613bd4612450565b5015613bdc57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f41413931206661696c65642073656e6420746f2062656e6566696369617279006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f4141393020696e76616c69642062656e656669636961727900000000000000006044820152fd5b73ffffffffffffffffffffffffffffffffffffffff166000526000602052613cc66040600020918254612409565b80915590565b610120610100820151910151808214613cf257480180821015613ced575090565b905090565b5090565b9190917f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f6080602083015192519473ffffffffffffffffffffffffffffffffffffffff946020868851169660e089015116970151916040519283526000602084015260408301526060820152a4565b60208101519051907f67b4fa9642f42120bf031f3051d1824b0fe25627945b27b8a6a65d5761d5482e60208073ffffffffffffffffffffffffffffffffffffffff855116940151604051908152a3565b613dcd604092959493956060835260608301906128ec565b9460208201520152565b8015613e6457600060408051613dec816121d1565b828152826020820152015273ffffffffffffffffffffffffffffffffffffffff811690604065ffffffffffff91828160a01c16908115613e5c575b60d01c92825191613e37836121d1565b8583528460208401521691829101524211908115613e5457509091565b905042109091565b839150613e27565b5060009060009056fea2646970667358221220b094fd69f04977ae9458e5ba422d01cd2d20dbcfca0992ff37f19aa07deec25464736f6c63430008170033", + "nonce": 2, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000002": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2": { + "balance": 0, + "code": "0x6080604052600436106100b55760003560e01c806366cfa057116100695780638456cb591161004e5780638456cb59146101d25780638da5cb5b146101e7578063f2fde38b1461021257600080fd5b806366cfa0571461019d578063715018a6146101bd57600080fd5b8063481286e61161009a578063481286e6146100f857806356299481146101425780635c975abb1461016257600080fd5b8063076c37b2146100c15780633f4ba83a146100e357600080fd5b366100bc57005b600080fd5b3480156100cd57600080fd5b506100e16100dc366004610845565b610232565b005b3480156100ef57600080fd5b506100e161028a565b34801561010457600080fd5b50610118610113366004610845565b61029c565b60405173ffffffffffffffffffffffffffffffffffffffff90911681526020015b60405180910390f35b34801561014e57600080fd5b5061011861015d366004610890565b6102af565b34801561016e57600080fd5b5060005474010000000000000000000000000000000000000000900460ff166040519015158152602001610139565b3480156101a957600080fd5b506100e16101b83660046108f4565b6102c4565b3480156101c957600080fd5b506100e16102dd565b3480156101de57600080fd5b506100e16102ef565b3480156101f357600080fd5b5060005473ffffffffffffffffffffffffffffffffffffffff16610118565b34801561021e57600080fd5b506100e161022d3660046109d6565b6102ff565b61023a6103bb565b61028582826040518060200161024f90610838565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe082820381018352601f90910116604052610440565b505050565b61029261059f565b61029a610620565b565b60006102a8838361069d565b9392505050565b60006102bc8484846106a6565b949350505050565b6102cc6103bb565b6102d7838383610440565b50505050565b6102e561059f565b61029a60006106d0565b6102f761059f565b61029a610745565b61030761059f565b73ffffffffffffffffffffffffffffffffffffffff81166103af576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f4f776e61626c653a206e6577206f776e657220697320746865207a65726f206160448201527f646472657373000000000000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b6103b8816106d0565b50565b60005474010000000000000000000000000000000000000000900460ff161561029a576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601060248201527f5061757361626c653a207061757365640000000000000000000000000000000060448201526064016103a6565b6000834710156104ac576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f437265617465323a20696e73756666696369656e742062616c616e636500000060448201526064016103a6565b8151600003610517576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820181905260248201527f437265617465323a2062797465636f6465206c656e677468206973207a65726f60448201526064016103a6565b8282516020840186f5905073ffffffffffffffffffffffffffffffffffffffff81166102a8576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601960248201527f437265617465323a204661696c6564206f6e206465706c6f790000000000000060448201526064016103a6565b60005473ffffffffffffffffffffffffffffffffffffffff16331461029a576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016103a6565b6106286107b4565b600080547fffffffffffffffffffffff00ffffffffffffffffffffffffffffffffffffffff1690557f5db9ee0a495bf2e6ff9c91a7834c1ba4fdd244a5e8aa4e537bd38aeae4b073aa335b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390a1565b60006102a88383305b6000604051836040820152846020820152828152600b8101905060ff815360559020949350505050565b6000805473ffffffffffffffffffffffffffffffffffffffff8381167fffffffffffffffffffffffff0000000000000000000000000000000000000000831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b61074d6103bb565b600080547fffffffffffffffffffffff00ffffffffffffffffffffffffffffffffffffffff16740100000000000000000000000000000000000000001790557f62e78cea01bee320cd4e420270b5ea74000d11b0c9f74754ebdbfc544b05a2586106733390565b60005474010000000000000000000000000000000000000000900460ff1661029a576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f5061757361626c653a206e6f742070617573656400000000000000000000000060448201526064016103a6565b61014e806109f283390190565b6000806040838503121561085857600080fd5b50508035926020909101359150565b803573ffffffffffffffffffffffffffffffffffffffff8116811461088b57600080fd5b919050565b6000806000606084860312156108a557600080fd5b83359250602084013591506108bc60408501610867565b90509250925092565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b60008060006060848603121561090957600080fd5b8335925060208401359150604084013567ffffffffffffffff8082111561092f57600080fd5b818601915086601f83011261094357600080fd5b813581811115610955576109556108c5565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190838211818310171561099b5761099b6108c5565b816040528281528960208487010111156109b457600080fd5b8260208601602083013760006020848301015280955050505050509250925092565b6000602082840312156109e857600080fd5b6102a88261086756fe608060405234801561001057600080fd5b5061012e806100206000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c8063249cb3fa14602d575b600080fd5b603c603836600460b1565b604e565b60405190815260200160405180910390f35b60008281526020818152604080832073ffffffffffffffffffffffffffffffffffffffff8516845290915281205460ff16608857600060aa565b7fa2ef4600d742022d532d4747cb3547474667d6f13804902513b2ec01c848f4b45b9392505050565b6000806040838503121560c357600080fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811460ed57600080fd5b80915050925092905056fea26469706673582212205ffd4e6cede7d06a5daf93d48d0541fc68189eeb16608c1999a82063b666eb1164736f6c63430008130033a26469706673582212201940386be57da7f417ccf1b0d0f7fb1cdce570da987e1443032c9f3b04fd7d0364736f6c63430008130033", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24": { + "balance": 0, + "code": "0x608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c0029", + "nonce": 1, + "storage": {} + }, + "0x2577507b78c2008Ff367261CB6285d44ba5eF2E9": { + "balance": 0, + "code": "0x6080806040526004361015610012575f80fd5b5f3560e01c90816348aac39214610250575080635c60da1b146102085763ea6d13ac1461003d575f80fd5b6100463661033f565b60405161006c602080838181019587898837878201908382015203018084520182610394565b519020905f92604051927fcc3735a920a3ca505d382bbc545af43d6000803e6038573d6000fd5b3d6000f36060527f5155f3363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e20766040526160096020527f000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b28601e5268603d3d8160223d3973600a52605f60212060358501523060581b845260ff84538060158501526055842094341591863b156101eb5750506001906101e6575f38818034895af1156101d9575b836040525f60605215610157575b6040516001600160a01b0385168152602090f35b9180825f9493859483378101838152039082855af13d156101d4573d67ffffffffffffffff81116101c0576040519061019a601f8201601f191660200183610394565b81525f60203d92013e5b156101b1575f8080610143565b63487e630960e11b5f5260045ffd5b634e487b7160e01b5f52604160045260245ffd5b6101a4565b63b12d13eb5f526004601cfd5b610135565b91509450605f602134f593846101355763301164255f526004601cfd5b3461024c575f36600319011261024c576040517f000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b286001600160a01b03168152602090f35b5f80fd5b3461024c57806102826020806102653661033f565b908084889499019889378201908382015203018084520182610394565b5190206040517fcc3735a920a3ca505d382bbc545af43d6000803e6038573d6000fd5b3d6000f36060527f5155f3363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e20766040526160096020527f000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b28601e5268603d3d8160223d3973600a52605f602120906040525f60605260ff5f536035523060601b600152601552602060555f205f6035526040519060018060a01b03168152f35b90604060031983011261024c5760043567ffffffffffffffff811161024c578260238201121561024c5780600401359267ffffffffffffffff841161024c576024848301011161024c57602401919060243590565b90601f8019910116810190811067ffffffffffffffff8211176101c05760405256", + "nonce": 1, + "storage": {} + }, + "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762": { + "balance": 0, + "code": "0x6080604052600436106101d15760003560e01c8063affed0e0116100f7578063e19a9dd911610095578063f08a032311610064578063f08a03231461156b578063f698da25146115bc578063f8dc5dd9146115e7578063ffa1ad741461166257610226565b8063e19a9dd9146112bf578063e318b52b14611310578063e75235b8146113a1578063e86637db146113cc57610226565b8063cc2f8452116100d1578063cc2f84521461100c578063d4d9bdcd146110d9578063d8d11f7814611114578063e009cfde1461124e57610226565b8063affed0e014610d89578063b4faba0914610db4578063b63e800d14610e9c57610226565b80635624b25b1161016f5780636a7612021161013e5780636a761202146109895780637d83297414610b45578063934f3a1114610bb4578063a0e67e2b14610d1d57610226565b80635624b25b146107f05780635ae6bd37146108ae578063610b5925146108fd578063694e80c31461094e57610226565b80632f54bf6e116101ab5780632f54bf6e146104c85780633408e4701461052f578063468721a71461055a5780635229073f1461066f57610226565b80630d582f131461029357806312fb68e0146102ee5780632d9ad53d1461046157610226565b36610226573373ffffffffffffffffffffffffffffffffffffffff167f3d0ce9bfc3ed7d6862dbb28b2dea94561fe714a1b4d019aa8af39730d1ad7c3d346040518082815260200191505060405180910390a2005b34801561023257600080fd5b5060007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b905080548061026757600080f35b36600080373360601b365260008060143601600080855af13d6000803e8061028e573d6000fd5b3d6000f35b34801561029f57600080fd5b506102ec600480360360408110156102b657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506116f2565b005b3480156102fa57600080fd5b5061045f6004803603608081101561031157600080fd5b81019080803590602001909291908035906020019064010000000081111561033857600080fd5b82018360208201111561034a57600080fd5b8035906020019184600183028401116401000000008311171561036c57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803590602001906401000000008111156103cf57600080fd5b8201836020820111156103e157600080fd5b8035906020019184600183028401116401000000008311171561040357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190505050611ad8565b005b34801561046d57600080fd5b506104b06004803603602081101561048457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506123d6565b60405180821515815260200191505060405180910390f35b3480156104d457600080fd5b50610517600480360360208110156104eb57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506124a8565b60405180821515815260200191505060405180910390f35b34801561053b57600080fd5b5061054461257a565b6040518082815260200191505060405180910390f35b34801561056657600080fd5b506106576004803603608081101561057d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156105c457600080fd5b8201836020820111156105d657600080fd5b803590602001918460018302840111640100000000831117156105f857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff169060200190929190505050612587565b60405180821515815260200191505060405180910390f35b34801561067b57600080fd5b5061076c6004803603608081101561069257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156106d957600080fd5b8201836020820111156106eb57600080fd5b8035906020019184600183028401116401000000008311171561070d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff169060200190929190505050612692565b60405180831515815260200180602001828103825283818151815260200191508051906020019080838360005b838110156107b4578082015181840152602081019050610799565b50505050905090810190601f1680156107e15780820380516001836020036101000a031916815260200191505b50935050505060405180910390f35b3480156107fc57600080fd5b506108336004803603604081101561081357600080fd5b8101908080359060200190929190803590602001909291905050506126c8565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015610873578082015181840152602081019050610858565b50505050905090810190601f1680156108a05780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b3480156108ba57600080fd5b506108e7600480360360208110156108d157600080fd5b810190808035906020019092919050505061274f565b6040518082815260200191505060405180910390f35b34801561090957600080fd5b5061094c6004803603602081101561092057600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612767565b005b34801561095a57600080fd5b506109876004803603602081101561097157600080fd5b8101908080359060200190929190505050612aef565b005b610b2d60048036036101408110156109a057600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156109e757600080fd5b8201836020820111156109f957600080fd5b80359060200191846001830284011164010000000083111715610a1b57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610aa757600080fd5b820183602082011115610ab957600080fd5b80359060200191846001830284011164010000000083111715610adb57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612c29565b60405180821515815260200191505060405180910390f35b348015610b5157600080fd5b50610b9e60048036036040811015610b6857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050612e68565b6040518082815260200191505060405180910390f35b348015610bc057600080fd5b50610d1b60048036036060811015610bd757600080fd5b810190808035906020019092919080359060200190640100000000811115610bfe57600080fd5b820183602082011115610c1057600080fd5b80359060200191846001830284011164010000000083111715610c3257600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190640100000000811115610c9557600080fd5b820183602082011115610ca757600080fd5b80359060200191846001830284011164010000000083111715610cc957600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612e8d565b005b348015610d2957600080fd5b50610d32612f1c565b6040518080602001828103825283818151815260200191508051906020019060200280838360005b83811015610d75578082015181840152602081019050610d5a565b505050509050019250505060405180910390f35b348015610d9557600080fd5b50610d9e6130c5565b6040518082815260200191505060405180910390f35b348015610dc057600080fd5b50610e9a60048036036040811015610dd757600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610e1457600080fd5b820183602082011115610e2657600080fd5b80359060200191846001830284011164010000000083111715610e4857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506130cb565b005b348015610ea857600080fd5b5061100a6004803603610100811015610ec057600080fd5b8101908080359060200190640100000000811115610edd57600080fd5b820183602082011115610eef57600080fd5b80359060200191846020830284011164010000000083111715610f1157600080fd5b909192939192939080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610f5c57600080fd5b820183602082011115610f6e57600080fd5b80359060200191846001830284011164010000000083111715610f9057600080fd5b9091929391929390803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506130ed565b005b34801561101857600080fd5b506110656004803603604081101561102f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506132ab565b60405180806020018373ffffffffffffffffffffffffffffffffffffffff168152602001828103825284818151815260200191508051906020019060200280838360005b838110156110c45780820151818401526020810190506110a9565b50505050905001935050505060405180910390f35b3480156110e557600080fd5b50611112600480360360208110156110fc57600080fd5b810190808035906020019092919050505061360e565b005b34801561112057600080fd5b50611238600480360361014081101561113857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561117f57600080fd5b82018360208201111561119157600080fd5b803590602001918460018302840111640100000000831117156111b357600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506137ad565b6040518082815260200191505060405180910390f35b34801561125a57600080fd5b506112bd6004803603604081101561127157600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506137da565b005b3480156112cb57600080fd5b5061130e600480360360208110156112e257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613b61565b005b34801561131c57600080fd5b5061139f6004803603606081101561133357600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613d4d565b005b3480156113ad57600080fd5b506113b66143ab565b6040518082815260200191505060405180910390f35b3480156113d857600080fd5b506114f060048036036101408110156113f057600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561143757600080fd5b82018360208201111561144957600080fd5b8035906020019184600183028401116401000000008311171561146b57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506143b5565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015611530578082015181840152602081019050611515565b50505050905090810190601f16801561155d5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561157757600080fd5b506115ba6004803603602081101561158e57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061455d565b005b3480156115c857600080fd5b506115d16145b4565b6040518082815260200191505060405180910390f35b3480156115f357600080fd5b506116606004803603606081101561160a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050614632565b005b34801561166e57600080fd5b50611677614a5b565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156116b757808201518184015260208101905061169c565b50505050905090810190601f1680156116e45780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6116fa614a94565b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156117645750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b801561179c57503073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b61180e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461190f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508160026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600081548092919060010191905055508173ffffffffffffffffffffffffffffffffffffffff167f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2660405160405180910390a28060045414611ad457611ad381612aef565b5b5050565b611aec604182614b3790919063ffffffff16565b82511015611b62576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808060008060005b868110156123ca57611b7e8882614b71565b80945081955082965050505060008460ff1614156120035789898051906020012014611c12576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323700000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8260001c9450611c2c604188614b3790919063ffffffff16565b8260001c1015611ca4576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8751611cbd60208460001c614ba090919063ffffffff16565b1115611d31576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006020838a01015190508851611d6782611d5960208760001c614ba090919063ffffffff16565b614ba090919063ffffffff16565b1115611ddb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60606020848b010190506320c13b0b60e01b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168773ffffffffffffffffffffffffffffffffffffffff166320c13b0b8d846040518363ffffffff1660e01b8152600401808060200180602001838103835285818151815260200191508051906020019080838360005b83811015611e7d578082015181840152602081019050611e62565b50505050905090810190601f168015611eaa5780820380516001836020036101000a031916815260200191505b50838103825284818151815260200191508051906020019080838360005b83811015611ee3578082015181840152602081019050611ec8565b50505050905090810190601f168015611f105780820380516001836020036101000a031916815260200191505b5094505050505060206040518083038186803b158015611f2f57600080fd5b505afa158015611f43573d6000803e3d6000fd5b505050506040513d6020811015611f5957600080fd5b81019080805190602001909291905050507bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614611ffc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5050612248565b60018460ff161415612117578260001c94508473ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614806120a057506000600860008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008c81526020019081526020016000205414155b612112576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b612247565b601e8460ff1611156121df5760018a60405160200180807f19457468657265756d205369676e6564204d6573736167653a0a333200000000815250601c018281526020019150506040516020818303038152906040528051906020012060048603858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156121ce573d6000803e3d6000fd5b505050602060405103519450612246565b60018a85858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa158015612239573d6000803e3d6000fd5b5050506020604051035194505b5b5b8573ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1611801561230f5750600073ffffffffffffffffffffffffffffffffffffffff16600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b80156123485750600173ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1614155b6123ba576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8495508080600101915050611b6c565b50505050505050505050565b60008173ffffffffffffffffffffffffffffffffffffffff16600173ffffffffffffffffffffffffffffffffffffffff16141580156124a15750600073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156125735750600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000804690508091505090565b60007fb648d3644f584ed1c2232d53c46d87e693586486ad0d1175f8656013110b714e3386868686604051808673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff1681526020018481526020018060200183600181111561260157fe5b8152602001828103825284818151815260200191508051906020019080838360005b8381101561263e578082015181840152602081019050612623565b50505050905090810190601f16801561266b5780820380516001836020036101000a031916815260200191505b50965050505050505060405180910390a161268885858585614bbf565b9050949350505050565b600060606126a286868686612587565b915060405160203d0181016040523d81523d6000602083013e8091505094509492505050565b606060006020830267ffffffffffffffff811180156126e657600080fd5b506040519080825280601f01601f1916602001820160405280156127195781602001600182028036833780820191505090505b50905060005b838110156127445780850154806020830260208501015250808060010191505061271f565b508091505092915050565b60076020528060005260406000206000915090505481565b61276f614a94565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156127d95750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b61284b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461294c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508073ffffffffffffffffffffffffffffffffffffffff167fecdf3a3effea5783a3c4c2140e677577666428d44ed9d474a0b3a4c9943f844060405160405180910390a250565b612af7614a94565b600354811115612b6f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001811015612be6576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b806004819055507f610f7ff2b304ae8903c3de74c60c6ab1f7d6226b3f52c5161905bb5ad4039c936004546040518082815260200191505060405180910390a150565b6000606060055433600454604051602001808481526020018373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001935050505060405160208183030381529060405290507f66753cd2356569ee081232e3be8909b950e0a76c1f8460c3a5e3c2be32b11bed8d8d8d8d8d8d8d8d8d8d8d8c604051808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115612cdc57fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018060200184810384528e8e82818152602001925080828437600081840152601f19601f820116905080830192505050848103835286818151815260200191508051906020019080838360005b83811015612d96578082015181840152602081019050612d7b565b50505050905090810190601f168015612dc35780820380516001836020036101000a031916815260200191505b50848103825285818151815260200191508051906020019080838360005b83811015612dfc578082015181840152602081019050612de1565b50505050905090810190601f168015612e295780820380516001836020036101000a031916815260200191505b509f5050505050505050505050505050505060405180910390a1612e568d8d8d8d8d8d8d8d8d8d8d614dc5565b9150509b9a5050505050505050505050565b6008602052816000526040600020602052806000526040600020600091509150505481565b6000600454905060008111612f0a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b612f1684848484611ad8565b50505050565b6060600060035467ffffffffffffffff81118015612f3957600080fd5b50604051908082528060200260200182016040528015612f685781602001602082028036833780820191505090505b50905060008060026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146130bc578083838151811061301357fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600260008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508180600101925050612fd2565b82935050505090565b60055481565b600080825160208401855af4806000523d6020523d600060403e60403d016000fd5b6131388a8a80806020026020016040519081016040528093929190818152602001838360200280828437600081840152601f19601f82011690508083019250505050505050896152f4565b600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff161461317657613175846157f4565b5b6131c48787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050506158c5565b60008211156131de576131dc82600060018685615b9b565b505b3373ffffffffffffffffffffffffffffffffffffffff167f141df868a6331af528e38c83b7aa03edc19be66e37ae67f9285bf4f8e3c6a1a88b8b8b8b8960405180806020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281038252878782818152602001925060200280828437600081840152601f19601f820116905080830192505050965050505050505060405180910390a250505050505050505050565b60606000600173ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff1614806132ef57506132ee846123d6565b5b613361576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600083116133d7576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8267ffffffffffffffff811180156133ee57600080fd5b5060405190808252806020026020018201604052801561341d5781602001602082028036833780820191505090505b5091506000600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1691505b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156134ef5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b80156134fa57508381105b156135b5578183828151811061350c57fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1691508080600101915050613485565b600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614613603578260018203815181106135f857fe5b602002602001015191505b808352509250929050565b600073ffffffffffffffffffffffffffffffffffffffff16600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161415613710576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001600860003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000838152602001908152602001600020819055503373ffffffffffffffffffffffffffffffffffffffff16817ff2a0eb156472d1440255b0d7c1e19cc07115d1051fe605b0dce69acfec884d9c60405160405180910390a350565b60006137c28c8c8c8c8c8c8c8c8c8c8c6143b5565b8051906020012090509b9a5050505050505050505050565b6137e2614a94565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415801561384c5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b6138be576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146139be576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508073ffffffffffffffffffffffffffffffffffffffff167faab4fa2b463f581b2b32cb3b7e3b704b9ce37cc209b5fb4d77e593ace405427660405160405180910390a25050565b613b69614a94565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614613cdb578073ffffffffffffffffffffffffffffffffffffffff166301ffc9a77fe6d7a83a000000000000000000000000000000000000000000000000000000006040518263ffffffff1660e01b815260040180827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916815260200191505060206040518083038186803b158015613c2d57600080fd5b505afa158015613c41573d6000803e3d6000fd5b505050506040513d6020811015613c5757600080fd5b8101908080519060200190929190505050613cda576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475333303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b60007f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b90508181558173ffffffffffffffffffffffffffffffffffffffff167f1151116914515bc0891ff9047a6cb32cf902546f83066499bcf8ba33d2353fa260405160405180910390a25050565b613d55614a94565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614158015613dbf5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b8015613df757503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b613e69576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614613f6a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614158015613fd45750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b614046576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614146576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555080600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508173ffffffffffffffffffffffffffffffffffffffff167ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf60405160405180910390a28073ffffffffffffffffffffffffffffffffffffffff167f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2660405160405180910390a2505050565b6000600454905090565b606060007fbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d860001b8d8d8d8d60405180838380828437808301925050509250505060405180910390208c8c8c8c8c8c8c604051602001808c81526020018b73ffffffffffffffffffffffffffffffffffffffff1681526020018a815260200189815260200188600181111561444657fe5b81526020018781526020018681526020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019b505050505050505050505050604051602081830303815290604052805190602001209050601960f81b600160f81b6144d26145b4565b8360405160200180857effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168152600101847effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff191681526001018381526020018281526020019450505050506040516020818303038152906040529150509b9a5050505050505050505050565b614565614a94565b61456e816157f4565b8073ffffffffffffffffffffffffffffffffffffffff167f5ac6c46c93c8d0e53714ba3b53db3e7c046da994313d7ed0d192028bc7c228b060405160405180910390a250565b60007f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a7946921860001b6145e261257a565b30604051602001808481526020018381526020018273ffffffffffffffffffffffffffffffffffffffff168152602001935050505060405160208183030381529060405280519060200120905090565b61463a614a94565b8060016003540310156146b5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161415801561471f5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b614791576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614891576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600360008154809291906001900391905055508173ffffffffffffffffffffffffffffffffffffffff167ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf60405160405180910390a28060045414614a5657614a5581612aef565b5b505050565b6040518060400160405280600581526020017f312e342e3100000000000000000000000000000000000000000000000000000081525081565b3073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614614b35576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b565b600080831415614b4a5760009050614b6b565b6000828402905082848281614b5b57fe5b0414614b6657600080fd5b809150505b92915050565b60008060008360410260208101860151925060408101860151915060ff60418201870151169350509250925092565b600080828401905083811015614bb557600080fd5b8091505092915050565b6000600173ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614158015614c8a5750600073ffffffffffffffffffffffffffffffffffffffff16600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b614cfc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b614d29858585857fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff615da1565b90508015614d79573373ffffffffffffffffffffffffffffffffffffffff167f6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb860405160405180910390a2614dbd565b3373ffffffffffffffffffffffffffffffffffffffff167facd2c8702804128fdb0db2bb49f6d127dd0181c13fd45dbfe16de0930e2bd37560405160405180910390a25b949350505050565b6000806000614ddf8e8e8e8e8e8e8e8e8e8e6005546143b5565b905060056000815480929190600101919050555080805190602001209150614e08828286612e8d565b506000614e13615ded565b9050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614614ff9578073ffffffffffffffffffffffffffffffffffffffff166375f0bb528f8f8f8f8f8f8f8f8f8f8f336040518d63ffffffff1660e01b8152600401808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115614eb657fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018473ffffffffffffffffffffffffffffffffffffffff16815260200183810383528d8d82818152602001925080828437600081840152601f19601f820116905080830192505050838103825285818151815260200191508051906020019080838360005b83811015614f88578082015181840152602081019050614f6d565b50505050905090810190601f168015614fb55780820380516001836020036101000a031916815260200191505b509e505050505050505050505050505050600060405180830381600087803b158015614fe057600080fd5b505af1158015614ff4573d6000803e3d6000fd5b505050505b6101f46150206109c48b01603f60408d028161501157fe5b04615e1e90919063ffffffff16565b015a1015615096576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60005a90506150ff8f8f8f8f8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050508e60008d146150f4578e6150fa565b6109c45a035b615da1565b93506151145a82615e3890919063ffffffff16565b90508380615123575060008a14155b8061512f575060008814155b6151a1576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808911156151bb576151b8828b8b8b8b615b9b565b90505b84156151fe57837f442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e826040518082815260200191505060405180910390a2615237565b837f23428b18acfb3ea64b08dc0c1d296ea9c09702c09083ca5272e64d115b687d23826040518082815260200191505060405180910390a25b5050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146152e3578073ffffffffffffffffffffffffffffffffffffffff16639327136883856040518363ffffffff1660e01b815260040180838152602001821515815260200192505050600060405180830381600087803b1580156152ca57600080fd5b505af11580156152de573d6000803e3d6000fd5b505050505b50509b9a5050505050505050505050565b60006004541461536c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b81518111156153e3576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600181101561545a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006001905060005b835181101561576057600084828151811061547a57fe5b60200260200101519050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156154ee5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561552657503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561555e57508073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614155b6155d0576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146156d1576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b80600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550809250508080600101915050615463565b506001600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550825160038190555081600481905550505050565b3073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415615896576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475334303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b90508181555050565b600073ffffffffffffffffffffffffffffffffffffffff1660016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146159c7576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001806000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614615b9757615a8382615e58565b615af5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b615b248260008360017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff615da1565b615b96576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5050565b600080600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614615bd85782615bda565b325b9050600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff161415615cf257615c443a8610615c21573a615c23565b855b615c36888a614ba090919063ffffffff16565b614b3790919063ffffffff16565b91508073ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f19350505050615ced576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b615d97565b615d1785615d09888a614ba090919063ffffffff16565b614b3790919063ffffffff16565b9150615d24848284615e6b565b615d96576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5095945050505050565b6000600180811115615daf57fe5b836001811115615dbb57fe5b1415615dd4576000808551602087018986f49050615de4565b600080855160208701888a87f190505b95945050505050565b6000807f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b9050805491505090565b600081831015615e2e5781615e30565b825b905092915050565b600082821115615e4757600080fd5b600082840390508091505092915050565b600080823b905060008111915050919050565b60008063a9059cbb8484604051602401808373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001925050506040516020818303038152906040529060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050509050602060008251602084016000896127105a03f13d60008114615f125760208114615f1a5760009350615f25565b819350615f25565b600051158215171593505b505050939250505056fea2646970667358221220cd2bdb262f44c0636136d3c6bfed2c2458921f82c3bf476053bd2e9ac618b2da64736f6c63430007060033", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0x2dd68b007B46fBe91B9A7c3EDa5A7a1063cB5b47": { + "balance": 0, + "code": "0x608060405234801561001057600080fd5b506004361061002b5760003560e01c80638d0dc49f14610030575b600080fd5b61004361003e36600461010c565b610045565b005b60005b81811015610107573063610b592584848481811061006857610068610181565b905060200201602081019061007d91906101b0565b6040517fffffffff0000000000000000000000000000000000000000000000000000000060e084901b16815273ffffffffffffffffffffffffffffffffffffffff9091166004820152602401600060405180830381600087803b1580156100e357600080fd5b505af11580156100f7573d6000803e3d6000fd5b5050600190920191506100489050565b505050565b6000806020838503121561011f57600080fd5b823567ffffffffffffffff8082111561013757600080fd5b818501915085601f83011261014b57600080fd5b81358181111561015a57600080fd5b8660208260051b850101111561016f57600080fd5b60209290920196919550909350505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b6000602082840312156101c257600080fd5b813573ffffffffffffffffffffffffffffffffffffffff811681146101e657600080fd5b939250505056fea26469706673582212203047d3f62c9d732011c4ca139e90521dfe651cb848b0d953431ca0836247de4b64736f6c63430008170033", + "nonce": 1, + "storage": {} + }, + "0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526": { + "balance": 0, + "code": "0x60806040526004361061001e5760003560e01c80638d80ff0a14610023575b600080fd5b6100dc6004803603602081101561003957600080fd5b810190808035906020019064010000000081111561005657600080fd5b82018360208201111561006857600080fd5b8035906020019184600183028401116401000000008311171561008a57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506100de565b005b7f00000000000000000000000038869bf66a61cf6bdb996a6ae40d5853fd43b52673ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff161415610183576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260308152602001806102106030913960400191505060405180910390fd5b805160205b8181101561020a578083015160f81c6001820184015160601c6015830185015160358401860151605585018701600085600081146101cd57600181146101dd576101e8565b6000808585888a5af191506101e8565b6000808585895af491505b5060008114156101f757600080fd5b8260550187019650505050505050610188565b50505056fe4d756c746953656e642073686f756c64206f6e6c792062652063616c6c6564207669612064656c656761746563616c6ca264697066735822122021102e6d5bc1da75411b41fe2792a1748bf5c49c794e51e81405ccd2399da13564736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0x3E5c63644E683549055b9Be8653de26E0B4CD36E": { + "balance": 0, + "code": "0x6080604052600436106101dc5760003560e01c8063affed0e011610102578063e19a9dd911610095578063f08a032311610064578063f08a032314611647578063f698da2514611698578063f8dc5dd9146116c3578063ffa1ad741461173e57610231565b8063e19a9dd91461139b578063e318b52b146113ec578063e75235b81461147d578063e86637db146114a857610231565b8063cc2f8452116100d1578063cc2f8452146110e8578063d4d9bdcd146111b5578063d8d11f78146111f0578063e009cfde1461132a57610231565b8063affed0e014610d94578063b4faba0914610dbf578063b63e800d14610ea7578063c4ca3a9c1461101757610231565b80635624b25b1161017a5780636a761202116101495780636a761202146109945780637d83297414610b50578063934f3a1114610bbf578063a0e67e2b14610d2857610231565b80635624b25b146107fb5780635ae6bd37146108b9578063610b592514610908578063694e80c31461095957610231565b80632f54bf6e116101b65780632f54bf6e146104d35780633408e4701461053a578063468721a7146105655780635229073f1461067a57610231565b80630d582f131461029e57806312fb68e0146102f95780632d9ad53d1461046c57610231565b36610231573373ffffffffffffffffffffffffffffffffffffffff167f3d0ce9bfc3ed7d6862dbb28b2dea94561fe714a1b4d019aa8af39730d1ad7c3d346040518082815260200191505060405180910390a2005b34801561023d57600080fd5b5060007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b905080548061027257600080f35b36600080373360601b365260008060143601600080855af13d6000803e80610299573d6000fd5b3d6000f35b3480156102aa57600080fd5b506102f7600480360360408110156102c157600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506117ce565b005b34801561030557600080fd5b5061046a6004803603608081101561031c57600080fd5b81019080803590602001909291908035906020019064010000000081111561034357600080fd5b82018360208201111561035557600080fd5b8035906020019184600183028401116401000000008311171561037757600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803590602001906401000000008111156103da57600080fd5b8201836020820111156103ec57600080fd5b8035906020019184600183028401116401000000008311171561040e57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190505050611bbe565b005b34801561047857600080fd5b506104bb6004803603602081101561048f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612440565b60405180821515815260200191505060405180910390f35b3480156104df57600080fd5b50610522600480360360208110156104f657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612512565b60405180821515815260200191505060405180910390f35b34801561054657600080fd5b5061054f6125e4565b6040518082815260200191505060405180910390f35b34801561057157600080fd5b506106626004803603608081101561058857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156105cf57600080fd5b8201836020820111156105e157600080fd5b8035906020019184600183028401116401000000008311171561060357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506125f1565b60405180821515815260200191505060405180910390f35b34801561068657600080fd5b506107776004803603608081101561069d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156106e457600080fd5b8201836020820111156106f657600080fd5b8035906020019184600183028401116401000000008311171561071857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506126fc565b60405180831515815260200180602001828103825283818151815260200191508051906020019080838360005b838110156107bf5780820151818401526020810190506107a4565b50505050905090810190601f1680156107ec5780820380516001836020036101000a031916815260200191505b50935050505060405180910390f35b34801561080757600080fd5b5061083e6004803603604081101561081e57600080fd5b810190808035906020019092919080359060200190929190505050612732565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561087e578082015181840152602081019050610863565b50505050905090810190601f1680156108ab5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b3480156108c557600080fd5b506108f2600480360360208110156108dc57600080fd5b81019080803590602001909291905050506127b9565b6040518082815260200191505060405180910390f35b34801561091457600080fd5b506109576004803603602081101561092b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506127d1565b005b34801561096557600080fd5b506109926004803603602081101561097c57600080fd5b8101908080359060200190929190505050612b63565b005b610b3860048036036101408110156109ab57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156109f257600080fd5b820183602082011115610a0457600080fd5b80359060200191846001830284011164010000000083111715610a2657600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610ab257600080fd5b820183602082011115610ac457600080fd5b80359060200191846001830284011164010000000083111715610ae657600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612c9d565b60405180821515815260200191505060405180910390f35b348015610b5c57600080fd5b50610ba960048036036040811015610b7357600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050612edc565b6040518082815260200191505060405180910390f35b348015610bcb57600080fd5b50610d2660048036036060811015610be257600080fd5b810190808035906020019092919080359060200190640100000000811115610c0957600080fd5b820183602082011115610c1b57600080fd5b80359060200191846001830284011164010000000083111715610c3d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190640100000000811115610ca057600080fd5b820183602082011115610cb257600080fd5b80359060200191846001830284011164010000000083111715610cd457600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612f01565b005b348015610d3457600080fd5b50610d3d612f90565b6040518080602001828103825283818151815260200191508051906020019060200280838360005b83811015610d80578082015181840152602081019050610d65565b505050509050019250505060405180910390f35b348015610da057600080fd5b50610da9613139565b6040518082815260200191505060405180910390f35b348015610dcb57600080fd5b50610ea560048036036040811015610de257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610e1f57600080fd5b820183602082011115610e3157600080fd5b80359060200191846001830284011164010000000083111715610e5357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929050505061313f565b005b348015610eb357600080fd5b506110156004803603610100811015610ecb57600080fd5b8101908080359060200190640100000000811115610ee857600080fd5b820183602082011115610efa57600080fd5b80359060200191846020830284011164010000000083111715610f1c57600080fd5b909192939192939080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610f6757600080fd5b820183602082011115610f7957600080fd5b80359060200191846001830284011164010000000083111715610f9b57600080fd5b9091929391929390803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613161565b005b34801561102357600080fd5b506110d26004803603608081101561103a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561108157600080fd5b82018360208201111561109357600080fd5b803590602001918460018302840111640100000000831117156110b557600080fd5b9091929391929390803560ff16906020019092919050505061331f565b6040518082815260200191505060405180910390f35b3480156110f457600080fd5b506111416004803603604081101561110b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613447565b60405180806020018373ffffffffffffffffffffffffffffffffffffffff168152602001828103825284818151815260200191508051906020019060200280838360005b838110156111a0578082015181840152602081019050611185565b50505050905001935050505060405180910390f35b3480156111c157600080fd5b506111ee600480360360208110156111d857600080fd5b8101908080359060200190929190505050613639565b005b3480156111fc57600080fd5b50611314600480360361014081101561121457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561125b57600080fd5b82018360208201111561126d57600080fd5b8035906020019184600183028401116401000000008311171561128f57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506137d8565b6040518082815260200191505060405180910390f35b34801561133657600080fd5b506113996004803603604081101561134d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613805565b005b3480156113a757600080fd5b506113ea600480360360208110156113be57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613b96565b005b3480156113f857600080fd5b5061147b6004803603606081101561140f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613c1a565b005b34801561148957600080fd5b5061149261428c565b6040518082815260200191505060405180910390f35b3480156114b457600080fd5b506115cc60048036036101408110156114cc57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561151357600080fd5b82018360208201111561152557600080fd5b8035906020019184600183028401116401000000008311171561154757600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050614296565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561160c5780820151818401526020810190506115f1565b50505050905090810190601f1680156116395780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561165357600080fd5b506116966004803603602081101561166a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061443e565b005b3480156116a457600080fd5b506116ad61449f565b6040518082815260200191505060405180910390f35b3480156116cf57600080fd5b5061173c600480360360608110156116e657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019092919050505061451d565b005b34801561174a57600080fd5b50611753614950565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015611793578082015181840152602081019050611778565b50505050905090810190601f1680156117c05780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6117d6614989565b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156118405750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b801561187857503073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b6118ea576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146119eb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508160026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600081548092919060010191905055507f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2682604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a18060045414611bba57611bb981612b63565b5b5050565b611bd2604182614a2c90919063ffffffff16565b82511015611c48576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808060008060005b8681101561243457611c648882614a66565b80945081955082965050505060008460ff16141561206d578260001c9450611c96604188614a2c90919063ffffffff16565b8260001c1015611d0e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8751611d2760208460001c614a9590919063ffffffff16565b1115611d9b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006020838a01015190508851611dd182611dc360208760001c614a9590919063ffffffff16565b614a9590919063ffffffff16565b1115611e45576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60606020848b010190506320c13b0b60e01b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168773ffffffffffffffffffffffffffffffffffffffff166320c13b0b8d846040518363ffffffff1660e01b8152600401808060200180602001838103835285818151815260200191508051906020019080838360005b83811015611ee7578082015181840152602081019050611ecc565b50505050905090810190601f168015611f145780820380516001836020036101000a031916815260200191505b50838103825284818151815260200191508051906020019080838360005b83811015611f4d578082015181840152602081019050611f32565b50505050905090810190601f168015611f7a5780820380516001836020036101000a031916815260200191505b5094505050505060206040518083038186803b158015611f9957600080fd5b505afa158015611fad573d6000803e3d6000fd5b505050506040513d6020811015611fc357600080fd5b81019080805190602001909291905050507bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614612066576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b50506122b2565b60018460ff161415612181578260001c94508473ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16148061210a57506000600860008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008c81526020019081526020016000205414155b61217c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6122b1565b601e8460ff1611156122495760018a60405160200180807f19457468657265756d205369676e6564204d6573736167653a0a333200000000815250601c018281526020019150506040516020818303038152906040528051906020012060048603858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa158015612238573d6000803e3d6000fd5b5050506020604051035194506122b0565b60018a85858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156122a3573d6000803e3d6000fd5b5050506020604051035194505b5b5b8573ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff161180156123795750600073ffffffffffffffffffffffffffffffffffffffff16600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b80156123b25750600173ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1614155b612424576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8495508080600101915050611c52565b50505050505050505050565b60008173ffffffffffffffffffffffffffffffffffffffff16600173ffffffffffffffffffffffffffffffffffffffff161415801561250b5750600073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156125dd5750600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000804690508091505090565b60007fb648d3644f584ed1c2232d53c46d87e693586486ad0d1175f8656013110b714e3386868686604051808673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff1681526020018481526020018060200183600181111561266b57fe5b8152602001828103825284818151815260200191508051906020019080838360005b838110156126a857808201518184015260208101905061268d565b50505050905090810190601f1680156126d55780820380516001836020036101000a031916815260200191505b50965050505050505060405180910390a16126f285858585614ab4565b9050949350505050565b6000606061270c868686866125f1565b915060405160203d0181016040523d81523d6000602083013e8091505094509492505050565b606060006020830267ffffffffffffffff8111801561275057600080fd5b506040519080825280601f01601f1916602001820160405280156127835781602001600182028036833780820191505090505b50905060005b838110156127ae57808501548060208302602085010152508080600101915050612789565b508091505092915050565b60076020528060005260406000206000915090505481565b6127d9614989565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156128435750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b6128b5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146129b6576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507fecdf3a3effea5783a3c4c2140e677577666428d44ed9d474a0b3a4c9943f844081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b612b6b614989565b600354811115612be3576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001811015612c5a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b806004819055507f610f7ff2b304ae8903c3de74c60c6ab1f7d6226b3f52c5161905bb5ad4039c936004546040518082815260200191505060405180910390a150565b6000606060055433600454604051602001808481526020018373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001935050505060405160208183030381529060405290507f66753cd2356569ee081232e3be8909b950e0a76c1f8460c3a5e3c2be32b11bed8d8d8d8d8d8d8d8d8d8d8d8c604051808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115612d5057fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018060200184810384528e8e82818152602001925080828437600081840152601f19601f820116905080830192505050848103835286818151815260200191508051906020019080838360005b83811015612e0a578082015181840152602081019050612def565b50505050905090810190601f168015612e375780820380516001836020036101000a031916815260200191505b50848103825285818151815260200191508051906020019080838360005b83811015612e70578082015181840152602081019050612e55565b50505050905090810190601f168015612e9d5780820380516001836020036101000a031916815260200191505b509f5050505050505050505050505050505060405180910390a1612eca8d8d8d8d8d8d8d8d8d8d8d614c9a565b9150509b9a5050505050505050505050565b6008602052816000526040600020602052806000526040600020600091509150505481565b6000600454905060008111612f7e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b612f8a84848484611bbe565b50505050565b6060600060035467ffffffffffffffff81118015612fad57600080fd5b50604051908082528060200260200182016040528015612fdc5781602001602082028036833780820191505090505b50905060008060026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614613130578083838151811061308757fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600260008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508180600101925050613046565b82935050505090565b60055481565b600080825160208401855af4806000523d6020523d600060403e60403d016000fd5b6131ac8a8a80806020026020016040519081016040528093929190818152602001838360200280828437600081840152601f19601f82011690508083019250505050505050896151d7565b600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16146131ea576131e9846156d7565b5b6132388787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050615706565b60008211156132525761325082600060018685615941565b505b3373ffffffffffffffffffffffffffffffffffffffff167f141df868a6331af528e38c83b7aa03edc19be66e37ae67f9285bf4f8e3c6a1a88b8b8b8b8960405180806020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281038252878782818152602001925060200280828437600081840152601f19601f820116905080830192505050965050505050505060405180910390a250505050505050505050565b6000805a9050613376878787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050865a615b47565b61337f57600080fd5b60005a8203905080604051602001808281526020019150506040516020818303038152906040526040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561340c5780820151818401526020810190506133f1565b50505050905090810190601f1680156134395780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b606060008267ffffffffffffffff8111801561346257600080fd5b506040519080825280602002602001820160405280156134915781602001602082028036833780820191505090505b509150600080600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156135645750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561356f57508482105b1561362a578084838151811061358157fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905081806001019250506134fa565b80925081845250509250929050565b600073ffffffffffffffffffffffffffffffffffffffff16600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16141561373b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001600860003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000838152602001908152602001600020819055503373ffffffffffffffffffffffffffffffffffffffff16817ff2a0eb156472d1440255b0d7c1e19cc07115d1051fe605b0dce69acfec884d9c60405160405180910390a350565b60006137ed8c8c8c8c8c8c8c8c8c8c8c614296565b8051906020012090509b9a5050505050505050505050565b61380d614989565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156138775750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b6138e9576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146139e9576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507faab4fa2b463f581b2b32cb3b7e3b704b9ce37cc209b5fb4d77e593ace405427681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613b9e614989565b60007f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b90508181557f1151116914515bc0891ff9047a6cb32cf902546f83066499bcf8ba33d2353fa282604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613c22614989565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614158015613c8c5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b8015613cc457503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b613d36576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614613e37576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614158015613ea15750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b613f13576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614013576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555080600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a17f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a1505050565b6000600454905090565b606060007fbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d860001b8d8d8d8d60405180838380828437808301925050509250505060405180910390208c8c8c8c8c8c8c604051602001808c81526020018b73ffffffffffffffffffffffffffffffffffffffff1681526020018a815260200189815260200188600181111561432757fe5b81526020018781526020018681526020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019b505050505050505050505050604051602081830303815290604052805190602001209050601960f81b600160f81b6143b361449f565b8360405160200180857effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168152600101847effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff191681526001018381526020018281526020019450505050506040516020818303038152906040529150509b9a5050505050505050505050565b614446614989565b61444f816156d7565b7f5ac6c46c93c8d0e53714ba3b53db3e7c046da994313d7ed0d192028bc7c228b081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b60007f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a7946921860001b6144cd6125e4565b30604051602001808481526020018381526020018273ffffffffffffffffffffffffffffffffffffffff168152602001935050505060405160208183030381529060405280519060200120905090565b614525614989565b8060016003540310156145a0576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161415801561460a5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b61467c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461477c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600360008154809291906001900391905055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a1806004541461494b5761494a81612b63565b5b505050565b6040518060400160405280600581526020017f312e332e3000000000000000000000000000000000000000000000000000000081525081565b3073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614614a2a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b565b600080831415614a3f5760009050614a60565b6000828402905082848281614a5057fe5b0414614a5b57600080fd5b809150505b92915050565b60008060008360410260208101860151925060408101860151915060ff60418201870151169350509250925092565b600080828401905083811015614aaa57600080fd5b8091505092915050565b6000600173ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614158015614b7f5750600073ffffffffffffffffffffffffffffffffffffffff16600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b614bf1576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b614bfe858585855a615b47565b90508015614c4e573373ffffffffffffffffffffffffffffffffffffffff167f6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb860405160405180910390a2614c92565b3373ffffffffffffffffffffffffffffffffffffffff167facd2c8702804128fdb0db2bb49f6d127dd0181c13fd45dbfe16de0930e2bd37560405160405180910390a25b949350505050565b6000806000614cb48e8e8e8e8e8e8e8e8e8e600554614296565b905060056000815480929190600101919050555080805190602001209150614cdd828286612f01565b506000614ce8615b93565b9050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614614ece578073ffffffffffffffffffffffffffffffffffffffff166375f0bb528f8f8f8f8f8f8f8f8f8f8f336040518d63ffffffff1660e01b8152600401808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115614d8b57fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018473ffffffffffffffffffffffffffffffffffffffff16815260200183810383528d8d82818152602001925080828437600081840152601f19601f820116905080830192505050838103825285818151815260200191508051906020019080838360005b83811015614e5d578082015181840152602081019050614e42565b50505050905090810190601f168015614e8a5780820380516001836020036101000a031916815260200191505b509e505050505050505050505050505050600060405180830381600087803b158015614eb557600080fd5b505af1158015614ec9573d6000803e3d6000fd5b505050505b6101f4614ef56109c48b01603f60408d0281614ee657fe5b04615bc490919063ffffffff16565b015a1015614f6b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60005a9050614fd48f8f8f8f8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050508e60008d14614fc9578e614fcf565b6109c45a035b615b47565b9350614fe95a82615bde90919063ffffffff16565b90508380614ff8575060008a14155b80615004575060008814155b615076576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808911156150905761508d828b8b8b8b615941565b90505b84156150da577f442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e8482604051808381526020018281526020019250505060405180910390a161511a565b7f23428b18acfb3ea64b08dc0c1d296ea9c09702c09083ca5272e64d115b687d238482604051808381526020018281526020019250505060405180910390a15b5050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146151c6578073ffffffffffffffffffffffffffffffffffffffff16639327136883856040518363ffffffff1660e01b815260040180838152602001821515815260200192505050600060405180830381600087803b1580156151ad57600080fd5b505af11580156151c1573d6000803e3d6000fd5b505050505b50509b9a5050505050505050505050565b60006004541461524f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b81518111156152c6576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600181101561533d576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006001905060005b835181101561564357600084828151811061535d57fe5b60200260200101519050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156153d15750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561540957503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561544157508073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614155b6154b3576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146155b4576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b80600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550809250508080600101915050615346565b506001600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550825160038190555081600481905550505050565b60007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b90508181555050565b600073ffffffffffffffffffffffffffffffffffffffff1660016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614615808576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001806000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161461593d576158ca8260008360015a615b47565b61593c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5050565b600080600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161461597e5782615980565b325b9050600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff161415615a98576159ea3a86106159c7573a6159c9565b855b6159dc888a614a9590919063ffffffff16565b614a2c90919063ffffffff16565b91508073ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f19350505050615a93576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b615b3d565b615abd85615aaf888a614a9590919063ffffffff16565b614a2c90919063ffffffff16565b9150615aca848284615bfe565b615b3c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5095945050505050565b6000600180811115615b5557fe5b836001811115615b6157fe5b1415615b7a576000808551602087018986f49050615b8a565b600080855160208701888a87f190505b95945050505050565b6000807f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b9050805491505090565b600081831015615bd45781615bd6565b825b905092915050565b600082821115615bed57600080fd5b600082840390508091505092915050565b60008063a9059cbb8484604051602401808373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001925050506040516020818303038152906040529060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050509050602060008251602084016000896127105a03f13d60008114615ca55760208114615cad5760009350615cb8565b819350615cb8565b600051158215171593505b505050939250505056fea2646970667358221220047fac33099ca576d1c4f1ac6a8abdb0396e42ad6a397d2cb2f4dc1624cc0c5b64736f6c63430007060033", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D": { + "balance": 0, + "code": "0x60806040526004361061001e5760003560e01c80638d80ff0a14610023575b600080fd5b6100dc6004803603602081101561003957600080fd5b810190808035906020019064010000000081111561005657600080fd5b82018360208201111561006857600080fd5b8035906020019184600183028401116401000000008311171561008a57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506100de565b005b805160205b8181101561015f578083015160f81c6001820184015160601c60158301850151603584018601516055850187016000856000811461012857600181146101385761013d565b6000808585888a5af1915061013d565b600080fd5b50600081141561014c57600080fd5b82605501870196505050505050506100e3565b50505056fea264697066735822122035246402746c96964495cae5b36461fd44dfb89f8e6cf6f6b8d60c0aa89f414864736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0x41675C099F32341bf84BFc5382aF534df5C7461a": { + "balance": 0, + "code": "0x6080604052600436106101d15760003560e01c8063affed0e0116100f7578063e19a9dd911610095578063f08a032311610064578063f08a03231461156b578063f698da25146115bc578063f8dc5dd9146115e7578063ffa1ad741461166257610226565b8063e19a9dd9146112bf578063e318b52b14611310578063e75235b8146113a1578063e86637db146113cc57610226565b8063cc2f8452116100d1578063cc2f84521461100c578063d4d9bdcd146110d9578063d8d11f7814611114578063e009cfde1461124e57610226565b8063affed0e014610d89578063b4faba0914610db4578063b63e800d14610e9c57610226565b80635624b25b1161016f5780636a7612021161013e5780636a761202146109895780637d83297414610b45578063934f3a1114610bb4578063a0e67e2b14610d1d57610226565b80635624b25b146107f05780635ae6bd37146108ae578063610b5925146108fd578063694e80c31461094e57610226565b80632f54bf6e116101ab5780632f54bf6e146104c85780633408e4701461052f578063468721a71461055a5780635229073f1461066f57610226565b80630d582f131461029357806312fb68e0146102ee5780632d9ad53d1461046157610226565b36610226573373ffffffffffffffffffffffffffffffffffffffff167f3d0ce9bfc3ed7d6862dbb28b2dea94561fe714a1b4d019aa8af39730d1ad7c3d346040518082815260200191505060405180910390a2005b34801561023257600080fd5b5060007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b905080548061026757600080f35b36600080373360601b365260008060143601600080855af13d6000803e8061028e573d6000fd5b3d6000f35b34801561029f57600080fd5b506102ec600480360360408110156102b657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506116f2565b005b3480156102fa57600080fd5b5061045f6004803603608081101561031157600080fd5b81019080803590602001909291908035906020019064010000000081111561033857600080fd5b82018360208201111561034a57600080fd5b8035906020019184600183028401116401000000008311171561036c57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803590602001906401000000008111156103cf57600080fd5b8201836020820111156103e157600080fd5b8035906020019184600183028401116401000000008311171561040357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190505050611ad8565b005b34801561046d57600080fd5b506104b06004803603602081101561048457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506123d6565b60405180821515815260200191505060405180910390f35b3480156104d457600080fd5b50610517600480360360208110156104eb57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506124a8565b60405180821515815260200191505060405180910390f35b34801561053b57600080fd5b5061054461257a565b6040518082815260200191505060405180910390f35b34801561056657600080fd5b506106576004803603608081101561057d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156105c457600080fd5b8201836020820111156105d657600080fd5b803590602001918460018302840111640100000000831117156105f857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff169060200190929190505050612587565b60405180821515815260200191505060405180910390f35b34801561067b57600080fd5b5061076c6004803603608081101561069257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156106d957600080fd5b8201836020820111156106eb57600080fd5b8035906020019184600183028401116401000000008311171561070d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff16906020019092919050505061278d565b60405180831515815260200180602001828103825283818151815260200191508051906020019080838360005b838110156107b4578082015181840152602081019050610799565b50505050905090810190601f1680156107e15780820380516001836020036101000a031916815260200191505b50935050505060405180910390f35b3480156107fc57600080fd5b506108336004803603604081101561081357600080fd5b8101908080359060200190929190803590602001909291905050506127c3565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015610873578082015181840152602081019050610858565b50505050905090810190601f1680156108a05780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b3480156108ba57600080fd5b506108e7600480360360208110156108d157600080fd5b810190808035906020019092919050505061284a565b6040518082815260200191505060405180910390f35b34801561090957600080fd5b5061094c6004803603602081101561092057600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612862565b005b34801561095a57600080fd5b506109876004803603602081101561097157600080fd5b8101908080359060200190929190505050612bea565b005b610b2d60048036036101408110156109a057600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156109e757600080fd5b8201836020820111156109f957600080fd5b80359060200191846001830284011164010000000083111715610a1b57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610aa757600080fd5b820183602082011115610ab957600080fd5b80359060200191846001830284011164010000000083111715610adb57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612d24565b60405180821515815260200191505060405180910390f35b348015610b5157600080fd5b50610b9e60048036036040811015610b6857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613253565b6040518082815260200191505060405180910390f35b348015610bc057600080fd5b50610d1b60048036036060811015610bd757600080fd5b810190808035906020019092919080359060200190640100000000811115610bfe57600080fd5b820183602082011115610c1057600080fd5b80359060200191846001830284011164010000000083111715610c3257600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190640100000000811115610c9557600080fd5b820183602082011115610ca757600080fd5b80359060200191846001830284011164010000000083111715610cc957600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050613278565b005b348015610d2957600080fd5b50610d32613307565b6040518080602001828103825283818151815260200191508051906020019060200280838360005b83811015610d75578082015181840152602081019050610d5a565b505050509050019250505060405180910390f35b348015610d9557600080fd5b50610d9e6134b0565b6040518082815260200191505060405180910390f35b348015610dc057600080fd5b50610e9a60048036036040811015610dd757600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610e1457600080fd5b820183602082011115610e2657600080fd5b80359060200191846001830284011164010000000083111715610e4857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506134b6565b005b348015610ea857600080fd5b5061100a6004803603610100811015610ec057600080fd5b8101908080359060200190640100000000811115610edd57600080fd5b820183602082011115610eef57600080fd5b80359060200191846020830284011164010000000083111715610f1157600080fd5b909192939192939080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610f5c57600080fd5b820183602082011115610f6e57600080fd5b80359060200191846001830284011164010000000083111715610f9057600080fd5b9091929391929390803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506134d8565b005b34801561101857600080fd5b506110656004803603604081101561102f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613696565b60405180806020018373ffffffffffffffffffffffffffffffffffffffff168152602001828103825284818151815260200191508051906020019060200280838360005b838110156110c45780820151818401526020810190506110a9565b50505050905001935050505060405180910390f35b3480156110e557600080fd5b50611112600480360360208110156110fc57600080fd5b81019080803590602001909291905050506139f9565b005b34801561112057600080fd5b50611238600480360361014081101561113857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561117f57600080fd5b82018360208201111561119157600080fd5b803590602001918460018302840111640100000000831117156111b357600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613b98565b6040518082815260200191505060405180910390f35b34801561125a57600080fd5b506112bd6004803603604081101561127157600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613bc5565b005b3480156112cb57600080fd5b5061130e600480360360208110156112e257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613f4c565b005b34801561131c57600080fd5b5061139f6004803603606081101561133357600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050614138565b005b3480156113ad57600080fd5b506113b6614796565b6040518082815260200191505060405180910390f35b3480156113d857600080fd5b506114f060048036036101408110156113f057600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561143757600080fd5b82018360208201111561144957600080fd5b8035906020019184600183028401116401000000008311171561146b57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506147a0565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015611530578082015181840152602081019050611515565b50505050905090810190601f16801561155d5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561157757600080fd5b506115ba6004803603602081101561158e57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050614948565b005b3480156115c857600080fd5b506115d161499f565b6040518082815260200191505060405180910390f35b3480156115f357600080fd5b506116606004803603606081101561160a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050614a1d565b005b34801561166e57600080fd5b50611677614e46565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156116b757808201518184015260208101905061169c565b50505050905090810190601f1680156116e45780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6116fa614e7f565b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156117645750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b801561179c57503073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b61180e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461190f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508160026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600081548092919060010191905055508173ffffffffffffffffffffffffffffffffffffffff167f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2660405160405180910390a28060045414611ad457611ad381612bea565b5b5050565b611aec604182614f2290919063ffffffff16565b82511015611b62576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808060008060005b868110156123ca57611b7e8882614f5c565b80945081955082965050505060008460ff1614156120035789898051906020012014611c12576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323700000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8260001c9450611c2c604188614f2290919063ffffffff16565b8260001c1015611ca4576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8751611cbd60208460001c614f8b90919063ffffffff16565b1115611d31576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006020838a01015190508851611d6782611d5960208760001c614f8b90919063ffffffff16565b614f8b90919063ffffffff16565b1115611ddb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60606020848b010190506320c13b0b60e01b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168773ffffffffffffffffffffffffffffffffffffffff166320c13b0b8d846040518363ffffffff1660e01b8152600401808060200180602001838103835285818151815260200191508051906020019080838360005b83811015611e7d578082015181840152602081019050611e62565b50505050905090810190601f168015611eaa5780820380516001836020036101000a031916815260200191505b50838103825284818151815260200191508051906020019080838360005b83811015611ee3578082015181840152602081019050611ec8565b50505050905090810190601f168015611f105780820380516001836020036101000a031916815260200191505b5094505050505060206040518083038186803b158015611f2f57600080fd5b505afa158015611f43573d6000803e3d6000fd5b505050506040513d6020811015611f5957600080fd5b81019080805190602001909291905050507bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614611ffc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5050612248565b60018460ff161415612117578260001c94508473ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614806120a057506000600860008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008c81526020019081526020016000205414155b612112576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b612247565b601e8460ff1611156121df5760018a60405160200180807f19457468657265756d205369676e6564204d6573736167653a0a333200000000815250601c018281526020019150506040516020818303038152906040528051906020012060048603858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156121ce573d6000803e3d6000fd5b505050602060405103519450612246565b60018a85858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa158015612239573d6000803e3d6000fd5b5050506020604051035194505b5b5b8573ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1611801561230f5750600073ffffffffffffffffffffffffffffffffffffffff16600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b80156123485750600173ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1614155b6123ba576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8495508080600101915050611b6c565b50505050505050505050565b60008173ffffffffffffffffffffffffffffffffffffffff16600173ffffffffffffffffffffffffffffffffffffffff16141580156124a15750600073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156125735750600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000804690508091505090565b6000600173ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141580156126525750600073ffffffffffffffffffffffffffffffffffffffff16600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b6126c4576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6126f1858585857fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff614faa565b90508015612741573373ffffffffffffffffffffffffffffffffffffffff167f6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb860405160405180910390a2612785565b3373ffffffffffffffffffffffffffffffffffffffff167facd2c8702804128fdb0db2bb49f6d127dd0181c13fd45dbfe16de0930e2bd37560405160405180910390a25b949350505050565b6000606061279d86868686612587565b915060405160203d0181016040523d81523d6000602083013e8091505094509492505050565b606060006020830267ffffffffffffffff811180156127e157600080fd5b506040519080825280601f01601f1916602001820160405280156128145781602001600182028036833780820191505090505b50905060005b8381101561283f5780850154806020830260208501015250808060010191505061281a565b508091505092915050565b60076020528060005260406000206000915090505481565b61286a614e7f565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156128d45750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b612946576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614612a47576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508073ffffffffffffffffffffffffffffffffffffffff167fecdf3a3effea5783a3c4c2140e677577666428d44ed9d474a0b3a4c9943f844060405160405180910390a250565b612bf2614e7f565b600354811115612c6a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001811015612ce1576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b806004819055507f610f7ff2b304ae8903c3de74c60c6ab1f7d6226b3f52c5161905bb5ad4039c936004546040518082815260200191505060405180910390a150565b6000806000612d3e8e8e8e8e8e8e8e8e8e8e6005546147a0565b905060056000815480929190600101919050555080805190602001209150612d67828286613278565b506000612d72614ff6565b9050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614612f58578073ffffffffffffffffffffffffffffffffffffffff166375f0bb528f8f8f8f8f8f8f8f8f8f8f336040518d63ffffffff1660e01b8152600401808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115612e1557fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018473ffffffffffffffffffffffffffffffffffffffff16815260200183810383528d8d82818152602001925080828437600081840152601f19601f820116905080830192505050838103825285818151815260200191508051906020019080838360005b83811015612ee7578082015181840152602081019050612ecc565b50505050905090810190601f168015612f145780820380516001836020036101000a031916815260200191505b509e505050505050505050505050505050600060405180830381600087803b158015612f3f57600080fd5b505af1158015612f53573d6000803e3d6000fd5b505050505b6101f4612f7f6109c48b01603f60408d0281612f7057fe5b0461502790919063ffffffff16565b015a1015612ff5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60005a905061305e8f8f8f8f8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050508e60008d14613053578e613059565b6109c45a035b614faa565b93506130735a8261504190919063ffffffff16565b90508380613082575060008a14155b8061308e575060008814155b613100576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60008089111561311a57613117828b8b8b8b615061565b90505b841561315d57837f442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e826040518082815260200191505060405180910390a2613196565b837f23428b18acfb3ea64b08dc0c1d296ea9c09702c09083ca5272e64d115b687d23826040518082815260200191505060405180910390a25b5050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614613242578073ffffffffffffffffffffffffffffffffffffffff16639327136883856040518363ffffffff1660e01b815260040180838152602001821515815260200192505050600060405180830381600087803b15801561322957600080fd5b505af115801561323d573d6000803e3d6000fd5b505050505b50509b9a5050505050505050505050565b6008602052816000526040600020602052806000526040600020600091509150505481565b60006004549050600081116132f5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b61330184848484611ad8565b50505050565b6060600060035467ffffffffffffffff8111801561332457600080fd5b506040519080825280602002602001820160405280156133535781602001602082028036833780820191505090505b50905060008060026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146134a757808383815181106133fe57fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600260008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905081806001019250506133bd565b82935050505090565b60055481565b600080825160208401855af4806000523d6020523d600060403e60403d016000fd5b6135238a8a80806020026020016040519081016040528093929190818152602001838360200280828437600081840152601f19601f8201169050808301925050505050505089615267565b600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16146135615761356084615767565b5b6135af8787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050615838565b60008211156135c9576135c782600060018685615061565b505b3373ffffffffffffffffffffffffffffffffffffffff167f141df868a6331af528e38c83b7aa03edc19be66e37ae67f9285bf4f8e3c6a1a88b8b8b8b8960405180806020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281038252878782818152602001925060200280828437600081840152601f19601f820116905080830192505050965050505050505060405180910390a250505050505050505050565b60606000600173ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff1614806136da57506136d9846123d6565b5b61374c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600083116137c2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8267ffffffffffffffff811180156137d957600080fd5b506040519080825280602002602001820160405280156138085781602001602082028036833780820191505090505b5091506000600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1691505b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156138da5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b80156138e557508381105b156139a057818382815181106138f757fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1691508080600101915050613870565b600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16146139ee578260018203815181106139e357fe5b602002602001015191505b808352509250929050565b600073ffffffffffffffffffffffffffffffffffffffff16600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161415613afb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001600860003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000838152602001908152602001600020819055503373ffffffffffffffffffffffffffffffffffffffff16817ff2a0eb156472d1440255b0d7c1e19cc07115d1051fe605b0dce69acfec884d9c60405160405180910390a350565b6000613bad8c8c8c8c8c8c8c8c8c8c8c6147a0565b8051906020012090509b9a5050505050505050505050565b613bcd614e7f565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614158015613c375750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b613ca9576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614613da9576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508073ffffffffffffffffffffffffffffffffffffffff167faab4fa2b463f581b2b32cb3b7e3b704b9ce37cc209b5fb4d77e593ace405427660405160405180910390a25050565b613f54614e7f565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146140c6578073ffffffffffffffffffffffffffffffffffffffff166301ffc9a77fe6d7a83a000000000000000000000000000000000000000000000000000000006040518263ffffffff1660e01b815260040180827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916815260200191505060206040518083038186803b15801561401857600080fd5b505afa15801561402c573d6000803e3d6000fd5b505050506040513d602081101561404257600080fd5b81019080805190602001909291905050506140c5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475333303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b60007f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b90508181558173ffffffffffffffffffffffffffffffffffffffff167f1151116914515bc0891ff9047a6cb32cf902546f83066499bcf8ba33d2353fa260405160405180910390a25050565b614140614e7f565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156141aa5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b80156141e257503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b614254576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614355576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156143bf5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b614431576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614531576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555080600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508173ffffffffffffffffffffffffffffffffffffffff167ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf60405160405180910390a28073ffffffffffffffffffffffffffffffffffffffff167f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2660405160405180910390a2505050565b6000600454905090565b606060007fbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d860001b8d8d8d8d60405180838380828437808301925050509250505060405180910390208c8c8c8c8c8c8c604051602001808c81526020018b73ffffffffffffffffffffffffffffffffffffffff1681526020018a815260200189815260200188600181111561483157fe5b81526020018781526020018681526020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019b505050505050505050505050604051602081830303815290604052805190602001209050601960f81b600160f81b6148bd61499f565b8360405160200180857effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168152600101847effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff191681526001018381526020018281526020019450505050506040516020818303038152906040529150509b9a5050505050505050505050565b614950614e7f565b61495981615767565b8073ffffffffffffffffffffffffffffffffffffffff167f5ac6c46c93c8d0e53714ba3b53db3e7c046da994313d7ed0d192028bc7c228b060405160405180910390a250565b60007f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a7946921860001b6149cd61257a565b30604051602001808481526020018381526020018273ffffffffffffffffffffffffffffffffffffffff168152602001935050505060405160208183030381529060405280519060200120905090565b614a25614e7f565b806001600354031015614aa0576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614158015614b0a5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b614b7c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614c7c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600360008154809291906001900391905055508173ffffffffffffffffffffffffffffffffffffffff167ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf60405160405180910390a28060045414614e4157614e4081612bea565b5b505050565b6040518060400160405280600581526020017f312e342e3100000000000000000000000000000000000000000000000000000081525081565b3073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614614f20576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b565b600080831415614f355760009050614f56565b6000828402905082848281614f4657fe5b0414614f5157600080fd5b809150505b92915050565b60008060008360410260208101860151925060408101860151915060ff60418201870151169350509250925092565b600080828401905083811015614fa057600080fd5b8091505092915050565b6000600180811115614fb857fe5b836001811115614fc457fe5b1415614fdd576000808551602087018986f49050614fed565b600080855160208701888a87f190505b95945050505050565b6000807f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b9050805491505090565b6000818310156150375781615039565b825b905092915050565b60008282111561505057600080fd5b600082840390508091505092915050565b600080600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161461509e57826150a0565b325b9050600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff1614156151b85761510a3a86106150e7573a6150e9565b855b6150fc888a614f8b90919063ffffffff16565b614f2290919063ffffffff16565b91508073ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f193505050506151b3576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b61525d565b6151dd856151cf888a614f8b90919063ffffffff16565b614f2290919063ffffffff16565b91506151ea848284615b0e565b61525c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5095945050505050565b6000600454146152df576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8151811115615356576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60018110156153cd576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006001905060005b83518110156156d35760008482815181106153ed57fe5b60200260200101519050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156154615750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561549957503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b80156154d157508073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614155b615543576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614615644576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b80600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508092505080806001019150506153d6565b506001600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550825160038190555081600481905550505050565b3073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415615809576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475334303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b90508181555050565b600073ffffffffffffffffffffffffffffffffffffffff1660016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461593a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001806000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614615b0a576159f682615bd2565b615a68576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b615a978260008360017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff614faa565b615b09576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5050565b60008063a9059cbb8484604051602401808373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001925050506040516020818303038152906040529060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050509050602060008251602084016000896127105a03f13d60008114615bb55760208114615bbd5760009350615bc8565b819350615bc8565b600051158215171593505b5050509392505050565b600080823b90506000811191505091905056fea264697066735822122057398fa72884cf9a6cb78aab2fb58a6b927f0e9d97d75b015daaee0959a153bf64736f6c63430007060033", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108": { + "balance": 0, + "code": "0x6101606040526004361015610024575b3615610019575f80fd5b610022336131f4565b005b5f610140525f3560e01c806242dc53146125d957806301ffc9a7146124875780630396cb60146120cc57806309ccb8801461205b5780630bd28e3b14611fbf57806313c65a6e14611f84578063154e58dc14611f295780631b2e01b814611e93578063205c287814611cf257806322cdde4c14611c6e57806335567e1a14611bb45780635287ce1214611a9457806370a0823114611a29578063765e827f1461198b57806384b0196e1461184b578063850aaf62146117865780639b249f6914611622578063b760faf9146115e1578063bb9fe6bf146113f2578063c23a5cea1461114f5763dbed18e00361000f5734610ec95761012136612d56565b6101005260e052610130613824565b6101405190815b60e0518110610f2e575061014a8261303a565b61012052610140516080526101405160c0525b60e05160c0511061029b577fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f9726101405161014051a161014051608081905290815b60e05181106101e1576101b48361010051614a19565b610140517f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005d6101405180f35b6102436101f18260e05185613267565b73ffffffffffffffffffffffffffffffffffffffff610212602083016132fb565b167f575ff3acadd5ab348fe1855e217e0f3678f8d767d7494c9f9fefbee2e17cca4d6101405161014051a2806132a7565b9061014051915b80831061025c5750505060010161019e565b909194600190610289610270888587613109565b61027f60805161012051613176565b519060805161437c565b0195816080510160805201919061024a565b6102aa60c05160e05183613267565b73ffffffffffffffffffffffffffffffffffffffff6102d860206102ce84806132a7565b60a05293016132fb565b61014051911691905b60a05181106103055750505060a05160805101608052600160c0510160c05261015d565b610316816080510161012051613176565b516103248260a05185613109565b61014051915a81519273ffffffffffffffffffffffffffffffffffffffff61034b826132fb565b168452602081810135908501526fffffffffffffffffffffffffffffffff6080808301358281166060880152811c604087015260a083013560c0808801919091528301359182166101008701521c6101208501526103ac60e082018261331c565b9081610e15575b5050604051936103c282612ee9565b6020850152846040526040810151946effffffffffffffffffffffffffffff8660c08401511760608401511760808401511760a084015117610100840151176101208401511711610daf5750604081015160608201510160808201510160a08201510160c0820151016101008201510294856040860152845173ffffffffffffffffffffffffffffffffffffffff60e08183511692610475898d61046960408b018b61331c565b92909160805101614fb5565b0151169661014051978015610d7e575b87516040810151905173ffffffffffffffffffffffffffffffffffffffff169061014051506040519a8b8960208d01519260208301937f19822f7c00000000000000000000000000000000000000000000000000000000855260248401926104ec93615460565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081018d5261051c908d612c2d565b61014051908c5190846101405190602095f161014051519a3d602003610d73575b60405215610c80575015610c02575b505073ffffffffffffffffffffffffffffffffffffffff825116602083015190610140515260016020526040610140512077ffffffffffffffffffffffffffffffffffffffffffffffff8260401c165f5260205267ffffffffffffffff60405f20918254926105ba84612e80565b90551603610b99575a840311610b305760e0015160609073ffffffffffffffffffffffffffffffffffffffff16610827575b73ffffffffffffffffffffffffffffffffffffffff949260a0859360809360606106219801520135905a900301910152614f15565b911685036107be576107555761064b73ffffffffffffffffffffffffffffffffffffffff91614f15565b91166106ec5761065d576001016102e1565b60a490604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152602160448201527f41413332207061796d61737465722065787069726564206f72206e6f7420647560648201527f65000000000000000000000000000000000000000000000000000000000000006084820152fd5b608482604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601460448201527f41413334207369676e6174757265206572726f720000000000000000000000006064820152fd5b608482604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601760448201527f414132322065787069726564206f72206e6f74206475650000000000000000006064820152fd5b608483604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601460448201527f41413234207369676e6174757265206572726f720000000000000000000000006064820152fd5b9897969594505a9883519961085b73ffffffffffffffffffffffffffffffffffffffff60e08d015116604087015190615482565b15610ac75760807f52b7512c000000000000000000000000000000000000000000000000000000009798999a9b01516040516108dc816108b060208a015160408b015190602084019d8e528960248501615460565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101835282612c2d565b8651608073ffffffffffffffffffffffffffffffffffffffff60e08301511691015161014051918b61014051928551926101405191f1983d908161014051843e51948251604084019b8c519015918215610abb575b508115610a8b575b50610a065750601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe09101160191826040525a90031161097a5750946105ec565b80887f220266b60000000000000000000000000000000000000000000000000000000060a4935260805101600482015260406024820152602760448201527f41413336206f766572207061796d6173746572566572696669636174696f6e4760648201527f61734c696d6974000000000000000000000000000000000000000000000000006084820152fd5b8b610a87610a1261349e565b6040519384937f65c8fd4d0000000000000000000000000000000000000000000000000000000085526080510160048501526024840152600d60648401527f4141333320726576657274656400000000000000000000000000000000000000608484015260a0604484015260a4830190612de9565b0390fd5b9050601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa084019101105f610939565b6040141591505f610931565b608487604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601e60448201527f41413331207061796d6173746572206465706f73697420746f6f206c6f7700006064820152fd5b608487604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601e60448201527f41413236206f76657220766572696669636174696f6e4761734c696d697400006064820152fd5b608488604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601a60448201527f4141323520696e76616c6964206163636f756e74206e6f6e63650000000000006064820152fd5b610c0b91615482565b15610c17578b8061054c565b608488604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601760448201527f41413231206469646e2774207061792070726566756e640000000000000000006064820152fd5b8b903b610cf057608490604051907f220266b600000000000000000000000000000000000000000000000000000000825260805101600482015260406024820152601960448201527f41413230206163636f756e74206e6f74206465706c6f796564000000000000006064820152fd5b610cf861349e565b90610a876040519283927f65c8fd4d00000000000000000000000000000000000000000000000000000000845260805101600484015260606024840152600d60648401527f4141323320726576657274656400000000000000000000000000000000000000608484015260a0604484015260a4830190612de9565b61014051915061053d565b6101408051849052516020819052604090205490985081811115610da85750610140515b97610485565b8103610da2565b80887f220266b6000000000000000000000000000000000000000000000000000000006084935260805101600482015260406024820152601860448201527f41413934206761732076616c756573206f766572666c6f7700000000000000006064820152fd5b60348210610ed05781601411610ec95780359160248110610ec957603411610ec9576024810135608090811c60a0880152601490910135811c90860152606081901c15610e6b5760601c60e085015289806103b3565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601660248201527f4141393820696e76616c6964207061796d6173746572000000000000000000006044820152fd5b6101405180fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f4141393320696e76616c6964207061796d6173746572416e64446174610000006044820152fd5b610f3b8160e05184613267565b92610f4684806132a7565b919073ffffffffffffffffffffffffffffffffffffffff610f69602088016132fb565b16956001871461111d5786610f86575b5050019250600101610137565b806040610f9492019061331c565b91873b15610ec957916040519283917f2dd8113300000000000000000000000000000000000000000000000000000000835286604484016040600486015252606483019160648860051b8501019281610140517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee182360301915b8b82106110c357505050505081611054917ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8580950301602485015261014051956131b6565b0381610140518a5af190816110a8575b5061109b57847f86a9f750000000000000000000000000000000000000000000000000000000006101405152600452602461014051fd5b929350839260015f610f79565b610140516110b591612c2d565b61014051610ec9575f611064565b9193967fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9c90879294969703018552863584811215610ec957602061110c600193858394016133bd565b98019501920188969594939161100e565b867f86a9f750000000000000000000000000000000000000000000000000000000006101405152600452602461014051fd5b34610ec95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec957611186612cde565b3361014051526101405160205260016040610140512001908154916dffffffffffffffffffffffffffff8360081c169283156113945760981c65ffffffffffff1680156113365742106112d85780547fffffffffffffff000000000000000000000000000000000000000000000000ff1690556040805173ffffffffffffffffffffffffffffffffffffffff831681526020810184905233917fb7c918e0e249f999e965cafeb6c664271b3f4317d296461500e71da39f0cbda391a2610140519182918291829173ffffffffffffffffffffffffffffffffffffffff165af161126d612eba565b501561127a576101405180f35b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f6661696c656420746f207769746864726177207374616b6500000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601b60248201527f5374616b65207769746864726177616c206973206e6f742064756500000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f6d7573742063616c6c20756e6c6f636b5374616b6528292066697273740000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f4e6f207374616b6520746f2077697468647261770000000000000000000000006044820152fd5b34610ec957610140517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec9573361014051526101405160205260016040610140512001805463ffffffff8160781c169081156115835760ff16156115255765ffffffffffff4216019065ffffffffffff82116114f25780547fffffffffffffff000000000000ffffffffffffffffffffffffffffffffffff001678ffffffffffff00000000000000000000000000000000000000609884901b1617905560405165ffffffffffff909116815233907ffa9b3c14cc825c412c9ed81b3ba365a5b459439403f18829e572ed53a4180f0a90602090a26101405180f35b7f4e487b710000000000000000000000000000000000000000000000000000000061014051526011600452602461014051fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f616c726561647920756e7374616b696e670000000000000000000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600a60248201527f6e6f74207374616b6564000000000000000000000000000000000000000000006044820152fd5b60207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95761161b611616612cde565b6131f4565b6101405180f35b34610ec95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95760043567ffffffffffffffff8111610ec95760206116766116b1923690600401612d01565b60405193849283927f570e1a3600000000000000000000000000000000000000000000000000000000845285600485015260248401916131b6565b03816101405173ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000449ed7c3e6fee6a97311d4b55475df59c44add33165af180156117785773ffffffffffffffffffffffffffffffffffffffff916101405191611749575b507f6ca7b80600000000000000000000000000000000000000000000000000000000610140515216600452602461014051fd5b61176b915060203d602011611771575b6117638183612c2d565b81019061318a565b82611716565b503d611759565b6040513d61014051823e3d90fd5b34610ec95760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec9576117bd612cde565b60243567ffffffffffffffff8111610ec9576117dd903690600401612d01565b604051929181908437820190610140518252610140519280610140519303915af4611806612eba565b90610a876040519283927f9941055400000000000000000000000000000000000000000000000000000000845215156004840152604060248401526044830190612de9565b34610ec957610140517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec9576119296118a87f4552433433333700000000000000000000000000000000000000000000000007614ccf565b6118d17f3100000000000000000000000000000000000000000000000000000000000001614e45565b60405190602090611937906118e68385612c2d565b6101405184525f3681376040519586957f0f00000000000000000000000000000000000000000000000000000000000000875260e08588015260e0870190612de9565b908582036040870152612de9565b4660608501523060808501526101405160a085015283810360c0850152818084519283815201930191610140515b82811061197457505050500390f35b835185528695509381019392810192600101611965565b34610ec95761199936612d56565b6119a4929192613824565b6119ad8361303a565b6119b8818585613898565b5061014051927fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f9728480a161014051915b8583106119f9576101b48585614a19565b909193600190611a1f611a0d878987613109565b611a178886613176565b51908861437c565b01940191906119e8565b34610ec95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95773ffffffffffffffffffffffffffffffffffffffff611a75612cde565b1661014051526101405160205260206040610140512054604051908152f35b34610ec95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95773ffffffffffffffffffffffffffffffffffffffff611ae0612cde565b604051611aec81612bab565b6101405181526101405160208201526101405160408201526101405160608201526080610140519101521661014051526101405160205260a06040610140512065ffffffffffff604051611b3f81612bab565b63ffffffff60018454948584520154916dffffffffffffffffffffffffffff6020820160ff8516151581526040830190828660081c1682528660806060860195878960781c168752019660981c1686526040519788525115156020880152511660408601525116606084015251166080820152f35b34610ec95760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec9576020611bed612cde565b73ffffffffffffffffffffffffffffffffffffffff611c0a612d2f565b91166101405152600182526040610140512077ffffffffffffffffffffffffffffffffffffffffffffffff82165f52825260405f20547fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000006040519260401b16178152f35b34610ec95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95760043567ffffffffffffffff8111610ec9576101207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8236030112610ec957611cea602091600401612ee9565b604051908152f35b34610ec95760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec957611d29612cde565b6024359033610140515261014051602052604061014051208054808411611e355783611d5491612ead565b90556040805173ffffffffffffffffffffffffffffffffffffffff831681526020810184905233917fd1c19fbcd4551a5edfb66d43d2e337c04837afda3482b42bdf569a8fccdae5fb91a2610140519182918291829173ffffffffffffffffffffffffffffffffffffffff165af1611dca612eba565b5015611dd7576101405180f35b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f6661696c656420746f20776974686472617700000000000000000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601960248201527f576974686472617720616d6f756e7420746f6f206c61726765000000000000006044820152fd5b34610ec95760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec957611eca612cde565b73ffffffffffffffffffffffffffffffffffffffff611ee7612d2f565b91166101405152600160205277ffffffffffffffffffffffffffffffffffffffffffffffff6040610140512091165f52602052602060405f2054604051908152f35b34610ec957610140517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95760206040517f29a0bca4af4be3421398da00295e58e6d7de38cb492214754cb6a47507dd6f8e8152f35b34610ec957610140517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec9576020611cea6134cb565b34610ec95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95760043577ffffffffffffffffffffffffffffffffffffffffffffffff81168103610ec957336101405152600160205277ffffffffffffffffffffffffffffffffffffffffffffffff6040610140512091165f5260205260405f206120528154612e80565b90556101405180f35b34610ec957610140517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec957602060405173ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000449ed7c3e6fee6a97311d4b55475df59c44add33168152f35b60207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec95760043563ffffffff8116809103610ec957336101405152610140516020526040610140512090801561242957600182015463ffffffff8160781c1682106123cb57612155906dffffffffffffffffffffffffffff349160081c16612e46565b91821561236d576dffffffffffffffffffffffffffff831161230f57546040516122d79161218282612bab565b815265ffffffffffff602082019160018352604081016dffffffffffffffffffffffffffff87168152606082019086825260016080840193610140518552336101405152610140516020526040610140512090518155019451151560ff7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff008754169116178555517fffffffffffffffffffffffffffffffffff0000000000000000000000000000ff6effffffffffffffffffffffffffff008087549360081b16169116178455517fffffffffffffffffffffffffff00000000ffffffffffffffffffffffffffffff72ffffffff0000000000000000000000000000008086549360781b1616911617835551167fffffffffffffff000000000000ffffffffffffffffffffffffffffffffffffff78ffffffffffff0000000000000000000000000000000000000083549260981b169116179055565b60405191825260208201527fa5ae833d0bb1dcd632d98a8b70973e8516812898e19bf27b70071ebc8dc52c0160403392a26101405180f35b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600e60248201527f7374616b65206f766572666c6f770000000000000000000000000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f6e6f207374616b652073706563696669656400000000000000000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f63616e6e6f7420646563726561736520756e7374616b652074696d65000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f6d757374207370656369667920756e7374616b652064656c61790000000000006044820152fd5b34610ec95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610ec9576004357fffffffff000000000000000000000000000000000000000000000000000000008116809103610ec957807f6930d3ee00000000000000000000000000000000000000000000000000000000602092149081156125af575b8115612585575b811561255b575b8115612531575b506040519015158152f35b7f01ffc9a70000000000000000000000000000000000000000000000000000000091501482612526565b7f3e84f021000000000000000000000000000000000000000000000000000000008114915061251f565b7fcf28ef970000000000000000000000000000000000000000000000000000000081149150612518565b7f989ccc580000000000000000000000000000000000000000000000000000000081149150612511565b34612a32576102007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112612a325760043567ffffffffffffffff8111612a325736602382011215612a325761263a903690602481600401359101612ca8565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc36016101c08112612a32576101406040519161267683612bab565b12612a325760405161268781612bf4565b60243573ffffffffffffffffffffffffffffffffffffffff81168103612a3257815260443560208201526064356040820152608435606082015260a435608082015260c43560a082015260e43560c08201526101043573ffffffffffffffffffffffffffffffffffffffff81168103612a325760e082015261012435610100820152610144356101208201528152602081019161016435835260408201906101843582526101a435606084015260808301916101c43583526101e43567ffffffffffffffff8111612a3257612760903690600401612d01565b955a90303303612b4d578651606081015195603f5a0260061c61271060a084015189010111612b25575f9681519182612a6b575b5050505050906127ac915a9003855101963691612ca8565b925a93855161010081015161012082015148018082105f14612a635750975b6127f873ffffffffffffffffffffffffffffffffffffffff60e08401511694518203606084015190614b09565b01925f928161290e5750505173ffffffffffffffffffffffffffffffffffffffff16945b5a900301019485029051928184105f146128ba5750506003811015612887576002036128595760209281611cea929361285481614c2a565b614b28565b7fdeadaa51000000000000000000000000000000000000000000000000000000006101405152602061014051fd5b7f4e487b710000000000000000000000000000000000000000000000000000000061014051526021600452602461014051fd5b816128f0929594969396039073ffffffffffffffffffffffffffffffffffffffff165f525f60205260405f209081540180915590565b5060038410156128875782612909926020951590614ba9565b611cea565b909691878251612921575b50505061281c565b90919293505a926003881015612a365760028803612957575b505060a061294e925a900391015190614b09565b90888080612919565b60a083015191803b15612a32578b925f92836129b3938c8b88604051998a98899788957f7c627b210000000000000000000000000000000000000000000000000000000087526004870152608060248701526084860190612de9565b9202604484015260648301520393f19081612a1d575b50612a1357610a876129d961349e565b6040519182917fad7954bc000000000000000000000000000000000000000000000000000000008352602060048401526024830190612de9565b60a061294e61293a565b5f612a2791612c2d565b5f610140528a6129c9565b5f80fd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602160045260245ffd5b9050976127cb565b915f9291838093602073ffffffffffffffffffffffffffffffffffffffff885116910192f115612a9e575b808080612794565b6127ac9392955060405191612ab161349e565b908151612aca575b505050604052600193909188612a96565b7f1c4fada7374c0a9ee8841fc38afe82932dc0f8e69012e927f061a8bae611a201905191602073ffffffffffffffffffffffffffffffffffffffff855116940151612b1a60405192839283612e2c565b0390a3888080612ab9565b7fdeaddead000000000000000000000000000000000000000000000000000000005f5260205ffd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4141393220696e7465726e616c2063616c6c206f6e6c790000000000000000006044820152fd5b60a0810190811067ffffffffffffffff821117612bc757604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b610140810190811067ffffffffffffffff821117612bc757604052565b6060810190811067ffffffffffffffff821117612bc757604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117612bc757604052565b67ffffffffffffffff8111612bc757601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200190565b929192612cb482612c6e565b91612cc26040519384612c2d565b829481845281830111612a32578281602093845f960137010152565b6004359073ffffffffffffffffffffffffffffffffffffffff82168203612a3257565b9181601f84011215612a325782359167ffffffffffffffff8311612a325760208381860195010111612a3257565b6024359077ffffffffffffffffffffffffffffffffffffffffffffffff82168203612a3257565b9060407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc830112612a325760043567ffffffffffffffff8111612a325760040182601f82011215612a325780359267ffffffffffffffff8411612a32576020808301928560051b010111612a3257919060243573ffffffffffffffffffffffffffffffffffffffff81168103612a325790565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f602080948051918291828752018686015e5f8582860101520116010190565b604090612e43939281528160208201520190612de9565b90565b91908201809211612e5357565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114612e535760010190565b91908203918211612e5357565b3d15612ee4573d90612ecb82612c6e565b91612ed96040519384612c2d565b82523d5f602084013e565b606090565b604290612ef5816135f4565b612efd6134cb565b91612f07816132fb565b91801561300557905b60c0612f1f606083018361331c565b90816040519182372091612f3660e082018261331c565b908160405191823720926040519473ffffffffffffffffffffffffffffffffffffffff60208701977f29a0bca4af4be3421398da00295e58e6d7de38cb492214754cb6a47507dd6f8e895216604087015260208301356060870152608086015260a085015260808101358285015260a081013560e085015201356101008301526101208201526101208152612fcd61014082612c2d565b519020604051917f19010000000000000000000000000000000000000000000000000000000000008352600283015260228201522090565b50613013604082018261331c565b90816040519182372090612f10565b67ffffffffffffffff8111612bc75760051b60200190565b9061304482613022565b6130516040519182612c2d565b8281527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe061307f8294613022565b01905f5b82811061308f57505050565b60209060405161309e81612bab565b6040516130aa81612bf4565b5f81525f848201525f60408201525f60608201525f60808201525f60a08201525f60c08201525f60e08201525f6101008201525f61012082015281525f838201525f60408201525f60608201525f608082015282828501015201613083565b91908110156131495760051b810135907ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee181360301821215612a32570190565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52603260045260245ffd5b80518210156131495760209160051b010190565b90816020910312612a32575173ffffffffffffffffffffffffffffffffffffffff81168103612a325790565b601f82602094937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe093818652868601375f8582860101520116010190565b7f2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4602073ffffffffffffffffffffffffffffffffffffffff61325b348573ffffffffffffffffffffffffffffffffffffffff165f525f60205260405f209081540180915590565b936040519485521692a2565b91908110156131495760051b810135907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa181360301821215612a32570190565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301821215612a32570180359067ffffffffffffffff8211612a3257602001918160051b36038313612a3257565b3573ffffffffffffffffffffffffffffffffffffffff81168103612a325790565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301821215612a32570180359067ffffffffffffffff8211612a3257602001918136038313612a3257565b90357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe182360301811215612a3257016020813591019167ffffffffffffffff8211612a32578136038313612a3257565b80359173ffffffffffffffffffffffffffffffffffffffff83168303612a325773ffffffffffffffffffffffffffffffffffffffff612e43931681526020820135602082015261348f61348361344a61342f61341c604087018761336d565b61012060408801526101208701916131b6565b61343c606087018761336d565b9086830360608801526131b6565b6080850135608085015260a085013560a085015260c085013560c085015261347560e086018661336d565b9085830360e08701526131b6565b9261010081019061336d565b916101008185039101526131b6565b3d61080081116134c2575b604051906020818301016040528082525f602083013e90565b506108006134a9565b73ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000004337084d9e255ff0702461cf8895ce9e3b5ff108163014806135cb575b15613533577f55c746e6d2d7c474cad02675e915385ef537dd92963aa9ab59f3921fe059620290565b60405160208101907f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f82527f364da28a5c92bcc87fe97c8813a6c6b8a3a049b0ea0a328fcb0b4f0e0033758660408201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608201524660808201523060a082015260a081526135c560c082612c2d565b51902090565b507f0000000000000000000000000000000000000000000000000000000000001237461461350a565b613601604082018261331c565b909161360d8284614c7a565b1561381d5761361b906132fb565b60175f80833c5f51907fef010000000000000000000000000000000000000000000000000000000000007fffffff000000000000000000000000000000000000000000000000000000000083160361375b575060181b91601482116136bb5750506040517fffffffffffffffffffffffffffffffffffffffff000000000000000000000000808060208401941616168252601481526135c5603482612c2d565b81601411612a325760206135c5916040519384917fffffffffffffffffffffffffffffffffffffffff000000000000000000000000808086860199161616875260147fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec83019101603484013781015f8382015203017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101835282612c2d565b3b156137bf5760646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f6e6f7420616e204549502d373730322064656c656761746500000000000000006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f73656e64657220686173206e6f20636f646500000000000000000000000000006044820152fd5b5050505f90565b7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005c6138705760017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005d565b7f3ee5aeb5000000000000000000000000000000000000000000000000000000005f5260045ffd5b92919092835f5b8181106138ac5750505050565b6138b68185613176565b516138c2828486613109565b5f915a81519273ffffffffffffffffffffffffffffffffffffffff6138e6826132fb565b168452602081013560208501526080810135936fffffffffffffffffffffffffffffffff8560801c951694604082019060608301968752815260c0820160a0840135815260c0840135906fffffffffffffffffffffffffffffffff8260801c9216916101208501906101008601938452815261396560e087018761331c565b9081614316575b505060405161397a87612ee9565b9960208a019a8b528160405285519586855117825117926effffffffffffffffffffffffffffff60808a01948551179560a08b0196875117895117905117116142b45750519051019051019051019051019051029560408601918783528973ffffffffffffffffffffffffffffffffffffffff60e08951613a0f8b8483511695613a0760408d018d61331c565b929091614fb5565b015116985f99801561428d575b89516040810151905173ffffffffffffffffffffffffffffffffffffffff1680916040519d8e808d8b519360208301947f19822f7c0000000000000000000000000000000000000000000000000000000086526024840192613a7d93615460565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081018252613aad9082612c2d565b51905f6020948194f15f519c3d602003614285575b6040521561419a575015614120575b505073ffffffffffffffffffffffffffffffffffffffff8451166020850151905f52600160205260405f2077ffffffffffffffffffffffffffffffffffffffffffffffff8260401c165f5260205267ffffffffffffffff60405f2091825492613b3984612e80565b905516036140bb575a8603116140565773ffffffffffffffffffffffffffffffffffffffff60e0606094015116613d96575b505073ffffffffffffffffffffffffffffffffffffffff949260a085936080936060613ba29801520135905a900301910152614f15565b9116613d3157613ccc57613bca73ffffffffffffffffffffffffffffffffffffffff91614f15565b9116613c6757613bdc5760010161389f565b60a490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602160448201527f41413332207061796d61737465722065787069726564206f72206e6f7420647560648201527f65000000000000000000000000000000000000000000000000000000000000006084820152fd5b608482604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601460448201527f41413334207369676e6174757265206572726f720000000000000000000000006064820152fd5b608482604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601760448201527f414132322065787069726564206f72206e6f74206475650000000000000000006064820152fd5b608483604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601460448201527f41413234207369676e6174757265206572726f720000000000000000000000006064820152fd5b909c9b9a99989796505a9085519d60e08f015173ffffffffffffffffffffffffffffffffffffffff168151613dca91615482565b15613ff157613e1d7f52b7512c00000000000000000000000000000000000000000000000000000000999a9b9c9d9e9f60800151926108b060405193849251905190602084019d8e528960248501615460565b5f8088518b82608073ffffffffffffffffffffffffffffffffffffffff60e08501511693015192865193f1983d90815f843e51948251604084019b8c519015918215613fe5575b508115613fb5575b50613f385750601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe09101160191826040525a900311613eb05750948260a0613b6b565b80887f220266b60000000000000000000000000000000000000000000000000000000060a49352600482015260406024820152602760448201527f41413336206f766572207061796d6173746572566572696669636174696f6e4760648201527f61734c696d6974000000000000000000000000000000000000000000000000006084820152fd5b8b610a87613f4461349e565b6040519384937f65c8fd4d00000000000000000000000000000000000000000000000000000000855260048501526024840152600d60648401527f4141333320726576657274656400000000000000000000000000000000000000608484015260a0604484015260a4830190612de9565b9050601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa084019101105f613e6c565b6040141591505f613e64565b608489604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601e60448201527f41413331207061796d6173746572206465706f73697420746f6f206c6f7700006064820152fd5b608489604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601e60448201527f41413236206f76657220766572696669636174696f6e4761734c696d697400006064820152fd5b60848a604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601a60448201527f4141323520696e76616c6964206163636f756e74206e6f6e63650000000000006064820152fd5b61412991615482565b15614135575f80613ad1565b60848a604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601760448201527f41413231206469646e2774207061792070726566756e640000000000000000006064820152fd5b8d903b61420657608490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601960448201527f41413230206163636f756e74206e6f74206465706c6f796564000000000000006064820152fd5b61420e61349e565b90610a876040519283927f65c8fd4d000000000000000000000000000000000000000000000000000000008452600484015260606024840152600d60648401527f4141323320726576657274656400000000000000000000000000000000000000608484015260a0604484015260a4830190612de9565b5f9150613ac2565b9950815f525f60205260405f20548181115f146142ad57505f5b99613a1c565b81036142a7565b808f7f220266b60000000000000000000000000000000000000000000000000000000060849352600482015260406024820152601860448201527f41413934206761732076616c756573206f766572666c6f7700000000000000006064820152fd5b60348210610ed05781601411612a3257803560601c9160248110612a3257601482013590603411612a32576fffffffffffffffffffffffffffffffff60248193013560801c1660a089015260801c1660808701528015610e6b5760e08601525f8061396c565b9092915a60608201516040519586614397606083018361331c565b5f60038211614a11575b7fffffffff00000000000000000000000000000000000000000000000000000000167f8dd7712f00000000000000000000000000000000000000000000000000000000036148a3575050505f6144ae6145a261443c61446e602095868a01516040519384927f8dd7712f000000000000000000000000000000000000000000000000000000008a8501526040602485015260648401906133bd565b906044830152037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101835282612c2d565b6108b06040519384927e42dc5300000000000000000000000000000000000000000000000000000000888501526102006024850152610224840190612de9565b614571604484018b60806101a091610120815173ffffffffffffffffffffffffffffffffffffffff8151168652602081015160208701526040810151604087015260608101516060870152838101518487015260a081015160a087015260c081015160c087015273ffffffffffffffffffffffffffffffffffffffff60e08201511660e087015261010081015161010087015201516101208501526020810151610140850152604081015161016085015260608101516101808501520151910152565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc8382030161020484015287612de9565b828151910182305af15f5196604052156145bd575b50505050565b9091929394505f3d602014614896575b7fdeaddead00000000000000000000000000000000000000000000000000000000810361465957608485604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152600f60448201527f41413935206f7574206f662067617300000000000000000000000000000000006064820152fd5b92935090917fdeadaa5100000000000000000000000000000000000000000000000000000000036146bc57506146a16146966146b1925a90612ead565b608084015190612e46565b6040830151836128548295614c2a565b905b5f8080806145b7565b9061472f9060405160208501518551907ff62676f440ff169a3a9afdbf812e89e7f95975ee8e5c31214ffdef631c5f4792602073ffffffffffffffffffffffffffffffffffffffff84511693015161471261349e565b9061472260405192839283612e2c565b0390a36040525a90612ead565b61473f6080840191825190612e46565b915f905a92855161010081015161012082015148018082105f1461488e5750955b61478d73ffffffffffffffffffffffffffffffffffffffff60e08401511693518203606084015190614b09565b01925f928061485f5750505173ffffffffffffffffffffffffffffffffffffffff16935b5a900301019283026040850151928184105f14614813575050806147e6575090816147e0929361285481614c2a565b906146b3565b807f4e487b7100000000000000000000000000000000000000000000000000000000602492526021600452fd5b614848908284939795039073ffffffffffffffffffffffffffffffffffffffff165f525f60205260405f209081540180915590565b506147e6575090825f61485a93614ba9565b6147e0565b9591905161486e575b506147b1565b935090506148875a9360a05f955a900391015190614b09565b905f614868565b905095614760565b5060205f803e5f516145cd565b614a0893506149dc916148e8917e42dc5300000000000000000000000000000000000000000000000000000000602086015261020060248601526102248501916131b6565b6149ab604484018860806101a091610120815173ffffffffffffffffffffffffffffffffffffffff8151168652602081015160208701526040810151604087015260608101516060870152838101518487015260a081015160a087015260c081015160c087015273ffffffffffffffffffffffffffffffffffffffff60e08201511660e087015261010081015161010087015201516101208501526020810151610140850152604081015161016085015260608101516101808501520151910152565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc8382030161020484015284612de9565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101885287612c2d565b60205f876145a2565b5081356143a1565b73ffffffffffffffffffffffffffffffffffffffff168015614aab575f80809381935af1614a45612eba565b5015614a4d57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f41413931206661696c65642073656e6420746f2062656e6566696369617279006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f4141393020696e76616c69642062656e656669636961727900000000000000006044820152fd5b90619c408201811115614b2257606491600a9103020490565b50505f90565b9190917f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f6080602083015192519473ffffffffffffffffffffffffffffffffffffffff86511694602073ffffffffffffffffffffffffffffffffffffffff60e089015116970151916040519283525f602084015260408301526060820152a4565b9060807f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f91602084015193519573ffffffffffffffffffffffffffffffffffffffff87511695602073ffffffffffffffffffffffffffffffffffffffff60e08a015116980151926040519384521515602084015260408301526060820152a4565b60208101519051907f67b4fa9642f42120bf031f3051d1824b0fe25627945b27b8a6a65d5761d5482e60208073ffffffffffffffffffffffffffffffffffffffff855116940151604051908152a3565b90600211614cca57357fffffffffffffffffffffffffffffffffffffffff000000000000000000000000167f77020000000000000000000000000000000000000000000000000000000000001490565b505f90565b60ff8114614d2e5760ff811690601f8211614d065760405191614cf3604084612c2d565b6020808452838101919036833783525290565b7fb3512b0c000000000000000000000000000000000000000000000000000000005f5260045ffd5b506040515f6002548060011c9160018216918215614e3b575b602084108314614e0e578385528492908115614dd15750600114614d72575b612e4392500382612c2d565b5060025f90815290917f405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace5b818310614db5575050906020612e4392820101614d66565b6020919350806001915483858801015201910190918392614d9d565b60209250612e439491507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001682840152151560051b820101614d66565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b92607f1692614d47565b60ff8114614e695760ff811690601f8211614d065760405191614cf3604084612c2d565b506040515f6003548060011c9160018216918215614f0b575b602084108314614e0e578385528492908115614dd15750600114614eac57612e4392500382612c2d565b5060035f90815290917fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b5b818310614eef575050906020612e4392820101614d66565b6020919350806001915483858801015201910190918392614ed7565b92607f1692614e82565b8015614fae575f60408051614f2981612c11565b828152826020820152015273ffffffffffffffffffffffffffffffffffffffff81169065ffffffffffff8160a01c16908115614fa0575b60409060d01c9165ffffffffffff825191614f7a83612c11565b8583528460208401521691829101524211908115614f9757509091565b90504211159091565b65ffffffffffff9150614f60565b505f905f90565b929190915f9080614fc8575b5050505050565b83519473ffffffffffffffffffffffffffffffffffffffff86511695614fee8386614c7a565b61535f5750853b6152fa576014821061529557604085510151602060405180927f570e1a36000000000000000000000000000000000000000000000000000000008252826004830152818781615048602482018a8d6131b6565b039273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000449ed7c3e6fee6a97311d4b55475df59c44add331690f190811561528a57849161526b575b5073ffffffffffffffffffffffffffffffffffffffff811680156152065787036151a1573b1561513c5750601411615139577fd51a9c61267aa6196961883ecf5ff2da6619c37dac0fa92122513fb32c032d2d91604091503573ffffffffffffffffffffffffffffffffffffffff60e06020860151955101511673ffffffffffffffffffffffffffffffffffffffff83519260601c1682526020820152a35f80808080614fc1565b80fd5b608490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602060448201527f4141313520696e6974436f6465206d757374206372656174652073656e6465726064820152fd5b608482604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152602060448201527f4141313420696e6974436f6465206d7573742072657475726e2073656e6465726064820152fd5b608483604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601b60448201527f4141313320696e6974436f6465206661696c6564206f72204f4f4700000000006064820152fd5b615284915060203d602011611771576117638183612c2d565b5f615091565b6040513d86823e3d90fd5b608490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601760448201527f4141393920696e6974436f646520746f6f20736d616c6c0000000000000000006064820152fd5b608490604051907f220266b6000000000000000000000000000000000000000000000000000000008252600482015260406024820152601f60448201527f414131302073656e64657220616c726561647920636f6e7374727563746564006064820152fd5b945050919050601482116153735750505050565b604073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000449ed7c3e6fee6a97311d4b55475df59c44add33169301519082601411612a3257833b15612a32575f809461542f96604051978896879586937fc09ad0d900000000000000000000000000000000000000000000000000000000855260048501526040602485015260147fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec60448601930191016131b6565b0393f1801561545557615445575b8080806145b7565b5f61544f91612c2d565b5f61543d565b6040513d5f823e3d90fd5b615478604092959493956060835260608301906133bd565b9460208201520152565b73ffffffffffffffffffffffffffffffffffffffff165f525f60205260405f2090815481811061381d5703905560019056fea2646970667358221220a2ee7c02d47f72772240d0dfa7174d99b6049a68ccdf3d4434c3918f6bd9c1e164736f6c634300081c0033", + "nonce": 2, + "storage": {} + }, + "0x449ED7C3e6Fee6a97311d4b55475DF59C44AdD33": { + "balance": 0, + "code": "0x60806040526004361015610011575f80fd5b5f3560e01c8063570e1a3614610258578063b0d691fe146101ea5763c09ad0d91461003a575f80fd5b346101e65760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101e65760043573ffffffffffffffffffffffffffffffffffffffff811681036101e65760243567ffffffffffffffff81116101e657366023820112156101e6575f916100bd83923690602481600401359101610384565b906100ff73ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000004337084d9e255ff0702461cf8895ce9e3b5ff108163314610426565b82602083519301915af11561011057005b3d61080081116101dd575b60c460405160208382010160405282815260208101925f843e7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f6040519485937f65c8fd4d0000000000000000000000000000000000000000000000000000000085525f6004860152606060248601528260648601527f4141313320454950373730322073656e64657220696e6974206661696c656400608486015260a060448601525180918160a48701528686015e5f85828601015201168101030190fd5b5061080061011b565b5f80fd5b346101e6575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101e657602060405173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000004337084d9e255ff0702461cf8895ce9e3b5ff108168152f35b346101e65760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101e65760043567ffffffffffffffff81116101e657366023820112156101e65780600401359067ffffffffffffffff82116101e65736602483830101116101e6575f9161030873ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000004337084d9e255ff0702461cf8895ce9e3b5ff108163314610426565b806014116101e6576020916103455f927fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec36910160388401610384565b90826024858451940192013560601c5af161037c575b60209073ffffffffffffffffffffffffffffffffffffffff60405191168152f35b505f5161035b565b92919267ffffffffffffffff82116103f957604051917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0603f81601f8401160116830183811067ffffffffffffffff8211176103f9576040528294818452818301116101e6578281602093845f960137010152565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b1561042d57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602060248201527f414139372073686f756c642063616c6c2066726f6d20456e747279506f696e746044820152fdfea26469706673582212206423798798d408242e814ed5c031f1afcdccad1146c1c11fab88cce1fdaa4c4a64736f6c634300081c0033", + "nonce": 1, + "storage": {} + }, + "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67": { + "balance": 0, + "code": "0x608060405234801561001057600080fd5b50600436106100575760003560e01c80631688f0b91461005c5780633408e4701461016b57806353e5d93514610189578063d18af54d1461020c578063ec9e80bb1461033b575b600080fd5b61013f6004803603606081101561007257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001906401000000008111156100af57600080fd5b8201836020820111156100c157600080fd5b803590602001918460018302840111640100000000831117156100e357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192908035906020019092919050505061044a565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b6101736104fe565b6040518082815260200191505060405180910390f35b61019161050b565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156101d15780820151818401526020810190506101b6565b50505050905090810190601f1680156101fe5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b61030f6004803603608081101561022257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019064010000000081111561025f57600080fd5b82018360208201111561027157600080fd5b8035906020019184600183028401116401000000008311171561029357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610536565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b61041e6004803603606081101561035157600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019064010000000081111561038e57600080fd5b8201836020820111156103a057600080fd5b803590602001918460018302840111640100000000831117156103c257600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803590602001909291905050506106e5565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b60008083805190602001208360405160200180838152602001828152602001925050506040516020818303038152906040528051906020012090506104908585836107a8565b91508173ffffffffffffffffffffffffffffffffffffffff167f4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e23586604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a2509392505050565b6000804690508091505090565b60606040518060200161051d906109c5565b6020820181038252601f19601f82011660405250905090565b6000808383604051602001808381526020018273ffffffffffffffffffffffffffffffffffffffff1660601b8152601401925050506040516020818303038152906040528051906020012060001c905061059186868361044a565b9150600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16146106dc578273ffffffffffffffffffffffffffffffffffffffff16631e52b518838888886040518563ffffffff1660e01b8152600401808573ffffffffffffffffffffffffffffffffffffffff1681526020018473ffffffffffffffffffffffffffffffffffffffff16815260200180602001838152602001828103825284818151815260200191508051906020019080838360005b83811015610674578082015181840152602081019050610659565b50505050905090810190601f1680156106a15780820380516001836020036101000a031916815260200191505b5095505050505050600060405180830381600087803b1580156106c357600080fd5b505af11580156106d7573d6000803e3d6000fd5b505050505b50949350505050565b6000808380519060200120836106f96104fe565b60405160200180848152602001838152602001828152602001935050505060405160208183030381529060405280519060200120905061073a8585836107a8565b91508173ffffffffffffffffffffffffffffffffffffffff167f4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e23586604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a2509392505050565b60006107b3846109b2565b610825576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040180806020018281038252601f8152602001807f53696e676c65746f6e20636f6e7472616374206e6f74206465706c6f7965640081525060200191505060405180910390fd5b600060405180602001610837906109c5565b6020820181038252601f19601f820116604052508573ffffffffffffffffffffffffffffffffffffffff166040516020018083805190602001908083835b602083106108985780518252602082019150602081019050602083039250610875565b6001836020036101000a038019825116818451168082178552505050505050905001828152602001925050506040516020818303038152906040529050828151826020016000f59150600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161415610984576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260138152602001807f437265617465322063616c6c206661696c65640000000000000000000000000081525060200191505060405180910390fd5b6000845111156109aa5760008060008651602088016000875af114156109a957600080fd5b5b509392505050565b600080823b905060008111915050919050565b6101e6806109d38339019056fe608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea264697066735822122003d1488ee65e08fa41e58e888a9865554c535f2c77126a82cb4c0f917f31441364736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564a26469706673582212200fd975ca8e62d9bf08aa3d09c74b9bdc9d7acba7621835be4187989ddd0e54b164736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0x4e59b44847b379578588920cA78FbF26c0B4956C": { + "balance": 0, + "code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3", + "nonce": 6, + "storage": {} + }, + "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789": { + "balance": 0, + "code": "0x60806040526004361015610023575b361561001957600080fd5b610021615531565b005b60003560e01c80630396cb60146101b35780630bd28e3b146101aa5780631b2e01b8146101a15780631d732756146101985780631fad948c1461018f578063205c28781461018657806335567e1a1461017d5780634b1d7cf5146101745780635287ce121461016b57806370a08231146101625780638f41ec5a14610159578063957122ab146101505780639b249f6914610147578063a61935311461013e578063b760faf914610135578063bb9fe6bf1461012c578063c23a5cea14610123578063d6383f941461011a578063ee219423146101115763fc7e286d0361000e5761010c611bcd565b61000e565b5061010c6119b5565b5061010c61184d565b5061010c6116b4565b5061010c611536565b5061010c6114f7565b5061010c6114d6565b5061010c611337565b5061010c611164565b5061010c611129565b5061010c6110a4565b5061010c610f54565b5061010c610bf8565b5061010c610b33565b5061010c610994565b5061010c6108ba565b5061010c6106e7565b5061010c610467565b5061010c610385565b5060207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760043563ffffffff8116808203610359576103547fa5ae833d0bb1dcd632d98a8b70973e8516812898e19bf27b70071ebc8dc52c01916102716102413373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b9161024d811515615697565b61026a610261600185015463ffffffff1690565b63ffffffff1690565b11156156fc565b54926103366dffffffffffffffffffffffffffff946102f461029834888460781c166121d5565b966102a4881515615761565b6102b0818911156157c6565b6102d4816102bc6105ec565b941684906dffffffffffffffffffffffffffff169052565b6001602084015287166dffffffffffffffffffffffffffff166040830152565b63ffffffff83166060820152600060808201526103313373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b61582b565b6040805194855263ffffffff90911660208501523393918291820190565b0390a2005b600080fd5b6024359077ffffffffffffffffffffffffffffffffffffffffffffffff8216820361035957565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760043577ffffffffffffffffffffffffffffffffffffffffffffffff81168103610359576104149033600052600160205260406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b61041e8154612491565b9055005b73ffffffffffffffffffffffffffffffffffffffff81160361035957565b6024359061044d82610422565b565b60c4359061044d82610422565b359061044d82610422565b50346103595760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760206104fc6004356104a881610422565b73ffffffffffffffffffffffffffffffffffffffff6104c561035e565b91166000526001835260406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b54604051908152f35b507f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b60a0810190811067ffffffffffffffff82111761055157604052565b610559610505565b604052565b610100810190811067ffffffffffffffff82111761055157604052565b67ffffffffffffffff811161055157604052565b6060810190811067ffffffffffffffff82111761055157604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff82111761055157604052565b6040519061044d82610535565b6040519060c0820182811067ffffffffffffffff82111761055157604052565b604051906040820182811067ffffffffffffffff82111761055157604052565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f60209267ffffffffffffffff8111610675575b01160190565b61067d610505565b61066f565b92919261068e82610639565b9161069c60405193846105ab565b829481845281830111610359578281602093846000960137010152565b9181601f840112156103595782359167ffffffffffffffff8311610359576020838186019501011161035957565b5034610359576101c07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595767ffffffffffffffff60043581811161035957366023820112156103595761074a903690602481600401359101610682565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc36016101808112610359576101006040519161078783610535565b12610359576040516107988161055e565b6107a0610440565b815260443560208201526064356040820152608435606082015260a43560808201526107ca61044f565b60a082015260e43560c08201526101043560e082015281526101243560208201526101443560408201526101643560608201526101843560808201526101a4359182116103595761083e9261082661082e9336906004016106b9565b9290916128b1565b6040519081529081906020820190565b0390f35b9060407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8301126103595760043567ffffffffffffffff9283821161035957806023830112156103595781600401359384116103595760248460051b830101116103595760240191906024356108b781610422565b90565b5034610359576108c936610842565b6108d4929192611e3a565b6108dd83611d2d565b60005b84811061095d57506000927fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f9728480a183915b85831061092d576109238585611ed7565b6100216001600255565b909193600190610953610941878987611dec565b61094b8886611dca565b51908861233f565b0194019190610912565b8061098b610984610972600194869896611dca565b5161097e848a88611dec565b84613448565b9083612f30565b019290926108e0565b50346103595760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610359576004356109d081610422565b6024359060009133835282602052604083206dffffffffffffffffffffffffffff81541692838311610ad557848373ffffffffffffffffffffffffffffffffffffffff829593610a788496610a3f610a2c8798610ad29c6121c0565b6dffffffffffffffffffffffffffff1690565b6dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b6040805173ffffffffffffffffffffffffffffffffffffffff831681526020810185905233917fd1c19fbcd4551a5edfb66d43d2e337c04837afda3482b42bdf569a8fccdae5fb91a2165af1610acc611ea7565b50615ba2565b80f35b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601960248201527f576974686472617720616d6f756e7420746f6f206c61726765000000000000006044820152fd5b50346103595760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610359576020600435610b7181610422565b73ffffffffffffffffffffffffffffffffffffffff610b8e61035e565b911660005260018252610bc98160406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b547fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000006040519260401b16178152f35b503461035957610c0736610842565b610c0f611e3a565b6000805b838210610df657610c249150611d2d565b7fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f972600080a16000805b848110610d5c57505060008093815b818110610c9357610923868660007f575ff3acadd5ab348fe1855e217e0f3678f8d767d7494c9f9fefbee2e17cca4d8180a2611ed7565b610cf7610ca182848a6124cb565b610ccc610cb3610cb36020840161256d565b73ffffffffffffffffffffffffffffffffffffffff1690565b7f575ff3acadd5ab348fe1855e217e0f3678f8d767d7494c9f9fefbee2e17cca4d600080a280612519565b906000915b808310610d1457505050610d0f90612491565b610c5c565b90919497610d4f610d49610d5592610d438c8b610d3c82610d368e8b8d611dec565b92611dca565b519161233f565b906121d5565b99612491565b95612491565b9190610cfc565b610d678186886124cb565b6020610d7f610d768380612519565b9290930161256d565b9173ffffffffffffffffffffffffffffffffffffffff60009316905b828410610db45750505050610daf90612491565b610c4d565b90919294610d4f81610de985610de2610dd0610dee968d611dca565b51610ddc8c8b8a611dec565b85613448565b908b613148565b612491565b929190610d9b565b610e018285876124cb565b90610e0c8280612519565b92610e1c610cb36020830161256d565b9173ffffffffffffffffffffffffffffffffffffffff8316610e416001821415612577565b610e62575b505050610e5c91610e56916121d5565b91612491565b90610c13565b909592610e7b6040999693999895989788810190611fc8565b92908a3b156103595789938b918a5193849283927fe3563a4f00000000000000000000000000000000000000000000000000000000845260049e8f850193610ec294612711565b03815a93600094fa9081610f3b575b50610f255786517f86a9f75000000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff8a16818a0190815281906020010390fd5b0390fd5b9497509295509093509181610e56610e5c610e46565b80610f48610f4e9261057b565b8061111e565b38610ed1565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595761083e73ffffffffffffffffffffffffffffffffffffffff600435610fa881610422565b608060409283928351610fba81610535565b60009381858093528260208201528287820152826060820152015216815280602052209061104965ffffffffffff6001835194610ff686610535565b80546dffffffffffffffffffffffffffff8082168852607082901c60ff161515602089015260789190911c1685870152015463ffffffff8116606086015260201c16608084019065ffffffffffff169052565b5191829182919091608065ffffffffffff8160a08401956dffffffffffffffffffffffffffff808251168652602082015115156020870152604082015116604086015263ffffffff6060820151166060860152015116910152565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595773ffffffffffffffffffffffffffffffffffffffff6004356110f581610422565b16600052600060205260206dffffffffffffffffffffffffffff60406000205416604051908152f35b600091031261035957565b50346103595760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261035957602060405160018152f35b50346103595760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261035957600467ffffffffffffffff8135818111610359576111b590369084016106b9565b9050602435916111c483610422565b604435908111610359576111db90369085016106b9565b92909115908161132d575b506112c6576014821015611236575b610f21836040519182917f08c379a0000000000000000000000000000000000000000000000000000000008352820160409060208152600060208201520190565b6112466112529261124c92612b88565b90612b96565b60601c90565b3b1561125f5738806111f5565b610f21906040519182917f08c379a0000000000000000000000000000000000000000000000000000000008352820160609060208152601b60208201527f41413330207061796d6173746572206e6f74206465706c6f796564000000000060408201520190565b610f21836040519182917f08c379a0000000000000000000000000000000000000000000000000000000008352820160609060208152601960208201527f41413230206163636f756e74206e6f74206465706c6f7965640000000000000060408201520190565b90503b15386111e6565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760043567ffffffffffffffff81116103595761138960249136906004016106b9565b906113bf6040519283927f570e1a3600000000000000000000000000000000000000000000000000000000845260048401612d2c565b0360208273ffffffffffffffffffffffffffffffffffffffff92816000857f0000000000000000000000007fc98430eaedbb6070b35b39d798725049088348165af1918215611471575b600092611441575b50604051917f6ca7b806000000000000000000000000000000000000000000000000000000008352166004820152fd5b61146391925060203d811161146a575b61145b81836105ab565b810190612d17565b9038611411565b503d611451565b611479612183565b611409565b90816101609103126103595790565b60207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc820112610359576004359067ffffffffffffffff8211610359576108b79160040161147e565b50346103595760206114ef6114ea3661148d565b612a0c565b604051908152f35b5060207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595761002160043561153181610422565b61562b565b5034610359576000807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126116b1573381528060205260408120600181019063ffffffff825416908115611653576115f06115b5611618936115a76115a2855460ff9060701c1690565b61598f565b65ffffffffffff42166159f4565b84547fffffffffffffffffffffffffffffffffffffffffffff000000000000ffffffff16602082901b69ffffffffffff000000001617909455565b7fffffffffffffffffffffffffffffffffff00ffffffffffffffffffffffffffff8154169055565b60405165ffffffffffff91909116815233907ffa9b3c14cc825c412c9ed81b3ba365a5b459439403f18829e572ed53a4180f0a90602090a280f35b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600a60248201527f6e6f74207374616b6564000000000000000000000000000000000000000000006044820152fd5b80fd5b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610359576004356116f081610422565b610ad273ffffffffffffffffffffffffffffffffffffffff6117323373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b926117ea611755610a2c86546dffffffffffffffffffffffffffff9060781c1690565b94611761861515615a0e565b6117c26001820161179a65ffffffffffff611786835465ffffffffffff9060201c1690565b16611792811515615a73565b421015615ad8565b80547fffffffffffffffffffffffffffffffffffffffffffff00000000000000000000169055565b7fffffff0000000000000000000000000000ffffffffffffffffffffffffffffff8154169055565b6040805173ffffffffffffffffffffffffffffffffffffffff831681526020810186905233917fb7c918e0e249f999e965cafeb6c664271b3f4317d296461500e71da39f0cbda391a2600080809581948294165af1611847611ea7565b50615b3d565b50346103595760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595767ffffffffffffffff6004358181116103595761189e90369060040161147e565b602435916118ab83610422565b604435908111610359576118c6610f219136906004016106b9565b6118ce611caa565b6118d785612e2b565b6118ea6118e48287613240565b906153ba565b946118fa826000924384526121e2565b96438252819360609573ffffffffffffffffffffffffffffffffffffffff8316611981575b50505050608001519361194e6040611940602084015165ffffffffffff1690565b92015165ffffffffffff1690565b906040519687967f8b7ac980000000000000000000000000000000000000000000000000000000008852600488016127e1565b8395508394965061199b60409492939451809481936127d3565b03925af19060806119aa611ea7565b92919038808061191f565b5034610359576119c43661148d565b6119cc611caa565b6119d582612e2b565b6119df8183613240565b825160a00151919391611a0c9073ffffffffffffffffffffffffffffffffffffffff166154dc565b6154dc565b90611a30611a07855173ffffffffffffffffffffffffffffffffffffffff90511690565b94611a39612b50565b50611a68611a4c60409586810190611fc8565b90600060148310611bc55750611246611a079261124c92612b88565b91611a72916153ba565b805173ffffffffffffffffffffffffffffffffffffffff169073ffffffffffffffffffffffffffffffffffffffff821660018114916080880151978781015191886020820151611ac79065ffffffffffff1690565b91015165ffffffffffff16916060015192611ae06105f9565b9a8b5260208b0152841515898b015265ffffffffffff1660608a015265ffffffffffff16608089015260a088015215159081611bbc575b50611b515750610f2192519485947fe0cff05f00000000000000000000000000000000000000000000000000000000865260048601612cbd565b9190610f2193611b60846154dc565b611b87611b6b610619565b73ffffffffffffffffffffffffffffffffffffffff9096168652565b6020850152519586957ffaecb4e400000000000000000000000000000000000000000000000000000000875260048701612c2b565b90501538611b17565b9150506154dc565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595773ffffffffffffffffffffffffffffffffffffffff600435611c1e81610422565b16600052600060205260a0604060002065ffffffffffff60018254920154604051926dffffffffffffffffffffffffffff90818116855260ff8160701c161515602086015260781c16604084015263ffffffff8116606084015260201c166080820152f35b60209067ffffffffffffffff8111611c9d575b60051b0190565b611ca5610505565b611c96565b60405190611cb782610535565b604051608083610100830167ffffffffffffffff811184821017611d20575b60405260009283815283602082015283604082015283606082015283838201528360a08201528360c08201528360e082015281528260208201528260408201528260608201520152565b611d28610505565b611cd6565b90611d3782611c83565b611d4460405191826105ab565b8281527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0611d728294611c83565b019060005b828110611d8357505050565b602090611d8e611caa565b82828501015201611d77565b507f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b6020918151811015611ddf575b60051b010190565b611de7611d9a565b611dd7565b9190811015611e2d575b60051b810135907ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffea181360301821215610359570190565b611e35611d9a565b611df6565b6002805414611e495760028055565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152fd5b3d15611ed2573d90611eb882610639565b91611ec660405193846105ab565b82523d6000602084013e565b606090565b73ffffffffffffffffffffffffffffffffffffffff168015611f6a57600080809381935af1611f04611ea7565b5015611f0c57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f41413931206661696c65642073656e6420746f2062656e6566696369617279006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f4141393020696e76616c69642062656e656669636961727900000000000000006044820152fd5b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301821215610359570180359067ffffffffffffffff82116103595760200191813603831361035957565b90816020910312610359575190565b601f82602094937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0938186528686013760008582860101520116010190565b60005b83811061207a5750506000910152565b818101518382015260200161206a565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f6020936120c681518092818752878088019101612067565b0116010190565b906120e76080916108b796946101c0808652850191612028565b9360e0815173ffffffffffffffffffffffffffffffffffffffff80825116602087015260208201516040870152604082015160608701526060820151858701528482015160a087015260a08201511660c086015260c081015182860152015161010084015260208101516101208401526040810151610140840152606081015161016084015201516101808201526101a081840391015261208a565b506040513d6000823e3d90fd5b507f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b919082039182116121cd57565b61044d612190565b919082018092116121cd57565b905a918160206121fb6060830151936060810190611fc8565b906122348560405195869485947f1d732756000000000000000000000000000000000000000000000000000000008652600486016120cd565b03816000305af16000918161230f575b50612308575060206000803e7fdeaddead000000000000000000000000000000000000000000000000000000006000511461229b5761229561228a6108b7945a906121c0565b6080840151906121d5565b91614afc565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152600f60408201527f41413935206f7574206f6620676173000000000000000000000000000000000060608201520190565b9250505090565b61233191925060203d8111612338575b61232981836105ab565b810190612019565b9038612244565b503d61231f565b909291925a9380602061235b6060830151946060810190611fc8565b906123948660405195869485947f1d732756000000000000000000000000000000000000000000000000000000008652600486016120cd565b03816000305af160009181612471575b5061246a575060206000803e7fdeaddead00000000000000000000000000000000000000000000000000000000600051146123fc576123f66123eb6108b795965a906121c0565b6080830151906121d5565b92614ddf565b610f21836040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152600f60408201527f41413935206f7574206f6620676173000000000000000000000000000000000060608201520190565b9450505050565b61248a91925060203d81116123385761232981836105ab565b90386123a4565b6001907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81146124bf570190565b6124c7612190565b0190565b919081101561250c575b60051b810135907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa181360301821215610359570190565b612514611d9a565b6124d5565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301821215610359570180359067ffffffffffffffff821161035957602001918160051b3603831361035957565b356108b781610422565b1561257e57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4141393620696e76616c69642061676772656761746f720000000000000000006044820152fd5b90357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18236030181121561035957016020813591019167ffffffffffffffff821161035957813603831361035957565b6108b7916126578161263d8461045c565b73ffffffffffffffffffffffffffffffffffffffff169052565b602082013560208201526126f26126a361268861267760408601866125dc565b610160806040880152860191612028565b61269560608601866125dc565b908583036060870152612028565b6080840135608084015260a084013560a084015260c084013560c084015260e084013560e084015261010080850135908401526101206126e5818601866125dc565b9185840390860152612028565b9161270361014091828101906125dc565b929091818503910152612028565b949391929083604087016040885252606086019360608160051b8801019482600090815b848310612754575050505050508460206108b795968503910152612028565b9091929394977fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa08b820301855288357ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffea1843603018112156127cf57600191846127bd920161262c565b98602090810196950193019190612735565b8280fd5b908092918237016000815290565b9290936108b796959260c0958552602085015265ffffffffffff8092166040850152166060830152151560808201528160a0820152019061208a565b1561282457565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4141393220696e7465726e616c2063616c6c206f6e6c790000000000000000006044820152fd5b9060406108b79260008152816020820152019061208a565b6040906108b793928152816020820152019061208a565b909291925a936128c230331461281d565b8151946040860151955a6113886060830151890101116129e2576108b7966000958051612909575b50505090612903915a9003608084015101943691610682565b91615047565b612938916129349161292f855173ffffffffffffffffffffffffffffffffffffffff1690565b615c12565b1590565b612944575b80806128ea565b61290392919450612953615c24565b908151612967575b5050600193909161293d565b7f1c4fada7374c0a9ee8841fc38afe82932dc0f8e69012e927f061a8bae611a20173ffffffffffffffffffffffffffffffffffffffff6020870151926129d860206129c6835173ffffffffffffffffffffffffffffffffffffffff1690565b9201519560405193849316968361289a565b0390a3388061295b565b7fdeaddead0000000000000000000000000000000000000000000000000000000060005260206000fd5b612a22612a1c6040830183611fc8565b90615c07565b90612a33612a1c6060830183611fc8565b90612ae9612a48612a1c610120840184611fc8565b60405194859360208501956101008201359260e08301359260c08101359260a08201359260808301359273ffffffffffffffffffffffffffffffffffffffff60208201359135168c9693909a9998959261012098959273ffffffffffffffffffffffffffffffffffffffff6101408a019d168952602089015260408801526060870152608086015260a085015260c084015260e08301526101008201520152565b0391612b1b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0938481018352826105ab565b51902060408051602081019283523091810191909152466060820152608092830181529091612b4a90826105ab565b51902090565b604051906040820182811067ffffffffffffffff821117612b7b575b60405260006020838281520152565b612b83610505565b612b6c565b906014116103595790601490565b7fffffffffffffffffffffffffffffffffffffffff0000000000000000000000009035818116939260148110612bcb57505050565b60140360031b82901b16169150565b9060c060a06108b793805184526020810151602085015260408101511515604085015265ffffffffffff80606083015116606086015260808201511660808501520151918160a0820152019061208a565b9294612c8c61044d95612c7a610100959998612c68612c54602097610140808c528b0190612bda565b9b878a019060208091805184520151910152565b80516060890152602001516080880152565b805160a08701526020015160c0860152565b73ffffffffffffffffffffffffffffffffffffffff81511660e0850152015191019060208091805184520151910152565b612d0661044d94612cf4612cdf60a0959998969960e0865260e0860190612bda565b98602085019060208091805184520151910152565b80516060840152602001516080830152565b019060208091805184520151910152565b9081602091031261035957516108b781610422565b9160206108b7938181520191612028565b90612d6c73ffffffffffffffffffffffffffffffffffffffff916108b797959694606085526060850191612028565b941660208201526040818503910152612028565b60009060033d11612d8d57565b905060046000803e60005160e01c90565b600060443d106108b7576040517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc91823d016004833e815167ffffffffffffffff918282113d602484011117612e1a57818401948551938411612e22573d85010160208487010111612e1a57506108b7929101602001906105ab565b949350505050565b50949350505050565b612e386040820182611fc8565b612e50612e448461256d565b93610120810190611fc8565b9290303b1561035957600093612e949160405196879586957f957122ab00000000000000000000000000000000000000000000000000000000875260048701612d3d565b0381305afa9081612f1d575b5061044d576001612eaf612d80565b6308c379a014612ec8575b612ec057565b61044d612183565b612ed0612d9e565b80612edc575b50612eba565b80516000925015612ed657610f21906040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301612882565b80610f48612f2a9261057b565b38612ea0565b9190612f3b9061317f565b73ffffffffffffffffffffffffffffffffffffffff929183166130da5761306c57612f659061317f565b9116612ffe57612f725750565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152602160448201527f41413332207061796d61737465722065787069726564206f72206e6f7420647560648201527f6500000000000000000000000000000000000000000000000000000000000000608482015260a490fd5b610f21826040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601460408201527f41413334207369676e6174757265206572726f7200000000000000000000000060608201520190565b610f21836040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601760408201527f414132322065787069726564206f72206e6f742064756500000000000000000060608201520190565b610f21846040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601460408201527f41413234207369676e6174757265206572726f7200000000000000000000000060608201520190565b9291906131549061317f565b909273ffffffffffffffffffffffffffffffffffffffff808095169116036130da5761306c57612f65905b80156131d25761318e9061535f565b73ffffffffffffffffffffffffffffffffffffffff65ffffffffffff8060408401511642119081156131c2575b5091511691565b90506020830151164210386131bb565b50600090600090565b156131e257565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f41413934206761732076616c756573206f766572666c6f7700000000000000006044820152fd5b916000915a9381519061325382826136b3565b61325c81612a0c565b602084015261329a6effffffffffffffffffffffffffffff60808401516060850151176040850151176101008401359060e0850135171711156131db565b6132a382613775565b6132ae818584613836565b97906132df6129346132d4875173ffffffffffffffffffffffffffffffffffffffff1690565b60208801519061546c565b6133db576132ec43600052565b73ffffffffffffffffffffffffffffffffffffffff61332460a0606097015173ffffffffffffffffffffffffffffffffffffffff1690565b166133c1575b505a810360a0840135106133545760809360c092604087015260608601525a900391013501910152565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601e60408201527f41413430206f76657220766572696669636174696f6e4761734c696d6974000060608201520190565b909350816133d2929750858461455c565b9590923861332a565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601a60408201527f4141323520696e76616c6964206163636f756e74206e6f6e636500000000000060608201520190565b9290916000925a825161345b81846136b3565b61346483612a0c565b60208501526134a26effffffffffffffffffffffffffffff60808301516060840151176040840151176101008601359060e0870135171711156131db565b6134ab81613775565b6134b78186868b613ba2565b98906134e86129346134dd865173ffffffffffffffffffffffffffffffffffffffff1690565b60208701519061546c565b6135e0576134f543600052565b73ffffffffffffffffffffffffffffffffffffffff61352d60a0606096015173ffffffffffffffffffffffffffffffffffffffff1690565b166135c5575b505a840360a08601351061355f5750604085015260608401526080919060c0905a900391013501910152565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601e60448201527f41413430206f76657220766572696669636174696f6e4761734c696d697400006064820152608490fd5b909250816135d79298508686856147ef565b96909138613533565b610f21826040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601a60408201527f4141323520696e76616c6964206163636f756e74206e6f6e636500000000000060608201520190565b1561365557565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f4141393320696e76616c6964207061796d6173746572416e64446174610000006044820152fd5b613725906136dd6136c38261256d565b73ffffffffffffffffffffffffffffffffffffffff168452565b602081013560208401526080810135604084015260a0810135606084015260c0810135608084015260e081013560c084015261010081013560e0840152610120810190611fc8565b90811561376a5761374f61124c6112468460a09461374a601461044d9998101561364e565b612b88565b73ffffffffffffffffffffffffffffffffffffffff16910152565b505060a06000910152565b60a081015173ffffffffffffffffffffffffffffffffffffffff16156137b75760c060035b60ff60408401519116606084015102016080830151019101510290565b60c0600161379a565b6137d86040929594939560608352606083019061262c565b9460208201520152565b9061044d602f60405180947f414132332072657665727465643a20000000000000000000000000000000000060208301526138268151809260208686019101612067565b810103600f8101855201836105ab565b916000926000925a936139046020835193613865855173ffffffffffffffffffffffffffffffffffffffff1690565b9561387d6138766040830183611fc8565b9084613e0d565b60a086015173ffffffffffffffffffffffffffffffffffffffff16906138a243600052565b85809373ffffffffffffffffffffffffffffffffffffffff809416159889613b3a575b60600151908601516040517f3a871cdd0000000000000000000000000000000000000000000000000000000081529788968795869390600485016137c0565b03938a1690f1829181613b1a575b50613b115750600190613923612d80565b6308c379a014613abd575b50613a50575b613941575b50505a900391565b61396b9073ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b613986610a2c82546dffffffffffffffffffffffffffff1690565b8083116139e3576139dc926dffffffffffffffffffffffffffff9103166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b3880613939565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601760408201527f41413231206469646e2774207061792070726566756e6400000000000000000060608201520190565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601660408201527f4141323320726576657274656420286f72204f4f47290000000000000000000060608201520190565b613ac5612d9e565b9081613ad1575061392e565b610f2191613adf91506137e2565b6040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301612882565b95506139349050565b613b3391925060203d81116123385761232981836105ab565b9038613912565b9450613b80610a2c613b6c8c73ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b546dffffffffffffffffffffffffffff1690565b8b811115613b975750856060835b969150506138c5565b606087918d03613b8e565b90926000936000935a94613beb6020835193613bd2855173ffffffffffffffffffffffffffffffffffffffff1690565b9561387d613be36040830183611fc8565b90848c61412b565b03938a1690f1829181613ded575b50613de45750600190613c0a612d80565b6308c379a014613d8e575b50613d20575b613c29575b5050505a900391565b613c539073ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b91613c6f610a2c84546dffffffffffffffffffffffffffff1690565b90818311613cba575082547fffffffffffffffffffffffffffffffffffff0000000000000000000000000000169190036dffffffffffffffffffffffffffff16179055388080613c20565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601760448201527f41413231206469646e2774207061792070726566756e640000000000000000006064820152608490fd5b610f21846040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601660408201527f4141323320726576657274656420286f72204f4f47290000000000000000000060608201520190565b613d96612d9e565b9081613da25750613c15565b8691613dae91506137e2565b90610f216040519283927f220266b60000000000000000000000000000000000000000000000000000000084526004840161289a565b9650613c1b9050565b613e0691925060203d81116123385761232981836105ab565b9038613bf9565b909180613e1957505050565b81515173ffffffffffffffffffffffffffffffffffffffff1692833b6140be57606083510151604051907f570e1a3600000000000000000000000000000000000000000000000000000000825260208280613e78878760048401612d2c565b0381600073ffffffffffffffffffffffffffffffffffffffff95867f0000000000000000000000007fc98430eaedbb6070b35b39d7987250490883481690f19182156140b1575b600092614091575b508082169586156140245716809503613fb7573b15613f4a5761124c6112467fd51a9c61267aa6196961883ecf5ff2da6619c37dac0fa92122513fb32c032d2d93613f1193612b88565b602083810151935160a001516040805173ffffffffffffffffffffffffffffffffffffffff9485168152939091169183019190915290a3565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152602060408201527f4141313520696e6974436f6465206d757374206372656174652073656e64657260608201520190565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152602060408201527f4141313420696e6974436f6465206d7573742072657475726e2073656e64657260608201520190565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601b60408201527f4141313320696e6974436f6465206661696c6564206f72204f4f47000000000060608201520190565b6140aa91925060203d811161146a5761145b81836105ab565b9038613ec7565b6140b9612183565b613ebf565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601f60408201527f414131302073656e64657220616c726561647920636f6e73747275637465640060608201520190565b9290918161413a575b50505050565b82515173ffffffffffffffffffffffffffffffffffffffff1693843b6143e257606084510151604051907f570e1a3600000000000000000000000000000000000000000000000000000000825260208280614199888860048401612d2c565b0381600073ffffffffffffffffffffffffffffffffffffffff95867f0000000000000000000000007fc98430eaedbb6070b35b39d7987250490883481690f19182156143d5575b6000926143b5575b5080821696871561434757168096036142d9573b15614273575061124c6112467fd51a9c61267aa6196961883ecf5ff2da6619c37dac0fa92122513fb32c032d2d9361423393612b88565b602083810151935160a001516040805173ffffffffffffffffffffffffffffffffffffffff9485168152939091169183019190915290a338808080614134565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152602060448201527f4141313520696e6974436f6465206d757374206372656174652073656e6465726064820152608490fd5b610f21826040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152602060408201527f4141313420696e6974436f6465206d7573742072657475726e2073656e64657260608201520190565b610f21846040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601b60408201527f4141313320696e6974436f6465206661696c6564206f72204f4f47000000000060608201520190565b6143ce91925060203d811161146a5761145b81836105ab565b90386141e8565b6143dd612183565b6141e0565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601f60448201527f414131302073656e64657220616c726561647920636f6e7374727563746564006064820152608490fd5b1561444f57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f4141343120746f6f206c6974746c6520766572696669636174696f6e476173006044820152fd5b919060408382031261035957825167ffffffffffffffff81116103595783019080601f83011215610359578151916144e483610639565b916144f260405193846105ab565b838352602084830101116103595760209261451291848085019101612067565b92015190565b9061044d602f60405180947f414133332072657665727465643a20000000000000000000000000000000000060208301526138268151809260208686019101612067565b93919260609460009460009380519261459b60a08a86015195614580888811614448565b015173ffffffffffffffffffffffffffffffffffffffff1690565b916145c68373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b946145e2610a2c87546dffffffffffffffffffffffffffff1690565b968588106147825773ffffffffffffffffffffffffffffffffffffffff60208a98946146588a966dffffffffffffffffffffffffffff8b6146919e03166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b015194604051998a98899788937ff465c77e000000000000000000000000000000000000000000000000000000008552600485016137c0565b0395169103f190818391849361475c575b506147555750506001906146b4612d80565b6308c379a014614733575b506146c657565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601660408201527f4141333320726576657274656420286f72204f4f47290000000000000000000060608201520190565b61473b612d9e565b908161474757506146bf565b610f2191613adf9150614518565b9450925050565b90925061477b91503d8085833e61477381836105ab565b8101906144ad565b91386146a2565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601e60408201527f41413331207061796d6173746572206465706f73697420746f6f206c6f77000060608201520190565b91949293909360609560009560009382519061481660a08b84015193614580848611614448565b936148418573ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b61485c610a2c82546dffffffffffffffffffffffffffff1690565b8781106149b7579273ffffffffffffffffffffffffffffffffffffffff60208a989693946146588a966dffffffffffffffffffffffffffff8d6148d69e9c9a03166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b0395169103f1908183918493614999575b506149915750506001906148f9612d80565b6308c379a014614972575b5061490c5750565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601660448201527f4141333320726576657274656420286f72204f4f4729000000000000000000006064820152608490fd5b61497a612d9e565b90816149865750614904565b613dae925050614518565b955093505050565b9092506149b091503d8085833e61477381836105ab565b91386148e7565b610f218a6040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601e60408201527f41413331207061796d6173746572206465706f73697420746f6f206c6f77000060608201520190565b60031115614a2f57565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b929190614a7c6040916002865260606020870152606086019061208a565b930152565b939291906003811015614a2f57604091614a7c91865260606020870152606086019061208a565b9061044d603660405180947f4141353020706f73744f702072657665727465643a20000000000000000000006020830152614aec8151809260208686019101612067565b81010360168101855201836105ab565b929190925a93600091805191614b1183615318565b9260a0810195614b35875173ffffffffffffffffffffffffffffffffffffffff1690565b73ffffffffffffffffffffffffffffffffffffffff93908481169081614ca457505050614b76825173ffffffffffffffffffffffffffffffffffffffff1690565b985b5a90030193840297604084019089825110614c37577f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f94614bc26020928c614c329551039061553a565b015194896020614c04614be9865173ffffffffffffffffffffffffffffffffffffffff1690565b9a5173ffffffffffffffffffffffffffffffffffffffff1690565b9401519785604051968796169a16988590949392606092608083019683521515602083015260408201520152565b0390a4565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152602060408201527f414135312070726566756e642062656c6f772061637475616c476173436f737460608201520190565b9a918051614cb4575b5050614b78565b6060850151600099509091803b15614ddb579189918983614d07956040518097819682957fa9a234090000000000000000000000000000000000000000000000000000000084528c029060048401614a5e565b0393f19081614dc8575b50614dc3576001614d20612d80565b6308c379a014614da4575b614d37575b3880614cad565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601260408201527f4141353020706f73744f7020726576657274000000000000000000000000000060608201520190565b614dac612d9e565b80614db75750614d2b565b613adf610f2191614aa8565b614d30565b80610f48614dd59261057b565b38614d11565b8980fd5b9392915a90600092805190614df382615318565b9360a0830196614e17885173ffffffffffffffffffffffffffffffffffffffff1690565b73ffffffffffffffffffffffffffffffffffffffff95908681169081614f0d57505050614e58845173ffffffffffffffffffffffffffffffffffffffff1690565b915b5a9003019485029860408301908a825110614ea757507f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f949392614bc2614c32938c60209451039061553a565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152602060448201527f414135312070726566756e642062656c6f772061637475616c476173436f73746064820152608490fd5b93918051614f1d575b5050614e5a565b606087015160009a509091803b1561504357918a918a83614f70956040518097819682957fa9a234090000000000000000000000000000000000000000000000000000000084528c029060048401614a5e565b0393f19081615030575b5061502b576001614f89612d80565b6308c379a01461500e575b614fa0575b3880614f16565b610f218b6040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601260408201527f4141353020706f73744f7020726576657274000000000000000000000000000060608201520190565b615016612d9e565b806150215750614f94565b613dae8d91614aa8565b614f99565b80610f4861503d9261057b565b38614f7a565b8a80fd5b909392915a9480519161505983615318565b9260a081019561507d875173ffffffffffffffffffffffffffffffffffffffff1690565b73ffffffffffffffffffffffffffffffffffffffff938185169182615165575050506150bd825173ffffffffffffffffffffffffffffffffffffffff1690565b985b5a90030193840297604084019089825110614c37577f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f946151096020928c614c329551039061553a565b61511288614a25565b015194896020615139614be9865173ffffffffffffffffffffffffffffffffffffffff1690565b940151604080519182529815602082015297880152606087015290821695909116939081906080820190565b9a918151615175575b50506150bf565b8784026151818a614a25565b60028a1461520c576060860151823b15610359576151d493600080948d604051978896879586937fa9a2340900000000000000000000000000000000000000000000000000000000855260048501614a81565b0393f180156151ff575b6151ec575b505b388061516e565b80610f486151f99261057b565b386151e3565b615207612183565b6151de565b6060860151823b156103595761525793600080948d604051978896879586937fa9a2340900000000000000000000000000000000000000000000000000000000855260048501614a81565b0393f19081615305575b50615300576001615270612d80565b6308c379a0146152ed575b156151e5576040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601260408201527f4141353020706f73744f7020726576657274000000000000000000000000000060608201520190565b6152f5612d9e565b80614db7575061527b565b6151e5565b80610f486153129261057b565b38615261565b60e060c082015191015180821461533c57480180821015615337575090565b905090565b5090565b6040519061534d8261058f565b60006040838281528260208201520152565b615367615340565b5065ffffffffffff808260a01c1680156153b3575b604051926153898461058f565b73ffffffffffffffffffffffffffffffffffffffff8116845260d01c602084015216604082015290565b508061537c565b6153cf6153d5916153c9615340565b5061535f565b9161535f565b9073ffffffffffffffffffffffffffffffffffffffff9182825116928315615461575b65ffffffffffff928391826040816020850151169301511693836040816020840151169201511690808410615459575b50808511615451575b506040519561543f8761058f565b16855216602084015216604082015290565b935038615431565b925038615428565b8151811693506153f8565b73ffffffffffffffffffffffffffffffffffffffff16600052600160205267ffffffffffffffff6154c88260401c60406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b918254926154d584612491565b9055161490565b9073ffffffffffffffffffffffffffffffffffffffff6154fa612b50565b9216600052600060205263ffffffff600160406000206dffffffffffffffffffffffffffff815460781c1685520154166020830152565b61044d3361562b565b73ffffffffffffffffffffffffffffffffffffffff16600052600060205260406000206dffffffffffffffffffffffffffff8082541692830180931161561e575b8083116155c05761044d92166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601060248201527f6465706f736974206f766572666c6f77000000000000000000000000000000006044820152fd5b615626612190565b61557b565b73ffffffffffffffffffffffffffffffffffffffff9061564b348261553a565b168060005260006020527f2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c460206dffffffffffffffffffffffffffff60406000205416604051908152a2565b1561569e57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f6d757374207370656369667920756e7374616b652064656c61790000000000006044820152fd5b1561570357565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f63616e6e6f7420646563726561736520756e7374616b652074696d65000000006044820152fd5b1561576857565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f6e6f207374616b652073706563696669656400000000000000000000000000006044820152fd5b156157cd57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600e60248201527f7374616b65206f766572666c6f770000000000000000000000000000000000006044820152fd5b9065ffffffffffff6080600161044d9461588b6dffffffffffffffffffffffffffff86511682906dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b602085015115156eff000000000000000000000000000082549160701b16807fffffffffffffffffffffffffffffffffff00ffffffffffffffffffffffffffff83161783557fffffff000000000000000000000000000000ffffffffffffffffffffffffffff7cffffffffffffffffffffffffffff000000000000000000000000000000604089015160781b16921617178155019263ffffffff6060820151167fffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000008554161784550151167fffffffffffffffffffffffffffffffffffffffffffff000000000000ffffffff69ffffffffffff0000000083549260201b169116179055565b1561599657565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f616c726561647920756e7374616b696e670000000000000000000000000000006044820152fd5b91909165ffffffffffff808094169116019182116121cd57565b15615a1557565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f4e6f207374616b6520746f2077697468647261770000000000000000000000006044820152fd5b15615a7a57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f6d7573742063616c6c20756e6c6f636b5374616b6528292066697273740000006044820152fd5b15615adf57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601b60248201527f5374616b65207769746864726177616c206973206e6f742064756500000000006044820152fd5b15615b4457565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f6661696c656420746f207769746864726177207374616b6500000000000000006044820152fd5b15615ba957565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f6661696c656420746f20776974686472617700000000000000000000000000006044820152fd5b816040519182372090565b9060009283809360208451940192f190565b3d610800808211615c4b575b50604051906020818301016040528082526000602083013e90565b905038615c3056fea2646970667358221220a706d8b02d7086d80e9330811f5af84b2614abdc5e9a1f2260126070a31d7cee64736f6c63430008110033", + "nonce": 2, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000002": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0x69f4D1788e39c87893C980c06EdF4b7f686e2938": { + "balance": 0, + "code": "0x6080604052600436106101dc5760003560e01c8063affed0e011610102578063e19a9dd911610095578063f08a032311610064578063f08a032314611647578063f698da2514611698578063f8dc5dd9146116c3578063ffa1ad741461173e57610231565b8063e19a9dd91461139b578063e318b52b146113ec578063e75235b81461147d578063e86637db146114a857610231565b8063cc2f8452116100d1578063cc2f8452146110e8578063d4d9bdcd146111b5578063d8d11f78146111f0578063e009cfde1461132a57610231565b8063affed0e014610d94578063b4faba0914610dbf578063b63e800d14610ea7578063c4ca3a9c1461101757610231565b80635624b25b1161017a5780636a761202116101495780636a761202146109945780637d83297414610b50578063934f3a1114610bbf578063a0e67e2b14610d2857610231565b80635624b25b146107fb5780635ae6bd37146108b9578063610b592514610908578063694e80c31461095957610231565b80632f54bf6e116101b65780632f54bf6e146104d35780633408e4701461053a578063468721a7146105655780635229073f1461067a57610231565b80630d582f131461029e57806312fb68e0146102f95780632d9ad53d1461046c57610231565b36610231573373ffffffffffffffffffffffffffffffffffffffff167f3d0ce9bfc3ed7d6862dbb28b2dea94561fe714a1b4d019aa8af39730d1ad7c3d346040518082815260200191505060405180910390a2005b34801561023d57600080fd5b5060007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b905080548061027257600080f35b36600080373360601b365260008060143601600080855af13d6000803e80610299573d6000fd5b3d6000f35b3480156102aa57600080fd5b506102f7600480360360408110156102c157600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506117ce565b005b34801561030557600080fd5b5061046a6004803603608081101561031c57600080fd5b81019080803590602001909291908035906020019064010000000081111561034357600080fd5b82018360208201111561035557600080fd5b8035906020019184600183028401116401000000008311171561037757600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803590602001906401000000008111156103da57600080fd5b8201836020820111156103ec57600080fd5b8035906020019184600183028401116401000000008311171561040e57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190505050611bbe565b005b34801561047857600080fd5b506104bb6004803603602081101561048f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612440565b60405180821515815260200191505060405180910390f35b3480156104df57600080fd5b50610522600480360360208110156104f657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612512565b60405180821515815260200191505060405180910390f35b34801561054657600080fd5b5061054f6125e4565b6040518082815260200191505060405180910390f35b34801561057157600080fd5b506106626004803603608081101561058857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156105cf57600080fd5b8201836020820111156105e157600080fd5b8035906020019184600183028401116401000000008311171561060357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506125f1565b60405180821515815260200191505060405180910390f35b34801561068657600080fd5b506107776004803603608081101561069d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156106e457600080fd5b8201836020820111156106f657600080fd5b8035906020019184600183028401116401000000008311171561071857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506127d7565b60405180831515815260200180602001828103825283818151815260200191508051906020019080838360005b838110156107bf5780820151818401526020810190506107a4565b50505050905090810190601f1680156107ec5780820380516001836020036101000a031916815260200191505b50935050505060405180910390f35b34801561080757600080fd5b5061083e6004803603604081101561081e57600080fd5b81019080803590602001909291908035906020019092919050505061280d565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561087e578082015181840152602081019050610863565b50505050905090810190601f1680156108ab5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b3480156108c557600080fd5b506108f2600480360360208110156108dc57600080fd5b8101908080359060200190929190505050612894565b6040518082815260200191505060405180910390f35b34801561091457600080fd5b506109576004803603602081101561092b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506128ac565b005b34801561096557600080fd5b506109926004803603602081101561097c57600080fd5b8101908080359060200190929190505050612c3e565b005b610b3860048036036101408110156109ab57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156109f257600080fd5b820183602082011115610a0457600080fd5b80359060200191846001830284011164010000000083111715610a2657600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610ab257600080fd5b820183602082011115610ac457600080fd5b80359060200191846001830284011164010000000083111715610ae657600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612d78565b60405180821515815260200191505060405180910390f35b348015610b5c57600080fd5b50610ba960048036036040811015610b7357600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506132b5565b6040518082815260200191505060405180910390f35b348015610bcb57600080fd5b50610d2660048036036060811015610be257600080fd5b810190808035906020019092919080359060200190640100000000811115610c0957600080fd5b820183602082011115610c1b57600080fd5b80359060200191846001830284011164010000000083111715610c3d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190640100000000811115610ca057600080fd5b820183602082011115610cb257600080fd5b80359060200191846001830284011164010000000083111715610cd457600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506132da565b005b348015610d3457600080fd5b50610d3d613369565b6040518080602001828103825283818151815260200191508051906020019060200280838360005b83811015610d80578082015181840152602081019050610d65565b505050509050019250505060405180910390f35b348015610da057600080fd5b50610da9613512565b6040518082815260200191505060405180910390f35b348015610dcb57600080fd5b50610ea560048036036040811015610de257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610e1f57600080fd5b820183602082011115610e3157600080fd5b80359060200191846001830284011164010000000083111715610e5357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050613518565b005b348015610eb357600080fd5b506110156004803603610100811015610ecb57600080fd5b8101908080359060200190640100000000811115610ee857600080fd5b820183602082011115610efa57600080fd5b80359060200191846020830284011164010000000083111715610f1c57600080fd5b909192939192939080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610f6757600080fd5b820183602082011115610f7957600080fd5b80359060200191846001830284011164010000000083111715610f9b57600080fd5b9091929391929390803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061353a565b005b34801561102357600080fd5b506110d26004803603608081101561103a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561108157600080fd5b82018360208201111561109357600080fd5b803590602001918460018302840111640100000000831117156110b557600080fd5b9091929391929390803560ff1690602001909291905050506136f8565b6040518082815260200191505060405180910390f35b3480156110f457600080fd5b506111416004803603604081101561110b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613820565b60405180806020018373ffffffffffffffffffffffffffffffffffffffff168152602001828103825284818151815260200191508051906020019060200280838360005b838110156111a0578082015181840152602081019050611185565b50505050905001935050505060405180910390f35b3480156111c157600080fd5b506111ee600480360360208110156111d857600080fd5b8101908080359060200190929190505050613a12565b005b3480156111fc57600080fd5b50611314600480360361014081101561121457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561125b57600080fd5b82018360208201111561126d57600080fd5b8035906020019184600183028401116401000000008311171561128f57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613bb1565b6040518082815260200191505060405180910390f35b34801561133657600080fd5b506113996004803603604081101561134d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613bde565b005b3480156113a757600080fd5b506113ea600480360360208110156113be57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613f6f565b005b3480156113f857600080fd5b5061147b6004803603606081101561140f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613ff3565b005b34801561148957600080fd5b50611492614665565b6040518082815260200191505060405180910390f35b3480156114b457600080fd5b506115cc60048036036101408110156114cc57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561151357600080fd5b82018360208201111561152557600080fd5b8035906020019184600183028401116401000000008311171561154757600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019092919050505061466f565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561160c5780820151818401526020810190506115f1565b50505050905090810190601f1680156116395780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561165357600080fd5b506116966004803603602081101561166a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050614817565b005b3480156116a457600080fd5b506116ad614878565b6040518082815260200191505060405180910390f35b3480156116cf57600080fd5b5061173c600480360360608110156116e657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506148f6565b005b34801561174a57600080fd5b50611753614d29565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015611793578082015181840152602081019050611778565b50505050905090810190601f1680156117c05780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6117d6614d62565b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156118405750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b801561187857503073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b6118ea576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146119eb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508160026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600081548092919060010191905055507f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2682604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a18060045414611bba57611bb981612c3e565b5b5050565b611bd2604182614e0590919063ffffffff16565b82511015611c48576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808060008060005b8681101561243457611c648882614e3f565b80945081955082965050505060008460ff16141561206d578260001c9450611c96604188614e0590919063ffffffff16565b8260001c1015611d0e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8751611d2760208460001c614e6e90919063ffffffff16565b1115611d9b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006020838a01015190508851611dd182611dc360208760001c614e6e90919063ffffffff16565b614e6e90919063ffffffff16565b1115611e45576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60606020848b010190506320c13b0b60e01b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168773ffffffffffffffffffffffffffffffffffffffff166320c13b0b8d846040518363ffffffff1660e01b8152600401808060200180602001838103835285818151815260200191508051906020019080838360005b83811015611ee7578082015181840152602081019050611ecc565b50505050905090810190601f168015611f145780820380516001836020036101000a031916815260200191505b50838103825284818151815260200191508051906020019080838360005b83811015611f4d578082015181840152602081019050611f32565b50505050905090810190601f168015611f7a5780820380516001836020036101000a031916815260200191505b5094505050505060206040518083038186803b158015611f9957600080fd5b505afa158015611fad573d6000803e3d6000fd5b505050506040513d6020811015611fc357600080fd5b81019080805190602001909291905050507bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614612066576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b50506122b2565b60018460ff161415612181578260001c94508473ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16148061210a57506000600860008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008c81526020019081526020016000205414155b61217c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6122b1565b601e8460ff1611156122495760018a60405160200180807f19457468657265756d205369676e6564204d6573736167653a0a333200000000815250601c018281526020019150506040516020818303038152906040528051906020012060048603858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa158015612238573d6000803e3d6000fd5b5050506020604051035194506122b0565b60018a85858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156122a3573d6000803e3d6000fd5b5050506020604051035194505b5b5b8573ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff161180156123795750600073ffffffffffffffffffffffffffffffffffffffff16600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b80156123b25750600173ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1614155b612424576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8495508080600101915050611c52565b50505050505050505050565b60008173ffffffffffffffffffffffffffffffffffffffff16600173ffffffffffffffffffffffffffffffffffffffff161415801561250b5750600073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156125dd5750600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000804690508091505090565b6000600173ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141580156126bc5750600073ffffffffffffffffffffffffffffffffffffffff16600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b61272e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b61273b858585855a614e8d565b9050801561278b573373ffffffffffffffffffffffffffffffffffffffff167f6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb860405160405180910390a26127cf565b3373ffffffffffffffffffffffffffffffffffffffff167facd2c8702804128fdb0db2bb49f6d127dd0181c13fd45dbfe16de0930e2bd37560405160405180910390a25b949350505050565b600060606127e7868686866125f1565b915060405160203d0181016040523d81523d6000602083013e8091505094509492505050565b606060006020830267ffffffffffffffff8111801561282b57600080fd5b506040519080825280601f01601f19166020018201604052801561285e5781602001600182028036833780820191505090505b50905060005b8381101561288957808501548060208302602085010152508080600101915050612864565b508091505092915050565b60076020528060005260406000206000915090505481565b6128b4614d62565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415801561291e5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b612990576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614612a91576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507fecdf3a3effea5783a3c4c2140e677577666428d44ed9d474a0b3a4c9943f844081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b612c46614d62565b600354811115612cbe576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001811015612d35576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b806004819055507f610f7ff2b304ae8903c3de74c60c6ab1f7d6226b3f52c5161905bb5ad4039c936004546040518082815260200191505060405180910390a150565b6000806000612d928e8e8e8e8e8e8e8e8e8e60055461466f565b905060056000815480929190600101919050555080805190602001209150612dbb8282866132da565b506000612dc6614ed9565b9050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614612fac578073ffffffffffffffffffffffffffffffffffffffff166375f0bb528f8f8f8f8f8f8f8f8f8f8f336040518d63ffffffff1660e01b8152600401808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115612e6957fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018473ffffffffffffffffffffffffffffffffffffffff16815260200183810383528d8d82818152602001925080828437600081840152601f19601f820116905080830192505050838103825285818151815260200191508051906020019080838360005b83811015612f3b578082015181840152602081019050612f20565b50505050905090810190601f168015612f685780820380516001836020036101000a031916815260200191505b509e505050505050505050505050505050600060405180830381600087803b158015612f9357600080fd5b505af1158015612fa7573d6000803e3d6000fd5b505050505b6101f4612fd36109c48b01603f60408d0281612fc457fe5b04614f0a90919063ffffffff16565b015a1015613049576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60005a90506130b28f8f8f8f8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050508e60008d146130a7578e6130ad565b6109c45a035b614e8d565b93506130c75a82614f2490919063ffffffff16565b905083806130d6575060008a14155b806130e2575060008814155b613154576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60008089111561316e5761316b828b8b8b8b614f44565b90505b84156131b8577f442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e8482604051808381526020018281526020019250505060405180910390a16131f8565b7f23428b18acfb3ea64b08dc0c1d296ea9c09702c09083ca5272e64d115b687d238482604051808381526020018281526020019250505060405180910390a15b5050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146132a4578073ffffffffffffffffffffffffffffffffffffffff16639327136883856040518363ffffffff1660e01b815260040180838152602001821515815260200192505050600060405180830381600087803b15801561328b57600080fd5b505af115801561329f573d6000803e3d6000fd5b505050505b50509b9a5050505050505050505050565b6008602052816000526040600020602052806000526040600020600091509150505481565b6000600454905060008111613357576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b61336384848484611bbe565b50505050565b6060600060035467ffffffffffffffff8111801561338657600080fd5b506040519080825280602002602001820160405280156133b55781602001602082028036833780820191505090505b50905060008060026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614613509578083838151811061346057fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600260008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050818060010192505061341f565b82935050505090565b60055481565b600080825160208401855af4806000523d6020523d600060403e60403d016000fd5b6135858a8a80806020026020016040519081016040528093929190818152602001838360200280828437600081840152601f19601f820116905080830192505050505050508961514a565b600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16146135c3576135c28461564a565b5b6136118787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050615679565b600082111561362b5761362982600060018685614f44565b505b3373ffffffffffffffffffffffffffffffffffffffff167f141df868a6331af528e38c83b7aa03edc19be66e37ae67f9285bf4f8e3c6a1a88b8b8b8b8960405180806020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281038252878782818152602001925060200280828437600081840152601f19601f820116905080830192505050965050505050505060405180910390a250505050505050505050565b6000805a905061374f878787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050865a614e8d565b61375857600080fd5b60005a8203905080604051602001808281526020019150506040516020818303038152906040526040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825283818151815260200191508051906020019080838360005b838110156137e55780820151818401526020810190506137ca565b50505050905090810190601f1680156138125780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b606060008267ffffffffffffffff8111801561383b57600080fd5b5060405190808252806020026020018201604052801561386a5781602001602082028036833780820191505090505b509150600080600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415801561393d5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561394857508482105b15613a03578084838151811061395a57fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905081806001019250506138d3565b80925081845250509250929050565b600073ffffffffffffffffffffffffffffffffffffffff16600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161415613b14576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001600860003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000838152602001908152602001600020819055503373ffffffffffffffffffffffffffffffffffffffff16817ff2a0eb156472d1440255b0d7c1e19cc07115d1051fe605b0dce69acfec884d9c60405160405180910390a350565b6000613bc68c8c8c8c8c8c8c8c8c8c8c61466f565b8051906020012090509b9a5050505050505050505050565b613be6614d62565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614158015613c505750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b613cc2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614613dc2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507faab4fa2b463f581b2b32cb3b7e3b704b9ce37cc209b5fb4d77e593ace405427681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613f77614d62565b60007f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b90508181557f1151116914515bc0891ff9047a6cb32cf902546f83066499bcf8ba33d2353fa282604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613ffb614d62565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156140655750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561409d57503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b61410f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614210576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161415801561427a5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b6142ec576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146143ec576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555080600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a17f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a1505050565b6000600454905090565b606060007fbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d860001b8d8d8d8d60405180838380828437808301925050509250505060405180910390208c8c8c8c8c8c8c604051602001808c81526020018b73ffffffffffffffffffffffffffffffffffffffff1681526020018a815260200189815260200188600181111561470057fe5b81526020018781526020018681526020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019b505050505050505050505050604051602081830303815290604052805190602001209050601960f81b600160f81b61478c614878565b8360405160200180857effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168152600101847effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff191681526001018381526020018281526020019450505050506040516020818303038152906040529150509b9a5050505050505050505050565b61481f614d62565b6148288161564a565b7f5ac6c46c93c8d0e53714ba3b53db3e7c046da994313d7ed0d192028bc7c228b081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b60007f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a7946921860001b6148a66125e4565b30604051602001808481526020018381526020018273ffffffffffffffffffffffffffffffffffffffff168152602001935050505060405160208183030381529060405280519060200120905090565b6148fe614d62565b806001600354031015614979576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156149e35750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b614a55576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614b55576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600360008154809291906001900391905055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a18060045414614d2457614d2381612c3e565b5b505050565b6040518060400160405280600581526020017f312e332e3000000000000000000000000000000000000000000000000000000081525081565b3073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614614e03576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b565b600080831415614e185760009050614e39565b6000828402905082848281614e2957fe5b0414614e3457600080fd5b809150505b92915050565b60008060008360410260208101860151925060408101860151915060ff60418201870151169350509250925092565b600080828401905083811015614e8357600080fd5b8091505092915050565b6000600180811115614e9b57fe5b836001811115614ea757fe5b1415614ec0576000808551602087018986f49050614ed0565b600080855160208701888a87f190505b95945050505050565b6000807f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b9050805491505090565b600081831015614f1a5781614f1c565b825b905092915050565b600082821115614f3357600080fd5b600082840390508091505092915050565b600080600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614614f815782614f83565b325b9050600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16141561509b57614fed3a8610614fca573a614fcc565b855b614fdf888a614e6e90919063ffffffff16565b614e0590919063ffffffff16565b91508073ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f19350505050615096576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b615140565b6150c0856150b2888a614e6e90919063ffffffff16565b614e0590919063ffffffff16565b91506150cd8482846158b4565b61513f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5095945050505050565b6000600454146151c2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8151811115615239576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60018110156152b0576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006001905060005b83518110156155b65760008482815181106152d057fe5b60200260200101519050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156153445750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561537c57503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b80156153b457508073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614155b615426576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614615527576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b80600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508092505080806001019150506152b9565b506001600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550825160038190555081600481905550505050565b60007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b90508181555050565b600073ffffffffffffffffffffffffffffffffffffffff1660016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461577b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001806000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16146158b05761583d8260008360015a614e8d565b6158af576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5050565b60008063a9059cbb8484604051602401808373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001925050506040516020818303038152906040529060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050509050602060008251602084016000896127105a03f13d6000811461595b5760208114615963576000935061596e565b81935061596e565b600051158215171593505b505050939250505056fea26469706673582212203874bcf92e1722cc7bfa0cef1a0985cf0dc3485ba0663db3747ccdf1605df53464736f6c63430007060033", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226": { + "balance": 0, + "code": "0x608060405234801561001057600080fd5b50600436106101355760003560e01c8063541d63c8116100b2578063bbe5dc4f11610081578063bd61951d11610066578063bd61951d14610347578063f23a6e611461035a578063f698da251461039357600080fd5b8063bbe5dc4f146102f9578063bc197c811461030c57600080fd5b8063541d63c8146102ab5780636ac24784146102be5780637bb37428146102d1578063b2494df3146102e457600080fd5b8063150b7a021161010957806319822f7c116100ee57806319822f7c1461026557806320c13b0b14610278578063230316401461028b57600080fd5b8063150b7a02146101e95780631626ba7e1461025257600080fd5b806223de291461013a57806301ffc9a7146101545780630a1028c41461017c578063137e051e1461019d575b600080fd5b610152610148366004611512565b5050505050505050565b005b6101676101623660046115f1565b6103ef565b60405190151581526020015b60405180910390f35b61018f61018a366004611728565b6104d4565b604051908152602001610173565b6101c47f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da03281565b60405173ffffffffffffffffffffffffffffffffffffffff9091168152602001610173565b6102216101f7366004611765565b7f150b7a020000000000000000000000000000000000000000000000000000000095945050505050565b6040517fffffffff000000000000000000000000000000000000000000000000000000009091168152602001610173565b6102216102603660046117d8565b6104e0565b61018f61027336600461183d565b610601565b61022161028636600461188b565b6108f0565b61029e6102993660046118ef565b610ac1565b60405161017391906119a3565b6101526102b93660046119b6565b610bf8565b61018f6102cc3660046118ef565b610d52565b6101526102df3660046119b6565b610d6d565b6102ec610ec3565b6040516101739190611a28565b61018f610307366004611a82565b610f75565b61022161031a366004611afc565b7fbc197c810000000000000000000000000000000000000000000000000000000098975050505050505050565b61029e610355366004611b9a565b610f97565b610221610368366004611bd6565b7ff23a6e61000000000000000000000000000000000000000000000000000000009695505050505050565b61018f604080517f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a794692186020820152469181019190915230606082015260009060800160405160208183030381529060405280519060200120905090565b60007fffffffff0000000000000000000000000000000000000000000000000000000082167f4e2312e000000000000000000000000000000000000000000000000000000000148061048257507fffffffff0000000000000000000000000000000000000000000000000000000082167f150b7a0200000000000000000000000000000000000000000000000000000000145b806104ce57507fffffffff0000000000000000000000000000000000000000000000000000000082167f01ffc9a700000000000000000000000000000000000000000000000000000000145b92915050565b60006104ce3383610d52565b60408051602080820186905282518083039091018152818301928390527f20c13b0b000000000000000000000000000000000000000000000000000000009092526000913391839183916320c13b0b916105409189908990604401611c9b565b602060405180830381865afa15801561055d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906105819190611ccb565b90507fffffffff0000000000000000000000000000000000000000000000000000000081167f20c13b0b00000000000000000000000000000000000000000000000000000000146105d35760006105f5565b7f1626ba7e000000000000000000000000000000000000000000000000000000005b925050505b9392505050565b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec36013560601c7f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da03273ffffffffffffffffffffffffffffffffffffffff1614610698576040517f85db247d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60006106a76020860186611ce8565b905073ffffffffffffffffffffffffffffffffffffffff811633146106f8576040517f48f5c3ed00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60006107076060870187611d05565b61071091611d6a565b90507fffffffff0000000000000000000000000000000000000000000000000000000081167f7bb3742800000000000000000000000000000000000000000000000000000000148015906107a657507fffffffff0000000000000000000000000000000000000000000000000000000081167f541d63c80000000000000000000000000000000000000000000000000000000014155b15610806576040517f45c229ce0000000000000000000000000000000000000000000000000000000081527fffffffff00000000000000000000000000000000000000000000000000000000821660048201526024015b60405180910390fd5b61080f86610ffa565b925083156108e7576040517f468721a700000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da032811660048301526024820186905260806044830152600060848301819052606483015283169063468721a79060a4016020604051808303816000875af11580156108c1573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108e59190611dc7565b505b50509392505050565b600033816108fe8286610ac1565b80516020820120855191925090600003610a0f576040517f5ae6bd370000000000000000000000000000000000000000000000000000000081526004810182905273ffffffffffffffffffffffffffffffffffffffff841690635ae6bd3790602401602060405180830381865afa15801561097d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906109a19190611de2565b600003610a0a576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f48617368206e6f7420617070726f76656400000000000000000000000000000060448201526064016107fd565b610a96565b6040517f934f3a1100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff84169063934f3a1190610a6590849086908a90600401611dfb565b60006040518083038186803b158015610a7d57600080fd5b505afa158015610a91573d6000803e3d6000fd5b505050505b507f20c13b0b0000000000000000000000000000000000000000000000000000000095945050505050565b606060007f60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca60001b8380519060200120604051602001610b0b929190918252602082015260400190565b604051602081830303815290604052805190602001209050601960f81b600160f81b8573ffffffffffffffffffffffffffffffffffffffff1663f698da256040518163ffffffff1660e01b8152600401602060405180830381865afa158015610b78573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610b9c9190611de2565b6040517fff00000000000000000000000000000000000000000000000000000000000000938416602082015292909116602183015260228201526042810182905260620160405160208183030381529060405291505092915050565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec36013560601c7f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da03273ffffffffffffffffffffffffffffffffffffffff1614610c8d576040517f85db247d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6040517f5229073f00000000000000000000000000000000000000000000000000000000815260009081903390635229073f90610cd4908990899089908990600401611e26565b6000604051808303816000875af1158015610cf3573d6000803e3d6000fd5b505050506040513d6000823e601f3d9081017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0168201604052610d399190810190611e6f565b9150915081610d4a57805160208201fd5b505050505050565b6000610d5e8383610ac1565b80519060200120905092915050565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec36013560601c7f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da03273ffffffffffffffffffffffffffffffffffffffff1614610e02576040517f85db247d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6040517f468721a7000000000000000000000000000000000000000000000000000000008152339063468721a790610e44908790879087908790600401611e26565b6020604051808303816000875af1158015610e63573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610e879190611dc7565b610ebd576040517facfdb44400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b50505050565b6040517fcc2f845200000000000000000000000000000000000000000000000000000000815260016004820152600a60248201526060903390600090829063cc2f845290604401600060405180830381865afa158015610f27573d6000803e3d6000fd5b505050506040513d6000823e601f3d9081017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0168201604052610f6d9190810190611f04565b509392505050565b600080610f81836110dd565b5050505090508080519060200120915050919050565b60606040517fb4faba09000000000000000000000000000000000000000000000000000000008152600436036004808301376020600036836000335af1505060203d036040519150808201604052806020833e506000516105fa57805160208201fd5b60008060008036600061100c876110dd565b9398509196509450925090506110256020880188611ce8565b855160208701206040517f934f3a1100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff929092169163934f3a119161108491899087908790600401611fc8565b60006040518083038186803b15801561109c57600080fd5b505afa9250505080156110ad575060015b6110c4576110bd60018486611411565b95506110d3565b6110d060008486611411565b95505b5050505050919050565b6060600080368181816110f4610100890189611d05565b9092509050611107600660008385611fff565b61111091612029565b60d01c9550611123600c60068385611fff565b61112c91612029565b60d01c945061113e81600c8185611fff565b9350935050506000604051806101c001604052807fc03dfc11d8b10bf9cf703d558958c8c42777f785d998c62060d85a4f0ef6ea7f60001b815260200188600001602081019061118e9190611ce8565b73ffffffffffffffffffffffffffffffffffffffff168152602001886020013581526020018880604001906111c39190611d05565b6040516111d192919061206f565b60405190819003902081526020016111ec60608a018a611d05565b6040516111fa92919061206f565b6040518091039020815260200161121089611449565b6fffffffffffffffffffffffffffffffff16815260200161123089611459565b6fffffffffffffffffffffffffffffffff1681526020018860a00135815260200161125a89611477565b6fffffffffffffffffffffffffffffffff16815260200161127a89611486565b6fffffffffffffffffffffffffffffffff16815260200161129e60e08a018a611d05565b6040516112ac92919061206f565b6040805191829003909120825265ffffffffffff808916602084015287169082015273ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da032166060909101526101c081209091507f19000000000000000000000000000000000000000000000000000000000000007f01000000000000000000000000000000000000000000000000000000000000006113b1604080517f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a794692186020820152469181019190915230606082015260009060800160405160208183030381529060405280519060200120905090565b6040517fff0000000000000000000000000000000000000000000000000000000000000093841660208201529290911660218301526022820152604281018290526062016040516020818303038152906040529650505091939590929450565b600060d08265ffffffffffff16901b60a08465ffffffffffff16901b8561143957600061143c565b60015b60ff161717949350505050565b60006104ce826080013560801c90565b60006fffffffffffffffffffffffffffffffff6080830135166104ce565b600060c082013560801c6104ce565b60006fffffffffffffffffffffffffffffffff60c0830135166104ce565b73ffffffffffffffffffffffffffffffffffffffff811681146114c657600080fd5b50565b60008083601f8401126114db57600080fd5b50813567ffffffffffffffff8111156114f357600080fd5b60208301915083602082850101111561150b57600080fd5b9250929050565b60008060008060008060008060c0898b03121561152e57600080fd5b8835611539816114a4565b97506020890135611549816114a4565b96506040890135611559816114a4565b955060608901359450608089013567ffffffffffffffff8082111561157d57600080fd5b6115898c838d016114c9565b909650945060a08b01359150808211156115a257600080fd5b506115af8b828c016114c9565b999c989b5096995094979396929594505050565b7fffffffff00000000000000000000000000000000000000000000000000000000811681146114c657600080fd5b60006020828403121561160357600080fd5b81356105fa816115c3565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016810167ffffffffffffffff811182821017156116845761168461160e565b604052919050565b600067ffffffffffffffff8211156116a6576116a661160e565b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200190565b600082601f8301126116e357600080fd5b81356116f66116f18261168c565b61163d565b81815284602083860101111561170b57600080fd5b816020850160208301376000918101602001919091529392505050565b60006020828403121561173a57600080fd5b813567ffffffffffffffff81111561175157600080fd5b61175d848285016116d2565b949350505050565b60008060008060006080868803121561177d57600080fd5b8535611788816114a4565b94506020860135611798816114a4565b935060408601359250606086013567ffffffffffffffff8111156117bb57600080fd5b6117c7888289016114c9565b969995985093965092949392505050565b6000806000604084860312156117ed57600080fd5b83359250602084013567ffffffffffffffff81111561180b57600080fd5b611817868287016114c9565b9497909650939450505050565b6000610120828403121561183757600080fd5b50919050565b60008060006060848603121561185257600080fd5b833567ffffffffffffffff81111561186957600080fd5b61187586828701611824565b9660208601359650604090950135949350505050565b6000806040838503121561189e57600080fd5b823567ffffffffffffffff808211156118b657600080fd5b6118c2868387016116d2565b935060208501359150808211156118d857600080fd5b506118e5858286016116d2565b9150509250929050565b6000806040838503121561190257600080fd5b823561190d816114a4565b9150602083013567ffffffffffffffff81111561192957600080fd5b6118e5858286016116d2565b60005b83811015611950578181015183820152602001611938565b50506000910152565b60008151808452611971816020860160208601611935565b601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b6020815260006105fa6020830184611959565b600080600080608085870312156119cc57600080fd5b84356119d7816114a4565b935060208501359250604085013567ffffffffffffffff8111156119fa57600080fd5b611a06878288016116d2565b925050606085013560ff81168114611a1d57600080fd5b939692955090935050565b6020808252825182820181905260009190848201906040850190845b81811015611a7657835173ffffffffffffffffffffffffffffffffffffffff1683529284019291840191600101611a44565b50909695505050505050565b600060208284031215611a9457600080fd5b813567ffffffffffffffff811115611aab57600080fd5b61175d84828501611824565b60008083601f840112611ac957600080fd5b50813567ffffffffffffffff811115611ae157600080fd5b6020830191508360208260051b850101111561150b57600080fd5b60008060008060008060008060a0898b031215611b1857600080fd5b8835611b23816114a4565b97506020890135611b33816114a4565b9650604089013567ffffffffffffffff80821115611b5057600080fd5b611b5c8c838d01611ab7565b909850965060608b0135915080821115611b7557600080fd5b611b818c838d01611ab7565b909650945060808b01359150808211156115a257600080fd5b600080600060408486031215611baf57600080fd5b8335611bba816114a4565b9250602084013567ffffffffffffffff81111561180b57600080fd5b60008060008060008060a08789031215611bef57600080fd5b8635611bfa816114a4565b95506020870135611c0a816114a4565b94506040870135935060608701359250608087013567ffffffffffffffff811115611c3457600080fd5b611c4089828a016114c9565b979a9699509497509295939492505050565b8183528181602085013750600060208284010152600060207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f840116840101905092915050565b604081526000611cae6040830186611959565b8281036020840152611cc1818587611c52565b9695505050505050565b600060208284031215611cdd57600080fd5b81516105fa816115c3565b600060208284031215611cfa57600080fd5b81356105fa816114a4565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112611d3a57600080fd5b83018035915067ffffffffffffffff821115611d5557600080fd5b60200191503681900382131561150b57600080fd5b7fffffffff000000000000000000000000000000000000000000000000000000008135818116916004851015611daa5780818660040360031b1b83161692505b505092915050565b80518015158114611dc257600080fd5b919050565b600060208284031215611dd957600080fd5b6105fa82611db2565b600060208284031215611df457600080fd5b5051919050565b838152606060208201526000611e146060830185611959565b8281036040840152611cc18185611959565b73ffffffffffffffffffffffffffffffffffffffff85168152836020820152608060408201526000611e5b6080830185611959565b905060ff8316606083015295945050505050565b60008060408385031215611e8257600080fd5b611e8b83611db2565b9150602083015167ffffffffffffffff811115611ea757600080fd5b8301601f81018513611eb857600080fd5b8051611ec66116f18261168c565b818152866020838501011115611edb57600080fd5b611eec826020830160208601611935565b8093505050509250929050565b8051611dc2816114a4565b60008060408385031215611f1757600080fd5b825167ffffffffffffffff80821115611f2f57600080fd5b818501915085601f830112611f4357600080fd5b8151602082821115611f5757611f5761160e565b8160051b9250611f6881840161163d565b8281529284018101928181019089851115611f8257600080fd5b948201945b84861015611fac5785519350611f9c846114a4565b8382529482019490820190611f87565b9650611fbb9050878201611ef9565b9450505050509250929050565b848152606060208201526000611fe16060830186611959565b8281036040840152611ff4818587611c52565b979650505050505050565b6000808585111561200f57600080fd5b8386111561201c57600080fd5b5050820193919092039150565b7fffffffffffff00000000000000000000000000000000000000000000000000008135818116916006851015611daa5760069490940360031b84901b1690921692915050565b818382376000910190815291905056fea2646970667358221220e42d44baf807ad5bd112459029994f688e5e18f6848983388139295e896c84b764736f6c63430008170033", + "nonce": 1, + "storage": {} + }, + "0x7A0D94F55792C434d74a40883C6ed8545E406D12": { + "balance": 0, + "code": "0x60003681823780368234f58015156014578182fd5b80825250506014600cf3", + "nonce": 1, + "storage": {} + }, + "0x7fc98430eAEdbb6070B35B39D798725049088348": { + "balance": 0, + "code": "0x6080604052600436101561001257600080fd5b6000803560e01c63570e1a361461002857600080fd5b346100c95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126100c95760043567ffffffffffffffff918282116100c957366023830112156100c95781600401359283116100c95736602484840101116100c9576100c561009e84602485016100fc565b60405173ffffffffffffffffffffffffffffffffffffffff90911681529081906020820190565b0390f35b80fd5b507f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b90806014116101bb5767ffffffffffffffff917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec82018381116101cd575b604051937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0603f81600b8701160116850190858210908211176101c0575b604052808452602084019036848401116101bb576020946000600c819682946014880187378301015251923560601c5af19060005191156101b557565b60009150565b600080fd5b6101c86100cc565b610178565b6101d56100cc565b61013a56fea26469706673582212201927e80b76ab9b71c952137dd676621a9fdf520c25928815636594036eb1c40364736f6c63430008110033", + "nonce": 1, + "storage": {} + }, + "0x822B0B93BE3f3B8Da35a2E90e877C01215be8506": { + "balance": 0, + "code": "0x608060405234801561001057600080fd5b50600436106100415760003560e01c806354fd4d501461004657806360d7a27814610064578063a2ea7c6e14610085575b600080fd5b61004e6100a5565b60405161005b9190610683565b60405180910390f35b6100776100723660046106b2565b610148565b60405190815260200161005b565b610098610093366004610760565b61030c565b60405161005b9190610779565b60606100d07f0000000000000000000000000000000000000000000000000000000000000001610434565b6100f97f0000000000000000000000000000000000000000000000000000000000000004610434565b6101227f0000000000000000000000000000000000000000000000000000000000000000610434565b604051602001610134939291906107d2565b604051602081830303815290604052905090565b60008060405180608001604052806000801b81526020018573ffffffffffffffffffffffffffffffffffffffff168152602001841515815260200187878080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920182905250939094525092935091506101ca9050826104f2565b60008181526020819052604090205490915015610213576040517f23369fa600000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b80825260008181526020818152604091829020845181559084015160018201805493860151151574010000000000000000000000000000000000000000027fffffffffffffffffffffff00000000000000000000000000000000000000000090941673ffffffffffffffffffffffffffffffffffffffff9092169190911792909217909155606083015183919060028201906102af908261093a565b509050503373ffffffffffffffffffffffffffffffffffffffff16817fd0b86852e21f9e5fa4bc3b0cff9757ffe243d50c4b43968a42202153d651ea5e846040516102fa9190610779565b60405180910390a39695505050505050565b604080516080810182526000808252602082018190529181019190915260608082015260008281526020818152604091829020825160808101845281548152600182015473ffffffffffffffffffffffffffffffffffffffff8116938201939093527401000000000000000000000000000000000000000090920460ff161515928201929092526002820180549192916060840191906103ab90610898565b80601f01602080910402602001604051908101604052809291908181526020018280546103d790610898565b80156104245780601f106103f957610100808354040283529160200191610424565b820191906000526020600020905b81548152906001019060200180831161040757829003601f168201915b5050505050815250509050919050565b6060600061044183610532565b600101905060008167ffffffffffffffff81111561046157610461610869565b6040519080825280601f01601f19166020018201604052801561048b576020820181803683370190505b5090508181016020015b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff017f3031323334353637383961626364656600000000000000000000000000000000600a86061a8153600a850494508461049557509392505050565b600081606001518260200151836040015160405160200161051593929190610a53565b604051602081830303815290604052805190602001209050919050565b6000807a184f03e93ff9f4daa797ed6e38ed64bf6a1f010000000000000000831061057b577a184f03e93ff9f4daa797ed6e38ed64bf6a1f010000000000000000830492506040015b6d04ee2d6d415b85acef810000000083106105a7576d04ee2d6d415b85acef8100000000830492506020015b662386f26fc1000083106105c557662386f26fc10000830492506010015b6305f5e10083106105dd576305f5e100830492506008015b61271083106105f157612710830492506004015b60648310610603576064830492506002015b600a831061060f576001015b92915050565b60005b83811015610630578181015183820152602001610618565b50506000910152565b60008151808452610651816020860160208601610615565b601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b6020815260006106966020830184610639565b9392505050565b803580151581146106ad57600080fd5b919050565b600080600080606085870312156106c857600080fd5b843567ffffffffffffffff8111156106df57600080fd5b8501601f810187136106f057600080fd5b803567ffffffffffffffff81111561070757600080fd5b87602082840101111561071957600080fd5b60209182019550935085013573ffffffffffffffffffffffffffffffffffffffff8116811461074757600080fd5b91506107556040860161069d565b905092959194509250565b60006020828403121561077257600080fd5b5035919050565b602081528151602082015273ffffffffffffffffffffffffffffffffffffffff6020830151166040820152604082015115156060820152600060608301516080808401526107ca60a0840182610639565b949350505050565b600084516107e4818460208901610615565b7f2e00000000000000000000000000000000000000000000000000000000000000908301908152845161081e816001840160208901610615565b7f2e0000000000000000000000000000000000000000000000000000000000000060019290910191820152835161085c816002840160208801610615565b0160020195945050505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600181811c908216806108ac57607f821691505b6020821081036108e5577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b50919050565b601f82111561093557806000526020600020601f840160051c810160208510156109125750805b601f840160051c820191505b81811015610932576000815560010161091e565b50505b505050565b815167ffffffffffffffff81111561095457610954610869565b610968816109628454610898565b846108eb565b6020601f8211600181146109ba57600083156109845750848201515b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600385901b1c1916600184901b178455610932565b6000848152602081207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08516915b82811015610a0857878501518255602094850194600190920191016109e8565b5084821015610a4457868401517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600387901b60f8161c191681555b50505050600190811b01905550565b60008451610a65818460208901610615565b60609490941b7fffffffffffffffffffffffffffffffffffffffff000000000000000000000000169190930190815290151560f81b60148201526015019291505056fea164736f6c634300081c000a", + "nonce": 1, + "storage": {} + }, + "0x845ADb2C711129d4f3966735eD98a9F09fC4cE57": { + "balance": 0, + "code": "0x608060408181526004918236101561001657600080fd5b600090813560e01c908163173bf7da1461043a57816320709efc14610403575080636d61fe701461034a5780638a91b0e3146102d4578063970032031461028e578063d60b347f14610240578063d68f602514610124578063ecd05961146100e55763f551e2ee1461008757600080fd5b346100e25760603660031901126100e2576100a061049e565b506044359067ffffffffffffffff82116100e257506100c76020936100d09236910161046b565b906024356105e5565b90516001600160e01b03199091168152f35b80fd5b50919034610120576020366003190112610120576020925081359160018314928315610116575b5050519015158152f35b149150388061010c565b8280fd5b509160603660031901126101205761013a61049e565b9267ffffffffffffffff6044358181116101205761015b903690850161046b565b505033825260208281528483205490956001600160a01b039182169116036101f157835192858401918211848310176101de575083949291945283815282519382859384528251928382860152825b8481106101c857505050828201840152601f01601f19168101030190f35b81810183015188820188015287955082016101aa565b634e487b7160e01b835260419052602482fd5b835162461bcd60e51b8152808401869052602360248201527f454344534156616c696461746f723a2073656e646572206973206e6f74206f776044820152623732b960e91b6064820152608490fd5b50903461028a57602036600319011261028a5760209061028161026161049e565b6001600160a01b0390811660009081526020819052604090205416151590565b90519015158152f35b5080fd5b506003199082823601126100e25783359167ffffffffffffffff831161028a576101209083360301126100e257506020926102cd91602435910161051d565b9051908152f35b5091602036600319011261012057803567ffffffffffffffff811161034657610300903690830161046b565b5050336000908152602081905260409020546001600160a01b031615610330575081903382528160205281205580f35b602491519063f91bd6f160e01b82523390820152fd5b8380fd5b5091602036600319011261012057803567ffffffffffffffff811161034657610376903690830161046b565b33600090815260208190526040902054919290916001600160a01b03166103ec5750601411610120573560601c90338352826020528220816bffffffffffffffffffffffff60a01b825416179055337fa5e1f8b4009110f5525798d04ae2125421a12d0590aa52c13682ff1bd3c492ca8380a380f35b6024908451906393360fbf60e01b82523390820152fd5b90508234610120576020366003190112610120576020926001600160a01b0391908261042d61049e565b1681528085522054168152f35b8285602036600319011261028a5780359067ffffffffffffffff8211610120576104669136910161046b565b505080f35b9181601f840112156104995782359167ffffffffffffffff8311610499576020838186019501011161049957565b600080fd5b600435906001600160a01b038216820361049957565b92919267ffffffffffffffff918281116105075760405192601f8201601f19908116603f011684019081118482101761050757604052829481845281830111610499578281602093846000960137010152565b634e487b7160e01b600052604160045260246000fd5b3360009081526020819052604081205490926001600160a01b03918216926101008101359036819003601e19018212156105e1570180359067ffffffffffffffff82116105e1576020019181360383136105e157836105866105803685876104b4565b8361067d565b1685146105d9576105ca926105c4916020527b19457468657265756d205369676e6564204d6573736167653a0a33328752603c6004209236916104b4565b9061067d565b16036105d35790565b50600190565b505050505090565b8580fd5b919033600052600060205260018060a01b0391826040600020541693836106106105803685876104b4565b16851461066c5761064f926105c4916020527b19457468657265756d205369676e6564204d6573736167653a0a3332600052603c6004209236916104b4565b160361066057630b135d3f60e11b90565b6001600160e01b031990565b50630b135d3f60e11b949350505050565b919091602060016080600082948390604080998151986106bb575b5050505afa51923d156106ad57600060605252565b638baa579f6000526004601cfd5b8492835287808201518352828251146106fb5760418251146106e4575050509050388881610698565b60609384830151901a905201519052388881610698565b9250015190601b8260ff1c019052848060ff1b031660605238888161069856", + "nonce": 1, + "storage": {} + }, + "0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7": { + "balance": 0, + "code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3", + "nonce": 1, + "storage": {} + }, + "0x9641d764fc13c8B624c04430C7356C1C7C8102e2": { + "balance": 0, + "code": "0x60806040526004361061001e5760003560e01c80638d80ff0a14610023575b600080fd5b6100dc6004803603602081101561003957600080fd5b810190808035906020019064010000000081111561005657600080fd5b82018360208201111561006857600080fd5b8035906020019184600183028401116401000000008311171561008a57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506100de565b005b805160205b8181101561015f578083015160f81c6001820184015160601c60158301850151603584018601516055850187016000856000811461012857600181146101385761013d565b6000808585888a5af1915061013d565b600080fd5b50600081141561014c57600080fd5b82605501870196505050505050506100e3565b50505056fea26469706673582212208d297bb003abee230b5dfb38774688f37a6fbb97a82a21728e8049b2acb9b73564736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0x998739BFdAAdde7C933B942a68053933098f9EDa": { + "balance": 0, + "code": "0x60806040526004361061001e5760003560e01c80638d80ff0a14610023575b600080fd5b6100dc6004803603602081101561003957600080fd5b810190808035906020019064010000000081111561005657600080fd5b82018360208201111561006857600080fd5b8035906020019184600183028401116401000000008311171561008a57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506100de565b005b7f000000000000000000000000998739bfdaadde7c933b942a68053933098f9eda73ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff161415610183576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260308152602001806102106030913960400191505060405180910390fd5b805160205b8181101561020a578083015160f81c6001820184015160601c6015830185015160358401860151605585018701600085600081146101cd57600181146101dd576101e8565b6000808585888a5af191506101e8565b6000808585895af491505b5060008114156101f757600080fd5b8260550187019650505050505050610188565b50505056fe4d756c746953656e642073686f756c64206f6e6c792062652063616c6c6564207669612064656c656761746563616c6ca26469706673582212205c784303626eec02b71940b551976170b500a8a36cc5adcbeb2c19751a76d05464736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B": { + "balance": 0, + "code": "0x60806040526004361061001e5760003560e01c80638d80ff0a14610023575b600080fd5b6100dc6004803603602081101561003957600080fd5b810190808035906020019064010000000081111561005657600080fd5b82018360208201111561006857600080fd5b8035906020019184600183028401116401000000008311171561008a57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506100de565b005b805160205b8181101561015f578083015160f81c6001820184015160601c60158301850151603584018601516055850187016000856000811461012857600181146101385761013d565b6000808585888a5af1915061013d565b600080fd5b50600081141561014c57600080fd5b82605501870196505050505050506100e3565b50505056fea264697066735822122035246402746c96964495cae5b36461fd44dfb89f8e6cf6f6b8d60c0aa89f414864736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761": { + "balance": 0, + "code": "0x60806040526004361061001e5760003560e01c80638d80ff0a14610023575b600080fd5b6100dc6004803603602081101561003957600080fd5b810190808035906020019064010000000081111561005657600080fd5b82018360208201111561006857600080fd5b8035906020019184600183028401116401000000008311171561008a57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506100de565b005b7f000000000000000000000000a238cbeb142c10ef7ad8442c6d1f9e89e07e776173ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff161415610183576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260308152602001806102106030913960400191505060405180910390fd5b805160205b8181101561020a578083015160f81c6001820184015160601c6015830185015160358401860151605585018701600085600081146101cd57600181146101dd576101e8565b6000808585888a5af191506101e8565b6000808585895af491505b5060008114156101f757600080fd5b8260550187019650505050505050610188565b50505056fe4d756c746953656e642073686f756c64206f6e6c792062652063616c6c6564207669612064656c656761746563616c6ca26469706673582212205c784303626eec02b71940b551976170b500a8a36cc5adcbeb2c19751a76d05464736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC": { + "balance": 0, + "code": "0x608060405234801561001057600080fd5b50600436106100625760003560e01c80631688f0b9146100675780632500510e1461017657806353e5d9351461024357806361b69abd146102c6578063addacc0f146103cb578063d18af54d1461044e575b600080fd5b61014a6004803603606081101561007d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001906401000000008111156100ba57600080fd5b8201836020820111156100cc57600080fd5b803590602001918460018302840111640100000000831117156100ee57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192908035906020019092919050505061057d565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b6102176004803603606081101561018c57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001906401000000008111156101c957600080fd5b8201836020820111156101db57600080fd5b803590602001918460018302840111640100000000831117156101fd57600080fd5b909192939192939080359060200190929190505050610624565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b61024b610751565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561028b578082015181840152602081019050610270565b50505050905090810190601f1680156102b85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b61039f600480360360408110156102dc57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019064010000000081111561031957600080fd5b82018360208201111561032b57600080fd5b8035906020019184600183028401116401000000008311171561034d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929050505061077c565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b6103d3610861565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156104135780820151818401526020810190506103f8565b50505050905090810190601f1680156104405780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6105516004803603608081101561046457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001906401000000008111156104a157600080fd5b8201836020820111156104b357600080fd5b803590602001918460018302840111640100000000831117156104d557600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061088c565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b600061058a848484610a3b565b90506000835111156105b25760008060008551602087016000865af114156105b157600080fd5b5b7f4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e2358185604051808373ffffffffffffffffffffffffffffffffffffffff1681526020018273ffffffffffffffffffffffffffffffffffffffff1681526020019250505060405180910390a19392505050565b60006106758585858080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505084610a3b565b905080604051602001808273ffffffffffffffffffffffffffffffffffffffff1660601b81526014019150506040516020818303038152906040526040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825283818151815260200191508051906020019080838360005b838110156107165780820151818401526020810190506106fb565b50505050905090810190601f1680156107435780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b60606040518060200161076390610bde565b6020820181038252601f19601f82011660405250905090565b60008260405161078b90610bde565b808273ffffffffffffffffffffffffffffffffffffffff168152602001915050604051809103906000f0801580156107c7573d6000803e3d6000fd5b5090506000825111156107f05760008060008451602086016000865af114156107ef57600080fd5b5b7f4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e2358184604051808373ffffffffffffffffffffffffffffffffffffffff1681526020018273ffffffffffffffffffffffffffffffffffffffff1681526020019250505060405180910390a192915050565b60606040518060200161087390610beb565b6020820181038252601f19601f82011660405250905090565b6000808383604051602001808381526020018273ffffffffffffffffffffffffffffffffffffffff1660601b8152601401925050506040516020818303038152906040528051906020012060001c90506108e786868361057d565b9150600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614610a32578273ffffffffffffffffffffffffffffffffffffffff16631e52b518838888886040518563ffffffff1660e01b8152600401808573ffffffffffffffffffffffffffffffffffffffff1681526020018473ffffffffffffffffffffffffffffffffffffffff16815260200180602001838152602001828103825284818151815260200191508051906020019080838360005b838110156109ca5780820151818401526020810190506109af565b50505050905090810190601f1680156109f75780820380516001836020036101000a031916815260200191505b5095505050505050600060405180830381600087803b158015610a1957600080fd5b505af1158015610a2d573d6000803e3d6000fd5b505050505b50949350505050565b6000808380519060200120836040516020018083815260200182815260200192505050604051602081830303815290604052805190602001209050600060405180602001610a8890610bde565b6020820181038252601f19601f820116604052508673ffffffffffffffffffffffffffffffffffffffff166040516020018083805190602001908083835b60208310610ae95780518252602082019150602081019050602083039250610ac6565b6001836020036101000a038019825116818451168082178552505050505050905001828152602001925050506040516020818303038152906040529050818151826020016000f59250600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415610bd5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260138152602001807f437265617465322063616c6c206661696c65640000000000000000000000000081525060200191505060405180910390fd5b50509392505050565b6101e680610bf883390190565b60ab80610dde8339019056fe608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea2646970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82360c277770b955264736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea2646970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82360c277770b955264736f6c63430007060033a26469706673582212200c75fe2196b9f752c82794253f2ebce0d821afef5997e1d5a35ec316ce592f6664736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C": { + "balance": 0, + "code": "0x6080600436101561000f57600080fd5b6000803560e01c63570e1a361461002557600080fd5b3461018a5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261018a576004359167ffffffffffffffff9081841161018657366023850112156101865783600401358281116101825736602482870101116101825780601411610182577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec810192808411610155577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0603f81600b8501160116830190838210908211176101555792846024819482600c60209a968b9960405286845289840196603889018837830101525193013560601c5af1908051911561014d575b5073ffffffffffffffffffffffffffffffffffffffff60405191168152f35b90503861012e565b6024857f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b8380fd5b8280fd5b80fdfea26469706673582212207adef8895ad3393b02fab10a111d85ea80ff35366aa43995f4ea20e67f29200664736f6c63430008170033", + "nonce": 1, + "storage": {} + }, + "0xF4C9CCaf46A866e2c12C5Bd95A39694718044444": { + "balance": 0, + "code": "0x6080604052600436106101965760003560e01c806384b0196e116100e1578063cf190f341161008a578063e71ff36511610064578063e71ff365146104dc578063ed24911d146104fc578063f10b5cc814610511578063f17325e71461056257600080fd5b8063cf190f3414610446578063d45c443514610466578063e30bb5631461049d57600080fd5b8063a6d4dbc7116100bb578063a6d4dbc7146103a6578063b469318d146103b9578063b83010d31461041357600080fd5b806384b0196e1461033e5780639541152514610366578063a3112a641461037957600080fd5b806344adc90e116101435780634d0030701161011d5780634d003070146102e957806354fd4d501461030957806379f7573a1461031e57600080fd5b806344adc90e146102a357806346926267146102c35780634cb7e9e5146102d657600080fd5b806317d7de7c1161017457806317d7de7c1461022b5780632d0335ab1461024d5780633c0427151461029057600080fd5b80630eabf6601461019b57806312b11a17146101b057806313893f61146101f2575b600080fd5b6101ae6101a93660046137af565b610575565b005b3480156101bc57600080fd5b507ffeb2925a02bae3dae48d424a0437a2b6ac939aa9230ddc55a1a76f065d9880765b6040519081526020015b60405180910390f35b3480156101fe57600080fd5b5061021261020d3660046137af565b610706565b60405167ffffffffffffffff90911681526020016101e9565b34801561023757600080fd5b5061024061074b565b6040516101e9919061385f565b34801561025957600080fd5b506101df6102683660046138a4565b73ffffffffffffffffffffffffffffffffffffffff1660009081526003602052604090205490565b6101df61029e3660046138c1565b6107dd565b6102b66102b13660046137af565b6108e0565b6040516101e991906138fc565b6101ae6102d136600461393f565b610a5b565b6101ae6102e43660046137af565b610adf565b3480156102f557600080fd5b5061021261030436600461395a565b610bbc565b34801561031557600080fd5b50610240610bc9565b34801561032a57600080fd5b506101ae61033936600461395a565b610c6c565b34801561034a57600080fd5b50610353610d05565b6040516101e997969594939291906139af565b6102b66103743660046137af565b610d67565b34801561038557600080fd5b5061039961039436600461395a565b610fd4565b6040516101e99190613b31565b6101ae6103b4366004613b44565b611196565b3480156103c557600080fd5b506102126103d4366004613b58565b73ffffffffffffffffffffffffffffffffffffffff919091166000908152600660209081526040808320938352929052205467ffffffffffffffff1690565b34801561041f57600080fd5b507fb5d556f07587ec0f08cf386545cc4362c702a001650c2058002615ee5c9d1e756101df565b34801561045257600080fd5b5061021261046136600461395a565b61123b565b34801561047257600080fd5b5061021261048136600461395a565b60009081526005602052604090205467ffffffffffffffff1690565b3480156104a957600080fd5b506104cc6104b836600461395a565b600090815260046020526040902054151590565b60405190151581526020016101e9565b3480156104e857600080fd5b506102126104f73660046137af565b611249565b34801561050857600080fd5b506101df611281565b34801561051d57600080fd5b5060405173ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000822b0b93be3f3b8da35a2e90e877c01215be85061681526020016101e9565b6101df610570366004613b84565b611290565b348160005b818110156106ff577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8201811460008686848181106105bb576105bb613bbf565b90506020028101906105cd9190613bee565b6105d690613e84565b60208101518051919250908015806105f357508260400151518114155b1561062a576040517f947d5a8400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60005b818110156106ce576106c66040518060a001604052808660000151815260200185848151811061065f5761065f613bbf565b602002602001015181526020018660400151848151811061068257610682613bbf565b60200260200101518152602001866060015173ffffffffffffffffffffffffffffffffffffffff168152602001866080015167ffffffffffffffff1681525061134e565b60010161062d565b506106e483600001518385606001518a88611560565b6106ee9088613fb4565b96505050505080600101905061057a565b5050505050565b60004282825b8181101561073f576107373387878481811061072a5761072a613bbf565b9050602002013585611bb1565b60010161070c565b50909150505b92915050565b60606002805461075a90613fc7565b80601f016020809104026020016040519081016040528092919081815260200182805461078690613fc7565b80156107d35780601f106107a8576101008083540402835291602001916107d3565b820191906000526020600020905b8154815290600101906020018083116107b657829003601f168201915b5050505050905090565b60006107f06107eb83614141565b611cb0565b604080516001808252818301909252600091816020015b6040805160c081018252600080825260208083018290529282018190526060808301829052608083015260a082015282527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff90920191018161080757905050905061087560208401846141bc565b61087e906141f0565b8160008151811061089157610891613bbf565b60209081029190910101526108ba8335826108b260c0870160a088016138a4565b346001611e3b565b602001516000815181106108d0576108d0613bbf565b6020026020010151915050919050565b60608160008167ffffffffffffffff8111156108fe576108fe613c2c565b60405190808252806020026020018201604052801561093157816020015b606081526020019060019003908161091c5790505b509050600034815b84811015610a45577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff850181143689898481811061097957610979613bbf565b905060200281019061098b91906141fc565b905061099a6020820182614230565b90506000036109d5576040517f947d5a8400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60006109fa82356109e96020850185614230565b6109f291614298565b338887611e3b565b8051909150610a099086613fb4565b94508060200151878581518110610a2257610a22613bbf565b602002602001018190525080602001515186019550505050806001019050610939565b50610a508383612568565b979650505050505050565b604080516001808252818301909252600091816020015b6040805180820190915260008082526020820152815260200190600190039081610a72579050509050610aad368390038301602084016142fe565b81600081518110610ac057610ac0613bbf565b6020908102919091010152610ada82358233346001611560565b505050565b348160005b818110156106ff577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8201811436868684818110610b2457610b24613bbf565b9050602002810190610b3691906141fc565b9050610ba38135610b4a602084018461431a565b808060200260200160405190810160405280939291908181526020016000905b82821015610b9657610b87604083028601368190038101906142fe565b81526020019060010190610b6a565b5050505050338886611560565b610bad9086613fb4565b94505050806001019050610ae4565b600042610745838261264c565b6060610bf47f000000000000000000000000000000000000000000000000000000000000000161270e565b610c1d7f000000000000000000000000000000000000000000000000000000000000000461270e565b610c467f000000000000000000000000000000000000000000000000000000000000000061270e565b604051602001610c5893929190614382565b604051602081830303815290604052905090565b33600090815260036020526040902054808211610cb5576040517f756688fe00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b3360009081526003602090815260409182902084905581518381529081018490527f57b09af877df9068fd60a69d7b21f5576b8b38955812d6ae4ac52942f1e38fb7910160405180910390a15050565b600060608060008060006060610d196127cc565b610d216127f9565b604080516000808252602082019092527f0f000000000000000000000000000000000000000000000000000000000000009b939a50919850469750309650945092509050565b60608160008167ffffffffffffffff811115610d8557610d85613c2c565b604051908082528060200260200182016040528015610db857816020015b6060815260200190600190039081610da35790505b509050600034815b84811015610a45577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8501811436898984818110610e0057610e00613bbf565b9050602002810190610e129190613bee565b9050366000610e246020840184614230565b909250905080801580610e455750610e3f6040850185614419565b90508114155b15610e7c576040517f947d5a8400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60005b81811015610f5d57610f556040518060a0016040528087600001358152602001868685818110610eb157610eb1613bbf565b9050602002810190610ec391906141bc565b610ecc906141f0565b8152602001610ede6040890189614419565b85818110610eee57610eee613bbf565b905060600201803603810190610f049190614480565b8152602001610f196080890160608a016138a4565b73ffffffffffffffffffffffffffffffffffffffff168152602001610f4460a0890160808a0161449c565b67ffffffffffffffff169052611cb0565b600101610e7f565b506000610f868535610f6f8587614298565b610f7f6080890160608a016138a4565b8b8a611e3b565b8051909150610f959089613fb4565b975080602001518a8881518110610fae57610fae613bbf565b602002602001018190525080602001515189019850505050505050806001019050610dc0565b604080516101408101825260008082526020820181905291810182905260608082018390526080820183905260a0820183905260c0820183905260e0820183905261010082019290925261012081019190915260008281526004602081815260409283902083516101408101855281548152600182015492810192909252600281015467ffffffffffffffff808216958401959095526801000000000000000081048516606084015270010000000000000000000000000000000090049093166080820152600383015460a08201529082015473ffffffffffffffffffffffffffffffffffffffff90811660c0830152600583015490811660e083015274010000000000000000000000000000000000000000900460ff1615156101008201526006820180549192916101208401919061110d90613fc7565b80601f016020809104026020016040519081016040528092919081815260200182805461113990613fc7565b80156111865780601f1061115b57610100808354040283529160200191611186565b820191906000526020600020905b81548152906001019060200180831161116957829003601f168201915b5050505050815250509050919050565b6111ad6111a8368390038301836144b7565b61134e565b604080516001808252818301909252600091816020015b60408051808201909152600080825260208201528152602001906001900390816111c45790505090506111ff368390038301602084016142fe565b8160008151811061121257611212613bbf565b6020908102919091010152610ada82358261123360e0860160c087016138a4565b346001611560565b600042610745338483611bb1565b60004282825b8181101561073f5761127986868381811061126c5761126c613bbf565b905060200201358461264c565b60010161124f565b600061128b612826565b905090565b604080516001808252818301909252600091829190816020015b6040805160c081018252600080825260208083018290529282018190526060808301829052608083015260a082015282527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9092019101816112aa57905050905061131860208401846141bc565b611321906141f0565b8160008151811061133457611334613bbf565b60209081029190910101526108ba83358233346001611e3b565b608081015167ffffffffffffffff161580159061138257504267ffffffffffffffff16816080015167ffffffffffffffff16105b156113b9576040517f1ab7da6b00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6020808201516040808401516060850151855184518587015173ffffffffffffffffffffffffffffffffffffffff84166000908152600390985294872080549697949694956114ae957fb5d556f07587ec0f08cf386545cc4362c702a001650c2058002615ee5c9d1e75959493928861143183614527565b909155506080808c015160408051602081019990995273ffffffffffffffffffffffffffffffffffffffff9097169688019690965260608701949094529285019190915260a084015260c083015267ffffffffffffffff1660e0820152610100015b6040516020818303038152906040528051906020012061295e565b905061152484606001518284602001518560400151866000015160405160200161151093929190928352602083019190915260f81b7fff0000000000000000000000000000000000000000000000000000000000000016604082015260410190565b6040516020818303038152906040526129a6565b61155a576040517f8baa579f00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b50505050565b6040517fa2ea7c6e00000000000000000000000000000000000000000000000000000000815260048101869052600090819073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000822b0b93be3f3b8da35a2e90e877c01215be8506169063a2ea7c6e90602401600060405180830381865afa1580156115f0573d6000803e3d6000fd5b505050506040513d6000823e601f3d9081017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0168201604052611636919081019061455f565b8051909150611671576040517fbf37b20e00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b855160008167ffffffffffffffff81111561168e5761168e613c2c565b60405190808252806020026020018201604052801561172d57816020015b60408051610140810182526000808252602080830182905292820181905260608083018290526080830182905260a0830182905260c0830182905260e0830182905261010083019190915261012082015282527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9092019101816116ac5790505b50905060008267ffffffffffffffff81111561174b5761174b613c2c565b604051908082528060200260200182016040528015611774578160200160208202803683370190505b50905060005b83811015611b935760008a828151811061179657611796613bbf565b60209081029190910181015180516000908152600490925260409091208054919250906117ef576040517fc5723b5100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8c81600101541461182c576040517fbf37b20e00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b600581015473ffffffffffffffffffffffffffffffffffffffff8c8116911614611882576040517f4ca8886700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b600581015474010000000000000000000000000000000000000000900460ff166118d8576040517f157bd4c300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6002810154700100000000000000000000000000000000900467ffffffffffffffff1615611932576040517f905e710700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b426002820180547fffffffffffffffff0000000000000000ffffffffffffffffffffffffffffffff811670010000000000000000000000000000000067ffffffffffffffff948516810291821793849055604080516101408101825287548152600188015460208201529386169286169290921791830191909152680100000000000000008304841660608301529091049091166080820152600382015460a0820152600482015473ffffffffffffffffffffffffffffffffffffffff90811660c0830152600583015490811660e083015274010000000000000000000000000000000000000000900460ff1615156101008201526006820180548391610120840191611a3e90613fc7565b80601f0160208091040260200160405190810160405280929190818152602001828054611a6a90613fc7565b8015611ab75780601f10611a8c57610100808354040283529160200191611ab7565b820191906000526020600020905b815481529060010190602001808311611a9a57829003601f168201915b505050505081525050858481518110611ad257611ad2613bbf565b60200260200101819052508160200151848481518110611af457611af4613bbf565b6020026020010181815250508c8b73ffffffffffffffffffffffffffffffffffffffff16868581518110611b2a57611b2a613bbf565b602002602001015160c0015173ffffffffffffffffffffffffffffffffffffffff167ff930a6e2523c9cc298691873087a740550b8fc85a0680830414c148ed927f6158560000151604051611b8191815260200190565b60405180910390a4505060010161177a565b50611ba384838360018b8b612a44565b9a9950505050505050505050565b73ffffffffffffffffffffffffffffffffffffffff83166000908152600660209081526040808320858452918290529091205467ffffffffffffffff1615611c25576040517fec9d6eeb00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008381526020829052604080822080547fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000001667ffffffffffffffff861690811790915590519091859173ffffffffffffffffffffffffffffffffffffffff8816917f92a1f7a41a7c585a8b09e25b195e225b1d43248daca46b0faf9e0792777a222991a450505050565b608081015167ffffffffffffffff1615801590611ce457504267ffffffffffffffff16816080015167ffffffffffffffff16105b15611d1b576040517f1ab7da6b00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6020808201516040808401516060808601518651855186880151868801519488015160808901518051908b012060a08a015173ffffffffffffffffffffffffffffffffffffffff871660009081526003909c52988b2080549a9b989a98996114ae997ffeb2925a02bae3dae48d424a0437a2b6ac939aa9230ddc55a1a76f065d988076999493928c611dac83614527565b919050558e608001516040516020016114939b9a999897969594939291909a8b5273ffffffffffffffffffffffffffffffffffffffff998a1660208c015260408b019890985295909716606089015267ffffffffffffffff938416608089015291151560a088015260c087015260e0860152610100850193909352610120840152166101408201526101600190565b60408051808201909152600081526060602082015284516040805180820190915260008152606060208201528167ffffffffffffffff811115611e8057611e80613c2c565b604051908082528060200260200182016040528015611ea9578160200160208202803683370190505b5060208201526040517fa2ea7c6e000000000000000000000000000000000000000000000000000000008152600481018990526000907f000000000000000000000000822b0b93be3f3b8da35a2e90e877c01215be850673ffffffffffffffffffffffffffffffffffffffff169063a2ea7c6e90602401600060405180830381865afa158015611f3d573d6000803e3d6000fd5b505050506040513d6000823e601f3d9081017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0168201604052611f83919081019061455f565b8051909150611fbe576040517fbf37b20e00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008367ffffffffffffffff811115611fd957611fd9613c2c565b60405190808252806020026020018201604052801561207857816020015b60408051610140810182526000808252602080830182905292820181905260608083018290526080830182905260a0830182905260c0830182905260e0830182905261010083019190915261012082015282527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff909201910181611ff75790505b50905060008467ffffffffffffffff81111561209657612096613c2c565b6040519080825280602002602001820160405280156120bf578160200160208202803683370190505b50905060005b858110156125475760008b82815181106120e1576120e1613bbf565b60200260200101519050600067ffffffffffffffff16816020015167ffffffffffffffff161415801561212c57504267ffffffffffffffff16816020015167ffffffffffffffff1611155b15612163576040517f08e8b93700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8460400151158015612176575080604001515b156121ad576040517f157bd4c300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60006040518061014001604052806000801b81526020018f81526020016121d14290565b67ffffffffffffffff168152602001836020015167ffffffffffffffff168152602001600067ffffffffffffffff16815260200183606001518152602001836000015173ffffffffffffffffffffffffffffffffffffffff1681526020018d73ffffffffffffffffffffffffffffffffffffffff16815260200183604001511515815260200183608001518152509050600080600090505b6122738382612e35565b6000818152600460205260409020549092501561229257600101612269565b81835260008281526004602081815260409283902086518155908601516001820155918501516002830180546060880151608089015167ffffffffffffffff908116700100000000000000000000000000000000027fffffffffffffffff0000000000000000ffffffffffffffffffffffffffffffff92821668010000000000000000027fffffffffffffffffffffffffffffffff000000000000000000000000000000009094169190951617919091171691909117905560a0850151600383015560c0850151908201805473ffffffffffffffffffffffffffffffffffffffff9283167fffffffffffffffffffffffff000000000000000000000000000000000000000090911617905560e0850151600583018054610100880151151574010000000000000000000000000000000000000000027fffffffffffffffffffffff000000000000000000000000000000000000000000909116929093169190911791909117905561012084015184919060068201906124119082614684565b505050606084015115612468576060840151600090815260046020526040902054612468576040517fc5723b5100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8287868151811061247b5761247b613bbf565b60200260200101819052508360a0015186868151811061249d5761249d613bbf565b60200260200101818152505081896020015186815181106124c0576124c0613bbf565b6020026020010181815250508f8e73ffffffffffffffffffffffffffffffffffffffff16856000015173ffffffffffffffffffffffffffffffffffffffff167f8bf46bf4cfd674fa735a3d63ec1c9ad4153f033c290341f3a588b75685141b358560405161253091815260200190565b60405180910390a4505050508060010190506120c5565b5061255783838360008c8c612a44565b845250919998505050505050505050565b606060008267ffffffffffffffff81111561258557612585613c2c565b6040519080825280602002602001820160405280156125ae578160200160208202803683370190505b508451909150600090815b818110156126415760008782815181106125d5576125d5613bbf565b6020026020010151905060008151905060005b818110156126335782818151811061260257612602613bbf565b602002602001015187878151811061261c5761261c613bbf565b6020908102919091010152600195860195016125e8565b5050508060010190506125b9565b509195945050505050565b60008281526005602052604090205467ffffffffffffffff161561269c576040517f2e26794600000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008281526005602052604080822080547fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000001667ffffffffffffffff85169081179091559051909184917f5aafceeb1c7ad58e4a84898bdee37c02c0fc46e7d24e6b60e8209449f183459f9190a35050565b6060600061271b83612e94565b600101905060008167ffffffffffffffff81111561273b5761273b613c2c565b6040519080825280601f01601f191660200182016040528015612765576020820181803683370190505b5090508181016020015b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff017f3031323334353637383961626364656600000000000000000000000000000000600a86061a8153600a850494508461276f57509392505050565b606061128b7f45415300000000000000000000000000000000000000000000000000000000036000612f76565b606061128b7f312e342e300000000000000000000000000000000000000000000000000000056001612f76565b60003073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000f4c9ccaf46a866e2c12c5bd95a396947180444441614801561288c57507f000000000000000000000000000000000000000000000000000000000000123746145b156128b657507f410f18e4a820ae21f62b45503e63cdbec2190599f0c29c74a2f7518993b89de990565b61128b604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60208201527f9fed719e0073f95229e6f4f6b6f28f260c524ab08aa40b11f9c28cb710d7c72a918101919091527f649061b8d3f3b9c5692fcbef47cba88c670a745e17e5297d42ddc0aa82ab112960608201524660808201523060a082015260009060c00160405160208183030381529060405280519060200120905090565b600061074561296b612826565b836040517f19010000000000000000000000000000000000000000000000000000000000008152600281019290925260228201526042902090565b60008373ffffffffffffffffffffffffffffffffffffffff163b600003612a2f576000806129d48585613021565b50909250905060008160038111156129ee576129ee61479d565b148015612a2657508573ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16145b92505050612a3d565b612a3a84848461306e565b90505b9392505050565b84516000906001819003612a9c57612a948888600081518110612a6957612a69613bbf565b602002602001015188600081518110612a8457612a84613bbf565b60200260200101518888886131bb565b915050612e2b565b602088015173ffffffffffffffffffffffffffffffffffffffff8116612b3d5760005b82811015612b2257878181518110612ad957612ad9613bbf565b6020026020010151600014612b1a576040517f1574f9f300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b600101612abf565b508315612b3257612b32856134da565b600092505050612e2b565b6000808273ffffffffffffffffffffffffffffffffffffffff1663ce46e0466040518163ffffffff1660e01b8152600401602060405180830381865afa158015612b8b573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612baf91906147cc565b905060005b84811015612c6c5760008a8281518110612bd057612bd0613bbf565b6020026020010151905080600003612be85750612c64565b82612c1f576040517f1574f9f300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b88811115612c59576040517f1101129400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b978890039792909201915b600101612bb4565b508715612d47576040517f88e5b2d900000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff8416906388e5b2d9908490612cc9908e908e906004016147e9565b60206040518083038185885af1158015612ce7573d6000803e3d6000fd5b50505050506040513d601f19601f82011682018060405250810190612d0c91906147cc565b612d42576040517fbf2f3a8b00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b612e16565b6040517f91db0b7e00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff8416906391db0b7e908490612d9d908e908e906004016147e9565b60206040518083038185885af1158015612dbb573d6000803e3d6000fd5b50505050506040513d601f19601f82011682018060405250810190612de091906147cc565b612e16576040517fe8bee83900000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8515612e2557612e25876134da565b50925050505b9695505050505050565b60208083015160c084015160e0850151604080870151606088015161010089015160a08a01516101208b01519451600099612e7699989796918c910161487f565b60405160208183030381529060405280519060200120905092915050565b6000807a184f03e93ff9f4daa797ed6e38ed64bf6a1f0100000000000000008310612edd577a184f03e93ff9f4daa797ed6e38ed64bf6a1f010000000000000000830492506040015b6d04ee2d6d415b85acef81000000008310612f09576d04ee2d6d415b85acef8100000000830492506020015b662386f26fc100008310612f2757662386f26fc10000830492506010015b6305f5e1008310612f3f576305f5e100830492506008015b6127108310612f5357612710830492506004015b60648310612f65576064830492506002015b600a83106107455760010192915050565b606060ff8314612f9057612f89836134ed565b9050610745565b818054612f9c90613fc7565b80601f0160208091040260200160405190810160405280929190818152602001828054612fc890613fc7565b80156130155780601f10612fea57610100808354040283529160200191613015565b820191906000526020600020905b815481529060010190602001808311612ff857829003601f168201915b50505050509050610745565b6000806000835160410361305b5760208401516040850151606086015160001a61304d8882858561352c565b955095509550505050613067565b50508151600091506002905b9250925092565b60008060008573ffffffffffffffffffffffffffffffffffffffff16858560405160240161309d92919061499c565b604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08184030181529181526020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167f1626ba7e000000000000000000000000000000000000000000000000000000001790525161311e91906149b5565b600060405180830381855afa9150503d8060008114613159576040519150601f19603f3d011682016040523d82523d6000602084013e61315e565b606091505b509150915081801561317257506020815110155b8015612e2b575080517f1626ba7e00000000000000000000000000000000000000000000000000000000906131b090830160209081019084016149c7565b149695505050505050565b602086015160009073ffffffffffffffffffffffffffffffffffffffff811661322f578515613216576040517f1574f9f300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b821561322557613225846134da565b6000915050612e2b565b851561331a578073ffffffffffffffffffffffffffffffffffffffff1663ce46e0466040518163ffffffff1660e01b8152600401602060405180830381865afa158015613280573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906132a491906147cc565b6132da576040517f1574f9f300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b83861115613314576040517f1101129400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b85840393505b84156133f2576040517fe49617e100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff82169063e49617e1908890613374908b90600401613b31565b60206040518083038185885af1158015613392573d6000803e3d6000fd5b50505050506040513d601f19601f820116820180604052508101906133b791906147cc565b6133ed576040517fccf3bb2700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6134bf565b6040517fe60c350500000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff82169063e60c3505908890613446908b90600401613b31565b60206040518083038185885af1158015613464573d6000803e3d6000fd5b50505050506040513d601f19601f8201168201806040525081019061348991906147cc565b6134bf576040517fbd8ba84d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b82156134ce576134ce846134da565b50939695505050505050565b80156134ea576134ea3382613626565b50565b606060006134fa836136e0565b604080516020808252818301909252919250600091906020820181803683375050509182525060208101929092525090565b600080807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0841115613567575060009150600390508261361c565b604080516000808252602082018084528a905260ff891692820192909252606081018790526080810186905260019060a0016020604051602081039080840390855afa1580156135bb573d6000803e3d6000fd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff81166136125750600092506001915082905061361c565b9250600091508190505b9450945094915050565b8047101561366d576040517fcf4791810000000000000000000000000000000000000000000000000000000081524760048201526024810182905260440160405180910390fd5b6000808373ffffffffffffffffffffffffffffffffffffffff168360405160006040518083038185875af1925050503d80600081146136c8576040519150601f19603f3d011682016040523d82523d6000602084013e6136cd565b606091505b50915091508161155a5761155a81613721565b600060ff8216601f811115610745576040517fb3512b0c00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8051156137315780518082602001fd5b6040517fd6bda27500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008083601f84011261377557600080fd5b50813567ffffffffffffffff81111561378d57600080fd5b6020830191508360208260051b85010111156137a857600080fd5b9250929050565b600080602083850312156137c257600080fd5b823567ffffffffffffffff8111156137d957600080fd5b6137e585828601613763565b90969095509350505050565b60005b8381101561380c5781810151838201526020016137f4565b50506000910152565b6000815180845261382d8160208601602086016137f1565b601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b602081526000612a3d6020830184613815565b73ffffffffffffffffffffffffffffffffffffffff811681146134ea57600080fd5b803561389f81613872565b919050565b6000602082840312156138b657600080fd5b8135612a3d81613872565b6000602082840312156138d357600080fd5b813567ffffffffffffffff8111156138ea57600080fd5b820160e08185031215612a3d57600080fd5b602080825282518282018190526000918401906040840190835b81811015613934578351835260209384019390920191600101613916565b509095945050505050565b6000606082840312801561395257600080fd5b509092915050565b60006020828403121561396c57600080fd5b5035919050565b600081518084526020840193506020830160005b828110156139a5578151865260209586019590910190600101613987565b5093949350505050565b7fff000000000000000000000000000000000000000000000000000000000000008816815260e0602082015260006139ea60e0830189613815565b82810360408401526139fc8189613815565b905086606084015273ffffffffffffffffffffffffffffffffffffffff861660808401528460a084015282810360c0840152611ba38185613973565b805182526020810151602083015260006040820151613a63604085018267ffffffffffffffff169052565b506060820151613a7f606085018267ffffffffffffffff169052565b506080820151613a9b608085018267ffffffffffffffff169052565b5060a082015160a084015260c0820151613acd60c085018273ffffffffffffffffffffffffffffffffffffffff169052565b5060e0820151613af560e085018273ffffffffffffffffffffffffffffffffffffffff169052565b50610100820151613b0b61010085018215159052565b50610120820151610140610120850152613b29610140850182613815565b949350505050565b602081526000612a3d6020830184613a38565b600061010082840312801561395257600080fd5b60008060408385031215613b6b57600080fd5b8235613b7681613872565b946020939093013593505050565b600060208284031215613b9657600080fd5b813567ffffffffffffffff811115613bad57600080fd5b820160408185031215612a3d57600080fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff61833603018112613c2257600080fd5b9190910192915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b60405160a0810167ffffffffffffffff81118282101715613c7e57613c7e613c2c565b60405290565b60405160c0810167ffffffffffffffff81118282101715613c7e57613c7e613c2c565b6040516080810167ffffffffffffffff81118282101715613c7e57613c7e613c2c565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016810167ffffffffffffffff81118282101715613d1157613d11613c2c565b604052919050565b600067ffffffffffffffff821115613d3357613d33613c2c565b5060051b60200190565b600060408284031215613d4f57600080fd5b6040805190810167ffffffffffffffff81118282101715613d7257613d72613c2c565b604052823581526020928301359281019290925250919050565b600060608284031215613d9e57600080fd5b6040516060810167ffffffffffffffff81118282101715613dc157613dc1613c2c565b604052905080823560ff81168114613dd857600080fd5b815260208381013590820152604092830135920191909152919050565b600082601f830112613e0657600080fd5b8135613e19613e1482613d19565b613cca565b80828252602082019150602060608402860101925085831115613e3b57600080fd5b602085015b83811015613e6257613e528782613d8c565b8352602090920191606001613e40565b5095945050505050565b803567ffffffffffffffff8116811461389f57600080fd5b600060a08236031215613e9657600080fd5b613e9e613c5b565b82358152602083013567ffffffffffffffff811115613ebc57600080fd5b830136601f820112613ecd57600080fd5b8035613edb613e1482613d19565b8082825260208201915060208360061b850101925036831115613efd57600080fd5b6020840193505b82841015613f2957613f163685613d3d565b8252602082019150604084019350613f04565b6020850152505050604083013567ffffffffffffffff811115613f4b57600080fd5b613f5736828601613df5565b604083015250613f6960608401613894565b6060820152613f7a60808401613e6c565b608082015292915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b8181038181111561074557610745613f85565b600181811c90821680613fdb57607f821691505b602082108103614014577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b50919050565b80151581146134ea57600080fd5b600067ffffffffffffffff82111561404257614042613c2c565b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200190565b600060c0828403121561408057600080fd5b614088613c84565b9050813561409581613872565b81526140a360208301613e6c565b602082015260408201356140b68161401a565b604082015260608281013590820152608082013567ffffffffffffffff8111156140df57600080fd5b8201601f810184136140f057600080fd5b80356140fe613e1482614028565b81815285602083850101111561411357600080fd5b8160208401602083013760009181016020019190915260808301525060a09182013591810191909152919050565b600060e0823603121561415357600080fd5b61415b613c5b565b82358152602083013567ffffffffffffffff81111561417957600080fd5b6141853682860161406e565b6020830152506141983660408501613d8c565b604082015260a08301356141ab81613872565b6060820152613f7a60c08401613e6c565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff41833603018112613c2257600080fd5b6000610745368361406e565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc1833603018112613c2257600080fd5b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe184360301811261426557600080fd5b83018035915067ffffffffffffffff82111561428057600080fd5b6020019150600581901b36038213156137a857600080fd5b60006142a6613e1484613d19565b8381526020810190600585901b8401368111156142c257600080fd5b845b8181101561393457803567ffffffffffffffff8111156142e357600080fd5b6142ef3682890161406e565b855250602093840193016142c4565b60006040828403121561431057600080fd5b612a3d8383613d3d565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe184360301811261434f57600080fd5b83018035915067ffffffffffffffff82111561436a57600080fd5b6020019150600681901b36038213156137a857600080fd5b600084516143948184602089016137f1565b7f2e0000000000000000000000000000000000000000000000000000000000000090830190815284516143ce8160018401602089016137f1565b7f2e0000000000000000000000000000000000000000000000000000000000000060019290910191820152835161440c8160028401602088016137f1565b0160020195945050505050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe184360301811261444e57600080fd5b83018035915067ffffffffffffffff82111561446957600080fd5b60200191506060810236038213156137a857600080fd5b60006060828403121561449257600080fd5b612a3d8383613d8c565b6000602082840312156144ae57600080fd5b612a3d82613e6c565b60006101008284031280156144cb57600080fd5b506144d4613c5b565b823581526144e58460208501613d3d565b60208201526144f78460608501613d8c565b604082015260c083013561450a81613872565b606082015261451b60e08401613e6c565b60808201529392505050565b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff820361455857614558613f85565b5060010190565b60006020828403121561457157600080fd5b815167ffffffffffffffff81111561458857600080fd5b82016080818503121561459a57600080fd5b6145a2613ca7565b8151815260208201516145b481613872565b602082015260408201516145c78161401a565b6040820152606082015167ffffffffffffffff8111156145e657600080fd5b80830192505084601f8301126145fb57600080fd5b8151614609613e1482614028565b81815286602083860101111561461e57600080fd5b61462f8260208301602087016137f1565b606083015250949350505050565b601f821115610ada57806000526020600020601f840160051c810160208510156146645750805b601f840160051c820191505b818110156106ff5760008155600101614670565b815167ffffffffffffffff81111561469e5761469e613c2c565b6146b2816146ac8454613fc7565b8461463d565b6020601f82116001811461470457600083156146ce5750848201515b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600385901b1c1916600184901b1784556106ff565b6000848152602081207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08516915b828110156147525787850151825560209485019460019092019101614732565b508482101561478e57868401517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600387901b60f8161c191681555b50505050600190811b01905550565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b6000602082840312156147de57600080fd5b8151612a3d8161401a565b6000604082016040835280855180835260608501915060608160051b86010192506020870160005b82811015614860577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa087860301845261484b858351613a38565b94506020938401939190910190600101614811565b5050505082810360208401526148768185613973565b95945050505050565b8981527fffffffffffffffffffffffffffffffffffffffff0000000000000000000000008960601b1660208201527fffffffffffffffffffffffffffffffffffffffff0000000000000000000000008860601b1660348201527fffffffffffffffff0000000000000000000000000000000000000000000000008760c01b1660488201527fffffffffffffffff0000000000000000000000000000000000000000000000008660c01b16605082015284151560f81b605882015283605982015260006079820160008551614957818460208a016137f1565b82019050614989818660e01b7fffffffff00000000000000000000000000000000000000000000000000000000169052565b6004019c9b505050505050505050505050565b828152604060208201526000613b296040830184613815565b60008251613c228184602087016137f1565b6000602082840312156149d957600080fd5b505191905056fea164736f6c634300081c000a", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000002": "0x4541530000000000000000000000000000000000000000000000000000000006" + } + }, + "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2": { + "balance": 0, + "code": "0x608060405234801561001057600080fd5b50600436106100625760003560e01c80631688f0b9146100675780632500510e1461017657806353e5d9351461024357806361b69abd146102c6578063addacc0f146103cb578063d18af54d1461044e575b600080fd5b61014a6004803603606081101561007d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001906401000000008111156100ba57600080fd5b8201836020820111156100cc57600080fd5b803590602001918460018302840111640100000000831117156100ee57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192908035906020019092919050505061057d565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b6102176004803603606081101561018c57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001906401000000008111156101c957600080fd5b8201836020820111156101db57600080fd5b803590602001918460018302840111640100000000831117156101fd57600080fd5b909192939192939080359060200190929190505050610624565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b61024b610751565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561028b578082015181840152602081019050610270565b50505050905090810190601f1680156102b85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b61039f600480360360408110156102dc57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019064010000000081111561031957600080fd5b82018360208201111561032b57600080fd5b8035906020019184600183028401116401000000008311171561034d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929050505061077c565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b6103d3610861565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156104135780820151818401526020810190506103f8565b50505050905090810190601f1680156104405780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6105516004803603608081101561046457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001906401000000008111156104a157600080fd5b8201836020820111156104b357600080fd5b803590602001918460018302840111640100000000831117156104d557600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061088c565b604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b600061058a848484610a3b565b90506000835111156105b25760008060008551602087016000865af114156105b157600080fd5b5b7f4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e2358185604051808373ffffffffffffffffffffffffffffffffffffffff1681526020018273ffffffffffffffffffffffffffffffffffffffff1681526020019250505060405180910390a19392505050565b60006106758585858080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505084610a3b565b905080604051602001808273ffffffffffffffffffffffffffffffffffffffff1660601b81526014019150506040516020818303038152906040526040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825283818151815260200191508051906020019080838360005b838110156107165780820151818401526020810190506106fb565b50505050905090810190601f1680156107435780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b60606040518060200161076390610bde565b6020820181038252601f19601f82011660405250905090565b60008260405161078b90610bde565b808273ffffffffffffffffffffffffffffffffffffffff168152602001915050604051809103906000f0801580156107c7573d6000803e3d6000fd5b5090506000825111156107f05760008060008451602086016000865af114156107ef57600080fd5b5b7f4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e2358184604051808373ffffffffffffffffffffffffffffffffffffffff1681526020018273ffffffffffffffffffffffffffffffffffffffff1681526020019250505060405180910390a192915050565b60606040518060200161087390610beb565b6020820181038252601f19601f82011660405250905090565b6000808383604051602001808381526020018273ffffffffffffffffffffffffffffffffffffffff1660601b8152601401925050506040516020818303038152906040528051906020012060001c90506108e786868361057d565b9150600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614610a32578273ffffffffffffffffffffffffffffffffffffffff16631e52b518838888886040518563ffffffff1660e01b8152600401808573ffffffffffffffffffffffffffffffffffffffff1681526020018473ffffffffffffffffffffffffffffffffffffffff16815260200180602001838152602001828103825284818151815260200191508051906020019080838360005b838110156109ca5780820151818401526020810190506109af565b50505050905090810190601f1680156109f75780820380516001836020036101000a031916815260200191505b5095505050505050600060405180830381600087803b158015610a1957600080fd5b505af1158015610a2d573d6000803e3d6000fd5b505050505b50949350505050565b6000808380519060200120836040516020018083815260200182815260200192505050604051602081830303815290604052805190602001209050600060405180602001610a8890610bde565b6020820181038252601f19601f820116604052508673ffffffffffffffffffffffffffffffffffffffff166040516020018083805190602001908083835b60208310610ae95780518252602082019150602081019050602083039250610ac6565b6001836020036101000a038019825116818451168082178552505050505050905001828152602001925050506040516020818303038152906040529050818151826020016000f59250600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415610bd5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260138152602001807f437265617465322063616c6c206661696c65640000000000000000000000000081525060200191505060405180910390fd5b50509392505050565b6101e680610bf883390190565b60ab80610dde8339019056fe608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea2646970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82360c277770b955264736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea2646970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82360c277770b955264736f6c63430007060033a26469706673582212200c75fe2196b9f752c82794253f2ebce0d821afef5997e1d5a35ec316ce592f6664736f6c63430007060033", + "nonce": 1, + "storage": {} + }, + "0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed": { + "balance": 0, + "code": "0x60806040526004361061018a5760003560e01c806381503da1116100d6578063d323826a1161007f578063e96deee411610059578063e96deee414610395578063f5745aba146103a8578063f9664498146103bb57600080fd5b8063d323826a1461034f578063ddda0acb1461036f578063e437252a1461038257600080fd5b80639c36a286116100b05780639c36a28614610316578063a7db93f214610329578063c3fe107b1461033c57600080fd5b806381503da1146102d0578063890c283b146102e357806398e810771461030357600080fd5b80632f990e3f116101385780636cec2536116101125780636cec25361461027d57806374637a7a1461029d5780637f565360146102bd57600080fd5b80632f990e3f1461023757806331a7c8c81461024a57806342d654fc1461025d57600080fd5b806327fe18221161016957806327fe1822146101f15780632852527a1461020457806328ddd0461461021757600080fd5b8062d84acb1461018f57806326307668146101cb57806326a32fc7146101de575b600080fd5b6101a261019d366004612915565b6103ce565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b6101a26101d9366004612994565b6103e6565b6101a26101ec3660046129db565b610452565b6101a26101ff3660046129db565b6104de565b6101a2610212366004612a39565b610539565b34801561022357600080fd5b506101a2610232366004612a90565b6106fe565b6101a2610245366004612aa9565b61072a565b6101a2610258366004612aa9565b6107bb565b34801561026957600080fd5b506101a2610278366004612b1e565b6107c9565b34801561028957600080fd5b506101a2610298366004612a90565b610823565b3480156102a957600080fd5b506101a26102b8366004612b4a565b61084f565b6101a26102cb3660046129db565b611162565b6101a26102de366004612b74565b6111e8565b3480156102ef57600080fd5b506101a26102fe366004612bac565b611276565b6101a2610311366004612bce565b6112a3565b6101a2610324366004612994565b611505565b6101a2610337366004612c49565b6116f1565b6101a261034a366004612aa9565b611964565b34801561035b57600080fd5b506101a261036a366004612cd9565b6119ed565b6101a261037d366004612c49565b611a17565b6101a2610390366004612bce565b611e0c565b6101a26103a3366004612915565b611e95565b6101a26103b6366004612bce565b611ea4565b6101a26103c9366004612b74565b611f2d565b60006103dd8585858533611a17565b95945050505050565b6000806103f2846120db565b90508083516020850134f59150610408826123d3565b604051819073ffffffffffffffffffffffffffffffffffffffff8416907fb8fda7e00c6b06a2b54e58521bc5894fee35f1090e5a3bb6390bfe2b98b497f790600090a35092915050565b60006104d86104d260408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b836103e6565b92915050565b600081516020830134f090506104f3816123d3565b60405173ffffffffffffffffffffffffffffffffffffffff8216907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a2919050565b600080610545856120db565b905060008460601b90506040517f3d602d80600a3d3981f3363d3d373d3d3d363d7300000000000000000000000081528160148201527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006028820152826037826000f593505073ffffffffffffffffffffffffffffffffffffffff8316610635576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed1660048201526024015b60405180910390fd5b604051829073ffffffffffffffffffffffffffffffffffffffff8516907fb8fda7e00c6b06a2b54e58521bc5894fee35f1090e5a3bb6390bfe2b98b497f790600090a36000808473ffffffffffffffffffffffffffffffffffffffff1634876040516106a19190612d29565b60006040518083038185875af1925050503d80600081146106de576040519150601f19603f3d011682016040523d82523d6000602084013e6106e3565b606091505b50915091506106f382828961247d565b505050509392505050565b60006104d87f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed8361084f565b60006107b36107aa60408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b85858533611a17565b949350505050565b60006107b3848484336112a3565b60006040518260005260ff600b53836020527f21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f6040526055600b20601452806040525061d694600052600160345350506017601e20919050565b60006104d8827f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed6107c9565b600060607f9400000000000000000000000000000000000000000000000000000000000000610887600167ffffffffffffffff612d45565b67ffffffffffffffff16841115610902576040517f3c55ab3b00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b836000036109c7576040517fd60000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f800000000000000000000000000000000000000000000000000000000000000060368201526037015b6040516020818303038152906040529150611152565b607f8411610a60576040517fd60000000000000000000000000000000000000000000000000000000000000060208201527fff0000000000000000000000000000000000000000000000000000000000000080831660218301527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606088901b16602283015260f886901b1660368201526037016109b1565b60ff8411610b1f576040517fd70000000000000000000000000000000000000000000000000000000000000060208201527fff0000000000000000000000000000000000000000000000000000000000000080831660218301527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606088901b1660228301527f8100000000000000000000000000000000000000000000000000000000000000603683015260f886901b1660378201526038016109b1565b61ffff8411610bff576040517fd80000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f820000000000000000000000000000000000000000000000000000000000000060368201527fffff00000000000000000000000000000000000000000000000000000000000060f086901b1660378201526039016109b1565b62ffffff8411610ce0576040517fd90000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f830000000000000000000000000000000000000000000000000000000000000060368201527fffffff000000000000000000000000000000000000000000000000000000000060e886901b166037820152603a016109b1565b63ffffffff8411610dc2576040517fda0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f840000000000000000000000000000000000000000000000000000000000000060368201527fffffffff0000000000000000000000000000000000000000000000000000000060e086901b166037820152603b016109b1565b64ffffffffff8411610ea5576040517fdb0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f850000000000000000000000000000000000000000000000000000000000000060368201527fffffffffff00000000000000000000000000000000000000000000000000000060d886901b166037820152603c016109b1565b65ffffffffffff8411610f89576040517fdc0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f860000000000000000000000000000000000000000000000000000000000000060368201527fffffffffffff000000000000000000000000000000000000000000000000000060d086901b166037820152603d016109b1565b66ffffffffffffff841161106e576040517fdd0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f870000000000000000000000000000000000000000000000000000000000000060368201527fffffffffffffff0000000000000000000000000000000000000000000000000060c886901b166037820152603e016109b1565b6040517fde0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f880000000000000000000000000000000000000000000000000000000000000060368201527fffffffffffffffff00000000000000000000000000000000000000000000000060c086901b166037820152603f0160405160208183030381529060405291505b5080516020909101209392505050565b60006104d86111e260408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b83611505565b600061126f61126860408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b8484610539565b9392505050565b600061126f83837f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed6119ed565b60008451602086018451f090506112b9816123d3565b60405173ffffffffffffffffffffffffffffffffffffffff8216907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a26000808273ffffffffffffffffffffffffffffffffffffffff168560200151876040516113279190612d29565b60006040518083038185875af1925050503d8060008114611364576040519150601f19603f3d011682016040523d82523d6000602084013e611369565b606091505b5091509150816113c9577f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed816040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b73ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed1631156114fb578373ffffffffffffffffffffffffffffffffffffffff167f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed73ffffffffffffffffffffffffffffffffffffffff163160405160006040518083038185875af1925050503d8060008114611495576040519150601f19603f3d011682016040523d82523d6000602084013e61149a565b606091505b509092509050816114fb577f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed816040517fc2b3f44500000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b5050949350505050565b600080611511846120db565b905060006040518060400160405280601081526020017f67363d3d37363d34f03d5260086018f30000000000000000000000000000000081525090506000828251602084016000f5905073ffffffffffffffffffffffffffffffffffffffff81166115e0576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b604051839073ffffffffffffffffffffffffffffffffffffffff8316907f2feea65dd4e9f9cbd86b74b7734210c59a1b2981b5b137bd0ee3e208200c906790600090a361162c83610823565b935060008173ffffffffffffffffffffffffffffffffffffffff1634876040516116569190612d29565b60006040518083038185875af1925050503d8060008114611693576040519150601f19603f3d011682016040523d82523d6000602084013e611698565b606091505b505090506116a681866124ff565b60405173ffffffffffffffffffffffffffffffffffffffff8616907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a25050505092915050565b6000806116fd876120db565b9050808651602088018651f59150611714826123d3565b604051819073ffffffffffffffffffffffffffffffffffffffff8416907fb8fda7e00c6b06a2b54e58521bc5894fee35f1090e5a3bb6390bfe2b98b497f790600090a36000808373ffffffffffffffffffffffffffffffffffffffff168660200151886040516117849190612d29565b60006040518083038185875af1925050503d80600081146117c1576040519150601f19603f3d011682016040523d82523d6000602084013e6117c6565b606091505b509150915081611826577f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed816040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b73ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed163115611958578473ffffffffffffffffffffffffffffffffffffffff167f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed73ffffffffffffffffffffffffffffffffffffffff163160405160006040518083038185875af1925050503d80600081146118f2576040519150601f19603f3d011682016040523d82523d6000602084013e6118f7565b606091505b50909250905081611958577f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed816040517fc2b3f44500000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b50505095945050505050565b60006107b36119e460408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b858585336116f1565b6000604051836040820152846020820152828152600b8101905060ff815360559020949350505050565b600080611a23876120db565b905060006040518060400160405280601081526020017f67363d3d37363d34f03d5260086018f30000000000000000000000000000000081525090506000828251602084016000f5905073ffffffffffffffffffffffffffffffffffffffff8116611af2576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b604051839073ffffffffffffffffffffffffffffffffffffffff8316907f2feea65dd4e9f9cbd86b74b7734210c59a1b2981b5b137bd0ee3e208200c906790600090a3611b3e83610823565b935060008173ffffffffffffffffffffffffffffffffffffffff1687600001518a604051611b6c9190612d29565b60006040518083038185875af1925050503d8060008114611ba9576040519150601f19603f3d011682016040523d82523d6000602084013e611bae565b606091505b50509050611bbc81866124ff565b60405173ffffffffffffffffffffffffffffffffffffffff8616907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a260608573ffffffffffffffffffffffffffffffffffffffff1688602001518a604051611c299190612d29565b60006040518083038185875af1925050503d8060008114611c66576040519150601f19603f3d011682016040523d82523d6000602084013e611c6b565b606091505b50909250905081611ccc577f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed816040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b73ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed163115611dfe578673ffffffffffffffffffffffffffffffffffffffff167f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed73ffffffffffffffffffffffffffffffffffffffff163160405160006040518083038185875af1925050503d8060008114611d98576040519150601f19603f3d011682016040523d82523d6000602084013e611d9d565b606091505b50909250905081611dfe577f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed816040517fc2b3f44500000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b505050505095945050505050565b60006103dd611e8c60408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b868686866116f1565b60006103dd85858585336116f1565b60006103dd611f2460408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b86868686611a17565b6000808360601b90506040517f3d602d80600a3d3981f3363d3d373d3d3d363d7300000000000000000000000081528160148201527f5af43d82803e903d91602b57fd5bf3000000000000000000000000000000000060288201526037816000f092505073ffffffffffffffffffffffffffffffffffffffff8216612016576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b60405173ffffffffffffffffffffffffffffffffffffffff8316907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a26000808373ffffffffffffffffffffffffffffffffffffffff1634866040516120809190612d29565b60006040518083038185875af1925050503d80600081146120bd576040519150601f19603f3d011682016040523d82523d6000602084013e6120c2565b606091505b50915091506120d282828861247d565b50505092915050565b60008060006120e9846125b3565b9092509050600082600281111561210257612102612e02565b1480156121205750600081600281111561211e5761211e612e02565b145b1561215e57604080513360208201524691810191909152606081018590526080016040516020818303038152906040528051906020012092506123cc565b600082600281111561217257612172612e02565b1480156121905750600181600281111561218e5761218e612e02565b145b156121b0576121a9338560009182526020526040902090565b92506123cc565b60008260028111156121c4576121c4612e02565b03612233576040517f13b3a2a100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b600182600281111561224757612247612e02565b1480156122655750600081600281111561226357612263612e02565b145b1561227e576121a9468560009182526020526040902090565b600182600281111561229257612292612e02565b1480156122b0575060028160028111156122ae576122ae612e02565b145b1561231f576040517f13b3a2a100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b61239a60408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b84036123a657836123c9565b604080516020810186905201604051602081830303815290604052805190602001205b92505b5050919050565b73ffffffffffffffffffffffffffffffffffffffff8116158061240b575073ffffffffffffffffffffffffffffffffffffffff81163b155b1561247a576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b50565b82158061249f575073ffffffffffffffffffffffffffffffffffffffff81163b155b156124fa577f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed826040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b505050565b811580612520575073ffffffffffffffffffffffffffffffffffffffff8116155b80612540575073ffffffffffffffffffffffffffffffffffffffff81163b155b156125af576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000ba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed16600482015260240161062c565b5050565b600080606083901c3314801561261057508260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f0100000000000000000000000000000000000000000000000000000000000000145b1561262057506000905080915091565b606083901c3314801561265a57507fff00000000000000000000000000000000000000000000000000000000000000601484901a60f81b16155b1561266b5750600090506001915091565b33606084901c036126825750600090506002915091565b606083901c1580156126db57508260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f0100000000000000000000000000000000000000000000000000000000000000145b156126ec5750600190506000915091565b606083901c15801561272557507fff00000000000000000000000000000000000000000000000000000000000000601484901a60f81b16155b1561273557506001905080915091565b606083901c61274a5750600190506002915091565b8260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f0100000000000000000000000000000000000000000000000000000000000000036127a55750600290506000915091565b8260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166000036127e15750600290506001915091565b506002905080915091565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600082601f83011261282c57600080fd5b813567ffffffffffffffff80821115612847576128476127ec565b604051601f83017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190828211818310171561288d5761288d6127ec565b816040528381528660208588010111156128a657600080fd5b836020870160208301376000602085830101528094505050505092915050565b6000604082840312156128d857600080fd5b6040516040810181811067ffffffffffffffff821117156128fb576128fb6127ec565b604052823581526020928301359281019290925250919050565b60008060008060a0858703121561292b57600080fd5b84359350602085013567ffffffffffffffff8082111561294a57600080fd5b6129568883890161281b565b9450604087013591508082111561296c57600080fd5b506129798782880161281b565b92505061298986606087016128c6565b905092959194509250565b600080604083850312156129a757600080fd5b82359150602083013567ffffffffffffffff8111156129c557600080fd5b6129d18582860161281b565b9150509250929050565b6000602082840312156129ed57600080fd5b813567ffffffffffffffff811115612a0457600080fd5b6107b38482850161281b565b803573ffffffffffffffffffffffffffffffffffffffff81168114612a3457600080fd5b919050565b600080600060608486031215612a4e57600080fd5b83359250612a5e60208501612a10565b9150604084013567ffffffffffffffff811115612a7a57600080fd5b612a868682870161281b565b9150509250925092565b600060208284031215612aa257600080fd5b5035919050565b600080600060808486031215612abe57600080fd5b833567ffffffffffffffff80821115612ad657600080fd5b612ae28783880161281b565b94506020860135915080821115612af857600080fd5b50612b058682870161281b565b925050612b1585604086016128c6565b90509250925092565b60008060408385031215612b3157600080fd5b82359150612b4160208401612a10565b90509250929050565b60008060408385031215612b5d57600080fd5b612b6683612a10565b946020939093013593505050565b60008060408385031215612b8757600080fd5b612b9083612a10565b9150602083013567ffffffffffffffff8111156129c557600080fd5b60008060408385031215612bbf57600080fd5b50508035926020909101359150565b60008060008060a08587031215612be457600080fd5b843567ffffffffffffffff80821115612bfc57600080fd5b612c088883890161281b565b95506020870135915080821115612c1e57600080fd5b50612c2b8782880161281b565b935050612c3b86604087016128c6565b915061298960808601612a10565b600080600080600060c08688031215612c6157600080fd5b85359450602086013567ffffffffffffffff80821115612c8057600080fd5b612c8c89838a0161281b565b95506040880135915080821115612ca257600080fd5b50612caf8882890161281b565b935050612cbf87606088016128c6565b9150612ccd60a08701612a10565b90509295509295909350565b600080600060608486031215612cee57600080fd5b8335925060208401359150612b1560408501612a10565b60005b83811015612d20578181015183820152602001612d08565b50506000910152565b60008251612d3b818460208701612d05565b9190910192915050565b67ffffffffffffffff828116828216039080821115612d8d577f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b5092915050565b73ffffffffffffffffffffffffffffffffffffffff831681526040602082015260008251806040840152612dcf816060850160208701612d05565b601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016919091016060019392505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fdfea164736f6c6343000817000a", + "nonce": 1, + "storage": {} + }, + "0xcA11bde05977b3631167028862bE2a173976CA11": { + "balance": 0, + "code": "0x6080604052600436106100f35760003560e01c80634d2301cc1161008a578063a8b0574e11610059578063a8b0574e1461025a578063bce38bd714610275578063c3077fa914610288578063ee82ac5e1461029b57600080fd5b80634d2301cc146101ec57806372425d9d1461022157806382ad56cb1461023457806386d516e81461024757600080fd5b80633408e470116100c65780633408e47014610191578063399542e9146101a45780633e64a696146101c657806342cbb15c146101d957600080fd5b80630f28c97d146100f8578063174dea711461011a578063252dba421461013a57806327e86d6e1461015b575b600080fd5b34801561010457600080fd5b50425b6040519081526020015b60405180910390f35b61012d610128366004610a85565b6102ba565b6040516101119190610bbe565b61014d610148366004610a85565b6104ef565b604051610111929190610bd8565b34801561016757600080fd5b50437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0140610107565b34801561019d57600080fd5b5046610107565b6101b76101b2366004610c60565b610690565b60405161011193929190610cba565b3480156101d257600080fd5b5048610107565b3480156101e557600080fd5b5043610107565b3480156101f857600080fd5b50610107610207366004610ce2565b73ffffffffffffffffffffffffffffffffffffffff163190565b34801561022d57600080fd5b5044610107565b61012d610242366004610a85565b6106ab565b34801561025357600080fd5b5045610107565b34801561026657600080fd5b50604051418152602001610111565b61012d610283366004610c60565b61085a565b6101b7610296366004610a85565b610a1a565b3480156102a757600080fd5b506101076102b6366004610d18565b4090565b60606000828067ffffffffffffffff8111156102d8576102d8610d31565b60405190808252806020026020018201604052801561031e57816020015b6040805180820190915260008152606060208201528152602001906001900390816102f65790505b5092503660005b8281101561047757600085828151811061034157610341610d60565b6020026020010151905087878381811061035d5761035d610d60565b905060200281019061036f9190610d8f565b6040810135958601959093506103886020850185610ce2565b73ffffffffffffffffffffffffffffffffffffffff16816103ac6060870187610dcd565b6040516103ba929190610e32565b60006040518083038185875af1925050503d80600081146103f7576040519150601f19603f3d011682016040523d82523d6000602084013e6103fc565b606091505b50602080850191909152901515808452908501351761046d577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260846000fd5b5050600101610325565b508234146104e6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f4d756c746963616c6c333a2076616c7565206d69736d6174636800000000000060448201526064015b60405180910390fd5b50505092915050565b436060828067ffffffffffffffff81111561050c5761050c610d31565b60405190808252806020026020018201604052801561053f57816020015b606081526020019060019003908161052a5790505b5091503660005b8281101561068657600087878381811061056257610562610d60565b90506020028101906105749190610e42565b92506105836020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166105a66020850185610dcd565b6040516105b4929190610e32565b6000604051808303816000865af19150503d80600081146105f1576040519150601f19603f3d011682016040523d82523d6000602084013e6105f6565b606091505b5086848151811061060957610609610d60565b602090810291909101015290508061067d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b50600101610546565b5050509250929050565b43804060606106a086868661085a565b905093509350939050565b6060818067ffffffffffffffff8111156106c7576106c7610d31565b60405190808252806020026020018201604052801561070d57816020015b6040805180820190915260008152606060208201528152602001906001900390816106e55790505b5091503660005b828110156104e657600084828151811061073057610730610d60565b6020026020010151905086868381811061074c5761074c610d60565b905060200281019061075e9190610e76565b925061076d6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166107906040850185610dcd565b60405161079e929190610e32565b6000604051808303816000865af19150503d80600081146107db576040519150601f19603f3d011682016040523d82523d6000602084013e6107e0565b606091505b506020808401919091529015158083529084013517610851577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260646000fd5b50600101610714565b6060818067ffffffffffffffff81111561087657610876610d31565b6040519080825280602002602001820160405280156108bc57816020015b6040805180820190915260008152606060208201528152602001906001900390816108945790505b5091503660005b82811015610a105760008482815181106108df576108df610d60565b602002602001015190508686838181106108fb576108fb610d60565b905060200281019061090d9190610e42565b925061091c6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff1661093f6020850185610dcd565b60405161094d929190610e32565b6000604051808303816000865af19150503d806000811461098a576040519150601f19603f3d011682016040523d82523d6000602084013e61098f565b606091505b506020830152151581528715610a07578051610a07576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b506001016108c3565b5050509392505050565b6000806060610a2b60018686610690565b919790965090945092505050565b60008083601f840112610a4b57600080fd5b50813567ffffffffffffffff811115610a6357600080fd5b6020830191508360208260051b8501011115610a7e57600080fd5b9250929050565b60008060208385031215610a9857600080fd5b823567ffffffffffffffff811115610aaf57600080fd5b610abb85828601610a39565b90969095509350505050565b6000815180845260005b81811015610aed57602081850181015186830182015201610ad1565b81811115610aff576000602083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b600082825180855260208086019550808260051b84010181860160005b84811015610bb1578583037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe001895281518051151584528401516040858501819052610b9d81860183610ac7565b9a86019a9450505090830190600101610b4f565b5090979650505050505050565b602081526000610bd16020830184610b32565b9392505050565b600060408201848352602060408185015281855180845260608601915060608160051b870101935082870160005b82811015610c52577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0888703018452610c40868351610ac7565b95509284019290840190600101610c06565b509398975050505050505050565b600080600060408486031215610c7557600080fd5b83358015158114610c8557600080fd5b9250602084013567ffffffffffffffff811115610ca157600080fd5b610cad86828701610a39565b9497909650939450505050565b838152826020820152606060408201526000610cd96060830184610b32565b95945050505050565b600060208284031215610cf457600080fd5b813573ffffffffffffffffffffffffffffffffffffffff81168114610bd157600080fd5b600060208284031215610d2a57600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81833603018112610dc357600080fd5b9190910192915050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610e0257600080fd5b83018035915067ffffffffffffffff821115610e1d57600080fd5b602001915036819003821315610a7e57600080fd5b8183823760009101908152919050565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc1833603018112610dc357600080fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa1833603018112610dc357600080fdfea2646970667358221220bb2b5c71a328032f97c676ae39a1ec2148d3e5d6f73d95e9b17910152d61f16264736f6c634300080c0033", + "nonce": 1, + "storage": {} + }, + "0xce0042B868300000d44A59004Da54A005ffdcf9f": { + "balance": 0, + "code": "0x6080604052348015600f57600080fd5b506004361060285760003560e01c80634af63f0214602d575b600080fd5b60cf60048036036040811015604157600080fd5b810190602081018135640100000000811115605b57600080fd5b820183602082011115606c57600080fd5b80359060200191846001830284011164010000000083111715608d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550509135925060eb915050565b604080516001600160a01b039092168252519081900360200190f35b6000818351602085016000f5939250505056fea26469706673582212206b44f8a82cb6b156bfcc3dc6aadd6df4eefd204bc928a4397fd15dacf6d5320564736f6c63430006020033", + "nonce": 1, + "storage": {} + }, + "0xd6CEDDe84be40893d153Be9d467CD6aD37875b28": { + "balance": 0, + "code": "0x6080604052600436101561001d575b366131ab5761001b61317a565b005b5f3560e01c8063112d3a7d1461021c578063150b7a02146102175780631626ba7e1461021257806319822f7c1461020d5780631f1b92e3146102085780633659cfe6146102035780633c3b752b146101fe57806352141cd9146101f957806357b3a5f4146101f45780636e6fa0c6146101ef578063721e67f4146101ea57806384b0196e146101e55780638dd7712f146101e057806390ef8862146101db5780639198bdf5146101d65780639517e29f146101d15780639cfd7cff146101cc578063a65d69d4146101c7578063a71763a8146101c2578063adb610a3146101bd578063b8afe17d146101b8578063b9b82941146101b3578063bc197c81146101ae578063c3e58978146101a9578063d03c7914146101a4578063d691c9641461019f578063e6f3d50a1461019a578063e9ae5c5314610195578063f1f7f0f914610190578063f23a6e611461018b578063f2dc691d146101865763fc9c5dec0361000e57611cc0565b611c9f565b611c45565b611c0f565b611adf565b61195f565b61187d565b611830565b61175d565b61164c565b6114f5565b61147f565b61144d565b61131d565b6112d9565b611281565b611151565b61103a565b610f2b565b610e30565b610d92565b610d0c565b610cb1565b610bfe565b610a5e565b6109c8565b61084b565b610695565b610390565b61032f565b6102d5565b6102af565b6001600160a01b0381160361023257565b5f80fd5b359061024182610221565b565b9181601f84011215610232578235916001600160401b038311610232576020838186019501011161023257565b6060600319820112610232576004359160243561028c81610221565b91604435906001600160401b038211610232576102ab91600401610243565b9091565b346102325760206102cb6102c236610270565b92919091611f13565b6040519015158152f35b34610232576080366003190112610232576102f1600435610221565b6102fc602435610221565b6064356001600160401b0381116102325761031b903690600401610243565b5050604051630a85bd0160e11b8152602090f35b34610232576040366003190112610232576004356024356001600160401b0381116102325760209161036861036e923690600401610243565b91613377565b6040516001600160e01b03199091168152f35b90816101209103126102325790565b6060366003190112610232576004356001600160401b038111610232576103bb903690600401610381565b602435604435917f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b03163303610662576103ff60208201356135c3565b9092919084836001600160f81b0319861615610649575b8261042193946137bc565b9361044661043661043184611ea5565b611fdd565b946001600160f81b031916151590565b938480610608575b6105f957602001516001600160a01b0316801591819083806105f2575b6104f2576001926104866104a5925f525f60205260405f2090565b80546001600160a01b0319166001600160a01b03909216919091179055565b149081156105ea575b501561055d5782610501575b50506104f257816104dc925b6104e0575b506040519081529081906020820190565b0390f35b3490349034903490335af1505f6104cb565b631a0a9b9f60e21b5f5260045ffd5b61055692506105529161053661053061052a61051f61054b95611edc565b936060810190612009565b90611d20565b90611e6f565b63ffffffff60e01b165f5260205260405f2090565b5460ff1690565b1590565b5f806104ba565b829192916105b7575b506104f25761052a81606061057c930190612009565b638dd7712f60e01b916001600160e01b03199161059891611e6f565b16036105a857816104dc926104c6565b63dbbb044b60e01b5f5260045ffd5b6105e4915061054b6105cb61055292611edc565b6105366105306105de6060880188612009565b90611d0f565b5f610566565b90505f6104ae565b508661046b565b633ab3447f60e11b5f5260045ffd5b50805163ffffffff1663ffffffff6106416106385f516020615f755f395f51905f525463ffffffff9060c81c1690565b63ffffffff1690565b91161061044e565b5f516020615f755f395f51905f525460581b9250610416565b6348f5c3ed60e01b5f5260045ffd5b6004359063ffffffff8216820361023257565b359063ffffffff8216820361023257565b6020366003190112610232576106a9610671565b337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580610841575b15610838576107116107056106ff5f516020615f755f395f51905f525460581b90565b60581c90565b6001600160a01b031690565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f91610809575b50156106625760405163d68f602560e01b8152916001600160a01b0391909116905f838061076e36343360048501612167565b038183865af19283156107dc575f936107e1575b5061078c90613a50565b803b1561023257604051630b9dfbed60e11b8152915f9183918290849082906107b890600483016121b0565b03925af180156107dc576107c857005b806107d65f61001b93610fe3565b80610d64565b6120bf565b61078c919350610802903d805f833e6107fa8183610fe3565b8101906120e5565b9290610782565b61082b915060203d602011610831575b6108238183610fe3565b8101906120a7565b5f61073b565b503d610819565b61001b90613a50565b50303314156106dc565b60203660031901126102325760043561086381610221565b337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b031614158061097c575b15610973576108b96107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f91610954575b50156106625760405163d68f602560e01b8152916001600160a01b0391909116905f838061091636343360048501612167565b038183865af19283156107dc575f93610934575b5061078c906121c1565b61078c91935061094d903d805f833e6107fa8183610fe3565b929061092a565b61096d915060203d602011610831576108238183610fe3565b5f6108e3565b61001b906121c1565b5030331415610896565b6001600160581b031981160361023257565b9181601f84011215610232578235916001600160401b038311610232576020808501948460051b01011161023257565b346102325760a0366003190112610232576004356109e581610986565b6024356109f181610221565b6044356001600160401b03811161023257610a10903690600401610243565b6064939193356001600160401b03811161023257610a32903690600401610243565b91608435956001600160401b03871161023257610a5661001b973690600401610998565b969095612327565b608036600319011261023257600435610a7681610986565b602435610a8281610221565b6044356001600160401b03811161023257610aa1903690600401610243565b6064356001600160401b03811161023257610ac0903690600401610243565b91337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580610be2575b15610bd457610b176107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f91610bb5575b50156106625760405163d68f602560e01b8152956001600160a01b039190911694905f8780610b7536343360048501612167565b0381838a5af19687156107dc575f97610b95575b5061078c9596976124aa565b61078c969750610bae903d805f833e6107fa8183610fe3565b9695610b89565b610bce915060203d602011610831576108238183610fe3565b5f610b41565b919361001b959391936124aa565b5030331415610af4565b6001600160e01b031981160361023257565b34610232576020366003190112610232576104dc610c3f600435610c2181610bec565b5f60408051610c2f81610f71565b828152826020820152015261203b565b600160405191610c4e83610f71565b805460a083901b839003168084529101546001600160a01b0380821660208086019182526001600160f81b031960589490941b84166040968701908152865195841686529151909216918401919091525116918101919091529081906060820190565b3461023257604036600319011261023257602060ff610d00600435610cd581610986565b610cea60243591610ce583610bec565b611edc565b9063ffffffff60e01b165f5260205260405f2090565b54166040519015158152f35b34610232576020366003190112610232576020610d41600435610d2e81610221565b5f604051610d3b81610f91565b5261332a565b60405190610d4e82610f91565b546001600160a01b031690819052604051908152f35b5f91031261023257565b805180835260209291819084018484015e5f828201840152601f01601f1916010190565b34610232575f36600319011261023257610dd1610ddf610db0613d9c565b604092919251938493600f60f81b855260e0602086015260e0850190610d6e565b908382036040850152610d6e565b4660608301523060808301525f60a083015281810360c083015260206060519182815201906080905f5b818110610e17575050500390f35b8251845285945060209384019390920191600101610e09565b6040366003190112610232576004356001600160401b03811161023257610e5b903690600401610381565b6024357f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b03163303610662575f908152602081905260409020546001600160a01b03169060609060018314159081610efb575b610ed0610ec9826060610ed7940190612009565b8091611d2e565b9030613e84565b5015610eec57610ee357005b61001b91613ea9565b63f21e646b60e01b5f5260045ffd5b9150610ed7610ed0610ec9610f21610f19610ec96060880188612009565b903489613e39565b9492505050610eb5565b34610232575f36600319011261023257602063ffffffff5f516020615f755f395f51905f525460c81c16604051908152f35b634e487b7160e01b5f52604160045260245ffd5b606081019081106001600160401b03821117610f8c57604052565b610f5d565b602081019081106001600160401b03821117610f8c57604052565b604081019081106001600160401b03821117610f8c57604052565b61012081019081106001600160401b03821117610f8c57604052565b90601f801991011681019081106001600160401b03821117610f8c57604052565b60405190610241604083610fe3565b6040519061024161012083610fe3565b6001600160401b038111610f8c5760051b60200190565b6080366003190112610232576004356001600160401b03811161023257611065903690600401610998565b602435916001600160401b038311610232573660238401121561023257826004013561109081611023565b9361109e6040519586610fe3565b8185526024602086019260061b8201019036821161023257602401915b818310611110575050506044356001600160401b038111610232576110e4903690600401610998565b90606435946001600160401b0386116102325761110861001b963690600401610998565b9590946125af565b604083360312610232576020604091825161112a81610fac565b61113386610684565b81528286013561114281610221565b838201528152019201916110bb565b61115a36610270565b337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580611277575b1561126d576111b06107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f9161124e575b50156106625760405163d68f602560e01b8152936001600160a01b039190911692905f858061120e36343360048501612167565b038183885af19485156107dc575f9561122e575b5061078c93949561277e565b61078c949550611247903d805f833e6107fa8183610fe3565b9493611222565b611267915060203d602011610831576108238183610fe3565b5f6111da565b9161001b9361277e565b503033141561118d565b34610232575f366003190112610232576104dc6040516112a2604082610fe3565b60168152756b65726e656c2e616476616e6365642e76302e332e3360501b6020820152604051918291602083526020830190610d6e565b34610232575f366003190112610232576040517f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b03168152602090f35b61132636610270565b337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580611443575b156114395761137c6107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f9161141a575b50156106625760405163d68f602560e01b8152936001600160a01b039190911692905f85806113da36343360048501612167565b038183885af19485156107dc575f956113fa575b5061078c939495612ad2565b61078c949550611413903d805f833e6107fa8183610fe3565b94936113ee565b611433915060203d602011610831576108238183610fe3565b5f6113a6565b9161001b93612ad2565b5030331415611359565b34610232575f36600319011261023257602063ffffffff5f516020615f755f395f51905f525460a81c16604051908152f35b346102325760203660031901126102325760406114af6004356114a181610986565b6114a9612cfc565b50611ea5565b8151906114bb82610fac565b5463ffffffff81168083526001600160a01b03602092831c81169383019384528451918252925190921690820152f35b8015150361023257565b60603660031901126102325760043561150d81610986565b60243561151981610bec565b604435611525816114eb565b337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580611642575b156116385761157b6107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f91611619575b50156106625760405163d68f602560e01b8152926001600160a01b039190911691905f84806115d936343360048501612167565b038183875af19384156107dc575f946115f9575b5061078c929394614356565b61078c939450611612903d805f833e6107fa8183610fe3565b93926115ed565b611632915060203d602011610831576108238183610fe3565b5f6115a5565b9061001b92614356565b5030331415611558565b346102325760a036600319011261023257611668600435610221565b611673602435610221565b6044356001600160401b03811161023257611692903690600401610998565b50506064356001600160401b038111610232576116b3903690600401610998565b50506084356001600160401b038111610232576116d4903690600401610243565b505060405163bc197c8160e01b8152602090f35b602080825282516001600160f01b03191681830152808301516001600160a01b03166040808401919091529092015160608083015280516080830181905260a09092019201905f5b81811061173d5750505090565b82516001600160501b031916845260209384019390920191600101611730565b346102325760203660031901126102325761179c60043561177d81610bec565b60606040805161178c81610f71565b5f81525f60208201520152612071565b6001604051916117ab83610f71565b805461ffff60f01b8160f01b168452828060a01b039060101c16602084015201906040519182602082549182815201915f5260205f20905f5b81811061180d576104dc85876117fc81880382610fe3565b6040820152604051918291826116e8565b825460501b6001600160501b0319168452602090930192600192830192016117e4565b346102325760203660031901126102325760206102cb600435612d14565b9060406003198301126102325760043591602435906001600160401b038211610232576102ab91600401610243565b6118863661184e565b90916001600160a01b036118993361332a565b54169182156119505760609060018414159485611939575b6118bb92936145dc565b92611929575b50506040518091602082016020835281518091526040830190602060408260051b8601019301915f905b8282106118fa57505050500390f35b919360019193955060206119198192603f198a82030186528851610d6e565b96019201920185949391926118eb565b61193291613ea9565b5f806118c1565b6118bb9250611949363487613de0565b92506118b1565b63710c949760e01b5f5260045ffd5b60603660031901126102325760043561197781610986565b6024356001600160401b03811161023257611996903690600401610243565b6044356001600160401b038111610232576119b5903690600401610243565b90337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580611ad5575b15611ac957611a0c6107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f91611aaa575b50156106625760405163d68f602560e01b8152946001600160a01b039190911693905f8680611a6a36343360048501612167565b038183895af19586156107dc575f96611a8a575b5061078c949596612dec565b61078c959650611aa3903d805f833e6107fa8183610fe3565b9594611a7e565b611ac3915060203d602011610831576108238183610fe3565b5f611a36565b91909261001b94612dec565b50303314156119e9565b611ae83661184e565b337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580611c05575b15611bfb57611b3e6107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f91611bdc575b50156106625760405163d68f602560e01b8152926001600160a01b039190911691905f8480611b9c36343360048501612167565b038183875af19384156107dc575f94611bbc575b5061078c92939461307f565b61078c939450611bd5903d805f833e6107fa8183610fe3565b9392611bb0565b611bf5915060203d602011610831576108238183610fe3565b5f611b68565b9061001b9261307f565b5030331415611b1b565b34610232575f3660031901126102325760205f516020615f755f395f51905f525460581b604051906001600160581b0319168152f35b346102325760a036600319011261023257611c61600435610221565b611c6c602435610221565b6084356001600160401b03811161023257611c8b903690600401610243565b505060405163f23a6e6160e01b8152602090f35b34610232576020366003190112610232576020600435600760405191108152f35b34610232576040366003190112610232576004356001600160401b03811161023257611d07611cf56020923690600401610381565b60243590611d0282610221565b61308a565b604051908152f35b906008116102325760040190600490565b906004116102325790600490565b909291928360041161023257831161023257600401916003190190565b906018116102325760040190601490565b906014116102325790601490565b906020116102325790602090565b90929192836020116102325783116102325760200191601f190190565b909291928360011161023257831161023257600101915f190190565b906009116102325760010190600890565b909291928360091161023257831161023257600901916008190190565b906016116102325790601690565b906016116102325760020190601490565b909291928360161161023257831161023257601601916015190190565b906002116102325790600290565b909291928360141161023257831161023257601401916013190190565b90602c116102325760180190601490565b90939293848311610232578411610232578101920390565b356001600160e01b0319811692919060048210611e8a575050565b6001600160e01b031960049290920360031b82901b16169150565b6001600160581b0319165f527f7bcaa2ced2a71450ed5a9a1b4848e8e5206dbc3f06011e595f7f55428cc6f85060205260405f2090565b6001600160581b0319165f527f7bcaa2ced2a71450ed5a9a1b4848e8e5206dbc3f06011e595f7f55428cc6f85160205260405f2090565b90929060018103611f6257506001600160a01b0392611f5c9250611f4c915060581b600160581b600160f81b0316600160f81b17611ea5565b5460201c6001600160a01b031690565b16151590565b60028103611f9657506001600160a01b0392611f5c92506107059150611f8990841661332a565b546001600160a01b031690565b600303611fd657611fb7611fb2610530600193611fc595611d20565b61203b565b01546001600160a01b031690565b6001600160a01b0390811691161490565b5050505f90565b90604051611fea81610fac565b915463ffffffff81168352602090811c6001600160a01b031690830152565b903590601e198136030182121561023257018035906001600160401b0382116102325760200191813603831361023257565b63ffffffff60e01b165f527f7c341349a4360fdd5d5bc07e69f325dc6aaea3eb018b3e0ea7e53cc0bb0d6f3b60205260405f2090565b63ffffffff60e01b165f527f7bcaa2ced2a71450ed5a9a1b4848e8e5206dbc3f06011e595f7f55428cc6f85260205260405f2090565b9081602091031261023257516120bc816114eb565b90565b6040513d5f823e3d90fd5b6001600160401b038111610f8c57601f01601f191660200190565b602081830312610232578051906001600160401b038211610232570181601f8201121561023257805190612118826120ca565b926121266040519485610fe3565b8284526020838301011161023257815f9260208093018386015e8301015290565b908060209392818452848401375f828201840152601f01601f1916010190565b6120bc939260609260018060a01b03168252602082015281604082015201905f612147565b6120bc949260609260018060a01b0316825260208201528160408201520191612147565b9060206120bc928181520190610d6e565b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc8190556001600160a01b03167fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b5f80a2565b60405190612223602083610fe3565b5f8252565b90813b5f612235826120ca565b936122436040519586610fe3565b82855260208501903c565b80516020909101516001600160e81b0319811692919060038210612270575050565b6001600160e81b0319600392830390921b82901b16169150565b5f516020615f755f395f51905f52805463ffffffff60a81b191660a89290921b63ffffffff60a81b16919091179055565b634e487b7160e01b5f52603260045260245ffd5b908210156122e6576102ab9160051b810190612009565b6122bb565b908092918237015f815290565b3d15612322573d90612309826120ca565b916123176040519384610fe3565b82523d5f602084013e565b606090565b94939291909695966001600160581b03196123606123535f516020615f755f395f51905f525460581b90565b6001600160581b03191690565b1615801590612482575b612474576001600160581b03198616156104f2576001600160f81b03198616600160f81b14158061245c575b61244d57856123a76123f497613b15565b6123c86123b2611004565b60018152925b6001600160a01b03166020840152565b5f516020615f755f395f51905f52805463ffffffff60a81b1916600160a81b179055613b7b565b613b7b565b5f5b81811061240257505050565b5f8061240f8385876122cf565b9061241f604051809381936122eb565b039082305af161242d6122f8565b501561243b576001016123f6565b636534eae560e11b5f5260045260245ffd5b6361c4e91b60e11b5f5260045ffd5b506001600160f81b03198616600160f91b1415612396565b62dc149f60e41b5f5260045ffd5b5061ef0160f01b6001600160e81b03196124a361249e30612228565b61224e565b161461236a565b94939291906001600160581b03198616156104f2576001600160f81b03198616600160f81b141580612555575b61244d576124e486613b15565b6001600160a01b036124f8611f4c88611ea5565b1615612507575b505050505050565b61254a956123ef61252d5f516020615f755f395f51905f525463ffffffff9060a81c1690565b926123b860405194612540604087610fe3565b63ffffffff168552565b5f80808080806124ff565b506001600160f81b03198616600160f91b14156124d7565b9060405161257a81610f71565b82546001600160a01b03908116825260019390930154928316602082015260589290921b6001600160f81b0319166040830152565b95909491939092337f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b0316141580612723575b156127155761260c6107056106ff5f516020615f755f395f51905f525460581b90565b60405163ecd0596160e01b8152600480820152602081602481855afa9081156107dc575f916126f6575b50156106625760405163d68f602560e01b8152966001600160a01b039190911695905f888061266a36343360048501612167565b0381838b5af19788156107dc575f986126d6575b5061268a969798613f22565b803b1561023257604051630b9dfbed60e11b8152915f9183918290849082906126b690600483016121b0565b03925af180156107dc576126c8575b50565b806107d65f61024193610fe3565b61268a9798506126ef903d805f833e6107fa8183610fe3565b979661267e565b61270f915060203d602011610831576108238183610fe3565b5f612636565b919390929461024196613f22565b50303314156125e9565b9160206120bc938181520191612147565b356bffffffffffffffffffffffff1981169291906014821061275e575050565b6bffffffffffffffffffffffff1960149290920360031b82901b16169150565b9091600182036128fa575f516020615f355f395f51905f5293906054600160f81b600160581b600160f81b03605887901b1617916127c86127be84611ea5565b5463ffffffff1690565b5f516020615f755f395f51905f525460a81c63ffffffff169063ffffffff8083169116146128e2575b5061285a61283361070561282d6128276128205f516020615f755f395f51905f525463ffffffff9060a81c1690565b9886611d5c565b9061273e565b60601c90565b61284a61283e611004565b63ffffffff9097168752565b6001600160a01b03166020860152565b612885601482019461286c8680612009565b61287c6034869493940189612009565b93909288613b7b565b019160046128938483612009565b9050146128c1575b5050505b604080519182526001600160a01b03909216602082015290819081015b0390a1565b61053061052a6128da946128d493612009565b906142f1565b5f808061289b565b6128f49060010163ffffffff1661228a565b5f6127f1565b6002820361296257806129575f516020615f355f395f51905f5295603461293161070561282d612827601461295d99019588611d5c565b93612950856129408580612009565b906001600160a01b038c16614267565b0190612009565b91614117565b61289f565b600382036129d857806129575f516020615f355f395f51905f529560386129d061070561282d612827601861295d9901956129ca8c8a6129b861070561282d612827876129b26105308288611d20565b95611d4b565b6129c28b80612009565b939092613f8f565b88611d4b565b930190612009565b600482148015612a76575b8015612a6c575b15612a5d576001600160a01b03831693843b1561023257612a24945f92836040518098819582946306d61fe760e41b84526004840161272d565b03925af19283156107dc575f516020615f355f395f51905f5293612a49575b5061289f565b806107d65f612a5793610fe3565b5f612a43565b631092ef5760e11b5f5260045ffd5b50600682146129ea565b50600582146129e3565b359060208110612a8e575090565b5f199060200360031b1b1690565b929192612aa8826120ca565b91612ab66040519384610fe3565b829481845281830111610232578281602093845f960137010152565b909160018203612b43575f516020615f555f395f51905f5293612b2191612b1b91612b12600160f81b600160581b600160f81b03605889901b1617614424565b505b3691612a9c565b83614484565b50604080519182526001600160a01b03909216602082015290819081016128bc565b60028203612bb3576001600160a01b038381165f9081527f1bbee3173dbdc223633258c9f337a0fff8115f206d302bea0ed3eac003b68b866020526040902080546001600160a01b031981169091555f516020615f555f395f51905f5295612b2193612b1b939092165b50612b14565b919060038103612bd8575050610530612bd492612bcf92611d20565b6143c0565b5050565b909160048203612c6157612b1b612b21915f516020615f555f395f51905f5295612c105f516020615f755f395f51905f525460581b90565b612c1c611f4c82611ea5565b6001600160a01b03888116911614612c345750612b14565b612c40612c5b91611ea5565b8054640100000000600160c01b031916640100000000179055565b5f612bad565b600582148015612cf2575b15612a5d575f516020615f755f395f51905f525460581b612c96612c908684611d6a565b90612a80565b906001600160f81b03198116600160f91b14612cca575b5050612b1b612b21915f516020615f555f395f51905f5295612b14565b60081b6001600160e01b03191614612ce3575f80612cad565b6313002bdd60e31b5f5260045ffd5b5060068214612c6c565b60405190612d0982610fac565b5f6020838281520152565b612d28818060081b918160301b9160501b90565b929091906001600160f81b03198116600160f81b14159081612dd8575b81612dc3575b81612daa575b50611fd65760ff60f81b16600160f81b8114159081612da0575b50612d9a576001600160e01b031916612d95576001600160501b031916612d9157600190565b5f90565b505f90565b50505f90565b905015155f612d6b565b6001600160f81b031916607f60f91b141590505f612d51565b6001600160f81b031980821614159150612d4b565b6001600160f81b0319811615159150612d45565b9094939291612dfa82614424565b916001600160f81b03198116600160f81b03612e72575f516020615f555f395f51905f5291612e48612e426102419899612e6a9460018060a01b039060581c16933691612a9c565b82614484565b5060408051600181526001600160a01b03909216602083015290918291820190565b0390a161488a565b9294919290506001600160f81b03198116600160f91b0361244d5760081b92612e9a84612071565b94612ea587806147fb565b94905060018701946001865401036130705794956001600160e01b0319871695905f5b8654811015612f7e57805f516020615f555f395f51905f52612f758c612f53612f45612e428e612f338f99612f2d612f1d612f15612f088460019f614830565b90549060031b1c60501b90565b908160501c90565b90508c8060a01b031697806147fb565b906122cf565b60409491945194859360208501614845565b03601f198101835282610fe3565b5060408051600581526001600160a01b03909216602083015290918291820190565b0390a101612ec8565b509194509296612ff961300c9161300761306b9598612faa6001612fa46102419d612071565b0161485a565b8554612fe790612fc59060101c6001600160a01b0316610705565b93612fdc612fd382806147fb565b919092806147fb565b5f19019290506122cf565b60409591955195869360208501614845565b03601f198101845283610fe3565b614484565b5080545f516020615f555f395f51905f5290613058906130379060101c6001600160a01b0316610705565b60408051600681526001600160a01b03909216602083015290918291820190565b0390a180546001600160b01b0319169055565b61488a565b63013dcc8d60e31b5f5260045ffd5b906126c592916145dc565b612f4590613174906130a56001600160a01b03823516610705565b906131466020820135612f456130c76130c16040860186612009565b90614915565b936130d86130c16060830183612009565b90608081013560a0820135906130fa6130c160c08501359460e0810190612009565b93604051988997602089019b8c94919260e0969399989794919961010087019a60018060a01b03168752602087015260408601526060850152608084015260a083015260c08201520152565b51902060408051602081019283526001600160a01b03909516908501525f6060850152929182906080820190565b51902090565b7f88a5966d370b9919b20f3e2c13ff65706f196a4e32cc2c12bf57088f8852587460408051338152346020820152a1565b6131c76131c25f356001600160e01b03191661203b565b61256d565b80516001600160a01b03906131dd908216610705565b161561331b5780516060906001600160a01b0316806002600160a01b031981016132f95750507f0000000000000000000000000000000071727de22e5e9d8baf0edac6f37da0326001600160a01b03163303610662575b60408201516001600160f81b031916806132b957506020820151613260906001600160a01b0316614920565b92905b156132b157516001600160a01b0316906001821415828161329d575b5061328d575b825160208401f35b61329691613ea9565b5f80613285565b6001600160a01b039150811614155f61327f565b825160208401fd5b6001600160f81b0319908116036132ea5760208201516132e3906001600160a01b03163690613e5f565b9290613263565b632d6a6bb760e01b5f5260045ffd5b600103613307575b50613234565b613315915036903490613de0565b5f613301565b631cd4b64760e21b5f5260045ffd5b6001600160a01b03165f9081527f1bbee3173dbdc223633258c9f337a0fff8115f206d302bea0ed3eac003b68b866020526040902090565b9081602091031261023257516120bc81610bec565b9161338191614962565b919080836001600160f81b03198416156135a3575b6020811015918261356c575b82613556575b50506133b661043184611ea5565b60208101516001600160a01b03906133cf908216610705565b161580613543575b6104f2576001600160f81b031984161515908161350a575b506105f9576001600160f81b03198316600160f81b03613483579061341b6020949392613439966149b4565b604051637aa8f17760e11b815295869485938493336004860161218c565b039160581c6001600160a01b03165afa9081156107dc575f9161345a575090565b6120bc915060203d60201161347c575b6134748183610fe3565b810190613362565b503d61346a565b92916001600160f81b03198116600160f91b036134e35760081b93600160f11b6134c66134b96134b288612071565b5460f01b90565b6001600160f01b03191690565b166134d4576120bc94614af2565b635b71057960e01b5f5260045ffd5b929391929091906001600160f81b03191661244d576120bc93613505916149b4565b614a0e565b5163ffffffff16905063ffffffff61353a6106385f516020615f755f395f51905f525463ffffffff9060c81c1690565b9116105f6133ef565b506001600160f81b0319841615156133d7565b819295506135649350611d78565b925f806133a8565b91507f0555ad2729e8da1777a4e5020806f8bf7601c3db6bfe402f410a34958363a95a61359c612c908385611d6a565b14916133a2565b92506135bd5f516020615f755f395f51905f525460581b90565b92613396565b90818060081b9160ff839260f01c166002146135db57565b6001600160d81b031983169150565b9080601f83011215610232578160206120bc93359101612a9c565b919091610120818403126102325761361b611013565b9261362582610236565b84526020820135602085015260408201356001600160401b03811161023257816136509184016135ea565b604085015260608201356001600160401b03811161023257816136749184016135ea565b60608501526080820135608085015260a082013560a085015260c082013560c085015260e08201356001600160401b03811161023257816136b69184016135ea565b60e08501526101008201356001600160401b038111610232576136d992016135ea565b610100830152565b90816020910312610232575190565b80516001600160a01b031682526120bc916020820151602082015261010061377161374161372f60408601516101206040870152610120860190610d6e565b60608601518582036060870152610d6e565b6080850151608085015260a085015160a085015260c085015160c085015260e085015184820360e0860152610d6e565b92015190610100818403910152610d6e565b9392916137a09060409286526060602087015260608601906136f0565b930152565b9291906137a06020916040865260408601906136f0565b9290925f926137cb3682613605565b916137da610100830183612009565b918193835f93602082101580613a1b575b6139dc575b5050506001600160f81b031916600160f81b145b6139b5575b506001600160f81b03198616600160f81b036138a0575050604051639700320360e01b815293602092859283915f91839161384791600484016137a5565b039260581c6001600160a01b03165af19081156107dc576120bc925f9261386f575b50614fce565b61389291925060203d602011613899575b61388a8183610fe3565b8101906136e1565b905f613869565b503d613880565b909491939092916001600160f81b03198116600160f91b036139515760081b93600160f01b6138d46134b96134b288612071565b16613942575f6138f46138ee60209661391e99858a614ddf565b94614fce565b604051630ccab7a160e01b8152979096889586948593926001600160e01b03191660048501613783565b03926001600160a01b03165af19081156107dc576120bc925f9261386f5750614fce565b6314b9743f60e01b5f5260045ffd5b919350506001600160f81b03191661244d576139a690630b135d3f60e11b9361350563ffffffff60e01b946020527b19457468657265756d205369676e6564204d6573736167653a0a33325f52603c60042090565b16036139b0575f90565b600190565b916139c293955086614ba7565b9193916139d0368284612a9c565b6101008401525f613809565b9195509250829196506139ef9350611d78565b90916139fc368385612a9c565b610100850152613804613a12600192339061308a565b95905f806137f0565b507f0555ad2729e8da1777a4e5020806f8bf7601c3db6bfe402f410a34958363a95a613a4a612c908484611d6a565b146137eb565b5f516020615f755f395f51905f5254600a63ffffffff8260a81c16019063ffffffff8211613b015763ffffffff83811692168211613af257613a9a9060c81c63ffffffff16610638565b10156105f9575f516020615f755f395f51905f52805463ffffffff60c81b60c893841b1663ffffffff60c81b1990911617908190559081901c63ffffffff9081169160a81c168111613ae95750565b6102419061228a565b63e60fd64760e01b5f5260045ffd5b634e487b7160e01b5f52601160045260245ffd5b60207f6789ec0c85d6458d897a36a70129b101f8b4d84c6e218046c3107373dbcbae88918060581c6001600160581b0360a81b5f516020615f755f395f51905f525416175f516020615f755f395f51905f5255604051906001600160581b0319168152a1565b929490939193613b8d6127be85611ea5565b5f516020615f755f395f51905f525460a81c63ffffffff169063ffffffff808316911614613d84575b506020810180516001600160a01b031615613d7b575b5f516020615f755f395f51905f525460a81c63ffffffff1663ffffffff613bfa610638855163ffffffff1690565b911614801590613d4f575b6105f957613c53613c6092613c1987611ea5565b815181546020938401516001600160c01b031990911663ffffffff9290921691909117921b640100000000600160c01b0316919091179055565b516001600160a01b031690565b915f196001600160a01b03841601613d3e575b5050506001600160f81b03198116600160f81b03613d1b5760581c6001600160a01b031690813b15610232576040516306d61fe760e41b8152925f9184918291613cc191906004840161272d565b038183855af19081156107dc575f516020615f355f395f51905f52926128bc92613d07575060408051600181526001600160a01b03909216602083015290918291820190565b806107d65f613d1593610fe3565b5f612e48565b91506001600160f81b03198216600160f91b0361244d576102419160081b61513c565b613d4792614117565b5f8080613c73565b50613d5c6127be86611ea5565b63ffffffff613d72610638855163ffffffff1690565b91161015613c05565b60018152613bcc565b613d969060010163ffffffff1661228a565b5f613bb6565b6040908151613dab8382610fe3565b600681526512d95c9b995b60d21b602082015291613dcb81519182610fe3565b6005815264302e332e3360d81b602082015290565b91613e07925f8080946040519687958694859363d68f602560e01b8552336004860161218c565b03926001600160a01b03165af19081156107dc575f91613e25575090565b6120bc91503d805f833e6107fa8183610fe3565b5f9283613e0795936040519687958694859363d68f602560e01b8552336004860161218c565b5f9060405192808385378338925af4913d82523d5f602084013e60203d830101604052565b5f9192806040519485378338925af4913d82523d5f602084013e60203d830101604052565b6001600160a01b0316803b1561023257604051630b9dfbed60e11b815260206004820152915f918391829084908290613ee6906024830190610d6e565b03925af180156107dc57613ef75750565b5f61024191610fe3565b8051156122e65760200190565b80518210156122e65760209160051b010190565b909593919492965f5b87811015613f7b57600190613f758a89613f618489613f5a828060051b8c013595613f5587610986565b613f0e565b51946122cf565b90613f6d868d8d6122cf565b949093613b7b565b01613f2b565b509650505050505050565b90156122e65790565b9293929091906001600160a01b03821615614108575b613fae9061203b565b93613fda613fcd613fbf8684613f86565b356001600160f81b03191690565b6001600160f81b03191690565b936001600160f81b031985166140e0576001600160a01b0384169161400191908190611d95565b823b156102325761402b925f92836040518096819582946306d61fe760e41b84526004840161272d565b03925af19485156107dc5761408f6140af93600193610241986140cc575b5060408051600381526001600160a01b03881660208201525f516020615f355f395f51905f529190a15b82546001600160a01b0319166001600160a01b03909116178255565b0180546001600160a01b0319166001600160a01b03909316929092178255565b805460ff60a01b191660589290921c60ff60a01b16919091179055565b806107d65f6140da93610fe3565b5f614049565b50909390506001600160f81b0319808416036132ea5760016140af9161408f61024196614073565b6001600160a01b039150613fa5565b6001600160a01b031691908215801561425d575b6142585760405163d60b347f60e01b8152306004820152602081602481875afa9081156107dc575f91614239575b50158015614210575b61419a575b505060408051600481526001600160a01b0390921660208301525f516020615f355f395f51905f529190819081016128bc565b90806141a592611d95565b9190813b15610232576040516306d61fe760e41b8152925f91849182916141d091906004840161272d565b038183855af19081156107dc575f516020615f355f395f51905f52926128bc926141fc575b5091614167565b806107d65f61420a93610fe3565b5f6141f5565b50811515801561416257506001600160f81b031980614232613fbf8585613f86565b1614614162565b614252915060203d602011610831576108238183610fe3565b5f614159565b505050565b506001831461412b565b926142759092919284615434565b806142bb57915f929183612f456142a5829560405192839160208301966306d61fe760e41b88526024840161272d565b51926001600160a01b03165af1506126c56122f8565b6001600160a01b0390921691823b15610232576126b6925f92836040518096819582946306d61fe760e41b84526004840161272d565b7f9d17cd6d095ac90a655405ab29f30a7ee7e88ef3974c1bf7544bf591043bb71a9160609161432382610cea83611edc565b600160ff198254161790556040519163ffffffff60e01b1682526001600160581b031916602082015260016040820152a1565b907f9d17cd6d095ac90a655405ab29f30a7ee7e88ef3974c1bf7544bf591043bb71a9260609261438983610cea83611edc565b9115159160ff1981541660ff84161790556040519263ffffffff60e01b1683526001600160581b03191660208301526040820152a1565b6143ca5f9161203b565b80546001600160a01b031981168255600190910180546001600160a01b0390921693916001600160f81b0319605882901b1615614413575b5080546001600160a81b0319169055565b6001600160a01b031692505f614402565b906001600160581b03195f516020615f755f395f51905f525460581b166001600160581b0319831614612ce3576144706001600160a01b0361446584611ea5565b5460201c1692611ea5565b8054640100000000600160c01b0319169055565b91907f2b82f87bf66300af618a9621d3f221edfab735f5bacb4e004cce1b62375396c3905a905f80604051936144e4856144d66020820196638a91b0e360e01b88526020602484018181520190610d6e565b03601f198101875286610fe3565b604051936144f3602086610fe3565b828552602085019583908a905f368a375193f1913d908161453a575b815f9293523e604080516001600160a01b0390951685528115156020860152909390819081016128bc565b5f915061450f565b604080519091906145538382610fe3565b6001815291601f1901825f5b82811061456b57505050565b80606060208093850101520161455f565b9061458682611023565b6145936040519182610fe3565b82815280926145a4601f1991611023565b01905f5b8281106145b457505050565b8060606020809385010152016145a8565b9060406120bc925f81528160208201520190610d6e565b600881901b9291906001600160f81b03198116600160f81b0361466f57509061460491615560565b90916001600160f81b0319811661461f57506120bc91615678565b6001600160f81b031916600160f81b0361463c576120bc916155cc565b60405162461bcd60e51b815260206004820152600b60248201526a155b9cdd5c1c1bdc9d195960aa1b6044820152606490fd5b9293926001600160f81b0319811661472757509061468c916154e2565b91614695614542565b956001600160f81b031981166146c257506146af9361552a565b6146b883613f01565b526126c582613f01565b6001600160f81b031916600160f81b0361463c576146df93615503565b6146e884613f01565b52610241577fe723f28f104e46b47fd3531f3608374ac226bcf3ddda334a23a266453e0efdb76128bc61471a84613f01565b51604051918291826145c5565b909391906001600160f81b03199081160361463c576147516147579161474b614542565b956154ba565b91613e84565b61476385939293613f01565b526001600160f81b03198116600160f81b036147ae57501561478157565b7fe723f28f104e46b47fd3531f3608374ac226bcf3ddda334a23a266453e0efdb76128bc61471a84613f01565b6001600160f81b03191661463c576102415760405162461bcd60e51b815260206004820152601360248201527211195b1959d85d1958d85b1b0819985a5b1959606a1b6044820152606490fd5b903590601e198136030182121561023257018035906001600160401b03821161023257602001918160051b3603831361023257565b80548210156122e6575f5260205f2001905f90565b9092809260209483528483013701015f815290565b8054905f815581614869575050565b5f5260205f20908101905b81811061487f575050565b5f8155600101614874565b9091906001600160a01b03168015801561490b575b6142585781156122e6575f516020615f555f395f51905f52926128bc926001600160f81b031982358116146148f2575b505060408051600481526001600160a01b03909216602083015290918291820190565b612b1481612e429261490394611d95565b505f806148cf565b506001811461489f565b816040519182372090565b5f809160405136810160405236838237604051601481016040523360601b90528260143601925af190604051903d82523d5f602084013e60203d830101604052565b9182358060f81c805f146149a6578060011461499957600214614983575f80fd5b6001600160d81b03191692600501916004190190565b5092601501916014190190565b505060015f9301915f190190565b60405160208101917f1547321c374afde8a591d972a084b071c594c275e36724931ff96c25f2999c8383526040820152604081526149f3606082610fe3565b5190209015614a05576120bc906157ea565b6120bc906156d8565b9190614a1b913691612a9c565b604051916020820191805180604014614aa657604114614a485750505050505b638baa579f5f526004601cfd5b80606060409201515f1a60205201516060525b5f52516040526020600160805f825afa51905f6060526040523d614a80575050614a3b565b6001600160a01b03163003614a9a57630b135d3f60e11b90565b6001600160e01b031990565b506040015160ff81901c601b016020526001600160ff1b0316606052614a5b565b90926080926120bc9694835260018060a01b0316602083015260408201528160608201520191612147565b614b0b65ffffffffffff929594614b179487338561585f565b97929491959095615929565b5091164210908115614b95575b50614b8257602094614b35916149b4565b60405163392dffaf60e01b815295869485938493614b6493929133906001600160e01b03191660048701614ac7565b03916001600160a01b03165afa9081156107dc575f9161345a575090565b506001600160e01b031995945050505050565b65ffffffffffff91501642115f614b24565b9190939293601481019160141161023257803560601c92829190614bc9612cfc565b6001600160a01b03909516602086019081529490614c28614bec6127be83611ea5565b5f516020615f755f395f51905f525460a81c63ffffffff169063ffffffff808316911603614d8d5760010163ffffffff165b63ffffffff168352565b815163ffffffff1695516001600160a01b031697614c468580612009565b614c4f91614915565b986034850199614c5f8b88612009565b614c6891614915565b906054870199614c788b8a612009565b614c8191614915565b604080517fb17ab1224aca0d4255ef8161acaf2ac121b8faa32a4b2258c912cc5f8308c505602082019081526001600160581b03198a169282019290925263ffffffff9390931660608401526001600160a01b0395909516608083015260a082019290925260c081019290925260e0808301919091528152614d0561010082610fe3565b519020905f1461052a6102ab98614d6660949861295097614d476128d49761053097614d7f57614d34906157ea565b614d4160748d0185612009565b91615bee565b9e614d5d614d558480612009565b929094612009565b9390928a613b7b565b614d79614d73828b612009565b90615d68565b88612009565b614d88906156d8565b614d34565b614c1e565b356001600160c01b0319811692919060088210614dad575050565b6001600160c01b031960089290920360031b82901b16169150565b6040906120bc9392815281602082015201906136f0565b93929190935f926001614df183612071565b015f5b8154811015614f7257614e0d612f15612f088385614830565b939095614e2f614e29614e23613fbf8585613f86565b60f81c90565b60ff1690565b808403614f445750908181614e68614e5c614e56614e5084614e8698611db1565b90614d92565b60c01c90565b6001600160401b031690565b600901918c610100614e7e612b14868686611dc2565b910152611e57565b939093955b600160f01b1615614ea3575b50600101939193614df4565b604051633894f6e760e11b81529196906020908390815f81614ed38f6001600160e01b03198d1660048401614dc8565b03926001600160a01b03165af19182156107dc575f92614f24575b506001600160a01b038216614f1057600191614f0991614fce565b9590614e97565b631f24c1fb60e11b5f52600487905260245ffd5b614f3d91925060203d81116138995761388a8183610fe3565b905f614eee565b9196909491831115614f5f57630760bdcf60e11b5f5260045ffd5b614f67612214565b6101008b0152614e8b565b505091929460ff80614f8a614e23613fbf8789613f86565b1603614fbf57610100614fa7612b1485614faf966120bc98611d95565b910152612071565b5460101c6001600160a01b031690565b63b32eeb6960e01b5f5260045ffd5b908082186001600160a01b031615606083811b838501821b9081149184901b141717600114614ffe575050600190565b65ffffffffffff60a01b8216916001600160d01b0319808216908316841561507e575b65ffffffffffff60a01b8416801561506e575b85811090861802808618951461505d575b81811190821802189160018060a01b03911716171790565b65ffffffffffff60a01b9450615045565b5065ffffffffffff60a01b615034565b65ffffffffffff60a01b9450615021565b356001600160501b03198116929190601682106150aa575050565b6001600160501b031960169290920360031b82901b16169150565b805468010000000000000000811015610f8c576150e791600182018155614830565b819291549060031b9160501c821b9160018060b01b03901b1916179055565b356001600160f01b0319811692919060028210615121575050565b6001600160f01b031960029290920360031b82901b16169150565b919061514890806147fb565b9260fe8411801561542c575b61541d57600161516382612071565b015461540a575b5f1984015f5b8181106152cc57509061522f61522883876151c86152196152006134b96151fa6151f487612ff99e9f8e6151ef61524b9f8f9061070561282d6128276151ba6151c09489896122cf565b90611ded565b9a8b91612071565b805462010000600160b01b03191660109290921b62010000600160b01b0316919091179055565b6122cf565b90611e1b565b90615106565b61520988612071565b9060f01c61ffff19825416179055565b6001600160a01b0316966122cf565b8091611dfe565b6040519586939092906001600160e01b03191660208501614845565b803b15610232575f61527192604051809481926306d61fe760e41b8352600483016121b0565b038183855af19081156107dc575f516020615f355f395f51905f52926128bc926152b8575b5060408051600681526001600160a01b03909216602083015290918291820190565b806107d65f6152c693610fe3565b5f615296565b6153679061531260016152de86612071565b0161530c6152ff6152f96152f3868d8c6122cf565b90611ddf565b9061508f565b6001600160501b03191690565b906150c5565b61532c61070561070561282d6128276151ba868d8c6122cf565b61533a615228838a896122cf565b60405194918591615359916001600160e01b03198a1660208501614845565b03601f198101855284610fe3565b803b15610232576040516306d61fe760e41b8152925f91849182908490829061539390600483016121b0565b03925af19182156107dc576001926153f6575b505f516020615f355f395f51905f526153ed6153cc61282d6128276151ba868d8c6122cf565b60408051600581526001600160a01b03909216602083015290918291820190565b0390a101615170565b806107d65f61540493610fe3565b5f6153a6565b6154186001612fa483612071565b61516a565b63b62d956d60e01b5f5260045ffd5b508315615154565b5f516020615f355f395f51905f52916040916001600160a01b038216156154b1575b6001600160a01b039081165f8181527f1bbee3173dbdc223633258c9f337a0fff8115f206d302bea0ed3eac003b68b8660205284902080546001600160a01b03191693909216929092179055815190600282526020820152a1565b60019150615456565b9160138211156154d557823560601c92601401916013190190565b63ba597e7e5f526004601cfd5b9060338111156154d557813560601c92601483013592603401916033190190565b905f928491604051958692833738935af1913d82523d5f602084013e60203d830101604052565b90925f92819594604051968792833738935af115615557573d82523d5f602084013e60203d830101604052565b503d5f823e3d90fd5b918235826020118160401c176154d557830160208101938135938461558457505050565b01601f190190835b5f190160208160051b8301013580830160608101908135809101918680602080860135809601011191111792171760401c176154d5578061558c57505050565b91906155d78161457c565b925f5b8281106155e657505050565b8061561060019260051b840135840160408101358101906020823592019060208101359035615503565b61561a8389613f0e565b5215615627575b016155da565b7fe723f28f104e46b47fd3531f3608374ac226bcf3ddda334a23a266453e0efdb76156706156558389613f0e565b51604051918291858352604060208401526040830190610d6e565b0390a1615621565b91906156838161457c565b925f5b82811061569257505050565b806156bc60019260051b84013584016040810135810190602082359201906020810135903561552a565b6156c68288613f0e565b526156d18187613f0e565b5001615686565b7f997f1593b92e38328cfead9b7755557ee9acdec066b7fe34c96c0b2ba8f465847f000000000000000000000000d6cedde84be40893d153be9d467cd6ad37875b2830147f000000000000000000000000000000000000000000000000000000000000123746141615615763575b6719010000000000005f52601a52603a526042601820905f603a52565b5060a06040517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f81527f32ba20807d2fff2dbb34e0bcfa82982565bef566d4c0c633dc57b700b81c342760208201527fe59f6ee1eb8c975b97f1f46d5710db9d8e551d6906f9446e8530ab7bac85a99a604082015246606082015230608082015220615746565b60a06157f4613d9c565b90602081519101209060208151910120604051917f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f8352602083015260408201525f6060820152306080820152206719010000000000005f52601a52603a526042601820905f603a52565b949184846158e092969593966040519061587882610fc7565b5f82525f602083015260408201995f8b526158da606084019a8b925f84525f60808701525f60a0870152606060c08701526158cb60e08701915f83526101008801955f87529063ffffffff60e01b169052565b6001600160a01b039091169052565b52615980565b60ff806158f3614e23613fbf8786613f86565b1603614fbf57614faf61590d8461591d9561592294611d95565b9490955163ffffffff60e01b1690565b612071565b9351929190565b8065ffffffffffff8260a01c16918215600114615947575b60d01c92565b65ffffffffffff9250615941565b90926080926120bc9594835260018060a01b0316602083015260408201528160608201520190610d6e565b91606083019060016159d561599c845163ffffffff60e01b1690565b6001600160e01b0319165f9081527f7bcaa2ced2a71450ed5a9a1b4848e8e5206dbc3f06011e595f7f55428cc6f8526020526040902090565b01805493905f5b8581106159ec5750505050505050565b6159fc612f15612f088385614830565b6001600160a01b031660a0890190815260808901949091615a25906001600160f01b0319168652565b615a3f615a38614e23613fbf8985613f86565b60ff168a52565b615a4d614e298a5160ff1690565b808403615bbc5750858181615a72614e5c614e56614e50615ac79b9c615a9a98611db1565b8c60c0615a8d612b1460208401948086526009018688611dc2565b9101525160090191611e57565b949094955b51600160f11b90615ab9906001600160f01b0319166134b9565b166001600160f01b03191690565b15615ad6575b506001016159dc565b51615ae9906001600160a01b0316610705565b6020615b0f615b02615b02895163ffffffff60e01b1690565b6001600160e01b03191690565b60e08a01516101008b015160c08c015160405163184dfdbb60e11b8152958694909385938493615b4e9390926001600160a01b03169060048601615955565b03915afa9081156107dc575f91615b9e575b506001600160a01b038116615b8a5790600191615b8260408a01918251614fce565b905290615acd565b631f24c1fb60e11b5f52600482905260245ffd5b615bb6915060203d81116138995761388a8183610fe3565b5f615b60565b909490831115615bd557630760bdcf60e11b5f5260045ffd5b615ac790615be4365f88612a9c565b60c08b0152615a9f565b5f516020615f755f395f51905f52545f9493929060581b6001600160f81b03198116600160f81b03615ca95791615c4e9391615c2f61070560209560581c90565b9160405195869485938493637aa8f17760e11b8552306004860161218c565b03915afa9081156107dc575f91615c8a575b505b6001600160e01b0319166374eca2c160e11b01615c7b57565b6362467c7760e11b5f5260045ffd5b615ca3915060203d60201161347c576134748183610fe3565b5f615c60565b90949192916001600160f81b03198216600160f91b03615d4a5750602091615cd79160081b9584308861585f565b60405163392dffaf60e01b81529297929586949385938493615d0a93909230906001600160e01b03191660048701614ac7565b03916001600160a01b03165afa9081156107dc575f91615d2b575b50615c62565b615d44915060203d60201161347c576134748183610fe3565b5f615d25565b94906001600160f81b03191661244d57615d6392614a0e565b615c62565b615d756105308383611d20565b91602c8110615f1857602c820190615d9661070561282d6128278487611d4b565b615dc0615db2613fcd613fbf615dac8780612009565b90613f86565b6001600160f81b0319161590565b80615e73575b61070561282d61282785615e1b6102419a87615dff61070561282d6128278f9d6129579f9e8f604c9f6129d09f615e21575b5050611e46565b615e098b80612009565b9390926001600160a01b031690613f8f565b88611e46565b612957615e65615e6c93606c860190615e60615e4f61070561282d612827615e498787612009565b90611d5c565b9586906001600160a01b0316615434565b612009565b8091611e29565b8f87615df8565b5060405163ecd0596160e01b81526002600482015290949291906020816024816001600160a01b038a165afa9384156107dc5761282d61282784615e1b610705948b615dff61070561282d6102419f8f9c6129579f9d8f604c9f936129d09f94612827955f91615ef9575b509f50505050505050505097509a5050959650505050615dc6565b615f12915060203d602011610831576108238183610fe3565b5f615ede565b90506004915003615f2557565b637a210a5d60e11b5f5260045ffdfed21d0b289f126c4b473ea641963e766833c2f13866e4ff480abd787c100ef123341347516a9de374859dfda710fa4828b2d48cb57d4fbe4c1149612b8e02276e7bcaa2ced2a71450ed5a9a1b4848e8e5206dbc3f06011e595f7f55428cc6f84f", + "nonce": 1, + "storage": { + "0x7bcaa2ced2a71450ed5a9a1b4848e8e5206dbc3f06011e595f7f55428cc6f84f": "0x0000000000000000000000deadbeef0000000000000000000000000000000000" + } + }, + "0xd703aaE79538628d27099B8c4f621bE4CCd142d5": { + "balance": 0, + "code": "0x6080604052600436106100c25760003560e01c8063b36f97051161007f578063d8b964e611610059578063d8b964e614610172578063f04e283e146101b2578063f2fde38b146101c5578063fee81cf4146101d857600080fd5b8063b36f970514610139578063c5265d5d1461014c578063c7e55f3e1461015f57600080fd5b806325692962146100c75780634a1ce599146100d157806354d1f13d146100e45780636e7dbabb146100ec578063715018a6146100ff5780638da5cb5b14610107575b600080fd5b6100cf610219565b005b6100cf6100df3660046105a7565b610269565b6100cf6102c7565b6100cf6100fa3660046105cb565b610303565b6100cf610336565b34801561011357600080fd5b50638b78c6d819545b6040516001600160a01b0390911681526020015b60405180910390f35b6100cf610147366004610609565b61034a565b61011c61015a366004610637565b6103b1565b6100cf61016d3660046106c2565b610466565b34801561017e57600080fd5b506101a261018d3660046105a7565b60006020819052908152604090205460ff1681565b6040519015158152602001610130565b6100cf6101c03660046105a7565b6104d2565b6100cf6101d33660046105a7565b610512565b3480156101e457600080fd5b5061020b6101f33660046105a7565b63389a75e1600c908152600091909152602090205490565b604051908152602001610130565b60006202a30067ffffffffffffffff164201905063389a75e1600c5233600052806020600c2055337fdbf36a107da19e49527a7176a1babf963b4b0ff8cde35ee35d6cd8f1f9ac7e1d600080a250565b610271610539565b806001600160a01b031663bb9fe6bf6040518163ffffffff1660e01b8152600401600060405180830381600087803b1580156102ac57600080fd5b505af11580156102c0573d6000803e3d6000fd5b5050505050565b63389a75e1600c523360005260006020600c2055337ffa7b8eab7da67f412cc9575ed43464468f9bfbae89d1675917346ca6d8fe3c92600080a2565b61030b610539565b6001600160a01b03919091166000908152602081905260409020805460ff1916911515919091179055565b61033e610539565b6103486000610554565b565b610352610539565b60405163611d2e7560e11b81526001600160a01b03828116600483015283169063c23a5cea90602401600060405180830381600087803b15801561039557600080fd5b505af11580156103a9573d6000803e3d6000fd5b505050505050565b6001600160a01b03841660009081526020819052604081205460ff166103ea57604051633220d5f360e21b815260040160405180910390fd5b604051633a9b44eb60e21b81526001600160a01b0386169063ea6d13ac9061041a908790879087906004016106f9565b6020604051808303816000875af1158015610439573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061045d9190610732565b95945050505050565b61046e610539565b604051621cb65b60e51b815263ffffffff821660048201526001600160a01b03831690630396cb609034906024016000604051808303818588803b1580156104b557600080fd5b505af11580156104c9573d6000803e3d6000fd5b50505050505050565b6104da610539565b63389a75e1600c52806000526020600c20805442111561050257636f5e88186000526004601cfd5b6000905561050f81610554565b50565b61051a610539565b8060601b61053057637448fbae6000526004601cfd5b61050f81610554565b638b78c6d819543314610348576382b429006000526004601cfd5b638b78c6d81980546001600160a01b039092169182907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0600080a355565b6001600160a01b038116811461050f57600080fd5b6000602082840312156105b957600080fd5b81356105c481610592565b9392505050565b600080604083850312156105de57600080fd5b82356105e981610592565b9150602083013580151581146105fe57600080fd5b809150509250929050565b6000806040838503121561061c57600080fd5b823561062781610592565b915060208301356105fe81610592565b6000806000806060858703121561064d57600080fd5b843561065881610592565b9350602085013567ffffffffffffffff8082111561067557600080fd5b818701915087601f83011261068957600080fd5b81358181111561069857600080fd5b8860208285010111156106aa57600080fd5b95986020929092019750949560400135945092505050565b600080604083850312156106d557600080fd5b82356106e081610592565b9150602083013563ffffffff811681146105fe57600080fd5b604081528260408201528284606083013760006060848301015260006060601f19601f8601168301019050826020830152949350505050565b60006020828403121561074457600080fd5b81516105c48161059256", + "nonce": 1, + "storage": { + "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff74873927": "0x0000000000000000000000009775137314fe595c943712b0b336327dfa80ae8a" + } + }, + "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552": { + "balance": 0, + "code": "0x6080604052600436106101dc5760003560e01c8063affed0e011610102578063e19a9dd911610095578063f08a032311610064578063f08a032314611647578063f698da2514611698578063f8dc5dd9146116c3578063ffa1ad741461173e57610231565b8063e19a9dd91461139b578063e318b52b146113ec578063e75235b81461147d578063e86637db146114a857610231565b8063cc2f8452116100d1578063cc2f8452146110e8578063d4d9bdcd146111b5578063d8d11f78146111f0578063e009cfde1461132a57610231565b8063affed0e014610d94578063b4faba0914610dbf578063b63e800d14610ea7578063c4ca3a9c1461101757610231565b80635624b25b1161017a5780636a761202116101495780636a761202146109945780637d83297414610b50578063934f3a1114610bbf578063a0e67e2b14610d2857610231565b80635624b25b146107fb5780635ae6bd37146108b9578063610b592514610908578063694e80c31461095957610231565b80632f54bf6e116101b65780632f54bf6e146104d35780633408e4701461053a578063468721a7146105655780635229073f1461067a57610231565b80630d582f131461029e57806312fb68e0146102f95780632d9ad53d1461046c57610231565b36610231573373ffffffffffffffffffffffffffffffffffffffff167f3d0ce9bfc3ed7d6862dbb28b2dea94561fe714a1b4d019aa8af39730d1ad7c3d346040518082815260200191505060405180910390a2005b34801561023d57600080fd5b5060007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b905080548061027257600080f35b36600080373360601b365260008060143601600080855af13d6000803e80610299573d6000fd5b3d6000f35b3480156102aa57600080fd5b506102f7600480360360408110156102c157600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506117ce565b005b34801561030557600080fd5b5061046a6004803603608081101561031c57600080fd5b81019080803590602001909291908035906020019064010000000081111561034357600080fd5b82018360208201111561035557600080fd5b8035906020019184600183028401116401000000008311171561037757600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803590602001906401000000008111156103da57600080fd5b8201836020820111156103ec57600080fd5b8035906020019184600183028401116401000000008311171561040e57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190505050611bbe565b005b34801561047857600080fd5b506104bb6004803603602081101561048f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612440565b60405180821515815260200191505060405180910390f35b3480156104df57600080fd5b50610522600480360360208110156104f657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612512565b60405180821515815260200191505060405180910390f35b34801561054657600080fd5b5061054f6125e4565b6040518082815260200191505060405180910390f35b34801561057157600080fd5b506106626004803603608081101561058857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156105cf57600080fd5b8201836020820111156105e157600080fd5b8035906020019184600183028401116401000000008311171561060357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506125f1565b60405180821515815260200191505060405180910390f35b34801561068657600080fd5b506107776004803603608081101561069d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156106e457600080fd5b8201836020820111156106f657600080fd5b8035906020019184600183028401116401000000008311171561071857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506127d7565b60405180831515815260200180602001828103825283818151815260200191508051906020019080838360005b838110156107bf5780820151818401526020810190506107a4565b50505050905090810190601f1680156107ec5780820380516001836020036101000a031916815260200191505b50935050505060405180910390f35b34801561080757600080fd5b5061083e6004803603604081101561081e57600080fd5b81019080803590602001909291908035906020019092919050505061280d565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561087e578082015181840152602081019050610863565b50505050905090810190601f1680156108ab5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b3480156108c557600080fd5b506108f2600480360360208110156108dc57600080fd5b8101908080359060200190929190505050612894565b6040518082815260200191505060405180910390f35b34801561091457600080fd5b506109576004803603602081101561092b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506128ac565b005b34801561096557600080fd5b506109926004803603602081101561097c57600080fd5b8101908080359060200190929190505050612c3e565b005b610b3860048036036101408110156109ab57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156109f257600080fd5b820183602082011115610a0457600080fd5b80359060200191846001830284011164010000000083111715610a2657600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610ab257600080fd5b820183602082011115610ac457600080fd5b80359060200191846001830284011164010000000083111715610ae657600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612d78565b60405180821515815260200191505060405180910390f35b348015610b5c57600080fd5b50610ba960048036036040811015610b7357600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506132b5565b6040518082815260200191505060405180910390f35b348015610bcb57600080fd5b50610d2660048036036060811015610be257600080fd5b810190808035906020019092919080359060200190640100000000811115610c0957600080fd5b820183602082011115610c1b57600080fd5b80359060200191846001830284011164010000000083111715610c3d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190640100000000811115610ca057600080fd5b820183602082011115610cb257600080fd5b80359060200191846001830284011164010000000083111715610cd457600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f8201169050808301925050505050505091929192905050506132da565b005b348015610d3457600080fd5b50610d3d613369565b6040518080602001828103825283818151815260200191508051906020019060200280838360005b83811015610d80578082015181840152602081019050610d65565b505050509050019250505060405180910390f35b348015610da057600080fd5b50610da9613512565b6040518082815260200191505060405180910390f35b348015610dcb57600080fd5b50610ea560048036036040811015610de257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610e1f57600080fd5b820183602082011115610e3157600080fd5b80359060200191846001830284011164010000000083111715610e5357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050613518565b005b348015610eb357600080fd5b506110156004803603610100811015610ecb57600080fd5b8101908080359060200190640100000000811115610ee857600080fd5b820183602082011115610efa57600080fd5b80359060200191846020830284011164010000000083111715610f1c57600080fd5b909192939192939080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610f6757600080fd5b820183602082011115610f7957600080fd5b80359060200191846001830284011164010000000083111715610f9b57600080fd5b9091929391929390803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061353a565b005b34801561102357600080fd5b506110d26004803603608081101561103a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561108157600080fd5b82018360208201111561109357600080fd5b803590602001918460018302840111640100000000831117156110b557600080fd5b9091929391929390803560ff1690602001909291905050506136f8565b6040518082815260200191505060405180910390f35b3480156110f457600080fd5b506111416004803603604081101561110b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613820565b60405180806020018373ffffffffffffffffffffffffffffffffffffffff168152602001828103825284818151815260200191508051906020019060200280838360005b838110156111a0578082015181840152602081019050611185565b50505050905001935050505060405180910390f35b3480156111c157600080fd5b506111ee600480360360208110156111d857600080fd5b8101908080359060200190929190505050613a12565b005b3480156111fc57600080fd5b50611314600480360361014081101561121457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561125b57600080fd5b82018360208201111561126d57600080fd5b8035906020019184600183028401116401000000008311171561128f57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613bb1565b6040518082815260200191505060405180910390f35b34801561133657600080fd5b506113996004803603604081101561134d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613bde565b005b3480156113a757600080fd5b506113ea600480360360208110156113be57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613f6f565b005b3480156113f857600080fd5b5061147b6004803603606081101561140f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613ff3565b005b34801561148957600080fd5b50611492614665565b6040518082815260200191505060405180910390f35b3480156114b457600080fd5b506115cc60048036036101408110156114cc57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561151357600080fd5b82018360208201111561152557600080fd5b8035906020019184600183028401116401000000008311171561154757600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019092919050505061466f565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561160c5780820151818401526020810190506115f1565b50505050905090810190601f1680156116395780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561165357600080fd5b506116966004803603602081101561166a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050614817565b005b3480156116a457600080fd5b506116ad614878565b6040518082815260200191505060405180910390f35b3480156116cf57600080fd5b5061173c600480360360608110156116e657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506148f6565b005b34801561174a57600080fd5b50611753614d29565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015611793578082015181840152602081019050611778565b50505050905090810190601f1680156117c05780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6117d6614d62565b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156118405750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b801561187857503073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b6118ea576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146119eb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508160026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600081548092919060010191905055507f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2682604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a18060045414611bba57611bb981612c3e565b5b5050565b611bd2604182614e0590919063ffffffff16565b82511015611c48576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808060008060005b8681101561243457611c648882614e3f565b80945081955082965050505060008460ff16141561206d578260001c9450611c96604188614e0590919063ffffffff16565b8260001c1015611d0e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8751611d2760208460001c614e6e90919063ffffffff16565b1115611d9b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006020838a01015190508851611dd182611dc360208760001c614e6e90919063ffffffff16565b614e6e90919063ffffffff16565b1115611e45576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60606020848b010190506320c13b0b60e01b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168773ffffffffffffffffffffffffffffffffffffffff166320c13b0b8d846040518363ffffffff1660e01b8152600401808060200180602001838103835285818151815260200191508051906020019080838360005b83811015611ee7578082015181840152602081019050611ecc565b50505050905090810190601f168015611f145780820380516001836020036101000a031916815260200191505b50838103825284818151815260200191508051906020019080838360005b83811015611f4d578082015181840152602081019050611f32565b50505050905090810190601f168015611f7a5780820380516001836020036101000a031916815260200191505b5094505050505060206040518083038186803b158015611f9957600080fd5b505afa158015611fad573d6000803e3d6000fd5b505050506040513d6020811015611fc357600080fd5b81019080805190602001909291905050507bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614612066576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b50506122b2565b60018460ff161415612181578260001c94508473ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16148061210a57506000600860008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008c81526020019081526020016000205414155b61217c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6122b1565b601e8460ff1611156122495760018a60405160200180807f19457468657265756d205369676e6564204d6573736167653a0a333200000000815250601c018281526020019150506040516020818303038152906040528051906020012060048603858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa158015612238573d6000803e3d6000fd5b5050506020604051035194506122b0565b60018a85858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156122a3573d6000803e3d6000fd5b5050506020604051035194505b5b5b8573ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff161180156123795750600073ffffffffffffffffffffffffffffffffffffffff16600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b80156123b25750600173ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1614155b612424576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8495508080600101915050611c52565b50505050505050505050565b60008173ffffffffffffffffffffffffffffffffffffffff16600173ffffffffffffffffffffffffffffffffffffffff161415801561250b5750600073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156125dd5750600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000804690508091505090565b6000600173ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141580156126bc5750600073ffffffffffffffffffffffffffffffffffffffff16600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b61272e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b61273b858585855a614e8d565b9050801561278b573373ffffffffffffffffffffffffffffffffffffffff167f6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb860405160405180910390a26127cf565b3373ffffffffffffffffffffffffffffffffffffffff167facd2c8702804128fdb0db2bb49f6d127dd0181c13fd45dbfe16de0930e2bd37560405160405180910390a25b949350505050565b600060606127e7868686866125f1565b915060405160203d0181016040523d81523d6000602083013e8091505094509492505050565b606060006020830267ffffffffffffffff8111801561282b57600080fd5b506040519080825280601f01601f19166020018201604052801561285e5781602001600182028036833780820191505090505b50905060005b8381101561288957808501548060208302602085010152508080600101915050612864565b508091505092915050565b60076020528060005260406000206000915090505481565b6128b4614d62565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415801561291e5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b612990576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614612a91576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507fecdf3a3effea5783a3c4c2140e677577666428d44ed9d474a0b3a4c9943f844081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b612c46614d62565b600354811115612cbe576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001811015612d35576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b806004819055507f610f7ff2b304ae8903c3de74c60c6ab1f7d6226b3f52c5161905bb5ad4039c936004546040518082815260200191505060405180910390a150565b6000806000612d928e8e8e8e8e8e8e8e8e8e60055461466f565b905060056000815480929190600101919050555080805190602001209150612dbb8282866132da565b506000612dc6614ed9565b9050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614612fac578073ffffffffffffffffffffffffffffffffffffffff166375f0bb528f8f8f8f8f8f8f8f8f8f8f336040518d63ffffffff1660e01b8152600401808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115612e6957fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018473ffffffffffffffffffffffffffffffffffffffff16815260200183810383528d8d82818152602001925080828437600081840152601f19601f820116905080830192505050838103825285818151815260200191508051906020019080838360005b83811015612f3b578082015181840152602081019050612f20565b50505050905090810190601f168015612f685780820380516001836020036101000a031916815260200191505b509e505050505050505050505050505050600060405180830381600087803b158015612f9357600080fd5b505af1158015612fa7573d6000803e3d6000fd5b505050505b6101f4612fd36109c48b01603f60408d0281612fc457fe5b04614f0a90919063ffffffff16565b015a1015613049576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60005a90506130b28f8f8f8f8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050508e60008d146130a7578e6130ad565b6109c45a035b614e8d565b93506130c75a82614f2490919063ffffffff16565b905083806130d6575060008a14155b806130e2575060008814155b613154576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60008089111561316e5761316b828b8b8b8b614f44565b90505b84156131b8577f442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e8482604051808381526020018281526020019250505060405180910390a16131f8565b7f23428b18acfb3ea64b08dc0c1d296ea9c09702c09083ca5272e64d115b687d238482604051808381526020018281526020019250505060405180910390a15b5050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146132a4578073ffffffffffffffffffffffffffffffffffffffff16639327136883856040518363ffffffff1660e01b815260040180838152602001821515815260200192505050600060405180830381600087803b15801561328b57600080fd5b505af115801561329f573d6000803e3d6000fd5b505050505b50509b9a5050505050505050505050565b6008602052816000526040600020602052806000526040600020600091509150505481565b6000600454905060008111613357576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b61336384848484611bbe565b50505050565b6060600060035467ffffffffffffffff8111801561338657600080fd5b506040519080825280602002602001820160405280156133b55781602001602082028036833780820191505090505b50905060008060026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614613509578083838151811061346057fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600260008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050818060010192505061341f565b82935050505090565b60055481565b600080825160208401855af4806000523d6020523d600060403e60403d016000fd5b6135858a8a80806020026020016040519081016040528093929190818152602001838360200280828437600081840152601f19601f820116905080830192505050505050508961514a565b600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16146135c3576135c28461564a565b5b6136118787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050615679565b600082111561362b5761362982600060018685614f44565b505b3373ffffffffffffffffffffffffffffffffffffffff167f141df868a6331af528e38c83b7aa03edc19be66e37ae67f9285bf4f8e3c6a1a88b8b8b8b8960405180806020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281038252878782818152602001925060200280828437600081840152601f19601f820116905080830192505050965050505050505060405180910390a250505050505050505050565b6000805a905061374f878787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050865a614e8d565b61375857600080fd5b60005a8203905080604051602001808281526020019150506040516020818303038152906040526040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825283818151815260200191508051906020019080838360005b838110156137e55780820151818401526020810190506137ca565b50505050905090810190601f1680156138125780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b606060008267ffffffffffffffff8111801561383b57600080fd5b5060405190808252806020026020018201604052801561386a5781602001602082028036833780820191505090505b509150600080600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415801561393d5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561394857508482105b15613a03578084838151811061395a57fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905081806001019250506138d3565b80925081845250509250929050565b600073ffffffffffffffffffffffffffffffffffffffff16600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161415613b14576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001600860003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000838152602001908152602001600020819055503373ffffffffffffffffffffffffffffffffffffffff16817ff2a0eb156472d1440255b0d7c1e19cc07115d1051fe605b0dce69acfec884d9c60405160405180910390a350565b6000613bc68c8c8c8c8c8c8c8c8c8c8c61466f565b8051906020012090509b9a5050505050505050505050565b613be6614d62565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614158015613c505750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b613cc2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614613dc2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507faab4fa2b463f581b2b32cb3b7e3b704b9ce37cc209b5fb4d77e593ace405427681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613f77614d62565b60007f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b90508181557f1151116914515bc0891ff9047a6cb32cf902546f83066499bcf8ba33d2353fa282604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613ffb614d62565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156140655750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561409d57503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b61410f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614210576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161415801561427a5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b6142ec576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146143ec576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555080600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a17f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a1505050565b6000600454905090565b606060007fbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d860001b8d8d8d8d60405180838380828437808301925050509250505060405180910390208c8c8c8c8c8c8c604051602001808c81526020018b73ffffffffffffffffffffffffffffffffffffffff1681526020018a815260200189815260200188600181111561470057fe5b81526020018781526020018681526020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019b505050505050505050505050604051602081830303815290604052805190602001209050601960f81b600160f81b61478c614878565b8360405160200180857effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168152600101847effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff191681526001018381526020018281526020019450505050506040516020818303038152906040529150509b9a5050505050505050505050565b61481f614d62565b6148288161564a565b7f5ac6c46c93c8d0e53714ba3b53db3e7c046da994313d7ed0d192028bc7c228b081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b60007f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a7946921860001b6148a66125e4565b30604051602001808481526020018381526020018273ffffffffffffffffffffffffffffffffffffffff168152602001935050505060405160208183030381529060405280519060200120905090565b6148fe614d62565b806001600354031015614979576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156149e35750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b614a55576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614b55576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600360008154809291906001900391905055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a18060045414614d2457614d2381612c3e565b5b505050565b6040518060400160405280600581526020017f312e332e3000000000000000000000000000000000000000000000000000000081525081565b3073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614614e03576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b565b600080831415614e185760009050614e39565b6000828402905082848281614e2957fe5b0414614e3457600080fd5b809150505b92915050565b60008060008360410260208101860151925060408101860151915060ff60418201870151169350509250925092565b600080828401905083811015614e8357600080fd5b8091505092915050565b6000600180811115614e9b57fe5b836001811115614ea757fe5b1415614ec0576000808551602087018986f49050614ed0565b600080855160208701888a87f190505b95945050505050565b6000807f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b9050805491505090565b600081831015614f1a5781614f1c565b825b905092915050565b600082821115614f3357600080fd5b600082840390508091505092915050565b600080600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614614f815782614f83565b325b9050600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16141561509b57614fed3a8610614fca573a614fcc565b855b614fdf888a614e6e90919063ffffffff16565b614e0590919063ffffffff16565b91508073ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f19350505050615096576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b615140565b6150c0856150b2888a614e6e90919063ffffffff16565b614e0590919063ffffffff16565b91506150cd8482846158b4565b61513f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5095945050505050565b6000600454146151c2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8151811115615239576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60018110156152b0576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006001905060005b83518110156155b65760008482815181106152d057fe5b60200260200101519050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156153445750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561537c57503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b80156153b457508073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614155b615426576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614615527576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b80600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508092505080806001019150506152b9565b506001600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550825160038190555081600481905550505050565b60007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b90508181555050565b600073ffffffffffffffffffffffffffffffffffffffff1660016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461577b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001806000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16146158b05761583d8260008360015a614e8d565b6158af576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5050565b60008063a9059cbb8484604051602401808373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001925050506040516020818303038152906040529060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050509050602060008251602084016000896127105a03f13d6000811461595b5760208114615963576000935061596e565b81935061596e565b600051158215171593505b505050939250505056fea26469706673582212203874bcf92e1722cc7bfa0cef1a0985cf0dc3485ba0663db3747ccdf1605df53464736f6c63430007060033", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + }, + "0xfb1bffC9d739B8D520DaF37dF666da4C687191EA": { + "balance": 0, + "code": "0x6080604052600436106101dc5760003560e01c8063affed0e011610102578063e19a9dd911610095578063f08a032311610064578063f08a032314611647578063f698da2514611698578063f8dc5dd9146116c3578063ffa1ad741461173e57610231565b8063e19a9dd91461139b578063e318b52b146113ec578063e75235b81461147d578063e86637db146114a857610231565b8063cc2f8452116100d1578063cc2f8452146110e8578063d4d9bdcd146111b5578063d8d11f78146111f0578063e009cfde1461132a57610231565b8063affed0e014610d94578063b4faba0914610dbf578063b63e800d14610ea7578063c4ca3a9c1461101757610231565b80635624b25b1161017a5780636a761202116101495780636a761202146109945780637d83297414610b50578063934f3a1114610bbf578063a0e67e2b14610d2857610231565b80635624b25b146107fb5780635ae6bd37146108b9578063610b592514610908578063694e80c31461095957610231565b80632f54bf6e116101b65780632f54bf6e146104d35780633408e4701461053a578063468721a7146105655780635229073f1461067a57610231565b80630d582f131461029e57806312fb68e0146102f95780632d9ad53d1461046c57610231565b36610231573373ffffffffffffffffffffffffffffffffffffffff167f3d0ce9bfc3ed7d6862dbb28b2dea94561fe714a1b4d019aa8af39730d1ad7c3d346040518082815260200191505060405180910390a2005b34801561023d57600080fd5b5060007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b905080548061027257600080f35b36600080373360601b365260008060143601600080855af13d6000803e80610299573d6000fd5b3d6000f35b3480156102aa57600080fd5b506102f7600480360360408110156102c157600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506117ce565b005b34801561030557600080fd5b5061046a6004803603608081101561031c57600080fd5b81019080803590602001909291908035906020019064010000000081111561034357600080fd5b82018360208201111561035557600080fd5b8035906020019184600183028401116401000000008311171561037757600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803590602001906401000000008111156103da57600080fd5b8201836020820111156103ec57600080fd5b8035906020019184600183028401116401000000008311171561040e57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190929190505050611bbe565b005b34801561047857600080fd5b506104bb6004803603602081101561048f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612440565b60405180821515815260200191505060405180910390f35b3480156104df57600080fd5b50610522600480360360208110156104f657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050612512565b60405180821515815260200191505060405180910390f35b34801561054657600080fd5b5061054f6125e4565b6040518082815260200191505060405180910390f35b34801561057157600080fd5b506106626004803603608081101561058857600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156105cf57600080fd5b8201836020820111156105e157600080fd5b8035906020019184600183028401116401000000008311171561060357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506125f1565b60405180821515815260200191505060405180910390f35b34801561068657600080fd5b506107776004803603608081101561069d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156106e457600080fd5b8201836020820111156106f657600080fd5b8035906020019184600183028401116401000000008311171561071857600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290803560ff1690602001909291905050506126fc565b60405180831515815260200180602001828103825283818151815260200191508051906020019080838360005b838110156107bf5780820151818401526020810190506107a4565b50505050905090810190601f1680156107ec5780820380516001836020036101000a031916815260200191505b50935050505060405180910390f35b34801561080757600080fd5b5061083e6004803603604081101561081e57600080fd5b810190808035906020019092919080359060200190929190505050612732565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561087e578082015181840152602081019050610863565b50505050905090810190601f1680156108ab5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b3480156108c557600080fd5b506108f2600480360360208110156108dc57600080fd5b81019080803590602001909291905050506127b9565b6040518082815260200191505060405180910390f35b34801561091457600080fd5b506109576004803603602081101561092b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506127d1565b005b34801561096557600080fd5b506109926004803603602081101561097c57600080fd5b8101908080359060200190929190505050612b63565b005b610b3860048036036101408110156109ab57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001906401000000008111156109f257600080fd5b820183602082011115610a0457600080fd5b80359060200191846001830284011164010000000083111715610a2657600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610ab257600080fd5b820183602082011115610ac457600080fd5b80359060200191846001830284011164010000000083111715610ae657600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612c9d565b60405180821515815260200191505060405180910390f35b348015610b5c57600080fd5b50610ba960048036036040811015610b7357600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050612edc565b6040518082815260200191505060405180910390f35b348015610bcb57600080fd5b50610d2660048036036060811015610be257600080fd5b810190808035906020019092919080359060200190640100000000811115610c0957600080fd5b820183602082011115610c1b57600080fd5b80359060200191846001830284011164010000000083111715610c3d57600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929080359060200190640100000000811115610ca057600080fd5b820183602082011115610cb257600080fd5b80359060200191846001830284011164010000000083111715610cd457600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509192919290505050612f01565b005b348015610d3457600080fd5b50610d3d612f90565b6040518080602001828103825283818151815260200191508051906020019060200280838360005b83811015610d80578082015181840152602081019050610d65565b505050509050019250505060405180910390f35b348015610da057600080fd5b50610da9613139565b6040518082815260200191505060405180910390f35b348015610dcb57600080fd5b50610ea560048036036040811015610de257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610e1f57600080fd5b820183602082011115610e3157600080fd5b80359060200191846001830284011164010000000083111715610e5357600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050919291929050505061313f565b005b348015610eb357600080fd5b506110156004803603610100811015610ecb57600080fd5b8101908080359060200190640100000000811115610ee857600080fd5b820183602082011115610efa57600080fd5b80359060200191846020830284011164010000000083111715610f1c57600080fd5b909192939192939080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190640100000000811115610f6757600080fd5b820183602082011115610f7957600080fd5b80359060200191846001830284011164010000000083111715610f9b57600080fd5b9091929391929390803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613161565b005b34801561102357600080fd5b506110d26004803603608081101561103a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561108157600080fd5b82018360208201111561109357600080fd5b803590602001918460018302840111640100000000831117156110b557600080fd5b9091929391929390803560ff16906020019092919050505061331f565b6040518082815260200191505060405180910390f35b3480156110f457600080fd5b506111416004803603604081101561110b57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050613447565b60405180806020018373ffffffffffffffffffffffffffffffffffffffff168152602001828103825284818151815260200191508051906020019060200280838360005b838110156111a0578082015181840152602081019050611185565b50505050905001935050505060405180910390f35b3480156111c157600080fd5b506111ee600480360360208110156111d857600080fd5b8101908080359060200190929190505050613639565b005b3480156111fc57600080fd5b50611314600480360361014081101561121457600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561125b57600080fd5b82018360208201111561126d57600080fd5b8035906020019184600183028401116401000000008311171561128f57600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506137d8565b6040518082815260200191505060405180910390f35b34801561133657600080fd5b506113996004803603604081101561134d57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613805565b005b3480156113a757600080fd5b506113ea600480360360208110156113be57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613b96565b005b3480156113f857600080fd5b5061147b6004803603606081101561140f57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050613c1a565b005b34801561148957600080fd5b5061149261428c565b6040518082815260200191505060405180910390f35b3480156114b457600080fd5b506115cc60048036036101408110156114cc57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291908035906020019064010000000081111561151357600080fd5b82018360208201111561152557600080fd5b8035906020019184600183028401116401000000008311171561154757600080fd5b9091929391929390803560ff169060200190929190803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050614296565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561160c5780820151818401526020810190506115f1565b50505050905090810190601f1680156116395780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561165357600080fd5b506116966004803603602081101561166a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061443e565b005b3480156116a457600080fd5b506116ad61449f565b6040518082815260200191505060405180910390f35b3480156116cf57600080fd5b5061173c600480360360608110156116e657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019092919050505061451d565b005b34801561174a57600080fd5b50611753614950565b6040518080602001828103825283818151815260200191508051906020019080838360005b83811015611793578082015181840152602081019050611778565b50505050905090810190601f1680156117c05780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6117d6614989565b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156118405750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b801561187857503073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b6118ea576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146119eb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508160026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600081548092919060010191905055507f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2682604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a18060045414611bba57611bb981612b63565b5b5050565b611bd2604182614a2c90919063ffffffff16565b82511015611c48576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808060008060005b8681101561243457611c648882614a66565b80945081955082965050505060008460ff16141561206d578260001c9450611c96604188614a2c90919063ffffffff16565b8260001c1015611d0e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8751611d2760208460001c614a9590919063ffffffff16565b1115611d9b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006020838a01015190508851611dd182611dc360208760001c614a9590919063ffffffff16565b614a9590919063ffffffff16565b1115611e45576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60606020848b010190506320c13b0b60e01b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168773ffffffffffffffffffffffffffffffffffffffff166320c13b0b8d846040518363ffffffff1660e01b8152600401808060200180602001838103835285818151815260200191508051906020019080838360005b83811015611ee7578082015181840152602081019050611ecc565b50505050905090810190601f168015611f145780820380516001836020036101000a031916815260200191505b50838103825284818151815260200191508051906020019080838360005b83811015611f4d578082015181840152602081019050611f32565b50505050905090810190601f168015611f7a5780820380516001836020036101000a031916815260200191505b5094505050505060206040518083038186803b158015611f9957600080fd5b505afa158015611fad573d6000803e3d6000fd5b505050506040513d6020811015611fc357600080fd5b81019080805190602001909291905050507bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614612066576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b50506122b2565b60018460ff161415612181578260001c94508473ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16148061210a57506000600860008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008c81526020019081526020016000205414155b61217c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6122b1565b601e8460ff1611156122495760018a60405160200180807f19457468657265756d205369676e6564204d6573736167653a0a333200000000815250601c018281526020019150506040516020818303038152906040528051906020012060048603858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa158015612238573d6000803e3d6000fd5b5050506020604051035194506122b0565b60018a85858560405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa1580156122a3573d6000803e3d6000fd5b5050506020604051035194505b5b5b8573ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff161180156123795750600073ffffffffffffffffffffffffffffffffffffffff16600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b80156123b25750600173ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff1614155b612424576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330323600000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8495508080600101915050611c52565b50505050505050505050565b60008173ffffffffffffffffffffffffffffffffffffffff16600173ffffffffffffffffffffffffffffffffffffffff161415801561250b5750600073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16141580156125dd5750600073ffffffffffffffffffffffffffffffffffffffff16600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b9050919050565b6000804690508091505090565b60007fb648d3644f584ed1c2232d53c46d87e693586486ad0d1175f8656013110b714e3386868686604051808673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff1681526020018481526020018060200183600181111561266b57fe5b8152602001828103825284818151815260200191508051906020019080838360005b838110156126a857808201518184015260208101905061268d565b50505050905090810190601f1680156126d55780820380516001836020036101000a031916815260200191505b50965050505050505060405180910390a16126f285858585614ab4565b9050949350505050565b6000606061270c868686866125f1565b915060405160203d0181016040523d81523d6000602083013e8091505094509492505050565b606060006020830267ffffffffffffffff8111801561275057600080fd5b506040519080825280601f01601f1916602001820160405280156127835781602001600182028036833780820191505090505b50905060005b838110156127ae57808501548060208302602085010152508080600101915050612789565b508091505092915050565b60076020528060005260406000206000915090505481565b6127d9614989565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156128435750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b6128b5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146129b6576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507fecdf3a3effea5783a3c4c2140e677577666428d44ed9d474a0b3a4c9943f844081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b612b6b614989565b600354811115612be3576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001811015612c5a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b806004819055507f610f7ff2b304ae8903c3de74c60c6ab1f7d6226b3f52c5161905bb5ad4039c936004546040518082815260200191505060405180910390a150565b6000606060055433600454604051602001808481526020018373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001935050505060405160208183030381529060405290507f66753cd2356569ee081232e3be8909b950e0a76c1f8460c3a5e3c2be32b11bed8d8d8d8d8d8d8d8d8d8d8d8c604051808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115612d5057fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018060200184810384528e8e82818152602001925080828437600081840152601f19601f820116905080830192505050848103835286818151815260200191508051906020019080838360005b83811015612e0a578082015181840152602081019050612def565b50505050905090810190601f168015612e375780820380516001836020036101000a031916815260200191505b50848103825285818151815260200191508051906020019080838360005b83811015612e70578082015181840152602081019050612e55565b50505050905090810190601f168015612e9d5780820380516001836020036101000a031916815260200191505b509f5050505050505050505050505050505060405180910390a1612eca8d8d8d8d8d8d8d8d8d8d8d614c9a565b9150509b9a5050505050505050505050565b6008602052816000526040600020602052806000526040600020600091509150505481565b6000600454905060008111612f7e576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b612f8a84848484611bbe565b50505050565b6060600060035467ffffffffffffffff81118015612fad57600080fd5b50604051908082528060200260200182016040528015612fdc5781602001602082028036833780820191505090505b50905060008060026000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614613130578083838151811061308757fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600260008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508180600101925050613046565b82935050505090565b60055481565b600080825160208401855af4806000523d6020523d600060403e60403d016000fd5b6131ac8a8a80806020026020016040519081016040528093929190818152602001838360200280828437600081840152601f19601f82011690508083019250505050505050896151d7565b600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16146131ea576131e9846156d7565b5b6132388787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050615706565b60008211156132525761325082600060018685615941565b505b3373ffffffffffffffffffffffffffffffffffffffff167f141df868a6331af528e38c83b7aa03edc19be66e37ae67f9285bf4f8e3c6a1a88b8b8b8b8960405180806020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281038252878782818152602001925060200280828437600081840152601f19601f820116905080830192505050965050505050505060405180910390a250505050505050505050565b6000805a9050613376878787878080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050865a615b47565b61337f57600080fd5b60005a8203905080604051602001808281526020019150506040516020818303038152906040526040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825283818151815260200191508051906020019080838360005b8381101561340c5780820151818401526020810190506133f1565b50505050905090810190601f1680156134395780820380516001836020036101000a031916815260200191505b509250505060405180910390fd5b606060008267ffffffffffffffff8111801561346257600080fd5b506040519080825280602002602001820160405280156134915781602001602082028036833780820191505090505b509150600080600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156135645750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561356f57508482105b1561362a578084838151811061358157fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff1681525050600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905081806001019250506134fa565b80925081845250509250929050565b600073ffffffffffffffffffffffffffffffffffffffff16600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16141561373b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001600860003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000838152602001908152602001600020819055503373ffffffffffffffffffffffffffffffffffffffff16817ff2a0eb156472d1440255b0d7c1e19cc07115d1051fe605b0dce69acfec884d9c60405160405180910390a350565b60006137ed8c8c8c8c8c8c8c8c8c8c8c614296565b8051906020012090509b9a5050505050505050505050565b61380d614989565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156138775750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b6138e9576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8073ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146139e9576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600160008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507faab4fa2b463f581b2b32cb3b7e3b704b9ce37cc209b5fb4d77e593ace405427681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613b9e614989565b60007f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b90508181557f1151116914515bc0891ff9047a6cb32cf902546f83066499bcf8ba33d2353fa282604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a15050565b613c22614989565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614158015613c8c5750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b8015613cc457503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b613d36576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614613e37576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614158015613ea15750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b613f13576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614614013576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555080600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a17f9465fa0c962cc76958e6373a993326400c1c94f8be2fe3a952adfa7f60b2ea2681604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a1505050565b6000600454905090565b606060007fbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d860001b8d8d8d8d60405180838380828437808301925050509250505060405180910390208c8c8c8c8c8c8c604051602001808c81526020018b73ffffffffffffffffffffffffffffffffffffffff1681526020018a815260200189815260200188600181111561432757fe5b81526020018781526020018681526020018581526020018473ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019b505050505050505050505050604051602081830303815290604052805190602001209050601960f81b600160f81b6143b361449f565b8360405160200180857effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19168152600101847effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff191681526001018381526020018281526020019450505050506040516020818303038152906040529150509b9a5050505050505050505050565b614446614989565b61444f816156d7565b7f5ac6c46c93c8d0e53714ba3b53db3e7c046da994313d7ed0d192028bc7c228b081604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b60007f47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a7946921860001b6144cd6125e4565b30604051602001808481526020018381526020018273ffffffffffffffffffffffffffffffffffffffff168152602001935050505060405160208183030381529060405280519060200120905090565b614525614989565b8060016003540310156145a0576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161415801561460a5750600173ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614155b61467c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461477c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303500000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600360008154809291906001900391905055507ff8d49fc529812e9a7c5c50e69c20f0dccc0db8fa95c98bc58cc9a4f1c1299eaf82604051808273ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a1806004541461494b5761494a81612b63565b5b505050565b6040518060400160405280600581526020017f312e332e3000000000000000000000000000000000000000000000000000000081525081565b3073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614614a2a576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330333100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b565b600080831415614a3f5760009050614a60565b6000828402905082848281614a5057fe5b0414614a5b57600080fd5b809150505b92915050565b60008060008360410260208101860151925060408101860151915060ff60418201870151169350509250925092565b600080828401905083811015614aaa57600080fd5b8091505092915050565b6000600173ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614158015614b7f5750600073ffffffffffffffffffffffffffffffffffffffff16600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b614bf1576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b614bfe858585855a615b47565b90508015614c4e573373ffffffffffffffffffffffffffffffffffffffff167f6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb860405160405180910390a2614c92565b3373ffffffffffffffffffffffffffffffffffffffff167facd2c8702804128fdb0db2bb49f6d127dd0181c13fd45dbfe16de0930e2bd37560405160405180910390a25b949350505050565b6000806000614cb48e8e8e8e8e8e8e8e8e8e600554614296565b905060056000815480929190600101919050555080805190602001209150614cdd828286612f01565b506000614ce8615b93565b9050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614614ece578073ffffffffffffffffffffffffffffffffffffffff166375f0bb528f8f8f8f8f8f8f8f8f8f8f336040518d63ffffffff1660e01b8152600401808d73ffffffffffffffffffffffffffffffffffffffff1681526020018c8152602001806020018a6001811115614d8b57fe5b81526020018981526020018881526020018781526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018573ffffffffffffffffffffffffffffffffffffffff168152602001806020018473ffffffffffffffffffffffffffffffffffffffff16815260200183810383528d8d82818152602001925080828437600081840152601f19601f820116905080830192505050838103825285818151815260200191508051906020019080838360005b83811015614e5d578082015181840152602081019050614e42565b50505050905090810190601f168015614e8a5780820380516001836020036101000a031916815260200191505b509e505050505050505050505050505050600060405180830381600087803b158015614eb557600080fd5b505af1158015614ec9573d6000803e3d6000fd5b505050505b6101f4614ef56109c48b01603f60408d0281614ee657fe5b04615bc490919063ffffffff16565b015a1015614f6b576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60005a9050614fd48f8f8f8f8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050508e60008d14614fc9578e614fcf565b6109c45a035b615b47565b9350614fe95a82615bde90919063ffffffff16565b90508380614ff8575060008a14155b80615004575060008814155b615076576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6000808911156150905761508d828b8b8b8b615941565b90505b84156150da577f442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e8482604051808381526020018281526020019250505060405180910390a161511a565b7f23428b18acfb3ea64b08dc0c1d296ea9c09702c09083ca5272e64d115b687d238482604051808381526020018281526020019250505060405180910390a15b5050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146151c6578073ffffffffffffffffffffffffffffffffffffffff16639327136883856040518363ffffffff1660e01b815260040180838152602001821515815260200192505050600060405180830381600087803b1580156151ad57600080fd5b505af11580156151c1573d6000803e3d6000fd5b505050505b50509b9a5050505050505050505050565b60006004541461524f576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b81518111156152c6576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600181101561533d576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b60006001905060005b835181101561564357600084828151811061535d57fe5b60200260200101519050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141580156153d15750600173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561540957503073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614155b801561544157508073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614155b6154b3576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303300000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146155b4576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475332303400000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b80600260008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550809250508080600101915050615346565b506001600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550825160038190555081600481905550505050565b60007f6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d560001b90508181555050565b600073ffffffffffffffffffffffffffffffffffffffff1660016000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614615808576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475331303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6001806000600173ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161461593d576158ca8260008360015a615b47565b61593c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330303000000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5050565b600080600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161461597e5782615980565b325b9050600073ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff161415615a98576159ea3a86106159c7573a6159c9565b855b6159dc888a614a9590919063ffffffff16565b614a2c90919063ffffffff16565b91508073ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f19350505050615a93576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313100000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b615b3d565b615abd85615aaf888a614a9590919063ffffffff16565b614a2c90919063ffffffff16565b9150615aca848284615bfe565b615b3c576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260058152602001807f475330313200000000000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b5b5095945050505050565b6000600180811115615b5557fe5b836001811115615b6157fe5b1415615b7a576000808551602087018986f49050615b8a565b600080855160208701888a87f190505b95945050505050565b6000807f4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c860001b9050805491505090565b600081831015615bd45781615bd6565b825b905092915050565b600082821115615bed57600080fd5b600082840390508091505092915050565b60008063a9059cbb8484604051602401808373ffffffffffffffffffffffffffffffffffffffff168152602001828152602001925050506040516020818303038152906040529060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050509050602060008251602084016000896127105a03f13d60008114615ca55760208114615cad5760009350615cb8565b819350615cb8565b600051158215171593505b505050939250505056fea2646970667358221220047fac33099ca576d1c4f1ac6a8abdb0396e42ad6a397d2cb2f4dc1624cc0c5b64736f6c63430007060033", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "arbOSInit": { + "initialL1BaseFee": 613218601 + }, + "coinbase": "0x0000000000000000000000000000000000000000", + "difficulty": "0x1", + "extraData": "0x", + "gasLimit": "0x1C9C380", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x0", + "serializedChainConfig": "{\"chainId\":4663,\"homesteadBlock\":0,\"daoForkBlock\":null,\"daoForkSupport\":true,\"eip150Block\":0,\"eip150Hash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"eip155Block\":0,\"eip158Block\":0,\"byzantiumBlock\":0,\"constantinopleBlock\":0,\"petersburgBlock\":0,\"istanbulBlock\":0,\"muirGlacierBlock\":0,\"berlinBlock\":0,\"londonBlock\":0,\"clique\":{\"period\":0,\"epoch\":0},\"arbitrum\":{\"EnableArbOS\":true,\"AllowDebugPrecompiles\":false,\"DataAvailabilityCommittee\":false,\"InitialArbOSVersion\":51,\"InitialChainOwner\":\"0xc8451c5DE260E7ea1b879e7994967077e71230Ca\",\"GenesisBlockNum\":0,\"MaxCodeSize\":98304,\"MaxInitCodeSize\":196608}}", + "timestamp": "0x0" +} +{{end}} diff --git a/build/templates/backend/scripts/hyperevm_archive.sh b/build/templates/backend/scripts/hyperevm_archive.sh new file mode 100644 index 0000000000..dcf25d492e --- /dev/null +++ b/build/templates/backend/scripts/hyperevm_archive.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +RETH_BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/reth-hl +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +# nanoreth (reth-hl) does not re-execute blocks, it ingests pre-executed blocks and +# receipts from s3://hl-mainnet-evm-blocks/, so the backend user needs AWS read +# credentials in ~/.aws/credentials, provisioned out of band as a secret. +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$RETH_BIN node \ + --datadir $DATA_DIR \ + --http \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,txpool,debug,trace \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.api eth,net,web3,txpool,debug,trace \ + --ws.origins '*' \ + --port {{.Ports.BackendP2P}} \ + --disable-discovery \ + --s3 + +{{end}} diff --git a/build/templates/backend/scripts/robinhood_archive.sh b/build/templates/backend/scripts/robinhood_archive.sh new file mode 100644 index 0000000000..b3a40ee35e --- /dev/null +++ b/build/templates/backend/scripts/robinhood_archive.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +NITRO_BIN=$INSTALL_DIR/nitro + +# Robinhood Chain is an Arbitrum Orbit chain whose definition is not embedded in the +# nitro binary. Keep this chain info aligned with Robinhood's published mainnet file; +# the genesis state is supplied separately via the packaged {{.Coin.Alias}}.conf. +CHAIN_INFO='[{"chain-id":4663,"parent-chain-id":1,"parent-chain-is-arbitrum":false,"chain-name":"Robinhood Chain","chain-config":{"chainId":4663,"homesteadBlock":0,"daoForkBlock":null,"daoForkSupport":true,"eip150Block":0,"eip150Hash":"0x0000000000000000000000000000000000000000000000000000000000000000","eip155Block":0,"eip158Block":0,"byzantiumBlock":0,"constantinopleBlock":0,"petersburgBlock":0,"istanbulBlock":0,"muirGlacierBlock":0,"berlinBlock":0,"londonBlock":0,"clique":{"period":0,"epoch":0},"arbitrum":{"EnableArbOS":true,"AllowDebugPrecompiles":false,"DataAvailabilityCommittee":false,"InitialArbOSVersion":51,"InitialChainOwner":"0xc8451c5DE260E7ea1b879e7994967077e71230Ca","GenesisBlockNum":0,"MaxCodeSize":98304,"MaxInitCodeSize":196608}},"rollup":{"bridge":"0xDf8755334ce7A73cCF6b581C02eA649AE3E864b3","inbox":"0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D","sequencer-inbox":"0xBd0D173EEb87D57A09521c24388a12789F33ba96","rollup":"0x23A19d23e89166adedbDcB432518AB01e4272D94","validator-utils":"0x0000000000000000000000000000000000000000","validator-wallet-creator":"0xB7FE37712e46F28C8f22Ec4bAA33A09fb8B52BD0","stake-token":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","deployed-at":24994238}}]' + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$NITRO_BIN \ + --chain.id 4663 \ + --chain.info-json "$CHAIN_INFO" \ + --init.genesis-json-file $INSTALL_DIR/{{.Coin.Alias}}.conf \ + --init.download-path $DATA_DIR/tmp \ + --auth.jwtsecret $DATA_DIR/jwtsecret \ + --persistent.global-config $DATA_DIR \ + --persistent.chain $DATA_DIR \ + --parent-chain.connection.url http://127.0.0.1:8016 \ + --parent-chain.blob-client.beacon-url http://127.0.0.1:7516 \ + --node.feed.input.url wss://feed.mainnet.chain.robinhood.com \ + --execution.forwarding-target https://sequencer.mainnet.chain.robinhood.com \ + --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.caching.state-scheme path \ + --execution.caching.state-history 0 \ + --execution.tx-indexer.tx-lookup-limit 0 \ + --validation.wasm.allowed-wasm-module-roots "$INSTALL_DIR/target/machines" + +{{end}} diff --git a/build/templates/backend/scripts/robinhood_testnet.sh b/build/templates/backend/scripts/robinhood_testnet.sh new file mode 100644 index 0000000000..0aa6188506 --- /dev/null +++ b/build/templates/backend/scripts/robinhood_testnet.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +NITRO_BIN=$INSTALL_DIR/nitro + +# Chain info taken verbatim from the official node config published at +# https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/chain-node-configs/robinhood-chain-testnet-config.json +# (parent chain is Ethereum Sepolia). +CHAIN_INFO='[{"chain-id":46630,"parent-chain-id":11155111,"parent-chain-is-arbitrum":false,"chain-name":"Robinhood Chain Sepolia","chain-config":{"chainId":46630,"homesteadBlock":0,"daoForkBlock":null,"daoForkSupport":true,"eip150Block":0,"eip150Hash":"0x0000000000000000000000000000000000000000000000000000000000000000","eip155Block":0,"eip158Block":0,"byzantiumBlock":0,"constantinopleBlock":0,"petersburgBlock":0,"istanbulBlock":0,"muirGlacierBlock":0,"berlinBlock":0,"londonBlock":0,"clique":{"period":0,"epoch":0},"arbitrum":{"EnableArbOS":true,"AllowDebugPrecompiles":false,"DataAvailabilityCommittee":false,"InitialArbOSVersion":51,"InitialChainOwner":"0x7131b1c28Df46d845Ee044f2f79AC19Ba14526a8","GenesisBlockNum":0,"MaxCodeSize":98304,"MaxInitCodeSize":196608}},"rollup":{"bridge":"0x96295BDad104eaD97cC08797b3dC68efF59CcF30","inbox":"0xF2939afA86F6f933A3CE17fCAB007907B6b0B7a4","sequencer-inbox":"0xA0D9dB3DC9791D54b5183C1C1866eFe1eCA7D414","rollup":"0xdc5F8E399DBd8a9F5F87AeC4C23Beb12431b386D","validator-utils":"0x0000000000000000000000000000000000000000","validator-wallet-creator":"0xB7FE37712e46F28C8f22Ec4bAA33A09fb8B52BD0","stake-token":"0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9","deployed-at":10204516}}]' + +# Robinhood does not publish a testnet genesis file; the testnet genesis state is +# empty (verified: mainnet-preallocated contracts have no code at testnet block 0), +# so a fresh database is initialized with --init.empty. +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$NITRO_BIN \ + --chain.id 46630 \ + --chain.info-json "$CHAIN_INFO" \ + --init.empty \ + --init.download-path $DATA_DIR/tmp \ + --auth.jwtsecret $DATA_DIR/jwtsecret \ + --persistent.global-config $DATA_DIR \ + --persistent.chain $DATA_DIR \ + --parent-chain.connection.url http://127.0.0.1:18176 \ + --parent-chain.blob-client.beacon-url http://127.0.0.1:17576 \ + --node.feed.input.url wss://feed.testnet.chain.robinhood.com \ + --execution.forwarding-target https://sequencer.testnet.chain.robinhood.com \ + --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.caching.state-scheme path \ + --execution.caching.state-history 0 \ + --execution.tx-indexer.tx-lookup-limit 0 \ + --validation.wasm.allowed-wasm-module-roots "$INSTALL_DIR/target/machines" + +{{end}} diff --git a/build/templates/blockbook/blockchaincfg.json b/build/templates/blockbook/blockchaincfg.json index 265c2dcb42..6d27d9737d 100644 --- a/build/templates/blockbook/blockchaincfg.json +++ b/build/templates/blockbook/blockchaincfg.json @@ -17,7 +17,8 @@ "rpc_timeout": {{.IPC.RPCTimeout}}, "parse": {{.Blockbook.BlockChain.Parse}}, "message_queue_binding": "{{template "IPC.MessageQueueBindingTemplate" .}}", - "subversion": "{{.Blockbook.BlockChain.Subversion}}", +{{if .IPC.MessageQueueCurve}} "message_queue_curve": {{jsonToString .IPC.MessageQueueCurve}}, +{{end}} "subversion": "{{.Blockbook.BlockChain.Subversion}}", "address_format": "{{.Blockbook.BlockChain.AddressFormat}}", {{if .Blockbook.BlockChain.XPubMagic}} "xpub_magic": {{.Blockbook.BlockChain.XPubMagic}}, {{end}}{{if .Blockbook.BlockChain.XPubMagicSegwitP2sh}} "xpub_magic_segwit_p2sh": {{.Blockbook.BlockChain.XPubMagicSegwitP2sh}}, diff --git a/build/templates/blockbook/debian/service b/build/templates/blockbook/debian/service index bd97e30baa..172a6d25ed 100644 --- a/build/templates/blockbook/debian/service +++ b/build/templates/blockbook/debian/service @@ -13,6 +13,10 @@ Wants={{.Backend.PackageName}}.service # 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. +# jemalloc tuning for the Blockbook binary (see docs/env.md): eight arenas instead of four per CPU +# stop idle arenas from holding purged memory, and the background thread purges without stalling +# the allocating goroutine. A MALLOC_CONF in blockbook.env replaces this value. +Environment=MALLOC_CONF=narenas:8,background_thread:true 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}} diff --git a/build/templates/blockbook/logrotate.sh b/build/templates/blockbook/logrotate.sh index 267e1bdc78..56fe166d8d 100755 --- a/build/templates/blockbook/logrotate.sh +++ b/build/templates/blockbook/logrotate.sh @@ -4,10 +4,10 @@ set -e LOGS={{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/logs -find $LOGS -mtime +7 -type f -print0 | while read -r -d $'\0' log; do +find "$LOGS" -mtime +7 -type f -print0 | while read -r -d $'\0' log; do # remove log if isn't opened by any process - if ! fuser -s $log; then - rm -f $log + if ! fuser -s -- "$log"; then + rm -f -- "$log" fi done {{end}} diff --git a/build/tools/additional_params.go b/build/tools/additional_params.go new file mode 100644 index 0000000000..f94a538427 --- /dev/null +++ b/build/tools/additional_params.go @@ -0,0 +1,168 @@ +package build + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + "unicode" +) + +// validateAdditionalParamsOverlay rejects an override of a setting the production +// additional_params does not declare, so a typo fails the build and additional_params +// stays the complete inventory of settings with their production values. +func validateAdditionalParamsOverlay(base, overlay map[string]json.RawMessage) error { + var unknown []string + for name := range overlay { + if _, ok := base[name]; !ok { + unknown = append(unknown, name) + } + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return fmt.Errorf("additional_params_dev overrides settings missing from additional_params: %s (declare the setting with its production value first)", strings.Join(unknown, ", ")) +} + +// mergeAdditionalParams applies the dev overlay over the production additional_params. +func mergeAdditionalParams(base, overlay map[string]json.RawMessage) (map[string]json.RawMessage, error) { + if len(overlay) == 0 { + return base, nil + } + + merged := make(map[string]json.RawMessage, len(base)) + for name, value := range base { + merged[name] = value + } + for name, override := range overlay { + value, err := mergeJSONValues(base[name], override) + if err != nil { + return nil, fmt.Errorf("additional_params_dev %q: %w", name, err) + } + merged[name] = value + } + return merged, nil +} + +// mergeJSONValues merges two JSON objects field by field and replaces anything else +// outright. A field the base object does not have is added: the must-exist rule is a +// typo guard for setting names, not for the fields inside one setting's parameters. +func mergeJSONValues(base, overlay json.RawMessage) (json.RawMessage, error) { + baseObject, baseWasString, baseOK := decodeJSONObject(base) + overlayObject, _, overlayOK := decodeJSONObject(overlay) + if !baseOK || !overlayOK { + return overlay, nil + } + + for name, override := range overlayObject { + value, err := mergeJSONValues(baseObject[name], override) + if err != nil { + return nil, err + } + baseObject[name] = value + } + + // Re-encoding normalizes whitespace and sorts keys inside the value; every consumer + // unmarshals it, so only the rendered text differs from the production config. + encoded, err := json.Marshal(baseObject) + if err != nil { + return nil, err + } + if !baseWasString { + return encoded, nil + } + // The *_params settings carry their object inside a JSON string; keep that form so + // the runtime schema of the setting is unchanged. + quoted, err := json.Marshal(string(encoded)) + if err != nil { + return nil, err + } + return quoted, nil +} + +// decodeJSONObject decodes a JSON object, either given directly or encoded in a JSON +// string as the *_params settings are, reporting which of the two forms it was. +func decodeJSONObject(raw json.RawMessage) (object map[string]json.RawMessage, wasString bool, ok bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return nil, false, false + } + + switch trimmed[0] { + case '{': + if err := json.Unmarshal(trimmed, &object); err != nil { + return nil, false, false + } + return object, false, true + case '"': + var unquoted string + if err := json.Unmarshal(trimmed, &unquoted); err != nil { + return nil, false, false + } + inner := bytes.TrimSpace([]byte(unquoted)) + if len(inner) == 0 || inner[0] != '{' { + return nil, false, false + } + if err := json.Unmarshal(inner, &object); err != nil { + return nil, false, false + } + return object, true, true + } + return nil, false, false +} + +// addnodePeerPattern limits an env-supplied peer to one host[:port] token, so the value +// cannot inject further lines or options into the rendered backend config. +var addnodePeerPattern = regexp.MustCompile(`^[A-Za-z0-9.:\[\]-]+$`) + +// appendBackendAddnodes appends private peers after the public addnode list of the coin +// config, keeping their order and dropping duplicates. +func appendBackendAddnodes(backend *Backend, raw string) error { + peers := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) + for i, peer := range peers { + if !addnodePeerPattern.MatchString(peer) { + // The peers are private, so name only the position of the bad entry. + return fmt.Errorf("addnode peer #%d is not a host[:port]", i+1) + } + } + if len(peers) == 0 { + return nil + } + + if backend.AdditionalParams == nil { + backend.AdditionalParams = map[string]interface{}{} + } + params, ok := backend.AdditionalParams.(map[string]interface{}) + if !ok { + return fmt.Errorf("backend additional_params is not an object") + } + + var nodes []interface{} + seen := make(map[string]struct{}) + switch existing := params["addnode"].(type) { + case nil: + case []interface{}: + for _, node := range existing { + name, ok := node.(string) + if !ok { + return fmt.Errorf("backend additional_params addnode must list strings") + } + nodes = append(nodes, name) + seen[name] = struct{}{} + } + default: + return fmt.Errorf("backend additional_params addnode must be a list") + } + for _, peer := range peers { + if _, dup := seen[peer]; dup { + continue + } + nodes = append(nodes, peer) + seen[peer] = struct{}{} + } + params["addnode"] = nodes + return nil +} diff --git a/build/tools/additional_params_test.go b/build/tools/additional_params_test.go new file mode 100644 index 0000000000..5c633906a2 --- /dev/null +++ b/build/tools/additional_params_test.go @@ -0,0 +1,201 @@ +package build + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestValidateAdditionalParamsOverlayRejectsUndeclaredSetting(t *testing.T) { + base := map[string]json.RawMessage{ + "alternative_estimate_fee_params": json.RawMessage(`"{\"periodSeconds\": 10}"`), + } + overlay := map[string]json.RawMessage{ + "alternative_estimate_fee_paarms": json.RawMessage(`{"periodSeconds": 300}`), + } + + err := validateAdditionalParamsOverlay(base, overlay) + if err == nil { + t.Fatal("expected a setting missing from additional_params to be rejected") + } + if !strings.Contains(err.Error(), "alternative_estimate_fee_paarms") { + t.Fatalf("expected the offending setting to be named, got %v", err) + } + + if err := validateAdditionalParamsOverlay(base, nil); err != nil { + t.Fatalf("validateAdditionalParamsOverlay(no overlay) error = %v", err) + } +} + +func TestMergeAdditionalParams(t *testing.T) { + const feeParams = `"{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees\", \"periodSeconds\": 10, \"staleSeconds\": 600}"` + + tests := []struct { + name string + base map[string]json.RawMessage + overlay map[string]json.RawMessage + want map[string]string + }{ + { + name: "no overlay keeps base", + base: map[string]json.RawMessage{"eip1559Fees": json.RawMessage(`true`)}, + overlay: nil, + want: map[string]string{"eip1559Fees": `true`}, + }, + { + name: "scalar is replaced", + base: map[string]json.RawMessage{"disableMempoolSync": json.RawMessage(`false`)}, + overlay: map[string]json.RawMessage{"disableMempoolSync": json.RawMessage(`true`)}, + want: map[string]string{"disableMempoolSync": `true`}, + }, + { + name: "string encoded params merge field by field and stay a string", + base: map[string]json.RawMessage{"alternative_estimate_fee_params": json.RawMessage(feeParams)}, + overlay: map[string]json.RawMessage{"alternative_estimate_fee_params": json.RawMessage(`{"periodSeconds": 300}`)}, + want: map[string]string{ + "alternative_estimate_fee_params": `"{\"periodSeconds\":300,\"staleSeconds\":600,\"url\":\"https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees\"}"`, + }, + }, + { + name: "string encoded overlay is accepted too", + base: map[string]json.RawMessage{"fiat_rates_params": json.RawMessage(`"{\"coin\": \"ethereum\", \"periodSeconds\": 900}"`)}, + overlay: map[string]json.RawMessage{"fiat_rates_params": json.RawMessage(`"{\"periodSeconds\": 3600}"`)}, + want: map[string]string{ + "fiat_rates_params": `"{\"coin\":\"ethereum\",\"periodSeconds\":3600}"`, + }, + }, + { + name: "overlay may add a field the base params omit", + base: map[string]json.RawMessage{"alternative_estimate_fee_params": json.RawMessage(`"{\"periodSeconds\": 10}"`)}, + overlay: map[string]json.RawMessage{"alternative_estimate_fee_params": json.RawMessage(`{"staleSeconds": 1800}`)}, + want: map[string]string{ + "alternative_estimate_fee_params": `"{\"periodSeconds\":10,\"staleSeconds\":1800}"`, + }, + }, + { + name: "plain objects merge recursively", + base: map[string]json.RawMessage{"missingBlockRetry": json.RawMessage(`{"attempts": 3, "backoff": {"initialMs": 500, "maxMs": 5000}}`)}, + overlay: map[string]json.RawMessage{"missingBlockRetry": json.RawMessage(`{"backoff": {"maxMs": 60000}}`)}, + want: map[string]string{ + "missingBlockRetry": `{"attempts":3,"backoff":{"initialMs":500,"maxMs":60000}}`, + }, + }, + { + name: "settings outside the overlay are untouched", + base: map[string]json.RawMessage{"eip1559Fees": json.RawMessage(`true`), "mempoolTxTimeoutHours": json.RawMessage(`48`)}, + overlay: map[string]json.RawMessage{"mempoolTxTimeoutHours": json.RawMessage(`2`)}, + want: map[string]string{"eip1559Fees": `true`, "mempoolTxTimeoutHours": `2`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + merged, err := mergeAdditionalParams(tt.base, tt.overlay) + if err != nil { + t.Fatalf("mergeAdditionalParams() error = %v", err) + } + if len(merged) != len(tt.want) { + t.Fatalf("merged has %d settings, want %d: %v", len(merged), len(tt.want), merged) + } + for name, want := range tt.want { + if got := string(merged[name]); got != want { + t.Fatalf("merged[%q] = %s, want %s", name, got, want) + } + } + }) + } +} + +func TestMergeAdditionalParamsDoesNotMutateBase(t *testing.T) { + const original = `"{\"url\": \"https://example.invalid\", \"periodSeconds\": 10}"` + base := map[string]json.RawMessage{"alternative_estimate_fee_params": json.RawMessage(original)} + overlay := map[string]json.RawMessage{"alternative_estimate_fee_params": json.RawMessage(`{"periodSeconds": 300}`)} + + if _, err := mergeAdditionalParams(base, overlay); err != nil { + t.Fatalf("mergeAdditionalParams() error = %v", err) + } + if got := string(base["alternative_estimate_fee_params"]); got != original { + t.Fatalf("base was mutated: %s", got) + } +} + +func TestDecodeJSONObjectReportsForm(t *testing.T) { + tests := []struct { + name string + raw string + wantOK bool + wantWasString bool + }{ + {name: "object", raw: `{"a": 1}`, wantOK: true}, + {name: "string encoded object", raw: `"{\"a\": 1}"`, wantOK: true, wantWasString: true}, + {name: "plain string", raw: `"infura"`}, + {name: "number", raw: `42`}, + {name: "array", raw: `[1, 2]`}, + {name: "empty", raw: ``}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, wasString, ok := decodeJSONObject(json.RawMessage(tt.raw)) + if ok != tt.wantOK { + t.Fatalf("decodeJSONObject(%s) ok = %v, want %v", tt.raw, ok, tt.wantOK) + } + if wasString != tt.wantWasString { + t.Fatalf("decodeJSONObject(%s) wasString = %v, want %v", tt.raw, wasString, tt.wantWasString) + } + }) + } +} + +func TestAppendBackendAddnodes(t *testing.T) { + backend := &Backend{AdditionalParams: map[string]interface{}{ + "addnode": []interface{}{"public.example"}, + "dbcache": "1000", + }} + if err := appendBackendAddnodes(backend, "p1.example:8333, 10.0.0.5\n[2001:db8::1]:8333,public.example p1.example:8333"); err != nil { + t.Fatalf("appendBackendAddnodes() error = %v", err) + } + params := backend.AdditionalParams.(map[string]interface{}) + got := fmt.Sprint(params["addnode"]) + if want := "[public.example p1.example:8333 10.0.0.5 [2001:db8::1]:8333]"; got != want { + t.Fatalf("addnode = %s, want %s", got, want) + } + if params["dbcache"] != "1000" { + t.Fatalf("other params must survive, got %v", params) + } +} + +func TestAppendBackendAddnodesWithoutExistingParams(t *testing.T) { + backend := &Backend{} + if err := appendBackendAddnodes(backend, "p1.example"); err != nil { + t.Fatalf("appendBackendAddnodes() error = %v", err) + } + if got := fmt.Sprint(backend.AdditionalParams.(map[string]interface{})["addnode"]); got != "[p1.example]" { + t.Fatalf("addnode = %s", got) + } + + backend = &Backend{AdditionalParams: map[string]interface{}{"dbcache": "1000"}} + if err := appendBackendAddnodes(backend, "p1.example"); err != nil { + t.Fatalf("appendBackendAddnodes() error = %v", err) + } + if got := fmt.Sprint(backend.AdditionalParams.(map[string]interface{})["addnode"]); got != "[p1.example]" { + t.Fatalf("addnode = %s", got) + } +} + +func TestAppendBackendAddnodesRejectsInjectionWithoutEchoingIt(t *testing.T) { + for _, raw := range []string{"secret.example\nrpcallowip=0.0.0.0/0", "secret.example=1", "secret.example;x"} { + backend := &Backend{} + err := appendBackendAddnodes(backend, raw) + if err == nil { + t.Fatalf("expected %q to be rejected", raw) + } + if strings.Contains(err.Error(), "secret.example") || strings.Contains(err.Error(), "rpcallowip") { + t.Fatalf("error must not echo the private value: %v", err) + } + if backend.AdditionalParams != nil { + t.Fatalf("rejected input must leave the params untouched, got %v", backend.AdditionalParams) + } + } +} diff --git a/build/tools/build_var_prefixes_test.go b/build/tools/build_var_prefixes_test.go index 1b26666ec6..b77cda6769 100644 --- a/build/tools/build_var_prefixes_test.go +++ b/build/tools/build_var_prefixes_test.go @@ -52,6 +52,7 @@ func TestCanonicalPrefixesCoverTemplateConsumers(t *testing.T) { prodMQURLPrefix, "BB_RPC_BIND_HOST_", // inline literal in LoadConfig "BB_RPC_ALLOW_IP_", // inline literal in LoadConfig + backendAddnodePrefix, } for _, prefix := range usedByTemplates { diff --git a/build/tools/templates.go b/build/tools/templates.go index dde76d6068..bb8a9ef0e2 100644 --- a/build/tools/templates.go +++ b/build/tools/templates.go @@ -44,6 +44,14 @@ type Backend struct { Platforms map[string]Backend `json:"platforms,omitempty"` } +// MessageQueueCurve is optional ZMQ CURVE key material from a coin definition +// (`ipc.message_queue_curve`). Copied into blockchaincfg.json as message_queue_curve. +type MessageQueueCurve struct { + PublicKey string `json:"public_key,omitempty"` + SecretKey string `json:"secret_key,omitempty"` + ServerKey string `json:"server_key,omitempty"` +} + // Config contains the structure of the config type Config struct { Coin struct { @@ -64,12 +72,13 @@ type Config struct { 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"` - MessageQueueBindingTemplate string `json:"message_queue_binding_template"` + 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"` + MessageQueueBindingTemplate string `json:"message_queue_binding_template"` + MessageQueueCurve *MessageQueueCurve `json:"message_queue_curve,omitempty"` } `json:"ipc"` Backend Backend `json:"backend"` Blockbook struct { @@ -97,6 +106,9 @@ type Config struct { Web3Explorer string `json:"web3_explorer_url,omitempty"` AdditionalParams map[string]json.RawMessage `json:"additional_params"` + // Overrides merged over AdditionalParams only when BB_BUILD_ENV=dev, so a value + // tuned for a dev instance cannot reach a production package. + AdditionalParamsDev map[string]json.RawMessage `json:"additional_params_dev,omitempty"` } `json:"block_chain"` } `json:"blockbook"` Meta struct { @@ -128,11 +140,12 @@ const ( prodRPCURLHTTPPrefix = "BB_PROD_RPC_URL_HTTP_" prodRPCURLWSPrefix = "BB_PROD_RPC_URL_WS_" prodMQURLPrefix = "BB_PROD_MQ_URL_" + backendAddnodePrefix = "BB_ADDNODE_" devBlockbookPprofPortOffset = 20000 ) -func jsonToString(msg json.RawMessage) (string, error) { - d, err := msg.MarshalJSON() +func jsonToString(msg any) (string, error) { + d, err := json.Marshal(msg) if err != nil { return "", err } @@ -150,18 +163,38 @@ func generateRPCAuth(user, pass string) (string, error) { return out.String(), nil } +const stagingEnvVar = "BB_STAGING" + 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 } + // Tolerate BB_STAGING work-in-progress coins whose config lives only on a feature branch. + for alias := range stagingAliases() { + validAliases[alias] = struct{}{} + } 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, ", ")) + return fmt.Errorf("RPC env vars reference unknown coin aliases: %s (add work-in-progress coins to BB_STAGING)", strings.Join(unknown, ", ")) +} + +// stagingAliases parses BB_STAGING (comma/space separated work-in-progress coins) +// into an alias set with '-'/'_' variants so their RPC vars validate on branches +// that do not yet carry the coin config. +func stagingAliases() map[string]struct{} { + aliases := map[string]struct{}{} + for _, field := range strings.Fields(strings.ReplaceAll(os.Getenv(stagingEnvVar), ",", " ")) { + name := strings.ToLower(field) + aliases[name] = struct{}{} + aliases[strings.ReplaceAll(name, "-", "_")] = struct{}{} + aliases[strings.ReplaceAll(name, "_", "-")] = struct{}{} + } + return aliases } type coinAliasHolder struct { @@ -228,6 +261,7 @@ func rpcEnvPrefixes() []string { prodMQURLPrefix, "BB_RPC_BIND_HOST_", "BB_RPC_ALLOW_IP_", + backendAddnodePrefix, } } @@ -397,6 +431,18 @@ func LoadConfig(configsDir, coin string) (*Config, error) { return nil, err } + blockChain := &config.Blockbook.BlockChain + // Validate in every build env so a typo in a dev override fails a production build too. + if err := validateAdditionalParamsOverlay(blockChain.AdditionalParams, blockChain.AdditionalParamsDev); err != nil { + return nil, fmt.Errorf("%s: %w", coin, err) + } + if buildEnv == buildEnvDev { + blockChain.AdditionalParams, err = mergeAdditionalParams(blockChain.AdditionalParams, blockChain.AdditionalParamsDev) + if err != nil { + return nil, fmt.Errorf("%s: %w", coin, err) + } + } + 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) @@ -452,6 +498,10 @@ func LoadConfig(configsDir, coin string) (*Config, error) { } } + if err := applyBackendAddnodesFromEnv(config); err != nil { + return nil, fmt.Errorf("%s: %w", coin, err) + } + config.Env.WantsBackendService, err = wantsBackendService(config) if err != nil { return nil, err @@ -460,6 +510,23 @@ func LoadConfig(configsDir, coin string) (*Config, error) { return config, nil } +// applyBackendAddnodesFromEnv appends private backend peers from the build env, which keeps +// them out of the public coin config. +func applyBackendAddnodesFromEnv(config *Config) error { + peers, ok := lookupAliasEnv(backendAddnodePrefix, config.Coin.Alias) + if !ok { + return nil + } + key := backendAddnodePrefix + config.Coin.Alias + if isEmpty(config, "backend") { + return fmt.Errorf("%s is set but the coin has no backend package", key) + } + if err := appendBackendAddnodes(&config.Backend, peers); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + return nil +} + func isEmpty(config *Config, target string) bool { switch target { case "backend": @@ -486,6 +553,19 @@ func lookupEnvWithArchiveFallback(prefix, alias string) (string, bool) { return "", false } +// lookupAliasEnv reads prefix+alias, also accepting the underscore form of a hyphenated alias. +func lookupAliasEnv(prefix, alias string) (string, bool) { + if alias == "" { + return "", false + } + for _, candidate := range withEnvAliasVariants([]string{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) { diff --git a/build/tools/templates_test.go b/build/tools/templates_test.go index cdc6bdce8d..e41641ec38 100644 --- a/build/tools/templates_test.go +++ b/build/tools/templates_test.go @@ -398,6 +398,126 @@ func TestEthereumClassicRPCAndBackendHTTPPortStayAligned(t *testing.T) { } } +func TestValidateRPCEnvVarsToleratesStagingCoin(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + withTemporarilyUnsetEnv(t, stagingEnvVar) + // An RPC override for a coin whose config lives only on a feature branch. + t.Setenv(devRPCURLHTTPPrefix+"nonexistent_wip_coin", "http://backend.example:8030") + + // Assert on this coin specifically, not on a globally-clean result: the env may + // carry other orphan overrides (the multi-WIP-coin case this feature targets). + if err := validateRPCEnvVars(configsDir); err == nil || !strings.Contains(err.Error(), "nonexistent_wip_coin") { + t.Fatalf("expected nonexistent_wip_coin to be rejected without BB_STAGING, got %v", err) + } + + t.Setenv(stagingEnvVar, "nonexistent_wip_coin") + if err := validateRPCEnvVars(configsDir); err != nil && strings.Contains(err.Error(), "nonexistent_wip_coin") { + t.Fatalf("BB_STAGING should tolerate nonexistent_wip_coin, got %v", err) + } +} + +func TestBlockchainCfgOmitsMessageQueueCurveWhenUnset(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + withTemporarilyUnsetEnv(t, + buildEnvVar, + devMQURLPrefix+"bitcoin", + prodMQURLPrefix+"bitcoin", + ) + + config, err := LoadConfig(configsDir, "bitcoin") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if config.IPC.MessageQueueCurve != nil { + t.Fatal("expected bitcoin coin definition to omit message_queue_curve") + } + + rendered := renderBlockchainCfg(t, config) + var parsed map[string]json.RawMessage + if err := json.Unmarshal(rendered, &parsed); err != nil { + t.Fatalf("json.Unmarshal(blockchaincfg) error = %v", err) + } + if _, ok := parsed["message_queue_curve"]; ok { + t.Fatalf("did not expect message_queue_curve in rendered blockchaincfg:\n%s", rendered) + } +} + +func TestBlockchainCfgRendersMessageQueueCurveFromCoinIPC(t *testing.T) { + const raw = `{ + "ipc": { + "message_queue_binding_template": "tcp://127.0.0.1:38330", + "message_queue_curve": { + "public_key": "client-public", + "secret_key": "client-secret", + "server_key": "server-public" + } + } + }` + + var decoded Config + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatalf("json.Unmarshal(coin ipc) error = %v", err) + } + if decoded.IPC.MessageQueueCurve == nil { + t.Fatal("expected ipc.message_queue_curve to decode") + } + + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + withTemporarilyUnsetEnv(t, + buildEnvVar, + devMQURLPrefix+"bitcoin", + prodMQURLPrefix+"bitcoin", + ) + + config, err := LoadConfig(configsDir, "bitcoin") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + config.IPC.MessageQueueCurve = decoded.IPC.MessageQueueCurve + + rendered := renderBlockchainCfg(t, config) + var parsed struct { + MessageQueueCurve *MessageQueueCurve `json:"message_queue_curve"` + } + if err := json.Unmarshal(rendered, &parsed); err != nil { + t.Fatalf("json.Unmarshal(blockchaincfg) error = %v", err) + } + if parsed.MessageQueueCurve == nil { + t.Fatalf("expected message_queue_curve in rendered blockchaincfg:\n%s", rendered) + } + if parsed.MessageQueueCurve.PublicKey != "client-public" || + parsed.MessageQueueCurve.SecretKey != "client-secret" || + parsed.MessageQueueCurve.ServerKey != "server-public" { + t.Fatalf("message_queue_curve = %+v", parsed.MessageQueueCurve) + } +} + +func renderBlockchainCfg(t *testing.T, config *Config) []byte { + t.Helper() + + 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) + } + return blockchainCfg.Bytes() +} + +func TestStagingAliasesParsesListAndVariants(t *testing.T) { + t.Setenv(stagingEnvVar, "robinhood_archive, foo-bar") + + got := stagingAliases() + for _, want := range []string{"robinhood_archive", "robinhood-archive", "foo-bar", "foo_bar"} { + if _, ok := got[want]; !ok { + t.Errorf("stagingAliases() missing %q", want) + } + } +} + func withTemporarilyUnsetEnv(t *testing.T, keys ...string) { t.Helper() @@ -424,3 +544,168 @@ func withTemporarilyUnsetEnv(t *testing.T, keys ...string) { } }) } + +func TestAdditionalParamsDevOverlayAppliesOnlyToDevBuilds(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + tests := []struct { + buildEnv string + wantFeePeriod float64 + wantFiatPeriod float64 + }{ + {buildEnv: buildEnvProd, wantFeePeriod: 10, wantFiatPeriod: 900}, + {buildEnv: buildEnvDev, wantFeePeriod: 300, wantFiatPeriod: 3600}, + } + + for _, tt := range tests { + t.Run(tt.buildEnv, func(t *testing.T) { + withTemporarilyUnsetEnv(t, buildEnvVar) + t.Setenv(buildEnvVar, tt.buildEnv) + + config, err := LoadConfig(configsDir, "ethereum_archive") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + rendered := renderBlockchainCfg(t, config) + + var renderedCfg struct { + FeeParams string `json:"alternative_estimate_fee_params"` + FiatParams string `json:"fiat_rates_params"` + DevOverlay json.RawMessage `json:"additional_params_dev"` + } + if err := json.Unmarshal(rendered, &renderedCfg); err != nil { + t.Fatalf("json.Unmarshal(blockchaincfg) error = %v", err) + } + if renderedCfg.DevOverlay != nil { + t.Fatalf("additional_params_dev leaked into the rendered config: %s", renderedCfg.DevOverlay) + } + + for name, params := range map[string]struct { + raw string + want float64 + }{ + "alternative_estimate_fee_params": {raw: renderedCfg.FeeParams, want: tt.wantFeePeriod}, + "fiat_rates_params": {raw: renderedCfg.FiatParams, want: tt.wantFiatPeriod}, + } { + var decoded map[string]interface{} + if err := json.Unmarshal([]byte(params.raw), &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s = %q) error = %v", name, params.raw, err) + } + if got := decoded["periodSeconds"]; got != params.want { + t.Fatalf("%s periodSeconds = %v, want %v", name, got, params.want) + } + } + + // The overlay retunes one field, so the rest of the setting must survive intact. + if !strings.Contains(renderedCfg.FeeParams, "gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees") { + t.Fatalf("fee provider url lost in overlay merge: %q", renderedCfg.FeeParams) + } + }) + } +} + +func TestAllCoinConfigsRenderInBothBuildEnvs(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + entries, err := os.ReadDir(filepath.Join(configsDir, "coins")) + if err != nil { + t.Fatalf("ReadDir(coins) error = %v", err) + } + + for _, entry := range entries { + coin := strings.TrimSuffix(entry.Name(), ".json") + if entry.IsDir() || coin == entry.Name() { + continue + } + + t.Run(coin, func(t *testing.T) { + for _, buildEnv := range []string{buildEnvDev, buildEnvProd} { + withTemporarilyUnsetEnv(t, buildEnvVar) + t.Setenv(buildEnvVar, buildEnv) + + config, err := LoadConfig(configsDir, coin) + if err != nil { + t.Fatalf("LoadConfig(%s) in %s error = %v", coin, buildEnv, err) + } + if isEmpty(config, "blockbook") { + continue + } + + var renderedCfg map[string]json.RawMessage + if err := json.Unmarshal(renderBlockchainCfg(t, config), &renderedCfg); err != nil { + t.Fatalf("json.Unmarshal(blockchaincfg for %s in %s) error = %v", coin, buildEnv, err) + } + if _, ok := renderedCfg["additional_params_dev"]; ok { + t.Fatalf("additional_params_dev leaked into the rendered config of %s in %s", coin, buildEnv) + } + } + }) + } +} + +func TestLoadConfigAppendsAddnodePeersFromEnv(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + t.Setenv(backendAddnodePrefix+"bitcoin", "p1.example:8333,10.0.0.5") + + config, err := LoadConfig(configsDir, "bitcoin") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + templ := config.ParseTemplate() + // The real generateRPCAuth shells out to a repo-root script. + templ.Funcs(template.FuncMap{"generateRPCAuth": func(string, string) (string, error) { return "rpcauth=stub", nil }}) + conf, err := os.ReadFile(filepath.Join("..", "templates", "backend", "config", config.Backend.ServerConfigFile)) + if err != nil { + t.Fatalf("ReadFile(server config) error = %v", err) + } + templ = template.Must(templ.Parse(string(conf))) + var out bytes.Buffer + if err := templ.ExecuteTemplate(&out, "main", config); err != nil { + t.Fatalf("render server config error = %v", err) + } + + var addnodes []string + for _, line := range strings.Split(out.String(), "\n") { + if strings.HasPrefix(line, "addnode=") { + addnodes = append(addnodes, strings.TrimPrefix(line, "addnode=")) + } + } + if got, want := strings.Join(addnodes, " "), "ove.palatinus.cz p1.example:8333 10.0.0.5"; got != want { + t.Fatalf("rendered addnode lines = %q, want %q\n%s", got, want, out.String()) + } +} + +func TestLoadConfigRejectsInvalidAddnodePeers(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + t.Setenv(backendAddnodePrefix+"bitcoin", "secret.example\nrpcallowip=0.0.0.0/0") + + _, err := LoadConfig(configsDir, "bitcoin") + if err == nil { + t.Fatal("expected an invalid addnode peer to fail the build") + } + if strings.Contains(err.Error(), "secret.example") { + t.Fatalf("error must not echo the private value: %v", err) + } +} + +func TestLoadConfigRejectsAddnodePeersForUnknownAlias(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + withTemporarilyUnsetEnv(t, stagingEnvVar) + t.Setenv(backendAddnodePrefix+"bitcion", "p1.example") + + if _, err := LoadConfig(configsDir, "bitcoin"); err == nil || !strings.Contains(err.Error(), "bitcion") { + t.Fatalf("expected the misspelled alias to be rejected, got %v", err) + } +} + +func TestApplyBackendAddnodesRejectsCoinWithoutBackend(t *testing.T) { + config := &Config{} + config.Coin.Alias = "nobackend" + t.Setenv(backendAddnodePrefix+"nobackend", "p1.example") + + if err := applyBackendAddnodesFromEnv(config); err == nil || !strings.Contains(err.Error(), "no backend package") { + t.Fatalf("expected peers for a coin without a backend to be rejected, got %v", err) + } +} diff --git a/build/tools/typescriptify/typescriptify.go b/build/tools/typescriptify/typescriptify.go index 076b06cd2e..4e6bb55848 100644 --- a/build/tools/typescriptify/typescriptify.go +++ b/build/tools/typescriptify/typescriptify.go @@ -1,8 +1,25 @@ +// Command typescriptify regenerates blockbook-api.ts from the Go API structs. +// +// make typescriptify # inside the build image, no local RocksDB needed +// go run ./build/tools/typescriptify # from the repository root +// +// TestCommittedFileIsCurrent fails the unit tests while the committed file is stale. +// +// On top of the library's ts_type/ts_doc tags, two conventions keep the output reproducible: +// - Structs implementing bchain.ChainExtraPayloadWrapper are emitted as discriminated unions +// built from bchain.ChainExtraPayloads, so `payloadType === 'tron'` narrows the payload in +// TypeScript (the library can neither emit named aliases nor unions itself). +// - ts_nullable:"true" on a pointer field without omitempty emits `name: T | null` instead of +// the library's `name?: T`, matching what encoding/json actually puts on the wire. package main import ( "fmt" "math/big" + "os" + "reflect" + "regexp" + "strings" "time" "github.com/tkrajina/typescriptify-golang-structs/typescriptify" @@ -11,10 +28,80 @@ import ( "github.com/trezor/blockbook/server" ) +const outputFile = "blockbook-api.ts" + +// The first line is what the library's ConvertToFile used to write, kept for diff stability. +const header = "/* Do not change, this code is generated from Golang structs */\n" + + "/* Regenerate with `make typescriptify` (see build/tools/typescriptify) */\n\n" + +var wrapperIface = reflect.TypeOf((*bchain.ChainExtraPayloadWrapper)(nil)).Elem() + +// apiTypes are the roots of the generated file; nested structs are discovered by the library. +// The payload structs behind the unions are added from bchain.ChainExtraPayloads. +var apiTypes = []interface{}{ + // API - REST and Websocket + api.APIError{}, + api.Tx{}, + api.FeeStats{}, + api.Address{}, + api.ContractInfoResult{}, + api.Utxo{}, + api.BalanceHistory{}, + api.Blocks{}, + api.Block{}, + api.BlockRaw{}, + api.SystemInfo{}, + api.FiatTicker{}, + api.FiatTickers{}, + api.AvailableVsCurrencies{}, + + // Websocket specific + server.WsReq{}, + server.WsRes{}, + server.WsAccountInfoReq{}, + server.WsContractInfoReq{}, + server.WsInfoRes{}, + server.WsBlockHashReq{}, + server.WsBlockHashRes{}, + server.WsBlockReq{}, + server.WsBlockFilterReq{}, + server.WsBlockFiltersBatchReq{}, + server.WsAccountUtxoReq{}, + server.WsBalanceHistoryReq{}, + server.WsTransactionReq{}, + server.WsTransactionSpecificReq{}, + server.WsEstimateFeeReq{}, + server.WsEstimateFeeRes{}, + server.WsLongTermFeeRateRes{}, + server.WsNewBlock{}, + server.WsSendTransactionReq{}, + server.WsSubscribeAddressesReq{}, + server.WsSubscribeFiatRatesReq{}, + server.WsCurrentFiatRatesReq{}, + server.WsFiatRatesForTimestampsReq{}, + server.WsFiatRatesTickersListReq{}, + server.WsMempoolFiltersReq{}, + server.WsRpcCallReq{}, + server.WsRpcCallRes{}, + bchain.MempoolTxidFilterEntries{}, +} + func main() { + out, err := generate(apiTypes, bchain.ChainExtraPayloads) + if err != nil { + fmt.Fprintln(os.Stderr, "typescriptify:", err) + os.Exit(1) + } + if err := os.WriteFile(outputFile, []byte(out), 0644); err != nil { + fmt.Fprintln(os.Stderr, "typescriptify:", err) + os.Exit(1) + } + fmt.Println("OK") +} + +func newConverter() *typescriptify.TypeScriptify { t := typescriptify.New() t.CreateInterface = true - t.Indent = " " t.BackupDir = "" t.ManageType(api.Amount{}, typescriptify.TypeOptions{TSType: "string"}) @@ -22,57 +109,151 @@ func main() { 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"}) + return t +} - // 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") +// generate returns the complete file contents. +func generate(roots []interface{}, payloads []bchain.ChainExtraPayload) (string, error) { + t := newConverter() + nullable, wrappers, err := scanFields(roots) if err != nil { - panic(err.Error()) + return "", err } - fmt.Println("OK") + aliases, err := renderUnions(wrappers, payloads) + if err != nil { + return "", err + } + // The alias takes the wrapper's Go name, so every field of the wrapper type is typed with it. + for _, w := range wrappers { + t.ManageType(w, typescriptify.TypeOptions{TSType: w.Name()}) + } + for _, p := range payloads { + t.Add(p.Tx) + t.Add(p.Account) + } + for _, root := range roots { + t.Add(root) + } + body, err := t.Convert(nil) + if err != nil { + return "", err + } + body, err = applyNullable(body, nullable, t.Indent) + if err != nil { + return "", err + } + // ManageType only matches a wrapper behind a pointer field; one listed as a root or reached + // through a slice or map is still converted to an interface that would shadow the alias. + for _, w := range wrappers { + if strings.Contains(body, "export interface "+w.Name()+" {") { + return "", fmt.Errorf("union %s was also emitted as an interface; the alias would be shadowed", w.Name()) + } + } + // A root type already emitted as a nested type leaves a stray blank line behind. + body = regexp.MustCompile("\n{2,}").ReplaceAllString(body, "\n") + return header + aliases + body + "\n", nil +} + +// renderUnions emits one closed union per wrapper with a member per registered payload. +func renderUnions(wrappers []reflect.Type, payloads []bchain.ChainExtraPayload) (string, error) { + if len(payloads) == 0 { + return "", fmt.Errorf("no chainExtraData payloads registered") + } + var b strings.Builder + for _, w := range wrappers { + name := w.Name() + pick := reflect.Zero(w).Interface().(bchain.ChainExtraPayloadWrapper) + members := make([]string, 0, len(payloads)) + for _, p := range payloads { + payload := pick.ChainExtraPayload(p) + if p.Type == bchain.ChainExtraPayloadTypeUnknown || payload == nil || reflect.TypeOf(payload).Kind() != reflect.Struct { + return "", fmt.Errorf("%s: payload type %q must map to a struct", name, p.Type) + } + members = append(members, fmt.Sprintf("{ payloadType: '%s'; payload?: %s }", p.Type, reflect.TypeOf(payload).Name())) + } + fmt.Fprintf(&b, "export type %s = %s;\n", name, strings.Join(members, " | ")) + } + return b.String(), nil +} + +// nullableField identifies one `ts_nullable` field by the interface and JSON name the library emits. +type nullableField struct { + iface string + json string +} + +// scanFields walks the struct graph the same way the library does and gathers the ts_nullable +// fields and, in first-seen order, the wrapper structs that must be emitted as unions. +func scanFields(roots []interface{}) ([]nullableField, []reflect.Type, error) { + seen := map[reflect.Type]bool{} + var nullable []nullableField + var wrappers []reflect.Type + var walk func(typ reflect.Type) error + walk = func(typ reflect.Type) error { + for typ.Kind() == reflect.Ptr || typ.Kind() == reflect.Slice || typ.Kind() == reflect.Array || typ.Kind() == reflect.Map { + typ = typ.Elem() + } + if typ.Kind() != reflect.Struct || seen[typ] { + return nil + } + seen[typ] = true + if typ.Implements(wrapperIface) { + // The wrapper is replaced by its union, so its own fields are never emitted. + wrappers = append(wrappers, typ) + return nil + } + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + jsonTag := f.Tag.Get("json") + if f.Tag.Get("ts_nullable") == "true" { + if f.Type.Kind() != reflect.Ptr || strings.Contains(jsonTag, ",omitempty") { + return fmt.Errorf("%s.%s: ts_nullable requires a pointer field without omitempty", typ.Name(), f.Name) + } + nullable = append(nullable, nullableField{iface: typ.Name(), json: strings.Split(jsonTag, ",")[0]}) + } + // The library does not descend into fields overridden by ts_type, so neither do we. + if f.Tag.Get("ts_type") == "" { + if err := walk(f.Type); err != nil { + return err + } + } + } + return nil + } + for _, root := range roots { + if err := walk(reflect.TypeOf(root)); err != nil { + return nil, nil, err + } + } + return nullable, wrappers, nil +} + +// applyNullable rewrites `name?: T;` to `name: T | null;` inside the owning interface block. +func applyNullable(body string, fields []nullableField, indent string) (string, error) { + lines := strings.Split(body, "\n") + for _, nf := range fields { + if err := markNullable(lines, nf, indent); err != nil { + return "", err + } + } + return strings.Join(lines, "\n"), nil +} + +func markNullable(lines []string, nf nullableField, indent string) error { + open := "export interface " + nf.iface + " {" + prefix := indent + nf.json + "?: " + inBlock := false + for i, line := range lines { + switch { + case line == open: + inBlock = true + case inBlock && line == "}": + return fmt.Errorf("%s.%s: no optional field line found to mark nullable", nf.iface, nf.json) + case inBlock && strings.HasPrefix(line, prefix) && strings.HasSuffix(line, ";"): + tsType := strings.TrimSuffix(strings.TrimPrefix(line, prefix), ";") + lines[i] = indent + nf.json + ": " + tsType + " | null;" + return nil + } + } + return fmt.Errorf("%s: interface not found in generated output", nf.iface) } diff --git a/build/tools/typescriptify/typescriptify_test.go b/build/tools/typescriptify/typescriptify_test.go new file mode 100644 index 0000000000..60b9490405 --- /dev/null +++ b/build/tools/typescriptify/typescriptify_test.go @@ -0,0 +1,200 @@ +//go:build unittest + +package main + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/bchain" +) + +type probeInner struct { + Value string `json:"value"` +} + +type probePayloadA struct { + A string `json:"a"` +} + +type probePayloadB struct { + B string `json:"b"` +} + +type probeWrapper struct { + PayloadType string `json:"payloadType"` +} + +func (probeWrapper) ChainExtraPayload(p bchain.ChainExtraPayload) interface{} { return p.Tx } + +type probeOuter struct { + Always *probeInner `json:"always" ts_nullable:"true"` + Maybe *probeInner `json:"maybe,omitempty"` + Extra *probeWrapper `json:"extra,omitempty"` +} + +var probePayloads = []bchain.ChainExtraPayload{ + {Type: "a", Tx: probePayloadA{}, Account: probePayloadA{}}, + {Type: "b", Tx: probePayloadB{}, Account: probePayloadB{}}, +} + +type probeOmitEmpty struct { + Bad *probeInner `json:"bad,omitempty" ts_nullable:"true"` +} + +type probeNonPointer struct { + Bad probeInner `json:"bad" ts_nullable:"true"` +} + +// TestCommittedFileIsCurrent fails while blockbook-api.ts differs from what the generator emits. +func TestCommittedFileIsCurrent(t *testing.T) { + committed, err := os.ReadFile(filepath.Join("..", "..", "..", outputFile)) + if err != nil { + t.Fatal(err) + } + want, err := generate(apiTypes, bchain.ChainExtraPayloads) + if err != nil { + t.Fatal(err) + } + if string(committed) != want { + t.Fatalf("%s is stale, run 'make typescriptify'", outputFile) + } +} + +func TestGenerateNullableAndUnion(t *testing.T) { + out, err := generate([]interface{}{probeOuter{}}, probePayloads) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + header, + "export type probeWrapper = { payloadType: 'a'; payload?: probePayloadA } | { payloadType: 'b'; payload?: probePayloadB };\n", + "export interface probePayloadA {\n", + "export interface probePayloadB {\n", + "\n always: probeInner | null;\n", + "\n maybe?: probeInner;\n", + "\n extra?: probeWrapper;\n", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } +} + +func TestGenerateRejectsShadowedUnion(t *testing.T) { + if _, err := generate([]interface{}{probeWrapper{}}, probePayloads); err == nil { + t.Error("expected an error for a wrapper listed as a root") + } +} + +func TestGenerateEmitsUnionOnlyWhenReachable(t *testing.T) { + out, err := generate([]interface{}{probeInner{}}, probePayloads) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "probeWrapper") { + t.Errorf("unreachable wrapper leaked into the output:\n%s", out) + } +} + +func TestGenerateRejectsBadPayloadRegistry(t *testing.T) { + for name, payloads := range map[string][]bchain.ChainExtraPayload{ + "empty": nil, + "unknown type": {{Type: bchain.ChainExtraPayloadTypeUnknown, Tx: probePayloadA{}, Account: probePayloadA{}}}, + "non-struct": {{Type: "a", Tx: "x", Account: probePayloadA{}}}, + } { + if _, err := generate([]interface{}{probeOuter{}}, payloads); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} + +func TestGenerateRejectsMisusedNullable(t *testing.T) { + for _, root := range []interface{}{probeOmitEmpty{}, probeNonPointer{}} { + _, err := generate([]interface{}{root}, probePayloads) + if err == nil || !strings.Contains(err.Error(), "ts_nullable") { + t.Errorf("%T: expected ts_nullable misuse error, got %v", root, err) + } + } +} + +func TestMarkNullableIsScopedToInterface(t *testing.T) { + body := "\nexport interface A {\n value?: string;\n}\nexport interface B {\n value?: string;\n}" + out, err := applyNullable(body, []nullableField{{iface: "B", json: "value"}}, " ") + if err != nil { + t.Fatal(err) + } + want := "\nexport interface A {\n value?: string;\n}\nexport interface B {\n value: string | null;\n}" + if out != want { + t.Errorf("got:\n%s\nwant:\n%s", out, want) + } + if _, err := applyNullable(body, []nullableField{{iface: "B", json: "missing"}}, " "); err == nil { + t.Error("expected error for a field the interface does not have") + } +} + +func TestTransactionMemoMatchesJSONWireType(t *testing.T) { + memo := []byte{0, 1, 2, 0xff, 0x0a} + encoded, err := json.Marshal(api.Tx{Memo: memo}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatal(err) + } + var wireMemo string + if err := json.Unmarshal(fields["memo"], &wireMemo); err != nil { + t.Fatalf("transaction memo must be a JSON string: %v", err) + } + if want := base64.StdEncoding.EncodeToString(memo); wireMemo != want { + t.Fatalf("transaction memo = %q, want base64 %q", wireMemo, want) + } + out, err := generate([]interface{}{api.Tx{}}, bchain.ChainExtraPayloads) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "\n memo?: string;\n") { + t.Fatal("generated transaction memo does not match its optional JSON string representation") + } +} + +func TestSyscoinTokenStandardsMatchGeneratedTypes(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + standard bchain.TokenStandardName + }{ + {"Token", api.Token{}, bchain.SPTTokenType}, + {"TokenTransfer", api.TokenTransfer{}, bchain.TokenStandardName("SPT")}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := generate([]interface{}{tc.root}, bchain.ChainExtraPayloads) + if err != nil { + t.Fatal(err) + } + start := strings.Index(out, "export interface "+tc.name+" {\n") + if start < 0 { + t.Fatalf("missing %s interface", tc.name) + } + body := strings.SplitN(out[start:], "\n}", 2)[0] + for _, field := range []string{"type", "standard"} { + found := false + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), field+":") { + found = strings.Contains(line, "'"+string(tc.standard)+"'") + break + } + } + if !found { + t.Errorf("%s.%s excludes emitted Syscoin value %q", tc.name, field, tc.standard) + } + } + }) + } +} diff --git a/changelog.md b/changelog.md index aa630fa97c..e7fefa230f 100644 --- a/changelog.md +++ b/changelog.md @@ -11,23 +11,73 @@ - **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. +- **Memory optimizations** ([#1479](https://github.com/trezor/blockbook/pull/1479)): addresses agent-discovered heap-pressure findings, reducing overall memory footprint. +- **Skip mempool tx body loading for AccountDetailsBasic** ([#1499](https://github.com/trezor/blockbook/pull/1499)): drops per-tx body fetch for `details=basic` WebSocket requests; reports `unconfirmedTxs` from cheap mempool-index length only, saving significant RPC work on mempool-heavy addresses. +- **Accumulate address-hotness hits across blocks** ([#1529](https://github.com/trezor/blockbook/pull/1529)): makes the contract-index LRU promotion counter persist across blocks instead of resetting per block, so hot-address detection works on lower-throughput chains too. +- **Websocket communication optimizations** ([#1550](https://github.com/trezor/blockbook/pull/1550)): reuses the xpub best-height within a request across derived addresses, adds a WebSocket write-buffer pool, reduces subscription lock hold time, and caches merged confirmed xpub txids for unfiltered paged requests. +- **Resolve tx inputs without unpacking full prev-tx records** ([#1595](https://github.com/trezor/blockbook/pull/1595)): reads only the single needed output per input instead of unpacking the entire previous transaction, eliminating quadratic heap allocation on high-fan-in addresses. ### Reliability and Correctness +- **Drop internal transfers from failed call frames (EVM & Tron)** ([#1621](https://github.com/trezor/blockbook/issues/1621)): a failed frame and its whole subtree (including a reverted root) no longer produce phantom internal transfers, CREATE or SELFDESTRUCT records in the address index and balance history. - **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. +- **Fix Nillion (NIL) token decimals** ([#1443](https://github.com/trezor/blockbook/pull/1443)): adds the Nillion `NIL` ERC-20 contract to the contract-fix config with `decimals: 6`, correcting misscaled balance/transfer amounts. +- **Don't lowercase Base58 Tron token addresses for fiat rates** ([#1458](https://github.com/trezor/blockbook/pull/1458)): stops case-sensitive Base58 Tron contract addresses from being lowercased during fiat-rate lookups (which broke their rate resolution), while hex EVM tokens keep lowercase normalization. +- **Skip ZMQ initialization when message_queue_binding is empty** ([#1460](https://github.com/trezor/blockbook/pull/1460)): enables ZMQ-less operation with zebrad by skipping zmq_connect("") instead of returning EINVAL. +- **Fix WebSocket GetBlock pagination defaults** ([#1476](https://github.com/trezor/blockbook/pull/1476)): replaces the 1M-transaction default page size with a sane bound matching the REST interface, addressing security alerts. +- **Accept BTC non-standard tx versions outside int32 range** ([#1478](https://github.com/trezor/blockbook/pull/1478)): decodes `getrawtransaction` JSON version as uint32 to fix "Transaction not found" for historical bitcoin transactions whose version exceeds int32 max. +- **Retry unfinalized RPC responses** ([#1489](https://github.com/trezor/blockbook/pull/1489)): maps Avalanche "cannot query unfinalized data" errors to `ErrBlockNotFound` instead of swallowing them, preventing trace-length mismatches during sync. +- **Fix fiat rates: change platform currency from eth to usd** ([#1495](https://github.com/trezor/blockbook/pull/1495)): corrects the fiat-rate query currency for token rates; requires running `reset_eth_token_rates.sh` after deploy. +- **Stop ERC20 batch fallback from amplifying RPC calls** ([#1501](https://github.com/trezor/blockbook/pull/1501)): prevents a single bad batch response from triggering per-contract `eth_call` fallbacks that amplified failed requests into N+1 RPCs. +- **Deduplicate address descriptors and fix confirmed-block matching** ([#1508](https://github.com/trezor/blockbook/pull/1508)): deduplicates subscriber addresses before counting and restricts confirmed-block tx conversion to `publishNewBlockTxs` subscribers, reducing expensive receipt RPCs. +- **Retry transient missing-tip block fetches before ResyncIndex** ([#1517](https://github.com/trezor/blockbook/pull/1517)): retries transient `ErrBlockNotFound` inside the sequential sync loop instead of unwinding to the full `ResyncIndex` path, preventing slowdowns on load-balanced RPC tip skew. +- **Fix NFT self-transfer reorg rollback** ([#1525](https://github.com/trezor/blockbook/pull/1525)): corrects ERC-721/1155 ownership rollback when a self-transfer block is disconnected during reorg. +- **Fix config propagation duck-typing issue** ([#1528](https://github.com/trezor/blockbook/pull/1528)): fixes a structural-typing mismatch that prevented certain config values from being propagated. +- **Prevent sync stalls on load-balanced EVM RPC skew** ([#1530](https://github.com/trezor/blockbook/pull/1530)): makes EVM sync tolerate load-balanced backend tip height inconsistencies and prevents the parallel/bulk coordinator from wedging when workers report abort conditions. +- **Heal silently-stalled EVM newHeads tip feeds** ([#1533](https://github.com/trezor/blockbook/pull/1533)): adds a chain-aware staleness watchdog that detects when the push feed goes silent without raising `sub.Err()` and heals it, preventing undetected sync stalls. +- **Make CoinGecko fiat-rate fetching resilient to 429 throttling** ([#1560](https://github.com/trezor/blockbook/pull/1560)): adds a self-healing pass for missing daily rates at startup and improves resiliency of fiat rates fetching. +- **Reconcile the alternative-provider send-tx mempool cache** ([#1562](https://github.com/trezor/blockbook/pull/1562)): re-checks liveness and heals nonce-superseded entries in the self-maintained alternative/private-relay send-tx cache (which relays don't expose for sync), with new metrics; no API/wire change. +- **Keep alt-mempool txs visible when private relay stops surfacing them** ([#1572](https://github.com/trezor/blockbook/pull/1572)): defers eviction of alt-mempool txs to the absolute cache timeout when `eth_getTransactionByHash` returns empty from private relays, preventing premature disappearance. +- **Tron: polling-only tip sync fallback when ZeroMQ is unavailable** ([#1578](https://github.com/trezor/blockbook/pull/1578)): adds a polling fallback for Tron tip sync when the node runs the MongoDB event plugin instead of the native ZMQ queue. +- **Always serialize token decimals, default unresolved to coin convention** ([#1579](https://github.com/trezor/blockbook/pull/1579)): removes `omitempty` from token decimals so clients can distinguish 0-decimal tokens from missing metadata; defaults unresolved values to 18 for ERC-20. +- **Tron: keep receipt logs from unsolidified blocks** ([#1583](https://github.com/trezor/blockbook/pull/1583)): retains receipt data for not-yet-solidified Tron blocks so token transfers are indexed immediately instead of waiting for solidification. +- **Send a Blockbook user-agent on CoinGecko requests** ([#1589](https://github.com/trezor/blockbook/pull/1589)): adds a `User-Agent` header to CoinGecko fiat-rate requests to avoid being blocked/throttled, complementing the 429-resilience work in #1560. +- **Prevent panic parsing non-32-byte-aligned EVM input data** ([#1591](https://github.com/trezor/blockbook/pull/1591)): fixes an index-out-of-range panic when transaction input data is not aligned to 32-byte words. +- **Disable mempool sync on remaining L2 configs** ([#1599](https://github.com/trezor/blockbook/pull/1599)): sets `disableMempoolSync: true` for OP Stack and Arbitrum configurations where the backend does not support `newPendingTransactions` subscriptions. +- **Make `--repair` honest about what it can and cannot fix** ([#1413](https://github.com/trezor/blockbook/issues/1413)): `--repair` now performs a proper physical RocksDB repair, reopens the database with all discovered column families (matching production per-CF options), and reports failures with a non-zero exit code instead of always exiting 0. It refuses to certify a database left in inconsistent state by an interrupted initial/bulk import (which is unrecoverable by repair and must be recreated); the new `-forcerepair` flag can force such a database into open state so blockbook can start, with loud warnings that the index may remain permanently incomplete (normal sync resumes forward from the best height and does not backfill blocks below it) and that a full recreate is the only guaranteed fix. Column families dropped by the repair are reported rather than silently accepted, without aborting on stale `--computedbstats` counts. +- **Avoid int64 overflow in on-chain base fee conversion** ([#1605](https://github.com/trezor/blockbook/pull/1605)): uses `big.Int.SetUint64` for `baseFeePerGas` instead of int64 cast, preventing negative values on high-fee L2s. +- **Fix PIVX Sapling txid computation** ([#1607](https://github.com/trezor/blockbook/pull/1607)): computes the correct canonical txid for PIVX Sapling transactions (v>=3) by hashing the entire raw transaction bytes instead of just the decoded portion. +- **Recover GetTransaction on archive backends with pruned tx index** ([#1610](https://github.com/trezor/blockbook/pull/1610)): falls back to reconstructing the transaction from its receipt and block body when `eth_getTransactionByHash` returns null on backends with pruned transaction hash indexes. +- **Prevent slice-bounds panic from ENS log name-length overflow** ([#1613](https://github.com/trezor/blockbook/pull/1613)): guards against a crafted ENS `NameRegistered` log causing a `low > high` slice expression, fixing an unauthenticated availability DoS that could crash the EVM sync goroutine. +- **Prevent duplicate ERC-721 holdings** ([#1624](https://github.com/trezor/blockbook/pull/1624)): makes ERC-721 holding insertion idempotent so a replayed `Transfer` log can't credit the same token ID twice, preserves holdings across self-transfers and their reorg rollback, and adds a read-only RocksDB checker that reports (but never repairs) existing duplicates. +- **Gate alternative-provider nonce lookups to recent private senders** ([#1628](https://github.com/trezor/blockbook/pull/1628)): stops routing every `eth_getTransactionCount` to the alternative provider when `*_ALTERNATIVE_SENDTX_URLS` is set, restricting it to senders with a recent privately submitted tx — avoiding provider rate-quota exhaustion, 429 log floods, and stale pending nonces. ### 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. +- **Override Message Queue URL from environment variables** ([#1468](https://github.com/trezor/blockbook/pull/1468)): allows overriding the message queue connection URL via environment variables without modifying coin config. +- **Configurable debouncedelay; increased defaults for Polygon and Arbitrum** ([#1475](https://github.com/trezor/blockbook/pull/1475)): makes the newHeads debounce delay configurable and increases defaults for Polygon and Arbitrum to tolerate frequent short reorgs; adds explicit `trace_timeout` for heavy `debug_traceBlockByHash` calls. +- **Decrease mempool timeout for MEV-protected coins** ([#1500](https://github.com/trezor/blockbook/pull/1500)): reduces the stale-tx eviction timeout for alternative/private relay mempool entries that expire faster than the public mempool. +- **Runtime env via optional EnvironmentFile** ([#1574](https://github.com/trezor/blockbook/pull/1574)): adds an optional `EnvironmentFile=/etc/blockbook/blockbook.env` to the systemd unit so runtime config no longer requires hand-editing `/etc/systemd/system.conf`. +- **Extend rate limiting to cover all dynamic routes** ([#1588](https://github.com/trezor/blockbook/pull/1588)): replaces the `/api`-only rate-limit allowlist with a deny-all-except-static gate, covering explorer UI routes alongside REST API routes. +- **Tune REST/UI rate-limit defaults for individual user traffic** ([#1590](https://github.com/trezor/blockbook/pull/1590)): adjusts per-client rate-limit defaults to 20 req/min with burst 20, matching individual browsing patterns rather than high-throughput clients. +- **Make WebSocket per-connection pending-request limit configurable** ([#1626](https://github.com/trezor/blockbook/pull/1626)): exposes the previously hard-coded `maxWebsocketPendingRequests` (48) as a per-coin env knob so self-hosted batch consumers pipelining many requests over one connection are no longer disconnected mid-batch. +- **Configurable per-coin xpub expansion and cache caps** ([#1630](https://github.com/trezor/blockbook/pull/1630)): replaces the hard-coded xpub cache-size, expiration, and max-derivation constants with a per-coin `XpubConfig`, and raises the default cache to 1024 entries to stop thrashing under many concurrent users. ### 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. +- **Metric for alternative fee-provider requests** ([#1485](https://github.com/trezor/blockbook/pull/1485)): adds a Prometheus counter tracking alternative fee-provider request outcomes, labeled by provider and status. +- **Graceful consensus node monitoring degradation** ([#1488](https://github.com/trezor/blockbook/pull/1488)): marks the consensus node as `"unreachable-locally"` on dev instances when not running locally, reducing maintenance overhead while preserving production visibility. +- **Observe JSON-RPC calls for block sync** ([#1527](https://github.com/trezor/blockbook/pull/1527)): adds error-request metrics for JSON-RPC calls made during block sync. +- **Auto-generated Grafana dashboard from source of truth** ([#1541](https://github.com/trezor/blockbook/pull/1541)): generates the Grafana dashboard from `metrics.yaml` and `panels.yaml` to eliminate duplication; adds four new WebSocket abuse-observability metrics (connection requests, rejections, unique IPs, per-IP connection count). +- **Correct stale EIP-1559 max-fee panel** ([#1596](https://github.com/trezor/blockbook/pull/1596)): fixes an incorrect instant-tier claim in the EIP-1559 max-fee Grafana panel description. +- **Sync RPC latency metric** ([#1601](https://github.com/trezor/blockbook/pull/1601)): adds a Prometheus latency histogram for sync-internal RPC calls. ### Fiat Pipeline @@ -36,6 +86,14 @@ ### 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. +- **Tron E2E API tests** ([#1440](https://github.com/trezor/blockbook/pull/1440)): adds E2E tests for Tron API endpoints. +- **Ethereum Classic integration tests** ([#1454](https://github.com/trezor/blockbook/pull/1454)): adds integration tests covering Ethereum Classic RPC and API endpoints. +- **ERC-4626 E2E tests** ([#1511](https://github.com/trezor/blockbook/pull/1511)): adds E2E tests for the ERC-4626 vault enrichment protocol. +- **OpenAPI specification + swagger endpoint with TS-compiled verification** ([#1523](https://github.com/trezor/blockbook/pull/1523)): introduces an `openapi.yaml` spec served via swagger-ui, separates E2E tests from integration tests, and adds CI/CD verification that `blockbook-api.ts` is type-compatible. +- **Extend E2E suite with fiat rate and WebSocket tests** ([#1539](https://github.com/trezor/blockbook/pull/1539)): adds fiat-rate freshness and validity checks plus WebSocket block/balance-history/tx tests; produces jUnit XML test reports for CI visibility. +- **System check for local test prerequisites** ([#1564](https://github.com/trezor/blockbook/pull/1564)): adds a pre-check that validates the local environment meets testing requirements, with clear error messages for missing dependencies. +- **Testnet integration/E2E tests** ([#1575](https://github.com/trezor/blockbook/pull/1575)): adds integration and post-deploy E2E coverage for Sepolia, Hoodi, and Nile testnets; fixes a matchable-name collision that caused sibling testnets to share one subtest name. +- **Parallelize E2E test suite** ([#1601](https://github.com/trezor/blockbook/pull/1601)): runs E2E tests across coins in parallel, adds proactive sample preloading and persistent WebSocket connections. ### Security @@ -43,6 +101,13 @@ - **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`. +- **Limit fiat timestamp requests** ([#1520](https://github.com/trezor/blockbook/pull/1520)): adds a 1000-timestamp maximum for fiat-rate batch lookups and a global WebSocket active-request limit. +- **Limit xpub descriptor expansion** ([#1521](https://github.com/trezor/blockbook/pull/1521)): bounds xpub change-list expansion before account scans, caps the in-memory xpub cache size, and evicts LRU entries. +- **Fix XSS in xpub explorer descriptor handling** ([#1552](https://github.com/trezor/blockbook/pull/1552)): anchors the xpub descriptor regex with a terminal `$` and avoids rendering user-supplied strings through `template.JSStr` in the QR-code JavaScript expression. +- **CI/CD security hardening** ([#1553](https://github.com/trezor/blockbook/pull/1553)): pins actions and dependencies to full commit SHAs, adds dependabot, verifies Go tarball and backend artifact checksums, restricts workflow permissions, and adds opt-in TLS verification for deploy scripts. +- **Upper-cap limits on WebSocket endpoints** ([#1582](https://github.com/trezor/blockbook/pull/1582)): caps in-flight mempool-filters responses per connection, bounds fiat-rate currency selectors, and hardens ERC-1155 `TransferBatch` log parsing against OOM from malformed data. +- **Quote paths in root logrotate cleanup** ([#1602](https://github.com/trezor/blockbook/pull/1602)): prevents arbitrary recursive deletion by quoting log paths in the root cron logrotate script, fixing a local privilege-boundary issue reachable from the Blockbook service user. +- **Cap subscribeFiatRates tokens array length** ([#1623](https://github.com/trezor/blockbook/pull/1623)): adds a 1000-token maximum for `subscribeFiatRates` WebSocket requests, consistent with existing caps on other list parameters. ### New Features and Chain Support @@ -51,7 +116,45 @@ - **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`. +- **Tron: split bandwidth into staked and free fields** ([#1444](https://github.com/trezor/blockbook/pull/1444)): separates Tron account bandwidth into `staked` and `free` fields for clearer resource accounting. +- **Tron: tx balance history maps freezing, unfreezing and voting rewards** ([#1463](https://github.com/trezor/blockbook/pull/1463)): correctly maps Tron freeze/unfreeze operations and voting rewards as incoming/outgoing in balance history. +- **Tron: recognize account activation transactions** ([#1466](https://github.com/trezor/blockbook/pull/1466)): adds `AccountCreateContract` parsing so Tron account-activation transactions are no longer shown as unparsed. +- **Tron: expose staking and voting data in account API** ([#1467](https://github.com/trezor/blockbook/pull/1467)): implements `TronStakingInfo` in `TronAccountExtraData` exposing staking and governance data via the account API. +- **EVM getContractInfo endpoint** ([#1477](https://github.com/trezor/blockbook/pull/1477)): adds a single-contract lookup endpoint so clients can refresh ERC-4626 vault data and fiat pricing without reloading full `accountInfo`; unifies protocol enrichment shape between `getAccountInfo` and `getContractInfo`. +- **WebSocket connection caps** ([#1483](https://github.com/trezor/blockbook/pull/1483)): adds per-IP connection limits with configurable trusted-proxy origin detection for more reliable client-IP attribution. +- **Stateful ERC-4626 persistent cache with multicall batching** ([#1502](https://github.com/trezor/blockbook/pull/1502)): replaces per-request sequential `eth_call`s with a block-pinned, singleflight-deduped, reorg-safe persistent cache using Multicall3 batch aggregation; eliminates virtually all RPC overhead for vault enrichment. +- **Tron: extend account data with total energy and bandwidth** ([#1547](https://github.com/trezor/blockbook/pull/1547)): adds total energy and total bandwidth to `TronAccountExtraData`. +- **Expose confirmed nonce on address API** ([#1562](https://github.com/trezor/blockbook/pull/1562)): adds the confirmed account nonce to the address API response for EVM chains. +- **Rate-limit WebSocket and REST API traffic** ([#1563](https://github.com/trezor/blockbook/pull/1563)): adds application-level abuse protection with per-connection WS message-rate limits, per-client REST request throttling, shared trusted-proxy attribution, and bounded balance-history queries per transport. +- **EVM block-gas data for EIP-1559 fee projection** ([#1586](https://github.com/trezor/blockbook/pull/1586)): adds `baseFeePerGas`, `blockGasUsed`, and `blockGasLimit` to `subscribeNewBlock` notifications so wallets can project EIP-1559 base fees deterministically. +- **Method-selector allowlist for rpcCall** ([#1597](https://github.com/trezor/blockbook/pull/1597)): adds `ALLOWED_EVM_CALL_METHODS` configuration allowing `rpcCall` to invoke whitelisted 4-byte selectors on any address; runtime-configurable via admin API. +- **Tron: expose latestWithdrawTime in staking info** ([#1604](https://github.com/trezor/blockbook/pull/1604)): adds `latestWithdrawTime` parsed from `latest_withdraw_time` to Tron account staking information. ### Backend Compatibility - **Adjusted ZebraRPC for new zebrad backend version** ([#1377](https://github.com/trezor/blockbook/pull/1377)). +- **Update Firo daemon to 0.14.15.3** ([#1432](https://github.com/trezor/blockbook/pull/1432)). +- **Upgrade Ethereum erigon/prysm to latest versions** ([#1455](https://github.com/trezor/blockbook/pull/1455)): mainnet; ([#1462](https://github.com/trezor/blockbook/pull/1462)): Sepolia testnet. +- **Zebra upgrades** ([#1504](https://github.com/trezor/blockbook/pull/1504)): 4.4.1; ([#1532](https://github.com/trezor/blockbook/pull/1532)): 4.5.0; ([#1544](https://github.com/trezor/blockbook/pull/1544)): 5.0.0. +- **Bcash 28.0.1 → 29.0.0** ([#1505](https://github.com/trezor/blockbook/pull/1505)). +- **Zcash testnet: migrate to Zebra 5.0.0 for NU6.2** ([#1548](https://github.com/trezor/blockbook/pull/1548)). +- **Tron testnet backend 4.8.2** ([#1611](https://github.com/trezor/blockbook/pull/1611)). +- **Bitcoin Core 29.2 → 31.1 (mainnet, testnet3, testnet4, signet, regtest)** ([#1809](https://github.com/trezor/blockbook/pull/1809)): moves off the 29.x line before it goes EOL with 32.0 and drops the stale `mempoolfullrbf` (removed in Core 29.0), `deprecatedrpc=warnings` and `deprecatedrpc=estimatefee` options from the bitcoind configs. + +### Removed + +- **Remove obsolete Socket.IO interface** ([#1487](https://github.com/trezor/blockbook/pull/1487)): drops the deprecated Socket.IO transport, no longer used by any client. + +## Contributors + +* @pragmaxim https://github.com/trezor/blockbook/pull/1383, https://github.com/trezor/blockbook/pull/1385, https://github.com/trezor/blockbook/pull/1388, https://github.com/trezor/blockbook/pull/1392, https://github.com/trezor/blockbook/pull/1398, https://github.com/trezor/blockbook/pull/1400, https://github.com/trezor/blockbook/pull/1403, https://github.com/trezor/blockbook/pull/1407, https://github.com/trezor/blockbook/pull/1408, https://github.com/trezor/blockbook/pull/1409, https://github.com/trezor/blockbook/pull/1417, https://github.com/trezor/blockbook/pull/1420, https://github.com/trezor/blockbook/pull/1421, https://github.com/trezor/blockbook/pull/1423, https://github.com/trezor/blockbook/pull/1424, https://github.com/trezor/blockbook/pull/1426, https://github.com/trezor/blockbook/pull/1429, https://github.com/trezor/blockbook/pull/1431, https://github.com/trezor/blockbook/pull/1434, https://github.com/trezor/blockbook/pull/1440, https://github.com/trezor/blockbook/pull/1454, https://github.com/trezor/blockbook/pull/1455, https://github.com/trezor/blockbook/pull/1462, https://github.com/trezor/blockbook/pull/1468, https://github.com/trezor/blockbook/pull/1475, https://github.com/trezor/blockbook/pull/1476, https://github.com/trezor/blockbook/pull/1477, https://github.com/trezor/blockbook/pull/1478, https://github.com/trezor/blockbook/pull/1479, https://github.com/trezor/blockbook/pull/1483, https://github.com/trezor/blockbook/pull/1488, https://github.com/trezor/blockbook/pull/1489, https://github.com/trezor/blockbook/pull/1499, https://github.com/trezor/blockbook/pull/1500, https://github.com/trezor/blockbook/pull/1501, https://github.com/trezor/blockbook/pull/1502, https://github.com/trezor/blockbook/pull/1508, https://github.com/trezor/blockbook/pull/1511, https://github.com/trezor/blockbook/pull/1517, https://github.com/trezor/blockbook/pull/1520, https://github.com/trezor/blockbook/pull/1521, https://github.com/trezor/blockbook/pull/1523, https://github.com/trezor/blockbook/pull/1525, https://github.com/trezor/blockbook/pull/1528, https://github.com/trezor/blockbook/pull/1529, https://github.com/trezor/blockbook/pull/1530, https://github.com/trezor/blockbook/pull/1533, https://github.com/trezor/blockbook/pull/1541, https://github.com/trezor/blockbook/pull/1552, https://github.com/trezor/blockbook/pull/1553, https://github.com/trezor/blockbook/pull/1562, https://github.com/trezor/blockbook/pull/1563, https://github.com/trezor/blockbook/pull/1564, https://github.com/trezor/blockbook/pull/1572, https://github.com/trezor/blockbook/pull/1574, https://github.com/trezor/blockbook/pull/1575, https://github.com/trezor/blockbook/pull/1579, https://github.com/trezor/blockbook/pull/1586, https://github.com/trezor/blockbook/pull/1589, https://github.com/trezor/blockbook/pull/1590, https://github.com/trezor/blockbook/pull/1591, https://github.com/trezor/blockbook/pull/1596, https://github.com/trezor/blockbook/pull/1597, https://github.com/trezor/blockbook/pull/1599, https://github.com/trezor/blockbook/pull/1601, https://github.com/trezor/blockbook/pull/1602, https://github.com/trezor/blockbook/pull/1605, https://github.com/trezor/blockbook/pull/1607, https://github.com/trezor/blockbook/pull/1610, https://github.com/trezor/blockbook/pull/1613, https://github.com/trezor/blockbook/pull/1630 +* @cranycrane https://github.com/trezor/blockbook/pull/1273, https://github.com/trezor/blockbook/pull/1458, https://github.com/trezor/blockbook/pull/1463, https://github.com/trezor/blockbook/pull/1466, https://github.com/trezor/blockbook/pull/1467, https://github.com/trezor/blockbook/pull/1485, https://github.com/trezor/blockbook/pull/1487, https://github.com/trezor/blockbook/pull/1495, https://github.com/trezor/blockbook/pull/1504, https://github.com/trezor/blockbook/pull/1505, https://github.com/trezor/blockbook/pull/1527, https://github.com/trezor/blockbook/pull/1532, https://github.com/trezor/blockbook/pull/1539, https://github.com/trezor/blockbook/pull/1544, https://github.com/trezor/blockbook/pull/1547, https://github.com/trezor/blockbook/pull/1550, https://github.com/trezor/blockbook/pull/1560, https://github.com/trezor/blockbook/pull/1582, https://github.com/trezor/blockbook/pull/1583, https://github.com/trezor/blockbook/pull/1588, https://github.com/trezor/blockbook/pull/1611, https://github.com/trezor/blockbook/pull/1623, https://github.com/trezor/blockbook/pull/1624, https://github.com/trezor/blockbook/pull/1628 +* @etimofeeva https://github.com/trezor/blockbook/pull/1289, https://github.com/trezor/blockbook/pull/1363, https://github.com/trezor/blockbook/pull/1377, https://github.com/trezor/blockbook/pull/1397, https://github.com/trezor/blockbook/pull/1443 +* @matusbalascak https://github.com/trezor/blockbook/pull/1444, https://github.com/trezor/blockbook/pull/1604 +* @Aharonee https://github.com/trezor/blockbook/pull/1595 +* @emersonian https://github.com/trezor/blockbook/pull/1402 +* @h45hc47 https://github.com/trezor/blockbook/pull/1578 +* @justanwar https://github.com/trezor/blockbook/pull/1432 +* @knaperek https://github.com/trezor/blockbook/pull/1626 +* @sneurlax https://github.com/trezor/blockbook/pull/1548 +* @tuan-phan https://github.com/trezor/blockbook/pull/1460 diff --git a/common/internalstate.go b/common/internalstate.go index 1964d287c4..77674c13fc 100644 --- a/common/internalstate.go +++ b/common/internalstate.go @@ -34,6 +34,7 @@ type InternalStateColumn struct { // BackendInfo is used to get information about blockchain type BackendInfo struct { + // served to clients on /api/ and /, so producers store it with URLs redacted (RedactURLs) 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."` @@ -68,13 +69,21 @@ type InternalState struct { // true if application is with flag --sync 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."` + // InitialSync is a runtime flag, deliberately not persisted (json:"-"). A SIGTERM + // during the initial build would otherwise store initialSync=true, and a subsequent + // start without --sync never clears it, latching blockbook_initial_sync at 1 and + // permanently gating off any alert that excludes reindexes. A --sync run always sets + // it explicitly on startup, so nothing of value is lost by not persisting it. + InitialSync bool `json:"-" ts_doc:"If true, the system is in the initial sync phase (not persisted)."` 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)."` + // AvgBlockPeriodSeconds is the same average in fractional seconds; the integer form + // above truncates to 0 on sub-second chains. See computeAvgBlockPeriod. + AvgBlockPeriodSeconds float64 `json:"-" ts_doc:"Average time (in fractional 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."` @@ -91,7 +100,13 @@ type InternalState struct { 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)."` + // BackendTipLastAdvance and BackendTipHeight are persisted so the derived + // blockbook_tip_age_seconds gauge survives a restart. BackendInfo itself is not + // persisted, so comparing against it would have made every process start look like + // a fresh tip advance and reset the age to zero - which is exactly what happened + // during a crash loop, leaving the gauge pinned near zero for a 17-hour stall. + BackendTipLastAdvance time.Time `json:"backendTipLastAdvance,omitempty" ts_doc:"Wall-clock time when the backend's block height was last observed to change."` + BackendTipHeight int `json:"backendTipHeight,omitempty" ts_doc:"Backend block height observed at BackendTipLastAdvance."` // database migrations UtxoChecked bool `json:"utxoChecked" ts_doc:"Indicates if UTXO consistency checks have been performed."` @@ -119,6 +134,67 @@ type InternalState struct { // shared /64. Guarded by its own mutex (consulted on every connection attempt). wsBlockMux sync.Mutex wsBlockedIPs map[string]*WsBlockedIP + + // rpcCallAllowlists is the effective websocket rpcCall allowlist snapshot, + // resolved from the DB runtime-setting overrides and the environment + // defaults (see server initRpcCallAllowlists). Unexported so it is never + // serialized by Pack; replaced wholesale via the accessors below, giving + // the rpcCall hot path a lock-free, consistent view. + rpcCallAllowlists atomic.Pointer[RpcCallAllowlists] +} + +// Sources of a runtime setting value, reported by the /admin runtime-settings +// interface. +const ( + RuntimeSettingSourceUnset = "unset" + RuntimeSettingSourceEnv = "env" + RuntimeSettingSourceDB = "db" +) + +// RpcCallAllowlists is an immutable snapshot of the websocket rpcCall +// allowlists. Readers must not mutate the maps; writers build a new snapshot +// and replace it via SetRpcCallAllowlists. +type RpcCallAllowlists struct { + // To and Methods are the parsed allowlists; a nil map means that dimension + // is unconfigured. With both nil, rpcCall is unrestricted. + // + // Each dimension's key format must match what the rpcCall check looks up + // (server rpcCallAllowed), and the two deliberately differ: + // - To keys are the configured entries trimmed and lowercased verbatim — + // in practice 0x-prefixed hex addresses, because they are matched + // against the lowercased `to` field of the request as sent by clients. + // - Methods keys are 4-byte selectors as 8 lowercase hex characters + // without the 0x prefix, because they are matched against selectors + // hex-decoded from the request calldata (server evmCallSelector). + // A future dimension should document its key format here and keep the + // parser (server runtimeSettingDefs) and the rpcCall lookup in lockstep. + To map[string]struct{} + Methods map[string]struct{} + // Raw comma-separated values and their sources (unset/env/db), kept for + // the admin interface and logging. + ToValue string + MethodsValue string + ToSource string + MethodsSource string +} + +// GetRpcCallAllowlists returns the current rpcCall allowlist snapshot, nil +// when not yet initialized. +func (is *InternalState) GetRpcCallAllowlists() *RpcCallAllowlists { + return is.rpcCallAllowlists.Load() +} + +// SetRpcCallAllowlists atomically replaces the rpcCall allowlist snapshot. +func (is *InternalState) SetRpcCallAllowlists(a *RpcCallAllowlists) { + is.rpcCallAllowlists.Store(a) +} + +// InitRpcCallAllowlists publishes the initial snapshot only when none exists +// yet and reports whether it did. The compare-and-swap keeps a snapshot that +// was already published — possibly updated by the admin interface in the +// meantime — intact when several server constructors initialize in any order. +func (is *InternalState) InitRpcCallAllowlists(a *RpcCallAllowlists) bool { + return is.rpcCallAllowlists.CompareAndSwap(nil, a) } // WsBlockedIP records a websocket client key (IPv4 address or full IPv6 /128) @@ -163,6 +239,22 @@ func (is *InternalState) FinishedSyncNoChange() { is.IsSynchronized = true } +// GetInitialSync reports whether the initial index build (or a full reindex) is running. +// InitialSync is written from main while the sync loop and the metrics refresh read it, so +// both sides go through the lock. +func (is *InternalState) GetInitialSync() bool { + is.mux.Lock() + defer is.mux.Unlock() + return is.InitialSync +} + +// SetInitialSync records whether the initial index build is running. +func (is *InternalState) SetInitialSync(initialSync bool) { + is.mux.Lock() + defer is.mux.Unlock() + is.InitialSync = initialSync +} + // GetSyncState gets the state of synchronization func (is *InternalState) GetSyncState() (bool, uint32, time.Time, time.Time) { is.mux.Lock() @@ -170,6 +262,17 @@ func (is *InternalState) GetSyncState() (bool, uint32, time.Time, time.Time) { return is.IsSynchronized, is.BestHeight, is.LastSync, is.StartSync } +// GetSyncMetricsSnapshot returns every value RefreshSyncMetrics needs under a single lock +// acquisition. Taking BackendInfo, InitialSync and the sync state through three separate +// locked accessors let a concurrent updateBackendInfo()/FinishedSync() land between them, +// so the gap between a pre-resync backend height and a post-resync indexed height could +// tear and flip the published gauge for one sample. +func (is *InternalState) GetSyncMetricsSnapshot() (backendInfo BackendInfo, initialSync, isSynchronized bool, bestHeight uint32, lastSync, startSync time.Time) { + is.mux.Lock() + defer is.mux.Unlock() + return is.BackendInfo, is.InitialSync, is.IsSynchronized, is.BestHeight, is.LastSync, is.StartSync +} + // StartedMempoolSync signals start of mempool synchronization func (is *InternalState) StartedMempoolSync() { is.mux.Lock() @@ -263,8 +366,11 @@ func (is *InternalState) GetLastBlockTime() uint32 { return 0 } -// SetBlockTimes initializes BlockTimes array, returns AvgBlockPeriod -func (is *InternalState) SetBlockTimes(blockTimes []uint32) uint32 { +// SetBlockTimes initializes BlockTimes array and returns the average block period in +// fractional seconds (the value the metric uses); the integer form truncates sub-second +// chains to 0. Returning it here keeps the single lock acquisition instead of forcing the +// caller to re-enter GetAvgBlockPeriodSeconds. +func (is *InternalState) SetBlockTimes(blockTimes []uint32) float64 { is.mux.Lock() defer is.mux.Unlock() if len(is.BlockTimes) < len(blockTimes) { @@ -275,11 +381,14 @@ func (is *InternalState) SetBlockTimes(blockTimes []uint32) uint32 { } is.computeAvgBlockPeriod() glog.Info("set ", len(is.BlockTimes), " block times, average block period ", is.AvgBlockPeriod, "s") - return is.AvgBlockPeriod + return is.AvgBlockPeriodSeconds } -// SetBlockTime sets block time to BlockTimes, allocating the slice as necessary, returns AvgBlockPeriod -func (is *InternalState) SetBlockTime(height uint32, time uint32) uint32 { +// SetBlockTime sets block time to BlockTimes, allocating the slice as necessary, and +// returns the average block period in fractional seconds (see SetBlockTimes). This is the +// hottest write path, so it computes and returns the value under the same lock the write +// already holds rather than making the caller take the lock a second time. +func (is *InternalState) SetBlockTime(height uint32, time uint32) float64 { is.mux.Lock() defer is.mux.Unlock() if int(height) >= len(is.BlockTimes) { @@ -291,7 +400,7 @@ func (is *InternalState) SetBlockTime(height uint32, time uint32) uint32 { is.BlockTimes[height] = time } is.computeAvgBlockPeriod() - return is.AvgBlockPeriod + return is.AvgBlockPeriodSeconds } // RemoveLastBlockTimes removes last times from BlockTimes @@ -335,14 +444,37 @@ func (is *InternalState) GetAvgBlockPeriod() uint32 { return is.AvgBlockPeriod } -// computeAvgBlockPeriod returns computes average of the last 100 blocks in seconds +// GetAvgBlockPeriodSeconds returns the same observed average as GetAvgBlockPeriod but in +// fractional seconds. 0 means "not yet computed" - fewer than avgBlockPeriodSample+2 +// block times are loaded, which is the case early in an initial build or reindex. +func (is *InternalState) GetAvgBlockPeriodSeconds() float64 { + is.mux.Lock() + defer is.mux.Unlock() + return is.AvgBlockPeriodSeconds +} + +// computeAvgBlockPeriod averages the spacing of the most recent block times, as integer +// seconds for the sync ETA and fractional seconds for the metric. The integer form +// truncates, so sub-second chains report 0 and drop out of any rule gated on +// blockbook_avg_block_period > 0; it also divides a span of avgBlockPeriodSample+1 +// intervals by avgBlockPeriodSample (~1% high) and is left alone so the ETA is unchanged. 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 + // Header timestamps are not guaranteed monotonic (see GetBlockHeightOfTime), and + // RemoveLastBlockTimes re-runs this on a truncated array after a disconnect. A + // uint32 subtraction with BlockTimes[last] < BlockTimes[first] would wrap to ~4.29e9, + // spiking both the ETA and the blockbook_avg_block_period gauge. Leave the previous + // averages in place for such a window rather than publishing a wrapped value. + if is.BlockTimes[last] <= is.BlockTimes[first] { + return + } + span := is.BlockTimes[last] - is.BlockTimes[first] + is.AvgBlockPeriod = span / avgBlockPeriodSample + is.AvgBlockPeriodSeconds = float64(span) / float64(last-first) } // GetNetwork returns network. If not set returns the same value as CoinShortcut @@ -355,13 +487,25 @@ func (is *InternalState) GetNetwork() string { } // 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." +// A failed query is recorded as an error on top of the last known good payload: callers +// build BackendInfo from an empty ChainInfo on failure, and storing that would zero +// blockbook_backend_best_height and reset the tip advance. The guard needs both an error +// and no height, so a backend answering at genesis is still stored. func (is *InternalState) SetBackendInfo(bi *BackendInfo) { is.mux.Lock() defer is.mux.Unlock() - if bi.Blocks > is.BackendInfo.Blocks || is.BackendTipLastAdvance.IsZero() { + if bi.BackendError != "" && bi.Blocks == 0 { + is.BackendInfo.BackendError = bi.BackendError + return + } + // Any change of the reported height - not only an increase - means the backend is + // alive and answering, so a rollback or a replaced backend at a lower height cannot + // wedge the tip age at "climbing forever". The comparison is against the persisted + // BackendTipHeight rather than the in-memory BackendInfo, so a restart does not + // register as a fresh advance. + if is.BackendTipLastAdvance.IsZero() || bi.Blocks != is.BackendTipHeight { is.BackendTipLastAdvance = time.Now() + is.BackendTipHeight = bi.Blocks } is.BackendInfo = *bi } @@ -373,10 +517,11 @@ 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. +// GetBackendTipLastAdvance returns the wall-clock time when the backend's block +// height was last observed to change. It is persisted, so it carries across a +// restart; the zero seed below only covers a state that has never observed a +// backend at all (a fresh database), where reporting an age "since epoch" would be +// meaningless. func (is *InternalState) GetBackendTipLastAdvance() time.Time { is.mux.Lock() defer is.mux.Unlock() diff --git a/common/internalstate_test.go b/common/internalstate_test.go index a139ab25ef..5979f942b3 100644 --- a/common/internalstate_test.go +++ b/common/internalstate_test.go @@ -138,3 +138,150 @@ func TestWsIPBlocklistSnapshotOrderAndReset(t *testing.T) { t.Fatalf("after reset snapshot len = %d, want 0", got) } } + +// TestSetBackendInfoKeepsLastGoodOnError covers the guard that stops a failed +// GetChainInfo from zeroing the backend height: publishing 0 makes the "backend stuck" +// alerts read "no blocks produced", and the recovery jump back to the real height then +// blinds their delta() window and resets the tip-advance timestamp. +func TestSetBackendInfoKeepsLastGoodOnError(t *testing.T) { + is := &InternalState{} + is.SetBackendInfo(&BackendInfo{Blocks: 100, Version: "v1", Subversion: "sub"}) + advance := is.GetBackendTipLastAdvance() + + is.SetBackendInfo(&BackendInfo{BackendError: "GetChainInfo: connection refused"}) + + bi := is.GetBackendInfo() + if bi.Blocks != 100 { + t.Errorf("Blocks = %d, want the last known good 100", bi.Blocks) + } + if bi.Version != "v1" || bi.Subversion != "sub" { + t.Errorf("version fields were blanked: %+v", bi) + } + if bi.BackendError == "" { + t.Error("BackendError was not recorded") + } + if got := is.GetBackendTipLastAdvance(); !got.Equal(advance) { + t.Errorf("tip advance moved on error: %v -> %v", advance, got) + } + + // Recovery must not read as an advance just because the error left the height alone. + is.SetBackendInfo(&BackendInfo{Blocks: 100, Version: "v1"}) + if got := is.GetBackendTipLastAdvance(); !got.Equal(advance) { + t.Errorf("tip advance moved on recovery without a new block: %v -> %v", advance, got) + } + if is.GetBackendInfo().BackendError != "" { + t.Error("BackendError was not cleared on recovery") + } +} + +// TestSetBackendInfoStoresGenesisHeight guards the narrowness of the error path: a +// backend that legitimately answers with height 0 must still be stored. +func TestSetBackendInfoStoresGenesisHeight(t *testing.T) { + is := &InternalState{} + is.SetBackendInfo(&BackendInfo{Blocks: 0, Version: "genesis"}) + if got := is.GetBackendInfo().Version; got != "genesis" { + t.Errorf("Version = %q, want %q", got, "genesis") + } +} + +// TestBackendTipAdvanceSurvivesRestart covers the persisted tip-advance reference. +// BackendInfo is not persisted, so comparing against it made every process start look +// like a fresh advance - the reason blockbook_tip_age_seconds stayed pinned near zero +// through a crash loop instead of climbing. +func TestBackendTipAdvanceSurvivesRestart(t *testing.T) { + is := &InternalState{} + is.SetBackendInfo(&BackendInfo{Blocks: 500}) + advance := is.GetBackendTipLastAdvance() + + packed, err := is.Pack() + if err != nil { + t.Fatalf("Pack: %v", err) + } + restored, err := UnpackInternalState(packed) + if err != nil { + t.Fatalf("UnpackInternalState: %v", err) + } + if !restored.BackendTipLastAdvance.Equal(advance) { + t.Errorf("advance not persisted: %v -> %v", advance, restored.BackendTipLastAdvance) + } + if restored.BackendTipHeight != 500 { + t.Errorf("BackendTipHeight = %d, want 500", restored.BackendTipHeight) + } + + // The backend is still at the same height after the restart: this is a stall, so the + // age must keep climbing from the original timestamp rather than restart at zero. + restored.SetBackendInfo(&BackendInfo{Blocks: 500}) + if got := restored.GetBackendTipLastAdvance(); !got.Equal(advance) { + t.Errorf("restart reset the tip advance: %v -> %v", advance, got) + } +} + +// TestBackendTipAdvanceOnRollback checks that a lower height still counts as the backend +// being alive, so a reorg or a replaced backend cannot wedge the age at "climbing". +func TestBackendTipAdvanceOnRollback(t *testing.T) { + is := &InternalState{} + is.SetBackendInfo(&BackendInfo{Blocks: 500}) + advance := is.GetBackendTipLastAdvance() + + is.SetBackendInfo(&BackendInfo{Blocks: 480}) + got := is.GetBackendTipLastAdvance() + if !got.After(advance) { + t.Errorf("rollback did not refresh the tip advance: %v -> %v", advance, got) + } + if is.BackendTipHeight != 480 { + t.Errorf("BackendTipHeight = %d, want 480", is.BackendTipHeight) + } +} + +// TestComputeAvgBlockPeriodSubSecond covers the reason the fractional average exists: the +// integer form truncates, so a sub-second chain reports 0 - and both "backend stuck" rules +// are gated on blockbook_avg_block_period > 0, which silently excluded Arbitrum (~0.24s), +// BNB Smart Chain (~0.45s) and Robinhood (~0.10s) from either of them. +func TestComputeAvgBlockPeriodSubSecond(t *testing.T) { + // 102 block times spaced 250ms apart, rounded into the uint32 seconds they are stored + // as: 101 intervals over ~25s. + is := &InternalState{} + times := make([]uint32, avgBlockPeriodSample+2) + for i := range times { + times[i] = uint32(i) / 4 + } + is.SetBlockTimes(times) + + if got := is.GetAvgBlockPeriod(); got != 0 { + t.Errorf("GetAvgBlockPeriod() = %d, want 0 (integer form truncates)", got) + } + got := is.GetAvgBlockPeriodSeconds() + if got < 0.2 || got > 0.3 { + t.Errorf("GetAvgBlockPeriodSeconds() = %v, want ~0.25", got) + } +} + +// TestComputeAvgBlockPeriodNotEnoughBlocks pins the "not yet computed" case: early in an +// initial build or reindex there are too few block times, and the metric reads 0. +func TestComputeAvgBlockPeriodNotEnoughBlocks(t *testing.T) { + is := &InternalState{} + is.SetBlockTimes(make([]uint32, avgBlockPeriodSample)) + if got := is.GetAvgBlockPeriodSeconds(); got != 0 { + t.Errorf("GetAvgBlockPeriodSeconds() = %v, want 0", got) + } +} + +// TestComputeAvgBlockPeriodDividesByRealIntervalCount checks the float form divides the +// span by the number of intervals it actually covers. The integer form divides a span of +// avgBlockPeriodSample+1 intervals by avgBlockPeriodSample and so runs ~1% high; it is +// left alone because it feeds the sync ETA. +func TestComputeAvgBlockPeriodDividesByRealIntervalCount(t *testing.T) { + is := &InternalState{} + times := make([]uint32, avgBlockPeriodSample+2) + for i := range times { + times[i] = uint32(i) * 3 + } + is.SetBlockTimes(times) + + if got := is.GetAvgBlockPeriodSeconds(); got != 3 { + t.Errorf("GetAvgBlockPeriodSeconds() = %v, want exactly 3", got) + } + if got := is.GetAvgBlockPeriod(); got != 3 { + t.Errorf("GetAvgBlockPeriod() = %d, want 3 (3.03 truncated)", got) + } +} diff --git a/common/metrics.go b/common/metrics.go index 37bd524558..5693f72c03 100644 --- a/common/metrics.go +++ b/common/metrics.go @@ -1,11 +1,14 @@ package common import ( + "errors" "fmt" "reflect" + "regexp" "strings" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" "github.com/trezor/blockbook/configs" yaml "gopkg.in/yaml.v3" ) @@ -18,85 +21,111 @@ import ( // 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 { - 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"` + 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"` + EthCallMulticallRequests prometheus.Counter `metric:"eth_call_multicall_requests"` + 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"` + InternalDataErrorQueue prometheus.Gauge `metric:"internal_data_error_queue"` + InternalDataHeals *prometheus.CounterVec `metric:"internal_data_heals"` + ExplorerViews *prometheus.CounterVec `metric:"explorer_views"` + MempoolSize prometheus.Gauge `metric:"mempool_size"` + MempoolLastSync *prometheus.GaugeVec `metric:"mempool_last_sync_timestamp_seconds"` + 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"` + EthAlternativeMempoolOldestAge prometheus.Gauge `metric:"eth_alternative_mempool_oldest_age_seconds"` + EthAlternativeSendNotSurfaced *prometheus.CounterVec `metric:"eth_alternative_send_not_surfaced_total"` + EthAlternativePendingFloorRaised *prometheus.CounterVec `metric:"eth_alternative_pending_floor_raised_total"` + EthAlternativePendingFloorStranded *prometheus.CounterVec `metric:"eth_alternative_pending_floor_stranded_total"` + EthAlternativeNonceRequests *prometheus.CounterVec `metric:"eth_alternative_nonce_requests_total"` + SendTxRequests *prometheus.CounterVec `metric:"sendtx_requests_total"` + EthSendTxPath *prometheus.CounterVec `metric:"eth_sendtx_path_total"` + EthAlternativeSendTx *prometheus.CounterVec `metric:"eth_alternative_sendtx_total"` + EthAlternativeSendTxDuration *prometheus.HistogramVec `metric:"eth_alternative_sendtx_duration_seconds"` + EthAlternativeEstimateGasRequests *prometheus.CounterVec `metric:"eth_alternative_estimate_gas_requests_total"` + 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"` + InitialSync prometheus.Gauge `metric:"initial_sync"` + 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"` + XPubCacheBytes prometheus.Gauge `metric:"xpub_cache_bytes"` + XPubCacheHits prometheus.Counter `metric:"xpub_cache_hits_total"` + XPubCacheMisses *prometheus.CounterVec `metric:"xpub_cache_misses_total"` + 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"` + AlternativeFeeProviderLastSync *prometheus.GaugeVec `metric:"alternative_fee_provider_last_sync_timestamp_seconds"` + AlternativeFeeProviderCache *prometheus.CounterVec `metric:"alternative_fee_provider_cache"` + EthEip1559Fee *prometheus.GaugeVec `metric:"eth_eip1559_fee"` + EthEip1559BaseFee prometheus.Gauge `metric:"eth_eip1559_base_fee"` + EthEip1559FeeSource *prometheus.CounterVec `metric:"eth_eip1559_fee_source_total"` + EthBlockGasUsedRatio prometheus.Gauge `metric:"eth_block_gas_used_ratio"` + EthBlockBaseFee prometheus.Gauge `metric:"eth_block_base_fee"` + RPCSyncLatency *prometheus.HistogramVec `metric:"rpc_sync_latency"` + EthSyncRpcErrors *prometheus.CounterVec `metric:"eth_sync_rpc_errors"` } // Labels represents a collection of label name -> value mappings. @@ -165,9 +194,38 @@ func GetMetrics(coin string) (*Metrics, error) { } } + if err := registerGoRuntimeMetrics(); err != nil { + return nil, fmt.Errorf("metrics: registering Go runtime metrics: %w", err) + } + return metrics, nil } +// registerGoRuntimeMetrics swaps the default Go collector (memstats gauges only) for one +// that also exports GC CPU time and the live/goal heap from runtime/metrics, so the GC +// share of an instance's CPU can be read from Grafana instead of a pprof session. +// The alloc/free size histograms are left out: ~30 series per instance for no question +// the memstats gauges do not already answer. +func registerGoRuntimeMetrics() error { + extended := collectors.NewGoCollector( + collectors.WithGoCollectorRuntimeMetrics( + collectors.GoRuntimeMetricsRule{Matcher: regexp.MustCompile(`^/cpu/classes/`)}, + collectors.MetricsGC, + ), + collectors.WithoutGoCollectorRuntimeMetrics(regexp.MustCompile(`^/gc/heap/(allocs|frees)-by-size:bytes$`)), + ) + // Both collectors describe the same go_memstats_* descriptors, so the stock one has to go first. + prometheus.Unregister(collectors.NewGoCollector()) + if err := prometheus.Register(extended); err != nil { + var already prometheus.AlreadyRegisteredError + if errors.As(err, &already) { + return nil + } + return err + } + return 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 diff --git a/common/metrics_test.go b/common/metrics_test.go index ed6c5e652d..c5d22e484c 100644 --- a/common/metrics_test.go +++ b/common/metrics_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" "github.com/trezor/blockbook/configs" yaml "gopkg.in/yaml.v3" ) @@ -86,3 +87,47 @@ func TestMetricsYAMLInvariants(t *testing.T) { names[def.Name] = key } } + +// TestGoRuntimeMetricsExported pins the runtime/metrics families the Grafana system +// panels query (GC CPU share, live/goal heap) and that swapping the Go collector keeps +// the memstats gauges older panels use while leaving the noisy size histograms out. +func TestGoRuntimeMetricsExported(t *testing.T) { + useTestPrometheusRegistry(t) + // The fresh test registry has no stock Go collector; the production registry does. + prometheus.MustRegister(collectors.NewGoCollector()) + + if _, err := GetMetrics("metrics_unittest_go_runtime"); err != nil { + t.Fatalf("GetMetrics: %v", err) + } + // A second registration in the same process (multi-coin test binaries) must be a no-op. + if err := registerGoRuntimeMetrics(); err != nil { + t.Fatalf("second registerGoRuntimeMetrics: %v", err) + } + + families, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("Gather: %v", err) + } + names := make(map[string]bool, len(families)) + for _, f := range families { + names[f.GetName()] = true + } + for _, want := range []string{ + "go_cpu_classes_gc_total_cpu_seconds_total", + "go_cpu_classes_total_cpu_seconds_total", + "go_cpu_classes_idle_cpu_seconds_total", + "go_gc_heap_live_bytes", + "go_gc_heap_goal_bytes", + "go_memstats_alloc_bytes", + "go_gc_duration_seconds", + } { + if !names[want] { + t.Errorf("metric family %s not exported", want) + } + } + for _, unwanted := range []string{"go_gc_heap_allocs_by_size_bytes", "go_gc_heap_frees_by_size_bytes"} { + if names[unwanted] { + t.Errorf("metric family %s should be excluded", unwanted) + } + } +} diff --git a/common/redact.go b/common/redact.go new file mode 100644 index 0000000000..b17eeddb94 --- /dev/null +++ b/common/redact.go @@ -0,0 +1,34 @@ +package common + +import ( + "net/url" + "regexp" + "strings" +) + +// urlInMessage matches an absolute URL anywhere in a message. The match stops at whitespace and at +// the quoting characters Go's http client puts around a URL, which is the shape that leaks the most +// often: Post "": dial tcp ... +var urlInMessage = regexp.MustCompile(`(?i)[a-z][a-z0-9+.\-]*://[^\s"'` + "`" + `<>]+`) + +// RedactURLs reduces every URL in a message to its scheme and host. Backend and provider URLs carry +// an API key in their userinfo, path or query (a private relay, Infura, 1inch, CoinGecko), and they +// reach error messages both from our own annotations and from the http client, which renders the +// full URL into *url.Error on any dial failure or timeout. Apply this wherever a message is handed +// to an API client; logs keep the unredacted error, which is what an operator needs. +func RedactURLs(s string) string { + if !strings.Contains(s, "://") { + return s + } + return urlInMessage.ReplaceAllStringFunc(s, redactURL) +} + +func redactURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + // unparseable: drop it whole rather than keep a part that may be the key itself + return "[redacted url]" + } + // Host excludes userinfo, so scheme://host drops the credentials along with the path and query. + return u.Scheme + "://" + u.Host +} diff --git a/common/redact_test.go b/common/redact_test.go new file mode 100644 index 0000000000..1ed2124161 --- /dev/null +++ b/common/redact_test.go @@ -0,0 +1,79 @@ +//go:build unittest + +package common + +import "testing" + +func TestRedactURLs(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + // the http client's *url.Error on a dial failure or timeout - the widest leak + name: "url.Error from the http client", + in: `Post "https://gas.api.infura.io/v3/SECRET_API_KEY/networks/56/suggestedGasFees": context deadline exceeded`, + want: `Post "https://gas.api.infura.io": context deadline exceeded`, + }, + { + // our own annotation, errors.New(url + " " + method + ...) + name: "own annotation", + in: "https://relay.example.com/v1/SECRET eth_sendRawTransaction : failed, empty result", + want: "https://relay.example.com eth_sendRawTransaction : failed, empty result", + }, + { + name: "key in userinfo", + in: `Post "https://user:SECRET@relay.example.com/rpc": EOF`, + want: `Post "https://relay.example.com": EOF`, + }, + { + name: "key in query", + in: "get https://api.coingecko.com/api/v3/simple/price?x_cg_pro_api_key=SECRET failed", + want: "get https://api.coingecko.com failed", + }, + { + name: "host and port kept", + in: `Post "http://127.0.0.1:8065/v3/SECRET": connection refused`, + want: `Post "http://127.0.0.1:8065": connection refused`, + }, + { + name: "websocket scheme", + in: `dial ws://127.0.0.1:8065/SECRET: connection reset by peer`, + want: `dial ws://127.0.0.1:8065 connection reset by peer`, + }, + { + name: "several urls in one message", + in: "https://a.example.com/K1 failed; https://b.example.com/K2 failed", + want: "https://a.example.com failed; https://b.example.com failed", + }, + { + // a plain JSON-RPC rejection must reach the client untouched: the wallet classifies on it + name: "json-rpc rejection untouched", + in: "replacement transaction underpriced", + want: "replacement transaction underpriced", + }, + { + name: "nonce rejection untouched", + in: "nonce too low: next nonce 5, tx nonce 3", + want: "nonce too low: next nonce 5, tx nonce 3", + }, + { + name: "no host to keep", + in: "reading file:///etc/blockbook/secret.conf failed", + want: "reading [redacted url] failed", + }, + { + name: "empty", + in: "", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RedactURLs(tt.in); got != tt.want { + t.Errorf("RedactURLs()\n got %q\nwant %q", got, tt.want) + } + }) + } +} diff --git a/common/ttlvalue.go b/common/ttlvalue.go new file mode 100644 index 0000000000..950de930cd --- /dev/null +++ b/common/ttlvalue.go @@ -0,0 +1,125 @@ +package common + +import ( + "sync" + "time" + + "github.com/golang/glog" + "github.com/juju/errors" +) + +// ttlFailureRetry is how soon a failed fetch may be retried - much shorter than typical value +// TTLs so recovery from an outage is noticed within seconds, not a whole TTL later. +const ttlFailureRetry = 5 * time.Second + +// errFetchPanicked is preset before each fetch so a panic is recorded as a failed attempt. +var errFetchPanicked = errors.New("fetch panicked") + +// TTLValue caches one slowly-changing fact, refreshed through fetch at most once per ttl. +// Refreshes of an already cached value run in the background so no caller ever waits on the +// fetch; only the very first one is synchronous. Fetches are single-flight (hand-rolled: +// singleflight would block concurrent callers) and a failure keeps the last known value. +// It refreshes only when read - a fact that must stay fresh with no readers needs a poller +// goroutine instead. +type TTLValue[T any] struct { + ttl time.Duration + fetch func() (T, error) + + mu sync.Mutex + value *T + fetchedAt time.Time + fetching bool + // lastAttempt paces retries: failures wait only ttlFailureRetry instead of a whole ttl. + lastAttempt time.Time + failure ttlFailure +} + +// ttlFailure is the current run of consecutive fetch failures; the zero value means healthy. +type ttlFailure struct { + since time.Time + err error +} + +// NewTTLValue returns a cache serving a snapshot at most ttl old, refilled by fetch. +func NewTTLValue[T any](ttl time.Duration, fetch func() (T, error)) *TTLValue[T] { + return &TTLValue[T]{ttl: ttl, fetch: fetch} +} + +// Get returns the last fetched value (nil if no fetch ever succeeded), how long refresh +// attempts have been failing (0 while healthy) and the last fetch error. now is injected so +// the TTL rules can be tested without sleeping. +func (c *TTLValue[T]) Get(now time.Time) (*T, time.Duration, error) { + c.mu.Lock() + if c.value != nil && now.Sub(c.fetchedAt) < c.ttl { + defer c.mu.Unlock() + return c.value, 0, nil + } + retryAfter := c.ttl + if c.failure.err != nil { + retryAfter = ttlFailureRetry + } + // single-flight with retry pacing: everyone but one fetcher gets the current state at once + if c.fetching || (!c.lastAttempt.IsZero() && now.Sub(c.lastAttempt) < retryAfter) { + defer c.mu.Unlock() + return c.stateLocked(now) + } + c.fetching = true + c.lastAttempt = now + warm := c.value != nil + c.mu.Unlock() + + if warm { + // refresh in the background - a caller holding a stale value must never block on a + // fetch that can sit until an RPC timeout + go c.refresh(now) + } else { + // cold: the first caller fetches synchronously so its error (or panic) propagates + c.fetchAndStore(now) + } + c.mu.Lock() + defer c.mu.Unlock() + return c.stateLocked(now) +} + +// stateLocked returns the cached state; the caller holds mu. +func (c *TTLValue[T]) stateLocked(now time.Time) (*T, time.Duration, error) { + var failingFor time.Duration + if c.failure.err != nil { + failingFor = now.Sub(c.failure.since) + } + return c.value, failingFor, c.failure.err +} + +// refresh recovers a fetch panic so it cannot crash the process from a bare goroutine - +// fetchAndStore has recorded it as a failed attempt by the time it propagates here. +func (c *TTLValue[T]) refresh(now time.Time) { + defer func() { + if r := recover(); r != nil { + glog.Errorf("TTLValue refresh panicked: %v", r) + } + }() + c.fetchAndStore(now) +} + +// fetchAndStore runs one fetch and records its outcome in a defer, so even a panicking fetch +// resets the in-flight flag (as a failed attempt) instead of wedging the cache forever. +func (c *TTLValue[T]) fetchAndStore(now time.Time) { + var value T + err := errFetchPanicked + defer func() { + c.mu.Lock() + defer c.mu.Unlock() + c.fetching = false + if err != nil { + if c.failure.err == nil { + c.failure.since = now + } + c.failure.err = err + return + } + c.failure = ttlFailure{} + c.value = &value + c.fetchedAt = now + }() + value, err = c.fetch() +} diff --git a/common/ttlvalue_test.go b/common/ttlvalue_test.go new file mode 100644 index 0000000000..bff37fa677 --- /dev/null +++ b/common/ttlvalue_test.go @@ -0,0 +1,227 @@ +//go:build unittest + +package common + +import ( + "strings" + "testing" + "time" + + "github.com/juju/errors" +) + +const testTTL = time.Minute + +// waitUntil polls cond for a short real-time window - used only to observe background +// refreshes landing; the TTL rules themselves always run on an injected clock. +func waitUntil(t *testing.T, what string, cond func() bool) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); { + if cond() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func TestTTLValue(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run("cold fetch is synchronous and cached for a TTL", func(t *testing.T) { + calls := 0 + c := NewTTLValue(testTTL, func() (string, error) { calls++; return "v1", nil }) + v, failingFor, err := c.Get(base) + if err != nil || v == nil || *v != "v1" || failingFor != 0 { + t.Fatalf("cold get = %v, %v, %v", v, failingFor, err) + } + // every read inside the TTL must be free - these are the /api/ request paths + for _, at := range []time.Time{base, base.Add(time.Second), base.Add(testTTL - time.Nanosecond)} { + if v, _, err := c.Get(at); err != nil || *v != "v1" { + t.Fatalf("get at %v = %v, %v", at, v, err) + } + } + if calls != 1 { + t.Fatalf("fetch calls = %d, want 1", calls) + } + }) + + t.Run("a stale value is refreshed in the background, readers never block", func(t *testing.T) { + calls := 0 + started := make(chan struct{}) + release := make(chan struct{}) + c := NewTTLValue(testTTL, func() (string, error) { + calls++ + switch calls { + case 1: + return "v1", nil + case 2: + close(started) + <-release + return "v2", nil + default: + t.Error("unexpected extra fetch") + return "", nil + } + }) + c.Get(base) + at := base.Add(testTTL) + v, _, err := c.Get(at) + if err != nil || *v != "v1" { + t.Fatalf("stale get = %v, %v, want the previous value immediately", v, err) + } + <-started + // a reader far past the TTL must not start a second fetch while one is in flight + if v, _, _ := c.Get(base.Add(10 * testTTL)); *v != "v1" { + t.Fatalf("in-flight read = %v", v) + } + close(release) + waitUntil(t, "the background refresh to land", func() bool { + v, _, _ := c.Get(at) + return *v == "v2" + }) + }) + + t.Run("failures keep the last value and are retried sooner than the TTL", func(t *testing.T) { + calls := 0 + c := NewTTLValue(testTTL, func() (string, error) { + calls++ + switch calls { + case 1: + return "v1", nil + case 2: + return "", errors.New("dial tcp: refused") + default: + return "v2", nil + } + }) + c.Get(base) + at := base.Add(testTTL) + if v, _, err := c.Get(at); err != nil || *v != "v1" { + t.Fatalf("get during outage = %v, %v, want the cached value", v, err) + } + waitUntil(t, "the failed refresh to be recorded", func() bool { + _, _, err := c.Get(at) + return err != nil + }) + v, failingFor, err := c.Get(at.Add(time.Second)) + if *v != "v1" || failingFor != time.Second || err == nil { + t.Fatalf("get after failure = %v, %v, %v", v, failingFor, err) + } + if calls != 2 { + t.Fatalf("fetch calls inside the retry window = %d, want 2", calls) + } + // after ttlFailureRetry the fetch runs again and a success resets the failure state + recoveredAt := at.Add(ttlFailureRetry) + c.Get(recoveredAt) + waitUntil(t, "the recovery to land", func() bool { + v, failingFor, err := c.Get(recoveredAt) + return err == nil && failingFor == 0 && *v == "v2" + }) + }) + + t.Run("cold failure propagates and is not retried inside the retry window", func(t *testing.T) { + calls := 0 + c := NewTTLValue(testTTL, func() (string, error) { + calls++ + if calls == 1 { + return "", errors.New("dial tcp: refused") + } + return "v1", nil + }) + v, _, err := c.Get(base) + if v != nil || err == nil || !strings.Contains(err.Error(), "refused") { + t.Fatalf("cold failure = %v, %v", v, err) + } + if v, _, err := c.Get(base.Add(ttlFailureRetry - time.Nanosecond)); v != nil || err == nil { + t.Fatalf("suppressed cold get = %v, %v, want the recorded error", v, err) + } + if calls != 1 { + t.Fatalf("fetch calls inside the retry window = %d, want 1", calls) + } + if v, _, err := c.Get(base.Add(ttlFailureRetry)); err != nil || *v != "v1" { + t.Fatalf("cold retry = %v, %v", v, err) + } + }) + + t.Run("cold fetch is single-flight", func(t *testing.T) { + calls := 0 + started := make(chan struct{}) + release := make(chan struct{}) + done := make(chan struct{}) + c := NewTTLValue(testTTL, func() (string, error) { + calls++ + if calls > 1 { + t.Error("second cold fetch while one is in flight") + return "", nil + } + close(started) + <-release + return "v1", nil + }) + go func() { + defer close(done) + c.Get(base) + }() + <-started + // nothing fetched yet and no failure recorded - the concurrent caller just has no data + if v, _, err := c.Get(base); v != nil || err != nil { + t.Fatalf("cold in-flight get = %v, %v", v, err) + } + close(release) + <-done + if v, _, err := c.Get(base); err != nil || *v != "v1" { + t.Fatalf("get after the cold fetch = %v, %v", v, err) + } + }) + + t.Run("a panicking cold fetch propagates but does not wedge the cache", func(t *testing.T) { + calls := 0 + c := NewTTLValue(testTTL, func() (string, error) { + calls++ + if calls == 1 { + panic("boom") + } + return "v1", nil + }) + func() { + defer func() { + if recover() == nil { + t.Error("cold fetch panic must propagate to the caller") + } + }() + c.Get(base) + }() + // the panic is a recorded failure, not retried before the retry window elapses + if _, _, err := c.Get(base.Add(time.Second)); err == nil || !strings.Contains(err.Error(), "panicked") { + t.Fatalf("state after panic = %v, want a recorded failure", err) + } + if calls != 1 { + t.Fatalf("fetch calls inside the retry window = %d, want 1", calls) + } + if v, _, err := c.Get(base.Add(ttlFailureRetry)); err != nil || *v != "v1" { + t.Fatalf("recovery after panic = %v, %v", v, err) + } + }) + + t.Run("a panic in a background refresh is contained", func(t *testing.T) { + panicked := make(chan struct{}) + calls := 0 + c := NewTTLValue(testTTL, func() (string, error) { + calls++ + if calls == 1 { + return "v1", nil + } + close(panicked) + panic("boom") + }) + c.Get(base) + at := base.Add(testTTL) + c.Get(at) + <-panicked + waitUntil(t, "the panic to be recorded as a failure", func() bool { + v, _, err := c.Get(at) + return err != nil && v != nil && *v == "v1" + }) + }) +} diff --git a/configs/coins/arbitrum.json b/configs/coins/arbitrum.json index ec8b5eb6ff..ff67f198bd 100644 --- a/configs/coins/arbitrum.json +++ b/configs/coins/arbitrum.json @@ -55,6 +55,7 @@ "averageBlockTimeMs": 250, "mempoolTxTimeoutHours": 12, "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\": \"arbitrum-one\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" diff --git a/configs/coins/arbitrum_archive.json b/configs/coins/arbitrum_archive.json index 4611e59218..d5ea8ff234 100644 --- a/configs/coins/arbitrum_archive.json +++ b/configs/coins/arbitrum_archive.json @@ -62,15 +62,26 @@ "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}", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/42161/suggestedGasFees\", \"periodSeconds\": 30}", "mempoolTxTimeoutHours": 12, + "mempoolTxTimeout": "12h", + "alternativeMempoolTxTimeout": "12h", "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\": \"arbitrum-one\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } } } }, diff --git a/configs/coins/arbitrum_nova.json b/configs/coins/arbitrum_nova.json index 513d159262..ac0819b226 100644 --- a/configs/coins/arbitrum_nova.json +++ b/configs/coins/arbitrum_nova.json @@ -54,6 +54,7 @@ "averageBlockTimeMs": 250, "mempoolTxTimeoutHours": 12, "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\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" diff --git a/configs/coins/arbitrum_nova_archive.json b/configs/coins/arbitrum_nova_archive.json index 41272e4bef..e3af7fe068 100644 --- a/configs/coins/arbitrum_nova_archive.json +++ b/configs/coins/arbitrum_nova_archive.json @@ -57,6 +57,7 @@ "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\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", diff --git a/configs/coins/avalanche.json b/configs/coins/avalanche.json index 2cde02340a..fd3cd6526b 100644 --- a/configs/coins/avalanche.json +++ b/configs/coins/avalanche.json @@ -55,15 +55,29 @@ "mempool_sub_workers": 2, "block_addresses_to_keep": 300, "additional_params": { - "averageBlockTimeMs": 2000, + "averageBlockTimeMs": 1000, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 14, + "tipRecheckThreshold": 14, + "maxStallMs": 60000 + }, "eip1559Fees": true, "alternative_estimate_fee": "infura", - "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/43114/suggestedGasFees\", \"periodSeconds\": 60}", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/43114/suggestedGasFees\", \"periodSeconds\": 15}", "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}" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } } } }, diff --git a/configs/coins/avalanche_archive.json b/configs/coins/avalanche_archive.json index e1c2c3cf35..1c8c9f44da 100644 --- a/configs/coins/avalanche_archive.json +++ b/configs/coins/avalanche_archive.json @@ -56,17 +56,17 @@ "mempool_sub_workers": 2, "block_addresses_to_keep": 600, "additional_params": { - "averageBlockTimeMs": 2000, + "averageBlockTimeMs": 1000, "missingBlockRetry": { "retryDelayMs": 1000, - "recheckThreshold": 10, - "tipRecheckThreshold": 3, + "recheckThreshold": 14, + "tipRecheckThreshold": 14, "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}", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/43114/suggestedGasFees\", \"periodSeconds\": 15}", "mempoolTxTimeoutHours": 12, "processInternalTransactions": true, "trace_timeout": "10s", @@ -75,6 +75,14 @@ "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/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } } } }, diff --git a/configs/coins/base.json b/configs/coins/base.json index 218346c459..fdad2bf36a 100644 --- a/configs/coins/base.json +++ b/configs/coins/base.json @@ -56,6 +56,7 @@ "averageBlockTimeMs": 2000, "mempoolTxTimeoutHours": 12, "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}" diff --git a/configs/coins/base_archive.json b/configs/coins/base_archive.json index 235a9f3279..1d2319d08f 100644 --- a/configs/coins/base_archive.json +++ b/configs/coins/base_archive.json @@ -64,8 +64,10 @@ "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}", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/8453/suggestedGasFees\", \"periodSeconds\": 5}", "mempoolTxTimeoutHours": 12, + "mempoolTxTimeout": "12h", + "alternativeMempoolTxTimeout": "12h", "processInternalTransactions": true, "trace_timeout": "10s", "queryBackendOnMempoolResync": false, @@ -74,6 +76,14 @@ "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/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } } } }, diff --git a/configs/coins/bitcoin.json b/configs/coins/bitcoin.json index 83ee279930..62fe0c52ec 100644 --- a/configs/coins/bitcoin.json +++ b/configs/coins/bitcoin.json @@ -23,10 +23,10 @@ "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", + "version": "31.1", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "verification_source": "b80d9c3e04da78fb6f0569685673418cf686fadba9042d926d13fb87ff503f9e", "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", @@ -39,13 +39,12 @@ "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" + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-aarch64-linux-gnu.tar.gz", + "verification_source": "dcf1873f2208ba4f962f3398d47e154c39c0084be8f4553e05c940d0ace3d004" } } }, @@ -67,8 +66,8 @@ "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}", + "alternative_estimate_fee": "mempoolspaceprecise", + "alternative_estimate_fee_params": "{\"url\": \"https://mempool.space/api/v1/fees/precise\", \"periodSeconds\": 20}", "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}", diff --git a/configs/coins/bitcoin_regtest.json b/configs/coins/bitcoin_regtest.json index a35179db29..8f0fd1184f 100644 --- a/configs/coins/bitcoin_regtest.json +++ b/configs/coins/bitcoin_regtest.json @@ -23,10 +23,10 @@ "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", + "version": "31.1", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "verification_source": "b80d9c3e04da78fb6f0569685673418cf686fadba9042d926d13fb87ff503f9e", "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", @@ -38,13 +38,11 @@ "protect_memory": true, "server_config_file": "bitcoin_regtest.conf", "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" - }, + "additional_params": {}, "platforms": { "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", - "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-aarch64-linux-gnu.tar.gz", + "verification_source": "dcf1873f2208ba4f962f3398d47e154c39c0084be8f4553e05c940d0ace3d004" } } }, @@ -65,8 +63,8 @@ "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}", + "alternative_estimate_fee": "mempoolspaceprecise", + "alternative_estimate_fee_params": "{\"url\": \"https://mempool.space/api/v1/fees/precise\", \"periodSeconds\": 20}", "block_golomb_filter_p": 20, "block_filter_scripts": "taproot-noordinals", "block_filter_use_zeroed_key": true, diff --git a/configs/coins/bitcoin_signet.json b/configs/coins/bitcoin_signet.json index 42fb3cd9b5..624b58f7d1 100644 --- a/configs/coins/bitcoin_signet.json +++ b/configs/coins/bitcoin_signet.json @@ -23,10 +23,10 @@ "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", + "version": "31.1", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "verification_source": "b80d9c3e04da78fb6f0569685673418cf686fadba9042d926d13fb87ff503f9e", "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", @@ -38,13 +38,11 @@ "mainnet": false, "server_config_file": "bitcoin_signet.conf", "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" - }, + "additional_params": {}, "platforms": { "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", - "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-aarch64-linux-gnu.tar.gz", + "verification_source": "dcf1873f2208ba4f962f3398d47e154c39c0084be8f4553e05c940d0ace3d004" } } }, @@ -64,7 +62,9 @@ "xpub_magic_segwit_p2sh": 71979618, "xpub_magic_segwit_native": 73342198, "slip44": 1, - "additional_params": {} + "additional_params": { + "averageBlockTimeMs": 600000 + } } }, "meta": { diff --git a/configs/coins/bitcoin_testnet.json b/configs/coins/bitcoin_testnet.json index 9c6b68ce7e..649155fb8f 100644 --- a/configs/coins/bitcoin_testnet.json +++ b/configs/coins/bitcoin_testnet.json @@ -23,10 +23,10 @@ "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", + "version": "31.1", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "verification_source": "b80d9c3e04da78fb6f0569685673418cf686fadba9042d926d13fb87ff503f9e", "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", @@ -38,13 +38,11 @@ "mainnet": false, "server_config_file": "bitcoin.conf", "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" - }, + "additional_params": {}, "platforms": { "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", - "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-aarch64-linux-gnu.tar.gz", + "verification_source": "dcf1873f2208ba4f962f3398d47e154c39c0084be8f4553e05c940d0ace3d004" } } }, @@ -65,6 +63,7 @@ "xpub_magic_segwit_native": 73342198, "slip44": 1, "additional_params": { + "averageBlockTimeMs": 600000, "block_golomb_filter_p": 20, "block_filter_scripts": "taproot-noordinals", "block_filter_use_zeroed_key": true, diff --git a/configs/coins/bitcoin_testnet4.json b/configs/coins/bitcoin_testnet4.json index 9d8b6a67c6..7fbcc3f40b 100644 --- a/configs/coins/bitcoin_testnet4.json +++ b/configs/coins/bitcoin_testnet4.json @@ -23,10 +23,10 @@ "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", + "version": "31.1", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "verification_source": "b80d9c3e04da78fb6f0569685673418cf686fadba9042d926d13fb87ff503f9e", "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", @@ -38,13 +38,11 @@ "mainnet": false, "server_config_file": "bitcoin_testnet4.conf", "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" - }, + "additional_params": {}, "platforms": { "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", - "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-31.1/bitcoin-31.1-aarch64-linux-gnu.tar.gz", + "verification_source": "dcf1873f2208ba4f962f3398d47e154c39c0084be8f4553e05c940d0ace3d004" } } }, @@ -65,8 +63,9 @@ "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}", + "averageBlockTimeMs": 600000, + "alternative_estimate_fee": "mempoolspaceprecise", + "alternative_estimate_fee_params": "{\"url\": \"https://mempool.space/testnet4/api/v1/fees/precise\", \"periodSeconds\": 60}", "block_golomb_filter_p": 20, "block_filter_scripts": "taproot-noordinals", "block_filter_use_zeroed_key": true, diff --git a/configs/coins/bsc.json b/configs/coins/bsc.json index b9d2561cd1..3bf98b2cf8 100644 --- a/configs/coins/bsc.json +++ b/configs/coins/bsc.json @@ -58,8 +58,10 @@ "mempool_sub_workers": 2, "block_addresses_to_keep": 300, "additional_params": { - "averageBlockTimeMs": 3000, + "averageBlockTimeMs": 450, "mempoolTxTimeoutHours": 12, + "mempoolTxTimeout": "12h", + "alternativeMempoolTxTimeout": "12h", "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", diff --git a/configs/coins/bsc_archive.json b/configs/coins/bsc_archive.json index bd8949ae79..0258a8dd2c 100644 --- a/configs/coins/bsc_archive.json +++ b/configs/coins/bsc_archive.json @@ -59,7 +59,7 @@ "mempool_sub_workers": 2, "block_addresses_to_keep": 600, "additional_params": { - "averageBlockTimeMs": 3000, + "averageBlockTimeMs": 450, "missingBlockRetry": { "retryDelayMs": 1000, "recheckThreshold": 10, @@ -71,6 +71,8 @@ "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, + "mempoolTxTimeout": "12h", + "alternativeMempoolTxTimeout": "12h", "processInternalTransactions": true, "trace_timeout": "10s", "queryBackendOnMempoolResync": false, diff --git a/configs/coins/ethereum.json b/configs/coins/ethereum.json index e20a5d2a57..afa96108b5 100644 --- a/configs/coins/ethereum.json +++ b/configs/coins/ethereum.json @@ -23,13 +23,13 @@ "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", + "version": "3.5.5", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_amd64.tar.gz", "verification_type": "sha256", - "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "verification_source": "2e875794234dc06fdcd0c716b38e936b8c820e90e16cee2d43fdfaf74df09ced", "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}}'", + "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}} --discovery.v4 --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\" --rpc.logs.maxresults 200000 --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", @@ -40,8 +40,8 @@ "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" + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_arm64.tar.gz", + "verification_source": "1e2e917b15338047f4a41cfd2cfd486b272c856fd549a68e3fe63d64762f8113" } } }, @@ -63,6 +63,8 @@ "address_aliases": true, "eip1559Fees": true, "mempoolTxTimeoutHours": 48, + "mempoolTxTimeout": "48h", + "alternativeMempoolTxTimeout": "48h", "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", diff --git a/configs/coins/ethereum_archive.json b/configs/coins/ethereum_archive.json index bb609155a6..fcf6131df8 100644 --- a/configs/coins/ethereum_archive.json +++ b/configs/coins/ethereum_archive.json @@ -24,13 +24,13 @@ "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", + "version": "3.5.5", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_amd64.tar.gz", "verification_type": "sha256", - "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "verification_source": "2e875794234dc06fdcd0c716b38e936b8c820e90e16cee2d43fdfaf74df09ced", "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}}'", + "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}} --discovery.v4 --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\" --rpc.logs.maxresults 200000 --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", @@ -41,8 +41,8 @@ "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" + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_arm64.tar.gz", + "verification_source": "1e2e917b15338047f4a41cfd2cfd486b272c856fd549a68e3fe63d64762f8113" } } }, @@ -70,14 +70,24 @@ "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}", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees\", \"periodSeconds\": 10}", "mempoolTxTimeoutHours": 48, + "mempoolTxTimeout": "48h", + "alternativeMempoolTxTimeout": "48h", "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/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } } } }, diff --git a/configs/coins/ethereum_archive_consensus.json b/configs/coins/ethereum_archive_consensus.json index d3d10379a5..bb750b6b8d 100644 --- a/configs/coins/ethereum_archive_consensus.json +++ b/configs/coins/ethereum_archive_consensus.json @@ -19,13 +19,13 @@ "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", + "version": "7.1.8", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-amd64", "verification_type": "sha256", - "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "verification_source": "16c701e32bd27d7f0bdf43160318ef75844da9137667c28748c44f266ed13be7", "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'", + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --mainnet --accept-terms-of-use --semi-supernode --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", @@ -36,8 +36,8 @@ "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" + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-arm64", + "verification_source": "f806344e07dd44c5266cb536bf5ca7e87a749a3c0d531c4201c4896cdfe970f7" } } }, diff --git a/configs/coins/ethereum_consensus.json b/configs/coins/ethereum_consensus.json index 8e898d9619..3a8d2702ea 100644 --- a/configs/coins/ethereum_consensus.json +++ b/configs/coins/ethereum_consensus.json @@ -19,10 +19,10 @@ "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", + "version": "7.1.8", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-amd64", "verification_type": "sha256", - "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "verification_source": "16c701e32bd27d7f0bdf43160318ef75844da9137667c28748c44f266ed13be7", "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'", @@ -36,8 +36,8 @@ "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" + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-arm64", + "verification_source": "f806344e07dd44c5266cb536bf5ca7e87a749a3c0d531c4201c4896cdfe970f7" } } }, diff --git a/configs/coins/ethereum_testnet_hoodi.json b/configs/coins/ethereum_testnet_hoodi.json index a472c05002..96feb4b8f6 100644 --- a/configs/coins/ethereum_testnet_hoodi.json +++ b/configs/coins/ethereum_testnet_hoodi.json @@ -23,13 +23,13 @@ "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", + "version": "3.5.5", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_amd64.tar.gz", "verification_type": "sha256", - "verification_source": "f72e38acbd4581f8e652f11923c0b72d67a53ce1770894a2bdb881d64722b097", + "verification_source": "2e875794234dc06fdcd0c716b38e936b8c820e90e16cee2d43fdfaf74df09ced", "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}}'", + "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}} --discovery.v4 --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\" --rpc.logs.maxresults 200000 --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", @@ -40,8 +40,8 @@ "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" + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_arm64.tar.gz", + "verification_source": "1e2e917b15338047f4a41cfd2cfd486b272c856fd549a68e3fe63d64762f8113" } } }, diff --git a/configs/coins/ethereum_testnet_hoodi_archive.json b/configs/coins/ethereum_testnet_hoodi_archive.json index 65e39a4022..5a9903561f 100644 --- a/configs/coins/ethereum_testnet_hoodi_archive.json +++ b/configs/coins/ethereum_testnet_hoodi_archive.json @@ -25,13 +25,13 @@ "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", + "version": "3.5.5", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_amd64.tar.gz", "verification_type": "sha256", - "verification_source": "f72e38acbd4581f8e652f11923c0b72d67a53ce1770894a2bdb881d64722b097", + "verification_source": "2e875794234dc06fdcd0c716b38e936b8c820e90e16cee2d43fdfaf74df09ced", "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}}'", + "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}} --discovery.v4 --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\" --rpc.logs.maxresults 200000 --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", @@ -42,8 +42,8 @@ "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" + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.5.5/erigon_v3.5.5_linux_arm64.tar.gz", + "verification_source": "1e2e917b15338047f4a41cfd2cfd486b272c856fd549a68e3fe63d64762f8113" } } }, diff --git a/configs/coins/ethereum_testnet_hoodi_archive_consensus.json b/configs/coins/ethereum_testnet_hoodi_archive_consensus.json index 3f9fc1a8e0..ff21fabb31 100644 --- a/configs/coins/ethereum_testnet_hoodi_archive_consensus.json +++ b/configs/coins/ethereum_testnet_hoodi_archive_consensus.json @@ -24,10 +24,10 @@ "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", + "version": "7.1.8", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-amd64", "verification_type": "sha256", - "verification_source": "a5402ea516d055f8ce150fff2ab4b73adbd8213789789e74c0e0a33eed3397ce", + "verification_source": "16c701e32bd27d7f0bdf43160318ef75844da9137667c28748c44f266ed13be7", "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'", @@ -41,8 +41,8 @@ "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" + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-arm64", + "verification_source": "f806344e07dd44c5266cb536bf5ca7e87a749a3c0d531c4201c4896cdfe970f7" } } }, diff --git a/configs/coins/ethereum_testnet_hoodi_consensus.json b/configs/coins/ethereum_testnet_hoodi_consensus.json index 58b869694b..86244c78fc 100644 --- a/configs/coins/ethereum_testnet_hoodi_consensus.json +++ b/configs/coins/ethereum_testnet_hoodi_consensus.json @@ -24,15 +24,15 @@ "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", + "version": "7.1.8", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-amd64", "verification_type": "sha256", - "verification_source": "a5402ea516d055f8ce150fff2ab4b73adbd8213789789e74c0e0a33eed3397ce", + "verification_source": "16c701e32bd27d7f0bdf43160318ef75844da9137667c28748c44f266ed13be7", "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", + "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, @@ -41,8 +41,8 @@ "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" + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.8/beacon-chain-v7.1.8-linux-arm64", + "verification_source": "f806344e07dd44c5266cb536bf5ca7e87a749a3c0d531c4201c4896cdfe970f7" } } }, diff --git a/configs/coins/ethereum_testnet_sepolia.json b/configs/coins/ethereum_testnet_sepolia.json index b2a4d4fadd..47b8a88e11 100644 --- a/configs/coins/ethereum_testnet_sepolia.json +++ b/configs/coins/ethereum_testnet_sepolia.json @@ -23,13 +23,13 @@ "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", + "version": "3.7.0", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.7.0/erigon_v3.7.0_linux_amd64.tar.gz", "verification_type": "sha256", - "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "verification_source": "fca0a2de71892917a5bfafe387d541b3bf36957b2160e34f421c9c585f0fc108", "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}}'", + "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}} --discovery.v4 --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\" --rpc.logs.maxresults 200000 --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", @@ -40,8 +40,8 @@ "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" + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.7.0/erigon_v3.7.0_linux_arm64.tar.gz", + "verification_source": "57ec92d0e68fbfb2436dab9c4c6af830a47f47fa29c2f20fafa0adbe67c8d25b" } } }, diff --git a/configs/coins/ethereum_testnet_sepolia_archive.json b/configs/coins/ethereum_testnet_sepolia_archive.json index 05344018c3..018a5a2201 100644 --- a/configs/coins/ethereum_testnet_sepolia_archive.json +++ b/configs/coins/ethereum_testnet_sepolia_archive.json @@ -25,13 +25,13 @@ "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", + "version": "3.7.0", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.7.0/erigon_v3.7.0_linux_amd64.tar.gz", "verification_type": "sha256", - "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "verification_source": "fca0a2de71892917a5bfafe387d541b3bf36957b2160e34f421c9c585f0fc108", "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}}'", + "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}} --discovery.v4 --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\" --rpc.logs.maxresults 200000 --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", @@ -42,8 +42,8 @@ "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" + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.7.0/erigon_v3.7.0_linux_arm64.tar.gz", + "verification_source": "57ec92d0e68fbfb2436dab9c4c6af830a47f47fa29c2f20fafa0adbe67c8d25b" } } }, diff --git a/configs/coins/ethereum_testnet_sepolia_archive_consensus.json b/configs/coins/ethereum_testnet_sepolia_archive_consensus.json index 5fdfdb29b8..ca75ed252f 100644 --- a/configs/coins/ethereum_testnet_sepolia_archive_consensus.json +++ b/configs/coins/ethereum_testnet_sepolia_archive_consensus.json @@ -24,10 +24,10 @@ "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", + "version": "7.2.0", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.2.0/beacon-chain-v7.2.0-linux-amd64", "verification_type": "sha256", - "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "verification_source": "9890817597bf6322a9015b2440ab7c5a84b9d52bb2cfc278d9caaff0a8edbdc2", "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'", @@ -41,8 +41,8 @@ "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" + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.2.0/beacon-chain-v7.2.0-linux-arm64", + "verification_source": "16f4a18f3c7fa52151f949cce355701ca08b7c2ccaa0e6999d7789987cd5b416" } } }, diff --git a/configs/coins/ethereum_testnet_sepolia_consensus.json b/configs/coins/ethereum_testnet_sepolia_consensus.json index 07ae25515f..47655aab7f 100644 --- a/configs/coins/ethereum_testnet_sepolia_consensus.json +++ b/configs/coins/ethereum_testnet_sepolia_consensus.json @@ -24,15 +24,15 @@ "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", + "version": "7.2.0", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.2.0/beacon-chain-v7.2.0-linux-amd64", "verification_type": "sha256", - "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "verification_source": "9890817597bf6322a9015b2440ab7c5a84b9d52bb2cfc278d9caaff0a8edbdc2", "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", + "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, @@ -41,8 +41,8 @@ "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" + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.2.0/beacon-chain-v7.2.0-linux-arm64", + "verification_source": "16f4a18f3c7fa52151f949cce355701ca08b7c2ccaa0e6999d7789987cd5b416" } } }, diff --git a/configs/coins/firo.json b/configs/coins/firo.json index f514651fbd..6ba26768b6 100644 --- a/configs/coins/firo.json +++ b/configs/coins/firo.json @@ -23,10 +23,10 @@ "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", + "version": "0.14.18.0", + "binary_url": "https://github.com/firoorg/firo/releases/download/v0.14.18.0/firo-0.14.18.0-linux64.tar.gz", "verification_type": "sha256", - "verification_source": "df6d0fb6abc8998909ecb3c3f4c5aa0b6bbf00474bb4739349d12079874754fc", + "verification_source": "b4b57108b66ee3ff9ec1b03dd7c24fe79acecf1110fe132f347758ae99555276", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/firo-qt", diff --git a/configs/coins/hyperevm_archive.json b/configs/coins/hyperevm_archive.json new file mode 100644 index 0000000000..73f33552b2 --- /dev/null +++ b/configs/coins/hyperevm_archive.json @@ -0,0 +1,90 @@ +{ + "coin": { + "name": "HyperEVM Archive", + "shortcut": "HYPE", + "network": "HYPE", + "label": "HyperEVM", + "alias": "hyperevm_archive" + }, + "ports": { + "backend_rpc": 8214, + "backend_p2p": 38414, + "backend_http": 8314, + "blockbook_internal": 9214, + "blockbook_public": 9314 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendHttp}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-hyperevm-archive", + "package_revision": "satoshilabs-1", + "system_user": "hyperevm", + "version": "20260613", + "binary_url": "https://github.com/hl-archive-node/nanoreth/releases/download/nb-20260613/nanoreth-x86_64-unknown-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "117ccf5a82fff8e28e7be82134cb81d02b85243fbc0cbc6e2af7e5430f4012d0", + "extract_command": "tar -C backend -xf", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/hyperevm_archive_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "hyperevm_archive.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": "" + }, + "blockbook": { + "package_name": "blockbook-hyperevm-archive", + "system_user": "blockbook-hyperevm", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 1000, + "additional_params": { + "averageBlockTimeMs": 1000, + "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/999/suggestedGasFees\", \"periodSeconds\": 10}", + "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\": \"hyperliquid\",\"platformIdentifier\": \"hyperevm\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/litecoin.json b/configs/coins/litecoin.json index 51253bc8e5..95dd864081 100644 --- a/configs/coins/litecoin.json +++ b/configs/coins/litecoin.json @@ -23,10 +23,10 @@ "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", + "version": "0.21.5.6", + "binary_url": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-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", + "verification_source": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-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", @@ -43,8 +43,8 @@ }, "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" + "binary_url": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-aarch64-linux-gnu.tar.gz", + "verification_source": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-aarch64-linux-gnu.tar.gz.asc" } } }, diff --git a/configs/coins/litecoin_testnet.json b/configs/coins/litecoin_testnet.json index 8a15853335..87fd7499c0 100644 --- a/configs/coins/litecoin_testnet.json +++ b/configs/coins/litecoin_testnet.json @@ -23,10 +23,10 @@ "package_name": "backend-litecoin-testnet", "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", + "version": "0.21.5.6", + "binary_url": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-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", + "verification_source": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-x86_64-linux-gnu.tar.gz.asc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/litecoin-qt" @@ -45,8 +45,8 @@ }, "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" + "binary_url": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-aarch64-linux-gnu.tar.gz", + "verification_source": "https://download.litecoin.org/litecoin-0.21.5.6/linux/litecoin-0.21.5.6-aarch64-linux-gnu.tar.gz.asc" } } }, diff --git a/configs/coins/optimism.json b/configs/coins/optimism.json index 8cd1a4cd0c..21f0a35e7d 100644 --- a/configs/coins/optimism.json +++ b/configs/coins/optimism.json @@ -56,6 +56,7 @@ "averageBlockTimeMs": 2000, "mempoolTxTimeoutHours": 12, "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\": \"optimistic-ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" diff --git a/configs/coins/optimism_archive.json b/configs/coins/optimism_archive.json index ec273cbe7c..f7b78eedcc 100644 --- a/configs/coins/optimism_archive.json +++ b/configs/coins/optimism_archive.json @@ -64,15 +64,24 @@ "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}", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/10/suggestedGasFees\", \"periodSeconds\": 15}", "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\": \"optimistic-ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } } } }, diff --git a/configs/coins/polygon.json b/configs/coins/polygon.json index 89eef8fe6f..86c435d981 100644 --- a/configs/coins/polygon.json +++ b/configs/coins/polygon.json @@ -58,7 +58,7 @@ "mempool_sub_workers": 2, "block_addresses_to_keep": 300, "additional_params": { - "averageBlockTimeMs": 2000, + "averageBlockTimeMs": 1500, "mempoolTxTimeoutHours": 12, "queryBackendOnMempoolResync": false, "fiat_rates": "coingecko", diff --git a/configs/coins/polygon_archive.json b/configs/coins/polygon_archive.json index de424c42e9..5e59758d88 100644 --- a/configs/coins/polygon_archive.json +++ b/configs/coins/polygon_archive.json @@ -59,7 +59,7 @@ "mempool_sub_workers": 2, "block_addresses_to_keep": 600, "additional_params": { - "averageBlockTimeMs": 2000, + "averageBlockTimeMs": 1500, "missingBlockRetry": { "retryDelayMs": 1000, "recheckThreshold": 10, @@ -69,7 +69,7 @@ "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}", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/137/suggestedGasFees\", \"periodSeconds\": 10}", "mempoolTxTimeoutHours": 12, "processInternalTransactions": true, "trace_timeout": "10s", @@ -79,6 +79,14 @@ "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/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } } } }, diff --git a/configs/coins/robinhood_archive.json b/configs/coins/robinhood_archive.json new file mode 100644 index 0000000000..344fda9615 --- /dev/null +++ b/configs/coins/robinhood_archive.json @@ -0,0 +1,91 @@ +{ + "coin": { + "name": "Robinhood Archive", + "shortcut": "ETH", + "network": "RHC", + "label": "Robinhood Chain", + "alias": "robinhood_archive" + }, + "ports": { + "backend_rpc": 8213, + "backend_p2p": 38413, + "backend_http": 8313, + "blockbook_internal": 9213, + "blockbook_public": 9313 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendHttp}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-robinhood-archive", + "package_revision": "satoshilabs-1", + "system_user": "robinhood", + "version": "3.11.2", + "docker_image": "offchainlabs/nitro-node:v3.11.2-3599aca", + "verification_type": "docker", + "verification_source": "ebc985e3b105980734630744981e1542001c22d74cba57509fe0d5ed8bb84c14", + "extract_command": "docker cp extract:/home/user/target backend/target; docker cp extract:/usr/local/bin/nitro backend/nitro", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/robinhood_archive_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "robinhood_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": false, + "mainnet": true, + "server_config_file": "robinhood_genesis.json", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-robinhood-archive", + "system_user": "blockbook-robinhood", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 4500, + "additional_params": { + "averageBlockTimeMs": 100, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/4663/suggestedGasFees\", \"periodSeconds\": 10}", + "mempoolTxTimeoutHours": 12, + "mempoolTxTimeout": "12h", + "alternativeMempoolTxTimeout": "12h", + "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\": \"robinhood\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/robinhood_testnet.json b/configs/coins/robinhood_testnet.json new file mode 100644 index 0000000000..6e22ff3c0a --- /dev/null +++ b/configs/coins/robinhood_testnet.json @@ -0,0 +1,76 @@ +{ + "coin": { + "name": "Robinhood Testnet", + "shortcut": "tETH", + "network": "tRHC", + "label": "Robinhood Chain Testnet", + "alias": "robinhood_testnet" + }, + "ports": { + "backend_rpc": 18077, + "backend_p2p": 48377, + "backend_http": 18177, + "blockbook_internal": 19077, + "blockbook_public": 19177 + }, + "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-robinhood-testnet", + "package_revision": "satoshilabs-1", + "system_user": "robinhood", + "version": "3.11.2", + "docker_image": "offchainlabs/nitro-node:v3.11.2-3599aca", + "verification_type": "docker", + "verification_source": "ebc985e3b105980734630744981e1542001c22d74cba57509fe0d5ed8bb84c14", + "extract_command": "docker cp extract:/home/user/target backend/target; docker cp extract:/usr/local/bin/nitro backend/nitro", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/robinhood_testnet_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "robinhood_testnet.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": false, + "mainnet": false, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-robinhood-testnet", + "system_user": "blockbook-robinhood", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=509", + "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 + }, + "eip1559Fees": true, + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "10s", + "queryBackendOnMempoolResync": false, + "disableMempoolSync": true, + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/tron.json b/configs/coins/tron.json index 0999457da1..fe5cd40acd 100644 --- a/configs/coins/tron.json +++ b/configs/coins/tron.json @@ -19,13 +19,13 @@ }, "backend": { "package_name": "backend-tron", - "package_revision": "latest", + "package_revision": "satoshilabs-1", "system_user": "tron", - "version": "4.7.7", - "binary_url": "https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.7.7/FullNode.jar", + "version": "4.8.2.3", + "binary_url": "https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.2.3/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 ", + "verification_source": "faef7f4d89b9f154632bbab2db1712c25f02ee37b356013e098558ea994accec", + "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\\n constantCallTimeoutMs = 5000/' main_net_config.conf && sed -i 's/^[ \\t]*saveInternalTx[ \\t]*=[ \\t]*false/saveInternalTx = 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", @@ -51,6 +51,7 @@ "block_addresses_to_keep": 10000, "additional_params": { "address_aliases": true, + "processInternalTransactions": true, "mempoolTxTimeoutHours": 4, "queryBackendOnMempoolResync": true, "averageBlockTimeMs": 3000, diff --git a/configs/coins/tron_testnet_nile.json b/configs/coins/tron_testnet_nile.json index 35739d1a3e..34f1926f52 100644 --- a/configs/coins/tron_testnet_nile.json +++ b/configs/coins/tron_testnet_nile.json @@ -20,15 +20,15 @@ }, "backend": { "package_name": "backend-tron-testnet-nile", - "package_revision": "latest", + "package_revision": "satoshilabs-1", "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", + "version": "4.8.2.2-pq1-build1", + "binary_url": "https://github.com/tron-nile-testnet/nile-testnet/releases/download/GreatVoyage-Nile-v4.8.2.2-PQ1-build1/FullNode-Nile-x64-4.8.2.2-pq1-build1.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 ", + "verification_source": "bf3f606aff7130635eda4e94f439774e58e443b51c86d34228c1f151a9b17534", + "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 && sed -i 's/^[ \\t]*saveFeaturedInternalTx[ \\t]*=[ \\t]*true/saveFeaturedInternalTx = false/' 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", + "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.2.2-pq1-build1.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", @@ -52,6 +52,7 @@ "block_addresses_to_keep": 10000, "additional_params": { "address_aliases": true, + "processInternalTransactions": true, "mempoolTxTimeoutHours": 4, "queryBackendOnMempoolResync": true, "averageBlockTimeMs": 3000, diff --git a/configs/coins/zcash.json b/configs/coins/zcash.json index e97929860f..168878faad 100644 --- a/configs/coins/zcash.json +++ b/configs/coins/zcash.json @@ -21,10 +21,10 @@ "package_name": "backend-zcash", "package_revision": "satoshilabs-1", "system_user": "zcash", - "version": "5.0.0", - "docker_image": "zfnd/zebra:5.0.0", + "version": "6.2.2", + "docker_image": "zfnd/zebra:6.2.2", "verification_type": "docker", - "verification_source": "cfcdc088977ccd85562a60911845b24d962317c93d861b12aa1b98813f9f2374", + "verification_source": "f464a4bf44c3402b2c9063c6df686b9177a4659ea61aecdc8aeb6947f2173197", "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", diff --git a/configs/coins/zcash_testnet.json b/configs/coins/zcash_testnet.json index f4c99ce127..bbba88a6f4 100644 --- a/configs/coins/zcash_testnet.json +++ b/configs/coins/zcash_testnet.json @@ -21,10 +21,10 @@ "package_name": "backend-zcash-testnet", "package_revision": "satoshilabs-1", "system_user": "zcash", - "version": "5.0.0", - "docker_image": "zfnd/zebra:5.0.0", + "version": "6.2.2", + "docker_image": "zfnd/zebra:6.2.2", "verification_type": "docker", - "verification_source": "cfcdc088977ccd85562a60911845b24d962317c93d861b12aa1b98813f9f2374", + "verification_source": "f464a4bf44c3402b2c9063c6df686b9177a4659ea61aecdc8aeb6947f2173197", "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", diff --git a/configs/grafana/README.md b/configs/grafana/README.md index 21ee1ae32d..a26293e49f 100644 --- a/configs/grafana/README.md +++ b/configs/grafana/README.md @@ -41,6 +41,24 @@ Use stable, descriptive keys: `x-panel-key` should look like `
. ` | `{{instance}} - {{method}} p95`, `{{instance}} - {{mode}}/s` | +| summed across replicas on purpose | `all replicas - ` | `all replicas - {{path}}/min` | +| constant reference line (`vector(...)`) | plain text | `stale cutoff (900s = 15 reload periods)` | +| table (`__auto`) or an all-coins panel with no `coin="$coin"` filter | Grafana default / `{{coin}} - {{instance}}` | `general.synchronized` | + +Aggregate `by (instance, ...)` when the panel exists to point at a misbehaving replica (errors, +retries, latency, send-path routing, per-replica caches); keep the fleet sum where the panel measures +load or an external dependency and per-replica lines would only multiply. `--check` rejects a legend +that repeats `{{coin}}`, lacks a replica scope, or claims a scope the query does not have +(`{{instance}}` on a query that sums it away, `all replicas` on a per-instance query). + ## Render ```bash @@ -50,7 +68,8 @@ python3 contrib/scripts/render_grafana.py --check # validate alignment only, no `--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`. +a leftover placeholder, a legend off the convention above, 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. diff --git a/configs/grafana/panels.yaml b/configs/grafana/panels.yaml index 9315604ccf..4c29fc3abc 100644 --- a/configs/grafana/panels.yaml +++ b/configs/grafana/panels.yaml @@ -14,7 +14,7 @@ panels: queries: virtual: promql: process_virtual_memory_bytes{job="blockbook"} - legend: vm-{{instance}} + legend: '{{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. @@ -22,6 +22,23 @@ panels: allocated: promql: go_memstats_alloc_bytes{job="blockbook"} legend: '{{instance}}' + system.go_gc_cpu: + title: Go GC share of process CPU + description: Fraction of the CPU the process actually used (idle excluded) that the Go garbage collector consumed; sustained values above ~0.25 mean GOGC/GOMEMLIMIT can trade heap for CPU, below ~0.05 GC tuning gains nothing. + queries: + gc_share: + promql: sum by (instance) (rate(go_cpu_classes_gc_total_cpu_seconds_total{job="blockbook"}[5m])) / sum by (instance) (rate(go_cpu_classes_total_cpu_seconds_total{job="blockbook"}[5m]) - rate(go_cpu_classes_idle_cpu_seconds_total{job="blockbook"}[5m])) + legend: '{{instance}}' + system.go_heap_live: + title: Go live heap vs GC goal + description: Live heap after the last mark and the heap size the next GC cycle is aimed at; the gap is what GOGC=100 costs in RAM and what a GOMEMLIMIT would cap. RSS above the goal is RocksDB and other native memory. + queries: + live: + promql: go_gc_heap_live_bytes{job="blockbook"} + legend: 'live {{instance}}' + goal: + promql: go_gc_heap_goal_bytes{job="blockbook"} + legend: 'goal {{instance}}' system.open_fds: title: Open file descriptors description: Sustained growth can indicate leaked sockets or files. @@ -50,71 +67,105 @@ panels: 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}}' + promql: sum(rate({{name:rpc_latency}}_count{job="blockbook", coin="$coin"}[1m])) by (instance, method) * 60 + legend: '{{instance}} - {{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}}' + promql: histogram_quantile(0.95, sum(rate({{name:rpc_latency}}_bucket{job="blockbook", coin="$coin"}[1m])) by (instance, method, le)) + legend: '{{instance}} - {{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}}' + promql: sum(rate({{name:rpc_fallback_calls_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, component, reason) * 60 + legend: '{{instance}} - {{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' + 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' + 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' + 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' + 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' + 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' + 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}}' + promql: sum(rate({{name:eth_sync_rpc_errors}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, method, status) * 60 + legend: '{{instance}} - {{method}} - {{status}}' + rpc.sendtx_requests: + title: Transaction sends per minute + description: 'Client transaction broadcasts through every API (websocket sendTransaction, REST sendtx, explorer form), split by status, with the failures broken down by rejection class. Read the class before reacting: underpriced, replacement_underpriced, nonce_too_low, nonce_too_high, insufficient_funds and already_known are the wallet''s own transaction being refused on its merits and need nothing from us, while rate_limited, unauthorized, timeout and unavailable mean the send never got a verdict from the backend or provider and is the operator''s problem. A rising "other" share means the classifier needs a new pattern, not that anything is broken. Split by transport with websocket_requests{method="sendTransaction"} and explorer_views{action=~"(api-)?sendtx"}.' + queries: + by_status: + promql: sum(rate({{name:sendtx_requests_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, status) * 60 + legend: '{{instance}} - {{status}}/min' + failures_by_reason: + promql: sum(rate({{name:sendtx_requests_total}}{job="blockbook", coin="$coin", status="failure"}[$__rate_interval])) by (instance, reason) * 60 + legend: '{{instance}} - failure {{reason}}/min' + rpc.eth_sendtx_path: + title: EVM send path per minute + description: 'Which path served each EVM broadcast. alternative = the private relay accepted it (or, with _ALTERNATIVE_SENDTX_ONLY set, the only path attempted); primary = the public RPC, because no alternative provider is configured or the client set disableAlternativeRPC; primary_fallback = every alternative provider rejected and the public RPC was tried instead. On a chain meant to broadcast privately, any primary_fallback traffic means transactions are leaking to the public mempool - correlate with the per-provider panel to the right to see which relay dropped out.' + queries: + by_path: + promql: sum(rate({{name:eth_sendtx_path_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, send_path, status) * 60 + legend: '{{instance}} - {{send_path}} - {{status}}/min' + rpc.eth_alternative_sendtx_providers: + title: Alternative send-tx provider outcomes per minute + description: 'eth_sendRawTransaction calls to each configured alternative provider, by provider_host, status and rejection class. A broadcast succeeds if ANY provider accepts it, so errors here are not failed sends on their own - a single relay can be down for a long time without any user noticing, and this is the only panel that shows it. Compare against the EVM send path panel: errors here with no primary_fallback there means the remaining providers are absorbing the traffic. The label is provider_host rather than the registry''s provider (which carries a symbolic name such as infura), because these providers are configured as a list of URLs and a host is the only stable identity they have; providers sharing a host therefore share a series.' + queries: + by_provider: + promql: sum(rate({{name:eth_alternative_sendtx_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (provider_host, status, reason) * 60 + legend: 'all replicas - {{provider_host}} - {{status}} {{reason}}/min' + rpc.eth_alternative_sendtx_duration: + title: Alternative send-tx provider latency + description: 'p50/p95 of an eth_sendRawTransaction call to an alternative provider, per provider_host and status. Private relays sit on the wallet''s critical path - the user waits for exactly this - so a p95 climbing toward the configured _RPC_TIMEOUT is a user-visible regression well before it turns into failed sends. An error series pinned at the top bucket is the provider timing out rather than rejecting; read it next to the rejection classes in the panel to the left.' + queries: + p50: + promql: histogram_quantile(0.5, sum(rate({{name:eth_alternative_sendtx_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (provider_host, status, le)) + legend: 'all replicas - {{provider_host}} {{status}} p50' + p95: + promql: histogram_quantile(0.95, sum(rate({{name:eth_alternative_sendtx_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (provider_host, status, le)) + legend: 'all replicas - {{provider_host}} {{status}} p95' row.sync: title: Sync sync.index_resync_duration_p95: @@ -122,109 +173,168 @@ panels: 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}}' + promql: histogram_quantile(0.95, sum(rate({{name:index_resync_duration}}_bucket{job="blockbook", coin="$coin"}[1m])) by (instance, le)) + legend: '{{instance}}' 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}}' + promql: sum(rate({{name:index_resync_errors}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, error) * 60 + legend: '{{instance}} - {{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}}' + legend: '{{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}}' + promql: sum(increase({{name:index_reorg_events}}{job="blockbook", coin="$coin"}[$__interval])) by (instance, type) + legend: '{{instance}} - {{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. + description: Sync gave control back to outer resync because a deadline elapsed, chain-state probes kept failing, or the sequential loop fell too far behind the backend tip. queries: yields: - promql: sum(increase({{name:index_sync_yields}}{job="blockbook", coin="$coin"}[$__interval])) by (reason) - legend: '{{reason}}' + promql: sum(increase({{name:index_sync_yields}}{job="blockbook", coin="$coin"}[$__interval])) by (instance, reason) + legend: '{{instance}} - {{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}}' + 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}}' + 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}}' + legend: '{{instance}}' + sync.mempool_reload_age: + title: Mempool reload age + description: 'Time since the last SUCCESSFUL mempool reload (time() - last sync). The reload loop runs about every 60s (resyncmempoolperiod), so a healthy node draws a sawtooth that resets under a minute; a line climbing without resetting means reloads are failing or the loop is wedged. Read this instead of the /api/ inSyncMempool flag, which is cleared at the START of every reload and so reads "not syncing" for a large fraction of samples on a healthy node. The series is absent until the first successful reload, so it is blank during an initial sync and on a coin with mempool sync disabled. The dashed line marks the 15-reload cutoff the BlockbookMempoolReloadStale alert uses.' + queries: + age: + promql: (time() - {{name:mempool_last_sync_timestamp_seconds}}{job="blockbook", coin="$coin"}) + legend: '{{instance}} - {{chain}}' + cutoff: + promql: vector(900) + legend: stale cutoff (900s = 15 reload periods) 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. + description: Decisions over the alternative-provider-only send-tx cache lifecycle (events/min by action). mined, nonce_superseded, provider_missing (missing from the relay for alternativeMissingTxTimeout - the dropped/cancelled exit, since the relay surfaces an accepted tx for its whole pending window), timeout, rbf_replaced and sync_removed remove an entry; skipped_fresh, skipped_backoff, kept, provider_error and provider_missing_pending retain it for a later cycle. Expect sync_removed to dominate and mined to be rare - block sync clears a confirmed private tx before the next reconcile probe would observe it as mined. queries: actions: - promql: sum(rate({{name:eth_alternative_mempool_reconciliation_events_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (action) * 60 - legend: '{{action}}/min' + promql: sum(rate({{name:eth_alternative_mempool_reconciliation_events_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, action) * 60 + legend: '{{instance}} - {{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. + description: Transactions currently held in the alternative-provider-only send-tx cache, sampled at the end of each reconcile cycle. It counts in-flight private sends; one the relay stops surfacing (dropped, cancelled) leaves through the missing eviction (alternativeMissingTxTimeout, 2m by default), so only txs the relay keeps reporting can stay for the full cache timeout (alternativePendingTxWindow, 3h by default). A steady climb means entries are added faster than they are mined or evicted; re-baseline the panel after changing those timeouts. queries: size: promql: '{{name:eth_alternative_mempool_cache_size}}{job="blockbook", coin="$coin"}' - legend: '{{coin}} - {{instance}}' + legend: '{{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. + description: p50/p95 of how long an alternative send-tx cache entry survives before each eviction reason fires. provider_missing is the dropped/cancelled exit; residence counts age since broadcast, so a cancel N minutes after the send records ~N plus alternativeMissingTxTimeout - read the reason, not the age. Collapsing below one reconcile cycle would be the premature-eviction regression (#1573); an entry reaching the cache timeout leaves as timeout even when the relay answers null, so with the missing eviction configured off provider_missing stops firing. sync_removed, normally the dominant exit, 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' + promql: histogram_quantile(0.5, sum(rate({{name:eth_alternative_mempool_tx_residence_seconds}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, action, le)) + legend: '{{instance}} - {{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' + promql: histogram_quantile(0.95, sum(rate({{name:eth_alternative_mempool_tx_residence_seconds}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, action, le)) + legend: '{{instance}} - {{action}} p95' + sync.eth_alternative_nonce_requests: + title: Alternative send-tx provider nonce requests (req/s) + description: 'eth_getTransactionCount lookups routed to the alternative send-tx provider, per second by result (error = fell back to the primary RPC). Routed on a declared privatePending hint or on the recent-sender window (15 min, see useForNonces / #1627), so it is a subset of nonce traffic tracking accounts with a pending tx - hint-declaring wallets re-declare on every account sync while anything is pending, so the rate follows pendinghood, not recent sends through this instance. A rising error share means the relay is unhealthy.' + queries: + by_result: + promql: sum by (instance, result) (rate({{name:eth_alternative_nonce_requests_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) + legend: '{{instance}} - {{result}}' + sync.eth_alternative_estimate_gas_requests: + title: Alternative send-tx provider gas estimates (req/s) + description: 'eth_estimateGas calls routed to the alternative send-tx provider, per second by result (error = fell back to the primary RPC). Routed on a declared privatePending hint or on the recent-sender window (15 min, see useForNonces / #1629), so it is a subset of estimateFee traffic - but a declaring wallet composing a send estimates at keystroke rate, so volume tracks senders with something pending rather than the 15-min window. Hint-less keystroke traffic still going to the relay for everyone would be the #1629 regression; a rising error share means the relay is unhealthy.' + queries: + by_result: + promql: sum by (instance, result) (rate({{name:eth_alternative_estimate_gas_requests_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) + legend: '{{instance}} - {{result}}' + sync.eth_alternative_mempool_oldest_age: + title: Alternative mempool oldest cached tx age (stuck-tx signal) + description: 'Age of the oldest transaction still in the alternative send-tx cache (1-min sampled at reconcile end), the live signal residence cannot give. Pinning near the cache timeout (alternativeMempoolTxTimeout - 3h by default, but 12h/48h on the coins that raised it) at non-zero cache depth means private txs the relay still surfaces are dying underpriced rather than mining - a dropped or cancelled tx leaves much earlier through the missing eviction (alternativeMissingTxTimeout, 2m by default). Empty is expected on coins without a configured relay (only arb/base/bsc/eth).' + queries: + age: + promql: '{{name:eth_alternative_mempool_oldest_age_seconds}}{job="blockbook", coin="$coin"}' + legend: '{{instance}}' + sync.eth_alternative_send_not_surfaced: + title: Alternative send not surfaced by the relay (req/s) + description: 'Relay-accepted private sends whose post-send fetch-back did not surface, per second by reason (not_found = relay returned null, error = relay call failed, dropped = fetch-back refused under load). The tx is still cached and served as pending; if the relay keeps not surfacing it, the missing eviction (alternativeMissingTxTimeout) retires it. Should be ~0 - the relay answers from one consistent store, so even the immediate post-send fetch-back sees an accepted tx; a sustained rate means the relay stopped surfacing what it accepts, the pre-alignment behavior. Empty is expected on coins without a configured relay (only arb/base/bsc/eth).' + queries: + by_reason: + promql: sum by (instance, reason) (rate({{name:eth_alternative_send_not_surfaced_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) + legend: '{{instance}} - {{reason}}' + sync.eth_alternative_pending_floor_raised: + title: Alternative pending-nonce floor raised (req/s) + description: 'eth_getTransactionCount answers where the pending nonce was advanced above the backend answer, per second by source. primary (the RPC never knew the private tx) is expected while a private send is in flight; provider should be ~0 now that the relay counts a pending tx over its whole window - a sustained rate means its pending count regressed below that window again, with a revert-protected send between its 96s retention lapsing and the missing eviction firing as the benign exception. The cost is bounded: a send that never mines holds its nonce slot until the missing eviction (alternativeMissingTxTimeout) retires it. Empty is expected on coins without a configured relay (only arb/base/bsc/eth).' + queries: + by_source: + promql: sum by (instance, source) (rate({{name:eth_alternative_pending_floor_raised_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) + legend: '{{instance}} - {{source}}' + sync.eth_alternative_pending_floor_stranded: + title: 'Alternative pending-nonce floor stranded above a hole (req/s, #1675 signal)' + description: 'eth_getTransactionCount answers where the cache held a private tx above a nonce hole - a slot nothing Blockbook knows of fills. The floor deliberately stops below the hole (#1675), so the stranded tx cannot mine until the hole is filled. A caller-declared nonce (privatePending) counts here as well as a cached one. Should be ~0; a sustained rate on source=provider means relay-accepted sends are not reaching the cache - cross-read with the send-not-surfaced panel. Each sample is one REQUEST, not one transaction. Empty is expected on coins without a configured relay (only arb/base/bsc/eth).' + queries: + by_source: + promql: sum by (instance, source) (rate({{name:eth_alternative_pending_floor_stranded_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) + legend: '{{instance}} - {{source}}' + sync.eth_alternative_mempool_death_ratio: + title: Alternative mempool exit death ratio (stuck / underpriced) + description: 'Fraction of cache EXITS that were deaths (provider_missing + timeout) versus settlements (mined, sync_removed, nonce_superseded, rbf_replaced); the keep actions are excluded from both. Derived from eth_alternative_mempool_reconciliation_events_total - no new metric. A rising ratio is the underpriced / stuck-send signal; on a healthy relay it stays near 0 with sync_removed dominating. Cancel-heavy traffic lifts it legitimately - a drop-mode cancel never mines by design, so it always exits as provider_missing or timeout. Empty is expected on coins without a configured relay (only arb/base/bsc/eth).' + queries: + death_ratio: + promql: sum by (instance) (rate({{name:eth_alternative_mempool_reconciliation_events_total}}{job="blockbook", coin="$coin", action=~"provider_missing|timeout"}[$__rate_interval])) / clamp_min(sum by (instance) (rate({{name:eth_alternative_mempool_reconciliation_events_total}}{job="blockbook", coin="$coin", action=~"mined|sync_removed|nonce_superseded|rbf_replaced|provider_missing|timeout"}[$__rate_interval])), 1e-9) + legend: '{{instance}} - death ratio' 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}}' + promql: histogram_quantile(0.95, sum(rate({{name:mempool_resync_throughput_txs_per_second}}_bucket{job="blockbook", coin="$coin"}[1m])) by (instance, chain, status, le)) + legend: '{{instance}} - {{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}}' + promql: histogram_quantile(0.95, sum(rate({{name:mempool_resync_duration}}_bucket{job="blockbook", coin="$coin"}[5m])) by (instance, le)) + legend: '{{instance}}' 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}}' + 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}}' + 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. @@ -245,47 +355,57 @@ panels: queries: hits: promql: rate({{name:addr_contracts_cache_hits_total}}{job="blockbook", coin="$coin"}[5m]) * 60 - legend: '{{instance}} hits/min' + legend: '{{instance}} - hits/min' misses: promql: rate({{name:addr_contracts_cache_misses_total}}{job="blockbook", coin="$coin"}[5m]) * 60 - legend: '{{instance}} misses/min' + legend: '{{instance}} - misses/min' flushes: promql: rate({{name:addr_contracts_cache_flush_total}}{job="blockbook", coin="$coin"}[1h]) * 60 - legend: '{{instance}} flushes {{reason}}/min' + 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}}' + legend: '{{instance}}' threshold: promql: clamp(30 * {{name:average_block_time_seconds}}{job="blockbook", coin="$coin"}, 30, 300) - legend: '{{coin}} stall threshold' + legend: '{{instance}} - 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' + promql: sum by (instance) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin", event="watchdog_stall"}[5m])) + legend: '{{instance}} - 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' + promql: sum by (instance) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin", event="watchdog_tip_advanced"}[5m])) + legend: '{{instance}} - 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' + promql: sum by (instance) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin", event=~"watchdog_reconnect_failed|resubscribe_failed"}[5m])) + legend: '{{instance}} - 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}}' + promql: sum by (instance, subscription, event) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin"}[5m])) + legend: '{{instance}} - {{subscription}} - {{event}}' + sync.rpc_sync_latency: + title: Sync RPC latency (p50/p95) + description: Round-trip time of successful sync-internal RPC calls (eth_getBlockByHash, eth_getLogs, debug_traceBlockByHash, etc.) that are not tracked by the general RPC latency panel; these directly affect block indexing speed. Typically sub-100ms; a sustained multi-second p95 means the backend is overloaded. Failed calls are excluded so timeout expiries (~10-15s) do not skew the quantiles; they show up in the Ethereum sync RPC errors panel instead. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:rpc_sync_latency}}_bucket{job="blockbook", coin="$coin", error=""}[1m])) by (instance, method, le)) + legend: '{{instance}} - {{method}} p95' + p50: + promql: histogram_quantile(0.50, sum(rate({{name:rpc_sync_latency}}_bucket{job="blockbook", coin="$coin", error=""}[1m])) by (instance, method, le)) + legend: '{{instance}} - {{method}} p50' row.websocket: title: Websocket websocket.clients: @@ -294,103 +414,117 @@ panels: queries: clients: promql: '{{name:websocket_clients}}{job="blockbook", coin="$coin"}' - legend: '{{coin}} - {{instance}}' + legend: '{{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}}' + promql: sum(rate({{name:websocket_requests}}{job="blockbook", coin="$coin"}[1m])) by (method, status) * 60 + legend: 'all replicas - {{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}} + legend: '{{instance}} - {{method}}' new_block_txs: promql: '{{name:websocket_new_block_txs_subscriptions}}{job="blockbook", coin="$coin"}' - legend: newBlockTxs subscriptions - {{instance}} + legend: '{{instance}} - newBlockTxs' 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' + promql: sum(rate({{name:websocket_new_block_notifications}}{job="blockbook", coin="$coin"}[1m])) by (instance) * 60 + legend: '{{instance}} - 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}}' + promql: histogram_quantile(0.95, sum(rate({{name:websocket_req_duration}}_bucket{job="blockbook", coin="$coin"}[1m])) by (method, le)) + legend: 'all replicas - {{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}}' + promql: sum(rate({{name:websocket_new_block_txs}}{job="blockbook", coin="$coin"}[1m])) by (instance, stage, status) * 60 + legend: '{{instance}} - {{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}}' + promql: histogram_quantile(0.95, sum(rate({{name:websocket_new_block_txs_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[1m])) by (instance, stage, le)) + legend: '{{instance}} - {{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}}' + promql: sum(rate({{name:websocket_addr_notifications}}{job="blockbook", coin="$coin"}[1m])) by (instance, source) * 60 + legend: '{{instance}} - {{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}} + promql: sum(rate({{name:websocket_channel_closes}}{job="blockbook", coin="$coin"}[1m])) by (reason) * 60 + legend: 'all replicas - closes {{reason}}' unknown: - promql: sum(rate({{name:websocket_unknown_methods}}{job="blockbook", coin="$coin"}[1m])) by (coin, method) * 60 - legend: unknown {{method}} - {{coin}} + promql: sum(rate({{name:websocket_unknown_methods}}{job="blockbook", coin="$coin"}[1m])) by (method) * 60 + legend: 'all replicas - unknown {{method}}' 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' + promql: sum(rate({{name:websocket_eth_receipt}}{job="blockbook", coin="$coin"}[5m])) by (instance, status) * 60 + legend: '{{instance}} - {{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' + promql: histogram_quantile(0.50, sum(rate({{name:websocket_connection_requests}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (le)) + legend: 'all replicas - 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' + promql: histogram_quantile(0.99, sum(rate({{name:websocket_connection_requests}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (le)) + legend: 'all replicas - 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' + promql: sum(rate({{name:websocket_connection_rejections}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, reason) * 60 + legend: '{{instance}} - {{reason}}/min' + websocket.blocked_ips: + title: Websocket IPs currently blocked + description: '{{help:websocket_blocked_ips}}. Every connection attempt from a blocked key is rejected with HTTP 429 before the upgrade, so a sustained non-zero value means real clients are locked out right now; under CGNAT one blocked key can cover many users. Refreshed once per limiter maintenance tick (1 minute). The internal status page lists the blocked keys with expiry and allows resetting the blocklist.' + queries: + blocked_ips: + promql: sum({{name:websocket_blocked_ips}}{job="blockbook", coin="$coin"}) + legend: 'all replicas - blocked IPs' + websocket.blocked_connections: + title: Websocket blocked-IP rejections per minute + description: 'Connection attempts rejected because the client key is on the temporary message-rate blocklist (HTTP 429 before the upgrade). Enforcement counterpart of the blocked-IPs gauge: high values mean banned clients are hammering reconnects for the whole block duration. Distinct from connection rejections, which count the per-IP concurrent-connection and attempt-rate caps rather than the message-rate ban.' + queries: + blocked_rejections: + promql: sum(rate({{name:websocket_blocked_connections}}{job="blockbook", coin="$coin"}[$__rate_interval])) * 60 + legend: 'all replicas - rejected/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' + promql: sum({{name:websocket_clients}}{job="blockbook", coin="$coin"}) + legend: 'all replicas - connections' unique_ips: - promql: sum({{name:websocket_unique_ips}}{job="blockbook", coin="$coin"}) by (coin) - legend: '{{coin}} - unique IPs' + promql: sum({{name:websocket_unique_ips}}{job="blockbook", coin="$coin"}) + legend: 'all replicas - 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.' @@ -399,31 +533,31 @@ panels: # 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' + promql: sum({{name:websocket_clients}}{job="blockbook", coin="$coin"}) / clamp_min(sum({{name:websocket_unique_ips}}{job="blockbook", coin="$coin"}), 1) + legend: 'all replicas - avg per IP' max: - promql: max({{name:websocket_max_connections_per_ip}}{job="blockbook", coin="$coin"}) by (coin) - legend: '{{coin}} - max per IP' + promql: max({{name:websocket_max_connections_per_ip}}{job="blockbook", coin="$coin"}) + legend: 'all replicas - 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' + promql: histogram_quantile(0.50, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (path, le)) + legend: 'all replicas - {{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' + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (path, le)) + legend: 'all replicas - {{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' + promql: histogram_quantile(0.99, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (path, le)) + legend: 'all replicas - {{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' + promql: sum(rate({{name:balance_history_cap_exceeded_total}}{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (path) * 60 + legend: 'all replicas - {{path}}/min' row.rest_api: title: REST API rest_api.requests: @@ -431,59 +565,59 @@ panels: 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' + promql: sum(rate({{name:explorer_views}}{job="blockbook", coin="$coin", action=~"api-.*"}[$__rate_interval])) by (action) * 60 + legend: 'all replicas - {{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' + promql: sum(rate({{name:explorer_views}}{job="blockbook", coin="$coin", action=~"api-.*balancehistory"}[$__rate_interval])) by (action) * 60 + legend: 'all replicas - {{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' + promql: sum(rate({{name:rest_ui_rate_limit_rejections}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance, reason) * 60 + legend: '{{instance}} - {{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' + promql: sum({{name:rest_ui_active_ips}}{job="blockbook", coin="$coin"}) + legend: 'all replicas - active IPs' blocked: - promql: sum({{name:rest_ui_blocked_ips}}{job="blockbook", coin="$coin"}) by (coin) - legend: '{{coin}} - blocked IPs' + promql: sum({{name:rest_ui_blocked_ips}}{job="blockbook", coin="$coin"}) + legend: 'all replicas - 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' + promql: max({{name:rest_ui_max_active_requests_per_ip}}{job="blockbook", coin="$coin"}) + legend: 'all replicas - 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' + promql: histogram_quantile(0.50, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (path, le)) + legend: 'all replicas - {{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' + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (path, le)) + legend: 'all replicas - {{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' + promql: histogram_quantile(0.99, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (path, le)) + legend: 'all replicas - {{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' + promql: sum(rate({{name:balance_history_cap_exceeded_total}}{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (path) * 60 + legend: 'all replicas - {{path}}/min' row.general: title: General general.synchronized: @@ -492,31 +626,31 @@ panels: queries: synchronized: promql: '{{name:synchronized}}{job="blockbook"}' - legend: '{{coin}}' + legend: '{{coin}} - {{instance}}' 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}} + legend: '{{instance}} - blockbook' backend: promql: '{{name:backend_best_height}}{job="blockbook", coin="$coin"}' - legend: Backend {{coin}} - {{instance}} + legend: '{{instance}} - backend' 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}}' + legend: '{{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}}' + legend: '{{instance}}' general.avg_block_time: title: Configured average block time description: Static chain config value used by sync heuristics; 0 means unavailable. @@ -537,130 +671,211 @@ panels: queries: size: promql: '{{name:index_db_size}}{job="blockbook", coin="$coin"}' - legend: '{{coin}} - {{instance}}' + legend: '{{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}}' + legend: '{{instance}} - {{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}}' + legend: '{{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}}' + promql: 100 * sum({{name:txcache_efficiency}}{job="blockbook", status="hit", coin="$coin"}) by (instance) / sum({{name:txcache_efficiency}}{job="blockbook", coin="$coin"}) by (instance) + legend: '{{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}}' + legend: '{{instance}}' + general.xpub_cache_bytes: + title: Xpub cache bytes + description: Estimated heap held by cached xpubs (addresses, balances, utxos, txid lists), capped by xpubConfig.maxCacheBytes; one whale account weighs as much as hundreds of small ones. + queries: + bytes: + promql: '{{name:xpub_cache_bytes}}{job="blockbook", coin="$coin"}' + legend: '{{instance}}' + general.xpub_cache_activity: + title: Xpub cache activity + description: Hits return the cached snapshot; absent misses derive the whole account, gap misses re-derive one cached under another gap, stale misses rescan every address after a new block, history misses load txids from the db. A size or bytes gauge pinned at its cap with many absent misses means the cache is too small. + queries: + hits: + promql: rate({{name:xpub_cache_hits_total}}{job="blockbook", coin="$coin"}[5m]) * 60 + legend: '{{instance}} hits/min' + misses: + promql: rate({{name:xpub_cache_misses_total}}{job="blockbook", coin="$coin"}[5m]) * 60 + legend: '{{instance}} misses {{reason}}/min' 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}}' + legend: 'all replicas - {{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}} + promql: sum({{name:explorer_pending_requests}}{job="blockbook", coin="$coin"}) by (instance, method) + legend: '{{instance}} - {{method}}' 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}} + promql: sum({{name:websocket_pending_requests}}{job="blockbook", coin="$coin"}) by (instance, method) + legend: '{{instance}} - websocket {{method}}' 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' + promql: sum({{name:explorer_pending_requests}}{job="blockbook", coin="$coin"}) by (instance, method) + legend: '{{instance}} - explorer {{method}}' 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' + description: Current-price, token-list and currency-list calls; status separates successful calls, throttling and hard errors. Historical market_chart downloads are in their own panel. + queries: + requests: + promql: sum(increase({{name:coingecko_requests}}{job="blockbook", coin="$coin", endpoint!="market_chart"}[$__interval])) by (instance, endpoint, status) + legend: '{{instance}} - {{status}} - {{endpoint}}' + general.coingecko_market_chart_requests: + title: CoinGecko historical requests + description: Historical market_chart downloads, which run in bursts of hundreds of calls; status separates successful calls, throttling and hard errors. + queries: + requests: + promql: sum(increase({{name:coingecko_requests}}{job="blockbook", coin="$coin", endpoint="market_chart"}[$__interval])) by (instance, status) + legend: '{{instance}} - {{status}}' 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}}' + ranges: + promql: sum(increase({{name:coingecko_range_requests}}{job="blockbook", coin="$coin"}[$__interval])) by (instance, range) + legend: '{{instance}} - {{range}}' 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}}' + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_fiat_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[1m])) by (path, mode, status, le)) + legend: 'all replicas - {{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}}' + promql: sum(rate({{name:balance_history_fiat_fallback_total}}{job="blockbook", coin="$coin"}[1m])) by (path, reason) * 60 + legend: 'all replicas - {{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}}' + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_points}}_bucket{job="blockbook", coin="$coin"}[1m])) by (path, le)) + legend: 'all replicas - {{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}}' + promql: histogram_quantile(0.95, sum(increase({{name:fiat_rates_update_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[1h])) by (instance, stage, status, le)) + legend: '{{instance}} - {{stage}} - {{status}}' + row.fees: + title: Fees + fees.estimated_fee: + title: Estimated fee rate (UTXO chains) + description: 'Fee estimates for 1/3/5/8 block targets; units are chain-specific. UTXO chains only - estimated_fee is not populated for EVM coins, which expose their fees in the EIP-1559 panels above, so this panel is empty when $coin is an EVM chain (expected, not an outage).' + queries: + blocks_1: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="1"}/1000) + legend: '{{instance}} - {{blocks}} blocks' + blocks_3: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="3"}/1000) + legend: '{{instance}} - {{blocks}} blocks' + blocks_5: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="5"}/1000) + legend: '{{instance}} - {{blocks}} blocks' + blocks_8: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="8"}/1000) + legend: '{{instance}} - {{blocks}} blocks' + fees.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}}' + fees.eip1559_max_fee: + title: EIP-1559 maxFeePerGas by tier (Gwei) + description: The maxFeePerGas the pull path (estimateFee) serves per tier, in Gwei (raw wei / 1e9). This is what wallets such as Trezor Suite spend as the fee cap, so it is the headline panel for "fees too high". On provider-backed coins (e.g. Infura, 1inch) these are the provider's suggestions, remapped per-provider onto low/medium/high for comparable aggressiveness across providers; on on-chain coins they are 2x baseFee + tip. The instant tier exists only on the on-chain path, so an instant line on a provider-backed coin means the provider cache was stale and the on-chain fallback served the estimate. Compare with the base fee and the buffer-ratio panel to tell a busy chain from an estimator regression. + queries: + by_tier: + promql: ({{name:eth_eip1559_fee}}{job="blockbook", coin="$coin", kind="max_fee"} / 1e9) + legend: '{{instance}} - {{tier}}' + fees.eip1559_priority_fee: + title: EIP-1559 priority tip by tier (Gwei) + description: The maxPriorityFeePerGas (miner tip) served per tier, in Gwei (raw wei / 1e9), shown in a separate panel from maxFeePerGas because tips are an order of magnitude smaller. On the on-chain path tiers map to eth_feeHistory reward percentiles 20/70/90/99; a near-zero flat tip is normal on quiet or L2 chains. Read exact values from the legend table. + queries: + by_tier: + promql: ({{name:eth_eip1559_fee}}{job="blockbook", coin="$coin", kind="priority_fee"} / 1e9) + legend: '{{instance}} - {{tier}}' + fees.eip1559_buffer_ratio: + title: EIP-1559 buffer ratio (maxFeePerGas / baseFee) + description: 'Per-tier maxFeePerGas divided by the next-block base fee - the headroom over the base fee. The on-chain estimator targets 2x by design (eip1559BaseFeeMultiplier), shown as the dashed line; provider-backed coins return their own padded suggestions which may differ (e.g. Infura''s high tier sits ~2.5x), so a steady ratio above 2 there is expected, not an anomaly - the 2x line is the on-chain target only. How to read it: a ratio flat near its source''s normal level while absolute fees are high means the chain is congested (nothing to fix); a ratio climbing well above that level means over-buffering - the "fees too high" signature.' + queries: + ratio: + promql: ({{name:eth_eip1559_fee}}{job="blockbook", coin="$coin", kind="max_fee"} / on(job, coin, instance) group_left {{name:eth_eip1559_base_fee}}{job="blockbook", coin="$coin"}) + legend: '{{instance}} - {{tier}}' + threshold: + promql: vector(2) + legend: target buffer (2x base fee) + fees.base_fee_push_vs_pull: + title: Base fee - mined block (push) vs next-block projection (pull) (Gwei) + description: 'Solid = base fee of the latest connected block (push path, eth_block_base_fee); dashed = the next-block base fee the pull-path estimate is built on (eth_eip1559_base_fee). On the on-chain path they should track within ~one block''s +/-12.5% step, so a persistent gap there means the eth_feeHistory projection is drifting from the chain. On provider-backed coins the projection is a cached value (up to the stale window old), so a larger, slowly-updating gap is expected cache lag - cross-check the cache-age and fee-source panels before calling it drift.' + queries: + mined: + promql: ({{name:eth_block_base_fee}}{job="blockbook", coin="$coin"} / 1e9) + legend: '{{instance}} - mined (push)' + projected: + promql: ({{name:eth_eip1559_base_fee}}{job="blockbook", coin="$coin"} / 1e9) + legend: '{{instance}} - projected (pull)' + fees.block_gas_used_ratio: + title: Latest block gas-used ratio (% of gas limit) + description: 'gasUsed / gasLimit of the most recently connected block, pushed on subscribeNewBlock. It is what moves the base fee: above the 50% balance point (dashed) the next block''s base fee rises up to +12.5%, below it falls. Read together with the base-fee and maxFeePerGas panels, this is the push-path explanation for "fees went up" - a sustained >50% ratio is real congestion, not an estimator issue.' + queries: + ratio: + promql: '{{name:eth_block_gas_used_ratio}}{job="blockbook", coin="$coin"}' + legend: '{{instance}}' + balance: + promql: vector(0.5) + legend: balance point (50%) + fees.alt_fee_provider_cache_age: + title: Alternative fee provider cache age + description: 'Age of the cached provider fees (time() - last successful refresh), per provider. The pull path keeps serving the cache until it crosses the provider''s stale window, after which estimateFee falls back to on-chain (visible as onchain_fallback in the fee-source panel). Both Infura and 1inch use a default stale window of 600s (10 minutes), configurable per coin via staleSeconds and independent of the poll cadence; the dashed line marks this default cutoff, so adjust it if a coin sets a different staleSeconds. A sawtooth that resets each poll is healthy; a line climbing past the cutoff is a wedged provider.' + queries: + age: + promql: (time() - {{name:alternative_fee_provider_last_sync_timestamp_seconds}}{job="blockbook", coin="$coin"}) + legend: '{{instance}} - {{provider}}' + cutoff: + promql: vector(600) + legend: default stale cutoff (600s = 10m) + fees.eip1559_fee_source: + title: EIP-1559 fee source (served estimates/s) + description: 'Where each served estimate came from, at the serve boundary: provider = alternative provider (Infura/1inch) cache hit; onchain_fallback = provider configured but its cache was stale/unready, so eth_feeHistory was used; onchain = no provider configured. Healthy state is provider dominating on provider-backed coins; a burst of onchain_fallback at startup is cold-cache warmup, not an incident (the cache-age panel disambiguates). Sustained onchain_fallback means the provider is wedged.' + queries: + by_source: + promql: sum by (instance, source) (rate({{name:eth_eip1559_fee_source_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) + legend: '{{instance}} - {{source}}' row.fiat_rates: title: Fiat Rates fiatrates.tip_fetches: @@ -668,32 +883,32 @@ panels: 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' + promql: sum(rate({{name:fiat_rates_fetched_units_total}}{job="blockbook", coin="$coin", phase="tip"}[$__rate_interval])) by (instance) * 60 + legend: '{{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' + promql: sum(rate({{name:fiat_rates_fetched_units_total}}{job="blockbook", coin="$coin", phase=~"backfill|bootstrap"}[$__rate_interval])) by (instance, phase) * 60 + legend: '{{instance}} - {{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' + promql: sum(rate({{name:fiat_rates_fetched_tokens_total}}{job="blockbook", coin="$coin", phase=~"backfill|bootstrap"}[$__rate_interval])) by (instance, phase) * 60 + legend: '{{instance}} - {{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' + promql: sum(increase({{name:fiat_rates_fetched_units_total}}{job="blockbook", coin="$coin", phase="reconcile"}[1h])) by (instance) + legend: '{{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' + promql: sum(increase({{name:fiat_rates_fetched_tokens_total}}{job="blockbook", coin="$coin", phase="reconcile"}[1h])) by (instance) + legend: '{{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}}' + promql: sum(increase({{name:fiat_rates_unable_total}}{job="blockbook", coin="$coin"}[1h])) by (instance, phase, reason) + legend: '{{instance}} - {{phase}} - {{reason}}' diff --git a/configs/grafana/template.json b/configs/grafana/template.json index bfc9fe1a89..85e93e00d1 100644 --- a/configs/grafana/template.json +++ b/configs/grafana/template.json @@ -285,11 +285,11 @@ } ] }, - "unit": "short" + "unit": "percentunit" }, "overrides": [] }, - "id": 22, + "id": 363, "options": { "legend": { "calcs": [ @@ -312,11 +312,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "fds" + "x-query-key": "gc_share" } ], "type": "timeseries", - "x-panel-key": "system.open_fds" + "x-panel-key": "system.go_gc_cpu" }, { "fieldConfig": { @@ -345,11 +345,11 @@ } ] }, - "unit": "short" + "unit": "bytes" }, "overrides": [] }, - "id": 32, + "id": 364, "options": { "legend": { "calcs": [ @@ -372,11 +372,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "load" + "x-query-key": "live" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "goal" } ], "type": "timeseries", - "x-panel-key": "system.node_load1" + "x-panel-key": "system.go_heap_live" }, { "fieldConfig": { @@ -405,11 +411,11 @@ } ] }, - "unit": "bytes" + "unit": "short" }, "overrides": [] }, - "id": 36, + "id": 22, "options": { "legend": { "calcs": [ @@ -432,18 +438,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "available" + "x-query-key": "fds" } ], "type": "timeseries", - "x-panel-key": "system.node_mem_available" - }, - { - "collapsed": false, - "id": 313, - "panels": [], - "type": "row", - "x-panel-key": "row.rpc" + "x-panel-key": "system.open_fds" }, { "fieldConfig": { @@ -452,7 +451,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "req / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -477,7 +475,7 @@ }, "overrides": [] }, - "id": 18, + "id": 32, "options": { "legend": { "calcs": [ @@ -500,11 +498,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "requests" + "x-query-key": "load" } ], "type": "timeseries", - "x-panel-key": "rpc.request_rate" + "x-panel-key": "system.node_load1" }, { "fieldConfig": { @@ -533,11 +531,11 @@ } ] }, - "unit": "ms" + "unit": "bytes" }, "overrides": [] }, - "id": 20, + "id": 36, "options": { "legend": { "calcs": [ @@ -560,11 +558,18 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "x-query-key": "available" } ], "type": "timeseries", - "x-panel-key": "rpc.request_duration_p95" + "x-panel-key": "system.node_mem_available" + }, + { + "collapsed": false, + "id": 313, + "panels": [], + "type": "row", + "x-panel-key": "row.rpc" }, { "fieldConfig": { @@ -573,8 +578,9 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "req / min", "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -597,7 +603,7 @@ }, "overrides": [] }, - "id": 302, + "id": 18, "options": { "legend": { "calcs": [ @@ -620,11 +626,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "fallbacks" + "x-query-key": "requests" } ], "type": "timeseries", - "x-panel-key": "rpc.fallback_calls" + "x-panel-key": "rpc.request_rate" }, { "fieldConfig": { @@ -633,7 +639,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "calls / sec", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -654,11 +659,11 @@ } ] }, - "unit": "short" + "unit": "ms" }, "overrides": [] }, - "id": 105, + "id": 20, "options": { "legend": { "calcs": [ @@ -681,11 +686,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "requests" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "rpc.eth_call_requests" + "x-panel-key": "rpc.request_duration_p95" }, { "fieldConfig": { @@ -694,9 +699,8 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "errors / min", "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -719,7 +723,7 @@ }, "overrides": [] }, - "id": 106, + "id": 302, "options": { "legend": { "calcs": [ @@ -742,11 +746,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "errors" + "x-query-key": "fallbacks" } ], "type": "timeseries", - "x-panel-key": "rpc.eth_call_errors" + "x-panel-key": "rpc.fallback_calls" }, { "fieldConfig": { @@ -755,7 +759,7 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "contracts / batch", + "axisLabel": "calls / sec", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -780,7 +784,7 @@ }, "overrides": [] }, - "id": 107, + "id": 105, "options": { "legend": { "calcs": [ @@ -803,11 +807,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "avg" + "x-query-key": "requests" } ], "type": "timeseries", - "x-panel-key": "rpc.eth_call_batch_size" + "x-panel-key": "rpc.eth_call_requests" }, { "fieldConfig": { @@ -816,7 +820,7 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "calls / sec", + "axisLabel": "errors / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -841,7 +845,7 @@ }, "overrides": [] }, - "id": 108, + "id": 106, "options": { "legend": { "calcs": [ @@ -864,11 +868,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "requests" + "x-query-key": "errors" } ], "type": "timeseries", - "x-panel-key": "rpc.eth_call_token_uri" + "x-panel-key": "rpc.eth_call_errors" }, { "fieldConfig": { @@ -877,7 +881,7 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "calls / min", + "axisLabel": "contracts / batch", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -902,7 +906,7 @@ }, "overrides": [] }, - "id": 109, + "id": 107, "options": { "legend": { "calcs": [ @@ -925,11 +929,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "requests" + "x-query-key": "avg" } ], "type": "timeseries", - "x-panel-key": "rpc.eth_call_staking_pool" + "x-panel-key": "rpc.eth_call_batch_size" }, { "fieldConfig": { @@ -938,7 +942,7 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "calls / min", + "axisLabel": "calls / sec", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -963,7 +967,7 @@ }, "overrides": [] }, - "id": 110, + "id": 108, "options": { "legend": { "calcs": [ @@ -990,7 +994,7 @@ } ], "type": "timeseries", - "x-panel-key": "rpc.eth_call_contract_info" + "x-panel-key": "rpc.eth_call_token_uri" }, { "fieldConfig": { @@ -999,8 +1003,9 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "calls / min", "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -1023,7 +1028,7 @@ }, "overrides": [] }, - "id": 111, + "id": 109, "options": { "legend": { "calcs": [ @@ -1046,18 +1051,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "errors" + "x-query-key": "requests" } ], "type": "timeseries", - "x-panel-key": "rpc.eth_sync_errors" - }, - { - "collapsed": false, - "id": 314, - "panels": [], - "type": "row", - "x-panel-key": "row.sync" + "x-panel-key": "rpc.eth_call_staking_pool" }, { "fieldConfig": { @@ -1066,6 +1064,7 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "calls / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -1086,11 +1085,11 @@ } ] }, - "unit": "ms" + "unit": "short" }, "overrides": [] }, - "id": 14, + "id": 110, "options": { "legend": { "calcs": [ @@ -1113,11 +1112,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "x-query-key": "requests" } ], "type": "timeseries", - "x-panel-key": "sync.index_resync_duration_p95" + "x-panel-key": "rpc.eth_call_contract_info" }, { "fieldConfig": { @@ -1150,7 +1149,7 @@ }, "overrides": [] }, - "id": 303, + "id": 111, "options": { "legend": { "calcs": [ @@ -1177,7 +1176,7 @@ } ], "type": "timeseries", - "x-panel-key": "sync.index_resync_errors" + "x-panel-key": "rpc.eth_sync_errors" }, { "fieldConfig": { @@ -1191,7 +1190,7 @@ "gradientMode": "none", "lineWidth": 1, "pointSize": 5, - "showPoints": "auto", + "showPoints": "never", "stacking": { "group": "A", "mode": "none" @@ -1210,7 +1209,7 @@ }, "overrides": [] }, - "id": 58, + "id": 353, "options": { "legend": { "calcs": [ @@ -1223,7 +1222,7 @@ "showLegend": true }, "tooltip": { - "mode": "single", + "mode": "multi", "sort": "none" } }, @@ -1233,11 +1232,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "retries" + "x-query-key": "by_status" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "failures_by_reason" } ], "type": "timeseries", - "x-panel-key": "sync.index_block_not_found_retries" + "x-panel-key": "rpc.sendtx_requests" }, { "fieldConfig": { @@ -1251,7 +1256,7 @@ "gradientMode": "none", "lineWidth": 1, "pointSize": 5, - "showPoints": "auto", + "showPoints": "never", "stacking": { "group": "A", "mode": "none" @@ -1270,7 +1275,7 @@ }, "overrides": [] }, - "id": 59, + "id": 354, "options": { "legend": { "calcs": [ @@ -1293,11 +1298,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "events" + "x-query-key": "by_path" } ], "type": "timeseries", - "x-panel-key": "sync.index_reorg_events" + "x-panel-key": "rpc.eth_sendtx_path" }, { "fieldConfig": { @@ -1311,7 +1316,7 @@ "gradientMode": "none", "lineWidth": 1, "pointSize": 5, - "showPoints": "auto", + "showPoints": "never", "stacking": { "group": "A", "mode": "none" @@ -1330,7 +1335,7 @@ }, "overrides": [] }, - "id": 60, + "id": 355, "options": { "legend": { "calcs": [ @@ -1353,11 +1358,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "yields" + "x-query-key": "by_provider" } ], "type": "timeseries", - "x-panel-key": "sync.index_sync_yields" + "x-panel-key": "rpc.eth_alternative_sendtx_providers" }, { "fieldConfig": { @@ -1367,7 +1372,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -1386,11 +1391,11 @@ } ] }, - "unit": "short" + "unit": "s" }, "overrides": [] }, - "id": 70, + "id": 356, "options": { "legend": { "calcs": [ @@ -1413,11 +1418,24 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "stats" + "x-query-key": "p50" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "sync.block_stats_tip" + "x-panel-key": "rpc.eth_alternative_sendtx_duration" + }, + { + "collapsed": false, + "id": 314, + "panels": [], + "type": "row", + "x-panel-key": "row.sync" }, { "fieldConfig": { @@ -1446,11 +1464,11 @@ } ] }, - "unit": "short" + "unit": "ms" }, "overrides": [] }, - "id": 71, + "id": 14, "options": { "legend": { "calcs": [ @@ -1473,11 +1491,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "stats" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "sync.block_stats_bulk" + "x-panel-key": "sync.index_resync_duration_p95" }, { "fieldConfig": { @@ -1487,7 +1505,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -1510,7 +1528,7 @@ }, "overrides": [] }, - "id": 28, + "id": 303, "options": { "legend": { "calcs": [ @@ -1533,11 +1551,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "size" + "x-query-key": "errors" } ], "type": "timeseries", - "x-panel-key": "sync.mempool_size" + "x-panel-key": "sync.index_resync_errors" }, { "fieldConfig": { @@ -1547,11 +1565,11 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, - "showPoints": "never", + "showPoints": "auto", "stacking": { "group": "A", "mode": "none" @@ -1570,7 +1588,7 @@ }, "overrides": [] }, - "id": 332, + "id": 58, "options": { "legend": { "calcs": [ @@ -1583,7 +1601,7 @@ "showLegend": true }, "tooltip": { - "mode": "multi", + "mode": "single", "sort": "none" } }, @@ -1593,11 +1611,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "actions" + "x-query-key": "retries" } ], "type": "timeseries", - "x-panel-key": "sync.eth_alternative_mempool_reconciliation" + "x-panel-key": "sync.index_block_not_found_retries" }, { "fieldConfig": { @@ -1607,11 +1625,11 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, - "showPoints": "never", + "showPoints": "auto", "stacking": { "group": "A", "mode": "none" @@ -1630,7 +1648,7 @@ }, "overrides": [] }, - "id": 338, + "id": 59, "options": { "legend": { "calcs": [ @@ -1653,11 +1671,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "size" + "x-query-key": "events" } ], "type": "timeseries", - "x-panel-key": "sync.eth_alternative_mempool_cache_size" + "x-panel-key": "sync.index_reorg_events" }, { "fieldConfig": { @@ -1671,7 +1689,7 @@ "gradientMode": "none", "lineWidth": 1, "pointSize": 5, - "showPoints": "never", + "showPoints": "auto", "stacking": { "group": "A", "mode": "none" @@ -1686,11 +1704,11 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, - "id": 339, + "id": 60, "options": { "legend": { "calcs": [ @@ -1713,17 +1731,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p50" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "p95" + "x-query-key": "yields" } ], "type": "timeseries", - "x-panel-key": "sync.eth_alternative_mempool_tx_residence" + "x-panel-key": "sync.index_sync_yields" }, { "fieldConfig": { @@ -1733,7 +1745,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -1756,7 +1768,7 @@ }, "overrides": [] }, - "id": 54, + "id": 70, "options": { "legend": { "calcs": [ @@ -1779,11 +1791,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "x-query-key": "stats" } ], "type": "timeseries", - "x-panel-key": "sync.mempool_resync_throughput_p95" + "x-panel-key": "sync.block_stats_tip" }, { "fieldConfig": { @@ -1793,7 +1805,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -1812,11 +1824,11 @@ } ] }, - "unit": "ms" + "unit": "short" }, "overrides": [] }, - "id": 304, + "id": 71, "options": { "legend": { "calcs": [ @@ -1839,11 +1851,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "x-query-key": "stats" } ], "type": "timeseries", - "x-panel-key": "sync.mempool_resync_duration_p95" + "x-panel-key": "sync.block_stats_bulk" }, { "fieldConfig": { @@ -1876,7 +1888,7 @@ }, "overrides": [] }, - "id": 100, + "id": 28, "options": { "legend": { "calcs": [ @@ -1899,11 +1911,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "stats" + "x-query-key": "size" } ], "type": "timeseries", - "x-panel-key": "sync.hotness_stats_tip" + "x-panel-key": "sync.mempool_size" }, { "fieldConfig": { @@ -1913,7 +1925,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -1932,11 +1944,37 @@ } ] }, - "unit": "short" + "unit": "s" }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*cutoff.*" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] }, - "id": 101, + "id": 352, "options": { "legend": { "calcs": [ @@ -1959,11 +1997,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "stats" + "x-query-key": "age" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "cutoff" } ], "type": "timeseries", - "x-panel-key": "sync.hotness_stats_bulk" + "x-panel-key": "sync.mempool_reload_age" }, { "fieldConfig": { @@ -1996,7 +2040,7 @@ }, "overrides": [] }, - "id": 102, + "id": 332, "options": { "legend": { "calcs": [ @@ -2019,11 +2063,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "entries" + "x-query-key": "actions" } ], "type": "timeseries", - "x-panel-key": "sync.addr_contracts_cache_entries" + "x-panel-key": "sync.eth_alternative_mempool_reconciliation" }, { "fieldConfig": { @@ -2052,11 +2096,11 @@ } ] }, - "unit": "bytes" + "unit": "short" }, "overrides": [] }, - "id": 103, + "id": 338, "options": { "legend": { "calcs": [ @@ -2079,11 +2123,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "bytes" + "x-query-key": "size" } ], "type": "timeseries", - "x-panel-key": "sync.addr_contracts_cache_bytes" + "x-panel-key": "sync.eth_alternative_mempool_cache_size" }, { "fieldConfig": { @@ -2092,9 +2136,8 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "events / min", "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -2113,11 +2156,11 @@ } ] }, - "unit": "short" + "unit": "s" }, "overrides": [] }, - "id": 104, + "id": 339, "options": { "legend": { "calcs": [ @@ -2140,23 +2183,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "hits" + "x-query-key": "p50" }, { "editorMode": "code", "range": true, "refId": "B", - "x-query-key": "misses" - }, - { - "editorMode": "code", - "range": true, - "refId": "C", - "x-query-key": "flushes" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "sync.addr_contracts_cache_activity" + "x-panel-key": "sync.eth_alternative_mempool_tx_residence" }, { "fieldConfig": { @@ -2185,37 +2222,11 @@ } ] }, - "unit": "s" + "unit": "short" }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": ".*stall threshold.*" - }, - "properties": [ - { - "id": "custom.lineStyle", - "value": { - "dash": [ - 10, - 10 - ], - "fill": "dash" - } - }, - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] + "overrides": [] }, - "id": 308, + "id": 349, "options": { "legend": { "calcs": [ @@ -2238,17 +2249,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "age" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "threshold" + "x-query-key": "by_result" } ], "type": "timeseries", - "x-panel-key": "sync.tip_feed_age" + "x-panel-key": "sync.eth_alternative_nonce_requests" }, { "fieldConfig": { @@ -2258,7 +2263,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -2281,7 +2286,7 @@ }, "overrides": [] }, - "id": 309, + "id": 358, "options": { "legend": { "calcs": [ @@ -2304,17 +2309,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "stall" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "tip_advanced" + "x-query-key": "by_result" } ], "type": "timeseries", - "x-panel-key": "sync.tip_feed_watchdog" + "x-panel-key": "sync.eth_alternative_estimate_gas_requests" }, { "fieldConfig": { @@ -2324,7 +2323,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -2340,14 +2339,22 @@ { "color": "green", "value": 0 + }, + { + "color": "yellow", + "value": 2160 + }, + { + "color": "red", + "value": 8640 } ] }, - "unit": "short" + "unit": "s" }, "overrides": [] }, - "id": 310, + "id": 359, "options": { "legend": { "calcs": [ @@ -2370,11 +2377,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "failures" + "x-query-key": "age" } ], "type": "timeseries", - "x-panel-key": "sync.tip_feed_reconnect_failures" + "x-panel-key": "sync.eth_alternative_mempool_oldest_age" }, { "fieldConfig": { @@ -2384,7 +2391,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -2403,11 +2410,11 @@ } ] }, - "unit": "short" + "unit": "reqps" }, "overrides": [] }, - "id": 311, + "id": 360, "options": { "legend": { "calcs": [ @@ -2430,18 +2437,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "events" + "x-query-key": "by_reason" } ], "type": "timeseries", - "x-panel-key": "sync.tip_feed_events" - }, - { - "collapsed": false, - "id": 315, - "panels": [], - "type": "row", - "x-panel-key": "row.websocket" + "x-panel-key": "sync.eth_alternative_send_not_surfaced" }, { "fieldConfig": { @@ -2470,11 +2470,11 @@ } ] }, - "unit": "short" + "unit": "reqps" }, "overrides": [] }, - "id": 37, + "id": 361, "options": { "legend": { "calcs": [ @@ -2497,11 +2497,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "clients" + "x-query-key": "by_source" } ], "type": "timeseries", - "x-panel-key": "websocket.clients" + "x-panel-key": "sync.eth_alternative_pending_floor_raised" }, { "fieldConfig": { @@ -2510,7 +2510,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "req / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -2531,11 +2530,11 @@ } ] }, - "unit": "short" + "unit": "reqps" }, "overrides": [] }, - "id": 38, + "id": 357, "options": { "legend": { "calcs": [ @@ -2558,11 +2557,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "requests" + "x-query-key": "by_source" } ], "type": "timeseries", - "x-panel-key": "websocket.request_rate" + "x-panel-key": "sync.eth_alternative_pending_floor_stranded" }, { "fieldConfig": { @@ -2591,11 +2590,11 @@ } ] }, - "unit": "short" + "unit": "percentunit" }, "overrides": [] }, - "id": 42, + "id": 362, "options": { "legend": { "calcs": [ @@ -2618,17 +2617,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "subscribes" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "new_block_txs" + "x-query-key": "death_ratio" } ], "type": "timeseries", - "x-panel-key": "websocket.subscriptions" + "x-panel-key": "sync.eth_alternative_mempool_death_ratio" }, { "fieldConfig": { @@ -2637,9 +2630,8 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "notif / min", "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -2662,7 +2654,7 @@ }, "overrides": [] }, - "id": 331, + "id": 54, "options": { "legend": { "calcs": [ @@ -2685,11 +2677,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "notifications" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "websocket.new_block_notifications" + "x-panel-key": "sync.mempool_resync_throughput_p95" }, { "fieldConfig": { @@ -2699,7 +2691,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -2718,11 +2710,11 @@ } ] }, - "unit": "µs" + "unit": "ms" }, "overrides": [] }, - "id": 46, + "id": 304, "options": { "legend": { "calcs": [ @@ -2749,7 +2741,7 @@ } ], "type": "timeseries", - "x-panel-key": "websocket.request_duration_p95" + "x-panel-key": "sync.mempool_resync_duration_p95" }, { "fieldConfig": { @@ -2758,7 +2750,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "events / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -2783,7 +2774,7 @@ }, "overrides": [] }, - "id": 47, + "id": 100, "options": { "legend": { "calcs": [ @@ -2806,11 +2797,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "events" + "x-query-key": "stats" } ], "type": "timeseries", - "x-panel-key": "websocket.new_block_txs_rate" + "x-panel-key": "sync.hotness_stats_tip" }, { "fieldConfig": { @@ -2839,11 +2830,11 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, - "id": 48, + "id": 101, "options": { "legend": { "calcs": [ @@ -2866,11 +2857,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "x-query-key": "stats" } ], "type": "timeseries", - "x-panel-key": "websocket.new_block_txs_duration_p95" + "x-panel-key": "sync.hotness_stats_bulk" }, { "fieldConfig": { @@ -2879,7 +2870,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "notif / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -2904,7 +2894,7 @@ }, "overrides": [] }, - "id": 49, + "id": 102, "options": { "legend": { "calcs": [ @@ -2927,11 +2917,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "notifications" + "x-query-key": "entries" } ], "type": "timeseries", - "x-panel-key": "websocket.addr_notifications" + "x-panel-key": "sync.addr_contracts_cache_entries" }, { "fieldConfig": { @@ -2940,7 +2930,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "events / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -2961,11 +2950,11 @@ } ] }, - "unit": "short" + "unit": "bytes" }, "overrides": [] }, - "id": 50, + "id": 103, "options": { "legend": { "calcs": [ @@ -2988,17 +2977,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "closes" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "unknown" + "x-query-key": "bytes" } ], "type": "timeseries", - "x-panel-key": "websocket.channel_closes_unknown" + "x-panel-key": "sync.addr_contracts_cache_bytes" }, { "fieldConfig": { @@ -3032,7 +3015,7 @@ }, "overrides": [] }, - "id": 112, + "id": 104, "options": { "legend": { "calcs": [ @@ -3055,11 +3038,23 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "outcomes" + "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": "websocket.eth_receipt" + "x-panel-key": "sync.addr_contracts_cache_activity" }, { "fieldConfig": { @@ -3069,7 +3064,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -3088,17 +3083,43 @@ } ] }, - "unit": "short" + "unit": "s" }, - "overrides": [] - }, - "id": 317, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max", - "mean" + "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", @@ -3115,17 +3136,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p50" + "x-query-key": "age" }, { "editorMode": "code", "range": true, "refId": "B", - "x-query-key": "p99" + "x-query-key": "threshold" } ], "type": "timeseries", - "x-panel-key": "websocket.connection_requests" + "x-panel-key": "sync.tip_feed_age" }, { "fieldConfig": { @@ -3134,7 +3155,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "events / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -3159,7 +3179,7 @@ }, "overrides": [] }, - "id": 318, + "id": 309, "options": { "legend": { "calcs": [ @@ -3182,11 +3202,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "rejections" + "x-query-key": "stall" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "tip_advanced" } ], "type": "timeseries", - "x-panel-key": "websocket.connection_rejections" + "x-panel-key": "sync.tip_feed_watchdog" }, { "fieldConfig": { @@ -3196,7 +3222,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -3219,7 +3245,7 @@ }, "overrides": [] }, - "id": 319, + "id": 310, "options": { "legend": { "calcs": [ @@ -3242,17 +3268,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "connections" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "unique_ips" + "x-query-key": "failures" } ], "type": "timeseries", - "x-panel-key": "websocket.unique_ips" + "x-panel-key": "sync.tip_feed_reconnect_failures" }, { "fieldConfig": { @@ -3262,7 +3282,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -3285,7 +3305,7 @@ }, "overrides": [] }, - "id": 320, + "id": 311, "options": { "legend": { "calcs": [ @@ -3308,17 +3328,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "avg" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "max" + "x-query-key": "events" } ], "type": "timeseries", - "x-panel-key": "websocket.connections_per_ip" + "x-panel-key": "sync.tip_feed_events" }, { "fieldConfig": { @@ -3327,9 +3341,8 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "transactions", "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -3348,11 +3361,11 @@ } ] }, - "unit": "short" + "unit": "ms" }, "overrides": [] }, - "id": 327, + "id": 348, "options": { "legend": { "calcs": [ @@ -3375,23 +3388,24 @@ "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" + "refId": "B", + "x-query-key": "p50" } ], "type": "timeseries", - "x-panel-key": "websocket.balance_history_txs" + "x-panel-key": "sync.rpc_sync_latency" + }, + { + "collapsed": false, + "id": 315, + "panels": [], + "type": "row", + "x-panel-key": "row.websocket" }, { "fieldConfig": { @@ -3400,7 +3414,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "rejections / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -3425,7 +3438,7 @@ }, "overrides": [] }, - "id": 328, + "id": 37, "options": { "legend": { "calcs": [ @@ -3448,18 +3461,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "by_path" + "x-query-key": "clients" } ], "type": "timeseries", - "x-panel-key": "websocket.balance_history_cap_exceeded" - }, - { - "collapsed": false, - "id": 321, - "panels": [], - "type": "row", - "x-panel-key": "row.rest_api" + "x-panel-key": "websocket.clients" }, { "fieldConfig": { @@ -3468,7 +3474,7 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "requests / min", + "axisLabel": "req / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -3493,7 +3499,7 @@ }, "overrides": [] }, - "id": 322, + "id": 38, "options": { "legend": { "calcs": [ @@ -3516,11 +3522,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "by_action" + "x-query-key": "requests" } ], "type": "timeseries", - "x-panel-key": "rest_api.requests" + "x-panel-key": "websocket.request_rate" }, { "fieldConfig": { @@ -3529,7 +3535,6 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "requests / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -3554,7 +3559,7 @@ }, "overrides": [] }, - "id": 323, + "id": 42, "options": { "legend": { "calcs": [ @@ -3577,11 +3582,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "by_action" + "x-query-key": "subscribes" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "new_block_txs" } ], "type": "timeseries", - "x-panel-key": "rest_api.balance_history_requests" + "x-panel-key": "websocket.subscriptions" }, { "fieldConfig": { @@ -3590,7 +3601,7 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "rejections / min", + "axisLabel": "notif / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -3615,7 +3626,7 @@ }, "overrides": [] }, - "id": 324, + "id": 331, "options": { "legend": { "calcs": [ @@ -3638,11 +3649,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "by_reason" + "x-query-key": "notifications" } ], "type": "timeseries", - "x-panel-key": "rest_ui.rejections" + "x-panel-key": "websocket.new_block_notifications" }, { "fieldConfig": { @@ -3671,11 +3682,11 @@ } ] }, - "unit": "short" + "unit": "\u00b5s" }, "overrides": [] }, - "id": 325, + "id": 46, "options": { "legend": { "calcs": [ @@ -3698,17 +3709,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "active" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "blocked" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "rest_ui.active_ips" + "x-panel-key": "websocket.request_duration_p95" }, { "fieldConfig": { @@ -3717,6 +3722,7 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "events / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -3741,7 +3747,7 @@ }, "overrides": [] }, - "id": 326, + "id": 47, "options": { "legend": { "calcs": [ @@ -3764,11 +3770,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "max" + "x-query-key": "events" } ], "type": "timeseries", - "x-panel-key": "rest_ui.max_active_per_ip" + "x-panel-key": "websocket.new_block_txs_rate" }, { "fieldConfig": { @@ -3777,9 +3783,8 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "transactions", "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -3798,11 +3803,11 @@ } ] }, - "unit": "short" + "unit": "s" }, "overrides": [] }, - "id": 329, + "id": 48, "options": { "legend": { "calcs": [ @@ -3825,23 +3830,11 @@ "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" + "x-panel-key": "websocket.new_block_txs_duration_p95" }, { "fieldConfig": { @@ -3850,7 +3843,7 @@ "mode": "palette-classic" }, "custom": { - "axisLabel": "rejections / min", + "axisLabel": "notif / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -3875,7 +3868,7 @@ }, "overrides": [] }, - "id": 330, + "id": 49, "options": { "legend": { "calcs": [ @@ -3898,50 +3891,37 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "by_path" + "x-query-key": "notifications" } ], "type": "timeseries", - "x-panel-key": "rest_api.balance_history_cap_exceeded" - }, - { - "collapsed": false, - "id": 316, - "panels": [], - "type": "row", - "x-panel-key": "row.general" + "x-panel-key": "websocket.addr_notifications" }, { "fieldConfig": { "defaults": { "color": { - "mode": "thresholds" + "mode": "palette-classic" }, - "mappings": [ - { - "options": { - "0": { - "text": "OUT OF SYNC" - }, - "1": { - "text": "in sync" - } - }, - "type": "value" + "custom": { + "axisLabel": "events / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" } - ], - "max": 1, - "min": 0, + }, "thresholds": { "mode": "absolute", "steps": [ - { - "color": "red", - "value": 0 - }, { "color": "green", - "value": 1 + "value": 0 } ] }, @@ -3949,33 +3929,40 @@ }, "overrides": [] }, - "id": 300, + "id": 50, "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { + "legend": { "calcs": [ - "lastNotNull" + "lastNotNull", + "max", + "mean" ], - "fields": "", - "values": false + "displayMode": "table", + "placement": "bottom", + "showLegend": true }, - "textMode": "auto" + "tooltip": { + "mode": "multi", + "sort": "none" + } }, "pluginVersion": "12.3.0", "targets": [ { "editorMode": "code", - "instant": true, - "range": false, + "range": true, "refId": "A", - "x-query-key": "synchronized" + "x-query-key": "closes" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "unknown" } ], - "type": "stat", - "x-panel-key": "general.synchronized" + "type": "timeseries", + "x-panel-key": "websocket.channel_closes_unknown" }, { "fieldConfig": { @@ -3984,8 +3971,9 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "events / min", "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -4008,12 +3996,13 @@ }, "overrides": [] }, - "id": 301, + "id": 112, "options": { "legend": { "calcs": [ "lastNotNull", - "max" + "max", + "mean" ], "displayMode": "table", "placement": "bottom", @@ -4030,17 +4019,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "blockbook" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "backend" + "x-query-key": "outcomes" } ], "type": "timeseries", - "x-panel-key": "general.block_height" + "x-panel-key": "websocket.eth_receipt" }, { "fieldConfig": { @@ -4050,7 +4033,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -4069,11 +4052,11 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, - "id": 63, + "id": 317, "options": { "legend": { "calcs": [ @@ -4086,7 +4069,7 @@ "showLegend": true }, "tooltip": { - "mode": "single", + "mode": "multi", "sort": "none" } }, @@ -4096,11 +4079,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "age" + "x-query-key": "p50" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "p99" } ], "type": "timeseries", - "x-panel-key": "general.tip_age" + "x-panel-key": "websocket.connection_requests" }, { "fieldConfig": { @@ -4109,6 +4098,7 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "events / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -4129,11 +4119,11 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, - "id": 44, + "id": 318, "options": { "legend": { "calcs": [ @@ -4156,24 +4146,29 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "period" + "x-query-key": "rejections" } ], "type": "timeseries", - "x-panel-key": "general.avg_block_period" + "x-panel-key": "websocket.connection_rejections" }, { "fieldConfig": { "defaults": { "color": { - "mode": "thresholds" + "mode": "palette-classic" }, "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } }, "thresholds": { "mode": "absolute", @@ -4184,58 +4179,57 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, - "id": 64, + "id": 350, "options": { - "cellHeight": "sm", - "showHeader": true + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, "pluginVersion": "12.3.0", "targets": [ { - "editorMode": "builder", - "exemplar": false, - "format": "table", - "instant": true, - "range": false, + "editorMode": "code", + "range": true, "refId": "A", - "x-query-key": "block_time" - } - ], - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "__name__": true, - "env": true, - "job": true - }, - "includeByName": {}, - "indexByName": {}, - "renameByName": {} - } + "x-query-key": "blocked_ips" } ], - "type": "table", - "x-panel-key": "general.avg_block_time" + "type": "timeseries", + "x-panel-key": "websocket.blocked_ips" }, { "fieldConfig": { "defaults": { "color": { - "mode": "thresholds" + "mode": "palette-classic" }, "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false + "axisLabel": "rejects / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } }, "thresholds": { "mode": "absolute", @@ -4250,41 +4244,34 @@ }, "overrides": [] }, - "id": 307, + "id": 351, "options": { - "cellHeight": "sm", - "showHeader": true + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, "pluginVersion": "12.3.0", "targets": [ { "editorMode": "code", - "format": "table", - "instant": true, - "range": false, + "range": true, "refId": "A", - "x-query-key": "info" - } - ], - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "Value": true, - "__name__": true, - "env": true, - "job": true - }, - "includeByName": {}, - "indexByName": {}, - "renameByName": {} - } + "x-query-key": "blocked_rejections" } ], - "type": "table", - "x-panel-key": "general.versions" + "type": "timeseries", + "x-panel-key": "websocket.blocked_connections" }, { "fieldConfig": { @@ -4298,10 +4285,6 @@ "gradientMode": "none", "lineWidth": 1, "pointSize": 5, - "scaleDistribution": { - "log": 2, - "type": "log" - }, "showPoints": "never", "stacking": { "group": "A", @@ -4317,16 +4300,17 @@ } ] }, - "unit": "decbytes" + "unit": "short" }, "overrides": [] }, - "id": 10, + "id": 319, "options": { "legend": { "calcs": [ "lastNotNull", - "min" + "max", + "mean" ], "displayMode": "table", "placement": "bottom", @@ -4343,11 +4327,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "size" + "x-query-key": "connections" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "unique_ips" } ], "type": "timeseries", - "x-panel-key": "general.index_db_size" + "x-panel-key": "websocket.unique_ips" }, { "fieldConfig": { @@ -4376,16 +4366,17 @@ } ] }, - "unit": "decbytes" + "unit": "short" }, "overrides": [] }, - "id": 305, + "id": 320, "options": { "legend": { "calcs": [ "lastNotNull", - "max" + "max", + "mean" ], "displayMode": "table", "placement": "bottom", @@ -4402,11 +4393,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "size" + "x-query-key": "avg" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "max" } ], "type": "timeseries", - "x-panel-key": "general.dbcolumn_size" + "x-panel-key": "websocket.connections_per_ip" }, { "fieldConfig": { @@ -4415,8 +4412,9 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "transactions", "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -4439,7 +4437,7 @@ }, "overrides": [] }, - "id": 30, + "id": 327, "options": { "legend": { "calcs": [ @@ -4462,11 +4460,23 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "rows" + "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": "general.cached_transactions" + "x-panel-key": "websocket.balance_history_txs" }, { "fieldConfig": { @@ -4475,6 +4485,7 @@ "mode": "palette-classic" }, "custom": { + "axisLabel": "rejections / min", "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", @@ -4495,11 +4506,1699 @@ } ] }, - "unit": "percent" + "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": "bytes" + }, + "overrides": [] + }, + "id": 365, + "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": "general.xpub_cache_bytes" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + }, + "axisLabel": "events / min" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 366, + "interval": "5m", + "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" + } + ], + "type": "timeseries", + "x-panel-key": "general.xpub_cache_activity" + }, + { + "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": "bars", + "fillOpacity": 80, + "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": [ + "sum", + "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": "requests" + } + ], + "interval": "5m", + "type": "timeseries", + "x-panel-key": "general.coingecko_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "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": 369, + "options": { + "legend": { + "calcs": [ + "sum", + "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": "requests" + } + ], + "interval": "5m", + "type": "timeseries", + "x-panel-key": "general.coingecko_market_chart_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "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": [ + "sum", + "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": "ranges" + } + ], + "interval": "5m", + "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": "s" }, "overrides": [] }, - "id": 16, + "id": 51, "options": { "legend": { "calcs": [ @@ -4522,11 +6221,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "ratio" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "general.txcache_hit_ratio" + "x-panel-key": "general.balance_history_fiat_duration_p95" }, { "fieldConfig": { @@ -4536,7 +6235,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -4559,7 +6258,7 @@ }, "overrides": [] }, - "id": 41, + "id": 52, "options": { "legend": { "calcs": [ @@ -4582,11 +6281,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "size" + "x-query-key": "fallbacks" } ], "type": "timeseries", - "x-panel-key": "general.xpub_cache_size" + "x-panel-key": "general.balance_history_fiat_fallbacks" }, { "fieldConfig": { @@ -4596,7 +6295,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -4619,7 +6318,7 @@ }, "overrides": [] }, - "id": 26, + "id": 53, "options": { "legend": { "calcs": [ @@ -4642,11 +6341,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "views" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "general.explorer_views" + "x-panel-key": "general.balance_history_points_p95" }, { "fieldConfig": { @@ -4675,11 +6374,11 @@ } ] }, - "unit": "short" + "unit": "s" }, "overrides": [] }, - "id": 306, + "id": 55, "options": { "legend": { "calcs": [ @@ -4702,11 +6401,18 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "pending" + "x-query-key": "p95" } ], "type": "timeseries", - "x-panel-key": "general.explorer_pending_requests" + "x-panel-key": "general.fiat_update_duration_p95" + }, + { + "collapsed": false, + "id": 340, + "panels": [], + "type": "row", + "x-panel-key": "row.fees" }, { "fieldConfig": { @@ -4739,7 +6445,7 @@ }, "overrides": [] }, - "id": 40, + "id": 341, "options": { "legend": { "calcs": [ @@ -4762,17 +6468,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "websocket" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "explorer" + "x-query-key": "by_tier" } ], "type": "timeseries", - "x-panel-key": "general.pending_requests" + "x-panel-key": "fees.eip1559_max_fee" }, { "fieldConfig": { @@ -4805,7 +6505,7 @@ }, "overrides": [] }, - "id": 45, + "id": 342, "options": { "legend": { "calcs": [ @@ -4828,29 +6528,11 @@ "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" + "x-query-key": "by_tier" } ], "type": "timeseries", - "x-panel-key": "general.estimated_fee" + "x-panel-key": "fees.eip1559_priority_fee" }, { "fieldConfig": { @@ -4860,7 +6542,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -4881,9 +6563,35 @@ }, "unit": "short" }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*target.*" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] }, - "id": 39, + "id": 343, "options": { "legend": { "calcs": [ @@ -4906,17 +6614,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "per_server" + "x-query-key": "ratio" }, { "editorMode": "code", "range": true, "refId": "B", - "x-query-key": "total" + "x-query-key": "threshold" } ], "type": "timeseries", - "x-panel-key": "general.coingecko_requests" + "x-panel-key": "fees.eip1559_buffer_ratio" }, { "fieldConfig": { @@ -4926,7 +6634,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 10, + "fillOpacity": 0, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -4949,7 +6657,7 @@ }, "overrides": [] }, - "id": 56, + "id": 344, "options": { "legend": { "calcs": [ @@ -4972,17 +6680,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "per_server" - }, - { - "editorMode": "code", - "range": true, - "refId": "B", - "x-query-key": "total" + "x-query-key": "by_source" } ], "type": "timeseries", - "x-panel-key": "general.coingecko_range_requests" + "x-panel-key": "fees.eip1559_fee_source" }, { "fieldConfig": { @@ -5011,11 +6713,37 @@ } ] }, - "unit": "short" + "unit": "s" }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*cutoff.*" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] }, - "id": 57, + "id": 345, "options": { "legend": { "calcs": [ @@ -5028,7 +6756,7 @@ "showLegend": true }, "tooltip": { - "mode": "single", + "mode": "multi", "sort": "none" } }, @@ -5038,11 +6766,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "requests" + "x-query-key": "age" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "cutoff" } ], "type": "timeseries", - "x-panel-key": "general.alt_fee_provider_requests" + "x-panel-key": "fees.alt_fee_provider_cache_age" }, { "fieldConfig": { @@ -5071,11 +6805,11 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, - "id": 51, + "id": 57, "options": { "legend": { "calcs": [ @@ -5098,11 +6832,11 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "x-query-key": "requests" } ], "type": "timeseries", - "x-panel-key": "general.balance_history_fiat_duration_p95" + "x-panel-key": "fees.alt_fee_provider_requests" }, { "fieldConfig": { @@ -5112,7 +6846,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -5131,11 +6865,37 @@ } ] }, - "unit": "short" + "unit": "percentunit" }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*balance point.*" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] }, - "id": 52, + "id": 346, "options": { "legend": { "calcs": [ @@ -5158,11 +6918,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "fallbacks" + "x-query-key": "ratio" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "balance" } ], "type": "timeseries", - "x-panel-key": "general.balance_history_fiat_fallbacks" + "x-panel-key": "fees.block_gas_used_ratio" }, { "fieldConfig": { @@ -5193,9 +6959,28 @@ }, "unit": "short" }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*projected.*" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + } + ] + } + ] }, - "id": 53, + "id": 347, "options": { "legend": { "calcs": [ @@ -5218,11 +7003,17 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "x-query-key": "mined" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "projected" } ], "type": "timeseries", - "x-panel-key": "general.balance_history_points_p95" + "x-panel-key": "fees.base_fee_push_vs_pull" }, { "fieldConfig": { @@ -5232,7 +7023,7 @@ }, "custom": { "drawStyle": "line", - "fillOpacity": 0, + "fillOpacity": 10, "gradientMode": "none", "lineWidth": 1, "pointSize": 5, @@ -5251,11 +7042,11 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, - "id": 55, + "id": 45, "options": { "legend": { "calcs": [ @@ -5278,11 +7069,29 @@ "editorMode": "code", "range": true, "refId": "A", - "x-query-key": "p95" + "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.fiat_update_duration_p95" + "x-panel-key": "fees.estimated_fee" }, { "collapsed": false, @@ -5653,11 +7462,6 @@ "text": "Testnet", "value": "Testnet" }, - { - "selected": false, - "text": "Ethereum Testnet Holesky Archive", - "value": "Ethereum Testnet Holesky Archive" - }, { "selected": false, "text": "Ethereum Testnet Sepolia Archive", @@ -5672,9 +7476,19 @@ "selected": false, "text": "Avalanche Archive", "value": "Avalanche Archive" + }, + { + "selected": false, + "text": "HyperEVM Archive", + "value": "HyperEVM Archive" + }, + { + "selected": false, + "text": "Robinhood Archive", + "value": "Robinhood 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", + "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 Sepolia Archive,Tron,Avalanche Archive,HyperEVM Archive,Robinhood Archive", "skipUrlSync": false, "type": "custom" } diff --git a/configs/metrics.yaml b/configs/metrics.yaml index 2293480acd..b94205caf1 100644 --- a/configs/metrics.yaml +++ b/configs/metrics.yaml @@ -177,6 +177,10 @@ metrics: type: histogram help: EVM read operations coalesced into one backend request buckets: [1, 2, 5, 10, 20, 50, 100, 200] + eth_call_multicall_requests: + name: blockbook_eth_call_multicall_requests + type: counter + help: Physical Multicall3 aggregate3 eth_call requests issued to the backend, each coalescing many sub-calls eth_call_contract_info_requests: name: blockbook_eth_call_contract_info_requests type: counter_vec @@ -195,7 +199,7 @@ metrics: index_resync_errors: name: blockbook_index_resync_errors type: counter_vec - help: Index resync loop failures, labeled by coarse error outcome + help: 'Index resync loop failures, labeled by where the failure came from - get_block_hash and get_block mean the backend did not answer, connect_block means fetching or connecting a block failed inside a sync worker, and resync means the resync iteration returned an error that was not attributed further. Sustained get_block_hash or get_block is a backend problem; sustained connect_block is a Blockbook or database problem. Retryable per-block misses are counted here too and may still recover within the same iteration, so alert on a sustained rate rather than on any single increment' labels: [error] index_block_not_found_retries: name: blockbook_index_block_not_found_retries @@ -209,12 +213,21 @@ metrics: 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)' + 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; reason=fell_behind: sequential sync lagged too far behind the backend tip and yielded for parallel catch-up)' labels: [reason] index_db_size: name: blockbook_index_db_size type: gauge help: Whole RocksDB footprint on disk, in bytes + internal_data_error_queue: + name: blockbook_internal_data_error_queue + type: gauge + help: Blocks queued for internal data healing, refreshed by every healing pass; a value that never drops means the backend cannot serve the traces + internal_data_heals: + name: blockbook_internal_data_heals_total + type: counter_vec + help: 'Outcomes of internal data healing attempts (healed: reconnected to the index; failed: the block itself failed again and was backed off; fetch_error: backend-wide fetch failure, retried without backoff; reconnect_error: the write failed, retried next pass without backoff)' + labels: [result] explorer_views: name: blockbook_explorer_views type: counter_vec @@ -223,22 +236,77 @@ metrics: mempool_size: name: blockbook_mempool_size type: gauge - help: Transactions retained from the last successful backend reload + help: Transactions retained from the last successful backend reload. Only written on success, so it freezes at the last good value when the reload loop breaks rather than signalling the failure - use mempool_last_sync_timestamp_seconds for that. Healthy magnitudes differ by orders of magnitude between chains (a Bitcoin peak near 1.6e5 against a Polygon peak above 5e6), so any threshold on it has to be per-coin or relative to the coin's own recent average + mempool_last_sync_timestamp_seconds: + name: blockbook_mempool_last_sync_timestamp_seconds + type: gauge_vec + labels: [chain] + help: Unix time of the last SUCCESSFUL mempool reload. Plot time() - this for reload staleness; a value climbing past a few multiples of resyncmempoolperiod (default ~60s) means the reload loop is failing or wedged. A gauge_vec rather than a plain gauge so the series is ABSENT until the first successful reload - a plain gauge would read 0 from process start and make time() - it page for the whole initial sync, before any reload has run. Exported as a timestamp rather than a computed *_age_seconds gauge on purpose - it is only written on success, so the age keeps rising while reloads fail, and it is restart-safe. This is the metric to alert on, in preference to the /api/ inSyncMempool flag - that flag is cleared at the START of every reload, so it reads "not syncing" for a large fraction of samples on a healthy node and cannot carry an alert 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 + help: Alternative send-transaction mempool cache lifecycle decisions, labeled by action. Retained - skipped_fresh, skipped_backoff (ageing entries are re-asked less often, see probeInterval), kept, provider_error, provider_missing_pending (a null relay answer younger than alternativeMissingTxTimeout). Removed - mined, nonce_superseded, provider_missing (missing from the relay for alternativeMissingTxTimeout, 2m by default - the relay answers from one consistent store over its whole pending window, so a run of nulls means dropped or cancelled), timeout, rbf_replaced, sync_removed (block sync indexed it, normally the dominant exit). A rising timeout share means private sends are dying rather than settling; provider_missing is the expected exit of a drop-mode cancel 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 + help: Age in seconds (time since broadcast) of an alternative send-transaction cache entry when it leaves the cache, labeled by the removing action (the exit actions of eth_alternative_mempool_reconciliation_events_total). provider_missing is the dropped/cancelled exit; residence counts age since broadcast, so a cancel N minutes after the send records ~N plus alternativeMissingTxTimeout - read the reason, not the age. Collapsing below one reconcile cycle would be the premature-eviction regression (#1573). An entry reaching the cache timeout leaves as timeout even when the relay answers null, so with the missing eviction configured off provider_missing stops firing rather than pinning at the timeout. Buckets run to 48h because the coin configs raise alternativeMempoolTxTimeout that far - a nonce-gapped private tx has been mined ~40h after broadcast, so where its exit lands is the evidence for whether the relay kept surfacing it that long labels: [action] - buckets: [30, 60, 120, 180, 300, 450, 600, 900, 1200, 1800, 3600] + buckets: [1, 2, 5, 10, 20, 30, 60, 120, 300, 600, 900, 1800, 3600, 5400, 7200, 9000, 10800, 12600, 21600, 43200, 86400, 129600, 172800] 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 + sendtx_requests_total: + name: blockbook_sendtx_requests_total + type: counter_vec + help: Transactions submitted by clients through any API (websocket, REST, explorer form), labeled by status and, for failures, by the class of rejection. Caller-side classes (underpriced, nonce_too_low, insufficient_funds, already_known) mean the transaction was refused on its own merits and no action is needed on the Blockbook side, while rate_limited, unauthorized, timeout and unavailable mean the send never got a verdict from the backend or provider and are the classes worth alerting on. A rising share of "other" is the signal to extend the classifier, not an incident + labels: [status, reason] + eth_sendtx_path_total: + name: blockbook_eth_sendtx_path_total + type: counter_vec + help: EVM send-transaction attempts by the send_path that served them and by status - alternative (private relay, the only path when _ALTERNATIVE_SENDTX_ONLY is true), primary (public RPC, either no alternative provider configured or the client asked to skip it) or primary_fallback (every alternative provider rejected and the public RPC was tried instead). Any primary_fallback traffic on a chain meant to broadcast privately means transactions are leaking to the public mempool + labels: [send_path, status] + eth_alternative_sendtx_total: + name: blockbook_eth_alternative_sendtx_total + type: counter_vec + help: eth_sendRawTransaction calls to individual alternative providers, labeled by provider_host, status and rejection class. A broadcast succeeds if ANY configured provider accepts it, so errors here are not failed sends on their own - compare against eth_sendtx_path_total to tell a degraded provider from a failed broadcast + labels: [provider_host, status, reason] + eth_alternative_sendtx_duration_seconds: + name: blockbook_eth_alternative_sendtx_duration_seconds + type: histogram_vec + help: Wall-clock duration of an eth_sendRawTransaction call to an alternative provider, labeled by provider_host and status. Private relays sit on the wallet's critical path, so this is the user-visible cost of the alternative send path; errors piling up in the buckets at the configured _RPC_TIMEOUT are timeouts rather than rejections + labels: [provider_host, status] + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 3, 5, 10, 20, 30] + eth_alternative_mempool_oldest_age_seconds: + name: blockbook_eth_alternative_mempool_oldest_age_seconds + type: gauge + help: Age in seconds of the oldest transaction still held in the alternative send-transaction mempool cache, sampled at the end of every reconcile cycle (0 when empty). Residence only records an age once an entry leaves, so this is the live stuck-tx signal. Pinning near the cache timeout (alternativePendingTxWindow, 3h by default) at non-zero depth means private txs the relay still surfaces are dying underpriced rather than mining - a dropped or cancelled tx leaves much earlier through the missing eviction (alternativeMissingTxTimeout, 2m by default) + eth_alternative_send_not_surfaced_total: + name: blockbook_eth_alternative_send_not_surfaced_total + type: counter_vec + help: Private sends a relay ACCEPTED but whose post-send fetch-back (eth_getTransactionByHash) did not surface, labeled by reason - not_found (relay returned null), error (relay call failed) or dropped (refused under load, on the one path that alone can expose the send). Normally the transaction stays cached and pending from its own signed bytes; a relay that keeps not surfacing it retires the entry through the missing eviction (alternativeMissingTxTimeout), with the cache timeout as the backstop. On the undecodable-raw-hex path any reason means the send is exposed nowhere despite ~30s of retries. Should be ~0 - the relay answers from one consistent store, so even the immediate post-send fetch-back sees an accepted tx; a sustained rate means the relay stopped surfacing what it accepts, the pre-alignment behavior Blockbook no longer assumes + labels: [reason] + eth_alternative_pending_floor_raised_total: + name: blockbook_eth_alternative_pending_floor_raised_total + type: counter_vec + help: eth_getTransactionCount answers where raiseToPendingFloor advanced the pending nonce above the backend's own, across the contiguous run of nonces the cache holds private txs for. Labeled by source - provider (the relay's pending count lagged a still-cached tx; with the relay counting pending over its whole window this should be near zero, and a sustained rate means its pending count regressed - the benign exception is a revert-protected send between its 96s retention lapsing and the missing eviction firing) or primary (the RPC never knew the private tx - expected while a private send is in flight). The cost is bounded - a send that never mines holds its nonce slot until the missing eviction (alternativeMissingTxTimeout) retires it. A nonce the caller declared on the request (privatePending) counts as in flight the same as a cached tx, except no eviction bounds it - the declaration raises only the caller's own answer, for as long as the caller keeps sending it + labels: [source] + eth_alternative_pending_floor_stranded_total: + name: blockbook_eth_alternative_pending_floor_stranded_total + type: counter_vec + help: eth_getTransactionCount answers where the cache held a private tx for a nonce ABOVE the contiguous run the floor could advance over, i.e. with a hole below it that nothing Blockbook knows of fills. Labeled by source as in the raised counter; each sample is one request, not one transaction. The floor deliberately stops below the hole rather than advertising past it (#1675), so the stranded tx cannot mine until the hole is filled. Should be ~0; a sustained rate on source=provider means relay-accepted sends are not reaching the cache. Counts a caller-declared nonce above the run as well as a cached one, so a client declaring odd nonces shows up here too + labels: [source] + eth_alternative_nonce_requests_total: + name: blockbook_eth_alternative_nonce_requests_total + type: counter_vec + help: eth_getTransactionCount lookups actually routed to the alternative send-transaction provider, labeled by result - success (the provider answered, its pending nonce raised to the mempool floor) or error (the lookup fell back to the primary RPC). Only addresses that sent privately through the provider within the routing window (15 min, see useForNonces) are routed here - the small gated subset, not every address request. A low, mostly-success rate confirms the gating is sparing the provider's rate quota + labels: [result] + eth_alternative_estimate_gas_requests_total: + name: blockbook_eth_alternative_estimate_gas_requests_total + type: counter_vec + help: eth_estimateGas calls actually routed to the alternative send-transaction provider, labeled by result - success (the provider answered) or error (the estimate fell back to the primary RPC). Only addresses that sent privately through the provider within the routing window (15 min, see useForNonces) are routed here - the small gated subset, not every estimateFee request. A low, mostly-success rate confirms the gating is keeping this hot endpoint off the provider's rate quota + labels: [result] estimated_fee: name: blockbook_estimated_fee type: gauge_vec @@ -247,7 +315,7 @@ metrics: 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) + help: Observed average spacing of the last 100 blocks' header timestamps, in fractional seconds (chain pace, not indexing cadence). 0 means "not yet computed" - fewer than 102 block times are loaded, which is the case early in an initial build or a reindex, and for a while after a restart until the block-time array is reloaded. A rule that gates on this being > 0 therefore switches itself off during exactly those windows; normalize by average_block_time_seconds instead, which comes from the coin's configuration and is stable across restarts sync_block_stats: name: blockbook_sync_block_stats type: gauge_vec @@ -298,10 +366,14 @@ metrics: name: blockbook_best_height type: gauge help: Newest block height persisted in Blockbook's index + initial_sync: + name: blockbook_initial_sync + type: gauge + help: 1 while the initial index build (or a full reindex) is running, 0 in steady state. Mirrors the initialSync field of /api/ and flips at the moment the flag does, not on the next periodic refresh. blockbook_synchronized is 0 for the whole of such a build - hours on a UTXO chain, days on an EVM archive - so a bare synchronized == 0 alert pages continuously through every planned resync and ends up silenced; gate it on this gauge instead (synchronized == 0 unless initial_sync == 1) so the alert only fires for an unplanned stall 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 + 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. Refreshed on every sync-loop iteration, so a stall shows up within one resync rather than on the next periodic metrics tick; pair it with blockbook_initial_sync to exclude planned reindexes. The index may trail the tip by up to 30s worth of blocks and still read 1 - 120 blocks on a 250ms chain, 2 on Ethereum, 1 on anything spaced over 15s - so this is a "not keeping up" signal, not a "one block behind" signal backend_best_height: name: blockbook_backend_best_height type: gauge @@ -309,7 +381,7 @@ metrics: tip_age_seconds: name: blockbook_tip_age_seconds type: gauge - help: Seconds since polling last observed the backend best height advance + help: Seconds since the backend's reported block height was last observed to change. The reference timestamp is persisted, so the age survives a restart; any change counts, so a rollback or a replaced backend at a lower height cannot wedge it. This tracks the BACKEND, not the index - a stalled indexer sitting behind a healthy backend keeps this near zero, and was observed doing exactly that through a 17-hour outage. Use delta(blockbook_best_height) against delta(blockbook_backend_best_height) to detect a stalled indexer, and this gauge to detect a backend or tip feed that went quiet backend_subscription_age_seconds: name: blockbook_backend_subscription_age_seconds type: gauge @@ -337,6 +409,19 @@ metrics: name: blockbook_xpub_cache_size type: gauge help: UTXO-only population of in-memory extended public-key lookup entries + xpub_cache_bytes: + name: blockbook_xpub_cache_bytes + type: gauge + help: Estimated memory used by the xpub cache (derived addresses, balances, utxos and txid lists) + xpub_cache_hits_total: + name: blockbook_xpub_cache_hits_total + type: counter + help: xpub lookups served from the cache without rescanning any address + xpub_cache_misses_total: + name: blockbook_xpub_cache_misses_total + type: counter_vec + help: xpub lookups that had to derive (absent), re-derive a different gap (gap), rescan every address after a new block (stale) or load txids from the db (history) + labels: [reason] coingecko_requests: name: blockbook_coingecko_requests type: counter_vec @@ -352,7 +437,7 @@ metrics: 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] + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30, 60, 120, 300, 600, 900, 1800, 3600] fiat_rates_fetched_units_total: name: blockbook_fiat_rates_fetched_units_total type: counter_vec @@ -373,6 +458,44 @@ metrics: type: counter_vec help: Requests to external fee providers, labeled by provider and outcome status labels: [provider, status] + alternative_fee_provider_last_sync_timestamp_seconds: + name: blockbook_alternative_fee_provider_last_sync_timestamp_seconds + type: gauge_vec + help: Unix time of the last successful refresh of the cached EVM alternative-provider fees, labeled by provider (infura or 1inch). Plot time() - this for cache age; the pull path falls back to on-chain once the cache is older than the provider's stale window (default 600s / 10m, configurable per coin via staleSeconds, independent of the periodSeconds cache TTL). Fees are fetched on demand, so on a quiet instance the age legitimately grows - alert on it only together with alternative_fee_provider_cache showing demand (fetched/coalesced/stale/miss rates). Exported as a timestamp rather than a computed *_age_seconds gauge on purpose - it is only written on a successful refresh, so age keeps rising when a provider wedges and it is restart-safe + labels: [provider] + alternative_fee_provider_cache: + name: blockbook_alternative_fee_provider_cache + type: counter_vec + help: EVM alternative-provider fee estimates served, by provider and cache result - fresh (cache hit within the periodSeconds TTL), fetched (this request triggered the upstream fetch), coalesced (waited on another request's fetch), stale (fetch failed, served last value within the stale window), miss (nothing to serve, caller falls back to on-chain estimation). Distinguishes an idle instance (no traffic at all) from a broken provider (stale/miss dominating) now that fees are fetched on demand + labels: [provider, result] + eth_eip1559_fee: + name: blockbook_eth_eip1559_fee + type: gauge_vec + help: Most recent EIP-1559 fee tier served by the pull path (estimateFee / EthereumTypeGetEip1559Fees), in wei - divide by 1e9 for Gwei; labeled by tier (low/medium/high/instant) and kind (max_fee = maxFeePerGas, priority_fee = maxPriorityFeePerGas). EVM fees are otherwise invisible (estimated_fee is UTXO-only). Last-writer-wins per estimate, like estimated_fee + labels: [tier, kind] + eth_eip1559_base_fee: + name: blockbook_eth_eip1559_base_fee + type: gauge + help: Base fee underlying the most recent pull-path EIP-1559 estimate, in wei - divide by 1e9 for Gwei. The alternative provider's reported base fee when served from a provider, otherwise eth_feeHistory's last requested-window base fee (baseFeePerGas[blocks-1]); on backends that drop the extra projected element (e.g. Erigon) that is already the next-block projection, on others it is the most recent included block. Pair with eth_block_base_fee to see projection vs realized + eth_eip1559_fee_source_total: + name: blockbook_eth_eip1559_fee_source_total + type: counter_vec + help: Served EIP-1559 pull-path estimates by source observed at the serve boundary - provider (alternative provider cache hit), onchain_fallback (provider configured but cache stale/unready, served from eth_feeHistory) or onchain (no provider configured). Complements alternative_fee_provider_requests (on-demand upstream fetch) and alternative_fee_provider_last_sync_timestamp_seconds (cache age), which together explain why a fallback happened + labels: [source] + eth_block_gas_used_ratio: + name: blockbook_eth_block_gas_used_ratio + type: gauge + help: gasUsed / gasLimit of the most recently connected EVM block (0..1), the congestion signal that drives the next base fee - above 0.5 the next block's base fee rises (up to +12.5% per block), below 0.5 it falls. This is the push path's view of why fees move; updated once per connected block, so it sweeps catch-up blocks and settles on the tip + eth_block_base_fee: + name: blockbook_eth_block_base_fee + type: gauge + help: Base fee per gas of the most recently connected EVM block, in wei - divide by 1e9 for Gwei. The realized base fee from the block header (push path); compare with eth_eip1559_base_fee (the pull path's next-block projection) to see whether the projection tracks the chain. Updated once per connected block + rpc_sync_latency: + name: blockbook_rpc_sync_latency + type: histogram_vec + help: Blockbook-to-backend round-trip time for sync-internal RPC calls (eth_getBlockByHash, eth_getLogs, debug_traceBlockByHash, etc.), in milliseconds, labeled by method and whether the call failed. Typically sub-100ms; multi-second values mean an overloaded backend, and observations near 10-15s are trace_timeout/rpc_timeout expiries rather than real round trips + labels: [method, error] + buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2000, 5000, 10000, 20000] eth_sync_rpc_errors: name: blockbook_eth_sync_rpc_errors type: counter_vec diff --git a/contrib/gh-vars.sh b/contrib/gh-vars.sh index 5320c71866..1c362edd22 100644 --- a/contrib/gh-vars.sh +++ b/contrib/gh-vars.sh @@ -6,6 +6,9 @@ # prefix-suffix normalisation used by .github/actions/export-env-vars so that # locally running tests see the exact same environment as CI. # +# BB_DEV_RPC_URL_* / BB_PROD_RPC_URL_* live in the write-only BB_RPC_URLS secret, +# not in variables, so export those yourself (e.g. from the team password store). +# # 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 diff --git a/contrib/scripts/render_grafana.py b/contrib/scripts/render_grafana.py index c800b20bd5..e141dbd96d 100644 --- a/contrib/scripts/render_grafana.py +++ b/contrib/scripts/render_grafana.py @@ -22,9 +22,10 @@ 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. + (unknown metric key, panel/query key mismatch, residual placeholder, a legend + off the replica-naming convention -- see legend_problems -- 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 @@ -47,6 +48,46 @@ # carries no per-panel/per-target datasource boilerplate. DATASOURCE = {"type": "prometheus", "uid": "${DS_PROMETHEUS}"} +# Legend convention (issue #1717): the coin dropdown already fixes `coin`, so a legend never +# repeats it; instead every series says which replica it is -- `{{instance}}` for a +# per-replica series, or the literal `all replicas` prefix when the query sums replicas away. +COIN_FILTER = 'coin="$coin"' +ALL_REPLICAS = "all replicas" +LEGEND_LABEL = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}") +BY_CLAUSE = re.compile(r"\bby\s*\(([^)]*)\)") +AGGREGATION = re.compile(r"\b(sum|min|max|avg|count)\s*(?:by\s*\([^)]*\)\s*)?\(") + + +def legend_problems(pkey, qkey, promql, legend): + """Check one query's legend against the replica-naming convention. + + Skips Grafana's `__auto` (tables) and constant reference lines (`vector(...)`), which name + no series. Otherwise the legend must carry `{{instance}}` and the query must keep the + instance label through every `by (...)`, or start with `all replicas - ` and actually + aggregate the instance label away -- so the legend never claims a scope the query lacks. + """ + where = "panel %r query %r" % (pkey, qkey) + if legend is None: + return ["%s has no 'legend' in panels.yaml" % where] + if legend == "__auto" or promql.lstrip().startswith("vector("): + return [] + problems = [] + if COIN_FILTER in promql and "coin" in LEGEND_LABEL.findall(legend): + problems.append("%s legend %r repeats {{coin}}, which the $coin dropdown already fixes" % (where, legend)) + by_groups = [{lbl.strip() for lbl in g.split(",")} for g in BY_CLAUSE.findall(promql)] + aggregates = bool(AGGREGATION.search(promql)) + if "instance" in LEGEND_LABEL.findall(legend): + if aggregates and (not by_groups or any("instance" not in g for g in by_groups)): + problems.append("%s legend %r names {{instance}} but the query aggregates it away; add instance to " + "every by (...) or prefix the legend with '%s - '" % (where, legend, ALL_REPLICAS)) + elif legend.startswith(ALL_REPLICAS + " - "): + if not aggregates or any("instance" in g for g in by_groups): + problems.append("%s legend %r claims '%s' but the query keeps per-instance series" + % (where, legend, ALL_REPLICAS)) + else: + problems.append("%s legend %r must carry {{instance}} or start with '%s - '" % (where, legend, ALL_REPLICAS)) + return problems + def fail(msg): sys.exit("error: " + msg) @@ -337,6 +378,7 @@ def render(): % (pkey, qkey, t.get("refId"))) continue t["expr"] = expand(promql, reg, missing) + problems.extend(legend_problems(pkey, qkey, promql, qc.get("legend"))) if "legend" in qc: t["legendFormat"] = expand(qc["legend"], reg, missing) extra = set(queries) - tmpl_qkeys diff --git a/contrib/scripts/render_grafana_test.py b/contrib/scripts/render_grafana_test.py index 9391ea2c9b..27515004a5 100644 --- a/contrib/scripts/render_grafana_test.py +++ b/contrib/scripts/render_grafana_test.py @@ -173,5 +173,54 @@ def test_row_without_panels_list_is_rejected(self): self.assertTrue(any("'panels' list" in p for p in render_grafana.validate_rendered(dash))) +class LegendConventionTest(unittest.TestCase): + PER_INSTANCE = 'blockbook_mempool_size{job="blockbook", coin="$coin"}' + SUMMED_BY_METHOD = 'sum(rate(blockbook_rpc_latency_count{job="blockbook", coin="$coin"}[1m])) by (method) * 60' + SUMMED_BY_INSTANCE = 'sum by (instance, method) (rate(blockbook_rpc_latency_count{job="blockbook", coin="$coin"}[1m]))' + + def problems(self, promql, legend): + return render_grafana.legend_problems("rpc.request_rate", "requests", promql, legend) + + def test_instance_legend_on_per_instance_series_is_accepted(self): + self.assertEqual([], self.problems(self.PER_INSTANCE, "{{instance}} - {{method}}/min")) + self.assertEqual([], self.problems(self.SUMMED_BY_INSTANCE, "{{instance}} - {{method}}")) + + def test_all_replicas_legend_on_aggregated_series_is_accepted(self): + self.assertEqual([], self.problems(self.SUMMED_BY_METHOD, "all replicas - {{method}}")) + + def test_auto_and_reference_lines_are_exempt(self): + self.assertEqual([], self.problems(self.PER_INSTANCE, "__auto")) + self.assertEqual([], self.problems("vector(600)", "default stale cutoff (600s = 10m)")) + + def test_coin_in_legend_is_rejected_when_the_dropdown_fixes_it(self): + problems = self.problems(self.PER_INSTANCE, "{{coin}} - {{instance}}") + self.assertEqual(1, len(problems)) + self.assertIn("repeats {{coin}}", problems[0]) + + def test_coin_in_legend_is_allowed_on_all_coin_panels(self): + self.assertEqual([], self.problems('blockbook_synchronized{job="blockbook"}', "{{coin}} - {{instance}}")) + + def test_legend_without_replica_scope_is_rejected(self): + problems = self.problems(self.SUMMED_BY_METHOD, "{{method}}") + self.assertEqual(1, len(problems)) + self.assertIn("must carry {{instance}}", problems[0]) + + def test_missing_legend_is_rejected(self): + self.assertEqual(1, len(self.problems(self.PER_INSTANCE, None))) + + def test_instance_legend_on_aggregated_series_is_rejected(self): + problems = self.problems(self.SUMMED_BY_METHOD, "{{instance}} - {{method}}") + self.assertEqual(1, len(problems)) + self.assertIn("aggregates it away", problems[0]) + # a bare sum() with no by-clause drops instance just the same + self.assertEqual(1, len(self.problems("sum(blockbook_websocket_clients{coin=\"$coin\"})", "{{instance}}"))) + + def test_all_replicas_legend_on_per_instance_series_is_rejected(self): + for promql in (self.PER_INSTANCE, self.SUMMED_BY_INSTANCE): + problems = self.problems(promql, "all replicas - {{method}}") + self.assertEqual(1, len(problems), promql) + self.assertIn("keeps per-instance series", problems[0]) + + if __name__ == "__main__": unittest.main() diff --git a/db/bulkconnect.go b/db/bulkconnect.go index 5c958055c4..bf23dc4577 100644 --- a/db/bulkconnect.go +++ b/db/bulkconnect.go @@ -609,6 +609,7 @@ func (b *BulkConnect) connectBlockEthereumType(block *bchain.Block, storeBlockTx return err } } + b.d.storeAddrContractsCacheIfDue() return nil } diff --git a/db/contract_info_cache.go b/db/contract_info_cache.go index 6582328eae..7cffceafd7 100644 --- a/db/contract_info_cache.go +++ b/db/contract_info_cache.go @@ -93,6 +93,18 @@ func (c *contractInfoLRU) add(key string, value *bchain.ContractInfo, reorgGen, delete(c.items, oldest.Value.(*contractInfoLRUEntry).key) } +// reset empties the cache; the package-level cache outlives a database instance, +// so tests must call it when opening a fresh database. +func (c *contractInfoLRU) reset() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.order.Init() + clear(c.items) +} + func (c *contractInfoLRU) delete(key string) { if c == nil { return diff --git a/db/contract_info_cache_unittest.go b/db/contract_info_cache_unittest.go new file mode 100644 index 0000000000..74270e010b --- /dev/null +++ b/db/contract_info_cache_unittest.go @@ -0,0 +1,10 @@ +//go:build unittest + +package db + +// ResetContractInfoCache drops the package-level ContractInfo cache. A blockbook process holds one +// RocksDB, but tests build several and every instance reads through this one cache. +// TODO: move the cache onto the RocksDB struct so multi-DB tests need no reset. +func ResetContractInfoCache() { + cachedContracts.reset() +} diff --git a/db/fiat.go b/db/fiat.go index 8cf6c9fa9d..9ffb3f98fd 100644 --- a/db/fiat.go +++ b/db/fiat.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/json" "math" + "strings" "time" vlq "github.com/bsm/go-vlq" @@ -53,36 +54,47 @@ func packCurrencyRatesTicker(ticker *common.CurrencyRatesTicker) []byte { 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) +// unpackRates decodes one packed rates section (count, then key/float32 pairs) and returns the rest of buf +func unpackRates(buf []byte) (map[string]float32, []byte) { + n, 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 - } + if n == 0 { + return nil, buf + } + rates := make(map[string]float32, n) + for i := 0; i < int(n); i++ { + s, l := unpackString(buf) + buf = buf[l:] + v, l := unpackFloat32(buf) + buf = buf[l:] + rates[s] = v + } + return rates, buf +} + +// unpackCurrencyRatesTicker decodes a stored ticker; a non-empty token keeps only that token's rate, +// because a ticker holds thousands of token rates and materializing all of them per lookup dominates CPU and GC. +// The token matches its exact or lowercase key, as CurrencyRatesTicker.findTokenRate does, and the kept rate +// is stored under token itself, so GetTokenRate(token) hits without a second ToLower. +func unpackCurrencyRatesTicker(buf []byte, token string) (*common.CurrencyRatesTicker, error) { + var ticker common.CurrencyRatesTicker + ticker.Rates, buf = unpackRates(buf) + if token == "" { + ticker.TokenRates, _ = unpackRates(buf) + return &ticker, nil } - len, l = unpackVaruint(buf) + lowerToken := strings.ToLower(token) + n, 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 + for i := 0; i < int(n); i++ { + key, l := unpackStringBytes(buf) + buf = buf[l:] + v, l := unpackFloat32(buf) + buf = buf[l:] + // compare without converting: string(key) == s is allocation-free only in this direct form + if string(key) == token || string(key) == lowerToken { + ticker.TokenRates = map[string]float32{token: v} + return &ticker, nil } } return &ticker, nil @@ -102,7 +114,7 @@ func getTickerFromIterator(it *grocksdb.Iterator, vsCurrency string, token strin if err != nil { return nil, err } - ticker, err := unpackCurrencyRatesTicker(it.Value().Data()) + ticker, err := unpackCurrencyRatesTicker(it.Value().Data(), token) if err != nil { return nil, err } @@ -125,7 +137,7 @@ func (d *RocksDB) FiatRatesGetTicker(tickerTime *time.Time) (*common.CurrencyRat if len(data) == 0 { return nil, nil } - ticker, err := unpackCurrencyRatesTicker(data) + ticker, err := unpackCurrencyRatesTicker(data, "") if err != nil { return nil, err } @@ -133,7 +145,8 @@ func (d *RocksDB) FiatRatesGetTicker(tickerTime *time.Time) (*common.CurrencyRat return ticker, nil } -// FiatRatesFindTicker gets FiatRates data closest to the specified timestamp, of the base currency, vsCurrency or the token if specified +// FiatRatesFindTicker gets FiatRates data closest to the specified timestamp, of the base currency, vsCurrency or the token if specified. +// Tickers found for a token carry only that token's rate. 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]) @@ -153,7 +166,7 @@ func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time, vsCurrency string, } // FiatRatesFindTickers gets FiatRates data closest to each specified timestamp. -// The method is optimized for timestamps sorted in ascending order. +// The method is optimized for timestamps sorted in ascending order. Tickers found for a token carry only that token's rate. func (d *RocksDB) FiatRatesFindTickers(timestamps []int64, vsCurrency string, token string) ([]*common.CurrencyRatesTicker, error) { tickers := make([]*common.CurrencyRatesTicker, len(timestamps)) if len(timestamps) == 0 { @@ -255,7 +268,8 @@ func (d *RocksDB) FiatRatesGetTickerTimestamps(from time.Time) ([]int64, error) return timestamps, nil } -// FiatRatesFindLastTicker gets the last FiatRates record, of the base currency, vsCurrency or the token if specified +// FiatRatesFindLastTicker gets the last FiatRates record, of the base currency, vsCurrency or the token if specified. +// Tickers found for a token carry only that token's rate. func (d *RocksDB) FiatRatesFindLastTicker(vsCurrency string, token string) (*common.CurrencyRatesTicker, error) { it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) defer it.Close() diff --git a/db/fiat_test.go b/db/fiat_test.go index 3d743d36db..9b894f6d35 100644 --- a/db/fiat_test.go +++ b/db/fiat_test.go @@ -3,6 +3,8 @@ package db import ( + "fmt" + "math/rand" "reflect" "testing" "time" @@ -316,7 +318,7 @@ func Test_packUnpackCurrencyRatesTicker(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { packed := packCurrencyRatesTicker(&tt.data) - got, err := unpackCurrencyRatesTicker(packed) + got, err := unpackCurrencyRatesTicker(packed, "") if err != nil { t.Errorf("unpackCurrencyRatesTicker() error = %v", err) return @@ -327,3 +329,111 @@ func Test_packUnpackCurrencyRatesTicker(t *testing.T) { }) } } + +func Test_unpackCurrencyRatesTickerToken(t *testing.T) { + data := common.CurrencyRatesTicker{ + Rates: map[string]float32{"usd": 2000, "eur": 1800}, + TokenRates: map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 0.0005, + "0x6B175474E89094C44Da98b954EedeAC495271d0F": 0.00051, + }, + } + packed := packCurrencyRatesTicker(&data) + full, _ := unpackCurrencyRatesTicker(packed, "") + tokens := []string{ + "0xdac17f958d2ee523a2206206994597c13d831ec7", // exact, lowercase + "0xdAC17F958D2ee523a2206206994597C13D831ec7", // lowercase fallback + "0x6B175474E89094C44Da98b954EedeAC495271d0F", // exact, mixed case + "0x6b175474e89094c44da98b954eedeac495271d0f", // stored mixed case only -> not found + "0x0000000000000000000000000000000000000000", // absent + } + for _, token := range tokens { + got, err := unpackCurrencyRatesTicker(packed, token) + if err != nil { + t.Fatalf("%s: %v", token, err) + } + if !reflect.DeepEqual(got.Rates, full.Rates) { + t.Errorf("%s: Rates = %v, want %v", token, got.Rates, full.Rates) + } + gotRate, gotFound := got.GetTokenRate(token) + wantRate, wantFound := full.GetTokenRate(token) + if gotRate != wantRate || gotFound != wantFound { + t.Errorf("%s: GetTokenRate = %v,%v, want %v,%v", token, gotRate, gotFound, wantRate, wantFound) + } + if g, w := common.IsSuitableTicker(got, "usd", token), common.IsSuitableTicker(full, "usd", token); g != w { + t.Errorf("%s: IsSuitableTicker = %v, want %v", token, g, w) + } + if len(got.TokenRates) > 1 { + t.Errorf("%s: kept %d token rates, want at most 1", token, len(got.TokenRates)) + } + } +} + +// benchTicker builds a ticker shaped like a recent ETH daily row: ~60 fiat rates, thousands of token rates. +func benchTicker(r *rand.Rand, ts time.Time, tokens []string) *common.CurrencyRatesTicker { + t := &common.CurrencyRatesTicker{Timestamp: ts, Rates: map[string]float32{}, TokenRates: map[string]float32{}} + for i := 0; i < 60; i++ { + t.Rates[fmt.Sprintf("c%02d", i)] = r.Float32() + } + for _, k := range tokens { + t.TokenRates[k] = r.Float32() + } + return t +} + +func benchTokens(r *rand.Rand, n int) []string { + tokens := make([]string, n) + for i := range tokens { + tokens[i] = fmt.Sprintf("0x%016x%016x%08x", r.Uint64(), r.Uint64(), r.Uint32()) + } + return tokens +} + +func Benchmark_unpackCurrencyRatesTicker(b *testing.B) { + r := rand.New(rand.NewSource(1)) + for _, n := range []int{2000, 8000} { + tokens := benchTokens(r, n) + packed := packCurrencyRatesTicker(benchTicker(r, time.Time{}, tokens)) + token := tokens[n/2] + for _, variant := range []struct{ name, filter string }{{"full", ""}, {"token", token}} { + b.Run(fmt.Sprintf("tokens=%d/variant=%s", n, variant.name), func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + tk, _ := unpackCurrencyRatesTicker(packed, variant.filter) + if !common.IsSuitableTicker(tk, "", token) { + b.Fatal("token not found") + } + } + }) + } + } +} + +// BenchmarkFiatRatesFindTickerToken is one explorer token-transfer row: seek to the tx time, first day with the token. +func BenchmarkFiatRatesFindTickerToken(b *testing.B) { + d := setupRocksDB(b, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}) + defer closeAndDestroyRocksDB(b, d) + r := rand.New(rand.NewSource(1)) + tokens := benchTokens(r, 5000) + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + const days = 60 + wb := grocksdb.NewWriteBatch() + for i := 0; i < days; i++ { + if err := d.FiatRatesStoreTicker(wb, benchTicker(r, start.AddDate(0, 0, i), tokens)); err != nil { + b.Fatal(err) + } + } + if err := d.WriteBatch(wb); err != nil { + b.Fatal(err) + } + wb.Destroy() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ts := start.Add(time.Duration(r.Intn((days-1)*24)) * time.Hour) + tk, err := d.FiatRatesFindTicker(&ts, "", tokens[r.Intn(len(tokens))]) + if err != nil || tk == nil { + b.Fatal("ticker not found", err) + } + } +} diff --git a/db/rocksdb.go b/db/rocksdb.go index 8111ff9a03..fc2269c5e0 100644 --- a/db/rocksdb.go +++ b/db/rocksdb.go @@ -28,15 +28,234 @@ const dbVersion = 7 const packedHeightBytes = 4 const maxAddrDescLen = 1024 +// maxAddrDescScanNonMatching bounds how many non-matching keys +// GetAddrDescTransactions is allowed to skip before it aborts the scan. +const maxAddrDescScanNonMatching = 1000 + // 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 -// RepairRocksDB calls RocksDb db repair function -func RepairRocksDB(name string) error { - glog.Infof("rocksdb: repair") +// RepairRocksDB performs a PHYSICAL repair of a corrupted RocksDB database and exits. +// +// What it does and its limitations: +// +// - It rebuilds the MANIFEST/CURRENT from the surviving SST files (grocksdb.RepairDb). +// RepairDb takes a SINGLE Options for the whole database and no per-column-family +// descriptors, so the physical-repair phase cannot reproduce the per-CF tuning of +// normal operation (in openDB the "addresses" CF is opened WITHOUT a bloom filter and +// every other CF WITH one). The reopen performed afterwards by +// checkAndResetStateAfterRepair DOES mirror the production per-CF options. +// - RepairDb DROPS any column family that has no live SST files. Empty column families +// (commonly fiatRates/blockFilter/transactions and several Ethereum CFs) are dropped +// and recreated empty on the next normal open — this is benign. A dropped CF that +// actually held data is unrecoverable; this function detects that case (via the +// tracked column stats in the internal state) and fails instead of certifying loss. +// - An interrupted initial/bulk import (DbStateInconsistent) is UNRECOVERABLE by repair: +// the index is incomplete because writes were never issued, and bulk flushes do not +// align to block boundaries (see db/bulkconnect.go), so there is no consistent height +// to resume from. Such a database must be recreated (deleted and re-imported from +// scratch). By default this function refuses it; pass resetInconsistent=true (blockbook +// -forcerepair) to force it into open state so blockbook can start, but the index may +// remain PERMANENTLY incomplete — normal sync only resumes forward from the stored best +// height and never backfills blocks below it, so -forcerepair is a startability escape +// hatch, not a recovery. +// +// In short: --repair fixes physical SST/MANIFEST corruption (this function); -fixutxo +// recomputes the UTXO/balance index for Bitcoin-type coins; an interrupted bulk import +// (DbStateInconsistent) requires a full re-import/resync. +// +// The *RocksDB methods (LoadInternalState/storeState/SetInconsistentState) cannot be used +// here because they need a fully constructed *RocksDB with a chainParser and the populated +// package-global cfNames, neither of which exists during --repair (it runs before the chain +// is loaded, so cfNames is empty). Column families are therefore discovered from the DB +// itself via ListColumnFamilies, and the internal-state primitives are reused directly. +func RepairRocksDB(name string, resetInconsistent bool) error { + glog.Infof("rocksdb: repair %s", name) + start := time.Now() + + // Best-effort pre-list so we can detect dropped column families. A corrupt/lost + // MANIFEST is exactly what --repair must fix, so a failure here must not neuter the + // tool: disable drop-detection and continue. The DbState gate below remains the + // safety backstop. + beforeCFs, listErr := repairListColumnFamilies(name) + if listErr != nil { + glog.Warningf("rocksdb: cannot list column families before repair, drop-detection disabled: %v", listErr) + beforeCFs = nil + } + + // Physical repair: rebuild MANIFEST from surviving SSTs. + c := grocksdb.NewLRUCache(64 << 20) + opts := createAndSetDBOptions(10, c, 1000) + err := grocksdb.RepairDb(name, opts) + opts.Destroy() + c.Destroy() + if err != nil { + return errors.Annotatef(err, "RepairDb %s", name) + } + glog.Infof("rocksdb: physical repair completed, took %v", time.Since(start)) + + // A repaired DB that cannot be listed genuinely failed. + afterCFs, err := repairListColumnFamilies(name) + if err != nil { + return errors.Annotatef(err, "listing column families after repair of %s", name) + } + + dropped := missingColumnFamilies(beforeCFs, afterCFs) + return checkAndResetStateAfterRepair(name, afterCFs, dropped, resetInconsistent) +} + +// checkAndResetStateAfterRepair reopens the repaired database with all discovered column +// families (mirroring openDB's per-CF options), reads the internal state from the default +// CF and acts on DbState honestly. See RepairRocksDB for the full contract. +func checkAndResetStateAfterRepair(name string, cfNames, dropped []string, resetInconsistent bool) error { + // Per-CF options mirroring openDB: bloom filter everywhere except the addresses CF. + c := grocksdb.NewLRUCache(64 << 20) + defer c.Destroy() + opts := createAndSetDBOptions(10, c, 1000) + defer opts.Destroy() + optsAddresses := createAndSetDBOptions(0, c, 1000) + defer optsAddresses.Destroy() + + cfOpts := make([]*grocksdb.Options, len(cfNames)) + defaultIdx := -1 + for i, n := range cfNames { + if n == cfBaseNames[cfAddresses] { + cfOpts[i] = optsAddresses + } else { + cfOpts[i] = opts + } + if n == cfBaseNames[cfDefault] { + defaultIdx = i + } + } + if defaultIdx < 0 { + return errors.Errorf("repaired database %s has no default column family", name) + } + + db, cfh, err := grocksdb.OpenDbColumnFamilies(opts, name, cfNames, cfOpts) + if err != nil { + return errors.Annotatef(err, "cannot reopen repaired database %s", name) + } + // Destroy CF handles then close the DB (matching closeDB), before the Options/Cache + // they reference are Destroyed by the defers above (LIFO). + defer func() { + for _, h := range cfh { + h.Destroy() + } + db.Close() + }() + + ro := grocksdb.NewDefaultReadOptions() + defer ro.Destroy() + val, err := db.GetCF(ro, cfh[defaultIdx], []byte(internalStateKey)) + if err != nil { + return errors.Annotatef(err, "cannot read internal state of %s after repair", name) + } + defer val.Free() + + if val.Size() == 0 { + for _, cf := range dropped { + glog.Infof("rocksdb: repair dropped empty column family %q (recreated on next open)", cf) + } + glog.Info("rocksdb: repair complete; no internal state stored (never-synced database)") + return nil + } + + is, err := common.UnpackInternalState(val.Data()) + if err != nil { + return errors.Annotatef(err, "cannot unpack internal state of %s after repair", name) + } + + // Report dropped column families. RepairDb only drops CFs that currently have no live + // SST files, so a dropped CF is empty *now* and is recreated (empty) on the next open — + // benign in the common case (fiatRates/blockFilter/transactions and several Ethereum CFs + // are routinely empty). is.DbColumns row counts can show a CF was populated in the past, + // but they are NOT authoritative: only cfTransactions is kept current during normal sync, + // every other count is set solely by --computedbstats and is never decremented, so a CF + // legitimately emptied by a later rollback/prune (then dropped by repair) can still report + // Rows>0. We therefore only WARN about a drop of a previously-populated CF rather than + // refusing repair — aborting here would force an unnecessary full resync of an otherwise + // repairable database (stale-stats false positive). The genuinely unrecoverable case, an + // interrupted bulk import, is caught authoritatively by the DbStateInconsistent gate below. + for _, cf := range dropped { + if rows, ok := columnRows(is, cf); ok && rows > 0 { + glog.Warningf("rocksdb: repair dropped column family %q which last reported %d rows; if this was not caused by a prior rollback/prune the index may be incomplete and a resync is advised", cf, rows) + } else { + glog.Infof("rocksdb: repair dropped empty column family %q (recreated on next open)", cf) + } + } + + switch is.DbState { + case common.DbStateInconsistent: + if !resetInconsistent { + return errors.Errorf("database is in inconsistent state: an initial/bulk import was interrupted, the index is incomplete and cannot be recovered by --repair; the database must be recreated (deleted and re-imported from scratch). Re-run with -forcerepair to force it into open state so blockbook can start, but note the index may be PERMANENTLY incomplete: normal sync resumes forward from the stored best height and never backfills blocks below it, so only a full recreate guarantees a complete index") + } + // -forcerepair is a startability escape hatch, NOT a recovery. Flipping to + // DbStateOpen lets the next start treat the DB as an ordinary ungraceful shutdown + // and resume forward from the persisted best height. But an interrupted bulk import + // can leave the best height ahead of the block/address data actually flushed (bulk + // flushes do not align to block boundaries), so ranges below the best height are + // never re-fetched and the index can keep permanent gaps. This is unavoidable + // without a full recreate; we make it loud rather than silent. + glog.Warning("rocksdb: -forcerepair: forcing inconsistent database into open state so blockbook can start") + glog.Warning("rocksdb: -forcerepair: the index may be PERMANENTLY INCOMPLETE - normal sync resumes forward from the stored best height and does NOT backfill blocks below it; recreate (delete + re-import) is the only way to guarantee a complete index") + // DbStateOpen, not DbStateClosed — matches SetInconsistentState(false) semantics; + // Closed would falsely claim a graceful shutdown. + is.DbState = common.DbStateOpen + buf, err := is.Pack() + if err != nil { + return errors.Annotatef(err, "cannot pack internal state") + } + wo := grocksdb.NewDefaultWriteOptions() + wo.SetSync(true) // durable flip in the WAL without relying on Close-time flush ordering + defer wo.Destroy() + if err := db.PutCF(wo, cfh[defaultIdx], []byte(internalStateKey), buf); err != nil { + return errors.Annotatef(err, "cannot store internal state after reset") + } + glog.Warning("rocksdb: DbStateInconsistent reset to DbStateOpen; blockbook will start and resume forward sync on next start (gaps below the best height, if any, will remain)") + case common.DbStateOpen: + glog.Info("rocksdb: repair complete; database was left in open state (previous ungraceful shutdown), normal sync will recover it") + case common.DbStateClosed: + glog.Info("rocksdb: repair complete; database is in closed (healthy) state") + default: + return errors.Errorf("repair: unexpected DbState=%d; refusing to mark database usable", is.DbState) + } + return nil +} + +// repairListColumnFamilies lists the column families of the database at name, owning and +// freeing its Options. +func repairListColumnFamilies(name string) ([]string, error) { opts := grocksdb.NewDefaultOptions() - return grocksdb.RepairDb(name, opts) + defer opts.Destroy() + return grocksdb.ListColumnFamilies(opts, name) +} + +// missingColumnFamilies returns the column families present in before but not in after. +func missingColumnFamilies(before, after []string) []string { + present := make(map[string]struct{}, len(after)) + for _, n := range after { + present[n] = struct{}{} + } + var dropped []string + for _, n := range before { // nil-safe: empty before => no drops reported + if _, ok := present[n]; !ok { + dropped = append(dropped, n) + } + } + return dropped +} + +// columnRows returns the tracked row count for the named column family and whether it was +// found in the internal state's column stats. +func columnRows(is *common.InternalState, name string) (int64, bool) { + for i := range is.DbColumns { + if is.DbColumns[i].Name == name { + return is.DbColumns[i].Rows, true + } + } + return 0, false } type connectBlockStats struct { @@ -60,6 +279,9 @@ const ( const addrContractsCacheMinSize = 300_000 // limit for caching address contracts in memory to speed up indexing +// addrContractsCacheStorePeriod is how often the cache is packed to disk during block connect. +const addrContractsCacheStorePeriod = 5 * time.Minute + // RocksDB handle type RocksDB struct { path string @@ -97,7 +319,9 @@ type RocksDB struct { // addrContractsCacheBytes tracks cached size based on the packed size at insertion time. addrContractsCacheBytes int64 hotAddrTracker *addressHotness - setBlockTimesWG sync.WaitGroup + // lastAddrContractsCacheStore is only read/written from the block-connect goroutine. + lastAddrContractsCacheStore time.Time + setBlockTimesWG sync.WaitGroup // SYSCOIN: used only for NEVM metadata fallback on asset-cache misses. chain bchain.BlockChain } @@ -247,7 +471,7 @@ func NewRocksDB(path string, cacheSize, maxOpenFiles int, parser bchain.BlockCha if r.bulkAddrContractsCacheMaxBytes == 0 { r.bulkAddrContractsCacheMaxBytes = r.addrContractsCacheMaxBytes } - go r.periodicStoreAddrContractsCache() + r.lastAddrContractsCacheStore = time.Now() } return r, nil } @@ -387,6 +611,7 @@ func (d *RocksDB) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor, low startKey := packAddressKey(addrDesc, higher) stopKey := packAddressKey(addrDesc, lower) indexes := make([]int32, 0, 16) + nonMatching := 0 it := d.db.NewIteratorCF(d.ro, d.cfh[cfAddresses]) defer it.Close() for it.Seek(startKey); it.Valid(); it.Next() { @@ -398,6 +623,11 @@ func (d *RocksDB) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor, low if glog.V(2) { glog.Warningf("rocksdb: addrDesc %s - mixed with %s", addrDesc, hex.EncodeToString(key)) } + // Longer indexed scripts can share even a complete descriptor as a prefix. Bound the scan, but report exhaustion so callers cannot mistake partial history for a complete result. + nonMatching++ + if nonMatching > maxAddrDescScanNonMatching { + return errors.Errorf("address transaction scan limit exceeded for descriptor %s after %d non-matching keys", hex.EncodeToString(addrDesc), nonMatching) + } continue } val := it.Value().Data() @@ -559,9 +789,15 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error { if err := d.WriteBatch(wb); err != nil { return err } - avg := d.is.SetBlockTime(block.Height, uint32(block.Time)) + if chainType == bchain.ChainEthereumType { + d.storeAddrContractsCacheIfDue() + } + // Fractional seconds: the integer average truncates to 0 on sub-second chains, which + // silently excludes them from any rule gated on this gauge being > 0. SetBlockTime + // returns the value under the write lock so this hot path takes is.mux only once. + avgBlockPeriodSeconds := d.is.SetBlockTime(block.Height, uint32(block.Time)) if d.metrics != nil { - d.metrics.AvgBlockPeriod.Set(float64(avg)) + d.metrics.AvgBlockPeriod.Set(avgBlockPeriodSeconds) } if d.metrics != nil { if chainType == bchain.ChainBitcoinType { @@ -1200,6 +1436,54 @@ func (d *RocksDB) GetTxAddresses(txid string) (*TxAddresses, error) { return d.getTxAddresses(btxID) } +// GetTxAddressesOutput returns Outputs[vout] of the transaction's TxAddresses +// record, or nil if the record or that output is not present. Unlike +// GetTxAddresses it does not unpack the whole record: inputs and preceding +// outputs are skipped without allocating, so resolving a single previous output +// costs O(1) allocations regardless of the referenced transaction's size. +func (d *RocksDB) GetTxAddressesOutput(txid string, vout uint32) (*TxOutput, error) { + btxID, err := d.chainParser.PackTxid(txid) + if err != nil { + return nil, err + } + val, err := d.db.GetCF(d.ro, d.cfh[cfTxAddresses], btxID) + if err != nil { + return nil, err + } + defer val.Free() + buf := val.Data() + // 3 is minimum length of a txAddresses record - 1 byte height, 1 byte inputs len, 1 byte outputs len + if len(buf) < 3 { + return nil, nil + } + var l int + if supportsSyscoinAssets(d.chainParser) { + _, l = unpackVaruint(buf) // version + } + _, n := unpackVaruint(buf[l:]) // height + l += n + if d.extendedIndex { + _, n := unpackVaruint(buf[l:]) // vsize + l += n + } + inputs, n := unpackVaruint(buf[l:]) + l += n + for i := uint(0); i < inputs; i++ { + l += d.packedTxInputLen(buf[l:]) + } + outputs, n := unpackVaruint(buf[l:]) + l += n + if uint(vout) >= outputs { + return nil, nil + } + for i := uint(0); i < uint(vout); i++ { + l += d.packedTxOutputLen(buf[l:]) + } + var output TxOutput + d.unpackTxOutput(&output, buf[l:]) + return &output, nil +} + // 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) { @@ -1550,6 +1834,59 @@ func (d *RocksDB) unpackTxOutput(to *TxOutput, buf []byte) int { return al } +// packedTxInputLen returns the byte length of the packed input at the start of buf +// without allocating. Keep in sync with appendTxInput/unpackTxInput. +func (d *RocksDB) packedTxInputLen(buf []byte) int { + if d.extendedIndex { + al, pos := unpackVarint(buf) + coinbase := al < 0 + if coinbase { + al = ^al + } + pos += al // addrDesc + pos += int(buf[pos]) + 1 // value (1-byte length prefix + bytes) + if !coinbase { + pos += d.chainParser.PackedTxidLen() // prev txid + _, n := unpackVaruint(buf[pos:]) // prev vout + pos += n + } + if supportsSyscoinAssets(d.chainParser) { + pos += packedAssetInfoLen(buf[pos:]) + } + return pos + } + al, pos := unpackVaruint(buf) + pos += int(al) // addrDesc + pos += int(buf[pos]) + 1 // value + if supportsSyscoinAssets(d.chainParser) { + pos += packedAssetInfoLen(buf[pos:]) + } + return pos +} + +// packedTxOutputLen returns the byte length of the packed output at the start of buf +// without allocating. Keep in sync with appendTxOutput/unpackTxOutput. +func (d *RocksDB) packedTxOutputLen(buf []byte) int { + al, pos := unpackVarint(buf) + spent := al < 0 + if spent { + al = ^al + } + pos += al // addrDesc + pos += int(buf[pos]) + 1 // value + if d.extendedIndex && spent { + pos += d.chainParser.PackedTxidLen() // spent txid + _, n := unpackVaruint(buf[pos:]) // spent index + pos += n + _, n = unpackVaruint(buf[pos:]) // spent height + pos += n + } + if supportsSyscoinAssets(d.chainParser) { + pos += packedAssetInfoLen(buf[pos:]) + } + return pos +} + func (d *RocksDB) packTxIndexes(txi []txIndexes) []byte { buf := make([]byte, 0, 32) bvout := make([]byte, vlq.MaxLen32) @@ -2235,9 +2572,9 @@ func (d *RocksDB) setBlockTimes() { glog.Error("rocksdb: cannot load block times ", err) return } - avg := d.is.SetBlockTimes(bt) + avgBlockPeriodSeconds := d.is.SetBlockTimes(bt) if d.metrics != nil { - d.metrics.AvgBlockPeriod.Set(float64(avg)) + d.metrics.AvgBlockPeriod.Set(avgBlockPeriodSeconds) } glog.Info("rocksdb: processed block times in ", time.Since(start)) } @@ -2860,6 +3197,18 @@ func unpackAssetInfo(buf []byte) (*bchain.AssetInfo, int) { return &bchain.AssetInfo{AssetGuid: assetGuid, ValueSat: &valueSat}, l } +// packedAssetInfoLen skips optional Syscoin metadata without allocating its value. +// Keep in sync with appendAssetInfo/unpackAssetInfo. +func packedAssetInfoLen(buf []byte) int { + flag, pos := unpackVaruint(buf) + if flag != 0 { + _, n := unpackVaruint64(buf[pos:]) // asset GUID + pos += n + pos += int(buf[pos]) + 1 // value + } + return pos +} + func packString(s string) []byte { varBuf := make([]byte, vlq.MaxLen64) l := len(s) @@ -2871,10 +3220,15 @@ func packString(s string) []byte { } func unpackString(buf []byte) (string, int) { + b, l := unpackStringBytes(buf) + return string(b), l +} + +// unpackStringBytes returns the packed string as a slice of buf, for comparisons that must not allocate +func unpackStringBytes(buf []byte) ([]byte, int) { sl, l := unpackVaruint(buf) so := l + int(sl) - s := string(buf[l:so]) - return s, so + return buf[l:so], so } const ( diff --git a/db/rocksdb_contracts.go b/db/rocksdb_contracts.go index 62e62eadc6..ccd962ded6 100644 --- a/db/rocksdb_contracts.go +++ b/db/rocksdb_contracts.go @@ -1,16 +1,37 @@ package db import ( + "unicode/utf8" + vlq "github.com/bsm/go-vlq" + "github.com/golang/glog" + "github.com/juju/errors" "github.com/linxGnu/grocksdb" "github.com/trezor/blockbook/bchain" ) var cachedContracts = newContractInfoLRU(cachedContractsLRUMaxSize) +// maxContractNameSymbolBytes bounds the token name/symbol persisted per contract. A malicious +// contract can return a multi-megabyte name()/symbol() over a free eth_call; capping here (the sole +// pack chokepoint for every write path) keeps one cheap deployment from bloating the index forever. +const maxContractNameSymbolBytes = 256 + +// clampUTF8 truncates s to at most max bytes without leaving a split trailing rune. +func clampUTF8(s string, max int) string { + if len(s) <= max { + return s + } + s = s[:max] + for len(s) > 0 && !utf8.ValidString(s) { + s = s[:len(s)-1] + } + return s +} + func packContractInfo(contractInfo *bchain.ContractInfo) []byte { - buf := packString(contractInfo.Name) - buf = append(buf, packString(contractInfo.Symbol)...) + buf := packString(clampUTF8(contractInfo.Name, maxContractNameSymbolBytes)) + buf = append(buf, packString(clampUTF8(contractInfo.Symbol, maxContractNameSymbolBytes))...) buf = append(buf, packString(string(contractInfo.Standard))...) varBuf := make([]byte, vlq.MaxLen64) l := packVaruint(uint(contractInfo.Decimals), varBuf) @@ -157,32 +178,266 @@ func (d *RocksDB) GetContractInfoErc4626Vault(contract bchain.AddressDescriptor) func (d *RocksDB) StoreContractInfo(contractInfo *bchain.ContractInfo) error { wb := grocksdb.NewWriteBatch() defer wb.Destroy() - if err := d.storeContractInfo(wb, contractInfo); err != nil { + if err := d.storeContractInfo(wb, contractInfo, nil); err != nil { return err } return d.WriteBatch(wb) } -func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchain.ContractInfo) error { +// inBatch carries rows already written to wb, keyed by address descriptor: a +// destruction can only merge into a same-batch creation through it, because +// GetContractInfo does not see the batch before it commits. +func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchain.ContractInfo, inBatch map[string]*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 - } + storedCI := inBatch[string(key)] if storedCI == nil { - return nil + ci, err := d.GetContractInfo(key, "") + if err != nil { + return err + } + if ci == nil { + return nil + } + // copy - the shared cached value must not change before the + // batch commits + ciCopy := *ci + storedCI = &ciCopy } storedCI.DestructedInBlock = contractInfo.DestructedInBlock contractInfo = storedCI } wb.PutCF(d.cfh[cfContracts], key, packContractInfo(contractInfo)) - cacheKey := string(key) - cachedContracts.delete(cacheKey) + if inBatch != nil { + inBatch[string(key)] = contractInfo + } + cachedContracts.delete(string(key)) + } + return nil +} + +// applyHealedCreation records a creation in block height, reporting whether the record +// applies. A creation older than the stored one belongs to a previous incarnation of a +// reused address (SELFDESTRUCT followed by CREATE2), so it is dropped rather than rewinding +// the row; a destruction no younger than the creation belongs to that same previous +// incarnation and is cleared, as the overwrite in storeContractInfo would have done. +func applyHealedCreation(contractInfo *bchain.ContractInfo, height uint32) bool { + if height < contractInfo.CreatedInBlock { + return false + } + contractInfo.CreatedInBlock = height + if contractInfo.DestructedInBlock <= height { + contractInfo.DestructedInBlock = 0 + } + return true +} + +// applyHealedDestruction records a destruction in block height, reporting whether the +// record applies. A destruction that precedes the stored creation, or that is not newer +// than the stored destruction, describes a previous incarnation of the address - storing it +// would leave the contract destructed before it was created. +func applyHealedDestruction(contractInfo *bchain.ContractInfo, height uint32) bool { + if height < contractInfo.CreatedInBlock || height <= contractInfo.DestructedInBlock { + return false + } + contractInfo.DestructedInBlock = height + return true +} + +// storeHealedContractInfos records the contracts a healed block created or destroyed. The +// failed internal data fetch is what would have produced them, so sync stored none of +// them. Only the lifecycle fields are overlaid on the stored row: sync never fills in +// name/symbol/standard, so a plain put would strip the metadata of a contract that a +// client already queried and enriched. +// +// The records are applied in trace order, each guarded so that the result is what a +// sequential sync would have left behind. Healing runs out of block order, so an unguarded +// overlay could rewind the lifecycle of a reused address or record a destruction preceding +// the creation - states no sync can produce. Applying them in order, rather than merging +// them per contract first, is what keeps a create and a destruct in the same block from +// depending on which one the map happened to keep. +func (d *RocksDB) storeHealedContractInfos(wb *grocksdb.WriteBatch, contracts []bchain.ContractInfo) error { + type healedContract struct { + addrDesc bchain.AddressDescriptor + contractInfo *bchain.ContractInfo + changed bool + } + healed := make(map[string]*healedContract, len(contracts)) + order := make([]string, 0, len(contracts)) + for i := range contracts { + ci := &contracts[i] + if ci.Contract == "" { + continue + } + addrDesc, err := d.chainParser.GetAddrDescFromAddress(ci.Contract) + if err != nil { + return err + } + if len(addrDesc) == 0 { + continue + } + key := string(addrDesc) + hc, found := healed[key] + if !found { + contractInfo, err := d.healedContractInfo(addrDesc) + if err != nil { + return err + } + hc = &healedContract{addrDesc: addrDesc, contractInfo: contractInfo} + healed[key] = hc + order = append(order, key) + } + if ci.CreatedInBlock != 0 { + if applyHealedCreation(hc.contractInfo, ci.CreatedInBlock) { + hc.changed = true + } else { + glog.Infof("storeHealedContractInfos: contract %s created in block %d skipped, the stored record is newer (created %d, destructed %d)", + ci.Contract, ci.CreatedInBlock, hc.contractInfo.CreatedInBlock, hc.contractInfo.DestructedInBlock) + } + } + if ci.DestructedInBlock != 0 { + if applyHealedDestruction(hc.contractInfo, ci.DestructedInBlock) { + hc.changed = true + } else { + glog.Infof("storeHealedContractInfos: contract %s destructed in block %d skipped, the stored record is newer (created %d, destructed %d)", + ci.Contract, ci.DestructedInBlock, hc.contractInfo.CreatedInBlock, hc.contractInfo.DestructedInBlock) + } + } + } + for _, key := range order { + hc := healed[key] + if !hc.changed { + continue + } + wb.PutCF(d.cfh[cfContracts], hc.addrDesc, packContractInfo(hc.contractInfo)) + cachedContracts.delete(key) } return nil } + +// healedContractInfo returns a private copy of the stored record to overlay a healed +// block's lifecycle onto, or the stub sync would have stored when there is no row - a +// destruction whose creation was never indexed would otherwise be dropped and the contract +// would look alive forever. +func (d *RocksDB) healedContractInfo(addrDesc bchain.AddressDescriptor) (*bchain.ContractInfo, error) { + stored, err := d.GetContractInfo(addrDesc, "") + if err != nil { + return nil, err + } + if stored != nil { + // copy before mutating, GetContractInfo may return the shared cached entry + contractInfo := *stored + return &contractInfo, nil + } + contractInfo := bchain.ContractInfo{ + Standard: bchain.UnhandledTokenStandard, + Type: bchain.UnhandledTokenStandard, + } + if addresses, _, err := d.chainParser.GetAddressesFromAddrDesc(addrDesc); err == nil && len(addresses) > 0 { + contractInfo.Contract = addresses[0] + } + return &contractInfo, nil +} + +// ListContractInfos returns up to limit stored contract records ordered by +// address descriptor, starting at the optional from address (inclusive), and +// the address to pass as from to fetch the next page ("" when the listing is +// complete). A page limit is mandatory: the rows are sync-populated (every +// contract creation when internal data processing is enabled), so the full +// set can run into millions on a busy chain. +func (d *RocksDB) ListContractInfos(from string, limit int) ([]bchain.ContractInfo, string, error) { + var start bchain.AddressDescriptor + if from != "" { + var err error + start, err = d.chainParser.GetAddrDescFromAddress(from) + if err != nil { + return nil, "", err + } + if start == nil { + return nil, "", errors.Errorf("invalid address %s", from) + } + } + it := d.db.NewIteratorCF(d.ro, d.cfh[cfContracts]) + defer it.Close() + if start != nil { + it.Seek(start) + } else { + it.SeekToFirst() + } + contracts := make([]bchain.ContractInfo, 0, limit) + for ; it.Valid(); it.Next() { + // The key is the contract's address descriptor. A key that fails to + // decode to an address is a corrupt row: fail loudly (as the + // unpackContractInfo error below does) rather than fall through to + // return next="", which a caller cannot tell apart from a completed + // listing and which would silently drop the boundary row's cursor — + // or return an in-page record with an empty Contract. + addresses, _, err := d.chainParser.GetAddressesFromAddrDesc(it.Key().Data()) + if err != nil { + return nil, "", err + } + if len(addresses) == 0 { + return nil, "", errors.Errorf("no address for contract descriptor %x", it.Key().Data()) + } + if len(contracts) == limit { + // one more row exists — its address is the cursor of the next page + return contracts, addresses[0], nil + } + contractInfo, err := unpackContractInfo(it.Value().Data()) + if err != nil { + return nil, "", err + } + contractInfo.Contract = addresses[0] + contracts = append(contracts, *contractInfo) + } + return contracts, "", nil +} + +// DeleteContractInfoForAddress removes the stored contract metadata for the given +// address (and its in-memory cache entry) so the next read re-fetches it from the +// backend node. It returns the purged record (nil when no row was stored): the +// whole row is discarded, including the sync-owned CreatedInBlock and +// DestructedInBlock, which a backend re-fetch cannot restore — only a reindex or +// storing the returned record back can. ERC-4626 protocol-detection rows in +// cfErcProtocols are kept: they live in their own column family with their own +// lifecycle and are merged on read. +func (d *RocksDB) DeleteContractInfoForAddress(address string) (*bchain.ContractInfo, error) { + contract, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + return nil, err + } + if contract == nil { + return nil, errors.Errorf("invalid address %s", address) + } + val, err := d.db.GetCF(d.ro, d.cfh[cfContracts], contract) + if err != nil { + return nil, err + } + buf := val.Data() + var purged *bchain.ContractInfo + if len(buf) > 0 { + purged, _ = unpackContractInfo(buf) + addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(contract) + if len(addresses) > 0 { + purged.Contract = addresses[0] + } + } + val.Free() + if purged == nil { + return nil, nil + } + if err := d.db.DeleteCF(d.wo, d.cfh[cfContracts], contract); err != nil { + return nil, err + } + // Bump before the cache delete: a concurrent GetContractInfo that already + // sampled the old protocolGen and is about to re-add the just-deleted row + // will mismatch on the next read and miss, even if its add lands after our + // delete clears the slot (same idiom as SetErcProtocol). + d.protocolGen.Add(1) + cachedContracts.delete(string(contract)) + return purged, nil +} diff --git a/db/rocksdb_contracts_test.go b/db/rocksdb_contracts_test.go index 0df1b9bcc0..8e4f11de8c 100644 --- a/db/rocksdb_contracts_test.go +++ b/db/rocksdb_contracts_test.go @@ -4,11 +4,437 @@ package db import ( "reflect" + "strconv" + "strings" "testing" + "unicode/utf8" + "github.com/linxGnu/grocksdb" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" ) +func TestRocksDB_ListContractInfos(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + // ordered by address descriptor: 0x20… < 0x4b… < 0x55… + addresses := []string{"0x" + dbtestdata.EthAddr20, "0x" + dbtestdata.EthAddr4b, "0x" + dbtestdata.EthAddr55} + for i, a := range addresses { + if err := d.StoreContractInfo(&bchain.ContractInfo{ + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + Contract: a, + Name: "Contract " + strconv.Itoa(i), + Decimals: 18, + }); err != nil { + t.Fatal(err) + } + } + + contracts, next, err := d.ListContractInfos("", 10) + if err != nil { + t.Fatal(err) + } + if len(contracts) != 3 || next != "" { + t.Fatalf("ListContractInfos() = %d rows, next %q, want 3 rows and no next", len(contracts), next) + } + for i, c := range contracts { + if !strings.EqualFold(c.Contract, addresses[i]) { + t.Errorf("row %d = %s, want %s", i, c.Contract, addresses[i]) + } + } + + // paging: a full first page and a next cursor pointing at the third row + contracts, next, err = d.ListContractInfos("", 2) + if err != nil { + t.Fatal(err) + } + if len(contracts) != 2 || !strings.EqualFold(next, addresses[2]) { + t.Fatalf("ListContractInfos(limit 2) = %d rows, next %q, want 2 rows and next %s", len(contracts), next, addresses[2]) + } + contracts, next, err = d.ListContractInfos(next, 2) + if err != nil { + t.Fatal(err) + } + if len(contracts) != 1 || next != "" || !strings.EqualFold(contracts[0].Contract, addresses[2]) { + t.Fatalf("ListContractInfos(from next) = %+v next %q, want only the third row", contracts, next) + } + + if _, _, err = d.ListContractInfos("not-an-address", 2); err == nil { + t.Error("ListContractInfos() with invalid from: expected error") + } +} + +func TestRocksDB_DeleteContractInfoForAddress(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + address := "0x" + dbtestdata.EthAddr20 + ci := &bchain.ContractInfo{ + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + Contract: address, + Name: "Test contract", + Symbol: "TCT", + Decimals: 18, + CreatedInBlock: 1234567, + } + if err := d.StoreContractInfo(ci); err != nil { + t.Fatal(err) + } + // The get populates the in-memory cache, so a successful delete below also + // proves the cache entry is purged along with the DB row. + got, err := d.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if got == nil || got.Name != ci.Name { + t.Fatalf("GetContractInfoForAddress() = %+v, want stored contract", got) + } + + genBefore := d.protocolGen.Load() + purged, err := d.DeleteContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if purged == nil || purged.Name != ci.Name || purged.CreatedInBlock != ci.CreatedInBlock { + t.Errorf("DeleteContractInfoForAddress() = %+v, want the stored record", purged) + } + // The generation bump protects against a concurrent GetContractInfo + // re-inserting the deleted row into the cache (see SetErcProtocol). + if d.protocolGen.Load() != genBefore+1 { + t.Error("DeleteContractInfoForAddress() did not bump protocolGen") + } + got, err = d.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Errorf("GetContractInfoForAddress() after delete = %+v, want nil", got) + } + + // Idempotent: deleting a missing row is not an error and does not bump + // the generation (nothing a concurrent reader could re-insert). + genBefore = d.protocolGen.Load() + purged, err = d.DeleteContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if purged != nil { + t.Errorf("DeleteContractInfoForAddress() = %+v, want nil for a missing row", purged) + } + if d.protocolGen.Load() != genBefore { + t.Error("DeleteContractInfoForAddress() of a missing row must not bump protocolGen") + } + + if _, err = d.DeleteContractInfoForAddress("not-an-address"); err == nil { + t.Error("DeleteContractInfoForAddress() with invalid address: expected error") + } +} + +func TestStoreContractInfo_MergesSameBatchCreateAndDestroy(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + ephemeral := "0x" + dbtestdata.EthAddr20 + destroyedOnly := "0x" + dbtestdata.EthAddr4b + + // an ephemeral contract emits creation and destruction in one block; the + // destruction can see the uncommitted creation only through the in-batch map + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + inBatch := make(map[string]*bchain.ContractInfo) + if err := d.storeContractInfo(wb, &bchain.ContractInfo{ + Contract: ephemeral, + Standard: bchain.UnhandledTokenStandard, + CreatedInBlock: 500, + }, inBatch); err != nil { + t.Fatal(err) + } + if err := d.storeContractInfo(wb, &bchain.ContractInfo{ + Contract: ephemeral, + DestructedInBlock: 500, + }, inBatch); err != nil { + t.Fatal(err) + } + // a destruction of a contract known neither in the batch nor in the DB + // stays dropped + if err := d.storeContractInfo(wb, &bchain.ContractInfo{ + Contract: destroyedOnly, + DestructedInBlock: 500, + }, inBatch); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } + + got, err := d.GetContractInfoForAddress(ephemeral) + if err != nil { + t.Fatal(err) + } + if got == nil || got.CreatedInBlock != 500 || got.DestructedInBlock != 500 { + t.Errorf("GetContractInfoForAddress() = %+v, want CreatedInBlock 500 and DestructedInBlock 500", got) + } + got, err = d.GetContractInfoForAddress(destroyedOnly) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Errorf("GetContractInfoForAddress() = %+v, want nil for an unknown destroyed contract", got) + } +} + +func TestRocksDB_storeHealedContractInfos(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + healContracts := func(contracts ...bchain.ContractInfo) { + t.Helper() + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.storeHealedContractInfos(wb, contracts); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } + } + + // A creation must fill CreatedInBlock into a row already enriched on demand - sync + // stores no name/symbol, so overwriting the row would strip a contract that a client + // queried before its creating block was healed. + enriched := "0x" + dbtestdata.EthAddr4b + if err := d.StoreContractInfo(&bchain.ContractInfo{ + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + Contract: enriched, + Name: "Enriched", + Symbol: "ENR", + Decimals: 18, + }); err != nil { + t.Fatal(err) + } + healContracts(bchain.ContractInfo{Contract: enriched, CreatedInBlock: 1500}) + got, err := d.GetContractInfoForAddress(enriched) + if err != nil { + t.Fatal(err) + } + if got == nil || got.CreatedInBlock != 1500 || got.Name != "Enriched" || got.Symbol != "ENR" || + got.Standard != bchain.ERC20TokenStandard || got.Decimals != 18 { + t.Fatalf("healed enriched row = %+v, want CreatedInBlock 1500 with the enrichment preserved", got) + } + + // A healed block can carry a destruction whose creation was never indexed; recording + // it keeps the contract from looking alive forever. + destroyed := "0x" + dbtestdata.EthAddr20 + healContracts(bchain.ContractInfo{Contract: destroyed, DestructedInBlock: 2000}) + got, err = d.GetContractInfoForAddress(destroyed) + if err != nil { + t.Fatal(err) + } + if got == nil || got.DestructedInBlock != 2000 || got.CreatedInBlock != 0 { + t.Fatalf("healed destruction = %+v, want DestructedInBlock 2000 and CreatedInBlock 0", got) + } + + // One block can create and destroy the same contract. The records are applied in order, + // so the second does not undo the first. + shortLived := "0x" + dbtestdata.EthAddr55 + healContracts( + bchain.ContractInfo{Contract: shortLived, CreatedInBlock: 3000}, + bchain.ContractInfo{Contract: shortLived, DestructedInBlock: 3000}, + ) + got, err = d.GetContractInfoForAddress(shortLived) + if err != nil { + t.Fatal(err) + } + if got == nil || got.CreatedInBlock != 3000 || got.DestructedInBlock != 3000 { + t.Fatalf("healed create+destruct = %+v, want both heights 3000", got) + } +} + +// Healing runs out of block order - the queue is drained after the blocks around it are +// already indexed, and the backoff can even invert the order of two queued blocks - so a +// reused address (SELFDESTRUCT followed by CREATE2) must not have its lifecycle rewound. +// Every case states what a sequential sync leaves behind, which is what healing must match. +func TestRocksDB_storeHealedContractInfos_ReusedAddress(t *testing.T) { + tests := []struct { + name string + stored *bchain.ContractInfo + healed []bchain.ContractInfo + wantCreated uint32 + wantDestructed uint32 + }{ + { + // sync: the creation in block 200 overwrote the row, so block 100 is history + name: "a creation older than the stored one is dropped", + stored: &bchain.ContractInfo{CreatedInBlock: 200}, + healed: []bchain.ContractInfo{{CreatedInBlock: 100}}, + wantCreated: 200, + }, + { + // sync: the destruction of the previous incarnation was wiped by the creation + // in block 200; storing it would destruct the contract before it was created + name: "a destruction older than the stored creation is dropped", + stored: &bchain.ContractInfo{CreatedInBlock: 200}, + healed: []bchain.ContractInfo{{DestructedInBlock: 150}}, + wantCreated: 200, + }, + { + name: "a destruction older than the stored destruction is dropped", + stored: &bchain.ContractInfo{CreatedInBlock: 100, DestructedInBlock: 300}, + healed: []bchain.ContractInfo{{DestructedInBlock: 200}}, + wantCreated: 100, + wantDestructed: 300, + }, + { + // sync: the creation overwrites the whole row, so the contract is alive again + name: "a creation after the stored destruction revives the contract", + stored: &bchain.ContractInfo{CreatedInBlock: 100, DestructedInBlock: 200}, + healed: []bchain.ContractInfo{{CreatedInBlock: 300}}, + wantCreated: 300, + }, + { + // a destruction in a later block must survive re-healing the creating block + name: "a newer destruction survives a creation healed again", + stored: &bchain.ContractInfo{CreatedInBlock: 100, DestructedInBlock: 150}, + healed: []bchain.ContractInfo{{CreatedInBlock: 100}}, + wantCreated: 100, + wantDestructed: 150, + }, + { + // sync: the destruction lands on the previous incarnation, then the creation + // overwrites the row - the contract deployed in this block is alive + name: "a destruction followed by a creation in one block leaves it alive", + stored: &bchain.ContractInfo{CreatedInBlock: 100}, + healed: []bchain.ContractInfo{{DestructedInBlock: 400}, {CreatedInBlock: 400}}, + wantCreated: 400, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + address := "0x" + dbtestdata.EthAddrContract4a + stored := *tt.stored + stored.Contract = address + stored.Standard = bchain.ERC20TokenStandard + stored.Type = bchain.ERC20TokenStandard + if err := d.StoreContractInfo(&stored); err != nil { + t.Fatal(err) + } + + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + healed := append([]bchain.ContractInfo(nil), tt.healed...) + for i := range healed { + healed[i].Contract = address + } + if err := d.storeHealedContractInfos(wb, healed); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } + + got, err := d.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if got == nil || got.CreatedInBlock != tt.wantCreated || got.DestructedInBlock != tt.wantDestructed { + t.Errorf("healed record = %+v, want created %d, destructed %d", got, tt.wantCreated, tt.wantDestructed) + } + // the guards must never cost the enrichment a client already queried + if got != nil && got.Standard != bchain.ERC20TokenStandard { + t.Errorf("healed record standard = %s, want %s", got.Standard, bchain.ERC20TokenStandard) + } + }) + } +} + +func Test_applyHealedLifecycle(t *testing.T) { + tests := []struct { + name string + contractInfo bchain.ContractInfo + creation uint32 + destruction uint32 + wantApplied bool + wantCreated uint32 + wantDestructed uint32 + }{ + // the case the healer exists for: a row enriched on demand carries no creation + {name: "creation fills an empty row", creation: 100, wantApplied: true, wantCreated: 100}, + { + name: "creation is idempotent", contractInfo: bchain.ContractInfo{CreatedInBlock: 100}, + creation: 100, wantApplied: true, wantCreated: 100, + }, + { + name: "creation older than the stored one is dropped", contractInfo: bchain.ContractInfo{CreatedInBlock: 200}, + creation: 100, wantApplied: false, wantCreated: 200, + }, + { + name: "creation clears a destruction of the previous incarnation", + contractInfo: bchain.ContractInfo{CreatedInBlock: 100, DestructedInBlock: 200}, + creation: 300, wantApplied: true, wantCreated: 300, + }, + { + name: "creation keeps a destruction that is newer", + contractInfo: bchain.ContractInfo{CreatedInBlock: 100, DestructedInBlock: 300}, + creation: 100, wantApplied: true, wantCreated: 100, wantDestructed: 300, + }, + { + name: "destruction of a stored creation applies", contractInfo: bchain.ContractInfo{CreatedInBlock: 100}, + destruction: 150, wantApplied: true, wantCreated: 100, wantDestructed: 150, + }, + // a destruction with no stored row is recorded, so a contract created before the + // index knew about it does not look alive forever + {name: "destruction of an unknown contract applies", destruction: 150, wantApplied: true, wantDestructed: 150}, + { + name: "destruction before the stored creation is dropped", contractInfo: bchain.ContractInfo{CreatedInBlock: 200}, + destruction: 150, wantApplied: false, wantCreated: 200, + }, + { + name: "destruction is idempotent", + contractInfo: bchain.ContractInfo{CreatedInBlock: 100, DestructedInBlock: 150}, + destruction: 150, wantApplied: false, wantCreated: 100, wantDestructed: 150, + }, + { + name: "destruction in the creating block applies", + contractInfo: bchain.ContractInfo{CreatedInBlock: 100}, + destruction: 100, wantApplied: true, wantCreated: 100, wantDestructed: 100, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + contractInfo := tt.contractInfo + var applied bool + if tt.creation != 0 { + applied = applyHealedCreation(&contractInfo, tt.creation) + } else { + applied = applyHealedDestruction(&contractInfo, tt.destruction) + } + if applied != tt.wantApplied { + t.Errorf("applied = %v, want %v", applied, tt.wantApplied) + } + if contractInfo.CreatedInBlock != tt.wantCreated || contractInfo.DestructedInBlock != tt.wantDestructed { + t.Errorf("record = %+v, want created %d, destructed %d", contractInfo, tt.wantCreated, tt.wantDestructed) + } + }) + } +} + // 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. @@ -59,3 +485,31 @@ func Test_packUnpackContractInfo(t *testing.T) { }) } } + +// A contract whose name()/symbol() return a multi-megabyte string must not bloat the index: the +// pack chokepoint clamps both to maxContractNameSymbolBytes without leaving a split trailing rune. +func Test_packContractInfo_ClampsNameSymbol(t *testing.T) { + ci := bchain.ContractInfo{ + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + // last byte within the cap starts a 4-byte rune, so a naive byte cut would split it + Name: strings.Repeat("a", maxContractNameSymbolBytes-1) + "🟢" + strings.Repeat("b", 5<<20), + Symbol: strings.Repeat("🟢", 5<<20), + } + got, err := unpackContractInfo(packContractInfo(&ci)) + if err != nil { + t.Fatalf("unpackContractInfo() err = %v", err) + } + if len(got.Name) > maxContractNameSymbolBytes { + t.Errorf("Name len = %d, want <= %d", len(got.Name), maxContractNameSymbolBytes) + } + if len(got.Symbol) > maxContractNameSymbolBytes { + t.Errorf("Symbol len = %d, want <= %d", len(got.Symbol), maxContractNameSymbolBytes) + } + if !utf8.ValidString(got.Name) || !utf8.ValidString(got.Symbol) { + t.Errorf("clamp left invalid UTF-8: name valid=%v symbol valid=%v", utf8.ValidString(got.Name), utf8.ValidString(got.Symbol)) + } + if !strings.HasPrefix(got.Name, strings.Repeat("a", maxContractNameSymbolBytes-1)) { + t.Errorf("clamp dropped legitimate leading bytes of Name") + } +} diff --git a/db/rocksdb_ethereumtype.go b/db/rocksdb_ethereumtype.go index cf17c39597..4bf8040bed 100644 --- a/db/rocksdb_ethereumtype.go +++ b/db/rocksdb_ethereumtype.go @@ -46,6 +46,9 @@ func (s *Ids) search(id big.Int) int { // insert id in ascending order func (s *Ids) insert(id big.Int) { i := s.search(id) + if i < len(*s) && (*s)[i].CmpAbs(&id) == 0 { + return + } if i == len(*s) { *s = append(*s, id) } else { @@ -321,6 +324,25 @@ func unpackAddrContracts(buf []byte, addrDesc bchain.AddressDescriptor) (acs *Ad }, nil } +// unpackAddrContractsHeader decodes only the three tx counters and leaves Contracts nil; +// callers that never read the contract array must not pay to decode it. +func unpackAddrContractsHeader(buf []byte, addrDesc bchain.AddressDescriptor) (*AddrContracts, error) { + var counters [3]uint + for i := range counters { + v, l, ok := unpackVaruintSafe(buf) + if !ok { + return nil, errors.New("Invalid data stored in cfAddressContracts for AddrDesc " + addrDesc.String()) + } + counters[i] = v + buf = buf[l:] + } + return &AddrContracts{ + TotalTxs: counters[0], + NonContractTxs: counters[1], + InternalTxs: counters[2], + }, nil +} + 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 @@ -334,17 +356,23 @@ func (d *RocksDB) storeAddressContracts(wb *grocksdb.WriteBatch, acm map[string] 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) +// GetAddrDescContracts returns AddrContracts for given addrDesc, nil for an absent record. +// With headerOnly only the tx counters are decoded and Contracts stays nil. +func (d *RocksDB) GetAddrDescContracts(addrDesc bchain.AddressDescriptor, headerOnly bool) (*AddrContracts, error) { + // a pinned read avoids copying the whole record out of the block cache; the decoders copy + // what they keep, so nothing references the pinned bytes after Destroy + val, err := d.db.GetPinnedCF(d.ro, d.cfh[cfAddressContracts], addrDesc) if err != nil { return nil, err } - defer val.Free() + defer val.Destroy() buf := val.Data() if len(buf) == 0 { return nil, nil } + if headerOnly { + return unpackAddrContractsHeader(buf, addrDesc) + } return unpackAddrContracts(buf, addrDesc) } @@ -399,7 +427,15 @@ func addToAddressesMapEthereumType(addresses addressesMap, strAddrDesc string, b return false } -func addToContract(c *unpackedAddrContract, contractIndex int, index int32, contract bchain.AddressDescriptor, transfer *bchain.TokenTransfer, addTxCount bool) int32 { +// isNFTSelfTransfer reports whether the transfer is an ERC721 transfer from an address to itself. +// Such a transfer must be indexed and counted, but it must not change the address's holdings - +// both processContractTransfers (connect) and disconnectAddress (rollback) derive the +// mutateHoldings behavior of addToContract from this predicate. +func isNFTSelfTransfer(from, to bchain.AddressDescriptor, standard bchain.TokenStandard) bool { + return standard == bchain.NonFungibleToken && bytes.Equal(from, to) +} + +func addToContract(c *unpackedAddrContract, contractIndex int, index int32, contract bchain.AddressDescriptor, transfer *bchain.TokenTransfer, addTxCount bool, mutateHoldings 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 { @@ -417,6 +453,16 @@ func addToContract(c *unpackedAddrContract, contractIndex int, index int32, cont s.Add(s, v) } } + if !mutateHoldings { + // mutateHoldings is false only for ERC721 self-transfers (see isNFTSelfTransfer): + // the transfer must still be indexed in both directions and counted once, but it + // cannot change the address's holdings. Other standards must not pass false here, + // it would silently skip their value normalization and aggregation. + if addTxCount { + c.Txs++ + } + return index + } 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 @@ -445,7 +491,7 @@ func addToContract(c *unpackedAddrContract, contractIndex int, index int32, cont 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 { +func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.AddressDescriptor, btxID []byte, index int32, contract bchain.AddressDescriptor, transfer *bchain.TokenTransfer, addTxCount bool, mutateHoldings bool, addresses addressesMap, addressContracts map[string]*unpackedAddrContracts) error { var err error strAddrDesc := string(addrDesc) ac, e := addressContracts[strAddrDesc] @@ -484,7 +530,7 @@ func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.Address ac.addContractIndex(contract, contractIndex) } c := &ac.Contracts[contractIndex] - index = addToContract(c, contractIndex, index, contract, transfer, addTxCount) + index = addToContract(c, contractIndex, index, contract, transfer, addTxCount, mutateHoldings) } else { if index < 0 { index = transferFrom @@ -539,7 +585,7 @@ func (d *RocksDB) processBaseTxData(blockTx *ethBlockTx, tx *bchain.Tx, addresse glog.Warningf("rocksdb: processBaseTxData: %v, tx %v, output", err, tx.Txid) } } else { - if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, transferTo, nil, nil, true, addresses, addressContracts); err != nil { + if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, transferTo, nil, nil, true, true, addresses, addressContracts); err != nil { return err } blockTx.to = to @@ -553,7 +599,7 @@ func (d *RocksDB) processBaseTxData(blockTx *ethBlockTx, tx *bchain.Tx, addresse glog.Warningf("rocksdb: processBaseTxData: %v, tx %v, input", err, tx.Txid) } } else { - if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, transferFrom, nil, nil, !bytes.Equal(from, to), addresses, addressContracts); err != nil { + if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, transferFrom, nil, nil, !bytes.Equal(from, to), true, addresses, addressContracts); err != nil { return err } blockTx.from = from @@ -599,7 +645,7 @@ func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bc return err } } - if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, internalTransferTo, nil, nil, true, addresses, addressContracts); err != nil { + if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, internalTransferTo, nil, nil, true, true, addresses, addressContracts); err != nil { return err } } @@ -622,7 +668,7 @@ func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bc return err } } - if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, internalTransferTo, nil, nil, true, addresses, addressContracts); err != nil { + if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, internalTransferTo, nil, nil, true, true, addresses, addressContracts); err != nil { return err } ito.to = to @@ -638,7 +684,7 @@ func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bc return err } } - if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, internalTransferFrom, nil, nil, !bytes.Equal(from, to), addresses, addressContracts); err != nil { + if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, internalTransferFrom, nil, nil, !bytes.Equal(from, to), true, addresses, addressContracts); err != nil { return err } ito.from = from @@ -669,11 +715,12 @@ func (d *RocksDB) processContractTransfers(blockTx *ethBlockTx, tx *bchain.Tx, a 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 { + eq := bytes.Equal(from, to) + mutateHoldings := !isNFTSelfTransfer(from, to, t.Standard) + if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, int32(i), contract, t, true, mutateHoldings, 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 { + if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, ^int32(i), contract, t, !eq, mutateHoldings, addresses, addressContracts); err != nil { return err } bc := &blockTx.contracts[i] @@ -718,7 +765,9 @@ func (d *RocksDB) processAddressesEthereumType(block *bchain.Block, addresses ad return blockTxs, nil } -// ReconnectInternalDataToBlockEthereumType adds missing internal data to the block and stores them in db +// ReconnectInternalDataToBlockEthereumType adds internal data to an already indexed block +// whose internal data failed during sync, and stores them together with the contracts the +// block created or destroyed. func (d *RocksDB) ReconnectInternalDataToBlockEthereumType(block *bchain.Block) error { d.connectBlockMux.Lock() defer d.connectBlockMux.Unlock() @@ -728,6 +777,18 @@ func (d *RocksDB) ReconnectInternalDataToBlockEthereumType(block *bchain.Block) if d.chainParser.GetChainType() != bchain.ChainEthereumType { return errors.New("Unsupported chain type") } + // The caller resolved this block from a snapshot of the error queue and then fetched + // it by hash, so a reorg in between can leave it orphaned - its transactions are no + // longer indexed and their internal data must not be written back. Checking here + // rather than in the caller makes it exact: connect, disconnect and this reconnect all + // hold connectBlockMux, so no reorg can land between the check and the write below. + indexedHash, err := d.GetBlockHash(block.Height) + if err != nil { + return err + } + if indexedHash != block.Hash { + return errors.Errorf("block %d hash changed from %s to %s, possible reorg, skipping", block.Height, block.Hash, indexedHash) + } if d.hotAddrTracker != nil { d.hotAddrTracker.BeginBlock() } @@ -740,17 +801,30 @@ func (d *RocksDB) ReconnectInternalDataToBlockEthereumType(block *bchain.Block) 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 eid.InternalData == nil { + continue + } + btxID, err := d.chainParser.PackTxid(tx.Txid) + if err != nil { + return err + } + // addToAddressesAndContractsEthereumType increments InternalTxs and NonContractTxs + // unconditionally - only TotalTxs is deduplicated - so storing internal data that + // are already indexed would inflate the counters permanently, while a later + // disconnect decrements them only once. A queued block never holds internal data, + // but skipping the transactions that do makes that invariant hold by construction. + stored, err := d.hasEthereumInternalData(btxID) + if err != nil { + return err + } + if stored { + continue + } + 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 } } @@ -763,6 +837,15 @@ func (d *RocksDB) ReconnectInternalDataToBlockEthereumType(block *bchain.Block) if err := d.storeAddresses(wb, block.Height, addresses); err != nil { return err } + // The contracts are a product of the internal data fetch, so the failed sync stored + // none of them. The block's address aliases are deliberately left alone: they come + // from the logs, which succeeded, so sync already stored them, and rewriting them now + // would replace the names of that time with whatever they resolve to today. + if blockSpecificData, _ := block.CoinSpecificData.(*bchain.EthereumBlockSpecificData); blockSpecificData != nil { + if err := d.storeHealedContractInfos(wb, blockSpecificData.Contracts); 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 { @@ -938,6 +1021,16 @@ func (d *RocksDB) GetEthereumInternalData(txid string) (*bchain.EthereumInternal return d.getEthereumInternalData(btxID) } +// hasEthereumInternalData reports whether internal data are already stored for the tx. +func (d *RocksDB) hasEthereumInternalData(btxID []byte) (bool, error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfInternalData], btxID) + if err != nil { + return false, err + } + defer val.Free() + return len(val.Data()) > 0, nil +} + func (d *RocksDB) getEthereumInternalData(btxID []byte) (*bchain.EthereumInternalData, error) { val, err := d.db.GetCF(d.ro, d.cfh[cfInternalData], btxID) if err != nil { @@ -1022,6 +1115,40 @@ func (d *RocksDB) StoreBlockInternalDataErrorEthereumType(wb *grocksdb.WriteBatc return nil } +// UpdateBlockInternalDataErrorEthereumType rewrites the queued error of the block at +// height with a new failure count and message, but only while the queue still holds an +// entry for hash. A healing pass reports from a snapshot taken before it started, so by +// the time it charges a failure the entry may have been removed by a rollback or replaced +// by a different block at the same height; a plain put would resurrect or overwrite it. +func (d *RocksDB) UpdateBlockInternalDataErrorEthereumType(height uint32, hash string, message string, retryCount uint8) error { + if hash == "" { + return nil + } + d.connectBlockMux.Lock() + defer d.connectBlockMux.Unlock() + val, err := d.db.GetCF(d.ro, d.cfh[cfBlockInternalDataErrors], packUint(height)) + if err != nil { + return err + } + defer val.Free() + storedHash, _, _, err := d.unpackBlockInternalDataError(val.Data()) + if err != nil { + return err + } + if storedHash != hash { + glog.Infof("UpdateBlockInternalDataErrorEthereumType %d %s: no longer queued, update skipped", height, hash) + return nil + } + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.StoreBlockInternalDataErrorEthereumType(wb, &bchain.Block{ + BlockHeader: bchain.BlockHeader{Hash: hash, Height: height}, + }, message, retryCount); err != nil { + return err + } + return d.WriteBatch(wb) +} + type BlockInternalDataError struct { Height uint32 Hash string @@ -1084,8 +1211,12 @@ func (d *RocksDB) storeBlockSpecificDataEthereumType(wb *grocksdb.WriteBatch, bl return err } } + // registry events of one block may create and destroy the same + // (ephemeral) contract - the in-batch map lets the destruction merge + // into the creation written a moment ago + inBatch := make(map[string]*bchain.ContractInfo) for i := range blockSpecificData.Contracts { - if err := d.storeContractInfo(wb, &blockSpecificData.Contracts[i]); err != nil { + if err := d.storeContractInfo(wb, &blockSpecificData.Contracts[i], inBatch); err != nil { return err } } @@ -1254,9 +1385,11 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain 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) + // an NFT self-transfer did not change holdings on connect, there is nothing to reverse + if !isNFTSelfTransfer(btxContract.from, btxContract.to, btxContract.transferStandard) { + addToContract(addrContract, contractIndex, transferTo, btxContract.contract, transfer, false, true) + addToContract(addrContract, contractIndex, transferFrom, btxContract.contract, transfer, false, true) + } } else { var index int32 if bytes.Equal(addrDesc, btxContract.to) { @@ -1264,7 +1397,7 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain } else { index = transferTo } - addToContract(addrContract, contractIndex, index, btxContract.contract, transfer, false) + addToContract(addrContract, contractIndex, index, btxContract.contract, transfer, false, true) } } } else { @@ -1284,12 +1417,69 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain return nil } -func (d *RocksDB) disconnectInternalData(btxID []byte, addresses map[string]map[string]struct{}, contracts map[string]*unpackedAddrContracts) error { +// contractRegistryRollback holds the pending cfContracts rows of a whole +// disconnected range, so a later block's destruction reset composes with an +// earlier block's creation removal. +type contractRegistryRollback struct { + // nil = delete the row + rows map[string]*bchain.ContractInfo + // height whose destruction frame was already rolled back, per key + destroyedAt map[string]uint32 +} + +func newContractRegistryRollback() *contractRegistryRollback { + return &contractRegistryRollback{ + rows: make(map[string]*bchain.ContractInfo), + destroyedAt: make(map[string]uint32), + } +} + +// rollbackContractRegistryRow reverts the cfContracts row of a contract created +// or destroyed in the disconnected block. +func (d *RocksDB) rollbackContractRegistryRow(addrDesc bchain.AddressDescriptor, height uint32, destroyed bool, rollback *contractRegistryRollback) error { + key := string(addrDesc) + ci, pending := rollback.rows[key] + if !pending { + stored, err := d.GetContractInfo(addrDesc, "") + if err != nil { + return err + } + if stored == nil { + return nil + } + // copy - the shared cached value must not change before the batch commits + ciCopy := *stored + ci = &ciCopy + } + if ci == nil { + // already deleted by a creation rollback + return nil + } + if destroyed { + rollback.destroyedAt[key] = height + if ci.DestructedInBlock == height { + ci.DestructedInBlock = 0 + rollback.rows[key] = ci + } + } else if ci.CreatedInBlock == height { + // a same-block destroy-then-recreate overwrote the pre-range CreatedInBlock, + // so only an unseen destruction proves the contract originated here - keeping + // a stale row beats dropping a contract that is live on the canonical chain + if destroyedAt, ok := rollback.destroyedAt[key]; !ok || destroyedAt != height { + rollback.rows[key] = nil + } + } + return nil +} + +func (d *RocksDB) disconnectInternalData(btxID []byte, height uint32, addresses map[string]map[string]struct{}, contracts map[string]*unpackedAddrContracts, registryRollback *contractRegistryRollback) error { internalData, err := d.getEthereumInternalData(btxID) if err != nil { return err } if internalData != nil { + // destructions arrive only as transfer frames below: the persisted tx type + // is one bit (CALL|CREATE), so a root SELFDESTRUCT cannot round-trip if internalData.Type == bchain.CREATE { contract, err := d.chainParser.GetAddrDescFromAddress(internalData.Contract) if err != nil { @@ -1298,6 +1488,9 @@ func (d *RocksDB) disconnectInternalData(btxID []byte, addresses map[string]map[ if err := d.disconnectAddress(btxID, true, contract, nil, addresses, contracts); err != nil { return err } + if err := d.rollbackContractRegistryRow(contract, height, false, registryRollback); err != nil { + return err + } } for j := range internalData.Transfers { t := &internalData.Transfers[j] @@ -1318,12 +1511,22 @@ func (d *RocksDB) disconnectInternalData(btxID []byte, addresses map[string]map[ return err } } + // revert the registry events emitted by nested creates and suicides + if t.Type == bchain.CREATE { + if err := d.rollbackContractRegistryRow(to, height, false, registryRollback); err != nil { + return err + } + } else if t.Type == bchain.SELFDESTRUCT { + if err := d.rollbackContractRegistryRow(from, height, true, registryRollback); err != nil { + return err + } + } } } return nil } -func (d *RocksDB) disconnectBlockTxsEthereumType(wb *grocksdb.WriteBatch, height uint32, blockTxs []ethBlockTx, contracts map[string]*unpackedAddrContracts) error { +func (d *RocksDB) disconnectBlockTxsEthereumType(wb *grocksdb.WriteBatch, height uint32, blockTxs []ethBlockTx, contracts map[string]*unpackedAddrContracts, registryRollback *contractRegistryRollback) error { glog.Info("Disconnecting block ", height, " containing ", len(blockTxs), " transactions") addresses := make(map[string]map[string]struct{}) for i := range blockTxs { @@ -1337,14 +1540,20 @@ func (d *RocksDB) disconnectBlockTxsEthereumType(wb *grocksdb.WriteBatch, height return err } } - // internal data - err := d.disconnectInternalData(blockTx.btxID, addresses, contracts) + // internal data - the contract registry rollback relies on this forward walk + err := d.disconnectInternalData(blockTx.btxID, height, addresses, contracts, registryRollback) if err != nil { return err } - // contracts - for j := range blockTx.contracts { + wb.DeleteCF(d.cfh[cfTransactions], blockTx.btxID) + wb.DeleteCF(d.cfh[cfInternalData], blockTx.btxID) + } + // token holdings are undone in reverse chronological order, otherwise a token that + // moved A->B->C within the block ends up recorded at both A and B + for i := len(blockTxs) - 1; i >= 0; i-- { + blockTx := &blockTxs[i] + for j := len(blockTx.contracts) - 1; j >= 0; j-- { c := &blockTx.contracts[j] if err := d.disconnectAddress(blockTx.btxID, false, c.from, c, addresses, contracts); err != nil { return err @@ -1355,8 +1564,6 @@ func (d *RocksDB) disconnectBlockTxsEthereumType(wb *grocksdb.WriteBatch, height } } } - wb.DeleteCF(d.cfh[cfTransactions], blockTx.btxID) - wb.DeleteCF(d.cfh[cfInternalData], blockTx.btxID) } for a := range addresses { key := packAddressKey([]byte(a), height) @@ -1388,8 +1595,9 @@ func (d *RocksDB) DisconnectBlockRangeEthereumType(lower uint32, higher uint32) wb := grocksdb.NewWriteBatch() defer wb.Destroy() contracts := make(map[string]*unpackedAddrContracts) + registryRollback := newContractRegistryRollback() for height := higher; height >= lower; height-- { - if err := d.disconnectBlockTxsEthereumType(wb, height, blocks[height-lower], contracts); err != nil { + if err := d.disconnectBlockTxsEthereumType(wb, height, blocks[height-lower], contracts, registryRollback); err != nil { return err } key := packUint(height) @@ -1398,6 +1606,15 @@ func (d *RocksDB) DisconnectBlockRangeEthereumType(lower uint32, higher uint32) wb.DeleteCF(d.cfh[cfBlockInternalDataErrors], key) } d.storeUnpackedAddressContracts(wb, contracts) + // Revert contract registry rows whose creation or destruction was + // disconnected; the reorgGen bump below invalidates their cache entries. + for key, ci := range registryRollback.rows { + if ci == nil { + wb.DeleteCF(d.cfh[cfContracts], []byte(key)) + } else { + wb.PutCF(d.cfh[cfContracts], []byte(key), packContractInfo(ci)) + } + } // Revert protocol rows whose persistHeight fell into [lower,higher]. if err := d.disconnectErcProtocols(wb, lower, higher); err != nil { return err @@ -1596,6 +1813,9 @@ func (s *unpackedIds) search(id big.Int) int { // insert id in ascending order func (s *unpackedIds) insert(id big.Int) { i := s.search(id) + if i < len(*s) && (*s)[i].get().CmpAbs(&id) == 0 { + return + } if i == len(*s) { *s = append(*s, unpackedBigInt{Value: &id}) } else { @@ -1917,12 +2137,13 @@ func (d *RocksDB) storeAddrContractsCache() { 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() +// storeAddrContractsCacheIfDue periodically flushes the cache from the block-connect +// goroutine. Cached records are mutated without addrContractsCacheMux while a block is +// connected, so packing them from any other goroutine would race with that mutation. +func (d *RocksDB) storeAddrContractsCacheIfDue() { + if time.Since(d.lastAddrContractsCacheStore) < addrContractsCacheStorePeriod { + return } + d.lastAddrContractsCacheStore = time.Now() + d.storeAddrContractsCache() } diff --git a/db/rocksdb_ethereumtype_test.go b/db/rocksdb_ethereumtype_test.go index 9bdf103e47..085f112920 100644 --- a/db/rocksdb_ethereumtype_test.go +++ b/db/rocksdb_ethereumtype_test.go @@ -3,6 +3,7 @@ package db import ( + "bytes" "encoding/hex" "fmt" "math/big" @@ -21,6 +22,24 @@ type testEthereumParser struct { *eth.EthereumParser } +type tokenTransferTestParser struct { + *eth.EthereumParser + transfers bchain.TokenTransfers +} + +func (p *tokenTransferTestParser) EthereumTypeGetTokenTransfersFromTx(*bchain.Tx) (bchain.TokenTransfers, error) { + return p.transfers, nil +} + +type txTokenTransferTestParser struct { + *eth.EthereumParser + transfers map[string]bchain.TokenTransfers +} + +func (p *txTokenTransferTestParser) EthereumTypeGetTokenTransfersFromTx(tx *bchain.Tx) (bchain.TokenTransfers, error) { + return p.transfers[tx.Txid], nil +} + func ethereumTestnetParser() *eth.EthereumParser { return eth.NewEthereumParser(1, true) } @@ -43,6 +62,309 @@ func makeTestAddrDesc(seed int) bchain.AddressDescriptor { return b } +func TestIdsInsertDoesNotDuplicate(t *testing.T) { + ids := Ids{*big.NewInt(1), *big.NewInt(2), *big.NewInt(3)} + for _, id := range []int64{1, 2, 3} { + ids.insert(*big.NewInt(id)) + } + want := Ids{*big.NewInt(1), *big.NewInt(2), *big.NewInt(3)} + if !reflect.DeepEqual(ids, want) { + t.Fatalf("Ids = %v, want %v", ids, want) + } + + unpacked := unpackedIds{ + {Value: big.NewInt(1)}, + {Value: big.NewInt(2)}, + {Value: big.NewInt(3)}, + } + for _, id := range []int64{1, 2, 3} { + unpacked.insert(*big.NewInt(id)) + } + if len(unpacked) != 3 { + t.Fatalf("unpackedIds length = %d, want 3", len(unpacked)) + } + for i, id := range []int64{1, 2, 3} { + if unpacked[i].get().Cmp(big.NewInt(id)) != 0 { + t.Fatalf("unpackedIds[%d] = %v, want %d", i, unpacked[i].get(), id) + } + } +} + +func TestERC721RepeatedTransferDoesNotLeaveStaleDuplicate(t *testing.T) { + contract := makeTestAddrDesc(0x7210) + c := &unpackedAddrContract{ + Standard: bchain.NonFungibleToken, + Contract: contract, + } + transfer := &bchain.TokenTransfer{ + Standard: bchain.NonFungibleToken, + Value: *big.NewInt(42), + } + + addToContract(c, 0, transferTo, contract, transfer, false, true) + addToContract(c, 0, transferTo, contract, transfer, false, true) + if len(c.Ids) != 1 || c.Ids[0].get().Cmp(&transfer.Value) != 0 { + t.Fatalf("Ids after repeated transfer-in = %v, want [42]", c.Ids) + } + + addToContract(c, 0, transferFrom, contract, transfer, false, true) + if len(c.Ids) != 0 { + t.Fatalf("Ids after transfer-out = %v, want empty", c.Ids) + } +} + +func TestERC721SelfTransferDoesNotMutateHoldings(t *testing.T) { + parser := ethereumTestnetParser() + address := dbtestdata.EthAddr83 + contractAddress := dbtestdata.EthAddrContractCd + addrDesc := addressToAddrDesc(address, parser) + contractDesc := addressToAddrDesc(contractAddress, parser) + transfer := &bchain.TokenTransfer{ + Standard: bchain.NonFungibleToken, + Contract: contractAddress, + From: address, + To: address, + Value: *big.NewInt(42), + } + d := &RocksDB{chainParser: &tokenTransferTestParser{ + EthereumParser: parser, + transfers: bchain.TokenTransfers{transfer}, + }} + acs := &unpackedAddrContracts{ + TotalTxs: 3, + Contracts: []unpackedAddrContract{{ + Standard: bchain.NonFungibleToken, + Contract: contractDesc, + Txs: 3, + Ids: unpackedIds{{Value: big.NewInt(42)}}, + }}, + } + addresses := addressesMap{} + addressContracts := map[string]*unpackedAddrContracts{string(addrDesc): acs} + blockTx := ðBlockTx{btxID: []byte{0x01}} + + if err := d.processContractTransfers(blockTx, &bchain.Tx{}, addresses, addressContracts); err != nil { + t.Fatal(err) + } + if acs.TotalTxs != 4 || acs.Contracts[0].Txs != 4 { + t.Fatalf("transaction counts = (%d, %d), want (4, 4)", acs.TotalTxs, acs.Contracts[0].Txs) + } + if len(acs.Contracts[0].Ids) != 1 || acs.Contracts[0].Ids[0].get().Cmp(big.NewInt(42)) != 0 { + t.Fatalf("Ids = %v, want [42]", acs.Contracts[0].Ids) + } + txIndexes := addresses[string(addrDesc)] + wantIndexes := []int32{ContractIndexOffset, ^int32(ContractIndexOffset)} + if len(txIndexes) != 1 || !reflect.DeepEqual(txIndexes[0].indexes, wantIndexes) { + t.Fatalf("address indexes = %+v, want %v for one transaction", txIndexes, wantIndexes) + } +} + +// TestERC721SelfTransferConnectDisconnectRoundTrip verifies the connect/disconnect symmetry +// of an ERC721 self-transfer: the holdings are untouched on both sides and the transaction +// counts stay balanced after the block with the self-transfer is rolled back. +func TestERC721SelfTransferConnectDisconnectRoundTrip(t *testing.T) { + parser := ethereumTestnetParser() + owner := dbtestdata.EthAddr83 + contractAddress := dbtestdata.EthAddrContractCd + tokenID := big.NewInt(42) + txidTransferIn := "80a0533b0f66e9d29aa4dbbdc8c4b90326b073e0d6b864e02c9598032ed05201" + txidSelfTransfer := "80a0533b0f66e9d29aa4dbbdc8c4b90326b073e0d6b864e02c9598032ed05202" + d := setupRocksDB(t, &txTokenTransferTestParser{ + EthereumParser: parser, + transfers: map[string]bchain.TokenTransfers{ + txidTransferIn: {{ + Standard: bchain.NonFungibleToken, + Contract: contractAddress, + From: dbtestdata.EthAddr7b, + To: owner, + Value: *tokenID, + }}, + txidSelfTransfer: {{ + Standard: bchain.NonFungibleToken, + Contract: contractAddress, + From: owner, + To: owner, + Value: *tokenID, + }}, + }, + }) + defer closeAndDestroyRocksDB(t, d) + ownerDesc := addressToAddrDesc(owner, parser) + contractDesc := addressToAddrDesc(contractAddress, parser) + + makeBlock := func(height uint32, hash, txid, from string) *bchain.Block { + return &bchain.Block{ + BlockHeader: bchain.BlockHeader{ + Height: height, + Hash: hash, + Time: 1534858022, + }, + Txs: []bchain.Tx{{ + Txid: txid, + Vin: []bchain.Vin{{Addresses: []string{from}}}, + Vout: []bchain.Vout{{ScriptPubKey: bchain.ScriptPubKey{Addresses: []string{contractAddress}}}}, + }}, + } + } + + ownerContracts := func(stage string, wantTotalTxs uint, wantContractTxs uint) *unpackedAddrContracts { + t.Helper() + acs, err := d.getUnpackedAddrDescContracts(ownerDesc) + if err != nil { + t.Fatalf("%s: %v", stage, err) + } + if acs == nil { + t.Fatalf("%s: owner address contracts not found", stage) + } + if acs.TotalTxs != wantTotalTxs { + t.Fatalf("%s: TotalTxs = %d, want %d", stage, acs.TotalTxs, wantTotalTxs) + } + i, found := findContractInAddressContracts(contractDesc, acs.Contracts) + if !found { + t.Fatalf("%s: contract not found in owner address contracts", stage) + } + c := &acs.Contracts[i] + if c.Txs != wantContractTxs { + t.Fatalf("%s: contract Txs = %d, want %d", stage, c.Txs, wantContractTxs) + } + if len(c.Ids) != 1 || c.Ids[0].get().Cmp(tokenID) != 0 { + t.Fatalf("%s: Ids = %v, want [42]", stage, c.Ids) + } + return acs + } + + // connect a block transferring the token to the owner + if err := d.ConnectBlock(makeBlock(4321000, "0xc7b98df95acfd11c51ba25611a39e004fe56c8fdfc1582af99354fcd09c17b11", txidTransferIn, dbtestdata.EthAddr7b)); err != nil { + t.Fatal(err) + } + snapshot := packUnpackedAddrContracts(ownerContracts("after connect of transfer in", 1, 1)) + + // connect a block with a self-transfer of the token: holdings must stay [42], tx counts must increase + if err := d.ConnectBlock(makeBlock(4321001, "0x2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee", txidSelfTransfer, owner)); err != nil { + t.Fatal(err) + } + ownerContracts("after connect of self-transfer", 2, 2) + + // disconnect the self-transfer block: holdings must stay [42], tx counts must return to the previous state + if err := d.DisconnectBlockRangeEthereumType(4321001, 4321001); err != nil { + t.Fatal(err) + } + restored := packUnpackedAddrContracts(ownerContracts("after disconnect of self-transfer", 1, 1)) + if !bytes.Equal(snapshot, restored) { + t.Fatalf("owner address contracts after disconnect = %s, want %s", hex.EncodeToString(restored), hex.EncodeToString(snapshot)) + } +} + +// TestDisconnectBlockRange_MultiHopTransferRestoresHoldings verifies that a token moving +// A->B->C inside one disconnected block is rolled back to A only. B already has history +// with the collection, so its contract entry survives the disconnect and would keep a +// phantom token if the transfers were undone in forward order. +func TestDisconnectBlockRange_MultiHopTransferRestoresHoldings(t *testing.T) { + parser := ethereumTestnetParser() + funder, a, b, c := dbtestdata.EthAddr7b, dbtestdata.EthAddr83, dbtestdata.EthAddrA3, dbtestdata.EthAddr5d + txHistoryB := "80a0533b0f66e9d29aa4dbbdc8c4b90326b073e0d6b864e02c9598032ed05211" + txFundA := "80a0533b0f66e9d29aa4dbbdc8c4b90326b073e0d6b864e02c9598032ed05212" + txHop1 := "80a0533b0f66e9d29aa4dbbdc8c4b90326b073e0d6b864e02c9598032ed05213" + txHop2 := "80a0533b0f66e9d29aa4dbbdc8c4b90326b073e0d6b864e02c9598032ed05214" + + nft := func(from, to string, id int64) *bchain.TokenTransfer { + return &bchain.TokenTransfer{Standard: bchain.NonFungibleToken, Contract: dbtestdata.EthAddrContractCd, From: from, To: to, Value: *big.NewInt(id)} + } + multi := func(from, to string, amount int64) *bchain.TokenTransfer { + return &bchain.TokenTransfer{Standard: bchain.MultiToken, Contract: dbtestdata.EthAddrContract6f, From: from, To: to, + MultiTokenValues: []bchain.MultiTokenValue{{Id: *big.NewInt(1), Value: *big.NewInt(amount)}}} + } + type txSpec struct{ txid, from string } + + tests := []struct { + name string + contract string + transfers map[string]bchain.TokenTransfers + reorgTxs []txSpec + }{ + { + name: "ERC721 across two transactions", + contract: dbtestdata.EthAddrContractCd, + transfers: map[string]bchain.TokenTransfers{ + txHistoryB: {nft(funder, b, 7)}, + txFundA: {nft(funder, a, 42)}, + txHop1: {nft(a, b, 42)}, + txHop2: {nft(b, c, 42)}, + }, + reorgTxs: []txSpec{{txHop1, a}, {txHop2, b}}, + }, + { + name: "ERC721 within one transaction", + contract: dbtestdata.EthAddrContractCd, + transfers: map[string]bchain.TokenTransfers{ + txHistoryB: {nft(funder, b, 7)}, + txFundA: {nft(funder, a, 42)}, + txHop1: {nft(a, b, 42), nft(b, c, 42)}, + }, + reorgTxs: []txSpec{{txHop1, a}}, + }, + { + name: "ERC1155 across two transactions", + contract: dbtestdata.EthAddrContract6f, + transfers: map[string]bchain.TokenTransfers{ + txHistoryB: {multi(funder, b, 3)}, + txFundA: {multi(funder, a, 5)}, + txHop1: {multi(a, b, 5)}, + txHop2: {multi(b, c, 5)}, + }, + reorgTxs: []txSpec{{txHop1, a}, {txHop2, b}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := setupRocksDB(t, &txTokenTransferTestParser{EthereumParser: parser, transfers: tt.transfers}) + defer closeAndDestroyRocksDB(t, d) + + makeBlock := func(height uint32, hash string, txs []txSpec) *bchain.Block { + block := &bchain.Block{BlockHeader: bchain.BlockHeader{Height: height, Hash: hash, Time: 1534858022}} + for _, tx := range txs { + block.Txs = append(block.Txs, bchain.Tx{ + Txid: tx.txid, + Vin: []bchain.Vin{{Addresses: []string{tx.from}}}, + Vout: []bchain.Vout{{ScriptPubKey: bchain.ScriptPubKey{Addresses: []string{tt.contract}}}}, + }) + } + return block + } + snapshot := func(stage string) map[string][]byte { + packed := make(map[string][]byte) + for _, addr := range []string{a, b, c} { + acs, err := d.getUnpackedAddrDescContracts(addressToAddrDesc(addr, parser)) + if err != nil { + t.Fatalf("%s: %v", stage, err) + } + if acs != nil { + packed[addr] = packUnpackedAddrContracts(acs) + } + } + return packed + } + + if err := d.ConnectBlock(makeBlock(4321000, "0xc7b98df95acfd11c51ba25611a39e004fe56c8fdfc1582af99354fcd09c17b11", []txSpec{{txHistoryB, funder}, {txFundA, funder}})); err != nil { + t.Fatal(err) + } + before := snapshot("before reorged block") + if err := d.ConnectBlock(makeBlock(4321001, "0x2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee", tt.reorgTxs)); err != nil { + t.Fatal(err) + } + if err := d.DisconnectBlockRangeEthereumType(4321001, 4321001); err != nil { + t.Fatal(err) + } + after := snapshot("after disconnect") + for _, addr := range []string{a, b, c} { + if !bytes.Equal(before[addr], after[addr]) { + t.Errorf("address %s contracts after disconnect = %s, want %s", addr, hex.EncodeToString(after[addr]), hex.EncodeToString(before[addr])) + } + } + }) + } +} + func Test_unpackedAddrContracts_findContractIndex_LazyMap(t *testing.T) { acs := &unpackedAddrContracts{} minContracts := 192 @@ -784,6 +1106,254 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { } +// contract registry rows must roll back on disconnect - a stale row whose +// creation was reorged away permanently disables GetBalanceHistory for that +// address +func TestDisconnectBlockRange_RollsBackContractRegistry(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + topLevel := "0x1111111111111111111111111111111111111111" + nested := "0x2222222222222222222222222222222222222222" + ephemeral := "0x3333333333333333333333333333333333333333" + longLived := "0x4444444444444444444444444444444444444444" + + block1 := dbtestdata.GetTestEthereumTypeBlock1(d.chainParser) + block1.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + Contracts: []bchain.ContractInfo{ + {Contract: longLived, Standard: bchain.UnhandledTokenStandard, CreatedInBlock: block1.Height}, + }, + } + if err := d.ConnectBlock(block1); err != nil { + t.Fatal(err) + } + + block2 := dbtestdata.GetTestEthereumTypeBlock2(d.chainParser) + internal := &bchain.EthereumInternalData{ + Type: bchain.CREATE, + Contract: topLevel, + Transfers: []bchain.EthereumInternalTransfer{ + {Type: bchain.CREATE, From: topLevel, To: nested}, + {Type: bchain.CREATE, From: topLevel, To: ephemeral}, + {Type: bchain.SELFDESTRUCT, From: ephemeral, To: topLevel}, + {Type: bchain.SELFDESTRUCT, From: longLived, To: topLevel}, + }, + } + for i := range block2.Txs { + csd, _ := block2.Txs[i].CoinSpecificData.(bchain.EthereumSpecificData) + if i == 0 { + csd.InternalData = internal + } else { + csd.InternalData = nil + } + block2.Txs[i].CoinSpecificData = csd + } + block2.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + Contracts: []bchain.ContractInfo{ + {Contract: topLevel, Standard: bchain.UnhandledTokenStandard, CreatedInBlock: block2.Height}, + {Contract: nested, Standard: bchain.UnhandledTokenStandard, CreatedInBlock: block2.Height}, + {Contract: ephemeral, Standard: bchain.UnhandledTokenStandard, CreatedInBlock: block2.Height}, + {Contract: ephemeral, DestructedInBlock: block2.Height}, + {Contract: longLived, DestructedInBlock: block2.Height}, + }, + } + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + + assertRegistryRow := func(address string, want *bchain.ContractInfo) { + t.Helper() + got, err := d.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if want == nil { + if got != nil { + t.Errorf("contract %s = %+v, want no row", address, got) + } + return + } + if got == nil || got.CreatedInBlock != want.CreatedInBlock || got.DestructedInBlock != want.DestructedInBlock { + t.Errorf("contract %s = %+v, want CreatedInBlock %d, DestructedInBlock %d", address, got, want.CreatedInBlock, want.DestructedInBlock) + } + } + + // connected state; the ephemeral row proves the same-batch merge + assertRegistryRow(topLevel, &bchain.ContractInfo{CreatedInBlock: block2.Height}) + assertRegistryRow(nested, &bchain.ContractInfo{CreatedInBlock: block2.Height}) + assertRegistryRow(ephemeral, &bchain.ContractInfo{CreatedInBlock: block2.Height, DestructedInBlock: block2.Height}) + assertRegistryRow(longLived, &bchain.ContractInfo{CreatedInBlock: block1.Height, DestructedInBlock: block2.Height}) + + if err := d.DisconnectBlockRangeEthereumType(block2.Height, block2.Height); err != nil { + t.Fatal(err) + } + + // creations of the disconnected block are gone, the destruction is reset + assertRegistryRow(topLevel, nil) + assertRegistryRow(nested, nil) + assertRegistryRow(ephemeral, nil) + assertRegistryRow(longLived, &bchain.ContractInfo{CreatedInBlock: block1.Height}) +} + +// a contract destroyed and re-deployed at the same address within one block must +// keep its row - deleting it would drop a contract live on the canonical chain +func TestDisconnectBlockRange_KeepsRowOfContractRecreatedInDisconnectedBlock(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + metamorphic := "0x5555555555555555555555555555555555555555" + + block1 := dbtestdata.GetTestEthereumTypeBlock1(d.chainParser) + block1.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + Contracts: []bchain.ContractInfo{ + {Contract: metamorphic, Standard: bchain.UnhandledTokenStandard, CreatedInBlock: block1.Height}, + }, + } + if err := d.ConnectBlock(block1); err != nil { + t.Fatal(err) + } + + // destruction first, re-deployment second - the order the disconnect walk sees + block2 := dbtestdata.GetTestEthereumTypeBlock2(d.chainParser) + internal := &bchain.EthereumInternalData{ + Type: bchain.CALL, + Transfers: []bchain.EthereumInternalTransfer{ + {Type: bchain.SELFDESTRUCT, From: metamorphic, To: metamorphic}, + {Type: bchain.CREATE, From: metamorphic, To: metamorphic}, + }, + } + for i := range block2.Txs { + csd, _ := block2.Txs[i].CoinSpecificData.(bchain.EthereumSpecificData) + if i == 0 { + csd.InternalData = internal + } else { + csd.InternalData = nil + } + block2.Txs[i].CoinSpecificData = csd + } + block2.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + Contracts: []bchain.ContractInfo{ + {Contract: metamorphic, DestructedInBlock: block2.Height}, + {Contract: metamorphic, Standard: bchain.UnhandledTokenStandard, CreatedInBlock: block2.Height}, + }, + } + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + + // the sparse creation record already overwrote the pre-range CreatedInBlock + ci, err := d.GetContractInfoForAddress(metamorphic) + if err != nil { + t.Fatal(err) + } + if ci == nil || ci.CreatedInBlock != block2.Height || ci.DestructedInBlock != 0 { + t.Fatalf("connected row = %+v, want CreatedInBlock %d, DestructedInBlock 0", ci, block2.Height) + } + + if err := d.DisconnectBlockRangeEthereumType(block2.Height, block2.Height); err != nil { + t.Fatal(err) + } + + // the row must survive; CreatedInBlock stays stale until a reindex + ci, err = d.GetContractInfoForAddress(metamorphic) + if err != nil { + t.Fatal(err) + } + if ci == nil { + t.Fatal("row of a contract created before the disconnected range was deleted") + } + if ci.DestructedInBlock != 0 { + t.Errorf("row = %+v, want DestructedInBlock 0", ci) + } +} + +// a later block's destruction reset must compose with an earlier block's creation +// removal - unreachable within one block, where CREATE always precedes SELFDESTRUCT +func TestDisconnectBlockRange_ComposesRegistryRollbackAcrossBlocks(t *testing.T) { + // BlockAddressesToKeep must cover the range, else cleanupBlockTxs prunes the + // older cfBlockTxs row and the disconnect aborts before any rollback + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: eth.NewEthereumParser(2, true), + }) + defer closeAndDestroyRocksDB(t, d) + + crossBlock := "0x6666666666666666666666666666666666666666" + + block1 := dbtestdata.GetTestEthereumTypeBlock1(d.chainParser) + block1.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + Contracts: []bchain.ContractInfo{ + {Contract: crossBlock, Standard: bchain.UnhandledTokenStandard, CreatedInBlock: block1.Height}, + }, + } + internal1 := &bchain.EthereumInternalData{ + Type: bchain.CREATE, + Contract: crossBlock, + Transfers: []bchain.EthereumInternalTransfer{}, + } + for i := range block1.Txs { + csd, _ := block1.Txs[i].CoinSpecificData.(bchain.EthereumSpecificData) + if i == 0 { + csd.InternalData = internal1 + } else { + csd.InternalData = nil + } + block1.Txs[i].CoinSpecificData = csd + } + if err := d.ConnectBlock(block1); err != nil { + t.Fatal(err) + } + + block2 := dbtestdata.GetTestEthereumTypeBlock2(d.chainParser) + internal2 := &bchain.EthereumInternalData{ + Type: bchain.CALL, + Transfers: []bchain.EthereumInternalTransfer{ + {Type: bchain.SELFDESTRUCT, From: crossBlock, To: crossBlock}, + }, + } + for i := range block2.Txs { + csd, _ := block2.Txs[i].CoinSpecificData.(bchain.EthereumSpecificData) + if i == 0 { + csd.InternalData = internal2 + } else { + csd.InternalData = nil + } + block2.Txs[i].CoinSpecificData = csd + } + block2.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + Contracts: []bchain.ContractInfo{ + {Contract: crossBlock, DestructedInBlock: block2.Height}, + }, + } + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + + ci, err := d.GetContractInfoForAddress(crossBlock) + if err != nil { + t.Fatal(err) + } + if ci == nil || ci.CreatedInBlock != block1.Height || ci.DestructedInBlock != block2.Height { + t.Fatalf("connected row = %+v, want CreatedInBlock %d, DestructedInBlock %d", ci, block1.Height, block2.Height) + } + + // block2 resets the destruction, block1 then replaces that same entry with a delete + if err := d.DisconnectBlockRangeEthereumType(block1.Height, block2.Height); err != nil { + t.Fatal(err) + } + + ci, err = d.GetContractInfoForAddress(crossBlock) + if err != nil { + t.Fatal(err) + } + if ci != nil { + t.Errorf("row = %+v, want no row - both the creation and the destruction were disconnected", ci) + } +} + func Test_BulkConnect_EthereumType(t *testing.T) { d := setupRocksDB(t, &testEthereumParser{ EthereumParser: ethereumTestnetParser(), @@ -1224,10 +1794,83 @@ func Test_packUnpackAddrContracts(t *testing.T) { if !reflect.DeepEqual(got, &tt.data) { t.Errorf("unpackAddrContracts() = %v, want %v", got, tt.data) } + // the header-only decoder must agree on the counters and leave the array undecoded + header, err := unpackAddrContractsHeader(packed, nil) + if err != nil { + t.Errorf("unpackAddrContractsHeader() error = %v", err) + return + } + wantHeader := &AddrContracts{TotalTxs: tt.data.TotalTxs, NonContractTxs: tt.data.NonContractTxs, InternalTxs: tt.data.InternalTxs} + if !reflect.DeepEqual(header, wantHeader) { + t.Errorf("unpackAddrContractsHeader() = %v, want %v", header, wantHeader) + } }) } } +func Test_unpackAddrContractsHeader_Truncated(t *testing.T) { + packed := packAddrContracts(&AddrContracts{TotalTxs: 300, NonContractTxs: 200, InternalTxs: 100}) + // the header is the three counters; the trailing contract count is not part of it + headerLen := 0 + for i := 0; i < 3; i++ { + _, l := unpackVaruint(packed[headerLen:]) + headerLen += l + } + for cut := 0; cut < headerLen; cut++ { + if _, err := unpackAddrContractsHeader(packed[:cut], nil); err == nil { + t.Errorf("unpackAddrContractsHeader(packed[:%d]) expected error", cut) + } + } +} + +func Test_GetAddrDescContracts_HeaderOnly(t *testing.T) { + parser := ethereumTestnetParser() + d := setupRocksDB(t, parser) + defer closeAndDestroyRocksDB(t, d) + addrDesc := bchain.AddressDescriptor(addressToAddrDesc(dbtestdata.EthAddr4b, parser)) + + for _, headerOnly := range []bool{false, true} { + got, err := d.GetAddrDescContracts(addrDesc, headerOnly) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("absent record, headerOnly=%v: got %v, want nil", headerOnly, got) + } + } + + stored := &AddrContracts{ + TotalTxs: 3333330, + NonContractTxs: 2222220, + InternalTxs: 1111110, + Contracts: generateAddrContracts(10, 1, 3, 1, 3), + } + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.storeAddressContracts(wb, map[string]*AddrContracts{string(addrDesc): stored}); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } + + full, err := d.GetAddrDescContracts(addrDesc, false) + if err != nil { + t.Fatal(err) + } + got, err := d.GetAddrDescContracts(addrDesc, true) + if err != nil { + t.Fatal(err) + } + want := &AddrContracts{TotalTxs: full.TotalTxs, NonContractTxs: full.NonContractTxs, InternalTxs: full.InternalTxs} + if !reflect.DeepEqual(got, want) { + t.Errorf("GetAddrDescContracts(headerOnly) = %v, want %v", got, want) + } + if len(full.Contracts) != len(stored.Contracts) { + t.Errorf("GetAddrDescContracts() decoded %d contracts, want %d", len(full.Contracts), len(stored.Contracts)) + } +} + 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 @@ -1540,7 +2183,7 @@ func Test_addToContracts(t *testing.T) { 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 { + if got := addToContract(&unpackedAddrContracts.Contracts[contractIndex], contractIndex, tt.args.index, tt.args.contract, tt.args.transfer, tt.args.addTxCount, true); got != tt.wantIndex { t.Errorf("addToContracts() = %v, want %v", got, tt.wantIndex) } // convert from partially unpacked form to final form used by API @@ -1564,7 +2207,7 @@ func Test_addToContract_ERC20ZeroesExistingValue(t *testing.T) { Contract: makeTestAddrDesc(123), Value: unpackedBigInt{Value: big.NewInt(123456)}, } - addToContract(c, 0, 1, c.Contract, transfer, false) + addToContract(c, 0, 1, c.Contract, transfer, false, true) if c.Value.Value == nil || c.Value.Value.Sign() != 0 { t.Fatalf("expected ERC20 value to be zeroed, got %v", c.Value.Value) } @@ -1577,7 +2220,7 @@ func Test_addToContract_ERC20ZeroesExistingValue(t *testing.T) { Contract: makeTestAddrDesc(124), Value: unpackedBigInt{Slice: []byte{0x1, 0x2}}, } - addToContract(c, 0, 1, c.Contract, transfer, false) + addToContract(c, 0, 1, c.Contract, transfer, false, true) 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) } diff --git a/db/rocksdb_internaldata_test.go b/db/rocksdb_internaldata_test.go new file mode 100644 index 0000000000..9a444b3019 --- /dev/null +++ b/db/rocksdb_internaldata_test.go @@ -0,0 +1,268 @@ +//go:build unittest + +package db + +import ( + "encoding/hex" + "fmt" + "math" + "reflect" + "sort" + "strings" + "testing" + + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// blockWithFailedInternalData returns the test block the way a failed internal data fetch +// leaves it: no per-tx internal data and no contracts - the trace is what would have +// produced both - only the error marker and the aliases that came from the logs. +func blockWithFailedInternalData(block *bchain.Block) *bchain.Block { + for i := range block.Txs { + csd, _ := block.Txs[i].CoinSpecificData.(bchain.EthereumSpecificData) + csd.InternalData = nil + block.Txs[i].CoinSpecificData = csd + } + block.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + InternalDataError: dbtestdata.Block2SpecificData.InternalDataError, + AddressAliasRecords: dbtestdata.Block2SpecificData.AddressAliasRecords, + } + return block +} + +// blockWithHealedInternalData returns the test block as a successful refetch delivers it - +// internal data present, no error - so it can be reconnected to the indexed block. +func blockWithHealedInternalData(block *bchain.Block) *bchain.Block { + block.CoinSpecificData = &bchain.EthereumBlockSpecificData{ + AddressAliasRecords: dbtestdata.Block2SpecificData.AddressAliasRecords, + Contracts: dbtestdata.Block2SpecificData.Contracts, + } + return block +} + +// columnDump returns a column family as a hex key -> hex value map. +func columnDump(t *testing.T, d *RocksDB, col int) map[string]string { + t.Helper() + dump := map[string]string{} + it := d.db.NewIteratorCF(d.ro, d.cfh[col]) + defer it.Close() + for it.SeekToFirst(); it.Valid(); it.Next() { + dump[hex.EncodeToString(it.Key().Data())] = hex.EncodeToString(it.Value().Data()) + } + return dump +} + +// addressTxIndexes returns every indexed transaction of every address with its index list +// sorted. The heal appends the internal data indexes to rows the failed sync already +// wrote, so they land in a different order than a sync that never failed produces. The +// entries are the same, and the order within one transaction only decides which index a +// filter matches first, so the comparison is order-insensitive here and exact everywhere +// else. +func addressTxIndexes(t *testing.T, d *RocksDB) map[string][]string { + t.Helper() + entries := map[string][]string{} + for key := range columnDump(t, d, cfAddresses) { + raw, err := hex.DecodeString(key) + if err != nil { + t.Fatal(err) + } + addrDesc, _, err := unpackAddressKey(raw) + if err != nil { + t.Fatal(err) + } + strAddrDesc := hex.EncodeToString(addrDesc) + if _, done := entries[strAddrDesc]; done { + continue + } + txs := []string{} + if err := d.GetAddrDescTransactions(addrDesc, 0, math.MaxUint32, func(txid string, height uint32, indexes []int32) error { + sorted := append([]int32(nil), indexes...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + txs = append(txs, fmt.Sprintf("%d %s %v", height, txid, sorted)) + return nil + }); err != nil { + t.Fatal(err) + } + sort.Strings(txs) + entries[strAddrDesc] = txs + } + return entries +} + +func assertSameIndex(t *testing.T, healed, synced *RocksDB) { + t.Helper() + for col := range cfNames { + if col == cfAddresses { + continue + } + if got, want := columnDump(t, healed, col), columnDump(t, synced, col); !reflect.DeepEqual(got, want) { + t.Errorf("column %s of the healed index differs\n got %v\nwant %v", cfNames[col], got, want) + } + } + if got, want := addressTxIndexes(t, healed), addressTxIndexes(t, synced); !reflect.DeepEqual(got, want) { + t.Errorf("addresses of the healed index differ\n got %v\nwant %v", got, want) + } +} + +// A healed block must be indistinguishable from one that synced without a trace failure: +// the same internal data, the same address counters, and the contract it destroyed +// recorded without losing the metadata block 1 stored for it. +func TestRocksDB_ReconnectInternalDataToBlockEthereumType_HealsLikeSync(t *testing.T) { + healed := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, healed) + synced := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, synced) + + for _, d := range []*RocksDB{healed, synced} { + if err := d.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock1(d.chainParser)); err != nil { + t.Fatal(err) + } + } + // the reference index: block 2 synced with its internal data + if err := synced.ConnectBlock(blockWithHealedInternalData(dbtestdata.GetTestEthereumTypeBlock2(synced.chainParser))); err != nil { + t.Fatal(err) + } + + // the healed index: block 2 connected the way a failed fetch leaves it, then healed + if err := healed.ConnectBlock(blockWithFailedInternalData(dbtestdata.GetTestEthereumTypeBlock2(healed.chainParser))); err != nil { + t.Fatal(err) + } + if internalDataErrorAtHeight(t, healed, 4321001) == nil { + t.Fatal("block 2 was not queued for healing") + } + if err := healed.ReconnectInternalDataToBlockEthereumType(blockWithHealedInternalData(dbtestdata.GetTestEthereumTypeBlock2(healed.chainParser))); err != nil { + t.Fatal(err) + } + assertSameIndex(t, healed, synced) + + // Healing the same block again must change nothing. The address counters are + // cumulative, so a second pass over data that are already stored would inflate them + // with no way to repair it. + if err := healed.ReconnectInternalDataToBlockEthereumType(blockWithHealedInternalData(dbtestdata.GetTestEthereumTypeBlock2(healed.chainParser))); err != nil { + t.Fatal(err) + } + assertSameIndex(t, healed, synced) +} + +func TestRocksDB_ReconnectInternalDataToBlockEthereumType_ReorgGuard(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + if err := d.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock1(d.chainParser)); err != nil { + t.Fatal(err) + } + + // The height still holds this hash, so the reconnect proceeds. Without this case a + // hash compared in the wrong format would reject every heal and silently disable + // healing altogether, while the reorg case below would still pass. + if err := d.ReconnectInternalDataToBlockEthereumType(dbtestdata.GetTestEthereumTypeBlock1(d.chainParser)); err != nil { + t.Fatalf("reconnect of the indexed block: %v", err) + } + + // A reorg replaced the block at this height: the orphan's transactions are no longer + // indexed, so its internal data must not be written back. + orphan := dbtestdata.GetTestEthereumTypeBlock1(d.chainParser) + orphan.Hash = "0x" + strings.Repeat("ab", 32) + err := d.ReconnectInternalDataToBlockEthereumType(orphan) + if err == nil { + t.Fatal("reconnect of an orphaned block succeeded, want an error") + } + if !strings.Contains(err.Error(), "possible reorg") { + t.Fatalf("reconnect of an orphaned block: %v, want a reorg error", err) + } +} + +func storeInternalDataError(t *testing.T, d *RocksDB, height uint32, hash, message string, retries uint8) { + t.Helper() + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.StoreBlockInternalDataErrorEthereumType(wb, &bchain.Block{ + BlockHeader: bchain.BlockHeader{Hash: hash, Height: height}, + }, message, retries); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } +} + +func internalDataErrorAtHeight(t *testing.T, d *RocksDB, height uint32) *BlockInternalDataError { + t.Helper() + queue, err := d.GetBlockInternalDataErrorsEthereumType() + if err != nil { + t.Fatal(err) + } + for i := range queue { + if queue[i].Height == height { + return &queue[i] + } + } + return nil +} + +// A healing pass reports a failure from a snapshot of the queue taken before it started, +// so the update must not resurrect an entry a rollback removed, nor overwrite the entry of +// a different block that took over the height in the meantime. +func TestRocksDB_UpdateBlockInternalDataErrorEthereumType(t *testing.T) { + const ( + height = uint32(4321) + hashA = "0x2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee" + hashB = "0x9f8d4c2e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e611" + queued = "trace failed" + refetch = "Block not found" + ) + + t.Run("updates the queued block", func(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{EthereumParser: ethereumTestnetParser()}) + defer closeAndDestroyRocksDB(t, d) + storeInternalDataError(t, d, height, hashA, queued, 7) + + if err := d.UpdateBlockInternalDataErrorEthereumType(height, hashA, refetch, 8); err != nil { + t.Fatal(err) + } + got := internalDataErrorAtHeight(t, d, height) + if got == nil { + t.Fatal("entry disappeared") + } + if got.Retries != 8 || got.ErrorMessage != refetch || got.Hash != hashA { + t.Errorf("got %d retries, message %q, hash %s; want 8, %q, %s", got.Retries, got.ErrorMessage, got.Hash, refetch, hashA) + } + }) + + t.Run("does not resurrect a removed entry", func(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{EthereumParser: ethereumTestnetParser()}) + defer closeAndDestroyRocksDB(t, d) + + if err := d.UpdateBlockInternalDataErrorEthereumType(height, hashA, refetch, 1); err != nil { + t.Fatal(err) + } + if got := internalDataErrorAtHeight(t, d, height); got != nil { + t.Errorf("entry resurrected: %+v", got) + } + }) + + t.Run("does not overwrite another block at the same height", func(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{EthereumParser: ethereumTestnetParser()}) + defer closeAndDestroyRocksDB(t, d) + storeInternalDataError(t, d, height, hashB, queued, 0) + + if err := d.UpdateBlockInternalDataErrorEthereumType(height, hashA, refetch, 25); err != nil { + t.Fatal(err) + } + got := internalDataErrorAtHeight(t, d, height) + if got == nil { + t.Fatal("entry disappeared") + } + if got.Hash != hashB || got.Retries != 0 || got.ErrorMessage != queued { + t.Errorf("got hash %s, %d retries, message %q; want %s, 0, %q", got.Hash, got.Retries, got.ErrorMessage, hashB, queued) + } + }) +} diff --git a/db/rocksdb_runtime_settings.go b/db/rocksdb_runtime_settings.go new file mode 100644 index 0000000000..50a7535c0a --- /dev/null +++ b/db/rocksdb_runtime_settings.go @@ -0,0 +1,56 @@ +package db + +import ( + "encoding/json" + "time" + + "github.com/juju/errors" +) + +// runtimeSettingKeyPrefix prefixes cfDefault keys holding runtime setting +// overrides written through the internal /admin interface. Each setting is +// stored under its own key, so a write or delete touches nothing else in the +// database (in particular it is independent of the periodically stored +// internalState blob) and the rows are ignored by older blockbook versions. +const runtimeSettingKeyPrefix = "runtimeSetting:" + +// runtimeSettingEnvelope wraps a stored runtime setting value. The envelope +// keeps a stored empty value distinguishable from a missing row and records +// when the value was last changed. +type runtimeSettingEnvelope struct { + Value string `json:"value"` + Updated time.Time `json:"updated"` +} + +// GetRuntimeSetting returns the stored override of the given runtime setting +// and whether an override exists. +func (d *RocksDB) GetRuntimeSetting(name string) (string, bool, error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name)) + if err != nil { + return "", false, err + } + defer val.Free() + data := val.Data() + if len(data) == 0 { + return "", false, nil + } + var e runtimeSettingEnvelope + if err := json.Unmarshal(data, &e); err != nil { + return "", false, errors.Annotatef(err, "cannot unpack runtime setting %s", name) + } + return e.Value, true, nil +} + +// StoreRuntimeSetting persists an override of the given runtime setting. +func (d *RocksDB) StoreRuntimeSetting(name, value string) error { + buf, err := json.Marshal(&runtimeSettingEnvelope{Value: value, Updated: time.Now()}) + if err != nil { + return err + } + return d.db.PutCF(d.wo, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name), buf) +} + +// DeleteRuntimeSetting removes the override of the given runtime setting. +func (d *RocksDB) DeleteRuntimeSetting(name string) error { + return d.db.DeleteCF(d.wo, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name)) +} diff --git a/db/rocksdb_runtime_settings_test.go b/db/rocksdb_runtime_settings_test.go new file mode 100644 index 0000000000..ce22e5eb3f --- /dev/null +++ b/db/rocksdb_runtime_settings_test.go @@ -0,0 +1,97 @@ +//go:build unittest + +package db + +import ( + "os" + "testing" + + "github.com/trezor/blockbook/common" +) + +func TestRocksDB_RuntimeSettings(t *testing.T) { + parser := &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + } + d := setupRocksDB(t, parser) + path := d.path + dClosed := false + defer func() { + if !dClosed { + if err := d.Close(); err != nil { + t.Error(err) + } + } + os.RemoveAll(path) + }() + + getSetting := func(db *RocksDB, name string) (string, bool) { + t.Helper() + value, found, err := db.GetRuntimeSetting(name) + if err != nil { + t.Fatalf("GetRuntimeSetting(%q) unexpected error: %v", name, err) + } + return value, found + } + + if value, found := getSetting(d, "MISSING"); found || value != "" { + t.Fatalf("GetRuntimeSetting(MISSING) = %q, %v, want empty, false", value, found) + } + + if err := d.StoreRuntimeSetting("ALLOWED_RPC_CALL_TO", "0xabcd,0xef01"); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_RPC_CALL_TO"); !found || value != "0xabcd,0xef01" { + t.Fatalf("GetRuntimeSetting after store = %q, %v, want 0xabcd,0xef01, true", value, found) + } + + // overwrite + if err := d.StoreRuntimeSetting("ALLOWED_RPC_CALL_TO", "0x1234"); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_RPC_CALL_TO"); !found || value != "0x1234" { + t.Fatalf("GetRuntimeSetting after overwrite = %q, %v, want 0x1234, true", value, found) + } + + // an explicitly stored empty value must stay distinguishable from a missing row + if err := d.StoreRuntimeSetting("ALLOWED_EVM_CALL_METHODS", ""); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_EVM_CALL_METHODS"); !found || value != "" { + t.Fatalf("GetRuntimeSetting of stored empty value = %q, %v, want empty, true", value, found) + } + + if err := d.DeleteRuntimeSetting("ALLOWED_EVM_CALL_METHODS"); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_EVM_CALL_METHODS"); found || value != "" { + t.Fatalf("GetRuntimeSetting after delete = %q, %v, want empty, false", value, found) + } + // deleting a missing row is not an error + if err := d.DeleteRuntimeSetting("ALLOWED_EVM_CALL_METHODS"); err != nil { + t.Fatal(err) + } + + // settings survive a database reopen + dClosed = true + if err := d.Close(); err != nil { + t.Fatal(err) + } + d2, err := NewRocksDB(path, 100000, -1, parser, nil, false) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := d2.Close(); err != nil { + t.Error(err) + } + }() + is, err := d2.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + if err != nil { + t.Fatal(err) + } + d2.SetInternalState(is) + if value, found := getSetting(d2, "ALLOWED_RPC_CALL_TO"); !found || value != "0x1234" { + t.Fatalf("GetRuntimeSetting after reopen = %q, %v, want 0x1234, true", value, found) + } +} diff --git a/db/rocksdb_syscoin_output_test.go b/db/rocksdb_syscoin_output_test.go new file mode 100644 index 0000000000..820d2bc147 --- /dev/null +++ b/db/rocksdb_syscoin_output_test.go @@ -0,0 +1,141 @@ +//go:build unittest + +package db + +import ( + "math/big" + "strings" + "testing" + + "github.com/linxGnu/grocksdb" + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func TestGetTxAddressesOutputSyscoin(t *testing.T) { + for _, extendedIndex := range []bool{false, true} { + name := "basic" + if extendedIndex { + name = "extendedIndex" + } + t.Run(name, func(t *testing.T) { + d := setupRocksDBWithExtendedIndex(t, &testSyscoinParser{SyscoinParser: syscoinTestParser()}, extendedIndex) + defer closeAndDestroyRocksDB(t, d) + + asset := &bchain.AssetInfo{AssetGuid: 1<<40 + 12345, ValueSat: new(big.Int).Lsh(big.NewInt(1), 120)} + zeroAsset := &bchain.AssetInfo{AssetGuid: 54321, ValueSat: big.NewInt(0)} + ta := &TxAddresses{ + Version: 132, + Height: 12345, + VSize: 512, + Memo: []byte("Syscoin memo after the outputs"), + Inputs: []TxInput{ + {ValueSat: *big.NewInt(0)}, + {AddrDesc: addressToAddrDesc(dbtestdata.AddrS1, d.chainParser), ValueSat: *big.NewInt(1000), Txid: strings.Repeat("b2", 32), Vout: 130, AssetInfo: asset}, + {AddrDesc: addressToAddrDesc(dbtestdata.AddrS2, d.chainParser), ValueSat: *big.NewInt(2000), Txid: strings.Repeat("c3", 32), Vout: 3}, + {ValueSat: *big.NewInt(0), AssetInfo: zeroAsset}, + }, + Outputs: []TxOutput{ + {AddrDesc: addressToAddrDesc(dbtestdata.AddrS1, d.chainParser), ValueSat: *big.NewInt(0), AssetInfo: asset, Spent: true, SpentTxid: strings.Repeat("d4", 32), SpentIndex: 257, SpentHeight: 12346}, + {ValueSat: *big.NewInt(0), AssetInfo: zeroAsset}, + {AddrDesc: addressToAddrDesc(dbtestdata.AddrS2, d.chainParser), ValueSat: *big.NewInt(3000), Spent: true, SpentTxid: strings.Repeat("e5", 32), SpentIndex: 0, SpentHeight: 12347}, + {AddrDesc: addressToAddrDesc(dbtestdata.AddrS1, d.chainParser), ValueSat: *big.NewInt(4000), AssetInfo: asset}, + }, + } + txid := strings.Repeat("a1", 32) + btxID, err := d.chainParser.PackTxid(txid) + require.NoError(t, err) + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + require.NoError(t, d.storeTxAddresses(wb, map[string]*TxAddresses{string(btxID): ta})) + require.NoError(t, d.db.Write(d.wo, wb)) + + full, err := d.GetTxAddresses(txid) + require.NoError(t, err) + require.NotNil(t, full) + require.Equal(t, ta.Version, full.Version) + require.Equal(t, ta.Memo, full.Memo) + for vout := range ta.Outputs { + got, err := d.GetTxAddressesOutput(txid, uint32(vout)) + require.NoErrorf(t, err, "vout %d", vout) + require.NotNilf(t, got, "vout %d", vout) + require.Equalf(t, full.Outputs[vout], *got, "vout %d", vout) + require.Equalf(t, ta.Outputs[vout].AssetInfo, got.AssetInfo, "vout %d asset", vout) + } + got, err := d.GetTxAddressesOutput(txid, uint32(len(ta.Outputs))) + require.NoError(t, err) + require.Nil(t, got) + got, err = d.GetTxAddressesOutput(strings.Repeat("ff", 32), 0) + require.NoError(t, err) + require.Nil(t, got) + + // Mempool outpoint resolution must retain asset metadata on both sides. + _, _, inputAsset := d.AddrDescForOutpoint(bchain.Outpoint{Txid: txid, Vout: ^int32(1)}) + require.Equal(t, asset, inputAsset) + _, _, outputAsset := d.AddrDescForOutpoint(bchain.Outpoint{Txid: txid, Vout: 3}) + require.Equal(t, asset, outputAsset) + }) + } +} + +func TestSyscoinExtendedSpendingIndexConnectDisconnect(t *testing.T) { + for _, bulk := range []bool{false, true} { + name := "normal" + if bulk { + name = "bulk" + } + t.Run(name, func(t *testing.T) { + d := setupRocksDBWithExtendedIndex(t, &testSyscoinParser{SyscoinParser: syscoinTestParser()}, true) + defer closeAndDestroyRocksDB(t, d) + const assetGuid uint64 = 12345 + const assetValue int64 = 25 + oldAssetCache := AssetCache + AssetCache = map[uint64]bchain.Asset{assetGuid: {}} + defer func() { AssetCache = oldAssetCache }() + txid1, txid2 := strings.Repeat("11", 32), strings.Repeat("22", 32) + block1 := syscoinAssetTestBlock(d.chainParser, 1, strings.Repeat("01", 32), txid1, []bchain.Vin{{Coinbase: "01"}}, dbtestdata.AddrS1, assetGuid, assetValue) + block2 := syscoinAssetTestBlock(d.chainParser, 2, strings.Repeat("02", 32), txid2, []bchain.Vin{{Txid: txid1, Vout: 0}}, dbtestdata.AddrS2, assetGuid, assetValue) + if bulk { + bc, err := d.InitBulkConnect() + require.NoError(t, err) + require.NoError(t, bc.ConnectBlock(block1, false)) + require.NoError(t, bc.ConnectBlock(block2, true)) + require.NoError(t, bc.Close()) + } else { + require.NoError(t, d.ConnectBlock(block1)) + require.NoError(t, d.ConnectBlock(block2)) + } + + output, err := d.GetTxAddressesOutput(txid1, 0) + require.NoError(t, err) + require.NotNil(t, output) + require.True(t, output.Spent) + require.Equal(t, txid2, output.SpentTxid) + require.Equal(t, uint32(0), output.SpentIndex) + require.Equal(t, uint32(2), output.SpentHeight) + require.Equal(t, &bchain.AssetInfo{AssetGuid: assetGuid, ValueSat: big.NewInt(assetValue)}, output.AssetInfo) + + require.NoError(t, d.DisconnectBlockRangeBitcoinType(2, 2)) + output, err = d.GetTxAddressesOutput(txid1, 0) + require.NoError(t, err) + require.NotNil(t, output) + require.False(t, output.Spent) + require.Empty(t, output.SpentTxid) + require.Zero(t, output.SpentIndex) + require.Zero(t, output.SpentHeight) + require.Equal(t, &bchain.AssetInfo{AssetGuid: assetGuid, ValueSat: big.NewInt(assetValue)}, output.AssetInfo) + removed, err := d.GetTxAddressesOutput(txid2, 0) + require.NoError(t, err) + require.Nil(t, removed) + addrDesc, err := d.chainParser.GetAddrDescFromAddress(dbtestdata.AddrS1) + require.NoError(t, err) + balance, err := d.GetAddrDescBalance(addrDesc, AddressBalanceDetailUTXO) + require.NoError(t, err) + require.NotNil(t, balance) + require.Len(t, balance.Utxos, 1) + require.Equal(t, output.AssetInfo, balance.Utxos[0].AssetInfo) + require.Equal(t, big.NewInt(assetValue), balance.AssetBalances[assetGuid].BalanceSat) + }) + } +} diff --git a/db/rocksdb_test.go b/db/rocksdb_test.go index 16f2dffd43..9c8786e65d 100644 --- a/db/rocksdb_test.go +++ b/db/rocksdb_test.go @@ -16,6 +16,7 @@ import ( "github.com/juju/errors" "github.com/linxGnu/grocksdb" "github.com/martinboehm/btcutil/chaincfg" + "github.com/stretchr/testify/require" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/btc" "github.com/trezor/blockbook/common" @@ -42,7 +43,10 @@ func bitcoinTestnetParser() *btc.BitcoinParser { &btc.Configuration{BlockAddressesToKeep: 1}) } -func setupRocksDB(t *testing.T, p bchain.BlockChainParser) *RocksDB { +func setupRocksDB(t testing.TB, p bchain.BlockChainParser) *RocksDB { + // the contract cache is package-level, so entries cached by a previous + // test's database would leak into this one + cachedContracts.reset() tmp, err := os.MkdirTemp("", "testdb") if err != nil { t.Fatal(err) @@ -59,7 +63,7 @@ func setupRocksDB(t *testing.T, p bchain.BlockChainParser) *RocksDB { return d } -func closeAndDestroyRocksDB(t *testing.T, d *RocksDB) { +func closeAndDestroyRocksDB(t testing.TB, d *RocksDB) { if err := d.Close(); err != nil { t.Fatal(err) } @@ -1747,3 +1751,380 @@ func TestRocksDB_packTxIndexes_unpackTxIndexes(t *testing.T) { }) } } + +// setupRocksDBWithExtendedIndex is like setupRocksDB but lets the caller choose +// the extendedIndex layout; used by TestGetTxAddressesOutput to cover both forms. +func setupRocksDBWithExtendedIndex(t *testing.T, p bchain.BlockChainParser, extendedIndex bool) *RocksDB { + t.Helper() + // the contract cache is package-level, so entries cached by a previous + // test's database would leak into this one + cachedContracts.reset() + tmp, err := os.MkdirTemp("", "testdb") + require.NoError(t, err) + d, err := NewRocksDB(tmp, 100000, -1, p, nil, extendedIndex) + require.NoError(t, err) + is, err := d.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + require.NoError(t, err) + d.SetInternalState(is) + return d +} + +// TestGetTxAddressesOutput checks that GetTxAddressesOutput(txid, vout) returns the +// same TxOutput as GetTxAddresses(txid).Outputs[vout]: the former skips over the +// height, inputs and preceding outputs by length and unpacks only the target output, +// while the latter unpacks the whole record. It runs with extendedIndex off and on +// and uses a record with a coinbase input and both spent and unspent outputs, so +// every length-skip case in packedTxInputLen/packedTxOutputLen is covered. +func TestGetTxAddressesOutput(t *testing.T) { + for _, extendedIndex := range []bool{false, true} { + name := "basic" + if extendedIndex { + name = "extendedIndex" + } + t.Run(name, func(t *testing.T) { + d := setupRocksDBWithExtendedIndex(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, extendedIndex) + defer closeAndDestroyRocksDB(t, d) + + ta := &TxAddresses{ + Height: 12345, + Inputs: []TxInput{ + {ValueSat: *big.NewInt(0)}, // coinbase: empty AddrDesc, no Txid + {AddrDesc: addressToAddrDesc(dbtestdata.Addr1, d.chainParser), ValueSat: *big.NewInt(1000), Txid: strings.Repeat("b2", 32), Vout: 0}, + {AddrDesc: addressToAddrDesc(dbtestdata.Addr2, d.chainParser), ValueSat: *big.NewInt(2000), Txid: strings.Repeat("c3", 32), Vout: 3}, + }, + Outputs: []TxOutput{ + {AddrDesc: addressToAddrDesc(dbtestdata.Addr3, d.chainParser), ValueSat: *big.NewInt(3000)}, + {AddrDesc: addressToAddrDesc(dbtestdata.Addr4, d.chainParser), ValueSat: *big.NewInt(4000), Spent: true, SpentTxid: strings.Repeat("d4", 32), SpentIndex: 5, SpentHeight: 12346}, + {AddrDesc: addressToAddrDesc(dbtestdata.Addr5, d.chainParser), ValueSat: *big.NewInt(5000)}, + }, + } + txid := strings.Repeat("a1", 32) + + btxID, err := d.chainParser.PackTxid(txid) + require.NoError(t, err) + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + require.NoError(t, d.storeTxAddresses(wb, map[string]*TxAddresses{string(btxID): ta})) + require.NoError(t, d.db.Write(d.wo, wb)) + + full, err := d.GetTxAddresses(txid) + require.NoError(t, err) + require.NotNil(t, full) + + // Each output read singly must equal the full-record unpack. + for vout := range full.Outputs { + got, err := d.GetTxAddressesOutput(txid, uint32(vout)) + require.NoErrorf(t, err, "vout %d", vout) + require.NotNilf(t, got, "vout %d", vout) + require.Equalf(t, full.Outputs[vout], *got, "vout %d", vout) + } + + // Out-of-range vout and missing txid both return nil. + got, err := d.GetTxAddressesOutput(txid, uint32(len(full.Outputs))) + require.NoError(t, err) + require.Nil(t, got, "out-of-range vout") + + got, err = d.GetTxAddressesOutput(strings.Repeat("ee", 32), 0) + require.NoError(t, err) + require.Nil(t, got, "missing txid") + }) + } +} + +func flushAllCFs(t *testing.T, d *RocksDB) { + fo := grocksdb.NewDefaultFlushOptions() + defer fo.Destroy() + if err := d.db.FlushCFs(d.cfh, fo); err != nil { + t.Fatalf("FlushCFs: %v", err) + } +} + +// TestRepairRocksDB_HealthyClosed_KeepsDataAndUsable verifies that repairing a healthy, +// gracefully closed multi-CF database preserves all populated column families and leaves +// the database usable, with empty CFs dropped-and-recreated being harmless. +func TestRepairRocksDB_HealthyClosed_KeepsDataAndUsable(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}) + path := d.path + + bc, err := d.InitBulkConnect() + if err != nil { + t.Fatal(err) + } + if err := bc.ConnectBlock(dbtestdata.GetTestBitcoinTypeBlock1(d.chainParser), false); err != nil { + t.Fatal(err) + } + if err := bc.ConnectBlock(dbtestdata.GetTestBitcoinTypeBlock2(d.chainParser), true); err != nil { + t.Fatal(err) + } + if err := bc.Close(); err != nil { + t.Fatal(err) + } + // CRUCIAL: force per-CF SSTs so repair preserves populated CFs. Without this the tiny + // dataset stays in the WAL and RepairDb collapses everything to [default]. + flushAllCFs(t, d) + // Open -> Closed, persisted. + if err := d.Close(); err != nil { + t.Fatal(err) + } + + if err := RepairRocksDB(path, false); err != nil { + t.Fatalf("RepairRocksDB: %v", err) + } + + d2, err := NewRocksDB(path, 100000, -1, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}, nil, false) + if err != nil { + t.Fatalf("NewRocksDB after repair: %v", err) + } + is, err := d2.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + if err != nil { + t.Fatal(err) + } + d2.SetInternalState(is) + if is.DbState != common.DbStateClosed { + t.Fatalf("expected DbStateClosed, got %d", is.DbState) + } + verifyAfterBitcoinTypeBlock2(t, d2) + + if err := d2.Close(); err != nil { + t.Fatal(err) + } + os.RemoveAll(path) +} + +// TestRepairRocksDB_Inconsistent_Refuses verifies that repairing a database left in +// inconsistent state (interrupted bulk import) does NOT falsely report success and does +// not silently alter the persisted DbState. +func TestRepairRocksDB_Inconsistent_Refuses(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}) + path := d.path + + bc, err := d.InitBulkConnect() // -> DbStateInconsistent persisted + if err != nil { + t.Fatal(err) + } + if err := bc.ConnectBlock(dbtestdata.GetTestBitcoinTypeBlock1(d.chainParser), true); err != nil { + t.Fatal(err) + } + flushAllCFs(t, d) + // simulate crash: do NOT call bc.Close(); Close only stores state when Open, so + // Inconsistent stays persisted. + if err := d.Close(); err != nil { + t.Fatal(err) + } + + err = RepairRocksDB(path, false) + if err == nil { + t.Fatal("expected error repairing inconsistent DB, got nil") + } + if !strings.Contains(err.Error(), "re-imported") && !strings.Contains(err.Error(), "forcerepair") { + t.Fatalf("unexpected error message: %v", err) + } + + d2, err := NewRocksDB(path, 100000, -1, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}, nil, false) + if err != nil { + t.Fatalf("NewRocksDB after repair: %v", err) + } + is, err := d2.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + if err != nil { + t.Fatal(err) + } + d2.SetInternalState(is) + if is.DbState != common.DbStateInconsistent { + t.Fatalf("expected DbStateInconsistent preserved, got %d", is.DbState) + } + + if err := d2.Close(); err != nil { + t.Fatal(err) + } + os.RemoveAll(path) +} + +// TestRepairRocksDB_Inconsistent_ResetForcesOpen verifies that -forcerepair flips an +// inconsistent database to DbStateOpen (not Closed) and leaves it reopenable. +func TestRepairRocksDB_Inconsistent_ResetForcesOpen(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}) + path := d.path + + bc, err := d.InitBulkConnect() + if err != nil { + t.Fatal(err) + } + if err := bc.ConnectBlock(dbtestdata.GetTestBitcoinTypeBlock1(d.chainParser), true); err != nil { + t.Fatal(err) + } + flushAllCFs(t, d) + if err := d.Close(); err != nil { + t.Fatal(err) + } + + if err := RepairRocksDB(path, true); err != nil { + t.Fatalf("RepairRocksDB with reset: %v", err) + } + + d2, err := NewRocksDB(path, 100000, -1, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}, nil, false) + if err != nil { + t.Fatalf("NewRocksDB after repair: %v", err) + } + is, err := d2.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + if err != nil { + t.Fatal(err) + } + d2.SetInternalState(is) + if is.DbState != common.DbStateOpen { + t.Fatalf("expected DbStateOpen after reset, got %d", is.DbState) + } + + if err := d2.Close(); err != nil { + t.Fatal(err) + } + os.RemoveAll(path) +} + +func TestMissingColumnFamilies(t *testing.T) { + got := missingColumnFamilies([]string{"default", "height", "addresses"}, []string{"default", "height"}) + if len(got) != 1 || got[0] != "addresses" { + t.Fatalf("expected [addresses], got %v", got) + } + if got := missingColumnFamilies(nil, []string{"default"}); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestRepairRocksDB_NonexistentPath(t *testing.T) { + if err := RepairRocksDB("/no/such/db", false); err == nil { + t.Fatal("expected error for nonexistent path, got nil") + } +} + +// TestGetAddrDescTransactions_ScanBound verifies the fix for a bug where a +// truncated address descriptor (such as the 2-byte "76a9" produced by +// bchain.AddressDescriptorFromString("ad:76a9")) caused an unbounded scan of +// the addresses column family. A short descriptor is a prefix of every P2PKH +// key (76 a9 14 ... 88 ac); those keys have the wrong length and are skipped, +// but without a bound the iterator still visits all of them. +func TestGetAddrDescTransactions_ScanBound(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + shortDesc := bchain.AddressDescriptor{0x76, 0xa9} // prefix of every P2PKH script + + // A well-formed value for a matching key: packed txid + one terminated index. + btxid, err := d.chainParser.PackTxid(dbtestdata.TxidB1T1) + require.NoError(t, err) + idxBuf := make([]byte, vlq.MaxLen32) + l := packVarint32((0<<1)|1, idxBuf) // index 0, terminator bit set + matchingVal := append(append([]byte{}, btxid...), idxBuf[:l]...) + + // The matching key for shortDesc is packAddressKey(shortDesc, 0) = 76a9FFFFFFFF. + // Its 3rd byte (0xFF) sorts it after every foreign P2PKH key (3rd byte 0x14), + // so it is only reached if the scan does not abort early. + matchingKey := packAddressKey(shortDesc, 0) + + // foreignP2PKH builds a realistic 25-byte P2PKH script: 76 a9 14 88 ac. + foreignP2PKH := func(i uint32) bchain.AddressDescriptor { + desc := make(bchain.AddressDescriptor, 25) + desc[0], desc[1], desc[2] = 0x76, 0xa9, 0x14 + binary.BigEndian.PutUint32(desc[3:], i) + desc[23], desc[24] = 0x88, 0xac + return desc + } + + fill := func(foreignCount int) { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + for i := 0; i < foreignCount; i++ { + wb.PutCF(d.cfh[cfAddresses], packAddressKey(foreignP2PKH(uint32(i)), 0), []byte{0x00}) + } + wb.PutCF(d.cfh[cfAddresses], matchingKey, matchingVal) + require.NoError(t, d.WriteBatch(wb)) + } + + countMatches := func() int { + matches := 0 + require.NoError(t, d.GetAddrDescTransactions(shortDesc, 0, ^uint32(0), func(txid string, height uint32, indexes []int32) error { + matches++ + return nil + })) + return matches + } + + // Control: with fewer foreign keys than the bound, the matching key at the + // end of the range is reached - proving the key is well-formed and reachable. + fill(10) + require.Equal(t, 1, countMatches(), "matching key must be reachable below the scan bound") + + // Bug shape: with more foreign keys than the bound, the scan aborts before + // reaching the matching key. Without the bound it would visit every foreign key. + d2 := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d2) + { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + for i := 0; i < maxAddrDescScanNonMatching+50; i++ { + wb.PutCF(d2.cfh[cfAddresses], packAddressKey(foreignP2PKH(uint32(i)), 0), []byte{0x00}) + } + wb.PutCF(d2.cfh[cfAddresses], matchingKey, matchingVal) + require.NoError(t, d2.WriteBatch(wb)) + } + matches := 0 + err = d2.GetAddrDescTransactions(shortDesc, 0, ^uint32(0), func(txid string, height uint32, indexes []int32) error { + matches++ + return nil + }) + require.ErrorContains(t, err, "address transaction scan limit exceeded") + require.Equal(t, 0, matches, "scan must abort before reaching the trailing matching key") +} + +func TestGetAddrDescTransactions_CompleteDescriptorScanLimit(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}) + defer closeAndDestroyRocksDB(t, d) + + addrDesc := addressToAddrDesc(dbtestdata.Addr1, d.chainParser) + btxid, err := d.chainParser.PackTxid(dbtestdata.TxidB1T1) + require.NoError(t, err) + idxBuf := make([]byte, vlq.MaxLen32) + l := packVarint32(1, idxBuf) // output 0, terminator bit set + matchingVal := append(append([]byte{}, btxid...), idxBuf[:l]...) + + // Complete P2PKH scripts can prefix longer indexed scripts; these suffixes sort before the address's own history during a full-range scan. + prefixScript := func(i int) bchain.AddressDescriptor { + return append(append(bchain.AddressDescriptor{}, addrDesc...), 0x02, byte(i>>8), byte(i), 0x75) + } + foreignDesc := prefixScript(0) + parsedDesc, err := d.chainParser.GetAddrDescFromVout(&bchain.Vout{ScriptPubKey: bchain.ScriptPubKey{Hex: hex.EncodeToString(foreignDesc)}}) + require.NoError(t, err) + require.Equal(t, foreignDesc, parsedDesc) + require.True(t, d.chainParser.IsAddrDescIndexable(parsedDesc)) + + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + for i := 0; i < maxAddrDescScanNonMatching; i++ { + wb.PutCF(d.cfh[cfAddresses], packAddressKey(prefixScript(i), 101), []byte{0}) + } + wb.PutCF(d.cfh[cfAddresses], packAddressKey(addrDesc, 100), matchingVal) + require.NoError(t, d.WriteBatch(wb)) + + matches := 0 + callback := func(txid string, height uint32, indexes []int32) error { + require.Equal(t, dbtestdata.TxidB1T1, txid) + require.Equal(t, uint32(100), height) + matches++ + return nil + } + require.NoError(t, d.GetTransactions(dbtestdata.Addr1, 0, ^uint32(0), callback)) + require.Equal(t, 1, matches, "a complete address remains readable at the scan bound") + + wb.Clear() + wb.PutCF(d.cfh[cfAddresses], packAddressKey(prefixScript(maxAddrDescScanNonMatching), 101), []byte{0}) + require.NoError(t, d.WriteBatch(wb)) + matches = 0 + err = d.GetTransactions(dbtestdata.Addr1, 0, ^uint32(0), callback) + require.ErrorContains(t, err, "address transaction scan limit exceeded") + require.Equal(t, 0, matches, "exhausting the bound must not return an incomplete history as success") +} diff --git a/db/sync.go b/db/sync.go index 31d321f443..b2e4671523 100644 --- a/db/sync.go +++ b/db/sync.go @@ -105,6 +105,46 @@ func ApplyMissingBlockRetryOverride(o *bchain.MissingBlockRetry) MissingBlockRet return cfg } +// Steady-state EVM parallel sync tuning. The sequential connect loop cannot +// re-evaluate the parallel gate while it runs, so on chains that outpace it the +// fall-behind check is the only way back to the parallel path. +const ( + // minParallelGap is the trigger threshold for the parallel path; below it the + // per-round pool setup costs more than sequential connect. + minParallelGap = 4 + // fallBehindCheckBlocks is how many connected blocks the sequential producer + // waits between backend tip probes. + fallBehindCheckBlocks = 100 + // fallBehindWorkerFactor and fallBehindMinYieldGap size the yield threshold + // well above minParallelGap so tip races do not thrash between the paths. + fallBehindWorkerFactor = 4 + fallBehindMinYieldGap = 64 +) + +// parallelSyncWorkerCount caps the configured pool at the number of blocks to +// connect; ParallelConnectBlocks needs exactly one write channel per worker, so +// a pool larger than the range would leave the height window non-contiguous. +func parallelSyncWorkerCount(configured int, gap uint32) uint32 { + workers := uint32(configured) + if workers > gap { + workers = gap + } + return workers +} + +// sequentialFallBehindGap returns the fall-behind yield threshold for the +// sequential loop, or 0 when the parallel path is not available. +func (w *SyncWorker) sequentialFallBehindGap() uint32 { + if w.syncWorkers <= 1 || w.chain.GetChainParser().GetChainType() != bchain.ChainEthereumType { + return 0 + } + gap := uint32(w.syncWorkers) * fallBehindWorkerFactor + if gap < fallBehindMinYieldGap { + gap = fallBehindMinYieldGap + } + return gap +} + // 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) @@ -141,6 +181,22 @@ func NewSyncWorkerWithConfig(db *RocksDB, chain bchain.BlockChain, syncWorkers, }, nil } +// Values of the `error` label on blockbook_index_resync_errors. The label used to be a +// constant "failure" at every site, which made it impossible to tell an unreachable +// backend from a failing block connect - the distinction that decides whether to look at +// the backend or at Blockbook. The set is deliberately tiny and stable so it stays safe +// to alert and group on. +const ( + // errorResync: the resync iteration itself returned an error, cause not attributed. + errorResync = "resync" + // errorGetBlockHash: the backend did not answer GetBlockHash for a height being connected. + errorGetBlockHash = "get_block_hash" + // errorGetBlock: a retryable failure fetching a block from the backend. + errorGetBlock = "get_block" + // errorConnectBlock: fetching or connecting a block failed inside a sync worker. + errorConnectBlock = "connect_block" +) + // 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") @@ -158,7 +214,7 @@ func (w *SyncWorker) updateBackendInfo() { var backendError string if err != nil { glog.Error("GetChainInfo error ", err) - backendError = errors.Annotatef(err, "GetChainInfo").Error() + backendError = common.RedactURLs(errors.Annotatef(err, "GetChainInfo").Error()) ci = &bchain.ChainInfo{} } w.is.SetBackendInfo(&common.BackendInfo{ @@ -180,7 +236,9 @@ func (w *SyncWorker) updateBackendInfo() { // 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. + // loop. A climbing blockbook_tip_age_seconds means the BACKEND's height stopped + // changing; an indexer that has stalled behind a healthy backend keeps this near zero, + // and shows up in blockbook_best_height instead (refreshed just below). w.metrics.BackendTipAgeSeconds.Set(time.Since(w.is.GetBackendTipLastAdvance()).Seconds()) } @@ -221,15 +279,28 @@ func (w *SyncWorker) ResyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b return nil } - w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + w.metrics.IndexResyncErrors.With(common.Labels{"error": errorResync}).Inc() return err } func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync bool) error { - remoteBestHash, err := w.chain.GetBestBlockHash() - if err != nil { - return err + isEthereumType := w.chain.GetChainParser().GetChainType() == bchain.ChainEthereumType + // EVM reads one snapshot of the cached tip so hash, parent and height describe the same header. + var tip *bchain.EVMTip + var remoteBestHash string + var err error + if isEthereumType { + tip, err = w.chain.EthereumTypeGetBestTip() + if err != nil { + return err + } + remoteBestHash = tip.Hash + } else { + remoteBestHash, err = w.chain.GetBestBlockHash() + if err != nil { + return err + } } localBestHeight, localBestHash, err := w.db.GetBestBlock() if err != nil { @@ -241,34 +312,42 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b 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 && !stdErrors.Is(err, bchain.ErrBlockNotFound) { - return err - } - if remoteHash != localBestHash { - // forked - the remote hash differs from the local hash at the same height - glog.Info("resync: local is forked at height ", localBestHeight, ", local hash ", localBestHash, ", remote hash ", remoteHash) - return w.handleFork(localBestHeight, localBestHash, onNewBlock, initialSync) + // A pushed tip whose parent is the local best block extends the indexed chain, so the fork + // check would only re-confirm it; a sibling that later wins is caught by the next cycle. + linked := tip != nil && tip.Height == localBestHeight+1 && tip.ParentHash == localBestHash + if !linked { + remoteHash, err := w.chain.GetBlockHash(localBestHeight) + // for some coins (eth) remote can be at lower best height after rollback + if err != nil && !stdErrors.Is(err, bchain.ErrBlockNotFound) { + return err + } + if remoteHash != localBestHash { + // forked - the remote hash differs from the local hash at the same height + glog.Info("resync: local is forked at height ", localBestHeight, ", local hash ", localBestHash, ", remote hash ", remoteHash) + return w.handleFork(localBestHeight, localBestHash, onNewBlock, initialSync) + } } w.startHeight = localBestHeight + 1 } else { // database is empty, start genesis glog.Info("resync: genesis from block ", w.startHeight) } - w.startHash, err = w.chain.GetBlockHash(w.startHeight) - if err != nil { - return err + useParallel := w.syncWorkers > 1 && (initialSync || isEthereumType) + var remoteBestHeight uint32 + if tip != nil { + remoteBestHeight = tip.Height + } else if useParallel { + // Bitcoin-type chains only pay this RPC on the parallel path, as before. + remoteBestHeight, err = w.chain.GetBestBlockHeight() + if err != nil { + return err + } } // 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 // 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 useParallel { if remoteBestHeight < w.startHeight { glog.Warning("resync: observed remote best height ", remoteBestHeight, " less than sync start height ", w.startHeight, ", falling back to sequential sync") } else { @@ -290,9 +369,13 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b return w.resyncIndex(onNewBlock, initialSync) } } - if w.chain.GetChainParser().GetChainType() == bchain.ChainEthereumType { - syncWorkers := uint32(4) - if remoteBestHeight-w.startHeight >= syncWorkers { + if isEthereumType { + // The trigger threshold is deliberately independent of the pool size: + // gating on the configured worker count would push small steady-state + // rounds onto the slower sequential path. + gap := remoteBestHeight - w.startHeight + if gap >= minParallelGap { + syncWorkers := parallelSyncWorkerCount(w.syncWorkers, gap) 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. @@ -311,6 +394,12 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b } } } + // Only the sequential path needs the start hash; the parallel and bulk paths above + // resolve every hash themselves, so looking it up earlier duplicated one RPC. + w.startHash, err = w.startHashForHeight(w.startHeight, remoteBestHash, remoteBestHeight, isEthereumType) + if err != nil { + return err + } err = w.connectBlocks(onNewBlock, initialSync) if stdErrors.Is(err, errFork) || stdErrors.Is(err, errResync) { return w.resyncIndex(onNewBlock, initialSync) @@ -318,6 +407,15 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b return err } +// startHashForHeight resolves the hash of the first block to connect. When the block is the +// cached EVM tip, its hash is already known and the eth_getBlockByNumber lookup is skipped. +func (w *SyncWorker) startHashForHeight(height uint32, tipHash string, tipHeight uint32, tipCached bool) (string, error) { + if tipCached && tipHash != "" && tipHeight == height { + return tipHash, nil + } + return w.chain.GetBlockHash(height) +} + func (w *SyncWorker) handleFork(localBestHeight uint32, localBestHash string, onNewBlock bchain.OnNewBlockFunc, initialSync bool) error { // find forked blocks, disconnect them and then synchronize again var height uint32 @@ -440,6 +538,11 @@ func (w *SyncWorker) sendHashHeight(hch chan<- hashHeight, abortCh <-chan error, } func (w *SyncWorker) shouldRestartSyncOnMissingBlock(height uint32, expectedHash string) (bool, error) { + // Without an expected hash (the ethereum-type path walks by height) a fetchable + // block is no evidence of a reorg; getBlockChain's prevHash check catches forks. + if expectedHash == "" { + return false, nil + } // 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 @@ -472,8 +575,14 @@ func (w *SyncWorker) onRetryableMiss(retries *int, threshold int, label string, 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) + // Without a hash shouldRestartSyncOnMissingBlock is a no-op; don't claim a recheck. + if hash == "" { + glog.Warningf("%s: block %d still missing after %d retries (last: %v); waiting for backend tip", + label, height, *retries, err) + } else { + 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) } @@ -528,6 +637,25 @@ func isRetryableGetBlockError(err error) bool { return cause != nil && isRetryable(cause) } +// waitWorkersOrAbort returns once every worker has exited, or as soon as a worker reports +// an abort after the hash queue closed. Without the abort branch a worker that exits on a +// tail block without producing it parks the writer and its siblings forever (#1767). +func waitWorkersOrAbort(wg *sync.WaitGroup, abortCh <-chan error, terminate func()) error { + workersDone := make(chan struct{}) + go func() { + wg.Wait() + close(workersDone) + }() + select { + case <-workersDone: + return nil + case abortErr := <-abortCh: + terminate() + <-workersDone + return abortErr + } +} + // 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 @@ -541,6 +669,8 @@ func (w *SyncWorker) ParallelConnectBlocks(onNewBlock bchain.OnNewBlockFunc, low hchClosed.Store(false) writeBlockDone := make(chan struct{}) terminating := make(chan struct{}) + // The connect loop and the worker wait can both decide to stop the round; guard the close. + terminate := sync.OnceFunc(func() { close(terminating) }) // 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. @@ -578,9 +708,6 @@ func (w *SyncWorker) ParallelConnectBlocks(onNewBlock bchain.OnNewBlockFunc, low break WriteBlockLoop } } - if err != nil { - glog.Error("sync: ParallelConnectBlocks.Close error ", err) - } glog.Info("WriteBlock exiting...") } for i := 0; i < int(syncWorkers); i++ { @@ -599,19 +726,19 @@ ConnectLoop: glog.Error("sync: parallel connect aborted, worker error ", abortErr) } err = abortErr - close(terminating) + terminate() 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) + terminate() break ConnectLoop default: hash, err = w.chain.GetBlockHash(h) if err != nil { glog.Error("GetBlockHash error ", err) - w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + w.metrics.IndexResyncErrors.With(common.Labels{"error": errorGetBlockHash}).Inc() time.Sleep(time.Millisecond * 500) continue } @@ -623,7 +750,7 @@ ConnectLoop: } else { glog.Error("sync: parallel connect aborted while queueing block hash, worker error ", err) } - close(terminating) + terminate() break ConnectLoop } h++ @@ -633,7 +760,16 @@ ConnectLoop: // 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() + if abortErr := waitWorkersOrAbort(&wg, abortCh, terminate); abortErr != nil { + if stdErrors.Is(abortErr, errResync) { + glog.Warning("sync: parallel connect aborted at tail, restarting sync") + } else { + glog.Error("sync: parallel connect aborted at tail, worker error ", abortErr) + } + if err == nil { + err = abortErr + } + } // 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. @@ -745,7 +881,7 @@ GetBlockLoop: loopStart = time.Now() glog.Error("getBlockWorker ", i, " connect block error ", err, ". Retrying...") } - w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + w.metrics.IndexResyncErrors.With(common.Labels{"error": errorConnectBlock}).Inc() select { case <-terminating: return @@ -782,6 +918,8 @@ func (w *SyncWorker) BulkConnectBlocks(lower, higher uint32) error { hchClosed.Store(false) writeBlockDone := make(chan struct{}) terminating := make(chan struct{}) + // The connect loop and the worker wait can both decide to stop the round; guard the close. + terminate := sync.OnceFunc(func() { close(terminating) }) // 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. @@ -839,19 +977,19 @@ ConnectLoop: glog.Error("sync: bulk connect aborted, worker error ", abortErr) } err = abortErr - close(terminating) + terminate() break ConnectLoop case <-w.chanOsSignal: glog.Info("BulkConnectBlocks interrupted at height ", h) err = ErrOperationInterrupted // signal all workers to terminate their loops (error loops are interrupted below) - close(terminating) + terminate() break ConnectLoop default: hash, err = w.chain.GetBlockHash(h) if err != nil { glog.Error("GetBlockHash error ", err) - w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + w.metrics.IndexResyncErrors.With(common.Labels{"error": errorGetBlockHash}).Inc() time.Sleep(time.Millisecond * 500) continue } @@ -863,7 +1001,7 @@ ConnectLoop: } else { glog.Error("sync: bulk connect aborted while queueing block hash, worker error ", err) } - close(terminating) + terminate() break ConnectLoop } if h > 0 && h%1000 == 0 { @@ -885,7 +1023,16 @@ ConnectLoop: // 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() + if abortErr := waitWorkersOrAbort(&wg, abortCh, terminate); abortErr != nil { + if stdErrors.Is(abortErr, errResync) { + glog.Warning("sync: bulk connect aborted at tail, restarting sync") + } else { + glog.Error("sync: bulk connect aborted at tail, worker error ", abortErr) + } + if err == nil { + err = abortErr + } + } // Hardening: capture a late worker error reported after the connect loop // exits so the caller can retry instead of treating sync as successful. select { @@ -913,6 +1060,8 @@ func (w *SyncWorker) getBlockChain(out chan blockResult, done chan struct{}) { hash := w.startHash height := w.startHeight prevHash := "" + fallBehindGap := w.sequentialFallBehindGap() + blocksSinceTipCheck := 0 cfg := w.missingBlockRetry retryDelay := cfg.RetryDelay if retryDelay <= 0 || retryDelay > 250*time.Millisecond { @@ -932,11 +1081,24 @@ func (w *SyncWorker) getBlockChain(out chan blockResult, done chan struct{}) { return default: } + // EVM serves the tip height from the cached feed header, so the end-of-chain check is + // free and can run before the tail probe instead of after its null response. + tipCached := w.chain.GetChainParser().GetChainType() == bchain.ChainEthereumType retries := 0 loopStart := time.Now() var block *bchain.Block var err error for { + if tipCached && hash == "" && retries == 0 { + bestHeight, bestErr := w.chain.GetBestBlockHeight() + if bestErr != nil { + out <- blockResult{err: bestErr} + return + } + if height > bestHeight { + return + } + } block, err = w.chain.GetBlock(hash, height) if err == nil { break @@ -986,7 +1148,7 @@ func (w *SyncWorker) getBlockChain(out chan blockResult, done chan struct{}) { } return } - w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + w.metrics.IndexResyncErrors.With(common.Labels{"error": errorGetBlock}).Inc() select { case <-done: return @@ -1005,6 +1167,23 @@ func (w *SyncWorker) getBlockChain(out chan blockResult, done chan struct{}) { hash = block.Next height++ out <- blockResult{block: block} + blocksSinceTipCheck++ + if fallBehindGap > 0 && blocksSinceTipCheck >= fallBehindCheckBlocks { + blocksSinceTipCheck = 0 + // A probe error is ignored: yielding is an optimization, the loop can + // keep connecting and re-probe after the next batch. + if bestHeight, bestErr := w.chain.GetBestBlockHeight(); bestErr == nil && bestHeight > height && bestHeight-height > fallBehindGap { + glog.Warningf("getBlockChain: sequential sync at %d is %d blocks behind backend tip %d; yielding to resync for parallel catch-up", + height, bestHeight-height, bestHeight) + w.metrics.IndexSyncYields.With(common.Labels{"reason": "fell_behind"}).Inc() + select { + case out <- blockResult{err: errResync}: + case <-done: + case <-w.chanOsSignal: + } + return + } + } } } diff --git a/db/sync_test.go b/db/sync_test.go index c774dcfa23..b64214f416 100644 --- a/db/sync_test.go +++ b/db/sync_test.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "strconv" + "strings" "sync" "sync/atomic" "syscall" @@ -18,6 +19,7 @@ import ( jujuErrors "github.com/juju/errors" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" "github.com/trezor/blockbook/common" ) @@ -159,17 +161,48 @@ func TestConnectBlocksHonorsClosedShutdownBeforeStart(t *testing.T) { 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 + mu sync.Mutex // parallel sync calls GetBlock from several workers at once + chainType bchain.ChainType // zero value keeps existing tests on the bitcoin-type path + bestHeight uint32 + bestHash string + tip *bchain.EVMTip // nil: snapshot is derived from bestHash/bestHeight with no parent + bestHeightErr error + bestHeightCalls int + hashes map[uint32]string + blocks map[uint32]*bchain.Block + blockErrors map[uint32][]error + getBlockCalls map[uint32]int + getBlockHashCalls map[uint32]int + getBlockHashErr error +} + +type chainTypeTestParser struct { + bchain.BlockChainParser + chainType bchain.ChainType +} + +func (p *chainTypeTestParser) GetChainType() bchain.ChainType { + return p.chainType +} + +func (c *getBlockChainTestChain) GetChainParser() bchain.BlockChainParser { + return &chainTypeTestParser{chainType: c.chainType} +} + +func (c *getBlockChainTestChain) GetBestBlockHash() (string, error) { + return c.bestHash, nil +} + +func (c *getBlockChainTestChain) EthereumTypeGetBestTip() (*bchain.EVMTip, error) { + if c.tip != nil { + return c.tip, nil + } + return &bchain.EVMTip{Hash: c.bestHash, Height: c.bestHeight}, nil } func (c *getBlockChainTestChain) GetBestBlockHeight() (uint32, error) { + c.mu.Lock() + defer c.mu.Unlock() c.bestHeightCalls++ if c.bestHeightErr != nil { return 0, c.bestHeightErr @@ -178,6 +211,11 @@ func (c *getBlockChainTestChain) GetBestBlockHeight() (uint32, error) { } func (c *getBlockChainTestChain) GetBlockHash(height uint32) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.getBlockHashCalls != nil { + c.getBlockHashCalls[height]++ + } if c.getBlockHashErr != nil { return "", c.getBlockHashErr } @@ -188,6 +226,8 @@ func (c *getBlockChainTestChain) GetBlockHash(height uint32) (string, error) { } func (c *getBlockChainTestChain) GetBlock(hash string, height uint32) (*bchain.Block, error) { + c.mu.Lock() + defer c.mu.Unlock() c.getBlockCalls[height]++ if errs := c.blockErrors[height]; len(errs) > 0 { err := errs[0] @@ -302,6 +342,304 @@ func TestGetBlockChainRetriesKnownHashAboveObservedBestHeight(t *testing.T) { } } +func TestGetBlockChainEthereumTypeSkipsTailProbe(t *testing.T) { + chain := &getBlockChainTestChain{ + chainType: bchain.ChainEthereumType, + 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 != 0 { + t.Fatalf("GetBlock height 1 calls = %d, want 0 (probe above cached tip must be skipped)", calls) + } + if chain.bestHeightCalls != 1 { + t.Fatalf("GetBestBlockHeight calls = %d, want 1", chain.bestHeightCalls) + } +} + +func TestGetBlockChainEthereumTypeRetriesKnownHashAboveObservedBestHeight(t *testing.T) { + chain := &getBlockChainTestChain{ + chainType: bchain.ChainEthereumType, + 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 || results[0].err != nil || results[0].block == nil || results[0].block.Hash != "h1" { + t.Fatalf("unexpected results: %+v", results) + } + if calls := chain.getBlockCalls[1]; calls != 2 { + t.Fatalf("GetBlock height 1 calls = %d, want 2 (known hash above tip is still retried)", calls) + } +} + +func TestGetBlockChainEthereumTypeRetriesGenuineMissBelowTip(t *testing.T) { + chain := &getBlockChainTestChain{ + chainType: bchain.ChainEthereumType, + bestHeight: 2, + hashes: map[uint32]string{1: "h1", 2: "h2"}, + blocks: map[uint32]*bchain.Block{ + 1: {BlockHeader: bchain.BlockHeader{Hash: "h1", Height: 1}}, + 2: {BlockHeader: bchain.BlockHeader{Hash: "h2", Prev: "h1", Height: 2}}, + }, + blockErrors: map[uint32][]error{ + 2: {bchain.ErrBlockNotFound}, + }, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "h1", 1) + + results := runGetBlockChain(w) + if len(results) != 2 || results[0].err != nil || results[1].err != nil { + t.Fatalf("unexpected results: %+v", results) + } + if results[1].block == nil || results[1].block.Hash != "h2" { + t.Fatalf("unexpected second block: %+v", results[1].block) + } + if calls := chain.getBlockCalls[2]; calls != 2 { + t.Fatalf("GetBlock height 2 calls = %d, want 2 (miss at or below tip keeps retrying)", calls) + } + if calls := chain.getBlockCalls[3]; calls != 0 { + t.Fatalf("GetBlock height 3 calls = %d, want 0", calls) + } +} + +func TestStartHashForHeight(t *testing.T) { + tests := []struct { + name string + tipCached bool + tipHeight uint32 + wantHash string + wantCalls int + }{ + {name: "cached tip is the start block", tipCached: true, tipHeight: 5, wantHash: "tip", wantCalls: 0}, + {name: "cached tip ahead of start block", tipCached: true, tipHeight: 6, wantHash: "h5", wantCalls: 1}, + {name: "tip not cached", tipCached: false, tipHeight: 5, wantHash: "h5", wantCalls: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chain := &getBlockChainTestChain{ + hashes: map[uint32]string{5: "h5"}, + getBlockHashCalls: map[uint32]int{}, + } + w := &SyncWorker{chain: chain} + got, err := w.startHashForHeight(5, "tip", tt.tipHeight, tt.tipCached) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.wantHash { + t.Fatalf("startHash = %q, want %q", got, tt.wantHash) + } + if calls := chain.getBlockHashCalls[5]; calls != tt.wantCalls { + t.Fatalf("GetBlockHash calls = %d, want %d", calls, tt.wantCalls) + } + }) + } +} + +// evmTestHash builds a valid 32-byte hex block hash from a one-byte pattern. +func evmTestHash(b string) string { + return "0x" + strings.Repeat(b, 32) +} + +func evmTestBlock(hash, prev string, height uint32) *bchain.Block { + return &bchain.Block{BlockHeader: bchain.BlockHeader{Hash: hash, Prev: prev, Height: height, Time: int64(height)}} +} + +func newResyncTestChain(tip *bchain.EVMTip, blocks ...*bchain.Block) *getBlockChainTestChain { + c := &getBlockChainTestChain{ + chainType: bchain.ChainEthereumType, + tip: tip, + hashes: map[uint32]string{}, + blocks: map[uint32]*bchain.Block{}, + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + getBlockHashCalls: map[uint32]int{}, + } + for _, b := range blocks { + c.hashes[b.Height] = b.Hash + c.blocks[b.Height] = b + if b.Height > c.bestHeight { + c.bestHeight, c.bestHash = b.Height, b.Hash + } + } + return c +} + +func newResyncTestWorker(t *testing.T, d *RocksDB, chain *getBlockChainTestChain) *SyncWorker { + return &SyncWorker{ + db: d, + chain: chain, + syncWorkers: 1, + chanOsSignal: make(chan os.Signal), + metrics: getTestMetrics(t), + missingBlockRetry: MissingBlockRetryConfig{ + TipRecheckThreshold: 2, + RetryDelay: time.Millisecond, + }, + } +} + +func assertBestBlock(t *testing.T, d *RocksDB, wantHeight uint32, wantHash string) { + t.Helper() + height, hash, err := d.GetBestBlock() + if err != nil { + t.Fatalf("GetBestBlock: %v", err) + } + if height != wantHeight || hash != wantHash { + t.Fatalf("best block = %d %s, want %d %s", height, hash, wantHeight, wantHash) + } +} + +func assertCalls(t *testing.T, what string, calls map[uint32]int, height uint32, want int) { + t.Helper() + if got := calls[height]; got != want { + t.Fatalf("%s(%d) calls = %d, want %d", what, height, got, want) + } +} + +// One steady-state EVM cycle with a tip snapshot that carries no parent hash: the fork check +// is the only header lookup, the tip hash comes from the cache and the tail probe is not sent. +func TestResyncIndexEthereumTypeUsesCachedTipHash(t *testing.T) { + d := setupRocksDB(t, eth.NewEthereumParser(1, false)) + defer closeAndDestroyRocksDB(t, d) + + h1, h2 := evmTestHash("11"), evmTestHash("22") + block1, block2 := evmTestBlock(h1, "", 1), evmTestBlock(h2, h1, 2) + if err := d.ConnectBlock(block1); err != nil { + t.Fatalf("ConnectBlock: %v", err) + } + chain := newResyncTestChain(nil, block1, block2) + w := newResyncTestWorker(t, d, chain) + + if err := w.resyncIndex(nil, false); err != nil { + t.Fatalf("resyncIndex: %v", err) + } + assertBestBlock(t, d, 2, h2) + assertCalls(t, "GetBlockHash", chain.getBlockHashCalls, 1, 1) // fork check kept without parent linkage + assertCalls(t, "GetBlockHash", chain.getBlockHashCalls, 2, 0) // tip hash from the cached header + assertCalls(t, "GetBlock", chain.getBlockCalls, 2, 1) + assertCalls(t, "GetBlock", chain.getBlockCalls, 3, 0) // tail probe skipped +} + +// The pushed tip links to the local best block: no header lookup at all, only the block fetch. +func TestResyncIndexEthereumTypeLinkedTipSkipsForkCheck(t *testing.T) { + d := setupRocksDB(t, eth.NewEthereumParser(1, false)) + defer closeAndDestroyRocksDB(t, d) + + h1, h2 := evmTestHash("11"), evmTestHash("22") + block1, block2 := evmTestBlock(h1, "", 1), evmTestBlock(h2, h1, 2) + if err := d.ConnectBlock(block1); err != nil { + t.Fatalf("ConnectBlock: %v", err) + } + chain := newResyncTestChain(&bchain.EVMTip{Hash: h2, ParentHash: h1, Height: 2}, block1, block2) + w := newResyncTestWorker(t, d, chain) + + if err := w.resyncIndex(nil, false); err != nil { + t.Fatalf("resyncIndex: %v", err) + } + assertBestBlock(t, d, 2, h2) + assertCalls(t, "GetBlockHash", chain.getBlockHashCalls, 1, 0) + assertCalls(t, "GetBlockHash", chain.getBlockHashCalls, 2, 0) + assertCalls(t, "GetBlock", chain.getBlockCalls, 2, 1) + assertCalls(t, "GetBlock", chain.getBlockCalls, 3, 0) +} + +// A tip more than one block ahead cannot prove linkage, so both header lookups stay. +func TestResyncIndexEthereumTypeGapAboveOneKeepsForkCheck(t *testing.T) { + d := setupRocksDB(t, eth.NewEthereumParser(1, false)) + defer closeAndDestroyRocksDB(t, d) + + h1, h2, h3 := evmTestHash("11"), evmTestHash("22"), evmTestHash("33") + block1, block2, block3 := evmTestBlock(h1, "", 1), evmTestBlock(h2, h1, 2), evmTestBlock(h3, h2, 3) + if err := d.ConnectBlock(block1); err != nil { + t.Fatalf("ConnectBlock: %v", err) + } + chain := newResyncTestChain(&bchain.EVMTip{Hash: h3, ParentHash: h2, Height: 3}, block1, block2, block3) + w := newResyncTestWorker(t, d, chain) + + if err := w.resyncIndex(nil, false); err != nil { + t.Fatalf("resyncIndex: %v", err) + } + assertBestBlock(t, d, 3, h3) + assertCalls(t, "GetBlockHash", chain.getBlockHashCalls, 1, 1) + assertCalls(t, "GetBlockHash", chain.getBlockHashCalls, 2, 1) + assertCalls(t, "GetBlock", chain.getBlockCalls, 2, 1) + assertCalls(t, "GetBlock", chain.getBlockCalls, 3, 1) + assertCalls(t, "GetBlock", chain.getBlockCalls, 4, 0) +} + +// A gap of four or more takes the parallel path, which resolves every block hash itself, +// so resyncIndex must not look the first one up beforehand. +func TestResyncIndexEthereumTypeParallelPathResolvesStartHashOnce(t *testing.T) { + d := setupRocksDB(t, eth.NewEthereumParser(1, false)) + defer closeAndDestroyRocksDB(t, d) + + hashes := []string{"", evmTestHash("11"), evmTestHash("22"), evmTestHash("33"), evmTestHash("44"), evmTestHash("55"), evmTestHash("66")} + blocks := make([]*bchain.Block, 0, 6) + for h := uint32(1); h <= 6; h++ { + blocks = append(blocks, evmTestBlock(hashes[h], hashes[h-1], h)) + } + if err := d.ConnectBlock(blocks[0]); err != nil { + t.Fatalf("ConnectBlock: %v", err) + } + chain := newResyncTestChain(&bchain.EVMTip{Hash: hashes[6], ParentHash: hashes[5], Height: 6}, blocks...) + w := newResyncTestWorker(t, d, chain) + w.syncWorkers = 2 + + // After the parallel connect resyncIndex re-runs and reports the tip as reached. + if err := w.resyncIndex(nil, false); !stdErrors.Is(err, syncNotNeeded) { + t.Fatalf("resyncIndex error = %v, want %v", err, syncNotNeeded) + } + assertBestBlock(t, d, 6, hashes[6]) + for h := uint32(2); h <= 6; h++ { + assertCalls(t, "GetBlockHash", chain.getBlockHashCalls, h, 1) + assertCalls(t, "GetBlock", chain.getBlockCalls, h, 1) + } +} + +// The tip is one block ahead but its parent is not our best block: the fork check runs, +// the orphaned local block is disconnected and the canonical branch is connected. +func TestResyncIndexEthereumTypeParentMismatchHandlesFork(t *testing.T) { + d := setupRocksDB(t, eth.NewEthereumParser(1, false)) + defer closeAndDestroyRocksDB(t, d) + + h1, h2, h2b, h3b := evmTestHash("11"), evmTestHash("22"), evmTestHash("bb"), evmTestHash("cc") + block1, block2 := evmTestBlock(h1, "", 1), evmTestBlock(h2, h1, 2) + block2b, block3b := evmTestBlock(h2b, h1, 2), evmTestBlock(h3b, h2b, 3) + for _, b := range []*bchain.Block{block1, block2} { + if err := d.ConnectBlock(b); err != nil { + t.Fatalf("ConnectBlock: %v", err) + } + } + chain := newResyncTestChain(&bchain.EVMTip{Hash: h3b, ParentHash: h2b, Height: 3}, block1, block2b, block3b) + w := newResyncTestWorker(t, d, chain) + + if err := w.resyncIndex(nil, false); err != nil { + t.Fatalf("resyncIndex: %v", err) + } + assertBestBlock(t, d, 3, h3b) + if calls := chain.getBlockHashCalls[2]; calls == 0 { + t.Fatal("GetBlockHash(2) was not called: fork check must run when the parent does not link") + } +} + func TestGetBlockChainMissingBlockChangedHashResyncs(t *testing.T) { chain := &getBlockChainTestChain{ bestHeight: 1, @@ -356,6 +694,56 @@ func TestShouldRestartSyncOnMissingBlockIgnoresMissingHashProbe(t *testing.T) { } } +func TestShouldRestartSyncOnMissingBlockIgnoresEmptyExpectedHash(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 10, + hashes: map[uint32]string{10: "h10"}, + } + w := newGetBlockChainTestWorker(t, chain, "", 10) + + restart, err := w.shouldRestartSyncOnMissingBlock(10, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if restart { + t.Fatal("restart = true, want false when there is no expected hash to compare") + } + if chain.bestHeightCalls != 0 { + t.Fatalf("GetBestBlockHeight calls = %d, want 0: the guard must short-circuit before probing", chain.bestHeightCalls) + } +} + +// Reproduces the Avalanche tip race: the height is fetchable a moment later, which +// must be retried rather than mistaken for a reorg just because Next is unset. +func TestGetBlockChainByHeightMissDoesNotResync(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, "", 1) + + results := runGetBlockChain(w) + if len(results) != 1 { + t.Fatalf("got %d results, want 1: %+v", len(results), 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 TestGetBlockChainNonRetryableErrorReturns(t *testing.T) { boom := stdErrors.New("boom") chain := &getBlockChainTestChain{ @@ -560,3 +948,219 @@ func TestNewSyncWorkerClampsMaxStallDuration(t *testing.T) { }) } } + +func TestParallelSyncWorkerCount(t *testing.T) { + tests := []struct { + name string + configured int + gap uint32 + want uint32 + }{ + {name: "large gap uses configured pool", configured: 16, gap: 140000, want: 16}, + {name: "pool capped by gap", configured: 16, gap: 4, want: 4}, + {name: "configured below gap", configured: 8, gap: 100, want: 8}, + {name: "minimum configured pool", configured: 2, gap: 4, want: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parallelSyncWorkerCount(tt.configured, tt.gap); got != tt.want { + t.Fatalf("parallelSyncWorkerCount(%d, %d) = %d, want %d", tt.configured, tt.gap, got, tt.want) + } + }) + } +} + +func makeSequentialTestBlocks(n uint32) map[uint32]*bchain.Block { + blocks := make(map[uint32]*bchain.Block, n) + for h := uint32(1); h <= n; h++ { + blocks[h] = &bchain.Block{BlockHeader: bchain.BlockHeader{Hash: "h" + strconv.Itoa(int(h)), Height: h}} + } + return blocks +} + +func TestGetBlockChainYieldsWhenFallingBehind(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 100000, + chainType: bchain.ChainEthereumType, + hashes: map[uint32]string{}, + blocks: makeSequentialTestBlocks(fallBehindCheckBlocks), + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "h1", 1) + w.syncWorkers = 16 + + results := runGetBlockChain(w) + if len(results) != fallBehindCheckBlocks+1 { + t.Fatalf("got %d results, want %d", len(results), fallBehindCheckBlocks+1) + } + for i := 0; i < fallBehindCheckBlocks; i++ { + if results[i].err != nil { + t.Fatalf("result %d error = %v, want block", i, results[i].err) + } + } + if !stdErrors.Is(results[fallBehindCheckBlocks].err, errResync) { + t.Fatalf("last error = %v, want errResync", results[fallBehindCheckBlocks].err) + } + // EVM blocks carry no Next, so the tail guard reads the cached tip before every fetch + // after the first one; the fall-behind probe adds exactly one more read. + if want := fallBehindCheckBlocks - 1 + 1; chain.bestHeightCalls != want { + t.Fatalf("GetBestBlockHeight calls = %d, want %d", chain.bestHeightCalls, want) + } +} + +func TestGetBlockChainNoYieldNearTip(t *testing.T) { + const tip = fallBehindCheckBlocks + 50 + chain := &getBlockChainTestChain{ + bestHeight: tip, + chainType: bchain.ChainEthereumType, + hashes: map[uint32]string{}, + blocks: makeSequentialTestBlocks(tip), + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "h1", 1) + w.syncWorkers = 16 + + results := runGetBlockChain(w) + if len(results) != tip { + t.Fatalf("got %d results, want %d", len(results), tip) + } + for i, res := range results { + if res.err != nil { + t.Fatalf("result %d error = %v, want block", i, res.err) + } + } +} + +func TestGetBlockChainFallBehindYieldGating(t *testing.T) { + tests := []struct { + name string + syncWorkers int + chainType bchain.ChainType + }{ + {name: "single worker never yields", syncWorkers: 1, chainType: bchain.ChainEthereumType}, + {name: "non-EVM chain never yields", syncWorkers: 16, chainType: bchain.ChainBitcoinType}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + const available = fallBehindCheckBlocks + 20 + endErr := stdErrors.New("boom") + chain := &getBlockChainTestChain{ + bestHeight: 100000, + chainType: tt.chainType, + hashes: map[uint32]string{}, + blocks: makeSequentialTestBlocks(available), + blockErrors: map[uint32][]error{ + available + 1: {endErr}, + }, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "h1", 1) + w.syncWorkers = tt.syncWorkers + + results := runGetBlockChain(w) + if len(results) != available+1 { + t.Fatalf("got %d results, want %d", len(results), available+1) + } + for i := 0; i < available; i++ { + if results[i].err != nil { + t.Fatalf("result %d error = %v, want block", i, results[i].err) + } + } + if !stdErrors.Is(results[available].err, endErr) { + t.Fatalf("last error = %v, want %v", results[available].err, endErr) + } + }) + } +} + +// parallelTailTestChain serves a fixed range and fails one height forever. Unlike +// getBlockChainTestChain it is safe for concurrent getBlockWorkers. +type parallelTailTestChain struct { + bchain.BlockChain + mu sync.Mutex + failHeight uint32 + failErr error + calls map[uint32]int +} + +func (c *parallelTailTestChain) GetChainParser() bchain.BlockChainParser { + return &chainTypeTestParser{chainType: bchain.ChainEthereumType} +} + +func (c *parallelTailTestChain) GetBlockHash(height uint32) (string, error) { + return "h" + strconv.Itoa(int(height)), nil +} + +func (c *parallelTailTestChain) GetBlock(hash string, height uint32) (*bchain.Block, error) { + c.mu.Lock() + c.calls[height]++ + c.mu.Unlock() + if height == c.failHeight { + return nil, c.failErr + } + return &bchain.Block{BlockHeader: bchain.BlockHeader{Hash: hash, Height: height}}, nil +} + +// Regression for #1767: a worker that exits on a tail block without producing it must not +// leave the coordinator waiting forever on siblings parked behind the missing height. +func TestParallelConnectBlocksReturnsWhenTailWorkerExitsWithoutBlock(t *testing.T) { + wantErr := stdErrors.New("rpc -32000: logs unavailable") + chain := ¶llelTailTestChain{failHeight: 1, failErr: wantErr, calls: map[uint32]int{}} + w := &SyncWorker{ + chain: chain, + missingBlockRetry: MissingBlockRetryConfig{ + TipRecheckThreshold: 2, + RetryDelay: time.Millisecond, + }, + metrics: getTestMetrics(t), + } + done := make(chan error, 1) + go func() { done <- w.ParallelConnectBlocks(nil, 1, 4, 4) }() + select { + case err := <-done: + if !stdErrors.Is(err, wantErr) { + t.Fatalf("ParallelConnectBlocks error = %v, want %v", err, wantErr) + } + case <-time.After(5 * time.Second): + t.Fatal("ParallelConnectBlocks did not return: coordinator wedged on a tail worker exit") + } +} + +func TestWaitWorkersOrAbort(t *testing.T) { + t.Run("workers finish without abort", func(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + go wg.Done() + terminated := false + if err := waitWorkersOrAbort(&wg, make(chan error, 1), func() { terminated = true }); err != nil { + t.Fatalf("error = %v, want nil", err) + } + if terminated { + t.Fatal("terminate called although all workers exited cleanly") + } + }) + t.Run("abort unparks a blocked worker", func(t *testing.T) { + var wg sync.WaitGroup + terminating := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + <-terminating + }() + abortCh := make(chan error, 1) + wantErr := stdErrors.New("worker failed") + abortCh <- wantErr + done := make(chan error, 1) + go func() { done <- waitWorkersOrAbort(&wg, abortCh, func() { close(terminating) }) }() + select { + case err := <-done: + if !stdErrors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + case <-time.After(5 * time.Second): + t.Fatal("waitWorkersOrAbort did not return after abort") + } + }) +} diff --git a/db/txcache.go b/db/txcache.go index bcc89bc254..26f0da8391 100644 --- a/db/txcache.go +++ b/db/txcache.go @@ -47,7 +47,13 @@ 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() - tx.Confirmations = bestheight - h + 1 + // Ethereum-type txs are cached at their chain height, which can be above the + // indexed height; report the mined minimum instead of underflowing. + if bestheight < h { + tx.Confirmations = 1 + } else { + tx.Confirmations = bestheight - h + 1 + } c.metrics.TxCacheEfficiency.With(common.Labels{"status": "hit"}).Inc() return tx, int(h), nil } diff --git a/db/txcache_test.go b/db/txcache_test.go new file mode 100644 index 0000000000..0b72967888 --- /dev/null +++ b/db/txcache_test.go @@ -0,0 +1,75 @@ +//go:build unittest + +package db + +import ( + "testing" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" +) + +// confirmationsStubParser packs just enough to round-trip a txid at a fixed height, so the +// test does not depend on a complete Ethereum transaction fixture. +type confirmationsStubParser struct { + *eth.EthereumParser + height uint32 +} + +func (p *confirmationsStubParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ([]byte, error) { + return []byte(tx.Txid), nil +} + +func (p *confirmationsStubParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { + return &bchain.Tx{Txid: string(buf)}, p.height, nil +} + +// Ethereum-type transactions are cached at their chain height, which can be above the +// height Blockbook has indexed. Recomputing confirmations on a cache hit must then not +// underflow the unsigned subtraction into a bogus finality count. +func TestTxCacheGetTransaction_HeightAboveIndexedBestHeight(t *testing.T) { + const txHeight = 18000010 + const txid = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + + parser := &confirmationsStubParser{EthereumParser: eth.NewEthereumParser(1, true), height: txHeight} + d := setupRocksDB(t, parser) + defer closeAndDestroyRocksDB(t, d) + + metrics, err := common.GetMetrics("coin-unittest-txcache") + if err != nil { + t.Fatal(err) + } + c := &TxCache{db: d, metrics: metrics, is: d.is, enabled: true, chainType: bchain.ChainEthereumType} + + if err := d.PutTx(&bchain.Tx{Txid: txid}, txHeight, 1600000000); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + bestHeight uint32 + want uint32 + }{ + {"indexed height above tx", txHeight + 4, 5}, + {"indexed height at tx", txHeight, 1}, + {"indexed height behind by 1 block", txHeight - 1, 1}, + {"indexed height behind by many blocks", txHeight - 300, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d.is.BestHeight = tt.bestHeight + tx, h, err := c.GetTransaction(txid) + if err != nil { + t.Fatalf("GetTransaction error %v", err) + } + if h != txHeight { + t.Errorf("GetTransaction height = %d, want %d", h, txHeight) + } + if tx.Confirmations != tt.want { + t.Errorf("Confirmations with best height %d and tx height %d = %d, want %d", + tt.bestHeight, txHeight, tx.Confirmations, tt.want) + } + }) + } +} diff --git a/docs/README.md b/docs/README.md index ac437e984d..53c583fcc4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,4 +11,7 @@ * [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 +* [ERC protocols](/docs/erc-protocols.md) – Protocol-level EVM contract enrichments and how to add a new one +* [EVM send](/docs/evm-send.md) – EVM transaction broadcast through the private send-tx relay and its pending-transaction cache +* [EVM pending-transaction stores](/docs/evm-send-mempools.md) – Map of the two EVM pending stores: the Blockbook mempool and the private/MEV cache * [Testing](/docs/testing.md) – Description of tests used during Blockbook development diff --git a/docs/api-tron.md b/docs/api-tron.md index e206615eb7..9a8cfa9a30 100644 --- a/docs/api-tron.md +++ b/docs/api-tron.md @@ -101,3 +101,4 @@ Resource fields: - `unclaimedReward` (`string`): unclaimed voting reward (sun) - `delegatedBalanceEnergy` (`string`): delegated staked energy in sun - `delegatedBalanceBandwidth` (`string`): delegated staked bandwidth in sun +- `latestWithdrawTime` (`number`): Unix timestamp in **seconds** of the account's last voting-reward withdrawal, or `0` if it has never withdrawn. diff --git a/docs/build.md b/docs/build.md index 0d4a7e50bf..48579cdc37 100644 --- a/docs/build.md +++ b/docs/build.md @@ -86,13 +86,22 @@ 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`. +`JEMALLOC`: The Blockbook binary links [jemalloc](https://jemalloc.net) statically as the C heap allocator that RocksDB allocates from. glibc malloc keeps freed multi-megabyte RocksDB buffers (record values, memtable blocks, write batches) resident inside fragmented arena heaps, and on busy EVM chains that retained memory grew by gigabytes per hour; jemalloc returns such buffers to the kernel. Nothing has to be installed on the target server. Use `make JEMALLOC=0 all-bitcoin` to build against glibc malloc instead. The allocator can be tuned at runtime through the `MALLOC_CONF` environment variable (see [env.md](/docs/env.md)). + `PORTABLE`: By default, the RocksDB binaries shipped with Blockbook are optimized for the platform you're compiling on (-march=native or the equivalent). If you want to build a portable binary, use `make PORTABLE=1 all-bitcoin`. -`BB_BUILD_ENV`: Selects which RPC URL override family is active during package/config generation. Defaults to `dev`. -Accepted values are `dev` and `prod`. +`BB_BUILD_ENV`: Selects which environment the package is generated for. Defaults to `dev`. Accepted values are `dev` +and `prod`. It selects the active RPC URL override family, and in a `dev` build it also merges each coin's +`blockbook.block_chain.additional_params_dev` block over `additional_params` (see +[config guide](/docs/config.md)) so a dev instance can spend fewer requests on a paid fee or fiat-rates +provider. Generated dev Blockbook services include `-prof=:` automatically, while generated prod services do not include `-prof`. +**Because the default is `dev`, a package intended for production must be built with `BB_BUILD_ENV=prod`.** The +`Build / Deploy` workflow already does this: `mode=build` passes its `env` input through, and `mode=deploy` (dev-only) +pins `dev`. + `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. @@ -236,7 +245,7 @@ make command to create a portable binary. ``` sudo apt-get update && sudo apt-get install -y \ - build-essential git wget pkg-config libzmq3-dev libgflags-dev libsnappy-dev zlib1g-dev libzstd-dev libbz2-dev liblz4-dev + build-essential git wget pkg-config libzmq3-dev libgflags-dev libsnappy-dev zlib1g-dev libzstd-dev libbz2-dev liblz4-dev libjemalloc-dev git clone https://github.com/facebook/rocksdb.git cd rocksdb git checkout v9.10.0 @@ -247,9 +256,11 @@ 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 -lzstd" +export CGO_LDFLAGS="-L/path/to/rocksdb -lrocksdb -lstdc++ -lm -lz -ldl -lbz2 -lsnappy -llz4 -lzstd $(gcc -print-file-name=libjemalloc_pic.a)" ``` +The last argument links jemalloc statically as the C heap allocator (see `JEMALLOC` above); leave it out to use glibc malloc. + Install ZeroMQ: https://github.com/zeromq/libzmq ``` diff --git a/docs/ci_cd.md b/docs/ci_cd.md index 0baf5b00e0..e7de2ba381 100644 --- a/docs/ci_cd.md +++ b/docs/ci_cd.md @@ -49,7 +49,9 @@ Inputs: - `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` + - selected value is exported downstream as `BB_BUILD_ENV`, which also decides whether each coin's + `blockbook.block_chain.additional_params_dev` overrides are merged into the generated `blockchaincfg.json` + (`dev` merges them, `prod` ignores them) — see [config.md](config.md) - ignored when `mode=deploy` - `backend_mode`: - `auto` derives backend builds per coin from the selected `BB_{DEV|PROD}_RPC_URL_HTTP_` value @@ -64,7 +66,8 @@ Inputs: 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`. +Deploy and test-related workflow steps use `BB_BUILD_ENV=dev`, so a deployed instance and the integration/e2e +suites both get the dev `additional_params` overrides. Generated dev Blockbook services start with pprof enabled on `:`, for example Ethereum uses `:29036`. Generated prod services do not include `-prof`. @@ -84,6 +87,8 @@ Special cases: - `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` +- a coin listed in the `BB_STAGING` repository variable is skipped where its `configs/coins/.json` is absent, so a work-in-progress coin's repo-wide `BB_*` variables don't fail builds/deploys on branches that lack its config yet +- tolerance is opt-in, so a mistyped or abandoned `BB_STAGING` entry (no config on any branch) is silently skipped rather than erroring — the skip is logged, and entries should be removed once the coin merges ## Naming Matrix diff --git a/docs/config.md b/docs/config.md index 9a8fe89717..851db8dad2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,6 +51,11 @@ Good examples of coin configuration are `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. + * `message_queue_curve` – Optional ZMQ CURVE credentials. The object has three Z85 keys: `server_key` is the + publisher's public key (required to enable CURVE); `public_key` and `secret_key` are Blockbook's client keypair + (omit either and Blockbook generates a fresh pair at connect time). Bitcoin Core itself does not publish + CurveZMQ — stock `bitcoind` `zmqpub*` sockets are unauthenticated — so this is meant for a CurveZMQ proxy + or a patched backend in front of Blockbook, not for talking to Core directly. * `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). @@ -101,14 +106,19 @@ Good examples of coin configuration are * `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. + * Alternative fee provider configuration (BitcoinType): + * `alternative_estimate_fee` – Set to `mempoolspaceprecise` (mempool.space [recommended fees with sub-sat/vB precision](https://mempool.space/docs/api/rest#get-recommended-fees-precise); params `url`, `periodSeconds`), `mempoolspace` (mempool.space recommended fees; params `url`, `periodSeconds`), `mempoolspaceblock` (mempool.space projected mempool blocks; params `url`, `periodSeconds`, optional `feeRangeIndex` 0-6 percentile index instead of median, optional `fallbackFeePerKB` for targets beyond the projected blocks) or `whatthefee` (whatthefee.io) to serve provider fees instead of node `estimatesmartfee`. On provider error or stale data (>10 minutes), estimates fall back to the node. + * `alternative_estimate_fee_params` – JSON string with the provider params, e.g. `"{\"url\": \"https://mempool.space/api/v1/fees/precise\", \"periodSeconds\": 20}"`. + * Alternative EIP-1559 fee provider configuration: + * `alternative_estimate_fee` – Set to `infura` (Infura Gas API, requires `INFURA_API_KEY`) or `1inch` (1inch gas-price API, requires `ONE_INCH_API_KEY`) to use provider fee suggestions instead of native node fee estimation. Startup fails fast if the selected provider's API-key env var is missing. + * `alternative_estimate_fee_params` – JSON string with `url`, `periodSeconds` and optional `staleSeconds`. Fees are fetched on demand: the provider is contacted only when a client requests an estimate, concurrent requests are coalesced into a single upstream call, and the result is cached for `periodSeconds` (the cache TTL, so upstream traffic is capped at one request per `periodSeconds`). + `staleSeconds` is how long the last successfully fetched fees stay usable before native fallback (served when a fetch fails), independent of the cache TTL; it defaults to 600 (10 minutes) when omitted and is clamped up to `periodSeconds` so the window is never shorter than the TTL. Note for monitoring: `blockbook_alternative_fee_provider_last_sync_timestamp_seconds` only advances on demand, so its age legitimately grows on a quiet instance — correlate with `blockbook_alternative_fee_provider_cache` before alerting. * 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. + * `mempoolTxTimeoutHours` – Legacy Blockbook-side EVM mempool retention in whole hours. It is used when `mempoolTxTimeout` is not set and the alternative pending-tx cache is not enabled (no `*_ALTERNATIVE_FETCH_MEMPOOL_TX` — a URLS-only relay deployment keeps this legacy value). + * `mempoolTxTimeout` – Optional Blockbook-side EVM mempool retention as a Go duration string such as `"10m"`; `"0s"` preserves the legacy zero-retention setting, but note it inverts the ordering below and so fails startup on a coin with the pending-tx cache enabled. With the cache enabled (`*_ALTERNATIVE_FETCH_MEMPOOL_TX`) it defaults to `alternativePendingTxWindow` plus a **30 minute** margin instead of the legacy hour-based value, and must stay at or above the alternative cache retention — Blockbook refuses to start otherwise, because the mempool sweep would drop a private transaction's address index while the provider cache still serves it as pending. A relay used for broadcast only (URLS set, cache off) has no such coupling and keeps the legacy behavior. + * `alternativePendingTxWindow` – How long a transaction broadcast through the alternative (private relay) provider is treated as still pending, as a positive Go duration string. Defaults to **3 hours** — the window in which a private relay's transaction can still be built into a block, and (since the Blinklabs alignment) also the window over which the relay answers `eth_getTransactionByHash` and counts the transaction at the pending `eth_getTransactionCount` tag. It drives the alternative cache retention and, through it, `mempoolTxTimeout` above. + * `alternativeMempoolTxTimeout` – Optional alternative-provider transaction cache retention as a positive Go duration string. Defaults to `alternativePendingTxWindow`; override only to hold private transactions for a *different* period than the one in which they can still land. The coin configs set it equal to their explicit `mempoolTxTimeout`, the longest value the ordering above permits, so a private transaction can be served as pending for as long as its index survives — provided the relay keeps surfacing it past its advertised window. Otherwise the missing-run eviction (`alternativeMissingTxTimeout` below) takes it at roughly that window, and the full retention is only realized after raising that timeout per the guidance below. + * `alternativeMissingTxTimeout` – How long a cached private transaction may stay missing from the relay's `eth_getTransactionByHash` (consecutive null answers) before Blockbook treats it as dropped or cancelled and evicts it, as a positive Go duration string. Defaults to **2 minutes** — the relay answers from one consistent store, so a null means the transaction is gone, and the tolerance only absorbs a transient relay fluke. Set it at or above the cache retention (`alternativeMempoolTxTimeout`, itself defaulting to `alternativePendingTxWindow`) for a relay that does not surface accepted transactions for as long as they stay cached — that restores eviction by cache timeout only. * 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**). @@ -119,6 +129,67 @@ Good examples of coin configuration are * `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**). + * `xpubConfig` – xpub/descriptor expansion and cache tuning (BitcoinType only). Each field is optional; a missing or `<= 0` value keeps the built-in default, and a negative value is logged as invalid and ignored. When an override is present, Blockbook logs one `xpub: xpubConfig override applied: …` line at startup so you can confirm the effective values. + + | Field | Default | Semantic | + | --- | --- | --- | + | `maxCacheEntries` | **5000** | Maximum number of xpubs kept in the in-memory derivation cache before the oldest entries are evicted. Raising it reduces re-derivation on busy instances but increases resident memory, since each entry holds the xpub's derived addresses and their tx lists — size the host accordingly. | + | `maxCacheBytes` | **1073741824** (1 GiB) | Cap on the estimated memory held by the xpub cache; least recently used entries are evicted once it is exceeded. Bounds memory where `maxCacheEntries` cannot, since an entry holds the xpub's whole txid history (~100 MB for an account with a million transactions). | + | `maxCacheExpirationSeconds` | **3600** | Time after last access before a cached xpub entry is dropped. | + | `defaultAddressesGap` | **20** | Gap limit used when a request does not specify one. Clamped down to `maxAddressesGap` if set higher. | + | `maxAddressesGap` | **10000** | Upper bound on the caller-supplied gap limit, protecting against unreasonably large scans. | + + Example override in `configs/coins/*.json`: + + ```json + "additional_params": { + "xpubConfig": { + "maxCacheEntries": 2048 + } + } + ``` + * `additional_params_dev` – Overrides merged over `additional_params` **only** when the package is generated + with `BB_BUILD_ENV=dev` (see [environment variables](/docs/env.md#build-time-variables)). This is how a dev + instance runs a different value than production for a setting that costs money per request — above all the + `periodSeconds` of a paid fee or fiat-rates provider. A `BB_BUILD_ENV=prod` build ignores the block entirely, + so `additional_params` is always the production truth and a value tuned for dev cannot reach production. + + The merge is sparse, so an override retunes one field and inherits the rest — the provider `url` is never + duplicated and so cannot drift from the production value: + + | `additional_params` value | `additional_params_dev` value | Result | + | --- | --- | --- | + | JSON object | JSON object | merged field by field, recursively | + | JSON object encoded in a string (the `*_params` settings) | JSON object, or a string encoding one | the base object is decoded, merged, and re-encoded **as a string**, so the setting keeps the form the runtime expects | + | anything else | anything | the override replaces the value outright | + + A setting named in `additional_params_dev` must already be declared in `additional_params`, or package + generation fails — that catches a typo, and it keeps `additional_params` a complete inventory of every + setting with its production value. The check runs for a `prod` build too, even though the merge does not. + To turn a knob on only in dev, declare it with its production value in `additional_params` and override it. + Fields *inside* one setting's parameters may be added freely (for example a dev-only `staleSeconds`). + + ```json + "additional_params": { + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees\", \"periodSeconds\": 10}", + "fiat_rates_params": "{\"coin\": \"ethereum\", \"periodSeconds\": 900}" + }, + "additional_params_dev": { + "alternative_estimate_fee_params": { + "periodSeconds": 300 + }, + "fiat_rates_params": { + "periodSeconds": 3600 + } + } + ``` + + Note that re-encoding normalizes whitespace and sorts the keys inside a string-encoded setting, so the dev + render of such a value is not textually similar to the production one. Every consumer unmarshals it, so only + the rendered text differs. Two settings must not be overridden: `block_filter_scripts` and + `block_golomb_filter_p` are read straight from `additional_params` by the OpenAPI e2e suite + (`tests/openapi/src/config.ts`), which knows nothing about the overlay. * `meta` – Common package metadata. * `package_maintainer` – Full name of package maintainer. diff --git a/docs/env.md b/docs/env.md index d8b8ccb3d0..8495bfdc88 100644 --- a/docs/env.md +++ b/docs/env.md @@ -4,9 +4,11 @@ Some behavior of Blockbook can be modified by environment variables. The variabl 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. +The packaged binary uses jemalloc as the C heap allocator for RocksDB (see [build.md](/docs/build.md)). The packaged unit sets `MALLOC_CONF=narenas:8,background_thread:true`; a `MALLOC_CONF` in `/etc/blockbook/blockbook.env` replaces that whole value, so repeat those options when adding others, for example `MALLOC_CONF=narenas:8,background_thread:true,dirty_decay_ms:5000`. glibc's `MALLOC_*` variables have no effect on it. + - `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_GETACCOUNTINFO_LIMIT` - Limits the number of `getAccountInfo` requests per websocket connection to reduce server abuse. Accepts number as input. Defaults to `42` (matching the Trezor Suite client concurrency limit). - `_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. @@ -43,17 +45,25 @@ Blockbook reads these from its process environment. When installed from the Debi - `_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_MAX_CONNECTIONS_PER_IP` - Maximum number of WebSocket connections a single client key (an IPv4 address, or an IPv6 `/64`) may hold open concurrently; a new connection over the cap is rejected before the upgrade with `connection_limit`. Accepts a non-negative integer; default `128`, `0` disables the cap. + +- `_WS_MAX_CONNECTION_ATTEMPTS_PER_IP` - Maximum number of new WebSocket connection handshakes a single client key may make within a trailing one-minute window; an attempt over the cap is rejected with `connection_attempt_limit`. This throttles rapid connect/disconnect churn independently of how many connections stay open. Accepts a non-negative integer; default `64`, `0` disables the cap. + - `_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_PENDING_REQUESTS_LIMIT` - Maximum number of requests a single WebSocket connection may have executing concurrently ("pending") before the connection is closed with `pending_requests_limit`. Accepts a non-negative integer; default `48`, `0` disables the cap. The default comfortably covers interactive clients such as Trezor Suite; internal batch clients that pipeline hundreds of requests on one connection (e.g. bulk `getAccountInfo` address scans) need a higher value or `0`. The server-wide work limit still applies as a backstop against unbounded concurrency. + - `_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. +- `_WS_WHITELIST_IPS` / `WS_WHITELIST_IPS` - Comma-separated list of CIDR prefixes whose traffic bypasses all WebSocket rate limiting (connection attempt/active limits, per-connection message rate tracking, and the automatic IP blocklist). Addresses in this range are attributed normally through the shared `CF-Connecting-IP` / `X-Real-Ip` / peer-IP logic, but once resolved they are never throttled or blocked. The typical use is to exclude office, monitoring, or internal-infrastructure IPs that would otherwise collide with per-client budgets set for end users. A network-prefixed variable overrides the global one when both are set. Combine with `_REST_UI_WHITELIST_IPS` / `REST_UI_WHITELIST_IPS` (below) if the same ranges should also bypass the REST/UI limiter. + +- `_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 `20`, `0` disables request-rate limiting. The client key uses the shared REST/WebSocket attribution rules: an IPv4 address, or an IPv6 `/64` prefix. The default is tight because this surface is for individual human use (Suite uses the WebSocket interface). 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. @@ -61,7 +71,7 @@ Blockbook reads these from its process environment. When installed from the Debi - `_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_BURST` - Token-bucket burst size for one client key. Default `20`; must be positive when request-rate limiting is enabled. Allows a short flurry of page loads (the explorer renders each page as a single request) while `_REST_UI_RATE_LIMIT` caps the sustained rate. - `_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. @@ -69,9 +79,13 @@ Blockbook reads these from its process environment. When installed from the Debi - `_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. +- `_REST_UI_WHITELIST_IPS` / `REST_UI_WHITELIST_IPS` - Comma-separated list of CIDR prefixes whose traffic bypasses all REST/UI rate limiting (request-rate budget, concurrent-request cap, and the automatic temporary IP block). Addresses in this range are attributed normally through the shared `CF-Connecting-IP` / `X-Real-Ip` / peer-IP logic, but once resolved they are never throttled or blocked. The typical use is to exclude office, monitoring, or internal-infrastructure IPs that would otherwise collide with per-client budgets set for end users. A network-prefixed variable overrides the global one when both are set. Combine with `_WS_WHITELIST_IPS` / `WS_WHITELIST_IPS` (above) if the same ranges should also bypass the WebSocket 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. +- `INFURA_API_KEY` - API key for the Infura alternative EIP-1559 fee provider. Fees are fetched on demand when a client requests an estimate and cached for `periodSeconds` (a required config value; the shipped archive configs set it to 10s), capping upstream traffic at one request per period; the last successful fee data keeps being served for the configured `staleSeconds` stale window (default 600s / 10 minutes) before falling back to native fee estimation. A dev instance caches for longer, so it spends fewer of the paid requests: the archive configs override `periodSeconds` in their `additional_params_dev` block, which only a `BB_BUILD_ENV=dev` build applies (see [config.md](/docs/config.md)). + +- `ONE_INCH_API_KEY` - API key for the 1inch alternative EIP-1559 fee provider (used by `ethereum_archive`). Required at startup when a config selects `alternative_estimate_fee: 1inch`; the provider fetches on demand and caches fees on the same `periodSeconds`/`staleSeconds` schedule as Infura. - `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). @@ -81,20 +95,51 @@ Blockbook reads these from its process environment. When installed from the Debi 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. +- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. The value and its entries are trimmed and empty entries skipped; a set value that contains no addresses at all (a whitespace-only value included) is a configuration error and Blockbook fails on startup. If omitted (and `ALLOWED_EVM_CALL_METHODS` is not set either), `rpcCall` is enabled for all addresses. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. + +- `_ALLOWED_EVM_CALL_METHODS` - EVM method selectors (first 4 bytes of the calldata, for example `0xdd62ed3e` for ERC-20 `allowance(address,address)`) that `rpcCall` websocket requests may invoke on any address, as a comma-separated list; the `0x` prefix is optional and matching is case-insensitive. Combines with `ALLOWED_RPC_CALL_TO`: a call is allowed when either its target address or its calldata selector is allowed. When only this variable is set, only calls with an allowed selector pass. Malformed calldata (missing `0x` prefix, invalid or odd-length hex, fewer than 4 bytes) never matches a selector. A malformed selector in the list, or a set variable that contains no selectors at all (a whitespace-only value included), is a configuration error and Blockbook fails on startup. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. -- `_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_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. Nonce (`eth_getTransactionCount`) and gas-estimate (`eth_estimateGas`, from the WebSocket `estimateFee` handler) queries are routed to the alternative provider only for addresses that sent a transaction through it within the last 15 minutes; all others are served by the normal backend RPC, keeping the frequently-called estimate path off the provider's rate-limit quota. That window is deliberately much shorter than the pending-transaction retention below: once a send is cached, the cache reserves the sender's next nonce (the pending-nonce floor is applied to the normal backend's answer too), so routing for longer only spends quota. The routing state is kept in process memory, so only the instance that accepted the transaction routes that sender's queries, and a restart clears it. Behind a load balancer, websocket clients are naturally sticky to one instance; REST pollers may hit another and receive the public backend's view. - `_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`. +- `_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 re-checked with the provider's `eth_getTransactionByHash`. A mined transaction, or one whose nonce the chain has already consumed, removes the local pending entry immediately. The relay answers from one consistent store over its whole pending window, so a transaction that stays missing from its answers for `alternativeMissingTxTimeout` (default **2 minutes**) is treated as dropped or cancelled and removed too; the short tolerance absorbs a transient relay fluke rather than evicting on a single empty response. See [evm-send.md](/docs/evm-send.md). Transactions stay pending for at most `alternativePendingTxWindow` (default **3 hours**, the window in which a privately broadcast transaction can still be built into a block) and the Blockbook EVM mempool keeps them 30 minutes longer. All of these, and `alternativeMempoolTxTimeout` (which defaults to the window), are coin-config settings alongside `mempoolTxTimeout`. WebSocket `sendTransaction` can bypass the alternative provider for a single request by setting `disableAlternativeRPC` to `true`. + Broadcasts are observable per path and per provider: `blockbook_sendtx_requests_total{status,reason}` counts every client submission with the rejection classified (caller-side classes such as `underpriced` or `nonce_too_low` versus backend classes such as `rate_limited`, `unauthorized` (a rejected or exhausted provider key), `timeout` and `unavailable`), `blockbook_eth_sendtx_path_total{send_path,status}` shows whether the alternative provider, the public RPC or the `primary_fallback` served it, and `blockbook_eth_alternative_sendtx_total{provider_host,status,reason}` with `blockbook_eth_alternative_sendtx_duration_seconds{provider_host,status}` break the alternative path down per provider. Provider URLs are reduced to their host in metric labels and in the log lines that name a provider, so the metrics endpoint never carries an API key. A failing provider call embeds the URL it was made to in the error message (the http client renders `Post "": ...` on a dial failure or timeout), and that message is logged verbatim, so a key carried in a provider URL does appear in the logs. It does not reach a client through an error response: the REST, WebSocket and explorer error paths reduce a message's URLs to scheme and host first, which drops the userinfo, path and query a key is carried in while keeping the backend's own wording that wallets classify on. Each send is also logged with the sender address and nonce decoded from the raw transaction, which is what a later missing-transaction or nonce-gap report has to be reconciled against. + +## Runtime settings + +The `rpcCall` allowlists can be changed at runtime, without a restart, through the internal server's authenticated admin API (see `BB_ADMIN_USER`/`BB_ADMIN_PASSWORD` above). An override is persisted in the Blockbook database, survives restarts and takes precedence over the corresponding environment variable, which serves only as the startup default. Every change is logged. The `/admin/runtime-settings` page shows the current values and their sources. The endpoint and the page are registered on every chain type — the runtime-settings mechanism is chain-generic; the currently defined settings only affect EVM `rpcCall` and are simply unset elsewhere. + +`GET/POST/DELETE /admin/runtime-settings/` where `` is `ALLOWED_RPC_CALL_TO` or `ALLOWED_EVM_CALL_METHODS`: + +- `GET` returns the effective value and its source (`db` = stored override, `env` = environment default, `unset` = neither): `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e","source":"env"}`. A `GET` of the bare collection path `/admin/runtime-settings/` returns all settings as a JSON array. +- `POST` (or `PUT`) with body `{"value":"0xdd62ed3e,0x70a08231"}` validates the value (invalid values are rejected with `400` and change nothing), stores it in the database and only then applies it to the live allowlists — a database failure returns `500` and leaves the live state unchanged. A `"value"` of exactly `""` is a valid override meaning "explicitly unconfigured" (that allowlist dimension is disabled, as if its environment variable was not set) — it is the only way to un-restrict at runtime while the environment variable has a value. A value containing only whitespace or separators is rejected with `400`, so a botched automation input cannot silently disable the allowlist. +- `DELETE` removes the stored override and reverts to the environment default. If the environment value is malformed the request is rejected with `400` and the override is kept, so a later restart cannot fail on it. + +Old Blockbook versions ignore the stored overrides (the environment applies again after a version rollback) and keep them intact, so rolling forward resumes the override. + +The two sources play distinct roles in a replicated deployment. The environment variable is the deploy-managed baseline: it is shipped identically to every replica with the deployment's env file, applies from the first second of the process (before the admin port is even reachable) and — unlike the database, which is wiped when a replica is resynced — survives a database rebuild, so a freshly synced replica never starts with the allowlists silently unconfigured. The stored override is the runtime layer on top: it takes effect without a restart and persists across restarts until the deployment ships an updated environment and the override is removed. Because an override shadows the environment value, the two can drift (a replica that missed an admin update, or an env change rolled out while an override exists); the drift is visible in the `source` field and Blockbook logs a warning at startup when a stored override shadows a different environment value. + +## Contract-info admin endpoint + +On EVM chains the internal server also exposes `/admin/contract-info/` (same Basic auth) to manage the contract metadata Blockbook caches from the backend node: + +- `GET /admin/contract-info/
` returns the contract's metadata as a `ContractInfo` JSON object (fetching it from the backend node and storing it if not cached yet). +- `GET /admin/contract-info/` (bare collection path) lists the stored records page by page as `{"contracts":[{ContractInfo},…],"next":"
"}`. Unlike runtime settings, the collection is unbounded (sync stores a record per contract creation, millions on a busy chain), so the page size is limited: `?limit=<1..10000>` (default `1000`) and `?from=
` continue from a cursor; `next` (present only when more records exist) is the `from` of the next page. +- `POST` (or `PUT`) `/admin/contract-info/` with a JSON array body `[{ContractInfo},…]` updates the stored metadata of the listed contracts; the response is `{"updated":N}`. The write targets the collection path — a `POST` to an address path is rejected with `400`. +- `DELETE /admin/contract-info/
` purges the stored metadata of one contract so it is re-fetched from the backend node on the next read; the response is `{"contract":"
","deleted":true|false,"purged":{ContractInfo}}` (`deleted` is `false` and `purged` absent when nothing was stored — the delete is idempotent). Note that the whole record is discarded: the backend re-fetch restores only name/symbol/decimals, not the sync-owned `createdInBlock`/`destructedInBlock` fields, which are otherwise recoverable only by a reindex. The `purged` record in the response (also logged) can be `POST`ed back to restore them. + ## 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_BUILD_ENV` - Selects the environment a package/config is generated for. Defaults to `dev`. Accepted values are + `dev` and `prod`. It selects the active RPC URL override family, and a `dev` build additionally merges each coin's + `blockbook.block_chain.additional_params_dev` block over `additional_params` (see [config.md](/docs/config.md)), + which is how a dev instance spends fewer requests on a paid fee or fiat-rates provider. A `prod` build ignores + that block, so a dev value cannot reach production — but for the same reason **a production package must be built + with `BB_BUILD_ENV=prod`**, since the default is `dev`. - `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 @@ -110,12 +155,29 @@ Blockbook reads these from its process environment. When installed from the Debi `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`. +- `BB_ADDNODE_` - Comma or whitespace separated private backend peers (`host[:port]`) appended to + `backend.additional_params.addnode` during package/config generation, so they stay out of the public coin config. + Supported by the backend config templates that expand the `addnode` list (`bitcoin*.conf`, `bitcoin_like.conf`, + `bcash.conf`, `deeponion.conf`). The peers are baked into the built backend package. ## 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_STAGING` - Comma/space separated work-in-progress coins whose `BB_*` variables exist repo-wide but whose + `configs/coins/.json` is only on a feature branch. Each listed coin is skipped where its config is absent (so + sibling branches don't fail on the orphan variables) and builds/tests/deploys normally where the config is present. + +- `BB_RPC_URLS` (repository secret) - `KEY=VALUE` lines carrying every `BB_DEV_RPC_URL_*` / `BB_PROD_RPC_URL_*` + value for CI. Provider URLs embed auth tokens and Actions variables are printed unmasked in public job logs, so + the exporter masks each URL and fails a job that still finds these names among repository variables. Dependabot + runs don't receive it, so `testing.yml` skips connectivity/integration tests for them; a maintainer runs the + Testing workflow manually on the Dependabot branch. + +- `BB_BACKEND_PEERS` (repository secret) - `KEY=VALUE` lines carrying `BB_ADDNODE_*` values for the CI build job. + The exporter masks each peer and fails a job that finds these names among repository variables. + - `BB_PACKAGE_ROOT` - Absolute filesystem path where workflow build jobs stage copied `.deb` packages after build. Defaults to `/opt/blockbook-builds` in the workflow. diff --git a/docs/erc-protocols.md b/docs/erc-protocols.md new file mode 100644 index 0000000000..5a80604321 --- /dev/null +++ b/docs/erc-protocols.md @@ -0,0 +1,79 @@ +# ERC protocol enrichments + +Blockbook can enrich EVM contract responses with **protocol-level facts** that go beyond +the ERC-20/721/1155 metadata the indexer stores natively — for example "this contract is +an ERC-4626 vault, and its underlying asset is DAI". Protocol detection is lazy and +API-driven: it never slows down sync, and it is opt-in per request via the `protocols` +parameter on `getContractInfo` and `getAccountInfo`. + +ERC-4626 is the first protocol implemented and doubles as the reference implementation. +The pieces below it — storage, reorg safety, batched probing, caching — are protocol-agnostic +and are meant to be reused as-is by the next protocol. + +## The shared foundation + +**Storage.** Protocol records live in their own `ercProtocols` column family, keyed by +`(protocolId, contract)` with an opaque, protocol-defined payload — see +[RocksDB](/docs/rocksdb.md) for the exact key layout. Being a separate CF from `contracts` +means API-time protocol writes can never clobber sync-owned contract metadata, and purging a +contract's metadata leaves its protocol records intact. + +**Reorg safety.** This is the part you get for free, and the part that is easiest to get +wrong. Every record is anchored to the `persistHeight` at which it was observed. The writer +samples the reorg generation and block hash *before* the probe and refuses the write under +`connectBlockMux` if either shifted, so a reorg racing an in-flight multicall cannot persist a +stale fact. A secondary `byHeight` index lets `DisconnectBlockRangeEthereumType` revert exactly +the affected records with one range scan. Cost of a false refusal is one re-probe. + +**Probing.** Detection uses Multicall3 `aggregate3` with `AllowFailure`, so a whole page of +token candidates is classified in one `eth_call`, and contracts that don't implement the +protocol simply come back unsuccessful instead of erroring. Chains without a Multicall3 +deployment degrade to no enrichment rather than failing. + +## Querying for protocol information + +Detection is a two-call probe — `asset()` and `totalAssets()`, both mandated by EIP-4626 — run +against ERC-20-shaped contracts. On success the underlying asset address is persisted as the +payload (it is immutable per spec, so the record is written once and reused), and the response is +enriched with vault metadata plus live conversion rates (`convertToAssets`, `previewRedeem`, +and their asset-side counterparts). Partial failures degrade gracefully: confirmed fields are +returned alongside an `error` string rather than dropping the whole enrichment. + +The split is worth internalising, because a new protocol will want the same shape: + +* **`getAccountInfo`** returns cheap, already-known identifiers — `token.protocols: ["erc4626"]`, + sourced from indexed records and one batched probe for unknown candidates. +* **`getContractInfo`** returns the rich, freshly-fetched payload under `protocols.erc4626`. + +## Adding a new protocol, e.g. ERC-2612 + +ERC-2612 (`permit` — gasless approvals) is a good next candidate: detectable by view calls +(`DOMAIN_SEPARATOR()`, `nonces(address)`), relevant to any ERC-20 holder, and a natural +`getAccountInfo` flag. The path: + +1. **Reserve a protocol ID.** Take the next free byte in `db/rocksdb_protocols.go` + (`ErcProtocolErc2612 byte = 0x02`) and add it to the list `disconnectErcProtocols` iterates + — that list is what makes records reorg-revertible, so it is the one step not to skip. +2. **Define the payload.** Whatever the protocol needs, packed with the existing `packString` / + `packVaruint` helpers. Empty is fine when presence alone is the fact. Consider leading the + payload with a version byte if you expect the format to evolve. +3. **Add typed accessors** in `db/rocksdb_contracts.go` mirroring + `SetContractInfoErc4626Vault` / `GetContractInfoErc4626Vault`, and merge the record into + `GetContractInfo` if the read path needs it. +4. **Write the probe** in a new `api/erc2612.go`, modelled on `api/erc4626.go`: declare the + method selectors, build the multicall, decode results. Extend the encode/decode helpers if + your calls need argument or return types beyond no-arg / `uint256` / `address`. +5. **Register the name** in `knownErcProtocols` (`api/contract.go`) so `protocols=erc2612` is + accepted, and hook the enrichment into `enrichTokenProtocols` and `GetContractInfoData`. +6. **Extend the response types** — a `Erc2612Token` struct in `api/types.go` and a field on + `ContractInfoProtocols`. Typed clients get a precise shape rather than a loose map. +7. **Update the API surface**: edit `openapi.yaml` by hand, regenerate `blockbook-api.ts` + with `make typescriptify`, and update the protocol ID table in [RocksDB](/docs/rocksdb.md). +8. **Test** against `db/rocksdb_protocols_test.go` (storage and disconnect) and + `api/erc4626_test.go` (probe and enrichment) as templates. + +One policy decision is yours to make. ERC-4626 stores an immutable fact, so the writer refuses +to overwrite a differing payload and warns instead. A protocol whose facts can legitimately +change wants the opposite — overwrite the record and re-anchor `persistHeight` so the byHeight +index still tracks it. That is a small branch in `SetErcProtocol`, keyed on the protocol ID; +everything below it stays as-is. diff --git a/docs/evm-send-mempools.md b/docs/evm-send-mempools.md new file mode 100644 index 0000000000..3d2f3de6cb --- /dev/null +++ b/docs/evm-send-mempools.md @@ -0,0 +1,215 @@ +# EVM pending-transaction stores + +Blockbook keeps EVM pending transactions in **two** stores when a coin is configured with a private +send-tx relay (`*_ALTERNATIVE_SENDTX_URLS`, `*_ALTERNATIVE_SENDTX_ONLY`, +`*_ALTERNATIVE_FETCH_MEMPOOL_TX`); without one only the Blockbook mempool exists. The broadcast flow +and its invariants are in [evm-send.md](/docs/evm-send.md); this page maps the two stores. + +| | Blockbook mempool | MEV / private cache | +|---|---|---| +| Type | `bchain.MempoolEthereumType` (`b.Mempool`) | `eth.AlternativeSendTxProvider.mempoolTxs` | +| Holds | txid + address index (+ token transfers) | full `RpcTransaction` body, sender, nonce, send generation | +| Populated from | `newPendingTransactions` WS feed; the resync snapshot; own successful sends when `disableMempoolSync` | only sends a relay ACKed, in `ALTERNATIVE_SENDTX_ONLY` + `ALTERNATIVE_FETCH_MEMPOOL_TX` mode — from the signed bytes alone | +| Serves | address and xpub txs (`GetAddrDescTransactions`), wallet `NewTx` pushes | tx bodies on `GetTransaction`, the pending-nonce floor | +| Retention | `mempoolTxTimeout` — the cache retention + 30 min when the pending-tx cache is enabled, else `mempoolTxTimeoutHours`; the coin configs set it explicitly and match `alternativeMempoolTxTimeout` to it | `alternativeMempoolTxTimeout`, defaulting to `alternativePendingTxWindow` — 3 h, the window in which a privately broadcast tx can still be built into a block | +| Reconciled | `Resync` every ~60 s; timeout sweep at most every 10 min | `reconcileMempoolTxs` on a 1 min tick, against the relay; an entry is re-probed less often as it ages | + +A private transaction is in **both**: the cache holds the body and is the source of truth, the wrapped +mempool the index built from it. A public transaction is only ever in the mempool. + +Two couplings between the stores are load-bearing: + +- `AddOrRefreshTransactionInMempool` — restamping an existing entry, delegating a first add to + `AddTransactionToMempool` — builds its address index through `GetTransactionForMempool` → + `GetTransaction`, which reads the cache first, so a private transaction must be cached *before* it + is added to the wrapped mempool or it cannot be indexed at all. The body comes from the send's own + signed bytes and the fetch-back never writes the cache, so this holds even when the relay never + surfaces the transaction (see [evm-send.md](/docs/evm-send.md)). +- The cache must **not outlive** the wrapped mempool. Every cache exit clears the wrapped mempool + too, but the mempool's own timeout sweep does not clear the cache; inverted, a private transaction + loses its address index while still being served as pending. The mempool default is *derived* from + the cache retention, so only an explicit `mempoolTxTimeout` can invert the pair, and `CreateMempool` + then refuses to start. Equal retentions are fine — every cache write creates or restamps the wrapped + mempool entry, so the cache is never the younger of the two — and are what the coin configs ship. + +## Broadcast, ingest and eviction + +```mermaid +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6b7280", "primaryTextColor": "#111827"}}}%% +flowchart TD + send["SendRawTransaction(hex, disableAlternativeRPC)"] + route{"relay configured
and not disabled?"} + relay["broadcast to every relay URL
eth_sendRawTransaction, concurrently"] + acc{"any relay URL accepted?"} + only{"ALTERNATIVE_SENDTX_ONLY?"} + fail["return relay error
no cache path, no fetch-back"] + primary["primary backend
eth_sendRawTransaction"] + reg["registerSuccessfulSend
sender + accepting URL + nonce slot
assign send generation"] + ackevict["evictReplacedByNonce
retire same from+nonce predecessor
on ACK, generation-ordered"] + cache["cacheMempoolTransaction
body decoded from the signed bytes
skipped if a newer send holds the slot"] + handle["probeSentTransaction (background)
fetch-back eth_getTransactionByHash
reports whether the relay surfaces the send;
never writes the cache"] + + ws["eth_subscribe newPendingTransactions
skipped when disableMempoolSync"] + snap["startup and Resync snapshot
eth_getBlockByNumber pending
only when queryBackendOnMempoolResync"] + + alt[("MEV / private cache
full tx bodies
timeout alternativeMempoolTxTimeout,
the pending window by default")] + pub[("Blockbook mempool
txids + address index
mempoolTxTimeout, at or above
the cache retention")] + + altrec["reconcileMempoolTxs, 1 min tick
evicts mined, nonce_superseded, timeout and
entries missing from the relay past
alternativeMissingTxTimeout"] + pubrec["Mempool Resync, every 60 s
timeout sweep at most every 10 min
plus backend-missing removal"] + + readalt["GetTransaction read path
entry past the cache timeout"] + blk["GetBlock: tx in a connected block, plus the
sender's entries at or below its nonce"] + readmined["GetTransaction: mined
(a null answer evicts nothing)"] + + altrm[("removeMempoolTx
cache delete decides the race
release nonce routing
metered by its caller, on its bool")] + bothrm[("removeTransactionFromMempool
clears BOTH stores")] + + send --> route + route -- "no" --> primary + route -- "yes" --> relay --> acc + acc -- "no" --> only + only -- "yes" --> fail + only -- "no" --> primary + acc -- "yes" --> reg --> ackevict --> cache --> handle + ackevict -. "predecessor" .-> altrm + cache -- "1. cache the body" --> alt + cache -- "2. AddOrRefreshTransactionInMempool,
index built by reading the cache
(a re-send restamps), then push NewTx" --> pub + handle -. "probe only,
never writes" .-> alt + primary -. "only when disableMempoolSync" .-> pub + ws --> pub + snap --> pub + + alt --> altrec --> altrm + readalt --> altrm + altrm -- "delegate, entry already gone" --> bothrm + blk -- "metered sync_removed" --> bothrm + readmined -- "metered sync_removed" --> bothrm + bothrm -- "delete" --> alt + bothrm -- "delete" --> pub + pub --> pubrec -- "wrapped mempool only" --> pub + + classDef step fill:#e7f0ff,stroke:#4078c0,color:#10243e; + classDef mev fill:#f3e8ff,stroke:#7c3aed,color:#2b1148; + classDef pubstore fill:#e8f7ed,stroke:#2e8b57,color:#0b2c19; + classDef sink fill:#fff7e6,stroke:#b8860b,color:#3a2a00; + classDef error fill:#ffecec,stroke:#c03535,color:#3b0a0a; + class send,route,relay,acc,only,primary,reg,ackevict,cache,handle,ws,snap,altrec,pubrec,readalt,blk,readmined step; + class alt mev; + class pub pubstore; + class altrm,bothrm sink; + class fail error; +``` + +## What the cache reconcile decides, per entry + +Evaluated in this order on every one-minute tick, for every cached transaction the probe backoff lets +through. The labels are the `action` values of +`blockbook_eth_alternative_mempool_reconciliation_events_total`; every `evict:` box goes through +`removeMempoolTx` in the first diagram, and is metered by the caller rather than there. + +```mermaid +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6b7280", "primaryTextColor": "#111827"}}}%% +flowchart TD + e["cached entry, 1 min tick"] + f{"age under 1 min?"} + keepFresh["keep: skipped_fresh"] + b{"probed within its interval?
1 min under 10 min old, 5 min under 1 h,
15 min under 3 h, then hourly"} + keepBackoff["keep: skipped_backoff"] + q["relay eth_getTransactionByHash"] + qerr{"past the cache timeout?"} + evTo["evict: timeout"] + keepErr["keep: provider_error"] + m{"blockNumber set?"} + evMined["evict: mined"] + s{"confirmed nonce above tx nonce?
relay eth_getTransactionCount latest"} + evSup["evict: nonce_superseded"] + k{"still surfaced by the relay?"} + nto{"past the cache timeout?"} + kto{"missing run past
alternativeMissingTxTimeout?"} + evMiss["evict: provider_missing"] + keepMiss["keep: provider_missing_pending
a short run of nulls is tolerated,
absorbing a transient relay fluke"] + yto{"past the cache timeout?"} + evTo2["evict: timeout"] + keepK["keep: kept"] + + e --> f + f -- "yes" --> keepFresh + f -- "no" --> b + b -- "yes, and not timed out" --> keepBackoff + b -- "no" --> q + q -- "error" --> qerr + qerr -- "yes" --> evTo + qerr -- "no" --> keepErr + q -- "answered" --> m + m -- "yes" --> evMined + m -- "no" --> s + s -- "yes" --> evSup + s -- "no" --> k + k -- "no" --> nto + nto -- "yes" --> evTo2 + nto -- "no" --> kto + kto -- "yes" --> evMiss + kto -- "no" --> keepMiss + k -- "yes" --> yto + yto -- "yes" --> evTo2 + yto -- "no" --> keepK + + classDef step fill:#e7f0ff,stroke:#4078c0,color:#10243e; + classDef keep fill:#e8f7ed,stroke:#2e8b57,color:#0b2c19; + classDef evict fill:#fff7e6,stroke:#b8860b,color:#3a2a00; + class e,f,b,q,qerr,m,s,k,nto,kto,yto step; + class keepFresh,keepBackoff,keepErr,keepMiss,keepK keep; + class evTo,evTo2,evMined,evSup,evMiss evict; +``` + +A mined private transaction is usually cleared by block sync first, counted as `sync_removed`, before +the next probe reaches the `mined` branch here. + +## How the two stores are read back + +Only addresses that sent through the relay in the last 15 minutes are routed to it (`useForNonces`); +everything else is served by the primary backend. That window is much shorter than the cache retention +on purpose — see [evm-send.md](/docs/evm-send.md#how-long-a-private-transaction-stays-pending). + +```mermaid +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6b7280", "primaryTextColor": "#111827"}}}%% +flowchart LR + tx["GetTransaction(txid)"] + hit{"in MEV cache
and not expired?"} + body["serve the cached body
relay never asked"] + rpcget["primary eth_getTransactionByHash
then pruned-index recovery"] + + addr["address / xpub request"] + idx["Blockbook mempool address index"] + + non["EthereumTypeGetNonces(addr)"] + gate{"useForNonces(addr)?
private send within 15 min"} + nrelay["relay eth_getTransactionCount
single accepting URL, batched pending+latest"] + nprim["primary eth_getTransactionCount"] + floor["raiseToPendingFloor
advance over the contiguous cached run,
never past a slot nothing fills"] + + est["EthereumTypeEstimateGas"] + egate{"from set and useForNonces?"} + erelay["relay eth_estimateGas"] + eprim["primary eth_estimateGas"] + + tx --> hit + hit -- "yes" --> body + hit -- "no" --> rpcget + addr --> idx -- "per txid" --> tx + non --> gate + gate -- "yes" --> nrelay --> floor + gate -- "no" --> nprim --> floor + est --> egate + egate -- "yes" --> erelay + erelay -. "error or bad result" .-> eprim + egate -- "no" --> eprim + + classDef step fill:#e7f0ff,stroke:#4078c0,color:#10243e; + classDef mev fill:#f3e8ff,stroke:#7c3aed,color:#2b1148; + classDef pubstore fill:#e8f7ed,stroke:#2e8b57,color:#0b2c19; + class tx,hit,rpcget,addr,non,gate,nprim,est,egate,erelay,eprim,floor step; + class body,nrelay mev; + class idx pubstore; +``` diff --git a/docs/evm-send.md b/docs/evm-send.md new file mode 100644 index 0000000000..e6bcf5288a --- /dev/null +++ b/docs/evm-send.md @@ -0,0 +1,286 @@ +# EVM transaction submission + +How Blockbook broadcasts a **Trezor Suite** EVM transaction through the private send-tx relay and +tracks it in its own pending-transaction cache until the chain confirms or supersedes it. Fee +estimation is covered in [fees.md](/docs/fees.md). + +The two pieces switch on separately. **Broadcasting** through the relay needs only +`*_ALTERNATIVE_SENDTX_URLS`: set it and every send goes to the relay, with no URL configured Blockbook +sends through the normal backend RPC. The **pending-tx cache** below additionally needs +`*_ALTERNATIVE_SENDTX_ONLY` and `*_ALTERNATIVE_FETCH_MEMPOOL_TX`; without them the relay still +broadcasts but Blockbook keeps no pending state of its own. + +## Broadcast and the pending-transaction cache + +`SendRawTransaction` broadcasts and, when the relay accepts in `ALTERNATIVE_SENDTX_ONLY` + +`ALTERNATIVE_FETCH_MEMPOOL_TX` mode, keeps its own pending-tx cache — the relay exposes no mempool to +reconcile against. A background loop and the read path reconcile that cache against the chain. + +```mermaid +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6b7280", "primaryTextColor": "#111827"}}}%% +flowchart TD + send["SendRawTransaction(hex, disableAlternativeRPC)"] + route{"provider configured
and not disabled?"} + primary["primary eth_sendRawTransaction"] + relay["broadcast to every relay URL
concurrently"] + acc{"any URL accepted?"} + reject["onlyAlternative: return error
else: fall back to primary"] + reg["registerSuccessfulSend
(record sender + URL, assign gen)"] + evict["evictReplacedByNonce
retire same-(from,nonce) predecessor
on ACK (RBF / cancel), gen-ordered"] + cache["cacheMempoolTransaction
cache body decoded from the signed bytes
(gen-ordered) → AddTransactionToMempool → notify"] + handle["probeSentTransaction (background)
fetch-back → report whether the relay
surfaces what it accepted; writes nothing
(undecodable hex → exposeAcceptedSend
retries, then caches)"] + + subgraph rec ["reconcileMempoolTxs (1 min tick, per cached tx, probes backed off by age)"] + mined["mined → evict"] + super["nonce_superseded
(confirmed nonce > tx nonce) → evict"] + miss["missing from the relay (null answer)
→ evict once the run outlasts
alternativeMissingTxTimeout"] + to["past cache timeout → evict"] + end + + readpath["GetTransaction read path
expired entry → evict"] + syncrm["block sync indexing its block or a later
nonce of its sender, or the read path finding it mined"] + remove[("clear cache + wrapped mempool
+ release nonce routing")] + + send --> route + route -- "no" --> primary + route -- "yes" --> relay --> acc + acc -- "no" --> reject + acc -- "yes" --> reg --> evict --> cache --> handle + evict -. removes predecessor .-> remove + cache -. caches new tx .-> readpath + mined -- "removeMempoolTx" --> remove + super -- "removeMempoolTx" --> remove + miss -- "removeMempoolTx" --> remove + to -- "removeMempoolTx" --> remove + readpath -- "removeMempoolTx" --> remove + syncrm -- "RemoveTransaction,
metered sync_removed" --> remove + + classDef normal fill:#e7f0ff,stroke:#4078c0,color:#10243e; + classDef store fill:#e8f7ed,stroke:#2e8b57,color:#0b2c19; + classDef error fill:#ffecec,stroke:#c03535,color:#3b0a0a; + class send,route,relay,acc,reg,evict,cache,handle,mined,super,miss,to,readpath,syncrm,primary normal; + class remove store; + class reject error; +``` + +Key invariants: + +- **On the relay-accepted path, the wallet's answer waits for the broadcast and nothing else.** The + broadcast to every relay URL runs concurrently, so it costs the slowest URL, not their sum; caching + is local work and the fetch-back is background. A wallet that gives up before Blockbook answers + tells the user the send failed while it is on its way to the chain, and a re-send at the next nonce + then pays the recipient twice. Trezor Suite's deadline is 20 s per request, 110 s for pushes once + [trezor-suite#30846](https://github.com/trezor/trezor-suite/pull/30846) lands — sized to the + fall-through tail below, not just the relay leg's one `rpc_timeout` (25 s). + + The fall-through path is not bounded that way. With no relay acceptance and + `ALTERNATIVE_SENDTX_ONLY` unset, the answer also waits for the primary `eth_sendRawTransaction` and, + on `disableMempoolSync` coins, for the mempool add that makes an own send visible: one primary + `eth_getTransactionByHash` plus the pruned-index recovery's `eth_getTransactionReceipt` when that + answers null, up to 3–4 × `rpc_timeout` in total. The same tail exists there with no relay at all. + The add is skipped on a failed send — no txid to index. +- **An accepted send is cached from its own signed bytes, before the relay is asked about it.** The + signed transaction carries everything a pending `RpcTransaction` needs, so the fetch-back writes + nothing and only reports whether the relay surfaces what it accepted. It must not write: by then the + transaction may have mined and block sync may have cleared it, and the relay's view can name a + different hash, `to` or `value` than the bytes Blockbook was given. So a relay that accepts and then + does not surface a transaction no longer leaves the send exposed nowhere — not served as pending, + not indexed, not raising the pending-nonce floor, its nonce free to reuse. The txid is the hash of + the signed bytes, not the relay's echo, so cache, address index and the wallet's answer all name what + the chain will show. Exception: an undecodable raw transaction, where nothing can be derived and the + fetch-back keeps its create semantics as the only thing that can expose the send. +- **What is cached is what a relay accepted, which is not the same as what will mine.** A transaction + the relay drops — a Blink drop-mode cancel is the deliberate case — stops being surfaced, so + reconcile retires it once its run of null answers outlasts `alternativeMissingTxTimeout` (2 min by + default), releasing its nonce slot and the pending-nonce floor with it. A transaction the relay + keeps surfacing but nobody mines holds its slot for the whole pending window; that is the accepted + side of the #1638 trade: a reserved dead nonce beats handing out one that is genuinely in flight. + What bounds it is the floor's contiguity rule — the floor never advances *past* an unfilled slot, + so the wallet always gets a usable nonce and only the stuck private transaction is affected, not + every send after it (#1675). Suite can speed it up or cancel it for the whole window. +- **Every same-`(from, nonce)` predecessor is retired the moment the relay ACKs its replacement** — + only one transaction per slot can mine — from the raw hex, not on the relay surfacing the + replacement: a Blink drop-mode cancel is never surfaced and never consumes its nonce on-chain, which + left the superseded tx "Unconfirmed" until the cache timeout (#1573). This is deliberately distinct + from an *empty* `eth_getTransactionByHash` probe: the relay answers from one consistent store over + its whole pending window (the Blinklabs alignment), so a run of nulls does evict — but only after + `alternativeMissingTxTimeout`, a short tolerance that absorbs a transient relay fluke rather than + evicting on a single answer. `mined` and `nonce_superseded` stay reconcile's immediate + deterministic evictions. +- **Send generations order concurrent same-nonce sends.** Each accepted send gets a monotonic + generation; an older submission's slow fetch-back neither caches itself over, nor evicts, a newer + replacement that already holds the nonce slot. +- **Every exit clears both stores and is metered exactly once.** The reconcile and RBF evictions go + through `removeMempoolTx`; `sync_removed` — block sync (the mined transaction and the sender's + entries at or below its nonce), and the read path finding a transaction mined — goes through + `RemoveTransaction` directly. A null answer on the read path evicts nothing (#1709). Neither meters anything itself: + `removeMempoolTx` passes an empty action, so its callers do the metering, gated on the bool it + returns, and `RemoveTransaction` passes `sync_removed`, which nothing else would record. The gating + is what keeps concurrent reconcile / read-path / RBF evictions of the same entry from + double-counting. +- **Removals are not pushed to the wallet.** Blockbook pushes only *added* txs; a wallet learns a + pending tx is gone on its next account re-fetch (the initiating device also removes it + optimistically). The cache timeout — `alternativeMempoolTxTimeout`, the pending window by default + and raised well past it in the coin configs — is only the backstop: in + practice `sync_removed` retires an entry, with `nonce_superseded` covering a replacement submitted + outside Blockbook. + +## How long a private transaction stays pending + +A privately broadcast transaction can be built into a block for around three hours (Blinklabs' +figure), and the relay's `eth_getTransactionByHash` and pending `eth_getTransactionCount` answers +cover that same window — they used to stop after roughly a minute, the root cause of +[#1573](https://github.com/trezor/blockbook/issues/1573). One documented exception: with Blink's +*revert protection* the retention is 96 seconds, not three hours — a send through such an endpoint +that never mines stops being answered after that window and leaves through the missing eviction +minutes later, not after `alternativePendingTxWindow`. Blockbook is sized to the window, and treats +a transaction the relay stops answering for as dropped: + +| setting | default | what it governs | +|---|---|---| +| `alternativePendingTxWindow` | 3 h | how long a relay-accepted transaction is served as pending and its nonce slot reserved | +| `alternativeMempoolTxTimeout` | the window | the cache retention, if it should differ from the window | +| `alternativeMissingTxTimeout` | 2 min | how long a cached transaction may stay missing from the relay (consecutive null answers) before reconcile evicts it — the dropped/cancelled exit | +| `mempoolTxTimeout` | cache retention + 30 min | the wrapped mempool, which must not expire before the cache — equal is allowed and is what the coin configs ship; Blockbook refuses to start if an explicit value inverts the pair | +| relay routing (`useForNonces`) | 15 min | how long the sender's `eth_getTransactionCount` and `eth_estimateGas` go to the relay rather than the primary backend | + +The routing horizon is deliberately the odd one out. Once the send is cached, the pending-nonce floor +is applied to the primary backend's answer too, so a sender released from routing gets the same nonce. +`eth_estimateGas` rides the same gate and is called once per send-form keystroke, which is how the +relay's rate quota was exhausted in [#1629](https://github.com/trezor/blockbook/issues/1629). + +**The missing eviction assumes the relay answers over its whole window** — the Blinklabs alignment, +deployed. Two signals verify it in production: +`blockbook_eth_alternative_pending_floor_raised_total{source="provider"}` and +`blockbook_eth_alternative_send_not_surfaced_total{reason="not_found"}` should both sit near zero; a +sustained rate on either means the relay's answers regressed below its window. Revert-protected +sends are the benign exception: between their 96 s retention lapsing and the missing eviction +firing, a nonce lookup routed to the relay can raise the floor from the still-cached tx, ticking +`floor_raised{source="provider"}` without any regression. For a relay that does +not surface accepted transactions over its window at all, set `alternativeMissingTxTimeout` at or +above the cache retention — that restores the old timeout-only eviction instead of re-living +[#1573](https://github.com/trezor/blockbook/issues/1573). + +## Wallet-declared `privatePending` hint (nonce + gas routing) + +A private relay exposes no mempool, so a transaction pending only there is invisible to the public +backend RPC. Blockbook otherwise *infers* "this sender has a private tx in flight" from the +`recentSenders` map (populated by `registerSuccessfulSend`), which is fragile across restarts and +across load-balanced replicas without request affinity. A wallet already knows this state with +certainty, so it may **declare** it on its read requests via an optional `privatePending` field. +Blockbook then routes deterministically from that declaration instead of guessing. + +The field appears in two places, matching the two consumers of the routing machinery: + +- **`getAccountInfo` → top-level `privatePending: {nonces, txids}`** drives the pending-**nonce** + lookup. Blockbook routes the `eth_getTransactionCount` to the relay and treats each declared nonce + as an occupied slot, exactly like one of its own cached transactions: the answer walks the backend's + own reply across the contiguous run of occupied slots, so the wallet is never handed back the nonce + of a private tx it has in flight — even one this instance never cached, because another replica + accepted it or a restart cleared it. It is the same walk described above, over the union of the two + sources, which is why a declared nonce above an unfilled slot strands rather than lifting the answer + over the hole (#1675). The answer only ever *raises* the backend's; it never lowers it. The nonce + list is capped (see `maxPrivatePendingNonces`); past the cap the lowest entries are kept, since the + walk can only ever consume the slots just above the backend's answer. + + Routing the lookup — replacing the primary's answer with the relay's — rests on a stated relay + property: Blink's pending `eth_getTransactionCount` answers *the greater of* the node's pending + count and the relay's latest accepted nonce + 1 (its API reference), i.e. a **superset of the + public mempool** — it never falls below what the primary would have said, even for pending txs + the declaring wallet knows nothing about (another device on the same account). A relay *without* + this property must not be routed to on the hint; its nonce path would have to be made floor-only — + the declared floor over the primary's answer, which the provider-failure fallback already computes. +- **`estimateFee` → `specific.privatePending`** is a **routing signal only**. Unlike a nonce, the + wallet cannot compute gas itself, so Blockbook still simulates the call — the declaration only says + "estimate against the relay's pending-private state" (e.g. a privately-submitted `approve` a + following swap's gas depends on). Presence of a non-empty `nonces` array is all that is read; the + field is stripped before the `eth_estimateGas` call object is forwarded to the relay. URL selection + is best-effort: the estimate goes to the provider that accepted this sender's most recent send + (`nonceURL`), or `urls[0]` when this instance never saw a send from the address (another replica + accepted it, or a restart cleared `recentSenders`). Unlike the nonce floor, gas has no + client-supplied value to fall back on, so a declared-but-unknown sender simulates against `urls[0]` + and may miss a private predecessor held only by a different relay node — an accepted limit of a + multi-URL relay without sender affinity, not compensable the way the nonce floor is. + +Only `nonces` drives behavior today; `txids` is accepted for forward compatibility (future +pending-tx correlation) and is not yet consumed on any path. + +The hint is **additive and backward-compatible**: absent the field, behavior is exactly as before +(the `recentSenders` heuristic remains the fallback, and is still consulted when no hint is +declared), and an older Blockbook simply ignores the unknown field. With no alternative provider +configured the hint is a no-op (there is no private mempool to reconcile against). + +Declaring the field also outlives the routing window: `useForNonces` stops routing a sender 15 minutes +after its send, but a wallet that keeps declaring an in-flight transaction keeps being routed to the +relay for as long as it is actually pending. + +Pruning the declaration is the wallet's side of the contract. Blockbook cannot tell a stale +declaration from a live one and never expires one server-side — unlike a cached transaction, which +the missing eviction retires once the relay stops answering for it (`alternativeMissingTxTimeout`) — +so a wallet that keeps declaring a dropped transaction's nonce keeps its own floor raised past it +(only its own: the declaration is per-request). A wallet that derives the declaration from the +pending transactions Blockbook itself reports self-heals: the missing eviction removes the dropped +transaction from those answers within minutes, and the declaration follows on the next re-fetch. + +Note the deliberate trade-off against pre-#1629 behavior: `estimateFee` is no longer routed to the +relay for *every* sender, so a wallet that sent privately, omitted the hint, and is served by a +different replica than the one that accepted the send has its estimate simulated on the primary RPC +without the private predecessor. Declaring `privatePending` closes that gap deterministically; +widening routing for hint-less senders is avoided because it is indistinguishable from the #1629 +hot-path drain. + +**Trust boundary (accepted).** `privatePending` is an *unauthenticated client hint*, and +per-request only — never written into `recentSenders` or the pending-tx cache — so a hostile client +can distort only its own request's answer, never shared state. Its one outward effect, routing the +read to the relay, adds no capability an unauthenticated caller does not already have: +`sendTransaction` spends the same relay quota with every call, fanned out to *every* relay URL, at +no cost for a rejected transaction. Relay-quota protection, if ever needed, belongs in the server's +per-IP rate limiting, uniformly across methods — not in this path. + +## Observability + +Prometheus counters for the cache lifecycle: + +- `blockbook_eth_alternative_mempool_reconciliation_events_total{action}` — cache exits by reason + (`mined`, `nonce_superseded`, `provider_missing`, `timeout`, `rbf_replaced`, `sync_removed`) plus + the kept actions (`skipped_fresh`, `skipped_backoff`, `provider_missing_pending`, `kept`, + `provider_error`). `sync_removed` — block sync indexing the tx's block — should dominate, `mined` + should be rare. The read path's mined removal also meters here, but it can fire only on a cache + miss (a cached entry is served without asking the node), so block sync is effectively the sole + source. +- `blockbook_eth_alternative_mempool_tx_residence_seconds{action}` — entry lifetime per eviction + reason. `provider_missing` is the dropped/cancelled exit; residence counts age since broadcast, + so a cancel N minutes after the send records ~N plus `alternativeMissingTxTimeout` — read the + reason, not the age. Collapsing below one reconcile cycle would be the premature-eviction + regression #1573 describes. An entry reaching the cache timeout leaves as `timeout` even when the + relay answers null, so with the missing eviction configured off `provider_missing` stops firing + rather than pinning at the timeout. +- `blockbook_eth_alternative_mempool_cache_size` — current cache depth. + +Signals for *hanging* private transactions — a tx stuck Unconfirmed, or a nonce pinned above a dead +on-chain gap: + +- `blockbook_eth_alternative_mempool_oldest_age_seconds` — age of the oldest cached entry, sampled per + reconcile cycle; the residence histogram records an age only once an entry leaves, so a stuck tx is + otherwise invisible until it times out. Climbing toward the cache timeout at non-zero depth means + txs the relay still surfaces are dying underpriced rather than mining — a dropped or cancelled tx + leaves much earlier through the missing eviction. +- `blockbook_eth_alternative_send_not_surfaced_total{reason}` — a relay-accepted send the fetch-back + did not surface (`not_found`/`error`) or never ran at all (`dropped`). On the normal path the tx is + still cached and indexed from its signed bytes, so this is not a nonce-reuse precursor, and a relay + that keeps not surfacing it retires the entry through the missing eviction. The exception is the + raw-hex-decode-failure path (logged as `cannot decode accepted transaction`), where the fetch-back + is the one thing that can expose the send: it retries for ~30 s (a lookup error means retry, never + gone, per the relay's contract), and a reason recorded there — `dropped` included — means the send + really is exposed nowhere. +- `blockbook_eth_alternative_pending_floor_raised_total{source}` — `raiseToPendingFloor` lifted the + pending nonce above the backend's own answer. `primary` (the fallback RPC never knew the private tx) + tracks private-send activity, not faults; `provider` should sit near zero now that the relay counts + a pending tx over its whole window — a sustained rate means its pending count regressed, with + revert-protected sends in their post-96s eviction gap as the benign exception (see above). +- `blockbook_eth_alternative_pending_floor_stranded_total{source}` — the cache holds a private tx at a + nonce *above* the run the floor could advance over, i.e. a slot nothing Blockbook knows of fills; the + floor stops below the hole, so the wallet still gets a usable nonce while that transaction cannot + mine until the hole is filled. Should be ~0; a sustained rate on `provider` means relay-accepted + sends are not reaching the cache. Each sample is one request, not one transaction, so the rate + follows wallet polling. diff --git a/docs/fees.md b/docs/fees.md new file mode 100644 index 0000000000..09c950bc1b --- /dev/null +++ b/docs/fees.md @@ -0,0 +1,160 @@ +# EVM EIP-1559 fee estimation + +This document describes how EIP-1559 fees are produced for EVM coins across **Blockbook** +(the indexer) and **Trezor Suite** (the wallet), and which component owns which decision. + +## Responsibilities + +The split is deliberate: Blockbook supplies facts, the wallet decides policy. + +**Blockbook — provides ground-truth inputs:** + +- base fee — the next-block projection, taken from the chain +- priority-fee tiers (tips) +- congestion and trend signals (via the configured alternative provider, when supported) +- per-block gas — `baseFee`, `gasUsed`, `gasLimit` + +**Trezor Suite — owns the fee policy:** + +- chooses the base-fee source +- applies the head-room buffer (currently `2×`) +- composes `maxFeePerGas = 2 × baseFee + tip` +- clamps to per-coin `minFee` / `minPriorityFee` / `maxFee` limits +- displays the fee options and signs the transaction + +This is why the wallet computes `maxFeePerGas` itself instead of trusting a provider's pre-padded +value (e.g. Infura's `suggestedMaxFeePerGas`, which was the source of the "fees too high" reports). + +--- + +## Blockbook — pull path (`estimateFee` / `EthereumTypeGetEip1559Fees`) + +On-demand estimate. Returns `Eip1559Fees`: a `baseFeePerGas` plus `low/medium/high` (and `instant` +on the on-chain path) tiers, and — on supported provider paths — congestion/trend ranges. + +```mermaid +flowchart TD + A["estimateFee"] --> B{"EIP-1559 supported?"} + B -->|no| Z["legacy feePerUnit only"] + B -->|yes| C{"fresh provider fees available?"} + C -->|yes| D["use cached provider fees"] + C -->|no| E["estimate on-chain from base fee + tips"] + D --> R["return Eip1559Fees
baseFee + fee tiers"] + E --> R +``` + +How each source is built: + +- **On-chain** (coins with no provider, or a stale one — e.g. `ethereum` non-archive, ETH testnets): + one `eth_feeHistory` call (4 blocks, newest = `pending`) yields the next-block `baseFeePerGas` + (array index `blocks-1`) and per-tier reward percentiles (20/70/90/99) used as tips. Blockbook + builds `maxFeePerGas = eip1559BaseFeeMultiplier(2) × baseFee + tip`. (Previously this field held the + tip alone — below the base fee, so not mineable; that was fixed.) +- **Alternative provider** (archive coins, served from an in-memory cache, no node RPC): blockbook + returns the provider's `maxFeePerGas` **unchanged**. For Infura, this is `suggestedMaxFeePerGas`, + padded well above the base fee (~2.5× for the high tier); for 1inch, it is the provider's own + computed `maxFeePerGas`. Blockbook does not rewrite it — the wallet overrides it (see the Suite + section). The cache is refilled on demand at most once per `periodSeconds` and stays usable for + `staleSeconds` afterwards; because the provider is billed per request, dev instances cache for far + longer via the coin config's `additional_params_dev` block, which only a `BB_BUILD_ENV=dev` build + applies (see [config.md](/docs/config.md)). + +--- + +## Blockbook — push path (`subscribeNewBlock` → `evmData`) + +Every connected block is pushed to subscribers with its block-level gas, so a wallet can keep its +fee projection fresh without polling. No extra RPC — the data comes from the block header already +fetched during sync. + +```mermaid +sequenceDiagram + participant N as EVM node + participant SY as Sync (connectBlocks) + participant GB as GetBlock + attachBlockGas + participant WS as WebsocketServer + participant C as Subscribers + + N->>SY: new block N + SY->>GB: GetBlock(hash, height) + GB->>GB: attachBlockGas: read baseFee, gasUsed, gasLimit + GB-->>SY: Block + EthereumBlockSpecificData (transient, not persisted) + SY->>WS: onNewBlock(block) + WS->>WS: newBlockNotification(block) + WS->>C: WsNewBlock height, hash, evmData + Note over C: evmData = baseFeePerGas, blockGasUsed, blockGasLimit
null on non-EVM chains and pre-London blocks +``` + +--- + +## Trezor Suite — `EthereumFeeLevels` (the fee policy) + +Suite computes its own fees from the chain's base fee plus a `2×` buffer, preferring that block-based +estimate and falling back to the backend's tiers when block data is unavailable (so nothing regresses). + +```mermaid +flowchart TD + A["EthereumFeeLevels.load()"] --> B["fetch backend fees + latest block"] + B --> C{"block-based estimate available?"} + C -->|yes| D["compute from chain base fee
maxFeePerGas = 2x baseFee + tip"] + C -->|no| E["fall back to backend fees"] + D --> F["clamp to coin min/max limits"] + E --> F + F --> G["fee levels: low / normal / high"] +``` + +Details and current coverage: + +- The block-based estimate derives the next-block base fee from the latest block (EIP-1559 formula on + `gasUsed`/`gasLimit`), takes tips from the block's transaction `maxPriorityFeePerGas` percentiles, + and sets `maxFeePerGas = 2 × nextBaseFee + tip`. The fallback uses the backend's `eip1559` tiers. +- **evm-rpc-backed networks**: `getBlock('latest')` returns block gas → block-based fees apply. +- **blockbook-backed networks**: `getBlock('latest')` is unsupported → block-based estimate is empty → + Suite falls back to the backend (provider) tiers. Wiring the `subscribeNewBlock` `evmData` push as the + block-data source for these networks is the remaining step. + +--- + +## End-to-end flow + +```mermaid +sequenceDiagram + actor U as User + participant SU as Suite send form + participant CN as connect — EthereumFeeLevels + participant BB as Blockbook / node + + U->>SU: open send form + SU->>CN: blockchainEstimateFee + par estimateFee + CN->>BB: estimateFee + and latest block + CN->>BB: getBlock latest + end + BB-->>CN: eip1559 tiers (+ block, if evm-rpc) + CN->>CN: compute block-based fees (2x buffer), else fall back + CN->>CN: clamp to coin min/max + CN-->>SU: FeeLevel[] maxFeePerGas, maxPriorityFeePerGas, baseFee + SU-->>U: show fee options + U->>SU: confirm + sign + SU->>BB: sendTransaction (EIP-1559) +``` + +--- + +## The `maxFeePerGas` formula + +Both blockbook's on-chain path and Suite's block-based path compute the same value: + +``` +maxFeePerGas = 2 × baseFee + tip +``` + +- **base fee** — consensus-determined for the next block (deterministic from the parent header), so it + is taken from the chain, never from an oracle estimate. +- **tip** (`maxPriorityFeePerGas`) — the only estimated part; from fee-history reward percentiles, or + the provider's per-tier suggestion. +- **`2×` buffer** — the wallet's policy knob. It absorbs roughly 6 consecutive full blocks of base-fee + growth (the base fee can rise at most 12.5% per block) before a transaction stalls, and EIP-1559 + refunds the unused part. In blockbook it is the `eip1559BaseFeeMultiplier` constant; in Suite it is + applied in `EthereumFeeLevels`. diff --git a/docs/ports.md b/docs/ports.md index 6ca5c7982c..31c78b1c89 100644 --- a/docs/ports.md +++ b/docs/ports.md @@ -63,6 +63,8 @@ | 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 | +| Robinhood Archive | 9313 | 9213 | 8213 | 38413 p2p, 8313 http | +| HyperEVM Archive | 9314 | 9214 | 8214 | 38414 p2p, 8314 http | | Ethereum Testnet Hoodi | 19106 | 19006 | 18006 | 18106 http, 18506 authrpc, 48306 p2p | | Bitcoin Signet | 19120 | 19020 | 18020 | 48320 | | Bitcoin Regtest | 19121 | 19021 | 18021 | 48321 | @@ -87,6 +89,7 @@ | Flo Testnet | 19166 | 19066 | 18066 | 48366 | | Syscoin Testnet | 19167 | 19067 | 18067 | 48367 | | Ethereum Testnet Sepolia | 19176 | 19076 | 18076 | 18176 http, 18576 authrpc, 48376 p2p | +| Robinhood Chain Testnet | 19177 | 19077 | 18077 | 18177 http, 48377 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 | diff --git a/docs/rocksdb.md b/docs/rocksdb.md index 0e95606443..21650daac2 100644 --- a/docs/rocksdb.md +++ b/docs/rocksdb.md @@ -53,6 +53,8 @@ Column families used only by **Ethereum type** coins: 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. + Recovery options differ by problem: `--repair` fixes physical SST/MANIFEST corruption (rebuilds the manifest from surviving SST files); `-fixutxo` recomputes the UTXO/balance index for Bitcoin-type coins; a database left in the _inconsistent_ dbState by an interrupted initial/bulk import cannot be recovered by `--repair` and must be re-imported (resynced from scratch) — `-forcerepair` only forces it back to an _open_ state at the risk of an incomplete index. + - **height** Maps _block height_ to _block hash_ and additional data about block. @@ -246,7 +248,11 @@ Column families used only by **Ethereum type** coins: - **blockInternalDataErrors** (used only by Ethereum type coins) - Errors when fetching internal data from backend. Stored so that the action can be retried. + Errors when fetching internal data from backend. Stored so that the fetch can be retried; + the queue is drained automatically about every hour. `retryCount` counts the failures of + the block itself and schedules its next attempt, which is made on passes divisible by + 2^retryCount (capped at 2^6, about 2.7 days of hourly passes) - a wait of up to, not + exactly, 2^retryCount passes - so a block is slowed down but never abandoned. ``` (blockHeight uint32) -> (blockHash [32]byte)+(retryCount byte)+(errorMessage []byte) diff --git a/docs/sync.md b/docs/sync.md index e7966a6a14..fc76f215b0 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -138,6 +138,7 @@ 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)`. +- **The cached tip also spares the header RPCs of a steady-state EVM cycle.** `resyncIndex` reads one `EthereumTypeGetBestTip` snapshot (hash, parent hash, height from the same header). When the tip is exactly one block above the local best *and* its parent hash is the local best hash, the pushed header extends the indexed chain, which is all the `GetBlockHash(localBestHeight)` fork check would re-confirm, so it is skipped and the tip hash is used as the start hash; `getBlockChain` consults the cached height before the end-of-chain probe instead of sending an `eth_getBlockByNumber` that returns `null`. A one-block cycle therefore costs only the block fetch (body, `eth_getLogs`, trace). Any other shape — a gap above one, a parent mismatch, a tip that regressed — takes the original path with the fork check intact. The match proves the local chain is the parent of the node's tip, not that the tip stays canonical: a same-height sibling that later wins is indexed for one cycle and swapped out by the next cycle's fork check, as it was before the shortcut. Bitcoin-type chains keep the original sequence: their `GetBestBlockHeight` is a real RPC and `GetBlock` leaves `Next` empty for every block. - **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. @@ -171,7 +172,7 @@ When an override is applied, blockbook logs one `sync: missingBlockRetry overrid 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_sync_yields{reason="deadline"|"probe_failed"|"fell_behind"}` — wall-clock cap fired, chain-state probe failed three times, or sequential sync fell too far behind the backend tip and yielded for parallel catch-up. - `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): diff --git a/docs/testing.md b/docs/testing.md index 66a1e292f4..2a038d62d9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -100,10 +100,19 @@ WebSocket connectivity also verifies both surfaces: ### 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*. +Public Blockbook API checks are implemented in TypeScript in *blockbook/tests/openapi* and configured per coin by the +`api` list in *blockbook/tests/tests.json*. Use `make test-e2e` to run this suite only. +XPUB coverage for Bitcoin-type coins is fixture driven: `xpubFixtures` in *tests/openapi/fixtures/.json* lists +xpubs or output descriptors (derived from the public Trezor test seed `all all all ...`) together with the confirmed +transaction and used-address counts observed when the fixture was written. History only grows, so those counts act as +floors and a lower value means the instance is missing indexed data. The tests (`GetXpub*`, `WsGetAccountInfoXpub`, +`WsGetAccountUtxoXpub`) check that lifetime totals reconcile with the balance, that the `tokens` filters are consistent +views of one derivation, that paging is exact, that confirmed UTXOs sum to the balance, that balance history covers every +transaction, that a malformed xpub is a `400`, and that HTTP and WebSocket answers agree. Coins without a fixture file +skip these tests. + Phase 1 covers smoke checks for: * HTTP: `Status`, `GetBlockIndex`, `GetBlockByHeight`, `GetBlock`, `GetTransaction`, `GetTransactionSpecific`, `GetAddress`, `GetAddressTxids`, `GetAddressTxs`, `GetUtxo`, `GetUtxoConfirmedFilter` @@ -116,6 +125,9 @@ Endpoint resolution uses the test name from `coin.test_name` in `configs/coins/< 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 +`OPENAPI_USER_AGENT` overrides the User-Agent sent on HTTP and WebSocket requests, for local runs against public +instances whose edge filters non-browser agents. + ### Synchronization integration tests Synchronization is crucial part of Blockbook and these tests test whether it is doing well. They sync few blocks from @@ -150,8 +162,6 @@ fixture file. So data in fixture file must be related together. *txid* is key and *transaction object* is value of *txDetails* object in fixture file. * `GetTransactionForMempool` – Calls *BlockChain.GetTransactionForMempool* that should be version of *BlockChain.GetTransaction* optimized for mempool. Implementation of test is similar. -* `GetMempoolEntry` – Calls *BlockChain.GetMempoolEntry* and checks result. Because mempool is living structure it - tries to load entry for random transaction in mempool repeatedly. * `EstimateSmartFee` – Calls *BlockChain.EstimateSmartFee* for few numbers of blocks and checks if returned fee is non-negative. * `EstimateFee` – Calls *BlockChain.EstimateFee*; implementation is same as *EstimateSmartFee*. diff --git a/docs/tron-config.md b/docs/tron-config.md index 8cfd24546c..107abf2634 100644 --- a/docs/tron-config.md +++ b/docs/tron-config.md @@ -61,7 +61,12 @@ Set explicit `tron_fullnode_http_url_template` and `tron_solidity_http_url_templ * the scheme differs from the JSON-RPC endpoint * the deployment uses non-standard ports +## Sync Performance Tuning + +For initial-sync tuning notes, see [Tron Sync Tuning](/docs/tron-sync-tuning.md). + ## Related Docs * [Config](/docs/config.md) +* [Tron Sync Tuning](/docs/tron-sync-tuning.md) * [Testing](/docs/testing.md) diff --git a/docs/tron-sync-tuning.md b/docs/tron-sync-tuning.md new file mode 100644 index 0000000000..33d3104f49 --- /dev/null +++ b/docs/tron-sync-tuning.md @@ -0,0 +1,78 @@ +# Tron Sync Tuning + +Fast Tron initial sync needs tuning on both sides of the RPC boundary. In the +observed setup, the bottleneck was backend block-hash/block lookup latency, not +Blockbook Go GC or RocksDB writes. + +## java-tron + +Keep the backend on LevelDB, keep transaction history enabled, enable the LevelDB read tuning and raise the +HTTP connection limit enough for Blockbook's worker count: + +```hocon +storage { + db.engine = "LEVELDB" + db.sync = false + transHistory.switch = "on" + + # LevelDB read tuning from Tron TIP-343. Raises fd usage. + default = { + maxOpenFiles = 100 + } + defaultM = { + maxOpenFiles = 500 + } + defaultL = { + maxOpenFiles = 1000 + } +} + +node { + # With Blockbook -workers=32, start around 200. + maxHttpConnectNumber = 200 +} +``` + +Run java-tron with enough heap, but leave RAM for the OS page cache. On large +hosts, `-Xms32g -Xmx32g` with G1GC is a good starting point. Validate with GC +logs and disk latency; a larger heap is not automatically faster. + +Keep event subscription minimal for Blockbook sync: + +```hocon +event.subscribe { + topics = [ + { triggerName = "block", enable = true, topic = "block", solidified = false }, + { triggerName = "solidity", enable = true, topic = "solidity" } + ] +} +``` + +Do not enable `transaction`, `contractevent`, or `contractlog` topics just for +Blockbook initial sync. + +## Blockbook + +Run initial sync with enough workers to keep java-tron busy: + +```ini +ExecStart=.../blockbook ... -sync -workers=32 ... +``` + +Pair this with a sufficiently large Tron HTTP connection pool in Blockbook and +`node.maxHttpConnectNumber` in java-tron. Raising workers without raising the +backend connection limit just moves the queue. + +## Verify + +Useful indicators while syncing: + +* Blockbook log `elapsed` per 1000 blocks +* `blockbook_rpc_latency{method="GetBlockHash"}` and `method="GetBlock"`, increased latency means the bottleneck is on the Java-Tron side +* java-tron GC pauses and heap after GC +* disk utilization and await, for example `iostat -xz 1` +* open file descriptors for both services +* `blockbook_index_block_not_found_retries` + +With the tuning above and native hash lookup, observed throughput reached about +7.5-9.5 seconds per 1000 historical blocks on the tested setup. diff --git a/fiat/coingecko.go b/fiat/coingecko.go index 628f7cfe21..5d81aa602b 100644 --- a/fiat/coingecko.go +++ b/fiat/coingecko.go @@ -78,6 +78,18 @@ const ( var errCoingeckoHistoricalTokenUpdateInProgress = errors.New("coingecko historical token update already in progress") +// errCoingeckoIncompleteTokenRates marks a current ticker whose native rates are complete but +// some token rates are missing, so the caller can still publish it. +var errCoingeckoIncompleteTokenRates = errors.New("coingecko current token rates incomplete") + +// coingeckoMaxQueryLen is the longest query string api.coingecko.com accepts: since 2026-09-29 its +// CloudFront edge answers 403 "Request blocked" to longer ones. coingeckoQueryLenMargin keeps +// token batches clear of it in case the limit tightens. +const ( + coingeckoMaxQueryLen = 2048 + coingeckoQueryLenMargin = 100 +) + // Coingecko is a structure that implements RatesDownloaderInterface type Coingecko struct { tipURL string @@ -642,6 +654,8 @@ func (cg *Coingecko) CurrentTickers() (*common.CurrencyRatesTicker, error) { if err != nil || prices == nil { return nil, err } + // token batches are priced after this, so the native fetch time bounds the age of every rate + newTickers.Timestamp = time.Now().UTC() newTickers.Rates = make(map[string]float32, len((*prices)[cg.coin])) for t, v := range (*prices)[cg.coin] { newTickers.Rates[t] = v @@ -654,38 +668,89 @@ func (cg *Coingecko) CurrentTickers() (*common.CurrencyRatesTicker, error) { if platformIdsToTokens == nil { err = cg.platformIdsAt(context.Background(), cg.tipURL, priorityHigh) if err != nil { - return nil, err + return &newTickers, fmt.Errorf("%w: coins/list failed: %v", errCoingeckoIncompleteTokenRates, 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 - } + var tokenErr error + newTickers.TokenRates, tokenErr = cg.currentTokenRates(platformIds, platformIdsToTokens) + if tokenErr != nil { + return &newTickers, tokenErr } } - newTickers.Timestamp = time.Now().UTC() return &newTickers, nil } +// currentTokenRates prices platformIds in platformVsCurrency, keyed by contract address. A failed +// batch is retried once after the others, so one rejected request cannot discard the rates of +// all other tokens and a transient error does not drop its tokens for a whole period. +func (cg *Coingecko) currentTokenRates(platformIds []string, platformIdsToTokens map[string]string) (map[string]float32, error) { + rates := make(map[string]float32, len(platformIds)) + batches := batchIdsByParamLen(platformIds, tokenPriceIdsBudget(cg.platformVsCurrency)) + failed, banned := cg.fetchTokenPriceBatches(batches, platformIdsToTokens, rates) + if len(failed) > 0 && !banned { + failed, _ = cg.fetchTokenPriceBatches(failed, platformIdsToTokens, rates) + } + if len(failed) > 0 { + return rates, fmt.Errorf("%w: %d of %d batches failed", errCoingeckoIncompleteTokenRates, len(failed), len(batches)) + } + return rates, nil +} + +// fetchTokenPriceBatches stores the prices of batches into rates and returns the batches that +// failed. On a Cloudflare ban it stops and returns the rest as failed, as further requests would +// only extend the ban. +func (cg *Coingecko) fetchTokenPriceBatches(batches [][]string, platformIdsToTokens map[string]string, rates map[string]float32) (failed [][]string, banned bool) { + for i, batch := range batches { + prices, err := cg.simplePrice(context.Background(), batch, []string{cg.platformVsCurrency}) + if err != nil { + if isCoingeckoCloudflareBanError(err) { + return append(failed, batches[i:]...), true + } + failed = append(failed, batch) + continue + } + for id, v := range *prices { + if t, found := platformIdsToTokens[id]; found { + rates[t] = v[cg.platformVsCurrency] + } + } + } + return failed, false +} + +// tokenPriceIdsBudget is the ids parameter length that keeps a simple/price query for +// vsCurrency within coingeckoMaxQueryLen, minus coingeckoQueryLenMargin. +func tokenPriceIdsBudget(vsCurrency string) int { + overhead := len("ids=&vs_currencies=") + len(url.QueryEscape(vsCurrency)) + return coingeckoMaxQueryLen - coingeckoQueryLenMargin - overhead +} + +// batchIdsByParamLen splits ids into batches whose comma-joined, URL-encoded length fits maxLen. +// An id longer than maxLen on its own still gets a batch of its own. +func batchIdsByParamLen(ids []string, maxLen int) [][]string { + var batches [][]string + from, paramLen := 0, 0 + for i, id := range ids { + idLen := len(url.QueryEscape(id)) + if i > from { + idLen += len("%2C") + } + if i > from && paramLen+idLen > maxLen { + batches = append(batches, ids[from:i]) + from, paramLen = i, 0 + idLen -= len("%2C") + } + paramLen += idLen + } + if from < len(ids) { + batches = append(batches, ids[from:]) + } + return batches +} + 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 { diff --git a/fiat/current_tickers_test.go b/fiat/current_tickers_test.go new file mode 100644 index 0000000000..0ccea1a53e --- /dev/null +++ b/fiat/current_tickers_test.go @@ -0,0 +1,362 @@ +//go:build unittest + +package fiat + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/trezor/blockbook/common" +) + +const cloudfrontBlockedBody = "ERROR: The request could not be satisfied

403 ERROR

Request blocked.
Generated by cloudfront (CloudFront)
" + +func TestBatchIdsByParamLen(t *testing.T) { + long := strings.Repeat("x", 30) + tests := []struct { + name string + ids []string + maxLen int + want [][]string + }{ + {name: "empty", ids: nil, maxLen: 10, want: nil}, + {name: "single batch", ids: []string{"a", "b", "c"}, maxLen: 100, want: [][]string{{"a", "b", "c"}}}, + // "aaa%2Cbbb" is 9 chars, adding "%2Cccc" would make 15 + {name: "separator counted", ids: []string{"aaa", "bbb", "ccc"}, maxLen: 14, want: [][]string{{"aaa", "bbb"}, {"ccc"}}}, + {name: "exact fit", ids: []string{"aaa", "bbb", "ccc"}, maxLen: 15, want: [][]string{{"aaa", "bbb", "ccc"}}}, + {name: "oversized id alone", ids: []string{"a", long, "b"}, maxLen: 10, want: [][]string{{"a"}, {long}, {"b"}}}, + {name: "escaped length", ids: []string{"a b", "c"}, maxLen: 5, want: [][]string{{"a b"}, {"c"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := batchIdsByParamLen(tt.ids, tt.maxLen); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("batchIdsByParamLen() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBatchIdsByParamLen_BatchesStayWithinLimit(t *testing.T) { + ids := make([]string, 0, 5000) + for i := 0; i < cap(ids); i++ { + ids = append(ids, fmt.Sprintf("token-%d-%s", i, strings.Repeat("y", i%40))) + } + const maxLen = 1900 + batches := batchIdsByParamLen(ids, maxLen) + var joined []string + for _, b := range batches { + if l := len(url.QueryEscape(strings.Join(b, ","))); l > maxLen { + t.Fatalf("batch ids param is %d chars, limit %d", l, maxLen) + } + joined = append(joined, b...) + } + if !reflect.DeepEqual(joined, ids) { + t.Fatal("batches do not preserve all ids in order") + } +} + +// stubCoingecko serves a token platform of n ids like the api.coingecko.com CloudFront edge: a +// query string over coingeckoMaxQueryLen is rejected with 403. The batch containing blockedID is +// rejected with blockedBody (CloudFront 403 by default) the first blockedTimes times (-1: always). +type stubCoingecko struct { + n int + blockedID string + blockedTimes int + blockedBody string + coinsListStatus int + + tokenRequests int + maxQueryLen int + firstTokenAt time.Time +} + +func (s *stubCoingecko) roundTrip(r *http.Request) (*http.Response, error) { + if len(r.URL.RawQuery) > coingeckoMaxQueryLen { + return stubHTTPResponse(http.StatusForbidden, cloudfrontBlockedBody), nil + } + switch r.URL.Path { + case "/simple/supported_vs_currencies": + return stubHTTPResponse(http.StatusOK, `["usd","eur"]`), nil + case "/coins/list": + if s.coinsListStatus != 0 && s.coinsListStatus != http.StatusOK { + return stubHTTPResponse(s.coinsListStatus, cloudfrontBlockedBody), nil + } + coins := make([]string, 0, s.n) + for i := 0; i < s.n; i++ { + coins = append(coins, fmt.Sprintf(`{"id":"token-%04d","symbol":"t","name":"t","platforms":{"ethereum":"0x%040x"}}`, i, i)) + } + return stubHTTPResponse(http.StatusOK, "["+strings.Join(coins, ",")+"]"), nil + case "/simple/price": + ids := strings.Split(r.URL.Query().Get("ids"), ",") + if ids[0] == "ethereum" { + return stubHTTPResponse(http.StatusOK, `{"ethereum":{"usd":2000,"eur":1800}}`), nil + } + if s.tokenRequests == 0 { + s.firstTokenAt = time.Now().UTC() + } + s.tokenRequests++ + if len(r.URL.RawQuery) > s.maxQueryLen { + s.maxQueryLen = len(r.URL.RawQuery) + } + parts := make([]string, 0, len(ids)) + for _, id := range ids { + if id == s.blockedID && s.blockedTimes != 0 { + s.blockedTimes-- + body := s.blockedBody + if body == "" { + body = cloudfrontBlockedBody + } + return stubHTTPResponse(http.StatusForbidden, body), nil + } + var i int + fmt.Sscanf(id, "token-%04d", &i) + parts = append(parts, fmt.Sprintf(`%q:{"eth":%d}`, id, i+1)) + } + return stubHTTPResponse(http.StatusOK, "{"+strings.Join(parts, ",")+"}"), nil + } + return stubHTTPResponse(http.StatusNotFound, "unexpected path "+r.URL.Path), nil +} + +func (s *stubCoingecko) coingecko() *Coingecko { + return &Coingecko{ + coin: "ethereum", + platformIdentifier: "ethereum", + platformVsCurrency: "eth", + tipURL: "http://coingecko.test", + httpClient: &http.Client{Transport: roundTripFunc(s.roundTrip)}, + plan: coingeckoPlanFree, + } +} + +func stubTokenRates(n int) map[string]float32 { + rates := make(map[string]float32, n) + for i := 0; i < n; i++ { + rates[fmt.Sprintf("0x%040x", i)] = float32(i + 1) + } + return rates +} + +func tokenBatchCount(n int) int { + ids := make([]string, n) + for i := range ids { + ids[i] = fmt.Sprintf("token-%04d", i) + } + return len(batchIdsByParamLen(ids, tokenPriceIdsBudget("eth"))) +} + +func TestCurrentTickers_TokenBatchesFitUnderQueryLimit(t *testing.T) { + // 1000 ids of 10 chars need several batches; a single oversized one would be rejected + stub := &stubCoingecko{n: 1000} + ticker, err := stub.coingecko().CurrentTickers() + if err != nil { + t.Fatalf("CurrentTickers() error = %v", err) + } + if limit := coingeckoMaxQueryLen - coingeckoQueryLenMargin; stub.maxQueryLen > limit || stub.maxQueryLen < limit-20 { + t.Fatalf("longest token-price query is %d chars, want just under %d", stub.maxQueryLen, limit) + } + if !reflect.DeepEqual(ticker.TokenRates, stubTokenRates(1000)) { + t.Fatalf("got %d token rates, want %d", len(ticker.TokenRates), 1000) + } + if want := tokenBatchCount(1000); stub.tokenRequests != want || want < 2 { + t.Fatalf("token requests = %d, want %d (>= 2)", stub.tokenRequests, want) + } +} + +func TestCurrentTickers_TimestampIsNativeFetchTime(t *testing.T) { + stub := &stubCoingecko{n: 1000} + before := time.Now().UTC() + ticker, err := stub.coingecko().CurrentTickers() + if err != nil { + t.Fatalf("CurrentTickers() error = %v", err) + } + if ticker.Timestamp.Before(before) || ticker.Timestamp.After(stub.firstTokenAt) { + t.Fatalf("Timestamp = %v, want between start %v and the first token request %v", ticker.Timestamp, before, stub.firstTokenAt) + } +} + +func TestCurrentTickers_TransientBatchFailureIsRetried(t *testing.T) { + stub := &stubCoingecko{n: 1000, blockedID: "token-0000", blockedTimes: 1} + ticker, err := stub.coingecko().CurrentTickers() + if err != nil { + t.Fatalf("CurrentTickers() error = %v", err) + } + if !reflect.DeepEqual(ticker.TokenRates, stubTokenRates(1000)) { + t.Fatalf("got %d token rates, want all %d after the retry", len(ticker.TokenRates), 1000) + } + if want := tokenBatchCount(1000) + 1; stub.tokenRequests != want { + t.Fatalf("token requests = %d, want %d (one retry)", stub.tokenRequests, want) + } +} + +func TestCurrentTickers_FailedTokenBatchKeepsNativeAndOtherTokenRates(t *testing.T) { + stub := &stubCoingecko{n: 1000, blockedID: "token-0000", blockedTimes: -1} + ticker, err := stub.coingecko().CurrentTickers() + if !errors.Is(err, errCoingeckoIncompleteTokenRates) { + t.Fatalf("CurrentTickers() error = %v, want errCoingeckoIncompleteTokenRates", err) + } + if ticker == nil { + t.Fatal("expected a ticker with the rates that were fetched") + } + if want := map[string]float32{"usd": 2000, "eur": 1800}; !reflect.DeepEqual(ticker.Rates, want) { + t.Fatalf("Rates = %v, want %v", ticker.Rates, want) + } + if ticker.Timestamp.IsZero() { + t.Fatal("expected ticker timestamp to be set") + } + if _, found := ticker.TokenRates[fmt.Sprintf("0x%040x", 0)]; found { + t.Fatal("token from the failed batch must be missing") + } + all := stubTokenRates(1000) + if len(ticker.TokenRates) == 0 || len(ticker.TokenRates) >= len(all) { + t.Fatalf("got %d token rates, want only those of the successful batches (< %d)", len(ticker.TokenRates), len(all)) + } + for contract, rate := range ticker.TokenRates { + if all[contract] != rate { + t.Fatalf("TokenRates[%s] = %v, want %v", contract, rate, all[contract]) + } + } + if want := tokenBatchCount(1000) + 1; stub.tokenRequests != want { + t.Fatalf("token requests = %d, want %d (failed batch retried exactly once)", stub.tokenRequests, want) + } +} + +func TestCurrentTickers_CloudflareBanStopsWithoutRetry(t *testing.T) { + // the ban hits the second batch: the first one is kept, the rest is neither requested nor retried + ids := make([]string, 1000) + for i := range ids { + ids[i] = fmt.Sprintf("token-%04d", i) + } + batches := batchIdsByParamLen(ids, tokenPriceIdsBudget("eth")) + stub := &stubCoingecko{n: 1000, blockedID: batches[1][0], blockedTimes: -1, blockedBody: cloudflareBanBody} + ticker, err := stub.coingecko().CurrentTickers() + if !errors.Is(err, errCoingeckoIncompleteTokenRates) { + t.Fatalf("CurrentTickers() error = %v, want errCoingeckoIncompleteTokenRates", err) + } + if want := fmt.Sprintf("%d of %d batches failed", len(batches)-1, len(batches)); !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want it to report %q", err, want) + } + if stub.tokenRequests != 2 { + t.Fatalf("token requests = %d, want 2 (stop at the ban, no retry)", stub.tokenRequests) + } + if len(ticker.TokenRates) != len(batches[0]) || ticker.Rates["usd"] != 2000 { + t.Fatalf("got %d token rates and usd %v, want %d tokens of the first batch and native rates", len(ticker.TokenRates), ticker.Rates["usd"], len(batches[0])) + } +} + +func TestCurrentTickers_FailedCoinsListKeepsNativeRates(t *testing.T) { + stub := &stubCoingecko{n: 10, coinsListStatus: http.StatusForbidden} + ticker, err := stub.coingecko().CurrentTickers() + if !errors.Is(err, errCoingeckoIncompleteTokenRates) { + t.Fatalf("CurrentTickers() error = %v, want errCoingeckoIncompleteTokenRates", err) + } + if ticker == nil || ticker.Rates["usd"] != 2000 || ticker.Timestamp.IsZero() || len(ticker.TokenRates) != 0 { + t.Fatalf("expected native rates and timestamp without token rates, got %+v", ticker) + } +} + +type stubCurrentTickersDownloader struct { + RatesDownloaderInterface + ticker *common.CurrencyRatesTicker + err error +} + +func (s *stubCurrentTickersDownloader) CurrentTickers() (*common.CurrencyRatesTicker, error) { + return s.ticker, s.err +} + +func TestUpdateCurrentTickers(t *testing.T) { + prevTs := time.Now().Add(-time.Hour).UTC() + newTs := time.Now().UTC() + prev := func() *common.CurrencyRatesTicker { + return &common.CurrencyRatesTicker{ + Timestamp: prevTs, + Rates: map[string]float32{"usd": 1}, + TokenRates: map[string]float32{"0xa": 10, "0xb": 20}, + } + } + tests := []struct { + name string + prev *common.CurrencyRatesTicker + ticker *common.CurrencyRatesTicker + err error + want *common.CurrencyRatesTicker + notified bool + }{ + { + name: "complete ticker replaces previous one", + prev: prev(), + ticker: &common.CurrencyRatesTicker{Timestamp: newTs, Rates: map[string]float32{"usd": 2}, TokenRates: map[string]float32{"0xa": 11}}, + want: &common.CurrencyRatesTicker{Timestamp: newTs, Rates: map[string]float32{"usd": 2}, TokenRates: map[string]float32{"0xa": 11}}, + notified: true, + }, + { + // a token from a failed batch must not carry its previous rate under the new timestamp + name: "incomplete tokens publish only the fetched token rates", + prev: prev(), + ticker: &common.CurrencyRatesTicker{Timestamp: newTs, Rates: map[string]float32{"usd": 2}, TokenRates: map[string]float32{"0xa": 11}}, + err: fmt.Errorf("%w: 1 of 2 batches failed", errCoingeckoIncompleteTokenRates), + want: &common.CurrencyRatesTicker{Timestamp: newTs, Rates: map[string]float32{"usd": 2}, TokenRates: map[string]float32{"0xa": 11}}, + notified: true, + }, + { + name: "incomplete tokens without previous ticker", + ticker: &common.CurrencyRatesTicker{Timestamp: newTs, Rates: map[string]float32{"usd": 2}}, + err: fmt.Errorf("%w: coins/list failed", errCoingeckoIncompleteTokenRates), + want: &common.CurrencyRatesTicker{Timestamp: newTs, Rates: map[string]float32{"usd": 2}}, + notified: true, + }, + { + name: "native failure keeps previous ticker", + prev: prev(), + err: errors.New("simple/price failed"), + want: prev(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := common.Config{CoinName: "fakecoin"} + d, _, tmp := setupRocksDB(t, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + notified := false + fr := &FiatRates{ + db: d, + currentTicker: tt.prev, + downloader: &stubCurrentTickersDownloader{ticker: tt.ticker, err: tt.err}, + callbackOnNewTicker: func(*common.CurrencyRatesTicker) { notified = true }, + } + fr.updateCurrentTickers() + if got := fr.GetCurrentTicker("", ""); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("current ticker = %+v, want %+v", got, tt.want) + } + if notified != tt.notified { + t.Fatalf("notified = %v, want %v", notified, tt.notified) + } + if !tt.notified { + return + } + stored, err := d.FiatRatesGetSpecialTickers(currentTickersKey) + if err != nil || stored == nil || len(*stored) != 1 { + t.Fatalf("stored current ticker = %v, %v", stored, err) + } + if got := (*stored)[0].TokenRates; len(got)+len(tt.want.TokenRates) > 0 && !reflect.DeepEqual(got, tt.want.TokenRates) { + t.Fatalf("stored TokenRates = %v, want %v", (*stored)[0].TokenRates, tt.want.TokenRates) + } + }) + } +} + +func TestTokenPriceIdsBudget(t *testing.T) { + for _, vs := range []string{"usd", "bnb", "eth", "matic-network"} { + ids := strings.Repeat("a", tokenPriceIdsBudget(vs)) + query := url.Values{"ids": {ids}, "vs_currencies": {vs}}.Encode() + if want := coingeckoMaxQueryLen - coingeckoQueryLenMargin; len(query) != want { + t.Fatalf("vs=%s: query with a full ids budget is %d chars, want %d", vs, len(query), want) + } + } +} diff --git a/fiat/fiat_rates.go b/fiat/fiat_rates.go index 41ae6212ac..9c04b06d2f 100644 --- a/fiat/fiat_rates.go +++ b/fiat/fiat_rates.go @@ -596,13 +596,18 @@ func (fr *FiatRates) runCurrentLoop() { func (fr *FiatRates) updateCurrentTickers() { start := time.Now() currentTicker, err := fr.downloader.CurrentTickers() - if err != nil || currentTicker == nil { + status := "success" + if currentTicker != nil && errors.Is(err, errCoingeckoIncompleteTokenRates) { + // publish the native rates; missing tokens are reported as unknown rather than served stale + status = "partial" + glog.Warningf("FiatRatesDownloader: CurrentTickers %v, publishing %d token rates", err, len(currentTicker.TokenRates)) + } else 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) + fr.observeUpdateDuration("current_tickers", status, start) glog.Info("FiatRatesDownloader: CurrentTickers updated") if fr.callbackOnNewTicker != nil { fr.callbackOnNewTicker(currentTicker) diff --git a/fiat/fiat_rates_test.go b/fiat/fiat_rates_test.go index f7643607b7..989de2729f 100644 --- a/fiat/fiat_rates_test.go +++ b/fiat/fiat_rates_test.go @@ -427,7 +427,7 @@ func TestGetTickersForTimestamps_ConcurrentReadersAndWriters(t *testing.T) { writers = 2 readers = 8 testDuration = 1200 * time.Millisecond - waitTimeout = 3 * time.Second + waitTimeout = 30 * time.Second // deadlock safety-net; kept generous so a CPU-starved CI runner does not flake ) stop := make(chan struct{}) @@ -581,7 +581,7 @@ func TestLogTickersInfo_ConcurrentWithCacheWriters(t *testing.T) { writers = 2 loggers = 4 testDuration = 300 * time.Millisecond - waitTimeout = 3 * time.Second + waitTimeout = 30 * time.Second // deadlock safety-net; kept generous so a CPU-starved CI runner does not flake ) stop := make(chan struct{}) diff --git a/fourbyte/fourbyte.go b/fourbyte/fourbyte.go index 2be1a158e1..cc846d5206 100644 --- a/fourbyte/fourbyte.go +++ b/fourbyte/fourbyte.go @@ -5,6 +5,7 @@ import ( "errors" "io/ioutil" "net/http" + "runtime/debug" "strconv" "strings" "time" @@ -102,7 +103,8 @@ func (fd *FourByteSignaturesDownloader) getPageWithRetry(url string) (*signature func parseSignatureFromText(t string) *bchain.FourByteSignature { s := strings.Index(t, "(") e := strings.LastIndex(t, ")") - if s < 0 || e < 0 { + // need a non-empty name, '(', and ')' after it (e <= s also blocks "a)b(") + if s <= 0 || e <= s { return nil } var signature bchain.FourByteSignature @@ -129,6 +131,15 @@ func parseSignatureFromText(t string) *bchain.FourByteSignature { } func (fd *FourByteSignaturesDownloader) downloadSignatures() { + // Run() executes on its own goroutine, so a panic here escapes main()'s + // recover and kills the whole process. Recover as a last-resort safety net + // over the entire download/store pipeline so untrusted feed content can + // never crash the indexer; log the stack trace to keep it diagnosable. + defer func() { + if r := recover(); r != nil { + glog.Errorf("FourByteSignaturesDownloader recovered from panic: %v\n%s", r, debug.Stack()) + } + }() period := time.Millisecond * 100 timer := time.NewTimer(period) url := fd.url @@ -146,18 +157,26 @@ func (fd *FourByteSignaturesDownloader) downloadSignatures() { } 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 + // dedup anchor (results newest-first): break once we reach a + // signature already stored. Scan for the first parseable entry so a + // malformed newest item can't skip the check and force a full re-sync. + reachedStored := false + for i := range page.Results { + r := &page.Results[i] + fourBytes, err := strconv.ParseUint(r.HexSignature, 0, 32) + if err != nil { + glog.Errorf("Invalid 4byte signature %+v on page %s: %v", *r, url, err) + continue + } + sig, err := fd.db.GetFourByteSignature(uint32(fourBytes), uint32(r.Id)) + if err != nil { + glog.Errorf("db.GetFourByteSignature error %+v on page %s: %v", *r, url, err) + return + } + reachedStored = sig != nil + break } - // signature is already stored in db, break - if sig != nil { + if reachedStored { break } results = append(results, page.Results...) @@ -178,10 +197,12 @@ func (fd *FourByteSignaturesDownloader) downloadSignatures() { for i := range results { r := &results[i] - fourBytes, err := strconv.ParseUint(r.HexSignature, 0, 0) + // bitSize 32: reject over-long values uint32() would mis-truncate + fourBytes, err := strconv.ParseUint(r.HexSignature, 0, 32) if err != nil { + // skip only this entry so one bad hex cannot abort the batch glog.Errorf("Invalid 4byte signature %+v: %v", r, err) - return + continue } fbs := parseSignatureFromText(r.TextSignature) if fbs != nil { diff --git a/fourbyte/fourbyte_test.go b/fourbyte/fourbyte_test.go index c64ddad5ce..9db0c40725 100644 --- a/fourbyte/fourbyte_test.go +++ b/fourbyte/fourbyte_test.go @@ -53,3 +53,28 @@ func Test_parseSignatureFromText(t *testing.T) { }) } } + +func Test_parseSignatureFromText_malformed(t *testing.T) { + // crowdsourced 4byte content is untrusted; malformed text must return nil + // rather than panic (see t[s+1:e] slicing with e <= s). + tests := []struct { + name string + signature string + }{ + {name: "no parentheses", signature: "notASignature"}, + {name: "missing open", signature: "foo)"}, + {name: "missing close", signature: "foo("}, + {name: "close before open", signature: "a)b("}, + {name: "reversed parens", signature: ")("}, + {name: "empty", signature: ""}, + {name: "empty name no params", signature: "()"}, + {name: "empty name with params", signature: "(uint256)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseSignatureFromText(tt.signature); got != nil { + t.Errorf("parseSignatureFromText(%q) = %v, want nil", tt.signature, *got) + } + }) + } +} diff --git a/go.mod b/go.mod index ee50fbaf7c..9ed7c1e763 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,9 @@ 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.16.7 - github.com/golang/glog v1.2.1 - github.com/gorilla/websocket v1.5.0 + github.com/ethereum/go-ethereum v1.17.0 + github.com/golang/glog v1.2.4 + github.com/gorilla/websocket v1.5.3 github.com/juju/errors v0.0.0-20170703010042-c7d06af17c68 github.com/linxGnu/grocksdb v1.9.8 github.com/martinboehm/bchutil v0.0.0-20190104112650-6373f11b6efe @@ -26,12 +26,13 @@ require ( 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/prometheus/client_model v0.6.2 github.com/schancel/cashaddr-converter v0.0.0-20181111022653-4769e7add95a 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 + golang.org/x/crypto v0.52.0 + golang.org/x/sync v0.18.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -50,7 +51,6 @@ require ( 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 @@ -63,7 +63,8 @@ require ( github.com/decred/dcrd/wire v1.4.0 // indirect github.com/decred/slog v1.1.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect - github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.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 @@ -73,7 +74,6 @@ require ( 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 @@ -83,8 +83,12 @@ require ( 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.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.45.0 // indirect gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect ) diff --git a/go.sum b/go.sum index 573a0db29d..396bf00e39 100644 --- a/go.sum +++ b/go.sum @@ -57,8 +57,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo 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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -111,14 +109,17 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3 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/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= +github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= 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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/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= @@ -128,18 +129,24 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/glog v1.2.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4= -github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc= +github.com/golang/glog v1.2.4/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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= 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/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= @@ -193,8 +200,6 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec 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= @@ -205,8 +210,6 @@ 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/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/gomega v1.4.1 h1:PZSj/UFNaVp3KxrzHOcS7oyuWA7LoOY/77yCTEFu21U= @@ -240,8 +243,6 @@ github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2x 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= @@ -276,6 +277,16 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC 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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= 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= @@ -284,29 +295,29 @@ golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= 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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -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/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -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/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/openapi.yaml b/openapi.yaml index f289b590ac..fabffd2c91 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -158,6 +158,7 @@ paths: schema: type: string - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/BlockPageSize" responses: "200": description: Block details. @@ -1184,7 +1185,23 @@ components: PageSize: name: pageSize in: query - description: Number of history items per page. The default and maximum for REST account endpoints is 1000. + description: >- + Number of history items per page. The default and maximum for REST account endpoints + is 1000. Unconfirmed transactions take the first slots of the paged history and count + towards both pageSize and totalPages, so a page is never longer than pageSize and, as + long as the account's pending set does not change between page requests, walking all + pages returns every transaction exactly once. A pending transaction that is replaced + or evicted mid-walk shifts the following pages; for a walk that is consistent under + mempool churn, set the to parameter to the current best height, which pages only the + confirmed history. + schema: + type: integer + minimum: 1 + maximum: 1000 + BlockPageSize: + name: pageSize + in: query + description: Number of transactions per block page. The default and maximum is 1000. schema: type: integer minimum: 1 @@ -1456,6 +1473,7 @@ components: gasLimit: 550941 gasUsed: 434686 gasPrice: "44035608242" + effectiveGasPrice: "44035608242" maxPriorityFeePerGas: "44035608243" maxFeePerGas: "44035608244" baseFeePerGas: "2035608244" @@ -1823,7 +1841,7 @@ components: 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] + enum: ["", XPUBAddress, ERC20, ERC721, ERC1155, BEP20, BEP721, BEP1155, TRC20, TRC721, TRC1155, SPT, SPTAllocated] BlockHashResponse: type: object @@ -1896,6 +1914,25 @@ components: type: array items: $ref: "#/components/schemas/MultiTokenValue" + assetGuid: + type: string + description: Syscoin SPT asset GUID. + + AssetInfo: + type: object + description: Syscoin SPT metadata attached to a transaction input, output, or UTXO. + properties: + assetGuid: + type: string + description: Syscoin SPT asset GUID. + value: + $ref: "#/components/schemas/AmountString" + valueStr: + type: string + description: SPT amount as a decimal string. + symbol: + type: string + description: SPT asset symbol, if available. Vin: type: object @@ -1927,6 +1964,8 @@ components: type: string coinbase: type: string + assetInfo: + $ref: "#/components/schemas/AssetInfo" Vout: type: object @@ -1961,6 +2000,8 @@ components: type: boolean type: type: string + assetInfo: + $ref: "#/components/schemas/AssetInfo" EthereumInternalTransfer: type: object @@ -2003,6 +2044,9 @@ components: type: array items: $ref: "#/components/schemas/EthereumParsedInputParam" + parsedDataTruncated: + type: boolean + description: True when input data exceeded the parse-size budget and parameters were not decoded. EthereumSpecific: type: object @@ -2028,6 +2072,8 @@ components: format: int64 gasPrice: $ref: "#/components/schemas/AmountString" + effectiveGasPrice: + $ref: "#/components/schemas/AmountString" maxPriorityFeePerGas: $ref: "#/components/schemas/AmountString" maxFeePerGas: @@ -2147,7 +2193,8 @@ components: description: Syscoin SPT transaction type. memo: type: string - description: Syscoin SPT memo decoded from OP_RETURN. + contentEncoding: base64 + description: Base64-encoded Syscoin SPT memo decoded from OP_RETURN. ethereumSpecific: $ref: "#/components/schemas/EthereumSpecific" addressAliases: @@ -2379,10 +2426,18 @@ components: type: string usedTokens: type: integer + usedAssetTokens: + type: integer + description: Number of Syscoin SPT assets with historical usage at this address. tokens: type: array items: $ref: "#/components/schemas/Token" + tokensAsset: + type: array + description: Syscoin SPT assets associated with this address. + items: + $ref: "#/components/schemas/Token" secondaryValue: type: number tokensBaseValue: @@ -2431,6 +2486,8 @@ components: type: integer coinbase: type: boolean + assetInfo: + $ref: "#/components/schemas/AssetInfo" BalanceHistory: type: object @@ -2817,6 +2874,25 @@ components: type: integer confirmedNonce: type: boolean + privatePending: + $ref: "#/components/schemas/WsPrivatePending" + + WsPrivatePending: + description: >- + Ethereum-like only: the private (alternative send-tx / relay) transactions the wallet knows + are in flight for the queried address. Only nonces drive behavior; txids are reserved for + future use. See docs/evm-send.md. + type: object + properties: + nonces: + type: array + items: + type: integer + format: int64 + txids: + type: array + items: + type: string WsContractInfoReq: type: object @@ -2960,6 +3036,8 @@ components: type: string value: type: string + privatePending: + $ref: "#/components/schemas/WsPrivatePending" Eip1559Fee: type: object @@ -3007,6 +3085,35 @@ components: type: string enum: [up, down] + EthereumGasData: + type: object + description: > + EVM block-level gas figures pushed with subscribeNewBlock notifications, used by the + frontend to deterministically project the next block's EIP-1559 base fee. Decimal strings, + omitted for pre-London blocks. + properties: + baseFeePerGas: + $ref: "#/components/schemas/AmountString" + blockGasUsed: + $ref: "#/components/schemas/AmountString" + blockGasLimit: + $ref: "#/components/schemas/AmountString" + + WsNewBlock: + type: object + required: [height, hash, evmData] + description: Pushed to subscribeNewBlock subscribers when a new block is connected. + properties: + height: + type: number + hash: + type: string + evmData: + description: EVM gas data for the EIP-1559 base-fee projection; null on non-EVM chains. + oneOf: + - $ref: "#/components/schemas/EthereumGasData" + - type: "null" + WsEstimateFeeRes: type: object properties: @@ -3027,6 +3134,12 @@ components: disableAlternativeRpc: type: boolean default: false + maxfeerate: + type: string + description: Optional Syscoin Core maxfeerate parameter. + maxburnamount: + type: string + description: Optional Syscoin Core maxburnamount parameter. WsMempoolFiltersReq: type: object diff --git a/server/client_ip.go b/server/client_ip.go index fbc8271f73..d2155766cc 100644 --- a/server/client_ip.go +++ b/server/client_ip.go @@ -334,6 +334,20 @@ func parseAddr(value string) (netip.Addr, bool) { return addr.Unmap().WithZone(""), true } +// isWhitelistedIP checks whether the given IP address falls inside any of the +// configured whitelist prefixes. IPs in the whitelist bypass all rate limiting +// (request-rate, concurrency, and automatic IP blocking) on the surface that +// loaded the list, so operators can exclude their own office or monitoring +// infrastructure from the per-client budget. +func isWhitelistedIP(addr netip.Addr, prefixes []netip.Prefix) bool { + for _, p := range prefixes { + if p.Contains(addr) { + return true + } + } + return false +} + // 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). diff --git a/server/html_templates.go b/server/html_templates.go index bbe1f4e9dc..98d298c88d 100644 --- a/server/html_templates.go +++ b/server/html_templates.go @@ -76,6 +76,13 @@ type htmlTemplates[TD any] struct { postHtmlTemplateHandler func(data *TD, w http.ResponseWriter, r *http.Request) } +// debugErrorText renders an error for a debug-mode response with its URLs reduced to scheme and +// host: debug messages carry raw backend error text, which is exactly where a provider API key +// sits (the http client renders the full URL into Post "": ... on a dial failure). +func debugErrorText(format string, a ...interface{}) string { + return common.RedactURLs(fmt.Sprintf(format, a...)) +} + 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"` @@ -90,7 +97,7 @@ func (s *htmlTemplates[TD]) jsonHandler(handler func(r *http.Request, apiVersion 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} + data = jsonError{debugErrorText("Internal server error: recovered from panic %v", e), http.StatusInternalServerError} } else { data = jsonError{"Internal server error", http.StatusInternalServerError} } @@ -114,10 +121,12 @@ func (s *htmlTemplates[TD]) jsonHandler(handler func(r *http.Request, apiVersion data, err = handler(r, apiVersion) if err != nil || data == nil { if apiErr, ok := err.(*api.APIError); ok { + // the client sees this text on both branches, so it must not carry a backend URL + text := api.RedactAPIError(apiErr).Error() if apiErr.Public { - data = jsonError{apiErr.Error(), http.StatusBadRequest} + data = jsonError{text, http.StatusBadRequest} } else { - data = jsonError{apiErr.Error(), http.StatusInternalServerError} + data = jsonError{text, http.StatusInternalServerError} } } else { if err != nil { @@ -125,9 +134,9 @@ func (s *htmlTemplates[TD]) jsonHandler(handler func(r *http.Request, apiVersion } if s.debug { if data != nil { - data = jsonError{fmt.Sprintf("Internal server error: %v, data %+v", err, data), http.StatusInternalServerError} + data = jsonError{debugErrorText("Internal server error: %v, data %+v", err, data), http.StatusInternalServerError} } else { - data = jsonError{fmt.Sprintf("Internal server error: %v", err), http.StatusInternalServerError} + data = jsonError{debugErrorText("Internal server error: %v", err), http.StatusInternalServerError} } } else { data = jsonError{"Internal server error", http.StatusInternalServerError} @@ -149,7 +158,7 @@ func (s *htmlTemplates[TD]) htmlTemplateHandler(handler func(w http.ResponseWrit debug.PrintStack() t = errorInternalTpl if s.debug { - data = s.newTemplateDataWithError(&api.APIError{Text: fmt.Sprint("Internal server error: recovered from panic ", e)}, r) + data = s.newTemplateDataWithError(&api.APIError{Text: debugErrorText("Internal server error: recovered from panic %v", e)}, r) } else { data = s.newTemplateDataWithError(&api.APIError{Text: "Internal server error"}, r) } @@ -182,7 +191,7 @@ func (s *htmlTemplates[TD]) htmlTemplateHandler(handler func(w http.ResponseWrit if err != nil || (data == nil && t != noTpl) { t = errorInternalTpl if apiErr, ok := err.(*api.APIError); ok { - data = s.newTemplateDataWithError(apiErr, r) + data = s.newTemplateDataWithError(api.RedactAPIError(apiErr), r) if apiErr.Public { t = errorTpl } @@ -191,7 +200,7 @@ func (s *htmlTemplates[TD]) htmlTemplateHandler(handler func(w http.ResponseWrit 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) + data = s.newTemplateDataWithError(&api.APIError{Text: debugErrorText("Internal server error: %v, data %+v", err, data)}, r) } else { data = s.newTemplateDataWithError(&api.APIError{Text: "Internal server error"}, r) } diff --git a/server/internal.go b/server/internal.go index 30584f05a8..96bfa00687 100644 --- a/server/internal.go +++ b/server/internal.go @@ -14,10 +14,10 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/golang/glog" - "github.com/juju/errors" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/trezor/blockbook/api" "github.com/trezor/blockbook/bchain" @@ -45,6 +45,12 @@ type InternalServer struct { adminAuthEnabled bool adminUserHash [32]byte adminPassHash [32]byte + // runtimeSettingsMux serializes runtime-setting writes (validate → store + // to DB → publish snapshot) so concurrent admin requests cannot publish a + // snapshot whose value lost the database write. runtimeSettings is the + // persistence backend of those writes (the RocksDB in production). + runtimeSettingsMux sync.Mutex + runtimeSettings runtimeSettingStore } // NewInternalServer creates new internal http interface to blockbook and returns its handle @@ -57,22 +63,28 @@ func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.B addr, path := splitBinding(binding) serveMux := http.NewServeMux() https := &http.Server{ - Addr: addr, - Handler: serveMux, + Addr: addr, + Handler: serveMux, + ReadHeaderTimeout: httpReadHeaderTimeout, + ReadTimeout: httpReadTimeout, + WriteTimeout: httpInternalWriteTimeout, + IdleTimeout: httpIdleTimeout, + MaxHeaderBytes: httpMaxHeaderBytes, } s := &InternalServer{ htmlTemplates: htmlTemplates[InternalTemplateData]{ debug: true, }, - https: https, - certFiles: certFiles, - db: db, - txCache: txCache, - chain: chain, - chainParser: chain.GetChainParser(), - mempool: mempool, - is: is, - api: api, + https: https, + certFiles: certFiles, + db: db, + txCache: txCache, + chain: chain, + chainParser: chain.GetChainParser(), + mempool: mempool, + is: is, + api: api, + runtimeSettings: db, } s.htmlTemplates.newTemplateData = s.newTemplateData s.htmlTemplates.newTemplateDataWithError = s.newTemplateDataWithError @@ -103,6 +115,15 @@ func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.B 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))) + // Runtime settings are chain-generic (initRpcCallAllowlists already runs + // unconditionally in NewWebsocketServer); the currently defined settings only + // affect EVM rpcCall and are simply unset on other chains. The init must stay + // before the route registration — currentRpcCallAllowlists relies on it. + if err := initRpcCallAllowlists(db, is); err != nil { + return nil, err + } + serveMux.HandleFunc(adminPath+"/runtime-settings", s.requireAdminAuth(s.htmlTemplateHandler(s.runtimeSettingsPage))) + serveMux.HandleFunc(adminPath+"/runtime-settings/", s.requireAdminAuth(s.jsonHandler(s.apiRuntimeSetting, 0))) 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))) @@ -138,6 +159,20 @@ func (s *InternalServer) adminSubtreeHandler(adminPath string) http.HandlerFunc } } +// urlPathSegment returns the last segment of the request path with surrounding +// whitespace trimmed, or "" when the path ends in "/" or has no sub-segment +// below a registered subtree (a root-level path like "/admin" yields ""). Callers +// apply their own normalization on top (runtime-setting keys are uppercased, +// contract addresses are left to the chain parser, the authority on case and +// checksum handling). +func urlPathSegment(r *http.Request) string { + var seg string + if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { + seg = r.URL.Path[i+1:] + } + return strings.TrimSpace(seg) +} + // 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 @@ -154,10 +189,49 @@ func (s *InternalServer) requireAdminAuth(next http.HandlerFunc) http.HandlerFun http.Error(w, "unauthorized", http.StatusUnauthorized) return } + // Browsers replay cached Basic credentials cross-origin, so reject + // state-changing requests forged by a page the admin visits (CSRF). + if isStateChangingMethod(r.Method) && !s.sameOrigin(r) { + // Logged so a Host-rewriting reverse proxy (which makes the Origin + // mismatch r.Host) is diagnosable rather than a silent 403. + glog.Warningf("admin: rejected cross-origin %s %s (Origin=%q Referer=%q Host=%q, client %s)", + r.Method, r.URL.Path, r.Header.Get("Origin"), r.Header.Get("Referer"), r.Host, r.RemoteAddr) + http.Error(w, "cross-origin request forbidden", http.StatusForbidden) + return + } next(w, r) } } +// isStateChangingMethod reports whether a method can mutate state and needs CSRF +// protection; the safe GET/HEAD/OPTIONS methods only read and are exempt. +func isStateChangingMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return false + } + return true +} + +// sameOrigin reports whether a state-changing request is same-origin. Browsers +// send Origin/Referer cross-origin; clients that send neither (curl) are allowed. +func (s *InternalServer) sameOrigin(r *http.Request) bool { + raw := r.Header.Get("Origin") + if raw == "" { + raw = r.Header.Get("Referer") + } + if raw == "" { + return true + } + scheme, host, ok := parseOrigin(raw) + if !ok || !strings.EqualFold(host, r.Host) { + return false + } + // When this server terminates TLS, a same-host request must also be https, so a + // plaintext page an on-path attacker serves on the port cannot forge to it. + return s.certFiles == "" || scheme == "https" +} + // 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 @@ -219,6 +293,7 @@ const ( adminInternalErrorsTpl adminLimitExceedingIPSTpl adminContractInfoTpl + adminRuntimeSettingsTpl internalTplCount ) @@ -242,16 +317,16 @@ type WsBlockedIPView struct { // 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 + CoinName string + CoinShortcut string + CoinLabel string + ChainType bchain.ChainType + Error *api.APIError + InternalDataErrors []db.BlockInternalDataError + WsGetAccountInfoLimit int + WsLimitExceedingIPs []WsLimitExceedingIP + WsBlockedIPs []WsBlockedIPView + RuntimeSettings []RuntimeSettingView } func (s *InternalServer) newTemplateData(r *http.Request) *InternalTemplateData { @@ -287,6 +362,7 @@ func (s *InternalServer) parseTemplates() []*template.Template { 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") + t[adminRuntimeSettingsTpl] = createTemplate("./static/internal_templates/runtime_settings.html", "./static/internal_templates/base.html") return t } @@ -295,20 +371,15 @@ func (s *InternalServer) adminIndex(w http.ResponseWriter, r *http.Request) (tpl return adminIndexTpl, data, nil } +// internalDataErrors lists the blocks queued for internal data healing. The queue is +// drained by healInternalDataLoop, so the page is read-only. 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 } @@ -356,20 +427,81 @@ func (s *InternalServer) contractInfoPage(w http.ResponseWriter, r *http.Request return adminContractInfoTpl, data, nil } +// contractInfoUpdateResponse is the JSON shape returned by POST /admin/contract-info/. +type contractInfoUpdateResponse struct { + Updated int `json:"updated"` +} + +// contractInfoListResponse is the JSON shape returned by GET /admin/contract-info/. +// Next, when present, is the from parameter of the next page. +type contractInfoListResponse struct { + Contracts []bchain.ContractInfo `json:"contracts"` + Next string `json:"next,omitempty"` +} + +const ( + contractInfoListDefaultLimit = 1000 + contractInfoListMaxLimit = 10000 +) + +// contractInfoDeleteResponse is the JSON shape returned by +// DELETE /admin/contract-info/
. Purged carries the removed record so +// the operator can restore it verbatim with a POST — the row includes the +// sync-owned createdInBlock/destructedInBlock fields, which the backend +// re-fetch on the next read cannot recover. +type contractInfoDeleteResponse struct { + Contract string `json:"contract"` + Deleted bool `json:"deleted"` + Purged *bchain.ContractInfo `json:"purged,omitempty"` +} + +// apiContractInfo handles GET/POST/PUT/DELETE of cached contract metadata at +// /admin/contract-info/
(POST/PUT write the collection path +// /admin/contract-info/ with a JSON array body; a GET of the collection path +// lists the stored records page by page). func (s *InternalServer) apiContractInfo(r *http.Request, apiVersion int) (interface{}, error) { - if r.Method == http.MethodPost { + address := urlPathSegment(r) + switch r.Method { + case http.MethodGet: + if address == "" { + return s.listContractInfos(r) + } + return s.getContractInfo(address) + case http.MethodPost, http.MethodPut: + // The bulk write addresses each contract in its body; reject a POST to + // an address path instead of silently ignoring the address segment. + if address != "" { + return nil, api.NewAPIError("POST updates the collection; use /admin/contract-info/ with a JSON array body", true) + } return s.updateContracts(r) + case http.MethodDelete: + return s.deleteContractInfo(address, r) } - var contractAddress string - i := strings.LastIndexByte(r.URL.Path, '/') - if i > 0 { - contractAddress = r.URL.Path[i+1:] + return nil, api.NewAPIError("Unsupported method "+r.Method, true) +} + +// listContractInfos returns one page of the stored contract records. Unlike +// the runtime-settings list, the collection is unbounded (sync stores a row +// per contract creation), so the page size is limited and the response +// carries a next cursor. +func (s *InternalServer) listContractInfos(r *http.Request) (interface{}, error) { + limit := contractInfoListDefaultLimit + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > contractInfoListMaxLimit { + return nil, api.NewAPIError("Invalid limit, expecting a number in 1.."+strconv.Itoa(contractInfoListMaxLimit), true) + } + limit = n } - if len(contractAddress) == 0 { - return nil, api.NewAPIError("Missing contract address", true) + contracts, next, err := s.db.ListContractInfos(r.URL.Query().Get("from"), limit) + if err != nil { + return nil, api.NewAPIError(err.Error(), true) } + return &contractInfoListResponse{Contracts: contracts, Next: next}, nil +} - contractInfo, valid, err := s.api.GetContractInfo(contractAddress, bchain.UnknownTokenStandard) +func (s *InternalServer) getContractInfo(address string) (interface{}, error) { + contractInfo, valid, err := s.api.GetContractInfo(address, bchain.UnknownTokenStandard) if err != nil { return nil, api.NewAPIError(err.Error(), true) } @@ -387,7 +519,7 @@ func (s *InternalServer) updateContracts(r *http.Request) (interface{}, error) { 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") + return nil, api.NewAPIError("Cannot unmarshal body to array of ContractInfo objects: "+err.Error(), true) } for i := range contractInfos { c := &contractInfos[i] @@ -397,5 +529,26 @@ func (s *InternalServer) updateContracts(r *http.Request) (interface{}, error) { } } - return "{\"success\":\"Updated " + strconv.Itoa(len(contractInfos)) + " contracts\"}", nil + return &contractInfoUpdateResponse{Updated: len(contractInfos)}, nil +} + +// deleteContractInfo purges the stored metadata of one contract so the next +// read re-fetches it from the backend node. The whole row is discarded — the +// backend re-fetch restores only name/symbol/decimals, not the sync-owned +// createdInBlock/destructedInBlock, so the purged record is logged and +// returned for a POST restore. Deleting is idempotent: a missing row reports +// deleted=false rather than an error, matching the runtime-settings DELETE +// semantics. +func (s *InternalServer) deleteContractInfo(address string, r *http.Request) (interface{}, error) { + if address == "" { + return nil, api.NewAPIError("Missing contract address", true) + } + purged, err := s.db.DeleteContractInfoForAddress(address) + if err != nil { + return nil, api.NewAPIError(err.Error(), true) + } + if purged != nil { + glog.Infof("admin: contract info %s purged (%+v), client %s", address, *purged, r.RemoteAddr) + } + return &contractInfoDeleteResponse{Contract: address, Deleted: purged != nil, Purged: purged}, nil } diff --git a/server/internal_contract_info_test.go b/server/internal_contract_info_test.go new file mode 100644 index 0000000000..7f8d917f65 --- /dev/null +++ b/server/internal_contract_info_test.go @@ -0,0 +1,261 @@ +//go:build unittest + +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// newInternalTestServer builds a full internal server (including the api.Worker +// the contract-info GET path needs) on top of the public-server test harness. +// The caller must have set BB_ADMIN_USER/BB_ADMIN_PASSWORD beforehand — the +// credentials are read in NewInternalServer. +func newInternalTestServer(t *testing.T, s *PublicServer) *httptest.Server { + t.Helper() + internal, err := NewInternalServer("localhost:12346", "", s.db, s.chain, s.mempool, s.txCache, metrics, s.is, s.fiatRates) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(internal.https.Handler) + t.Cleanup(ts.Close) + return ts +} + +func adminRequest(t *testing.T, ts *httptest.Server, method, path, body string) (int, string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, err := http.NewRequest(method, ts.URL+path, rdr) + if err != nil { + t.Fatal(err) + } + req.SetBasicAuth("admin", "password") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, strings.TrimSpace(string(b)) +} + +// TestContractInfoAdminAPI exercises the /admin/contract-info/ JSON API +// end-to-end: GET of one contract, bulk POST on the collection path (the +// response must decode as a JSON object — a regression check for the formerly +// double-encoded string body), DELETE with idempotent semantics, and the +// request-validation failure paths. +func TestContractInfoAdminAPI(t *testing.T) { + t.Setenv("BB_ADMIN_USER", "admin") + t.Setenv("BB_ADMIN_PASSWORD", "password") + 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) + ts := newInternalTestServer(t, s) + + address := "0x" + dbtestdata.EthAddr20 + + t.Run("GET collection lists stored contracts", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/", "") + if code != http.StatusOK { + t.Fatalf("GET = %d %s, want 200", code, body) + } + var resp contractInfoListResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("GET body %q does not decode: %v", body, err) + } + }) + + t.Run("GET collection with invalid limit", func(t *testing.T) { + for _, q := range []string{"limit=0", "limit=-1", "limit=abc", "limit=10001"} { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/?"+q, "") + if code != http.StatusBadRequest { + t.Fatalf("%s: GET = %d %s, want 400", q, code, body) + } + } + }) + + t.Run("GET address", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/"+address, "") + if code != http.StatusOK { + t.Fatalf("GET = %d %s, want 200", code, body) + } + var ci bchain.ContractInfo + if err := json.Unmarshal([]byte(body), &ci); err != nil { + t.Fatalf("GET body %q does not decode to ContractInfo: %v", body, err) + } + if ci.Contract == "" || ci.Name == "" || ci.Standard != bchain.ERC20TokenStandard { + t.Fatalf("GET returned unexpected contract info: %+v", ci) + } + }) + + t.Run("POST collection", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPost, "/admin/contract-info/", + `[{"contract":"`+address+`","name":"Renamed","symbol":"RNM","standard":"ERC20","decimals":18}]`) + if code != http.StatusOK { + t.Fatalf("POST = %d %s, want 200", code, body) + } + // The old handler returned a pre-serialized string that jsonHandler + // encoded again; decoding into a typed object fails on that shape. + var resp contractInfoUpdateResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("POST body %q does not decode to an object: %v", body, err) + } + if resp.Updated != 1 { + t.Fatalf("POST updated = %d, want 1", resp.Updated) + } + stored, err := s.db.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if stored == nil || stored.Name != "Renamed" { + t.Fatalf("stored contract = %+v, want name Renamed", stored) + } + }) + + t.Run("GET collection contains the updated contract", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/?limit=10", "") + if code != http.StatusOK { + t.Fatalf("GET = %d %s, want 200", code, body) + } + var resp contractInfoListResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("GET body %q does not decode: %v", body, err) + } + found := false + for _, c := range resp.Contracts { + if c.Name == "Renamed" { + found = true + } + } + if !found { + t.Fatalf("list %+v does not contain the contract stored by POST", resp.Contracts) + } + }) + + t.Run("POST with address segment", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPost, "/admin/contract-info/"+address, `[]`) + if code != http.StatusBadRequest || !strings.Contains(body, "collection") { + t.Fatalf("POST = %d %s, want 400 pointing to the collection path", code, body) + } + }) + + t.Run("POST non-array body", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPost, "/admin/contract-info/", `{"contract":"x"}`) + if code != http.StatusBadRequest { + t.Fatalf("POST = %d %s, want 400", code, body) + } + }) + + t.Run("unsupported method", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPatch, "/admin/contract-info/"+address, "") + if code != http.StatusBadRequest || !strings.Contains(body, "Unsupported method") { + t.Fatalf("PATCH = %d %s, want 400 Unsupported method", code, body) + } + }) + + t.Run("DELETE", func(t *testing.T) { + // stored by the POST subtest above + code, body := adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/"+address, "") + if code != http.StatusOK { + t.Fatalf("DELETE = %d %s, want 200", code, body) + } + var resp contractInfoDeleteResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("DELETE body %q does not decode: %v", body, err) + } + // the purged record is returned so the operator can restore it (incl. + // the sync-owned fields the backend re-fetch cannot recover) via POST + if !resp.Deleted || resp.Contract != address || resp.Purged == nil || resp.Purged.Name != "Renamed" { + t.Fatalf("DELETE = %+v, want deleted:true with the purged record", resp) + } + // assert on the DB directly — the GET endpoint would re-fetch from the + // backend and re-store the row + stored, err := s.db.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if stored != nil { + t.Fatalf("stored contract after DELETE = %+v, want nil", stored) + } + // idempotent: deleting a missing row reports deleted:false, not an error + code, body = adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/"+address, "") + if code != http.StatusOK { + t.Fatalf("repeated DELETE = %d %s, want 200", code, body) + } + resp = contractInfoDeleteResponse{} + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("repeated DELETE body %q does not decode: %v", body, err) + } + if resp.Deleted || resp.Purged != nil { + t.Fatalf("repeated DELETE = %+v, want deleted:false without a purged record", resp) + } + }) + + t.Run("DELETE invalid address", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/not-an-address", "") + if code != http.StatusBadRequest { + t.Fatalf("DELETE = %d %s, want 400", code, body) + } + }) + + t.Run("DELETE missing address", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/", "") + if code != http.StatusBadRequest || !strings.Contains(body, "Missing contract address") { + t.Fatalf("DELETE = %d %s, want 400 Missing contract address", code, body) + } + }) +} + +// TestInternalServerRouteGatingNonEVM verifies that the chain-generic +// runtime-settings admin routes are registered on a non-EVM chain while the +// EVM-specific contract-info routes stay gated (404 via the authenticated +// catch-all). +func TestInternalServerRouteGatingNonEVM(t *testing.T) { + t.Setenv("BB_ADMIN_USER", "admin") + t.Setenv("BB_ADMIN_PASSWORD", "password") + parser, chain := setupChain(t) + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + ts := newInternalTestServer(t, s) + + code, body := adminRequest(t, ts, http.MethodGet, "/admin/runtime-settings/ALLOWED_RPC_CALL_TO", "") + if code != http.StatusOK { + t.Fatalf("GET runtime setting = %d %s, want 200 on a non-EVM chain", code, body) + } + var resp runtimeSettingResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("runtime setting body %q does not decode: %v", body, err) + } + if resp.Key != "ALLOWED_RPC_CALL_TO" || resp.Source != "unset" { + t.Fatalf("runtime setting = %+v, want unset ALLOWED_RPC_CALL_TO", resp) + } + + code, _ = adminRequest(t, ts, http.MethodGet, "/admin/runtime-settings", "") + if code != http.StatusOK { + t.Fatalf("GET runtime-settings page = %d, want 200 on a non-EVM chain", code) + } + + code, _ = adminRequest(t, ts, http.MethodGet, "/admin/contract-info/0x20cd153de35d469ba46127a0c8f18626b59a256a", "") + if code != http.StatusNotFound { + t.Fatalf("GET contract-info = %d, want 404 on a non-EVM chain", code) + } +} diff --git a/server/internal_test.go b/server/internal_test.go index a0ab85fb5c..027c6e93ae 100644 --- a/server/internal_test.go +++ b/server/internal_test.go @@ -5,6 +5,7 @@ package server import ( "net/http" "net/http/httptest" + "strings" "testing" ) @@ -65,6 +66,94 @@ func TestRequireAdminAuth(t *testing.T) { } } +// TestRequireAdminAuthCSRF covers the same-origin gate on state-changing methods: +// reject cross-origin browser mutations, allow same-origin and header-less clients. +func TestRequireAdminAuthCSRF(t *testing.T) { + const user, pass = "admin", "s3cr3t-pass" + const host = "blockbook-internal:9130" + tests := []struct { + name string + method string + tls bool + host string + origin string + referer string + wantStatus int + }{ + // Safe methods are never subject to the origin check. + {"GET cross-origin -> allowed", http.MethodGet, false, host, "http://evil.example", "", http.StatusOK}, + {"HEAD cross-origin -> allowed", http.MethodHead, false, host, "http://evil.example", "", http.StatusOK}, + // State-changing methods: same-origin passes, cross-origin is forbidden. + {"POST same-origin -> allowed", http.MethodPost, false, host, "http://" + host, "", http.StatusOK}, + {"POST cross-origin -> forbidden", http.MethodPost, false, host, "http://evil.example", "", http.StatusForbidden}, + {"PUT cross-origin -> forbidden", http.MethodPut, false, host, "https://evil.example", "", http.StatusForbidden}, + {"DELETE cross-origin -> forbidden", http.MethodDelete, false, host, "http://evil.example", "", http.StatusForbidden}, + // Host comparison is case-insensitive. + {"POST same-origin host case-insensitive -> allowed", http.MethodPost, false, host, "http://" + strings.ToUpper(host), "", http.StatusOK}, + // Opaque origin (sandboxed iframe / data: document) never matches. + {"POST null origin -> forbidden", http.MethodPost, false, host, "null", "", http.StatusForbidden}, + // Origin is authoritative; a matching Origin passes even with a foreign Referer. + {"POST origin match beats foreign referer -> allowed", http.MethodPost, false, host, "http://" + host, "http://evil.example/x", http.StatusOK}, + // Referer is the fallback only when Origin is absent. + {"POST referer same-origin, no origin -> allowed", http.MethodPost, false, host, "", "http://" + host + "/admin", http.StatusOK}, + {"POST referer cross-origin, no origin -> forbidden", http.MethodPost, false, host, "", "http://evil.example/x", http.StatusForbidden}, + // Non-browser client: neither header present -> allowed (curl/automation). + {"POST no origin/referer -> allowed", http.MethodPost, false, host, "", "", http.StatusOK}, + // A garbage Origin does not parse to our host -> forbidden. + {"POST unparsable origin -> forbidden", http.MethodPost, false, host, "://::::", "", http.StatusForbidden}, + // TLS server: a plaintext (downgraded) same-host Origin is an on-path MITM vector. + {"POST http origin to TLS server -> forbidden", http.MethodPost, true, host, "http://" + host, "", http.StatusForbidden}, + {"POST https origin to TLS server -> allowed", http.MethodPost, true, host, "https://" + host, "", http.StatusOK}, + // Plaintext server behind a TLS-terminating proxy: an https Origin still passes. + {"POST https origin to plaintext server -> allowed", http.MethodPost, false, host, "https://" + host, "", http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newAdminServer(user, pass) + if tt.tls { + s.certFiles = "testcert" + } + h := s.requireAdminAuth(okHandler) + r := httptest.NewRequest(tt.method, "/admin/runtime-settings/ALLOWED_RPC_CALL_TO", nil) + r.Host = tt.host + r.SetBasicAuth(user, pass) + if tt.origin != "" { + r.Header.Set("Origin", tt.origin) + } + if tt.referer != "" { + r.Header.Set("Referer", tt.referer) + } + w := httptest.NewRecorder() + h(w, r) + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + }) + } +} + +func Test_urlPathSegment(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/admin/contract-info/0xAbC123", "0xAbC123"}, // case preserved + {"/admin/runtime-settings/allowed_rpc_call_to", "allowed_rpc_call_to"}, + {"/admin/contract-info/ 0xabc \t", "0xabc"}, // whitespace trimmed + {"/admin/contract-info/", ""}, // collection path + {"/admin", ""}, // root-level path has no sub-segment + {"/", ""}, + {"", ""}, + } + for _, tt := range tests { + r := httptest.NewRequest(http.MethodGet, "/x", nil) + r.URL.Path = tt.path // bypass NewRequest path validation to test raw shapes + if got := urlPathSegment(r); got != tt.want { + t.Errorf("urlPathSegment(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + // 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. @@ -81,7 +170,7 @@ func TestAdminSubtreeIsGated(t *testing.T) { // 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"} { + for _, p := range []string{"/admin", "/admin/", "/admin/unknown", "/admin/contract-info", "/admin/runtime-settings/ALLOWED_RPC_CALL_TO"} { t.Run("no-auth "+p, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, p, nil) w := httptest.NewRecorder() diff --git a/server/public.go b/server/public.go index bd72e6d339..97a979ecd9 100644 --- a/server/public.go +++ b/server/public.go @@ -31,6 +31,15 @@ import ( const txsOnPage = 25 const blocksOnPage = 50 + +// txIOCollapseThresholdTx is the input/output count above which the tx-detail +// template collapses the list by default on the standalone tx page. +const txIOCollapseThresholdTx = 8 + +// txIOCollapseThresholdAggregate is the higher threshold used on aggregate pages +// (address/xpub/block), where people scan many txs for their own address and a +// collapsed (display:none) list would hide it from in-page find and tx-own highlighting. +const txIOCollapseThresholdAggregate = 30 const mempoolTxsOnPage = 50 const txsInAPI = 1000 const maxWebsocketBlockPageSize = 10000 @@ -136,6 +145,12 @@ func NewPublicServer(binding string, certFiles string, db *db.RocksDB, chain bch https := &http.Server{ Addr: addr, Handler: handler, + // Bound slow request reads and idle keep-alive connections (see timeouts.go) + ReadHeaderTimeout: httpReadHeaderTimeout, + ReadTimeout: httpReadTimeout, + WriteTimeout: httpPublicWriteTimeout, + IdleTimeout: httpIdleTimeout, + MaxHeaderBytes: httpMaxHeaderBytes, } s := &PublicServer{ @@ -457,12 +472,13 @@ func getFunctionName(i interface{}) string { func (s *PublicServer) newTemplateData(r *http.Request) *TemplateData { t := &TemplateData{ - CoinName: s.is.Coin, - CoinShortcut: s.is.CoinShortcut, - CoinLabel: s.is.CoinLabel, - ChainType: s.chainParser.GetChainType(), - InternalExplorer: s.internalExplorer && !s.is.InitialSync, - TOSLink: api.Text.TOSLink, + CoinName: s.is.Coin, + CoinShortcut: s.is.CoinShortcut, + CoinLabel: s.is.CoinLabel, + ChainType: s.chainParser.GetChainType(), + InternalExplorer: s.internalExplorer && !s.is.GetInitialSync(), + TOSLink: api.Text.TOSLink, + TxIOCollapseThreshold: txIOCollapseThresholdAggregate, } if t.ChainType == bchain.ChainEthereumType { t.FungibleTokenName = bchain.EthereumTokenStandardMap[bchain.FungibleToken] @@ -470,7 +486,7 @@ func (s *PublicServer) newTemplateData(r *http.Request) *TemplateData { t.MultiTokenName = bchain.EthereumTokenStandardMap[bchain.MultiToken] } if !s.debug { - t.Minified = ".min.4" + t.Minified = ".min.5" } if s.is.HasFiatRates { // get the secondary coin and if it should be shown either from query parameters "secondary" and "use_secondary" @@ -582,6 +598,38 @@ type TemplateData struct { TxDate string TxSecondaryCoinRate float64 TxTicker *common.CurrencyRatesTicker + TxIOCollapseThreshold int + contractRates map[contractRateKey]contractRate +} + +type contractRateKey struct { + contract string + day string +} + +type contractRate struct { + rate float64 + found bool +} + +// contractBaseRate memoizes GetContractBaseRate per page, keyed by TxDate (the day of the stored daily ticker), +// so the DB fallback runs once per contract and day instead of once per token transfer row. +// A nil TxTicker is not memoized: amountSpan may still set it, with TxDate, for later rows of the same day. +// The lookup is a parameter so the memo can be tested without a Worker. +func (td *TemplateData) contractBaseRate(contract string, lookup func(ticker *common.CurrencyRatesTicker, token string, timestamp int64) (float64, bool)) (float64, bool) { + if td.TxTicker == nil { + return 0, false + } + key := contractRateKey{contract: contract, day: td.TxDate} + if r, ok := td.contractRates[key]; ok { + return r.rate, r.found + } + rate, found := lookup(td.TxTicker, contract, td.Tx.Blocktime) + if td.contractRates == nil { + td.contractRates = make(map[contractRateKey]contractRate) + } + td.contractRates[key] = contractRate{rate: rate, found: found} + return rate, found } func defaultTxTemplate(chainType bchain.ChainType) string { @@ -881,7 +929,7 @@ func (s *PublicServer) tokenAmountSpan(t *api.TokenTransfer, td *TemplateData, c 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) + baseRate, found := td.contractBaseRate(t.Contract, s.api.GetContractBaseRate) if found { base := p * baseRate txBase = strconv.FormatFloat(base, 'f', 6, 64) @@ -1064,6 +1112,7 @@ func (s *PublicServer) explorerTx(w http.ResponseWriter, r *http.Request) (tpl, } data := s.newTemplateData(r) data.Tx = tx + data.TxIOCollapseThreshold = txIOCollapseThresholdTx return txTpl, data, nil } @@ -1519,7 +1568,8 @@ func (s *PublicServer) explorerSendTx(w http.ResponseWriter, r *http.Request) (t data.SendTxHex = hex data.SendTxMaxFeeRate = req.MaxFeeRate // SYSCOIN data.SendTxMaxBurnAmount = req.MaxBurnAmount // SYSCOIN - data.Error = &api.APIError{Text: err.Error(), Public: true} + // rendered straight into the page, so it bypasses the handler-level redaction + data.Error = &api.APIError{Text: common.RedactURLs(err.Error()), Public: true} return sendTransactionTpl, data, nil } data.Status = "Transaction sent, result " + res @@ -1981,7 +2031,10 @@ func (s *PublicServer) apiBlock(r *http.Request, apiVersion int) (interface{}, e s.metrics.ExplorerViews.With(common.Labels{"action": "api-block"}).Inc() if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { page := validateIntParam(r.URL.Query().Get("page"), 0, 0, maxPageNumber) - block, err = s.api.GetBlock(r.URL.Path[i+1:], page, txsInAPI) + // Every tx on a page is materialized from the tx cache/backend, so a caller that only + // needs a few txs of a busy block (e.g. a sampler) must be able to shrink the page. + pageSize := validateIntParam(r.URL.Query().Get("pageSize"), txsInAPI, 1, txsInAPI) + block, err = s.api.GetBlock(r.URL.Path[i+1:], page, pageSize) if err == nil && apiVersion == apiV1 { return s.api.BlockToV1(block), nil } diff --git a/server/public_contract_rate_test.go b/server/public_contract_rate_test.go new file mode 100644 index 0000000000..f87e259dba --- /dev/null +++ b/server/public_contract_rate_test.go @@ -0,0 +1,64 @@ +//go:build unittest + +package server + +import ( + "testing" + "time" + + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/common" +) + +func TestTemplateDataContractBaseRate(t *testing.T) { + const ( + usdt = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + dai = "0x6B175474E89094C44Da98b954EedeAC495271d0F" + midnight = int64(1758585600) // 2025-09-23 00:00:00 UTC + ) + var calls []int64 + lookup := func(_ *common.CurrencyRatesTicker, token string, ts int64) (float64, bool) { + calls = append(calls, ts) + if token == dai { + return 0, false + } + return float64(ts), true + } + td := &TemplateData{TxTicker: &common.CurrencyRatesTicker{}} + rate := func(contract string, blocktime int64) (float64, bool) { + td.Tx = &api.Tx{Blocktime: blocktime} + // amountSpan labels the day the same way: the midnight ticker is valid for the whole day before + td.TxDate = time.Unix(blocktime, 0).UTC().Add(-time.Second).Format("2006-01-02") + return td.contractBaseRate(contract, lookup) + } + + // a day is (midnight-24h, midnight], matching the first stored daily row at or after the tx + if r, found := rate(usdt, midnight-3600); !found || r != float64(midnight-3600) { + t.Fatalf("first lookup = %v,%v, want %v,true", r, found, midnight-3600) + } + if r, _ := rate(usdt, midnight); r != float64(midnight-3600) { + t.Errorf("same day not memoized: got %v", r) + } + if r, _ := rate(usdt, midnight+1); r != float64(midnight+1) { + t.Errorf("next day reused previous day: got %v", r) + } + if _, found := rate(dai, midnight-60); found { + t.Error("dai found, want not found") + } + if _, found := rate(dai, midnight-120); found { + t.Error("memoized dai miss found") + } + if len(calls) != 3 { + t.Errorf("lookups = %v, want 3 (usdt twice on different days, dai once)", calls) + } + + // a missing tx ticker never reaches the lookup and is not memoized as a miss + calls = nil + td.TxTicker = nil + if _, found := rate(usdt, midnight+7200); found { + t.Error("nil tx ticker found a rate") + } + if len(calls) != 0 { + t.Errorf("lookups = %v, want none", calls) + } +} diff --git a/server/public_ethereumtype_test.go b/server/public_ethereumtype_test.go index 89ee2a5975..65c361be1d 100644 --- a/server/public_ethereumtype_test.go +++ b/server/public_ethereumtype_test.go @@ -4,9 +4,11 @@ package server import ( + "io" "net/http" "net/http/httptest" "reflect" + "strings" "testing" "time" @@ -27,7 +29,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Address address7b.eth

0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b

0.000000000123450123 FAKE
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-
`, + `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-
`, }, }, { @@ -36,7 +38,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Address

0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e

0.000000000123450093 FAKE
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
`, + `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
`, }, }, { @@ -45,14 +47,14 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Transaction

0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101
In BlockUnconfirmed
StatusSuccess
Value0 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
`, + `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
`}, + body: []string{`Trezor Fake Coin Explorer

NFT Token Detail

Token ID1
Contract0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9
Contract 205
StandardERC20
`}, }, { name: "apiIndex", @@ -76,6 +78,16 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { `{"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"}]}`, }, }, + { + // details=basic takes the header-only index read; the counters must match the full path + name: "apiAddress EthAddr4b details=basic", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.EthAddr4b + "?details=basic"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"address":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","balance":"123450075","unconfirmedTxs":0,"txs":1,"nonTokenTxs":1,"internalTxs":1,"nonce":"75"}`, + }, + }, { // fresh address with no indexed data: gated-on returns confirmedNonce:"0" (synthesized // locally, no backend call), mirroring the always-present pending nonce:"0". @@ -253,6 +265,23 @@ var websocketTestsEthereumType = []websocketTest{ }, want: `{"id":"7","data":{"error":{"message":"No tickers found"}}}`, }, + { + // Pins the privatePending plumbing between the request and the chain: the fake chain walks + // the pending nonce across the declared slots (75 is the address's own base nonce), so + // nonce 77 in the answer proves the declared nonces crossed the AddressFilter -> worker + // seam. Both forwarding sites are invisible to every other test - the chain parameter is an + // optional variadic, so dropping either one compiles and changes nothing else. + name: "websocket getAccountInfo privatePending nonces reach the chain", + req: websocketReq{ + Method: "getAccountInfo", + Params: map[string]interface{}{ + "descriptor": "0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D", + "details": "basic", + "privatePending": map[string]interface{}{"nonces": []uint64{75, 76}, "txids": []string{"0xdead"}}, + }, + }, + want: `{"id":"8","data":{"address":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","balance":"123450075","unconfirmedTxs":0,"txs":1,"nonTokenTxs":1,"internalTxs":1,"nonce":"77"}}`, + }, } func initEthereumTypeDB(d *db.RocksDB) error { @@ -346,6 +375,10 @@ func initTestFiatRatesEthereumType(d *db.RocksDB) error { func Test_PublicServer_EthereumType(t *testing.T) { timeNow = fixedTimeNow parser := eth.NewEthereumParser(1, true) + // httpTestsEthereumType asserts the ENS reverse labels (address7b.eth) that the test + // blocks record, so this server has to be an opted-in chain - the labels are off by + // default. Keeps the alias rendering path covered for chains that do opt in. + parser.EnableEnsReverseAliases = true chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) if err != nil { glog.Fatal("fakechain: ", err) @@ -361,6 +394,187 @@ func Test_PublicServer_EthereumType(t *testing.T) { httpTestsEthereumType(t, ts) runWebsocketTests(t, ts, websocketTestsEthereumType) } + +// allowance(owner, spender) calldata with the 0xdd62ed3e selector +const rpcCallAllowanceData = "0xdd62ed3e0000000000000000000000009ea3721b5bf3b64b4418c38b603154d2d597fae3000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829" + +var websocketTestsEthereumTypeRpcCallAllowlist = []websocketTest{ + { + name: "rpcCall to allowed address", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9", + Data: "0x4567", + }, + }, + want: `{"id":"0","data":{"data":"0x4567abcd"}}`, + }, + { + name: "rpcCall with allowed method selector to any address", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: rpcCallAllowanceData, + }, + }, + want: `{"id":"1","data":{"data":"` + rpcCallAllowanceData + `abcd"}}`, + }, + { + name: "rpcCall with not allowed address and method selector", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: "0x70a08231000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829", + }, + }, + want: `{"id":"2","data":{"error":{"message":"Not supported"}}}`, + }, + { + name: "rpcCall with allowed selector but malformed calldata", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: rpcCallAllowanceData[2:], + }, + }, + want: `{"id":"3","data":{"error":{"message":"Not supported"}}}`, + }, +} + +// Test_PublicServer_EthereumType_RuntimeSettingsLiveUpdate proves the full +// runtime-settings flow: a websocket rpcCall rejected under the env-seeded +// allowlist starts passing right after an authenticated admin POST (no +// restart), the override is resolved from the DB on a simulated restart with +// precedence over the environment, and DELETE reverts to the env default. +func Test_PublicServer_EthereumType_RuntimeSettingsLiveUpdate(t *testing.T) { + timeNow = fixedTimeNow + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "0xdd62ed3e") + t.Setenv("BB_ADMIN_USER", "admin") + t.Setenv("BB_ADMIN_PASSWORD", "password") + 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() + + internal, err := NewInternalServer("localhost:12346", "", s.db, s.chain, s.mempool, s.txCache, metrics, s.is, s.fiatRates) + if err != nil { + t.Fatal(err) + } + tsAdmin := httptest.NewServer(internal.https.Handler) + defer tsAdmin.Close() + + const balanceOfData = "0x70a08231000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829" + balanceOfCall := func(name, want string) []websocketTest { + return []websocketTest{{ + name: name, + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: balanceOfData, + }, + }, + want: want, + }} + } + rejected := `{"id":"0","data":{"error":{"message":"Not supported"}}}` + allowed := `{"id":"0","data":{"data":"` + balanceOfData + `abcd"}}` + + adminURL := tsAdmin.URL + "/admin/runtime-settings/ALLOWED_EVM_CALL_METHODS" + doAdmin := func(method, body string) (int, string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, err := http.NewRequest(method, adminURL, rdr) + if err != nil { + t.Fatal(err) + } + req.SetBasicAuth("admin", "password") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, strings.TrimSpace(string(b)) + } + + // the balanceOf selector is not in the env-seeded allowlist + runWebsocketTests(t, ts, balanceOfCall("rpcCall balanceOf rejected by env allowlist", rejected)) + + // the admin endpoint requires authentication + resp, err := http.Post(adminURL, "application/json", strings.NewReader(`{"value":"0x70a08231"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated POST status = %d, want 401", resp.StatusCode) + } + + // authenticated POST adds the selector and takes effect without a restart + code, body := doAdmin(http.MethodPost, `{"value":"0xdd62ed3e,0x70a08231"}`) + if code != http.StatusOK || body != `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e,0x70a08231","source":"db"}` { + t.Fatalf("admin POST = %d %s", code, body) + } + runWebsocketTests(t, ts, balanceOfCall("rpcCall balanceOf allowed after admin update", allowed)) + + // simulated restart: a fresh InternalState resolves the override from the + // DB with precedence over the environment variable + is2 := &common.InternalState{CoinShortcut: "FAKE"} + if err := initRpcCallAllowlists(s.db, is2); err != nil { + t.Fatal(err) + } + a := is2.GetRpcCallAllowlists() + if a.MethodsSource != common.RuntimeSettingSourceDB || a.MethodsValue != "0xdd62ed3e,0x70a08231" { + t.Fatalf("after simulated restart got source %q value %q, want db override", a.MethodsSource, a.MethodsValue) + } + if _, ok := a.Methods["70a08231"]; !ok { + t.Fatal("after simulated restart the stored selector is missing") + } + + // DELETE reverts to the env default and the call is rejected again + code, body = doAdmin(http.MethodDelete, "") + if code != http.StatusOK || body != `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e","source":"env"}` { + t.Fatalf("admin DELETE = %d %s", code, body) + } + runWebsocketTests(t, ts, balanceOfCall("rpcCall balanceOf rejected after revert", rejected)) +} + +func Test_PublicServer_EthereumType_RpcCallAllowlist(t *testing.T) { + timeNow = fixedTimeNow + t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9") + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "0xdd62ed3e") + 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() + + runWebsocketTests(t, ts, websocketTestsEthereumTypeRpcCallAllowlist) +} func TestENSResolution(t *testing.T) { parser := eth.NewEthereumParser(1, true) chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) diff --git a/server/public_test.go b/server/public_test.go index 71f490eb0d..7d37e865f6 100644 --- a/server/public_test.go +++ b/server/public_test.go @@ -398,7 +398,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Transaction

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

Address

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz

0.00012345 FAKE

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

Transaction

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

Error

Transaction not found

`, + `Trezor Fake Coin Explorer

Error

Transaction not found

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

Block

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

Transactions

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

Block

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

Transactions

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

Application status

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

`, `href="/address/TDGSR64oU4QDpViKfdwawSiqwyqpUB6JUD"`, `Unclaimed Reward`, + `Last Reward Claim`, }, }, { @@ -88,7 +89,7 @@ func httpTestsTron(t *testing.T, ts *httptest.Server) { 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"}}}}`, + `{"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","latestWithdrawTime":1777018452}}}}`, }, }, { @@ -110,7 +111,7 @@ func httpTestsTron(t *testing.T, ts *httptest.Server) { 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"}}`, + `"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","latestWithdrawTime":1777018452}}`, `"transactions":[{"txid":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, `"chainExtraData":{"contractType":"TriggerSmartContract","operation":"contractCall","assetIssueID":"1002001","totalFee":"3076500","energyUsage":"14650","energyUsageTotal":"14650","bandwidthUsage":"345","bandwidthFee":"0","result":"SUCCESS"}`, `"nonce":"36"`, @@ -181,7 +182,7 @@ var websocketTestsTron = []websocketTest{ "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"}}}}}`, + 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","latestWithdrawTime":1777018452}}}}}`, }, } diff --git a/server/redact_boundary_test.go b/server/redact_boundary_test.go new file mode 100644 index 0000000000..df6505352c --- /dev/null +++ b/server/redact_boundary_test.go @@ -0,0 +1,66 @@ +//go:build unittest + +package server + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/trezor/blockbook/api" +) + +// A send rejected by every alternative provider reaches the REST client as a public APIError whose +// text is the provider error, and that error embeds the provider URL (our own annotations, or the +// http client's Post "": ... on a dial failure). The handler must reduce it to the host before +// it leaves the process, or a key in the provider URL is served to anyone who can POST /api/v2/sendtx. +func TestJSONHandlerRedactsProviderURLFromClientError(t *testing.T) { + const key = "SECRET_API_KEY_ABCDEF" + providerErr := `Post "https://relay.example.com/v3/` + key + `": context deadline exceeded` + + tests := []struct { + name string + err error + debug bool + // status the client gets, to pin that redaction did not change the error's visibility + wantStatus int + }{ + {"public api error", api.NewAPIError(providerErr, true), false, http.StatusBadRequest}, + {"internal api error", api.NewAPIError(providerErr, false), false, http.StatusInternalServerError}, + {"plain error in debug mode", errors.New(providerErr), true, http.StatusInternalServerError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &htmlTemplates[TemplateData]{debug: tt.debug} + handler := h.jsonHandler(func(r *http.Request, apiVersion int) (interface{}, error) { + return nil, tt.err + }, apiV2) + + w := httptest.NewRecorder() + handler(w, httptest.NewRequest(http.MethodPost, "/api/v2/sendtx/", nil)) + + body := w.Body.String() + if strings.Contains(body, key) { + t.Errorf("api key leaked to the client: %s", body) + } + if !strings.Contains(body, "relay.example.com") { + t.Errorf("provider host should survive redaction, got: %s", body) + } + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + var decoded struct { + Error string `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &decoded); err != nil { + t.Fatalf("response is not the usual json error: %v, body %s", err, body) + } + if !strings.Contains(decoded.Error, "context deadline exceeded") { + t.Errorf("backend wording must survive for client-side classification, got %q", decoded.Error) + } + }) + } +} diff --git a/server/rest_rate_limiter.go b/server/rest_rate_limiter.go index 4c75deba8b..5616a90f71 100644 --- a/server/rest_rate_limiter.go +++ b/server/rest_rate_limiter.go @@ -16,9 +16,12 @@ import ( ) const ( - defaultRestUIRateLimit = 180 + // Public HTTP (explorer UI + REST API) is for individual human use; Suite uses + // the WebSocket interface. Keep per-client rate/burst tight so a distributed + // crawler needs many more source IPs to sustain a given aggregate rate. + defaultRestUIRateLimit = 20 defaultRestUIRateWindow = time.Minute - defaultRestUIBurst = 40 + defaultRestUIBurst = 20 defaultRestUIMaxConcurrent = 12 defaultRestUIStateTTL = 10 * time.Minute defaultRestUIBlockDuration = 0 @@ -45,15 +48,16 @@ const ( ) 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 + rateLimit int + rateWindow time.Duration + burst int + maxConcurrent int + stateTTL time.Duration + blockDuration time.Duration + trustedProxies []netip.Prefix + cloudflareCIDRs []netip.Prefix + trustPseudoIPv6 bool + whitelistPrefixes []netip.Prefix } type restUIRateLimiter struct { @@ -71,6 +75,7 @@ type restUIRateLimiter struct { trustedProxies []netip.Prefix cloudflarePrefixes []netip.Prefix trustPseudoIPv6 bool + whitelistPrefixes []netip.Prefix localBypassWarn sync.Once } @@ -124,6 +129,7 @@ func newRestUIRateLimiter(network string, metrics *common.Metrics) (*restUIRateL trustedProxies: cfg.trustedProxies, cloudflarePrefixes: cfg.cloudflareCIDRs, trustPseudoIPv6: cfg.trustPseudoIPv6, + whitelistPrefixes: cfg.whitelistPrefixes, } if metrics != nil { metrics.RestUIActiveIPs.Set(0) @@ -191,6 +197,23 @@ func readRestUILimiterConfig(network string) (restUILimiterConfig, error) { cfg.trustedProxies = clientIPCfg.trustedProxies cfg.cloudflareCIDRs = clientIPCfg.cloudflarePrefixes cfg.trustPseudoIPv6 = clientIPCfg.trustPseudoIPv6 + + whitelistEnvNames := []string{ + prefix + "_REST_UI_WHITELIST_IPS", + "REST_UI_WHITELIST_IPS", + } + for _, envName := range whitelistEnvNames { + whitelistValue := os.Getenv(envName) + if whitelistValue == "" { + continue + } + cfg.whitelistPrefixes, err = parseCIDRList(envName, splitCIDRList(whitelistValue)) + if err != nil { + return cfg, err + } + glog.Infof("REST/UI rate limiter whitelisted CIDRs (%s): %v", envName, cfg.whitelistPrefixes) + break + } return cfg, nil } @@ -248,6 +271,12 @@ func (l *restUIRateLimiter) wrapPublic(next http.Handler, basePath string) http. next.ServeHTTP(w, r) return } + if len(l.whitelistPrefixes) > 0 { + if addr, ok := parseAddr(ip); ok && isWhitelistedIP(addr, l.whitelistPrefixes) { + 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 diff --git a/server/rest_rate_limiter_test.go b/server/rest_rate_limiter_test.go index 026ec3d6c1..f0fb137dbd 100644 --- a/server/rest_rate_limiter_test.go +++ b/server/rest_rate_limiter_test.go @@ -311,11 +311,11 @@ func TestReadRestUILimiterConfig(t *testing.T) { } // 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.rateLimit != 20 { + t.Fatalf("default rateLimit = %d, want 20", cfg.rateLimit) } - if cfg.burst != 40 { - t.Fatalf("default burst = %d, want 40", cfg.burst) + if cfg.burst != 20 { + t.Fatalf("default burst = %d, want 20", cfg.burst) } if cfg.maxConcurrent != 12 { t.Fatalf("default maxConcurrent = %d, want 12", cfg.maxConcurrent) diff --git a/server/runtime_settings.go b/server/runtime_settings.go new file mode 100644 index 0000000000..20f845ed0b --- /dev/null +++ b/server/runtime_settings.go @@ -0,0 +1,444 @@ +package server + +import ( + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "strings" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" +) + +// Runtime settings are configuration values that can be changed at runtime +// through the internal /admin/runtime-settings interface. Each setting is +// named after the environment variable that provides its startup default +// (prefixed with the network name, e.g. ETH_ALLOWED_RPC_CALL_TO); an override +// written through the admin interface is persisted in the database, survives +// restarts and takes precedence over the environment variable. +const ( + runtimeSettingAllowedRpcCallTo = "ALLOWED_RPC_CALL_TO" + runtimeSettingAllowedEvmCallMethods = "ALLOWED_EVM_CALL_METHODS" +) + +// runtimeSettingDef describes one runtime setting: how to validate its raw +// value and how to read/write it in the RpcCallAllowlists snapshot. Future +// runtime settings plug in here. +type runtimeSettingDef struct { + key string + // parse validates the raw comma-separated value and returns the parsed + // set; name is used in error messages. + parse func(name, value string) (map[string]struct{}, error) + // apply writes the parsed value into a snapshot under construction. + apply func(a *common.RpcCallAllowlists, parsed map[string]struct{}, value, source string) + // get reads the raw value and its source from a snapshot. + get func(a *common.RpcCallAllowlists) (value, source string) +} + +var runtimeSettingDefs = []*runtimeSettingDef{ + { + key: runtimeSettingAllowedRpcCallTo, + parse: parseAllowedRpcCallTo, + apply: func(a *common.RpcCallAllowlists, parsed map[string]struct{}, value, source string) { + a.To, a.ToValue, a.ToSource = parsed, value, source + }, + get: func(a *common.RpcCallAllowlists) (string, string) { + return a.ToValue, a.ToSource + }, + }, + { + key: runtimeSettingAllowedEvmCallMethods, + parse: parseAllowedEvmCallMethods, + apply: func(a *common.RpcCallAllowlists, parsed map[string]struct{}, value, source string) { + a.Methods, a.MethodsValue, a.MethodsSource = parsed, value, source + }, + get: func(a *common.RpcCallAllowlists) (string, string) { + return a.MethodsValue, a.MethodsSource + }, + }, +} + +func runtimeSettingDefByKey(key string) *runtimeSettingDef { + for _, def := range runtimeSettingDefs { + if def.key == key { + return def + } + } + return nil +} + +func runtimeSettingKeys() string { + keys := make([]string, len(runtimeSettingDefs)) + for i, def := range runtimeSettingDefs { + keys[i] = def.key + } + return strings.Join(keys, ", ") +} + +// parseAllowedRpcCallTo parses a comma-separated list of contract addresses +// into a set keyed by the lowercase address string. Entries are trimmed and +// empty entries skipped; a set value that parses to no addresses at all is a +// configuration error — silently ignoring it would leave rpcCall unrestricted, +// and keeping an empty-string entry would allowlist rpcCall requests with an +// empty to field. +func parseAllowedRpcCallTo(name, value string) (map[string]struct{}, error) { + if value == "" { + return nil, nil + } + addresses := make(map[string]struct{}) + for _, a := range strings.Split(value, ",") { + a = strings.TrimSpace(a) + if a == "" { + continue + } + addresses[strings.ToLower(a)] = struct{}{} + } + if len(addresses) == 0 { + return nil, errors.Errorf("%s is set but contains no addresses", name) + } + return addresses, nil +} + +// parseAllowedEvmCallMethods parses a comma-separated list of 4-byte EVM call +// selectors (optional 0x prefix, case-insensitive) into a set keyed by +// lowercase hex without the prefix. Returns nil when the value is unset. +// A malformed selector or a set value that parses to no selectors at all is a +// configuration error so a typo cannot silently disable the intended +// allowlist. +func parseAllowedEvmCallMethods(name, value string) (map[string]struct{}, error) { + if value == "" { + return nil, nil + } + methods := make(map[string]struct{}) + for _, m := range strings.Split(value, ",") { + m = strings.TrimSpace(m) + if m == "" { + continue + } + selector := strings.TrimPrefix(strings.ToLower(m), "0x") + b, err := hex.DecodeString(selector) + if err != nil || len(b) != 4 { + return nil, errors.Errorf("invalid EVM call method selector %q in %s, expecting 4 bytes in hex", m, name) + } + methods[selector] = struct{}{} + } + if len(methods) == 0 { + return nil, errors.Errorf("%s is set but contains no method selectors", name) + } + return methods, nil +} + +// runtimeSettingEnvName returns the environment variable that provides the +// startup default of the given runtime setting. +func runtimeSettingEnvName(network, key string) string { + return strings.ToUpper(network) + "_" + key +} + +// runtimeSettingEnvValue returns the trimmed value of the given environment +// variable, so a stray space or newline in an env file does not leak into the +// value (admin POSTs are trimmed the same way). A set but whitespace-only +// value is a configuration error rather than unset: treating it as unset +// would silently un-restrict rpcCall, unlike every other malformed value, +// which fails loudly. +func runtimeSettingEnvValue(envName string) (string, error) { + raw := os.Getenv(envName) + value := strings.TrimSpace(raw) + if raw != "" && value == "" { + return "", errors.Errorf("%s contains only whitespace", envName) + } + return value, nil +} + +// resolveRuntimeSetting returns the effective raw value of a runtime setting +// and its source: a stored override wins over the environment variable. +func resolveRuntimeSetting(store runtimeSettingStore, network, key string) (string, string, error) { + if store != nil { + value, found, err := store.GetRuntimeSetting(key) + if err != nil { + return "", "", err + } + if found { + return value, common.RuntimeSettingSourceDB, nil + } + } + value, err := runtimeSettingEnvValue(runtimeSettingEnvName(network, key)) + if err != nil { + return "", "", err + } + if value != "" { + return value, common.RuntimeSettingSourceEnv, nil + } + return "", common.RuntimeSettingSourceUnset, nil +} + +// buildRpcCallAllowlists resolves and parses all runtime settings into a new +// snapshot. An unparsable value — a malformed environment variable or a +// corrupted stored override (writes are validated, so it cannot get there +// through the admin interface) — is an error; falling back could silently +// widen access. +func buildRpcCallAllowlists(d *db.RocksDB, is *common.InternalState) (*common.RpcCallAllowlists, error) { + // the explicit nil check avoids a typed-nil interface on which the store + // methods would be called + var store runtimeSettingStore + if d != nil { + store = d + } + network := is.GetNetwork() + a := &common.RpcCallAllowlists{} + for _, def := range runtimeSettingDefs { + value, source, err := resolveRuntimeSetting(store, network, def.key) + if err != nil { + return nil, err + } + var parsed map[string]struct{} + if value != "" { + name := def.key + if source == common.RuntimeSettingSourceEnv { + name = runtimeSettingEnvName(network, def.key) + } + parsed, err = def.parse(name, value) + if err != nil { + return nil, err + } + } + def.apply(a, parsed, value, source) + } + return a, nil +} + +// initRpcCallAllowlists resolves and publishes the rpcCall allowlist snapshot +// if none exists yet. It is called from both NewWebsocketServer and +// NewInternalServer so that each server gets correct allowlists regardless of +// which of them (or both, in any order) a deployment runs; the CAS in +// InitRpcCallAllowlists keeps an already published — and possibly already +// admin-updated — snapshot intact. +func initRpcCallAllowlists(d *db.RocksDB, is *common.InternalState) error { + if is.GetRpcCallAllowlists() != nil { + return nil + } + a, err := buildRpcCallAllowlists(d, is) + if err != nil { + return err + } + if !is.InitRpcCallAllowlists(a) { + return nil + } + if a.To != nil { + glog.Info("Support of rpcCall for these contracts (source ", a.ToSource, "): ", a.ToValue) + } + if a.Methods != nil { + glog.Info("Support of rpcCall for these method selectors (source ", a.MethodsSource, "): ", a.MethodsValue) + } + warnShadowedRuntimeSettingEnv(a, is.GetNetwork()) + return nil +} + +// warnShadowedRuntimeSettingEnv logs a warning for every stored override that +// shadows a different environment value, so drift between the deployed env +// file and the database (for example a replica that missed an admin update, +// or an env change rolled out while an override exists) is visible at +// startup. It only compares — a shadowed environment value is never parsed or +// validated, so it cannot fail the start. +func warnShadowedRuntimeSettingEnv(a *common.RpcCallAllowlists, network string) { + for _, def := range runtimeSettingDefs { + value, source := def.get(a) + if source != common.RuntimeSettingSourceDB { + continue + } + envName := runtimeSettingEnvName(network, def.key) + envValue, err := runtimeSettingEnvValue(envName) + if err != nil { + glog.Warning("runtime setting ", def.key, ": stored override shadows a malformed environment value: ", err) + continue + } + if envValue != "" && envValue != value { + glog.Warningf("runtime setting %s: stored override %q shadows a different environment value %q (%s); the override wins until it is removed", + def.key, value, envValue, envName) + } + } +} + +// runtimeSettingStore reads and persists runtime setting overrides; +// *db.RocksDB in production, replaceable in tests to exercise storage +// failures. +type runtimeSettingStore interface { + GetRuntimeSetting(name string) (value string, found bool, err error) + StoreRuntimeSetting(name, value string) error + DeleteRuntimeSetting(name string) error +} + +// runtimeSettingResponse is the JSON shape returned by the +// /admin/runtime-settings/ endpoint. +type runtimeSettingResponse struct { + Key string `json:"key"` + Value string `json:"value"` + Source string `json:"source"` +} + +// apiRuntimeSetting handles GET/POST/PUT/DELETE of a single runtime setting at +// /admin/runtime-settings/; a GET of the bare collection path returns all +// settings, so a management tool can read the whole state in one request. +func (s *InternalServer) apiRuntimeSetting(r *http.Request, apiVersion int) (interface{}, error) { + key := strings.ToUpper(urlPathSegment(r)) + if key == "" && r.Method == http.MethodGet { + return s.listRuntimeSettings() + } + def := runtimeSettingDefByKey(key) + if def == nil { + return nil, api.NewAPIError("Unknown runtime setting, supported: "+runtimeSettingKeys(), true) + } + switch r.Method { + case http.MethodGet: + return s.getRuntimeSetting(def) + case http.MethodPost, http.MethodPut: + return s.updateRuntimeSetting(def, r) + case http.MethodDelete: + return s.deleteRuntimeSetting(def, r) + } + return nil, api.NewAPIError("Unsupported method "+r.Method, true) +} + +// currentRpcCallAllowlists returns the live snapshot; it is published by +// NewInternalServer before the routes are registered, so a nil snapshot means +// a broken test setup rather than a runtime condition. +func (s *InternalServer) currentRpcCallAllowlists() (*common.RpcCallAllowlists, error) { + a := s.is.GetRpcCallAllowlists() + if a == nil { + return nil, errors.New("runtime settings not initialized") + } + return a, nil +} + +func (s *InternalServer) getRuntimeSetting(def *runtimeSettingDef) (interface{}, error) { + a, err := s.currentRpcCallAllowlists() + if err != nil { + return nil, err + } + value, source := def.get(a) + return &runtimeSettingResponse{Key: def.key, Value: value, Source: source}, nil +} + +// listRuntimeSettings returns the effective value and source of every runtime +// setting as a JSON array. +func (s *InternalServer) listRuntimeSettings() (interface{}, error) { + a, err := s.currentRpcCallAllowlists() + if err != nil { + return nil, err + } + settings := make([]runtimeSettingResponse, len(runtimeSettingDefs)) + for i, def := range runtimeSettingDefs { + value, source := def.get(a) + settings[i] = runtimeSettingResponse{Key: def.key, Value: value, Source: source} + } + return settings, nil +} + +// updateRuntimeSetting validates the new value, persists it to the database +// and only then publishes it to the live snapshot, so an admin never gets a +// success response for a change that would not survive a restart. A failed +// database write leaves the live allowlists untouched and returns HTTP 500; +// an invalid value returns HTTP 400. +func (s *InternalServer) updateRuntimeSetting(def *runtimeSettingDef, r *http.Request) (interface{}, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, api.NewAPIError("Cannot get request body", true) + } + var req struct { + Value *string `json:"value"` + } + if err := json.Unmarshal(body, &req); err != nil || req.Value == nil { + return nil, api.NewAPIError(`Body must be a JSON object {"value":"..."}`, true) + } + // A value of exactly "" is a valid override: it explicitly unconfigures + // the dimension (as if the environment variable was not set), which is + // the only way to un-restrict at runtime when the environment has a + // value. Anything else — including whitespace- or separator-only values — + // must parse to at least one entry, so a botched automation input cannot + // silently disable the allowlist. + value := strings.TrimSpace(*req.Value) + var parsed map[string]struct{} + if *req.Value != "" { + parsed, err = def.parse(def.key, *req.Value) + if err != nil { + return nil, api.NewAPIError(err.Error(), true) + } + } + s.runtimeSettingsMux.Lock() + defer s.runtimeSettingsMux.Unlock() + old, err := s.currentRpcCallAllowlists() + if err != nil { + return nil, err + } + if err := s.runtimeSettings.StoreRuntimeSetting(def.key, value); err != nil { + glog.Error("admin: storing runtime setting ", def.key, "=", value, " failed: ", err) + return nil, api.NewAPIError("Cannot store runtime setting "+def.key+": "+err.Error(), false) + } + oldValue, oldSource := def.get(old) + a := *old + def.apply(&a, parsed, value, common.RuntimeSettingSourceDB) + s.is.SetRpcCallAllowlists(&a) + glog.Infof("admin: runtime setting %s changed from %q (%s) to %q (%s), client %s", + def.key, oldValue, oldSource, value, common.RuntimeSettingSourceDB, r.RemoteAddr) + return &runtimeSettingResponse{Key: def.key, Value: value, Source: common.RuntimeSettingSourceDB}, nil +} + +// deleteRuntimeSetting removes the stored override and reverts the setting to +// its environment default. The environment fallback is validated before the +// row is deleted — deleting with a malformed environment value would leave +// the database and the live state divergent and fail the next restart. +func (s *InternalServer) deleteRuntimeSetting(def *runtimeSettingDef, r *http.Request) (interface{}, error) { + envName := runtimeSettingEnvName(s.is.GetNetwork(), def.key) + envValue, err := runtimeSettingEnvValue(envName) + if err != nil { + return nil, api.NewAPIError("Cannot remove override, fallback environment value is invalid: "+err.Error()+"; fix the environment or set a value instead", true) + } + var parsed map[string]struct{} + source := common.RuntimeSettingSourceUnset + if envValue != "" { + parsed, err = def.parse(envName, envValue) + if err != nil { + return nil, api.NewAPIError("Cannot remove override, fallback environment value is invalid: "+err.Error()+"; fix the environment or set a value instead", true) + } + source = common.RuntimeSettingSourceEnv + } + s.runtimeSettingsMux.Lock() + defer s.runtimeSettingsMux.Unlock() + old, err := s.currentRpcCallAllowlists() + if err != nil { + return nil, err + } + if err := s.runtimeSettings.DeleteRuntimeSetting(def.key); err != nil { + glog.Error("admin: deleting runtime setting ", def.key, " failed: ", err) + return nil, api.NewAPIError("Cannot delete runtime setting "+def.key+": "+err.Error(), false) + } + oldValue, oldSource := def.get(old) + a := *old + def.apply(&a, parsed, envValue, source) + s.is.SetRpcCallAllowlists(&a) + glog.Infof("admin: runtime setting %s override removed, reverted from %q (%s) to %q (%s), client %s", + def.key, oldValue, oldSource, envValue, source, r.RemoteAddr) + return &runtimeSettingResponse{Key: def.key, Value: envValue, Source: source}, nil +} + +// RuntimeSettingView is a single row of the admin runtime-settings page. +type RuntimeSettingView struct { + Key string + Value string + Source string +} + +func (s *InternalServer) runtimeSettingsPage(w http.ResponseWriter, r *http.Request) (tpl, *InternalTemplateData, error) { + data := s.newTemplateData(r) + if a := s.is.GetRpcCallAllowlists(); a != nil { + for _, def := range runtimeSettingDefs { + value, source := def.get(a) + data.RuntimeSettings = append(data.RuntimeSettings, RuntimeSettingView{Key: def.key, Value: value, Source: source}) + } + } + return adminRuntimeSettingsTpl, data, nil +} diff --git a/server/runtime_settings_test.go b/server/runtime_settings_test.go new file mode 100644 index 0000000000..23f935b74b --- /dev/null +++ b/server/runtime_settings_test.go @@ -0,0 +1,372 @@ +//go:build unittest +// +build unittest + +package server + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func TestParseAllowedRpcCallTo(t *testing.T) { + tests := []struct { + name string + value string + want []string + wantErr bool + }{ + {name: "empty", value: "", want: nil}, + {name: "single address", value: "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9", want: []string{"0xcda9fc258358ecaa88845f19af595e908bb7efe9"}}, + {name: "multiple with spaces", value: " 0xABCD , 0xEF01 ", want: []string{"0xabcd", "0xef01"}}, + {name: "empty entries skipped", value: "0xabcd,,0xef01", want: []string{"0xabcd", "0xef01"}}, + {name: "separators only", value: ",", wantErr: true}, + {name: "whitespace entries only", value: " , ,", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAllowedRpcCallTo("FAKE_ALLOWED_RPC_CALL_TO", tt.value) + if tt.wantErr { + if err == nil { + t.Fatalf("parseAllowedRpcCallTo(%q) = nil err, want error", tt.value) + } + return + } + if err != nil { + t.Fatalf("parseAllowedRpcCallTo(%q) unexpected error: %v", tt.value, err) + } + if tt.want == nil && got != nil { + t.Fatalf("parseAllowedRpcCallTo(%q) = %v, want nil", tt.value, got) + } + if len(got) != len(tt.want) { + t.Fatalf("parseAllowedRpcCallTo(%q) len = %d, want %d", tt.value, len(got), len(tt.want)) + } + for _, a := range tt.want { + if _, ok := got[a]; !ok { + t.Fatalf("parseAllowedRpcCallTo(%q) missing %q", tt.value, a) + } + } + }) + } +} + +// A whitespace-only environment value must fail startup resolution the same +// way a value with no parseable entries does — treating it as unset would +// silently leave rpcCall unrestricted. +func TestRuntimeSettingWhitespaceOnlyEnvFailsInit(t *testing.T) { + t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", " \n") + is := &common.InternalState{CoinShortcut: "FAKE"} + if err := initRpcCallAllowlists(nil, is); err == nil || !strings.Contains(err.Error(), "only whitespace") { + t.Fatalf("initRpcCallAllowlists() err = %v, want whitespace configuration error", err) + } +} + +func doRuntimeSettingRequest(t *testing.T, handler http.HandlerFunc, method, key, body string) (int, map[string]string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + r := httptest.NewRequest(method, "/admin/runtime-settings/"+key, rdr) + w := httptest.NewRecorder() + handler(w, r) + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s %s: cannot unmarshal response %q: %v", method, key, w.Body.String(), err) + } + return w.Code, resp +} + +// failingRuntimeSettingStore fails every store call; it stands in for a +// broken database to exercise the store-before-publish error paths. +type failingRuntimeSettingStore struct{} + +func (failingRuntimeSettingStore) GetRuntimeSetting(name string) (string, bool, error) { + return "", false, errors.New("get failed") +} + +func (failingRuntimeSettingStore) StoreRuntimeSetting(name, value string) error { + return errors.New("store failed") +} + +func (failingRuntimeSettingStore) DeleteRuntimeSetting(name string) error { + return errors.New("delete failed") +} + +// The subtests of TestRuntimeSettingsAPI intentionally share the DB and +// snapshot state and depend on running in order; run the whole test, not +// individual subtests. +func TestRuntimeSettingsAPI(t *testing.T) { + const envTo = "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9" + // the surrounding whitespace must never surface: the env value is trimmed + // wherever it is resolved (startup and DELETE fallback alike) + t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", " "+envTo+"\n") + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + t.Fatal(err) + } + d, is, path := setupRocksDB(parser, chain, t, false, &common.Config{CoinName: "Fakecoin", CoinShortcut: "FAKE"}) + defer func() { + if err := d.Close(); err != nil { + t.Error(err) + } + os.RemoveAll(path) + }() + if err := initRpcCallAllowlists(d, is); err != nil { + t.Fatal(err) + } + s := &InternalServer{ + htmlTemplates: htmlTemplates[InternalTemplateData]{debug: true}, + db: d, + is: is, + chainParser: parser, + runtimeSettings: d, + } + handler := s.jsonHandler(s.apiRuntimeSetting, 0) + + t.Run("GET unknown key", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "NO_SUCH_SETTING", "") + if code != http.StatusBadRequest || !strings.Contains(resp["error"], "Unknown runtime setting") { + t.Fatalf("got %d %v, want 400 unknown runtime setting", code, resp) + } + }) + + t.Run("GET env-sourced value", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "ALLOWED_RPC_CALL_TO", "") + if code != http.StatusOK || resp["value"] != envTo || resp["source"] != common.RuntimeSettingSourceEnv { + t.Fatalf("got %d %v, want 200 value %q source env", code, resp, envTo) + } + }) + + t.Run("GET unset value", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusOK || resp["value"] != "" || resp["source"] != common.RuntimeSettingSourceUnset { + t.Fatalf("got %d %v, want 200 empty value source unset", code, resp) + } + }) + + t.Run("GET all settings", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/admin/runtime-settings/", nil) + w := httptest.NewRecorder() + handler(w, r) + if w.Code != http.StatusOK { + t.Fatalf("got %d %s, want 200", w.Code, w.Body.String()) + } + var settings []runtimeSettingResponse + if err := json.Unmarshal(w.Body.Bytes(), &settings); err != nil { + t.Fatalf("cannot unmarshal response %q: %v", w.Body.String(), err) + } + want := map[string]runtimeSettingResponse{ + runtimeSettingAllowedRpcCallTo: {Key: runtimeSettingAllowedRpcCallTo, Value: envTo, Source: common.RuntimeSettingSourceEnv}, + runtimeSettingAllowedEvmCallMethods: {Key: runtimeSettingAllowedEvmCallMethods, Value: "", Source: common.RuntimeSettingSourceUnset}, + } + if len(settings) != len(want) { + t.Fatalf("got %d settings %v, want %d", len(settings), settings, len(want)) + } + for _, s := range settings { + if s != want[s.Key] { + t.Fatalf("setting %s = %+v, want %+v", s.Key, s, want[s.Key]) + } + } + }) + + t.Run("empty key accepts only GET", func(t *testing.T) { + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + code, resp := doRuntimeSettingRequest(t, handler, method, "", `{"value":""}`) + if code != http.StatusBadRequest || !strings.Contains(resp["error"], "Unknown runtime setting") { + t.Fatalf("%s: got %d %v, want 400 unknown runtime setting", method, code, resp) + } + } + }) + + t.Run("key is case-insensitive", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "allowed_rpc_call_to", "") + if code != http.StatusOK || resp["key"] != runtimeSettingAllowedRpcCallTo { + t.Fatalf("got %d %v, want 200 key %s", code, resp, runtimeSettingAllowedRpcCallTo) + } + }) + + t.Run("POST invalid selector is rejected", func(t *testing.T) { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", `{"value":"0x12"}`) + if code != http.StatusBadRequest { + t.Fatalf("got %d, want 400", code) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); found { + t.Fatal("rejected POST must not create a DB row") + } + if a := is.GetRpcCallAllowlists(); a.Methods != nil || a.MethodsSource != common.RuntimeSettingSourceUnset { + t.Fatalf("rejected POST must not change the snapshot, got %+v", a) + } + }) + + t.Run("POST invalid to list is rejected", func(t *testing.T) { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_RPC_CALL_TO", `{"value":","}`) + if code != http.StatusBadRequest { + t.Fatalf("got %d, want 400", code) + } + }) + + t.Run("POST bad body is rejected", func(t *testing.T) { + for _, body := range []string{"not json", `{"other":"field"}`} { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", body) + if code != http.StatusBadRequest { + t.Fatalf("body %q: got %d, want 400", body, code) + } + } + }) + + t.Run("POST whitespace-only value is rejected", func(t *testing.T) { + // only a value of exactly "" is an explicit unconfigure; a + // whitespace- or separator-only value is a typo that must not + // silently un-restrict rpcCall + for _, body := range []string{`{"value":" "}`, `{"value":"\n"}`} { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", body) + if code != http.StatusBadRequest { + t.Fatalf("body %q: got %d, want 400", body, code) + } + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); found { + t.Fatal("rejected POST must not create a DB row") + } + }) + + t.Run("unsupported method is rejected", func(t *testing.T) { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPatch, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusBadRequest { + t.Fatalf("got %d, want 400", code) + } + }) + + t.Run("POST stores override and updates snapshot", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", `{"value":"0xdd62ed3e,0x70a08231"}`) + if code != http.StatusOK || resp["value"] != "0xdd62ed3e,0x70a08231" || resp["source"] != common.RuntimeSettingSourceDB { + t.Fatalf("got %d %v, want 200 source db", code, resp) + } + value, found, err := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods) + if err != nil || !found || value != "0xdd62ed3e,0x70a08231" { + t.Fatalf("DB row = %q, %v, %v, want stored value", value, found, err) + } + a := is.GetRpcCallAllowlists() + if _, ok := a.Methods["70a08231"]; !ok || a.MethodsSource != common.RuntimeSettingSourceDB { + t.Fatalf("snapshot not updated: %+v", a) + } + }) + + t.Run("POST empty value explicitly unconfigures", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_RPC_CALL_TO", `{"value":""}`) + if code != http.StatusOK || resp["value"] != "" || resp["source"] != common.RuntimeSettingSourceDB { + t.Fatalf("got %d %v, want 200 empty value source db", code, resp) + } + a := is.GetRpcCallAllowlists() + if a.To != nil || a.ToSource != common.RuntimeSettingSourceDB { + t.Fatalf("snapshot not cleared: %+v", a) + } + if value, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedRpcCallTo); !found || value != "" { + t.Fatalf("DB row = %q, %v, want stored empty value", value, found) + } + }) + + t.Run("stored empty override beats env after restart", func(t *testing.T) { + // simulated restart: the stored empty override must keep the + // dimension unconfigured even though the env var has a value + restarted := &common.InternalState{CoinShortcut: "FAKE"} + if err := initRpcCallAllowlists(d, restarted); err != nil { + t.Fatal(err) + } + a := restarted.GetRpcCallAllowlists() + if a.To != nil || a.ToValue != "" || a.ToSource != common.RuntimeSettingSourceDB { + t.Fatalf("after restart got To %v value %q source %q, want unconfigured db override", a.To, a.ToValue, a.ToSource) + } + }) + + t.Run("stored override shadows malformed env without failing init", func(t *testing.T) { + // drift between a stored override and the environment must not fail a + // restart — the shadowed env value is never parsed, the override wins + // and the mismatch is only logged + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "not-a-selector") + restarted := &common.InternalState{CoinShortcut: "FAKE"} + if err := initRpcCallAllowlists(d, restarted); err != nil { + t.Fatal(err) + } + a := restarted.GetRpcCallAllowlists() + if a.MethodsSource != common.RuntimeSettingSourceDB || a.MethodsValue != "0xdd62ed3e,0x70a08231" { + t.Fatalf("got source %q value %q, want the db override", a.MethodsSource, a.MethodsValue) + } + }) + + t.Run("DELETE reverts to env", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_RPC_CALL_TO", "") + if code != http.StatusOK || resp["value"] != envTo || resp["source"] != common.RuntimeSettingSourceEnv { + t.Fatalf("got %d %v, want 200 value %q source env", code, resp, envTo) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedRpcCallTo); found { + t.Fatal("DELETE must remove the DB row") + } + a := is.GetRpcCallAllowlists() + if _, ok := a.To[strings.ToLower(envTo)]; !ok || a.ToSource != common.RuntimeSettingSourceEnv { + t.Fatalf("snapshot not reverted to env: %+v", a) + } + }) + + t.Run("DELETE with malformed env fallback is rejected", func(t *testing.T) { + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "not-a-selector") + code, resp := doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusBadRequest { + t.Fatalf("got %d %v, want 400", code, resp) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); !found { + t.Fatal("rejected DELETE must keep the DB row") + } + if a := is.GetRpcCallAllowlists(); a.MethodsSource != common.RuntimeSettingSourceDB { + t.Fatalf("rejected DELETE must not change the snapshot: %+v", a) + } + }) + + t.Run("DELETE with whitespace-only env fallback is rejected", func(t *testing.T) { + // a whitespace-only env value is a configuration error, not unset — + // reverting to it would silently un-restrict rpcCall and the next + // restart would resolve the same error + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", " \n") + code, resp := doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusBadRequest || !strings.Contains(resp["error"], "only whitespace") { + t.Fatalf("got %d %v, want 400 whitespace error", code, resp) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); !found { + t.Fatal("rejected DELETE must keep the DB row") + } + }) + + // proves the DB write happens before the snapshot swap: with a failing + // store the requests take the error branch (glog.Error + 500) and the + // live allowlists stay untouched + t.Run("failed store returns 500 and keeps snapshot", func(t *testing.T) { + s.runtimeSettings = failingRuntimeSettingStore{} + defer func() { s.runtimeSettings = d }() + before := is.GetRpcCallAllowlists() + code, resp := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", `{"value":"0xa9059cbb"}`) + if code != http.StatusInternalServerError || !strings.Contains(resp["error"], "Cannot store runtime setting") { + t.Fatalf("got %d %v, want 500 store error", code, resp) + } + if is.GetRpcCallAllowlists() != before { + t.Fatal("failed DB write must not publish a new snapshot") + } + code, resp = doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusInternalServerError || !strings.Contains(resp["error"], "Cannot delete runtime setting") { + t.Fatalf("got %d %v, want 500 delete error", code, resp) + } + if is.GetRpcCallAllowlists() != before { + t.Fatal("failed DB delete must not publish a new snapshot") + } + }) +} diff --git a/server/timeouts.go b/server/timeouts.go new file mode 100644 index 0000000000..e1eb06e315 --- /dev/null +++ b/server/timeouts.go @@ -0,0 +1,22 @@ +package server + +import "time" + +// HTTP server timeouts for the REST/UI (public) and admin/metrics (internal) +// interfaces. The public write timeout remains disabled because net/http +// applies it to the full handler and response lifetime, which can truncate +// valid expensive requests such as uncached xpub lookups. +const ( + // httpReadHeaderTimeout bounds the time a client may take to send the request line and headers + httpReadHeaderTimeout = 10 * time.Second + // httpReadTimeout bounds the whole request read + httpReadTimeout = 30 * time.Second + // httpPublicWriteTimeout avoids a hard cap on valid public handlers + httpPublicWriteTimeout time.Duration = 0 + // httpInternalWriteTimeout bounds the short admin and metrics handlers + httpInternalWriteTimeout = 120 * time.Second + // httpIdleTimeout closes idle keep-alive connections + httpIdleTimeout = 120 * time.Second + // httpMaxHeaderBytes caps the request-header size at Go's default + httpMaxHeaderBytes = 1 << 20 +) diff --git a/server/tron_template.go b/server/tron_template.go index 2bdd12b9c8..8d90150624 100644 --- a/server/tron_template.go +++ b/server/tron_template.go @@ -23,6 +23,7 @@ type tronTxExtraTemplateData struct { StakeAmountValue *api.Amount `json:"-"` UnstakeAmountValue *api.Amount `json:"-"` ClaimedVoteRewardValue *api.Amount `json:"-"` + WithdrawnUnfreezeValue *api.Amount `json:"-"` } type tronAccountExtraTemplateData struct { @@ -64,6 +65,7 @@ func chainExtra(tx *api.Tx) *tronTxExtraTemplateData { StakeAmountValue: parseTronSunAmount(extra.StakeAmount), UnstakeAmountValue: parseTronSunAmount(extra.UnstakeAmount), ClaimedVoteRewardValue: parseTronSunAmount(extra.ClaimedVoteReward), + WithdrawnUnfreezeValue: parseTronSunAmount(extra.WithdrawnUnfreeze), } return rv } diff --git a/server/websocket.go b/server/websocket.go index be10268292..c5fa613eb8 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -2,13 +2,16 @@ package server import ( "context" + "encoding/hex" "encoding/json" + "io" "math/big" "net/http" "net/netip" "net/url" "os" "runtime/debug" + "slices" "strconv" "strings" "sync" @@ -30,11 +33,32 @@ const outChannelSize = 500 const defaultTimeout = 60 * time.Second const unknownMethodLabel = "unknown" const maxWebsocketMessageBytes int64 = 4 * 1024 * 1024 -const maxWebsocketPendingRequests = 48 + +// defaultWsPendingRequestsLimit is the default per-connection cap on +// concurrently executing requests; override with +// _WS_PENDING_REQUESTS_LIMIT (0 disables), see docs/env.md. +const defaultWsPendingRequestsLimit = 48 + +// maxWebsocketMempoolFiltersResponses caps per-connection getMempoolFilters +// requests over their whole lifecycle: the slot is acquired before the handler +// computes the (potentially large) response and released only after the +// response is written to the websocket (or drained on close). The point is to +// bound the peak memory held in computed-but-unwritten filter responses, so the +// cap deliberately covers compute, queueing, and write together; a client that +// pipelines more than this many requests, or reads slower than it requests, +// gets a mempool_filters_limit error instead of queueing further responses. +const maxWebsocketMempoolFiltersResponses = 4 const maxWebsocketActiveRequests = 2048 const maxWebsocketEstimateFeeBlocks = 32 + +// maxPrivatePendingNonces bounds how many declared in-flight private nonces a getAccountInfo +// request may contribute (see WsPrivatePending). An address realistically has a handful of +// in-flight transactions; the cap keeps a malformed or hostile request from forcing unbounded work, +// and it bounds how far the pending-nonce walk can advance in one request. +const maxPrivatePendingNonces = 64 const maxWebsocketSubscribeAddresses = 1000 const maxWebsocketSubscribeAddressesWithNewBlockTxs = 100 +const maxWebsocketSubscribeFiatRatesTokens = 1000 const websocketLogPreviewBytes = 256 // allRates is a special "currency" parameter that means all available currencies @@ -58,10 +82,12 @@ type websocketChannel struct { conn *websocket.Conn out chan *WsRes pendingRequests chan struct{} + mempoolFiltersSlots chan struct{} // semaphore capping in-flight getMempoolFilters responses, see maxWebsocketMempoolFiltersResponses ip string ipKey string blockKey string blockable bool + limited bool messageRate *connMessageRate requestHeader http.Header alive bool @@ -105,13 +131,13 @@ type WebsocketServer struct { 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 + whitelistPrefixes []netip.Prefix // trustPseudoIPv6 honors the (otherwise client-spoofable) CF-Connecting-IPv6 // header; only safe with Cloudflare "Pseudo IPv4: Overwrite Headers" on. trustPseudoIPv6 bool @@ -123,6 +149,9 @@ type WebsocketServer struct { messageRateLimit int messageRateWindow time.Duration ipBlockDuration time.Duration + // pendingRequestsLimit caps how many requests a single connection may have + // executing concurrently before it is closed; 0 disables the cap. + pendingRequestsLimit int // 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. @@ -168,10 +197,12 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. 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{ + // Bound the handshake so a client that stalls mid-upgrade releases the + // connection instead of holding it open indefinitely + HandshakeTimeout: httpReadHeaderTimeout, ReadBufferSize: 1024 * 32, WriteBufferSize: 1024 * 32, WriteBufferPool: &sync.Pool{}, @@ -180,13 +211,8 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. } 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) + if err := initRpcCallAllowlists(db, is); err != nil { + return nil, err } clientIPCfg, err := readClientIPConfig(is.GetNetwork()) if err != nil { @@ -206,9 +232,31 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. 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.configureConnectionLimits(is.GetNetwork()); err != nil { + return nil, err + } + whitelistEnvNames := []string{ + strings.ToUpper(is.GetNetwork()) + "_WS_WHITELIST_IPS", + "WS_WHITELIST_IPS", + } + for _, envName := range whitelistEnvNames { + whitelistValue := os.Getenv(envName) + if whitelistValue == "" { + continue + } + s.whitelistPrefixes, err = parseCIDRList(envName, splitCIDRList(whitelistValue)) + if err != nil { + return nil, err + } + glog.Infof("Websocket whitelisted CIDRs (%s): %v", envName, s.whitelistPrefixes) + break + } if err := s.configureMessageRateLimit(is.GetNetwork()); err != nil { return nil, err } + if err := s.configurePendingRequestsLimit(is.GetNetwork()); err != nil { + return nil, err + } if s.metrics != nil { s.metrics.WebsocketNewBlockTxsSubscriptions.Set(0) s.metrics.WebsocketUniqueIPs.Set(0) @@ -261,12 +309,22 @@ func (s *WebsocketServer) checkOrigin(r *http.Request) bool { return ok } -func normalizeOrigin(origin string) (string, bool) { - u, err := url.Parse(origin) +// parseOrigin parses an Origin/Referer URL into its lowercased scheme and host, +// rejecting malformed values and the opaque "null" origin (ok=false). +func parseOrigin(raw string) (scheme, host string, ok bool) { + u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { + return "", "", false + } + return strings.ToLower(u.Scheme), strings.ToLower(u.Host), true +} + +func normalizeOrigin(origin string) (string, bool) { + scheme, host, ok := parseOrigin(origin) + if !ok { return "", false } - return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host), true + return scheme + "://" + host, true } func getWebsocketPayloadPreview(d []byte) string { @@ -290,13 +348,21 @@ func (s *WebsocketServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } ip, blockSafe, _ := resolveClientIP(r, s.trustedProxyPrefixes, s.cloudflarePrefixes, s.trustPseudoIPv6) + // Whitelisted IPs bypass all rate limiting (connection limiter, IP blocklist, + // and per-connection message rate tracking). + whitelisted := false + if len(s.whitelistPrefixes) > 0 { + if addr, ok := parseAddr(ip); ok { + whitelisted = isWhitelistedIP(addr, s.whitelistPrefixes) + } + } 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 { + if s.ipBlockEnabled && !whitelisted { bKey = blockKey(ip) blockable = blockSafe && isBlockableKey(ip, s.trustedProxyPrefixes, s.cloudflarePrefixes) } @@ -304,7 +370,7 @@ func (s *WebsocketServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 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 s.ipBlockEnabled && !whitelisted { if blocked, rejected := s.is.IsWsIPBlocked(bKey, time.Now()); blocked { if s.metrics != nil { s.metrics.WebsocketBlockedConnections.Inc() @@ -321,7 +387,7 @@ func (s *WebsocketServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } limited := false - if s.websocketLimiter != nil { + if s.websocketLimiter != nil && !whitelisted { ok, reason := s.websocketLimiter.accept(ipKey, time.Now()) if !ok { if s.metrics != nil { @@ -341,20 +407,32 @@ func (s *WebsocketServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, upgradeFailed+err.Error(), http.StatusServiceUnavailable) return } + // Upgrade inherits the HTTP server's read/write deadlines, which would break + // long-lived WebSocket streams. + // Clear them; outputLoop sets its own write deadlines. + _ = conn.SetReadDeadline(time.Time{}) + _ = conn.SetWriteDeadline(time.Time{}) conn.SetReadLimit(maxWebsocketMessageBytes) + // a nil channel disables the per-connection pending request cap + var pendingRequests chan struct{} + if s.pendingRequestsLimit > 0 { + pendingRequests = make(chan struct{}, s.pendingRequestsLimit) + } c := &websocketChannel{ - 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 { + id: atomic.AddUint64(&connectionCounter, 1), + conn: conn, + out: make(chan *WsRes, outChannelSize), + pendingRequests: pendingRequests, + mempoolFiltersSlots: make(chan struct{}, maxWebsocketMempoolFiltersResponses), + ip: ip, + ipKey: ipKey, + blockKey: bKey, + blockable: blockable, + limited: limited, + requestHeader: r.Header, + alive: true, + } + if s.messageRateLimit > 0 && !whitelisted { 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 @@ -489,7 +567,7 @@ func (c *websocketChannel) CloseOut(reason string) (bool, string) { //clean out close(c.out) for len(c.out) > 0 { - <-c.out + c.finalize(<-c.out) } return true, closeReason } @@ -501,20 +579,29 @@ func (c *websocketChannel) DataOut(data *WsRes) { defer c.aliveLock.Unlock() if c.alive { if len(c.out) < outChannelSize-1 { + // Enqueued: ownership passes to the out pipeline, which finalizes it + // once written (outputLoop) or drained (CloseOut). 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() + return + } + 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() } + // Not enqueued (overflow or dead connection): the response never reaches the + // out pipeline, so release any slot it held here. + c.finalize(data) } func (c *websocketChannel) acquireRequestSlot() bool { + if c.pendingRequests == nil { + // pending request limit disabled + return true + } select { case c.pendingRequests <- struct{}{}: return true @@ -524,9 +611,51 @@ func (c *websocketChannel) acquireRequestSlot() bool { } func (c *websocketChannel) releaseRequestSlot() { + if c.pendingRequests == nil { + return + } <-c.pendingRequests } +func (c *websocketChannel) acquireMempoolFiltersSlot() bool { + select { + case c.mempoolFiltersSlots <- struct{}{}: + return true + default: + return false + } +} + +func (c *websocketChannel) releaseMempoolFiltersSlot() { + <-c.mempoolFiltersSlots +} + +// finalize releases any resources a response held, exactly once +func (c *websocketChannel) finalize(res *WsRes) { + if res != nil && res.release != nil { + res.release() + res.release = nil + } +} + +// readLimitedMessage is conn.ReadMessage with the decompressed payload bounded +// to limit bytes. +func readLimitedMessage(conn *websocket.Conn) (messageType int, p []byte, tooLarge bool, err error) { + messageType, r, err := conn.NextReader() + if err != nil { + return 0, nil, false, err + } + // +1 so an exactly-limit-sized payload passes but anything larger is caught. + p, err = io.ReadAll(io.LimitReader(r, maxWebsocketMessageBytes+1)) + if err != nil { + return messageType, nil, false, err + } + if int64(len(p)) > maxWebsocketMessageBytes { + return messageType, nil, true, nil + } + return messageType, p, false, nil +} + func (s *WebsocketServer) inputLoop(c *websocketChannel) { defer func() { if r := recover(); r != nil { @@ -536,11 +665,16 @@ func (s *WebsocketServer) inputLoop(c *websocketChannel) { } }() for { - t, d, err := c.conn.ReadMessage() + t, d, tooLarge, err := readLimitedMessage(c.conn) if err != nil { s.closeChannel(c, "read_error") return } + if tooLarge { + glog.Warning("Websocket message from ", c.id, " (", c.ip, ") exceeds ", maxWebsocketMessageBytes, " bytes after decompression") + s.closeChannel(c, "message_too_large") + return + } switch t { case websocket.TextMessage: var req WsReq @@ -606,6 +740,7 @@ func (s *WebsocketServer) outputLoop(c *websocketChannel) { for m := range c.out { c.conn.SetWriteDeadline(time.Now().Add(defaultTimeout)) err := c.conn.WriteJSON(m) + c.finalize(m) if err != nil { glog.Error("Error sending message to ", c.id, ", ", err) s.closeChannel(c, "write_error") @@ -615,7 +750,6 @@ func (s *WebsocketServer) outputLoop(c *websocketChannel) { } func (s *WebsocketServer) onConnect(c *websocketChannel) { - glog.Info("Client connected ", c.id, ", ", c.ip) s.metrics.WebsocketClients.Inc() } @@ -624,11 +758,10 @@ func (s *WebsocketServer) onDisconnect(c *websocketChannel) { s.unsubscribeNewTransaction(c) s.unsubscribeAddresses(c) s.unsubscribeFiatRates(c) - if s.websocketLimiter != nil { + if s.websocketLimiter != nil && c.limited { 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() } @@ -680,7 +813,7 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *WsRe r := WsBlockReq{} err = json.Unmarshal(req.Params, &r) if err == nil { - r.Page, r.PageSize = sanitizePagingParams(r.Page, r.PageSize, txsInAPI, maxWebsocketBlockPageSize) + r.Page, r.PageSize = sanitizePagingParams(r.Page, r.PageSize, maxWebsocketBlockPageSize, maxWebsocketBlockPageSize) rv, err = s.getBlock(r.Id, r.Page, r.PageSize) } return @@ -808,6 +941,9 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *WsRe if err != nil { return nil, err } + if len(r.Tokens) > maxWebsocketSubscribeFiatRatesTokens { + return nil, api.NewAPIError("tokens max "+strconv.Itoa(maxWebsocketSubscribeFiatRatesTokens), true) + } r.Currency = strings.ToLower(r.Currency) return s.subscribeFiatRates(c, &r, req) @@ -848,6 +984,8 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *WsRe func (s *WebsocketServer) onRequest(c *websocketChannel, req *WsReq) { var err error var data interface{} + // release is non-nil while this request holds a rate-capped endpoint slot. + var release func() f, ok := requestHandlers[req.Method] methodLabel := req.Method if !ok { @@ -864,9 +1002,14 @@ func (s *WebsocketServer) onRequest(c *websocketChannel, req *WsReq) { // nil data means no response if data != nil { c.DataOut(&WsRes{ - ID: req.ID, - Data: data, + ID: req.ID, + Data: data, + release: release, }) + release = nil // ownership handed to the response + } + if release != nil { + release() // no response was produced — free the slot now } s.metrics.WebsocketPendingRequests.With(common.Labels{"method": methodLabel}).Dec() }() @@ -876,23 +1019,35 @@ func (s *WebsocketServer) onRequest(c *websocketChannel, req *WsReq) { s.metrics.WebsocketReqDuration.With(common.Labels{"method": methodLabel}).Observe(float64(time.Since(t)) / 1e3) // in microseconds }() if ok { + if req.Method == "getMempoolFilters" { + if !c.acquireMempoolFiltersSlot() { + e := resultError{} + e.Error.Message = "mempool_filters_limit" + data = e + s.metrics.WebsocketRequests.With(common.Labels{"method": methodLabel, "status": "failure"}).Inc() + glog.Warning("Client ", c.id, " exceeded getMempoolFilters response limit, ", c.ip) + return + } + release = c.releaseMempoolFiltersSlot + } 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": 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)) + glog.Error("Client ", c.id, " onMessage ", req.Method, ": ", errors.ErrorStack(err), ", data len ", len(req.Params), ", preview ", getWebsocketPayloadPreview(req.Params)) } s.metrics.WebsocketRequests.With(common.Labels{"method": methodLabel, "status": "failure"}).Inc() e := resultError{} - e.Error.Message = err.Error() + // the ws protocol returns the message for every error, public or not, so redact here + e.Error.Message = common.RedactURLs(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)) + glog.V(1).Info("Client ", c.id, " onMessage ", getWebsocketPayloadPreview([]byte(req.Method)), ": unknown method, data len ", len(req.Params), ", preview ", getWebsocketPayloadPreview(req.Params)) } } @@ -905,6 +1060,32 @@ func unmarshalGetAccountInfoRequest(params []byte) (*WsAccountInfoReq, error) { return &r, nil } +// privatePendingNonces extracts the declared in-flight private-transaction nonces from a +// getAccountInfo request, capped at maxPrivatePendingNonces, returning a defensive copy (or nil +// when none are declared). The copy prevents the request struct's backing array from being +// retained past the request and keeps the downstream filter independent of it. +func privatePendingNonces(p *WsPrivatePending) []uint64 { + if p == nil || len(p.Nonces) == 0 { + return nil + } + if len(p.Nonces) <= maxPrivatePendingNonces { + out := make([]uint64, len(p.Nonces)) + copy(out, p.Nonces) + return out + } + // More declared nonces than we carry downstream - a malformed or hostile request, since a real + // wallet has only a handful of sequential in-flight nonces. Keep the LOWEST ones: the pending + // nonce advances from the backend's own answer across a contiguous run (see + // raiseToPendingFloor), so the slots just above that answer are the only ones that can be + // consumed, and anything dropped here sits above them and would have stranded anyway. Sorting + // first also keeps the answer independent of the order the client happened to send. + out := make([]uint64, len(p.Nonces)) + copy(out, p.Nonces) + slices.Sort(out) + + return out[:maxPrivatePendingNonces] +} + func (s *WebsocketServer) getAccountInfo(req *WsAccountInfoReq) (res *api.Address, err error) { if err := s.api.ValidateProtocolsForChain(req.Protocols); err != nil { return nil, err @@ -940,13 +1121,14 @@ func (s *WebsocketServer) getAccountInfo(req *WsAccountInfoReq) (res *api.Addres tokensToReturn = api.TokensToReturnDerived } filter := api.AddressFilter{ - FromHeight: uint32(req.FromHeight), - ToHeight: uint32(req.ToHeight), - Contract: req.ContractFilter, - Vout: api.AddressFilterVoutOff, - TokensToReturn: tokensToReturn, - Protocols: req.Protocols, - WithConfirmedNonce: req.ConfirmedNonce, + FromHeight: uint32(req.FromHeight), + ToHeight: uint32(req.ToHeight), + Contract: req.ContractFilter, + Vout: api.AddressFilterVoutOff, + TokensToReturn: tokensToReturn, + Protocols: req.Protocols, + WithConfirmedNonce: req.ConfirmedNonce, + PrivatePendingNonces: privatePendingNonces(req.PrivatePending), } req.Page, req.PageSize = sanitizeAccountPagingParams(req.Page, req.PageSize, txsOnPage, txsInAPI) req.Gap = validateIntValue(req.Gap, 0, 0, maxGapValue) @@ -1221,13 +1403,65 @@ func (s *WebsocketServer) getBlockFiltersBatch(r *WsBlockFiltersBatchReq) (res i }, 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") +// evmCallSelector extracts the 4-byte function selector from hex-encoded EVM +// calldata as lowercase hex without the 0x prefix. It validates the full +// calldata hex (odd length or non-hex characters fail closed) but decodes +// only the first 4 bytes (8 hex chars) so that arbitrarily long calldata +// does not cause a large allocation that is then discarded. +func evmCallSelector(data string) (string, bool) { + if len(data) < 10 || data[0] != '0' || (data[1] != 'x' && data[1] != 'X') { + return "", false + } + s := data[2:] + if len(s)&1 == 1 { + return "", false + } + for i := 0; i < len(s); i++ { + switch { + case s[i] >= '0' && s[i] <= '9': + case s[i] >= 'a' && s[i] <= 'f': + case s[i] >= 'A' && s[i] <= 'F': + default: + return "", false + } + } + b, err := hex.DecodeString(s[:8]) + if err != nil { + return "", false + } + return hex.EncodeToString(b), true +} + +// rpcCallAllowed reports whether a rpcCall request passes the allowlists. With +// no allowlist configured rpcCall is unrestricted; otherwise the call must +// target an allowed address or invoke an allowed method selector. The snapshot +// is nil only when initRpcCallAllowlists has not run (bare test servers); +// NewWebsocketServer and NewInternalServer fail construction when they cannot +// publish one. +func (s *WebsocketServer) rpcCallAllowed(r *WsRpcCallReq) bool { + a := s.is.GetRpcCallAllowlists() + if a == nil || (a.To == nil && a.Methods == nil) { + return true + } + if a.To != nil { + if _, ok := a.To[strings.ToLower(r.To)]; ok { + return true + } + } + if a.Methods != nil { + if selector, ok := evmCallSelector(r.Data); ok { + if _, ok := a.Methods[selector]; ok { + return true + } } } + return false +} + +func (s *WebsocketServer) rpcCall(r *WsRpcCallReq) (*WsRpcCallRes, error) { + if !s.rpcCallAllowed(r) { + return nil, errors.New("Not supported") + } data, err := s.chain.EthereumTypeRpcCall(r.Data, r.To, r.From) if err != nil { return nil, err @@ -1431,24 +1665,60 @@ func (s *WebsocketServer) unsubscribeFiatRates(c *websocketChannel) (res interfa return &subscriptionResponse{false}, nil } -func (s *WebsocketServer) onNewBlockAsync(hash string, height uint32) { +// newBlockNotification builds the subscribeNewBlock payload for a connected block. For EVM chains it +// attaches block-level gas data so subscribers can project the next EIP-1559 base fee; EVMData stays +// nil (evmData: null) for non-EVM chains and pre-London blocks (BaseFeePerGas absent). +func newBlockNotification(block *bchain.Block) *WsNewBlock { + data := &WsNewBlock{ + Height: block.Height, + Hash: block.Hash, + } + if bsd, ok := block.CoinSpecificData.(*bchain.EthereumBlockSpecificData); ok && bsd != nil && bsd.BaseFeePerGas != nil { + data.EVMData = &EthereumGasData{ + BaseFeePerGas: (*api.Amount)(bsd.BaseFeePerGas), + BlockGasUsed: (*api.Amount)(bsd.GasUsed), + BlockGasLimit: (*api.Amount)(bsd.GasLimit), + } + } + return data +} + +// observeNewBlockGas records push-path block-gas metrics from the most recently connected block. +// It is called synchronously from OnNewBlock (which the single writeBlockWorker invokes in height +// order) before the async broadcast, so the gauges advance monotonically without a mutex; the +// per-block broadcast goroutines could otherwise reorder and let an older block clobber a newer +// value. Last-value semantics: it sweeps catch-up blocks and settles on the tip. Non-EVM and +// pre-London blocks (no EthereumBlockSpecificData with gas set) are skipped. +func (s *WebsocketServer) observeNewBlockGas(block *bchain.Block) { + if s.metrics == nil { + return + } + bsd, ok := block.CoinSpecificData.(*bchain.EthereumBlockSpecificData) + if !ok || bsd == nil { + return + } + if s.metrics.EthBlockGasUsedRatio != nil && bsd.GasUsed != nil && bsd.GasLimit != nil && bsd.GasLimit.Sign() > 0 { + ratio, _ := new(big.Float).Quo(new(big.Float).SetInt(bsd.GasUsed), new(big.Float).SetInt(bsd.GasLimit)).Float64() + s.metrics.EthBlockGasUsedRatio.Set(ratio) + } + if s.metrics.EthBlockBaseFee != nil && bsd.BaseFeePerGas != nil { + baseFee, _ := new(big.Float).SetInt(bsd.BaseFeePerGas).Float64() + s.metrics.EthBlockBaseFee.Set(baseFee) + } +} + +func (s *WebsocketServer) onNewBlockAsync(block *bchain.Block) { s.newBlockSubscriptionsLock.Lock() defer s.newBlockSubscriptionsLock.Unlock() - data := struct { - Height uint32 `json:"height"` - Hash string `json:"hash"` - }{ - Height: height, - Hash: hash, - } + data := newBlockNotification(block) for c, id := range s.newBlockSubscriptions { c.DataOut(&WsRes{ ID: id, - Data: &data, + Data: data, }) } s.metrics.WebsocketNewBlockNotifications.Add(float64(len(s.newBlockSubscriptions))) - glog.V(2).Info("broadcasting new block ", height, " ", hash, " to ", len(s.newBlockSubscriptions), " channels") + glog.V(2).Info("broadcasting new block ", block.Height, " ", block.Hash, " to ", len(s.newBlockSubscriptions), " channels") } // setConfirmedBlockTxMetadata normalizes parsed block transactions. @@ -1602,9 +1872,12 @@ func (s *WebsocketServer) publishNewBlockTxsByAddr(block *bchain.Block) { // OnNewBlock is a callback that broadcasts info about new block to subscribed clients func (s *WebsocketServer) OnNewBlock(block *bchain.Block) { + // Synchronous and before the async dispatch: OnNewBlock is called in monotonic height order, so + // the push-path gas gauges never get an older block's value written after a newer one's. + s.observeNewBlockGas(block) s.addressSubscriptionsLock.Lock() defer s.addressSubscriptionsLock.Unlock() - go s.onNewBlockAsync(block.Hash, block.Height) + go s.onNewBlockAsync(block) if s.newBlockTxsSubscriptionCount > 0 { // Skip per-tx address matching when nobody opted into newBlockTxs. if ok, _ := s.trackWork(); ok { diff --git a/server/websocket_privatepending_test.go b/server/websocket_privatepending_test.go new file mode 100644 index 0000000000..30d439c39d --- /dev/null +++ b/server/websocket_privatepending_test.go @@ -0,0 +1,129 @@ +//go:build unittest +// +build unittest + +package server + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TestUnmarshalGetAccountInfoRequestPrivatePending verifies the optional privatePending field is +// parsed when present and simply absent otherwise, and that an unknown extra field does not break +// parsing (forward compatibility). +func TestUnmarshalGetAccountInfoRequestPrivatePending(t *testing.T) { + t.Run("present", func(t *testing.T) { + r, err := unmarshalGetAccountInfoRequest([]byte(`{"descriptor":"0xabc","privatePending":{"nonces":[42,43],"txids":["0xdead"]}}`)) + if err != nil { + t.Fatalf("unmarshal error = %v", err) + } + if r.PrivatePending == nil { + t.Fatal("privatePending not parsed") + } + if !reflect.DeepEqual(r.PrivatePending.Nonces, []uint64{42, 43}) { + t.Errorf("nonces = %v, want [42 43]", r.PrivatePending.Nonces) + } + if !reflect.DeepEqual(r.PrivatePending.Txids, []string{"0xdead"}) { + t.Errorf("txids = %v, want [0xdead]", r.PrivatePending.Txids) + } + }) + + t.Run("absent", func(t *testing.T) { + r, err := unmarshalGetAccountInfoRequest([]byte(`{"descriptor":"0xabc"}`)) + if err != nil || r.PrivatePending != nil { + t.Fatalf("got (%+v, %v), want nil privatePending and no error", r.PrivatePending, err) + } + }) + + t.Run("unknown field ignored", func(t *testing.T) { + if _, err := unmarshalGetAccountInfoRequest([]byte(`{"descriptor":"0xabc","somethingNew":123}`)); err != nil { + t.Fatalf("unknown field broke parsing: %v", err) + } + }) +} + +// TestPrivatePendingNonces covers the extraction helper: nil-safe, defensive copy, and the cap. +func TestPrivatePendingNonces(t *testing.T) { + if got := privatePendingNonces(nil); got != nil { + t.Errorf("nil input = %v, want nil", got) + } + if got := privatePendingNonces(&WsPrivatePending{}); got != nil { + t.Errorf("empty nonces = %v, want nil", got) + } + + src := &WsPrivatePending{Nonces: []uint64{7, 8, 9}} + got := privatePendingNonces(src) + if !reflect.DeepEqual(got, []uint64{7, 8, 9}) { + t.Fatalf("got %v, want [7 8 9]", got) + } + // mutating the returned slice must not affect the request struct (defensive copy) + got[0] = 0 + if src.Nonces[0] != 7 { + t.Error("returned slice aliases the request's backing array") + } + + // exactly at the cap: kept in full, no truncation + atCap := make([]uint64, maxPrivatePendingNonces) + if got := privatePendingNonces(&WsPrivatePending{Nonces: atCap}); len(got) != maxPrivatePendingNonces { + t.Errorf("at-cap length = %d, want %d (no truncation at the boundary)", len(got), maxPrivatePendingNonces) + } + // Over the cap: keep the LOWEST nonces. The pending nonce advances from the backend's answer + // across a contiguous run, so only the slots just above that answer can be consumed; the high + // end would strand whatever it contained. Declared descending, so positional truncation would + // keep exactly the wrong end and the sort is what makes the result order-independent. + over := make([]uint64, maxPrivatePendingNonces+10) + for i := range over { + over[i] = uint64(len(over) - 1 - i) + } + overResult := privatePendingNonces(&WsPrivatePending{Nonces: over}) + if len(overResult) != maxPrivatePendingNonces { + t.Fatalf("over-cap length = %d, want %d", len(overResult), maxPrivatePendingNonces) + } + for i, n := range overResult { + if n != uint64(i) { + t.Fatalf("over-cap result[%d] = %d, want %d (the lowest nonces, sorted ascending)", i, n, i) + } + } +} + +// TestPrivatePendingJSONRoundTripsThroughWsReq confirms the field survives the two-stage decode +// (outer WsReq envelope, then params) the server actually uses. +func TestPrivatePendingJSONRoundTripsThroughWsReq(t *testing.T) { + var req WsReq + if err := json.Unmarshal([]byte(`{"id":"1","method":"getAccountInfo","params":{"descriptor":"0xabc","privatePending":{"nonces":[5]}}}`), &req); err != nil { + t.Fatalf("outer unmarshal error = %v", err) + } + r, err := unmarshalGetAccountInfoRequest(req.Params) + if err != nil { + t.Fatalf("params unmarshal error = %v", err) + } + if r.PrivatePending == nil || !reflect.DeepEqual(r.PrivatePending.Nonces, []uint64{5}) { + t.Fatalf("privatePending = %+v, want nonces [5]", r.PrivatePending) + } +} + +// TestEstimateFeePrivatePendingDecodesIntoSpecific confirms the estimate-side wire contract: a real +// estimateFee envelope carrying specific.privatePending decodes so that r.Specific["privatePending"] +// is the nested object EthereumTypeEstimateGas reads (nonces arriving as JSON float64). Unlike the +// typed getAccountInfo field, the estimate path is an untyped map, so this is the only place the +// nesting is pinned end-to-end. +func TestEstimateFeePrivatePendingDecodesIntoSpecific(t *testing.T) { + var req WsReq + body := `{"id":"1","method":"estimateFee","params":{"blocks":[1],"specific":{"from":"0xabc","privatePending":{"nonces":[42],"txids":["0xdead"]}}}}` + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("outer unmarshal error = %v", err) + } + var r WsEstimateFeeReq + if err := json.Unmarshal(req.Params, &r); err != nil { + t.Fatalf("params unmarshal error = %v", err) + } + pp, ok := r.Specific["privatePending"].(map[string]interface{}) + if !ok { + t.Fatalf("specific.privatePending = %#v, want a nested object", r.Specific["privatePending"]) + } + nonces, ok := pp["nonces"].([]interface{}) + if !ok || len(nonces) != 1 || nonces[0].(float64) != 42 { + t.Fatalf("specific.privatePending.nonces = %#v, want [42]", pp["nonces"]) + } +} diff --git a/server/websocket_rate_limiter.go b/server/websocket_rate_limiter.go index d072e9e128..17a7bfcef8 100644 --- a/server/websocket_rate_limiter.go +++ b/server/websocket_rate_limiter.go @@ -11,8 +11,8 @@ import ( "github.com/gorilla/websocket" ) -const maxWebsocketConnectionAttemptsPerIP = 64 -const maxWebsocketConnectionsPerIP = 128 +const defaultWsMaxConnectionAttemptsPerIP = 64 +const defaultWsMaxConnectionsPerIP = 128 const websocketConnectionAttemptWindow = time.Minute const websocketConnectionLimiterTTL = 10 * time.Minute const websocketConnectionLimiterCleanupInterval = time.Minute @@ -42,6 +42,11 @@ type websocketConnectionLimiter struct { mux sync.Mutex clients map[string]*websocketClientLimit lastCleanup time.Time + // maxConnections caps concurrently open connections per client key; maxAttempts + // caps connection handshakes per key within websocketConnectionAttemptWindow. + // A value of 0 disables that check. + maxConnections int + maxAttempts int } // configureMessageRateLimit reads the per-connection message-rate and IP-block @@ -71,9 +76,55 @@ func (s *WebsocketServer) configureMessageRateLimit(network string) error { return nil } -func newWebsocketConnectionLimiter() *websocketConnectionLimiter { +// configurePendingRequestsLimit reads the per-connection cap on concurrently +// executing ("pending") requests from the environment, applying the default +// (see docs/env.md); 0 disables the cap entirely. +func (s *WebsocketServer) configurePendingRequestsLimit(network string) error { + prefix := strings.ToUpper(network) + var err error + if s.pendingRequestsLimit, err = parseNonNegativeIntEnv(prefix+"_WS_PENDING_REQUESTS_LIMIT", defaultWsPendingRequestsLimit); err != nil { + return err + } + if s.pendingRequestsLimit > 0 { + glog.Infof("Websocket per-connection pending request limit: %d", s.pendingRequestsLimit) + } else { + glog.Info("Websocket per-connection pending request limit disabled") + } + return nil +} + +// configureConnectionLimits reads the per-IP websocket connection caps from the +// environment (see docs/env.md) and builds the connection limiter. Either cap +// set to 0 disables that check. +func (s *WebsocketServer) configureConnectionLimits(network string) error { + prefix := strings.ToUpper(network) + maxConnections, err := parseNonNegativeIntEnv(prefix+"_WS_MAX_CONNECTIONS_PER_IP", defaultWsMaxConnectionsPerIP) + if err != nil { + return err + } + maxAttempts, err := parseNonNegativeIntEnv(prefix+"_WS_MAX_CONNECTION_ATTEMPTS_PER_IP", defaultWsMaxConnectionAttemptsPerIP) + if err != nil { + return err + } + s.websocketLimiter = newWebsocketConnectionLimiter(maxConnections, maxAttempts) + if maxConnections > 0 { + glog.Infof("Websocket per-IP connection limit: %d", maxConnections) + } else { + glog.Info("Websocket per-IP connection limit disabled") + } + if maxAttempts > 0 { + glog.Infof("Websocket per-IP connection attempt limit: %d / %s", maxAttempts, websocketConnectionAttemptWindow) + } else { + glog.Info("Websocket per-IP connection attempt limit disabled") + } + return nil +} + +func newWebsocketConnectionLimiter(maxConnections, maxAttempts int) *websocketConnectionLimiter { return &websocketConnectionLimiter{ - clients: make(map[string]*websocketClientLimit), + clients: make(map[string]*websocketClientLimit), + maxConnections: maxConnections, + maxAttempts: maxAttempts, } } @@ -90,10 +141,10 @@ func (l *websocketConnectionLimiter) accept(ip string, now time.Time) (bool, str client.lastSeen = now client.trimAttempts(now) - if client.active >= maxWebsocketConnectionsPerIP { + if l.maxConnections > 0 && client.active >= l.maxConnections { return false, "connection_limit" } - if len(client.attempts) >= maxWebsocketConnectionAttemptsPerIP { + if l.maxAttempts > 0 && len(client.attempts) >= l.maxAttempts { return false, "connection_attempt_limit" } diff --git a/server/websocket_test.go b/server/websocket_test.go index fa98bbcef9..7e4cbe6743 100644 --- a/server/websocket_test.go +++ b/server/websocket_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "net/http" + "net/http/httptest" "net/netip" "os" "path/filepath" @@ -15,9 +16,11 @@ import ( "testing" "time" + "github.com/gorilla/websocket" "github.com/trezor/blockbook/api" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/tests/dbtestdata" ) @@ -167,6 +170,183 @@ func TestParseAllowedOrigins(t *testing.T) { } } +func TestParseAllowedEvmCallMethods(t *testing.T) { + tests := []struct { + name string + env string + want []string + wantErr bool + }{ + {name: "empty", env: "", want: nil}, + {name: "empty entries only", env: " , ,", wantErr: true}, + {name: "single selector", env: "0xdd62ed3e", want: []string{"dd62ed3e"}}, + {name: "without prefix", env: "dd62ed3e", want: []string{"dd62ed3e"}}, + {name: "uppercase", env: "0XDD62ED3E", want: []string{"dd62ed3e"}}, + {name: "multiple with spaces", env: " 0xdd62ed3e , 0x70a08231 ", want: []string{"dd62ed3e", "70a08231"}}, + {name: "empty entries skipped", env: "0xdd62ed3e,,0x70a08231", want: []string{"dd62ed3e", "70a08231"}}, + {name: "too short", env: "0xdd62ed", wantErr: true}, + {name: "too long", env: "0xdd62ed3e00", wantErr: true}, + {name: "odd length", env: "0xdd62ed3", wantErr: true}, + {name: "non hex", env: "0xdd62ed3g", wantErr: true}, + {name: "bare prefix", env: "0x", wantErr: true}, + {name: "one invalid among valid", env: "0xdd62ed3e,invalid", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAllowedEvmCallMethods("FAKE_ALLOWED_EVM_CALL_METHODS", tt.env) + if tt.wantErr { + if err == nil { + t.Fatalf("parseAllowedEvmCallMethods(%q) = nil err, want error", tt.env) + } + return + } + if err != nil { + t.Fatalf("parseAllowedEvmCallMethods(%q) unexpected error: %v", tt.env, err) + } + if tt.want == nil && got != nil { + t.Fatalf("parseAllowedEvmCallMethods(%q) = %v, want nil", tt.env, got) + } + if len(got) != len(tt.want) { + t.Fatalf("parseAllowedEvmCallMethods(%q) len = %d, want %d", tt.env, len(got), len(tt.want)) + } + for _, selector := range tt.want { + if _, ok := got[selector]; !ok { + t.Fatalf("parseAllowedEvmCallMethods(%q) missing %q", tt.env, selector) + } + } + }) + } +} + +func TestRpcCallAllowed(t *testing.T) { + // allowance(owner, spender) calldata with the 0xdd62ed3e selector + const allowanceData = "0xdd62ed3e0000000000000000000000009ea3721b5bf3b64b4418c38b603154d2d597fae3000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829" + const allowedTo = "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9" + const otherTo = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" + allowedToSet := map[string]struct{}{strings.ToLower(allowedTo): {}} + allowanceSelectorSet := map[string]struct{}{"dd62ed3e": {}} + + tests := []struct { + name string + to map[string]struct{} + methods map[string]struct{} + req WsRpcCallReq + want bool + }{ + { + name: "no allowlists configured allows all", + req: WsRpcCallReq{To: otherTo, Data: "0x12345678"}, + want: true, + }, + { + name: "allowed address passes", + to: allowedToSet, + req: WsRpcCallReq{To: allowedTo, Data: "0x12345678"}, + want: true, + }, + { + name: "allowed address is case-insensitive", + to: allowedToSet, + req: WsRpcCallReq{To: strings.ToUpper(allowedTo), Data: "0x12345678"}, + want: true, + }, + { + name: "address not allowed without methods list", + to: allowedToSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData}, + want: false, + }, + { + name: "allowed address passes regardless of selector", + to: allowedToSet, + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: allowedTo, Data: "0x12345678"}, + want: true, + }, + { + name: "allowed selector passes to any address", + to: allowedToSet, + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData}, + want: true, + }, + { + name: "selector alone is enough for exact 4 byte calldata", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0xdd62ed3e"}, + want: true, + }, + { + name: "uppercase hex calldata matches", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0XDD62ED3E"}, + want: true, + }, + { + name: "only methods list set rejects other selectors", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0x70a08231000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829"}, + want: false, + }, + { + name: "not allowed address nor selector", + to: allowedToSet, + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0x12345678"}, + want: false, + }, + { + name: "missing 0x prefix fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData[2:]}, + want: false, + }, + { + name: "odd length calldata fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData + "0"}, + want: false, + }, + { + name: "non hex tail after valid selector fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0xdd62ed3exx"}, + want: false, + }, + { + name: "calldata shorter than selector fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0xdd62ed"}, + want: false, + }, + { + name: "empty calldata fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: ""}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + is := &common.InternalState{} + is.SetRpcCallAllowlists(&common.RpcCallAllowlists{To: tt.to, Methods: tt.methods}) + s := &WebsocketServer{is: is} + if got := s.rpcCallAllowed(&tt.req); got != tt.want { + t.Fatalf("rpcCallAllowed(to=%q, data=%q) = %v, want %v", tt.req.To, tt.req.Data, got, tt.want) + } + }) + } + + t.Run("nil snapshot allows all", func(t *testing.T) { + s := &WebsocketServer{is: &common.InternalState{}} + if !s.rpcCallAllowed(&WsRpcCallReq{To: otherTo, Data: "0x12345678"}) { + t.Fatal("rpcCallAllowed with uninitialized snapshot = false, want true") + } + }) +} + func TestParseTrustedProxies(t *testing.T) { tests := []struct { name string @@ -765,11 +945,11 @@ func TestConnMessageRateClockSkew(t *testing.T) { } func TestWebsocketConnectionLimiterConnectionAttempts(t *testing.T) { - limiter := newWebsocketConnectionLimiter() + limiter := newWebsocketConnectionLimiter(defaultWsMaxConnectionsPerIP, defaultWsMaxConnectionAttemptsPerIP) now := time.Unix(1700000000, 0) ip := "192.0.2.10" - for i := 0; i < maxWebsocketConnectionAttemptsPerIP; i++ { + for i := 0; i < defaultWsMaxConnectionAttemptsPerIP; i++ { ok, reason := limiter.accept(ip, now) if !ok { t.Fatalf("accept(%d) rejected with %q", i, reason) @@ -789,12 +969,12 @@ func TestWebsocketConnectionLimiterConnectionAttempts(t *testing.T) { } func TestWebsocketConnectionLimiterActiveConnections(t *testing.T) { - limiter := newWebsocketConnectionLimiter() + limiter := newWebsocketConnectionLimiter(defaultWsMaxConnectionsPerIP, defaultWsMaxConnectionAttemptsPerIP) now := time.Unix(1700000000, 0) ip := "192.0.2.20" - for i := 0; i < maxWebsocketConnectionsPerIP; i++ { - if i > 0 && i%maxWebsocketConnectionAttemptsPerIP == 0 { + for i := 0; i < defaultWsMaxConnectionsPerIP; i++ { + if i > 0 && i%defaultWsMaxConnectionAttemptsPerIP == 0 { now = now.Add(websocketConnectionAttemptWindow + time.Second) } ok, reason := limiter.accept(ip, now) @@ -815,8 +995,21 @@ func TestWebsocketConnectionLimiterActiveConnections(t *testing.T) { } } +func TestWebsocketConnectionLimiterDisabled(t *testing.T) { + // 0 caps disable both the connection and attempt checks; accept never rejects. + limiter := newWebsocketConnectionLimiter(0, 0) + now := time.Unix(1700000000, 0) + ip := "192.0.2.60" + + for i := 0; i < defaultWsMaxConnectionsPerIP+defaultWsMaxConnectionAttemptsPerIP+1; i++ { + if ok, reason := limiter.accept(ip, now); !ok { + t.Fatalf("accept(%d) rejected with %q on a disabled limiter", i, reason) + } + } +} + func TestWebsocketConnectionLimiterSweepEvictsIdleEntries(t *testing.T) { - limiter := newWebsocketConnectionLimiter() + limiter := newWebsocketConnectionLimiter(defaultWsMaxConnectionsPerIP, defaultWsMaxConnectionAttemptsPerIP) now := time.Unix(1700000000, 0) idle := "192.0.2.40" active := "192.0.2.41" @@ -846,7 +1039,7 @@ func TestWebsocketConnectionLimiterSweepEvictsIdleEntries(t *testing.T) { } func TestWebsocketConnectionLimiterStats(t *testing.T) { - limiter := newWebsocketConnectionLimiter() + limiter := newWebsocketConnectionLimiter(defaultWsMaxConnectionsPerIP, defaultWsMaxConnectionAttemptsPerIP) now := time.Unix(1700000000, 0) a := "192.0.2.50" b := "192.0.2.51" @@ -886,7 +1079,7 @@ func TestWebsocketConnectionLimiterStats(t *testing.T) { } func TestWebsocketConnectionLimiterCleanup(t *testing.T) { - limiter := newWebsocketConnectionLimiter() + limiter := newWebsocketConnectionLimiter(defaultWsMaxConnectionsPerIP, defaultWsMaxConnectionAttemptsPerIP) now := time.Unix(1700000000, 0) ip := "192.0.2.30" @@ -1261,8 +1454,11 @@ func TestWebsocketShutdownWaitsForInFlightWork(t *testing.T) { go func() { // Simulate a DB-touching goroutine that takes some time. time.Sleep(50 * time.Millisecond) - s.workDone() + // Signal completion before workDone(): workDone() is what releases + // Shutdown, so closing afterwards races with the check below and the + // non-blocking select can observe finished as still open. close(finished) + s.workDone() }() ctx, cancel := context.WithTimeout(context.Background(), time.Second) @@ -1344,6 +1540,103 @@ func TestWebsocketTrackWorkAppliesGlobalLimit(t *testing.T) { } } +func TestWebsocketMempoolFilterResponseSlots(t *testing.T) { + c := &websocketChannel{ + mempoolFiltersSlots: make(chan struct{}, maxWebsocketMempoolFiltersResponses), + } + for i := 0; i < maxWebsocketMempoolFiltersResponses; i++ { + if !c.acquireMempoolFiltersSlot() { + t.Fatalf("acquireMempoolFiltersSlot() = false at slot %d", i) + } + } + if c.acquireMempoolFiltersSlot() { + t.Fatal("acquireMempoolFiltersSlot() = true at limit") + } + c.releaseMempoolFiltersSlot() + if !c.acquireMempoolFiltersSlot() { + t.Fatal("acquireMempoolFiltersSlot() = false after release") + } +} + +func TestWebsocketCloseOutReleasesQueuedMempoolFilterResponses(t *testing.T) { + c := &websocketChannel{ + out: make(chan *WsRes, outChannelSize), + mempoolFiltersSlots: make(chan struct{}, maxWebsocketMempoolFiltersResponses), + alive: true, + } + if !c.acquireMempoolFiltersSlot() { + t.Fatal("acquireMempoolFiltersSlot() = false") + } + c.DataOut(&WsRes{ID: "mempool", Data: struct{}{}, release: c.releaseMempoolFiltersSlot}) + if got := len(c.mempoolFiltersSlots); got != 1 { + t.Fatalf("held mempool filter slots = %d before CloseOut, want 1", got) + } + if closed, reason := c.CloseOut("test"); !closed || reason != "test" { + t.Fatalf("CloseOut() = %v, %q, want true, test", closed, reason) + } + if got := len(c.mempoolFiltersSlots); got != 0 { + t.Fatalf("held mempool filter slots = %d after CloseOut, want 0", got) + } +} + +// TestWebsocketOutputLoopReleasesMempoolFilterSlotAfterWrite exercises the +// primary slot-release path for a live connection: outputLoop's c.finalize(m) +// after a successful WriteJSON. Neither TestWebsocketMempoolFilterResponseSlots +// (raw semaphore) nor TestWebsocketCloseOutReleasesQueuedMempoolFilterResponses +// (drain-on-close) touch this line, so without this test a removal of the +// finalize call would leak a slot on every successful response and go undetected. +func TestWebsocketOutputLoopReleasesMempoolFilterSlotAfterWrite(t *testing.T) { + // Stand up a real websocket connection pair so outputLoop's WriteJSON and + // finalize run against a live *websocket.Conn. + upgrader := websocket.Upgrader{} + serverConnCh := make(chan *websocket.Conn, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + serverConnCh <- conn + })) + defer ts.Close() + + clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http"), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer clientConn.Close() + serverConn := <-serverConnCh + defer serverConn.Close() + + c := &websocketChannel{ + conn: serverConn, + out: make(chan *WsRes, outChannelSize), + mempoolFiltersSlots: make(chan struct{}, maxWebsocketMempoolFiltersResponses), + alive: true, + } + if !c.acquireMempoolFiltersSlot() { + t.Fatal("acquireMempoolFiltersSlot() = false") + } + c.out <- &WsRes{ID: "mempool", Data: struct{}{}, release: c.releaseMempoolFiltersSlot} + close(c.out) // outputLoop returns once the single queued response is written + + s := &WebsocketServer{} + done := make(chan struct{}) + go func() { + s.outputLoop(c) + close(done) + }() + + if _, _, err := clientConn.ReadMessage(); err != nil { + t.Fatalf("client read: %v", err) + } + <-done + + if got := len(c.mempoolFiltersSlots); got != 0 { + t.Fatalf("held mempool filter slots = %d after successful write, want 0", got) + } +} + func TestWebsocketShutdownIsIdempotent(t *testing.T) { s := newShutdownTestServer() ctx, cancel := context.WithTimeout(context.Background(), time.Second) diff --git a/server/ws_newblock_test.go b/server/ws_newblock_test.go new file mode 100644 index 0000000000..4022f4645d --- /dev/null +++ b/server/ws_newblock_test.go @@ -0,0 +1,148 @@ +//go:build unittest + +package server + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// gaugeFloat reads a single gauge's value via a throwaway registry, avoiding the +// prometheus/testutil dependency (and its transitive modules). +func gaugeFloat(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 +} + +func TestObserveNewBlockGas(t *testing.T) { + m := &common.Metrics{ + EthBlockGasUsedRatio: prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_eth_block_gas_used_ratio"}), + EthBlockBaseFee: prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_eth_block_base_fee"}), + } + s := &WebsocketServer{metrics: m} + + // half-full post-London block -> ratio 0.5, base fee in raw wei + s.observeNewBlockGas(&bchain.Block{CoinSpecificData: &bchain.EthereumBlockSpecificData{ + BaseFeePerGas: big.NewInt(7), GasUsed: big.NewInt(15000000), GasLimit: big.NewInt(30000000), + }}) + if got := gaugeFloat(t, m.EthBlockGasUsedRatio); got != 0.5 { + t.Errorf("gas-used ratio = %v, want 0.5", got) + } + if got := gaugeFloat(t, m.EthBlockBaseFee); got != 7 { + t.Errorf("base fee = %v, want 7 (raw wei)", got) + } + + // non-EVM block and nil metrics must be safe no-ops + s.observeNewBlockGas(&bchain.Block{}) + (&WebsocketServer{}).observeNewBlockGas(&bchain.Block{CoinSpecificData: &bchain.EthereumBlockSpecificData{ + GasUsed: big.NewInt(1), GasLimit: big.NewInt(2), + }}) +} + +// TestNewBlockNotification covers the block -> subscribeNewBlock payload mapping: only EVM +// post-London blocks (EthereumBlockSpecificData with BaseFeePerGas set) carry evmData; non-EVM, +// pre-London, and missing coin-specific data leave it nil. +func TestNewBlockNotification(t *testing.T) { + t.Run("non-EVM block has nil EVMData", func(t *testing.T) { + got := newBlockNotification(&bchain.Block{BlockHeader: bchain.BlockHeader{Height: 7, Hash: "0xh"}}) + if got.Height != 7 || got.Hash != "0xh" { + t.Errorf("height/hash = %d/%q, want 7/0xh", got.Height, got.Hash) + } + if got.EVMData != nil { + t.Errorf("expected nil EVMData for non-EVM block, got %+v", got.EVMData) + } + }) + + t.Run("EVM post-London block carries gas data from the block header", func(t *testing.T) { + block := &bchain.Block{ + BlockHeader: bchain.BlockHeader{Height: 7, Hash: "0xh"}, + CoinSpecificData: &bchain.EthereumBlockSpecificData{ + BaseFeePerGas: big.NewInt(7), + GasUsed: big.NewInt(21000), + GasLimit: big.NewInt(30000000), + }, + } + got := newBlockNotification(block) + if got.EVMData == nil { + t.Fatal("expected EVMData, got nil") + } + if (*big.Int)(got.EVMData.BaseFeePerGas).Cmp(big.NewInt(7)) != 0 { + t.Errorf("BaseFeePerGas = %v, want 7", got.EVMData.BaseFeePerGas) + } + if (*big.Int)(got.EVMData.BlockGasUsed).Cmp(big.NewInt(21000)) != 0 { + t.Errorf("BlockGasUsed = %v, want 21000", got.EVMData.BlockGasUsed) + } + if (*big.Int)(got.EVMData.BlockGasLimit).Cmp(big.NewInt(30000000)) != 0 { + t.Errorf("BlockGasLimit = %v, want 30000000", got.EVMData.BlockGasLimit) + } + }) + + t.Run("EVM pre-London block (no base fee) has nil EVMData", func(t *testing.T) { + block := &bchain.Block{ + BlockHeader: bchain.BlockHeader{Height: 7, Hash: "0xh"}, + CoinSpecificData: &bchain.EthereumBlockSpecificData{GasUsed: big.NewInt(21000), GasLimit: big.NewInt(30000000)}, + } + if got := newBlockNotification(block); got.EVMData != nil { + t.Errorf("expected nil EVMData pre-London, got %+v", got.EVMData) + } + }) +} + +// TestWsNewBlockJSON pins the subscribeNewBlock wire shape: non-EVM chains serialize evmData as +// null (height/hash unchanged), while EVM chains carry decimal-string gas figures for the EIP-1559 projection. +func TestWsNewBlockJSON(t *testing.T) { + tests := []struct { + name string + in WsNewBlock + want string + }{ + { + name: "non-EVM emits evmData null", + in: WsNewBlock{Height: 5, Hash: "0xabc"}, + want: `{"height":5,"hash":"0xabc","evmData":null}`, + }, + { + name: "EVM emits decimal gas figures", + in: WsNewBlock{ + Height: 5, + Hash: "0xabc", + EVMData: &EthereumGasData{ + BaseFeePerGas: (*api.Amount)(big.NewInt(7)), + BlockGasUsed: (*api.Amount)(big.NewInt(21000)), + BlockGasLimit: (*api.Amount)(big.NewInt(30000000)), + }, + }, + want: `{"height":5,"hash":"0xabc","evmData":{"baseFeePerGas":"7","blockGasUsed":"21000","blockGasLimit":"30000000"}}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := json.Marshal(&tt.in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != tt.want { + t.Errorf("got %s\nwant %s", b, tt.want) + } + }) + } +} diff --git a/server/ws_types.go b/server/ws_types.go index a67dc22f87..4f5707a74c 100644 --- a/server/ws_types.go +++ b/server/ws_types.go @@ -17,6 +17,8 @@ type WsReq struct { 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."` + // release, if non-nil, is invoked exactly once when this response leaves the out pipeline + release func() } type resultError struct { @@ -27,19 +29,31 @@ type resultError struct { // WsAccountInfoReq carries parameters for the 'getAccountInfo' method. type WsAccountInfoReq struct { - Descriptor string `json:"descriptor" ts_doc:"Address or XPUB descriptor to query."` - // SYSCOIN: txsummary is a compact account-context history mode for wallet lists. - Details string `json:"details,omitempty" ts_type:"'basic' | 'tokens' | 'tokenBalances' | 'txids' | 'txsummary' | '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)."` + Descriptor string `json:"descriptor" ts_doc:"Address or XPUB descriptor to query."` + Details string `json:"details,omitempty" ts_type:"'basic' | 'tokens' | 'tokenBalances' | 'txids' | 'txsummary' | '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)."` + PrivatePending *WsPrivatePending `json:"privatePending,omitempty" ts_doc:"Ethereum-like only: the sender's in-flight private (alternative send-tx / relay) transactions the wallet is tracking for this address. When it declares nonces, Blockbook answers the pending-nonce lookup from this authoritative wallet state instead of inferring it from recently accepted sends (see docs/evm-send.md)."` +} + +// WsPrivatePending declares the private (alternative send-tx / relay) transactions a wallet knows +// are in flight for the queried Ethereum-like address. A private relay exposes no mempool, so a tx +// pending only there is invisible to the public backend RPC; declaring it lets Blockbook route the +// nonce lookup to the relay and count each declared nonce as an occupied slot in the pending-nonce +// walk (see the Nonces doc), rather than guessing from which addresses recently sent through this +// instance. Only Nonces drive behavior today; Txids are accepted for forward compatibility (future +// pending-tx correlation). +type WsPrivatePending struct { + Nonces []uint64 `json:"nonces,omitempty" ts_doc:"Account nonces of the wallet's in-flight private transactions for this address. Each entry is a literal in-flight nonce (0 is a valid value, not a sentinel) and is treated as an occupied nonce slot alongside Blockbook's own cached private transactions: the reported pending nonce advances from the backend's own answer across the contiguous run of occupied slots. Send the whole in-flight set, not just its maximum, and do not pad or default the array - a declared nonce above a slot nothing fills does not lift the answer over that slot."` + Txids []string `json:"txids,omitempty" ts_doc:"Transaction hashes of the in-flight private transactions (reserved for future use)."` } // WsContractInfoReq carries parameters for the 'getContractInfo' method. @@ -138,7 +152,7 @@ type WsTransactionSpecificReq struct { // 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)."` + Specific map[string]interface{} `json:"specific,omitempty" ts_type:"{conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string; privatePending?: WsPrivatePending;}" ts_doc:"Additional chain-specific parameters (e.g. for Ethereum). privatePending (Ethereum-like) declares the sender's in-flight private transactions so the gas estimate is routed to the alternative send-tx provider; see WsPrivatePending and docs/evm-send.md."` } // WsEstimateFeeRes is returned in response to a fee estimation request. @@ -149,6 +163,21 @@ type WsEstimateFeeRes struct { Eip1559 *api.Eip1559Fees `json:"eip1559,omitempty"` } +// EthereumGasData carries EVM block-level gas figures used by the frontend to deterministically +// project the next block's EIP-1559 base fee. Decimal strings, omitted for pre-London blocks. +type EthereumGasData struct { + BaseFeePerGas *api.Amount `json:"baseFeePerGas,omitempty" ts_doc:"Base fee per gas of the new block (post-London)."` + BlockGasUsed *api.Amount `json:"blockGasUsed,omitempty" ts_doc:"Total gas used by the new block (decimal)."` + BlockGasLimit *api.Amount `json:"blockGasLimit,omitempty" ts_doc:"Gas limit of the new block (decimal)."` +} + +// WsNewBlock is pushed to subscribeNewBlock subscribers when a new block is connected. +type WsNewBlock struct { + Height uint32 `json:"height" ts_doc:"Height of the new block."` + Hash string `json:"hash" ts_doc:"Hash of the new block."` + EVMData *EthereumGasData `json:"evmData" ts_nullable:"true" ts_doc:"EVM gas data for the EIP-1559 base-fee projection; null on non-EVM chains."` +} + // 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)."` diff --git a/static/css/main.css b/static/css/main.css index 1f95c1dfa0..9e08e4c23b 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -508,6 +508,41 @@ span.btn-paging:hover { white-space: normal; } +.tx-io-toggle { + display: block; + width: 100%; + background-color: #f6f6f6; + border: 1px solid #e2e2e2; + border-radius: 0.5rem; + padding: 0.4rem 1.1rem; + margin-bottom: 0.4rem; + color: #757575; + font-size: 0.85em; + font-weight: bold; + text-align: center; + cursor: pointer; +} + +.tx-in > .tx-io-toggle, +.tx-out > .tx-io-toggle { + margin-bottom: 0; + margin-top: 0.4rem; +} + +.tx-io-toggle:hover { + color: black; + background-color: #f6f6f6; + border-color: #e2e2e2; +} + +.tx-io-toggle::after { + content: " \25BC"; +} + +.tx-io-toggle[aria-expanded="true"]::after { + content: " \25B2"; +} + .spent { color: #dc3545 !important; } diff --git a/static/css/main.min.4.css b/static/css/main.min.4.css deleted file mode 100644 index a404827014..0000000000 --- a/static/css/main.min.4.css +++ /dev/null @@ -1 +0,0 @@ -@import "TTHoves/TTHoves.css";* {margin: 0;padding: 0;outline: none;font-family: "TT Hoves", -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;}html, body {height: 100%;}body {min-height: 100%;margin: 0;background: linear-gradient(to bottom, #f6f6f6 360px, #e5e5e5 0), #e5e5e5;background-repeat: no-repeat;}a {color: #00854d;text-decoration: none;}a:hover {color: #00854d;text-decoration: underline;}select {border-radius: 0.5rem;padding-left: 0.5rem;border: 1px solid #ced4da;color: var(--bs-body-color);min-height: 45px;}#header {position: fixed;top: 0;left: 0;width: 100%;margin: 0;padding-bottom: 0;padding-top: 0;background-color: white;border-bottom: 1px solid #f6f6f6;z-index: 10;}#header a {color: var(--bs-navbar-brand-color);}#header a:hover {color: var(--bs-navbar-brand-hover-color);}#header .navbar {--bs-navbar-padding-y: 0.7rem;}#header .form-control-lg {font-size: 1rem;padding: 0.75rem 1rem;}#header .container {min-height: 50px;}#header .btn.dropdown-toggle {padding-right: 0;}#header .dropdown-menu {--bs-dropdown-min-width: 13rem;}#header .dropdown-menu[data-bs-popper] {left: initial;right: 0;}#header .dropdown-menu.show {display: flex;}.form-control:focus {outline: 0;box-shadow: none;border-color: #00854d;}.base-value {color: #757575 !important;padding-left: 0.5rem;font-weight: normal;}.badge {vertical-align: middle;text-transform: uppercase;letter-spacing: 0.15em;--bs-badge-padding-x: 0.8rem;--bs-badge-font-weight: normal;--bs-badge-border-radius: 0.6rem;}.bg-secondary {background-color: #757575 !important;}.accordion {--bs-accordion-border-radius: 10px;--bs-accordion-inner-border-radius: calc(10px - 1px);--bs-accordion-color: var(--bs-body-color);--bs-accordion-active-color: var(--bs-body-color);--bs-accordion-active-bg: white;--bs-accordion-btn-active-icon: url("data:image/svg+xml,");}.accordion-button:focus {outline: 0;box-shadow: none;}.accordion-body {letter-spacing: -0.01em;}.bb-group {border: 0.6rem solid #f6f6f6;background-color: #f6f6f6;border-radius: 0.5rem;position: relative;display: inline-flex;vertical-align: middle;}.bb-group>.btn {--bs-btn-padding-x: 0.5rem;--bs-btn-padding-y: 0.22rem;--bs-btn-border-radius: 0.3rem;--bs-btn-border-width: 0;color: #545454;}.bb-group>.btn-check:checked+.btn, .bb-group .btn.active {color: black;font-weight: bold;background-color: white;}.paging {display: flex;}.paging .bb-group>.btn {min-width: 2rem;margin-left: 0.1rem;margin-right: 0.1rem;}.paging .bb-group>.btn:hover {background-color: white;}.paging a {text-decoration: none;}.btn-paging {--bs-btn-color: #757575;--bs-btn-border-color: #e2e2e2;--bs-btn-hover-color: black;--bs-btn-hover-bg: #f6f6f6;--bs-btn-hover-border-color: #e2e2e2;--bs-btn-focus-shadow-rgb: 108, 117, 125;--bs-btn-active-color: #fff;--bs-btn-active-bg: #e2e2e2;--bs-btn-active-border-color: #e2e2e2;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-gradient: none;--bs-btn-padding-y: 0.75rem;--bs-btn-padding-x: 1.1rem;--bs-btn-border-radius: 0.5rem;--bs-btn-font-weight: bold;background-color: #f6f6f6;}span.btn-paging {cursor: initial;}span.btn-paging:hover {color: #757575;}.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-type-badges {clear: both;}.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;}/* SYSCOIN: reserve space for asset-aware amounts beside long scripts/OP_RETURN data. */.tx-io-row {align-items: flex-start;display: flex;gap: 0.75rem;}.tx-io-main {flex: 1 1 auto;min-width: 0;}.tx-detail .tx-io-main span.ellipsis, .tx-detail .tx-io-main a.ellipsis {float: none;}.tx-io-amount {flex: 0 1 45%;min-width: 9rem;overflow-wrap: anywhere;text-align: right;}.tx-io-asset {display: block;line-height: 1.35;margin-top: 0.15rem;white-space: normal;}.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 }/* SYSCOIN: stack amount metadata below address/script text on narrower screens. */.tx-io-row {flex-wrap: wrap;}.tx-io-amount {flex-basis: 100%;min-width: 0;}}@media (min-width: 769px) {body {font-size: 0.9rem;}.btn {--bs-btn-font-size: 0.9rem;}}@media (min-width: 1200px) {.h1, h1 {font-size: 2.4rem;}body {font-size: 1rem;}.btn {--bs-btn-font-size: 1rem;}} \ No newline at end of file diff --git a/static/css/main.min.5.css b/static/css/main.min.5.css new file mode 100644 index 0000000000..22a5818d25 --- /dev/null +++ b/static/css/main.min.5.css @@ -0,0 +1 @@ +@font-face{font-family:'TT Hoves';src:url('./TTHoves-Bold.woff2') format('woff2'),url('./TTHoves-Bold.woff') format('woff');font-weight:700;font-style:normal}@font-face{font-family:'TT Hoves';src:url('./TTHoves-Regular.woff2') format('woff2'),url('./TTHoves-Regular.woff') format('woff');font-weight:400;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}*{margin:0;padding:0;outline:0;font-family:"TT Hoves",-apple-system,"Segoe UI","Helvetica Neue",Arial,sans-serif}body,html{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:.5rem;padding-left:.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:#fff;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:.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:.5rem;font-weight:400}.badge{vertical-align:middle;text-transform:uppercase;letter-spacing:.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:-.01em}.bb-group{border:.6rem solid #f6f6f6;background-color:#f6f6f6;border-radius:.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.active,.bb-group>.btn-check:checked+.btn{color:#000;font-weight:700;background-color:#fff}.paging{display:flex}.paging .bb-group>.btn{min-width:2rem;margin-left:.1rem;margin-right:.1rem}.paging .bb-group>.btn:hover{background-color:#fff}.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:#fff}.paging-group{border:1px solid #e2e2e2;border-radius:.5rem}.paging-group>.bb-group{border:.53rem solid #f6f6f6}#wrap{min-height:100%;height:auto;padding:112px 0 75px 0;margin:0 auto -56px}#footer{background-color:#000;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:.8rem .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 th,.data-table thead tr{color:#757575;border:none;font-weight:400}.data-table tbody th{color:#757575;font-weight:400}.data-table tbody{background:#fff;border-radius:8px;box-shadow:0 0 0 8px #fff}.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:700}.info-table tr>td:first-child{font-weight:400;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")}.copied::before,.copyable::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 .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:.12em;font-weight:400!important}.tx-detail{background:#f6f6f6;color:#757575;border-radius:10px;box-shadow:0 0 0 10px #fff;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 a.ellipsis,.tx-detail span.ellipsis{display:block;float:left;max-width:100%}.tx-detail>.footer,.tx-detail>.head{padding:1.5rem;--bs-gutter-x:0}.tx-detail>.head{border-radius:10px 10px 0 0}.tx-detail .txid{font-size:106%;letter-spacing:-.01em}.tx-type-badges{clear:both}.tx-detail>.body{padding:0 1.5rem;--bs-gutter-x:0;letter-spacing:-.01em}.tx-detail>.subhead{padding:1.5rem 1.5rem .4rem 1.5rem;--bs-gutter-x:0;letter-spacing:.1em;text-transform:uppercase;color:var(--bs-body-color)}.tx-detail>.subhead-2{padding:.3rem 1.5rem 0 1.5rem;--bs-gutter-x:0;font-size:.875em;color:var(--bs-body-color)}.tx-addr .col-12,.tx-in .col-12,.tx-out .col-12{background-color:#fff;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}.tx-io-row{align-items:flex-start;display:flex;gap:.75rem}.tx-io-main{flex:1 1 auto;min-width:0}.tx-detail .tx-io-main a.ellipsis,.tx-detail .tx-io-main span.ellipsis{float:none}.tx-io-amount{flex:0 1 45%;min-width:9rem;overflow-wrap:anywhere;text-align:right}.tx-io-asset{display:block;line-height:1.35;margin-top:.15rem;white-space:normal}.tx-io-toggle{display:block;width:100%;background-color:#f6f6f6;border:1px solid #e2e2e2;border-radius:.5rem;padding:.4rem 1.1rem;margin-bottom:.4rem;color:#757575;font-size:.85em;font-weight:700;text-align:center;cursor:pointer}.tx-in>.tx-io-toggle,.tx-out>.tx-io-toggle{margin-bottom:0;margin-top:.4rem}.tx-io-toggle:hover{color:#000;background-color:#f6f6f6;border-color:#e2e2e2}.tx-io-toggle::after{content:" \25BC"}.tx-io-toggle[aria-expanded=true]::after{content:" \25B2"}.spent{color:#dc3545!important}.unspent{color:#28a745!important}.outpoint{color:#757575!important}.outpoint,.spent,.unspent{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:700}.txerror{color:#c51f13}.txerror .txvalue,.txerror a{color:#c51f13}.txerror .copied::before,.txerror .copyable::before{filter:invert(86%) sepia(43%) saturate(732%) hue-rotate(367deg) brightness(84%)}.amt-out>.amt:hover,.tx-amt .amt:hover,.tx-amt.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, .25))}.l-tooltip{text-align:start;display:inline-block}.l-tooltip .base-amt,.l-tooltip .cbase-amt,.l-tooltip .csec-amt,.l-tooltip .prim-amt,.l-tooltip .sec-amt{display:initial;float:right}.l-tooltip .amt-time{padding-right:3rem;float:left}.amt-dec{font-size:95%}.unconfirmed{color:#fff;background-color:#c51e13;padding:.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:#fff;letter-spacing:.02em}#raw .string{color:#2bca87}#raw .boolean,#raw .number{color:#efc941}#raw .null{color:red}@media (max-width:768px){body{font-size:.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:.1rem .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:.8rem .4rem}.tx-addr .col-12,.tx-in .col-12,.tx-out .col-12{padding:.7rem 1.1rem}.amt-out{padding:.7rem 0 .7rem 1rem}.tx-io-row{flex-wrap:wrap}.tx-io-amount{flex-basis:100%;min-width:0}}@media (min-width:769px){body{font-size:.9rem}.btn{--bs-btn-font-size:0.9rem}}@media (min-width:1200px){.h1,h1{font-size:2.4rem}body{font-size:1rem}.btn{--bs-btn-font-size:1rem}} \ No newline at end of file diff --git a/static/internal_templates/block_internal_data_errors.html b/static/internal_templates/block_internal_data_errors.html index 2301f94362..9d548d5787 100644 --- a/static/internal_templates/block_internal_data_errors.html +++ b/static/internal_templates/block_internal_data_errors.html @@ -2,14 +2,12 @@

Blocks with errors from fetching internal data

Count: {{len .InternalDataErrors}}
-
- {{if .RefetchingInternalData}}Fetching...{{else}} -
- - - {{end}} -
- +
+
+
The queue is retried automatically about every hour. A block that keeps + failing is retried less often, doubling the delay with each failure up to about + 2.7 days, so the Retries column shows which blocks have backed off.
+

Blockbook

CoinFakecoin
Host
Version / Commit / Buildunknown / unknown / unknown
Synchronized
true
Last Block225494
Last Block Update`, + `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.
`, }, @@ -463,7 +463,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Block

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

Transactions

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

Block

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

Transactions

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

Block

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

Transactions

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

Block

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

Transactions

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

Transaction

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

Address

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz

0.00012345 FAKE

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

XPUB

upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q

1186.419755 FAKE

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

Transactions

`, + `Trezor Fake Coin Explorer

XPUB

upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q

1186.419755 FAKE

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

Transactions

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

XPUB

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

0 FAKE

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

XPUB

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

0 FAKE

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

Error

No matching records found for '1234'

`, + `Trezor Fake Coin Explorer

Error

No matching records found for '1234'

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

Send Raw Transaction

`, + `Trezor Fake Coin Explorer

Send Raw Transaction

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

Send Raw Transaction

Invalid data
`, + `Trezor Fake Coin Explorer

Send Raw Transaction

Invalid data
`, }, }, { @@ -911,6 +911,15 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { `{"error":"Missing xpub"}`, }, }, + { + name: "apiXpub v2 invalid xpub", + r: newGetRequest(ts.URL + "/api/v2/xpub/not-an-xpub"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Invalid xpub, bad extended key checksum"}`, + }, + }, { name: "apiUtxo v1", r: newGetRequest(ts.URL + "/api/v1/utxo/mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL"), @@ -938,6 +947,17 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { `[{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vout":0,"value":"118641975500","height":225494,"confirmations":1,"address":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3"}]`, }, }, + { + // change 1 is the only branch of the descriptor, so its position is 0 - the path + // must still name the configured change index + name: "apiUtxo v2 xpub change only descriptor", + r: newGetRequest(ts.URL + "/api/v2/utxo/" + url.QueryEscape("sh(wpkh([5c9e228d/49'/1'/33']"+dbtestdata.Xpub+"/1/*))")), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `[{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vout":0,"value":"118641975500","height":225494,"confirmations":1,"address":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3"}]`, + }, + }, { name: "apiUtxo v2 xpub", r: newGetRequest(ts.URL + "/api/v2/utxo/" + url.QueryEscape(dbtestdata.TaprootDescriptor)), @@ -1073,6 +1093,15 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { `{"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,"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,"addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true},{"value":"9876","n":2,"spent":true,"addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}]}`, }, }, + { + name: "apiGetBlockPageSize", + r: newGetRequest(ts.URL + "/api/v2/block/225493?page=2&pageSize=1"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"page":2,"totalPages":2,"itemsOnPage":1,"hash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","nextBlockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","height":225493,"confirmations":2,"size":1234567,"time":1521515026,"version":0,"merkleRoot":"","nonce":"","bits":"","difficulty":"","txCount":2,"txs":[{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true},{"value":"9876","n":2,"spent":true,"addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}]}`, + }, + }, { name: "apiGetRawBlock", r: newGetRequest(ts.URL + "/api/v2/rawblock/225493"), @@ -1194,7 +1223,7 @@ func Test_WebsocketRejectsOversizedMessage(t *testing.T) { t.Fatalf("unexpected websocket error after oversized message: %v", err) } -func Test_WebsocketClosesWhenPendingRequestLimitExceeded(t *testing.T) { +func testWebsocketPendingRequestLimit(t *testing.T, limit int) { parser, chain := setupChain(t) s, dbpath := setupPublicHTTPServer(parser, chain, t, false) @@ -1203,7 +1232,7 @@ func Test_WebsocketClosesWhenPendingRequestLimitExceeded(t *testing.T) { releaseRequests := make(chan struct{}) defer close(releaseRequests) - startedRequests := make(chan struct{}, maxWebsocketPendingRequests) + startedRequests := make(chan struct{}, limit) originalPingHandler := requestHandlers["ping"] requestHandlers["ping"] = func(s *WebsocketServer, c *websocketChannel, req *WsReq) (interface{}, error) { startedRequests <- struct{}{} @@ -1220,12 +1249,12 @@ func Test_WebsocketClosesWhenPendingRequestLimitExceeded(t *testing.T) { ws := connectWebsocket(t, ts) defer ws.Close() - for i := 0; i < maxWebsocketPendingRequests; i++ { + for i := 0; i < limit; i++ { if err := ws.WriteJSON(websocketReq{ID: strconv.Itoa(i), Method: "ping"}); err != nil { t.Fatal(err) } } - for i := 0; i < maxWebsocketPendingRequests; i++ { + for i := 0; i < limit; i++ { select { case <-startedRequests: case <-time.After(2 * time.Second): @@ -1251,6 +1280,17 @@ func Test_WebsocketClosesWhenPendingRequestLimitExceeded(t *testing.T) { } } +func Test_WebsocketClosesWhenPendingRequestLimitExceeded(t *testing.T) { + testWebsocketPendingRequestLimit(t, defaultWsPendingRequestsLimit) +} + +func Test_WebsocketPendingRequestLimitEnvOverride(t *testing.T) { + // the test coin sets no Network, so GetNetwork() falls back to the + // CoinShortcut FAKE + t.Setenv("FAKE_WS_PENDING_REQUESTS_LIMIT", "3") + testWebsocketPendingRequestLimit(t, 3) +} + var websocketTestsBitcoinType = []websocketTest{ { name: "websocket getInfo", @@ -1805,6 +1845,33 @@ func setupChain(t *testing.T) (bchain.BlockChainParser, bchain.BlockChain) { return parser, chain } +func TestHTTPServerTimeoutConfiguration(t *testing.T) { + parser, chain := setupChain(t) + public, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, public, dbpath) + + if got := public.https.WriteTimeout; got != 0 { + t.Fatalf("public WriteTimeout = %v, want disabled", got) + } + if got := public.https.ReadHeaderTimeout; got != httpReadHeaderTimeout { + t.Errorf("public ReadHeaderTimeout = %v, want %v", got, httpReadHeaderTimeout) + } + if got := public.https.ReadTimeout; got != httpReadTimeout { + t.Errorf("public ReadTimeout = %v, want %v", got, httpReadTimeout) + } + if got := public.https.IdleTimeout; got != httpIdleTimeout { + t.Errorf("public IdleTimeout = %v, want %v", got, httpIdleTimeout) + } + + internal, err := NewInternalServer("localhost:12346", "", public.db, public.chain, public.mempool, public.txCache, metrics, public.is, public.fiatRates) + if err != nil { + t.Fatal(err) + } + if got := internal.https.WriteTimeout; got != httpInternalWriteTimeout { + t.Errorf("internal WriteTimeout = %v, want %v", got, httpInternalWriteTimeout) + } +} + func Test_PublicServer_OpenAPIDocs(t *testing.T) { parser, chain := setupChain(t) @@ -2207,6 +2274,80 @@ func Test_WebsocketFiatRates_SubscribeBroadcastAndUnsubscribe(t *testing.T) { assertNoWebsocketMessage(t, ws, 300*time.Millisecond) } +func Test_WebsocketFiatRates_SubscribeTokensCap(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() + + makeTokens := func(n int) []string { + tokens := make([]string, n) + for i := range tokens { + tokens[i] = "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3" + } + return tokens + } + + // Over the cap: the subscription is rejected with a public error and no + // token slice is retained for the connection. + if err := ws.WriteJSON(websocketReq{ + ID: "sub-over", + Method: "subscribeFiatRates", + Params: map[string]interface{}{ + "currency": "USD", + "tokens": makeTokens(maxWebsocketSubscribeFiatRatesTokens + 1), + }, + }); err != nil { + t.Fatal(err) + } + over := readWebsocketResponse(t, ws, time.Second) + if over.ID != "sub-over" { + t.Fatalf("unexpected response id: got %q, want %q", over.ID, "sub-over") + } + var overData struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(over.Data, &overData); err != nil { + t.Fatal(err) + } + wantMsg := "tokens max " + strconv.Itoa(maxWebsocketSubscribeFiatRatesTokens) + if overData.Error.Message != wantMsg { + t.Fatalf("unexpected error message: got %q, want %q", overData.Error.Message, wantMsg) + } + + // Exactly at the cap: the subscription succeeds. + if err := ws.WriteJSON(websocketReq{ + ID: "sub-at", + Method: "subscribeFiatRates", + Params: map[string]interface{}{ + "currency": "USD", + "tokens": makeTokens(maxWebsocketSubscribeFiatRatesTokens), + }, + }); err != nil { + t.Fatal(err) + } + at := readWebsocketResponse(t, ws, time.Second) + if at.ID != "sub-at" { + t.Fatalf("unexpected response id: got %q, want %q", at.ID, "sub-at") + } + var atData struct { + Subscribed bool `json:"subscribed"` + } + if err := json.Unmarshal(at.Data, &atData); err != nil { + t.Fatal(err) + } + if !atData.Subscribed { + t.Fatalf("expected subscribed=true at the token cap, got false (data %s)", at.Data) + } +} + func Test_WebsocketFiatRates_GetCurrentFiatRates_TokenContractsCaseHandling_BitcoinType(t *testing.T) { parser, chain := setupChain(t) @@ -2617,7 +2758,7 @@ var websocketTestsBitcoinTypeExtendedIndex = []websocketTest{ "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"}]}}`, + want: `{"id":"3","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 getBlock caps pageSize", diff --git a/server/public_tron_test.go b/server/public_tron_test.go index caae9b6709..1018350bf1 100644 --- a/server/public_tron_test.go +++ b/server/public_tron_test.go @@ -30,6 +30,7 @@ func httpTestsTron(t *testing.T, ts *httptest.Server) { `Voting Power
7 / 10
diff --git a/static/internal_templates/contract_info.html b/static/internal_templates/contract_info.html index 57cbfece24..f98813d932 100644 --- a/static/internal_templates/contract_info.html +++ b/static/internal_templates/contract_info.html @@ -26,13 +26,29 @@
- To update contract, use POST request to /admin/contract-info/ endpoint. Example: + Use the /admin/contract-info/ endpoint to manage cached contract metadata. Examples:
-            curl -k -v  \
-            'https://<internaladdress>/admin/contract-info/' \
+            # read the cached metadata of one contract
+            curl -k -u user:password 'https://<internaladdress>/admin/contract-info/<address>'
+
+            # list the stored records page by page; the response
+            # {"contracts":[...],"next":"<address>"} carries the from cursor of the
+            # next page (the collection can hold millions of sync-stored records)
+            curl -k -u user:password 'https://<internaladdress>/admin/contract-info/?limit=1000&from=<address>'
+
+            # update contracts; POST targets the collection path with a JSON array body,
+            # the response is {"updated":N}
+            curl -k -u user:password -X POST 'https://<internaladdress>/admin/contract-info/' \
             -H 'Content-Type: application/json' \
-            --data '[{ContractInfo},{ContractInfo},...]'        
+            --data '[{ContractInfo},{ContractInfo},...]'
+
+            # purge the stored metadata of one contract so it is re-fetched from the
+            # backend node on the next read; the response
+            # {"contract":"...","deleted":true|false,"purged":{ContractInfo}}
+            # returns the removed record — POST it back to restore the sync-owned
+            # createdInBlock/destructedInBlock fields the re-fetch cannot recover
+            curl -k -u user:password -X DELETE 'https://<internaladdress>/admin/contract-info/<address>'
         
diff --git a/static/internal_templates/index.html b/static/internal_templates/index.html index 7a94bce8f0..166a68278d 100644 --- a/static/internal_templates/index.html +++ b/static/internal_templates/index.html @@ -4,6 +4,9 @@ IP addresses that exceeded websocket usage limit + {{if eq .ChainType 1}}
diff --git a/static/internal_templates/runtime_settings.html b/static/internal_templates/runtime_settings.html new file mode 100644 index 0000000000..43d1e21d97 --- /dev/null +++ b/static/internal_templates/runtime_settings.html @@ -0,0 +1,139 @@ +{{define "specific"}} + +
+
+ + + + + + + + + + {{range .RuntimeSettings}} + + + + + + + {{end}} + +
SettingValueSourceActions
{{.Key}}{{.Value}}{{.Source}} + + +
+ +{{end}} diff --git a/static/js/main.js b/static/js/main.js index b1d31408f8..6381738c9b 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -215,4 +215,12 @@ window.addEventListener("DOMContentLoaded", () => { } }) ); + + // keep the transaction in view when its input/output list is collapsed, + // instead of leaving the viewport wherever the shrinking content left it + document.querySelectorAll(".tx-in.collapse, .tx-out.collapse").forEach((e) => + e.addEventListener("hidden.bs.collapse", () => + e.closest(".tx-detail")?.scrollIntoView({ block: "nearest" }) + ) + ); }); diff --git a/static/js/main.min.4.js b/static/js/main.min.4.js deleted file mode 100644 index 5e237185ab..0000000000 --- a/static/js/main.min.4.js +++ /dev/null @@ -1 +0,0 @@ -function syntaxHighlight(t){return(t=(t=JSON.stringify(t,void 0,2)).replace(/&/g,"&").replace(//g,">")).length>1e6?`${t}`:t.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,(t=>{let e="number";return/^"/.test(t)?e=/:$/.test(t)?"key":"string":/true|false/.test(t)?e="boolean":/null/.test(t)&&(e="null"),`${t}`}))}function getCoinCookie(){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/js/main.min.5.js b/static/js/main.min.5.js new file mode 100644 index 0000000000..1ab7c3745f --- /dev/null +++ b/static/js/main.min.5.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"),s+=`${t}${a.outerHTML}
`}if(o&&(s+=`now${o.outerHTML}
`),e){let t=e.getAttribute("tm");t||(t="now"),s+=`${t}${e.outerHTML}
`}return n&&(s+=`now${n.outerHTML}
`),`${s}`}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 s=!1;const l={};async function r(e){if(t.hex)return t.hex;if(l[e])return l[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 l[e]=n,n}function i(){s?(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")}i(),o.addEventListener("click",async()=>{if(!s){try{const t=await r(e);n.textContent=t}catch(t){console.error("Error fetching raw transaction hex:",t),n.textContent=`Error fetching raw transaction hex: ${t.message}`}s=!0,i()}}),a.addEventListener("click",()=>{s&&(n.innerHTML=syntaxHighlight(t),s=!1,i())})}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()}})),document.querySelectorAll(".tx-in.collapse, .tx-out.collapse").forEach(t=>t.addEventListener("hidden.bs.collapse",()=>t.closest(".tx-detail")?.scrollIntoView({block:"nearest"})))}); \ No newline at end of file diff --git a/static/templates/address_chainextra_tron.html b/static/templates/address_chainextra_tron.html index dc081d7d1b..64c9727df6 100644 --- a/static/templates/address_chainextra_tron.html +++ b/static/templates/address_chainextra_tron.html @@ -95,6 +95,12 @@ {{$staking.UnclaimedReward}} sun {{end}} +{{if $staking.LatestWithdrawTime}} + + Last Reward Claim + {{unixTimeSpan $staking.LatestWithdrawTime}} + +{{end}} {{if $staking.DelegatedBalanceEnergyValue}} Delegated for Energy diff --git a/static/templates/tx_ethereumtype.html b/static/templates/tx_ethereumtype.html index 1c3da7937b..120ed3c2e1 100644 --- a/static/templates/tx_ethereumtype.html +++ b/static/templates/tx_ethereumtype.html @@ -52,6 +52,12 @@
{{$tx.Txid}}Gas Price {{amountSpan $eth.GasPrice $data "copyable"}} ({{amountSatsSpan $eth.GasPrice $data "copyable"}} Gwei) + {{if $eth.EffectiveGasPrice}} + + Effective Gas Price + {{amountSpan $eth.EffectiveGasPrice $data "copyable"}} ({{amountSatsSpan $eth.EffectiveGasPrice $data "copyable"}} Gwei) + + {{end}} {{if $eth.MaxPriorityFeePerGas}} Max Priority Fee Per Gas diff --git a/static/templates/tx_tron.html b/static/templates/tx_tron.html index d79392224d..0d4ece87c6 100644 --- a/static/templates/tx_tron.html +++ b/static/templates/tx_tron.html @@ -107,6 +107,17 @@
{{$tx.Txid}}{{$chainExtra.UnstakeAmount}} sun {{end}} + {{if $chainExtra.WithdrawnUnfreezeValue}} + + Withdrawn Unfreeze + {{formattedAmountSpan $chainExtra.WithdrawnUnfreezeValue 6 "TRX" $data "copyable"}} + + {{else if $chainExtra.WithdrawnUnfreeze}} + + Withdrawn Unfreeze + {{$chainExtra.WithdrawnUnfreeze}} sun + + {{end}} {{if $chainExtra.DelegateAmountValue}} Delegate Amount diff --git a/static/templates/txdetail.html b/static/templates/txdetail.html index b1bd8b2e50..6b52bf6c5a 100644 --- a/static/templates/txdetail.html +++ b/static/templates/txdetail.html @@ -1,5 +1,5 @@ -{{define "txdetail"}}{{$addr := .AddrStr}}{{$tx := .Tx}}{{$data := .}} -
+{{define "txdetail"}}{{$addr := .AddrStr}}{{$tx := .Tx}}{{$data := .}}{{$collapseThreshold := .TxIOCollapseThreshold}} +
{{$tx.Txid}} @@ -21,7 +21,11 @@
-
+ {{$vinCount := len $tx.Vin}} + {{if gt $vinCount $collapseThreshold}} + + {{end}} + {{range $vin := $tx.Vin}}
{{if isSyscoinShortcut $data.CoinShortcut}} @@ -73,11 +77,18 @@ {{else}}
No Inputs
{{end}} + {{if gt $vinCount $collapseThreshold}} + + {{end}}
 
-
+ {{$voutCount := len $tx.Vout}} + {{if gt $voutCount $collapseThreshold}} + + {{end}} + {{range $vout := $tx.Vout}}
{{if isSyscoinShortcut $data.CoinShortcut}} @@ -123,6 +134,9 @@ {{else}}
No Outputs
{{end}} + {{if gt $voutCount $collapseThreshold}} + + {{end}}
diff --git a/static/templates/txdetail_ethereumtype.html b/static/templates/txdetail_ethereumtype.html index e95f5f226a..df52f760ae 100644 --- a/static/templates/txdetail_ethereumtype.html +++ b/static/templates/txdetail_ethereumtype.html @@ -187,7 +187,7 @@
{{if $tx.FeesSat}} Fee {{amountSpan $tx.FeesSat $data "txvalue copyable ms-3"}} - {{" "}}({{amountSatsSpan $tx.EthereumSpecific.GasPrice $data ""}} Gwei/gas) + {{" "}}({{if $tx.EthereumSpecific.EffectiveGasPrice}}{{amountSatsSpan $tx.EthereumSpecific.EffectiveGasPrice $data ""}}{{else}}{{amountSatsSpan $tx.EthereumSpecific.GasPrice $data ""}}{{end}} Gwei/gas) {{end}}
diff --git a/static/templates/txdetail_tron.html b/static/templates/txdetail_tron.html index c37817e694..27a4a31c54 100644 --- a/static/templates/txdetail_tron.html +++ b/static/templates/txdetail_tron.html @@ -18,6 +18,7 @@ {{if $chainExtra.DelegateAmountValue}}delegate {{formattedAmountSpan $chainExtra.DelegateAmountValue 6 "TRX" $data ""}}{{else if $chainExtra.DelegateAmount}}delegate {{$chainExtra.DelegateAmount}} sun{{end}} {{if $chainExtra.StakeAmountValue}}stake {{formattedAmountSpan $chainExtra.StakeAmountValue 6 "TRX" $data ""}}{{else if $chainExtra.StakeAmount}}stake {{$chainExtra.StakeAmount}} sun{{end}} {{if $chainExtra.UnstakeAmountValue}}unstake {{formattedAmountSpan $chainExtra.UnstakeAmountValue 6 "TRX" $data ""}}{{else if $chainExtra.UnstakeAmount}}unstake {{$chainExtra.UnstakeAmount}} sun{{end}} + {{if $chainExtra.WithdrawnUnfreezeValue}}withdrawn unfreeze {{formattedAmountSpan $chainExtra.WithdrawnUnfreezeValue 6 "TRX" $data ""}}{{else if $chainExtra.WithdrawnUnfreeze}}withdrawn unfreeze {{$chainExtra.WithdrawnUnfreeze}} sun{{end}} {{if $chainExtra.ClaimedVoteRewardValue}}withdraw reward {{formattedAmountSpan $chainExtra.ClaimedVoteRewardValue 6 "TRX" $data ""}}{{else if $chainExtra.ClaimedVoteReward}}withdraw reward {{$chainExtra.ClaimedVoteReward}} sun{{end}}
{{end}} diff --git a/tests/dbtestdata/fakechain_ethereumtype.go b/tests/dbtestdata/fakechain_ethereumtype.go index bffa322bfb..7ac251fb3f 100644 --- a/tests/dbtestdata/fakechain_ethereumtype.go +++ b/tests/dbtestdata/fakechain_ethereumtype.go @@ -115,10 +115,25 @@ func (c *fakeBlockChainEthereumType) EthereumTypeGetBalance(addrDesc bchain.Addr return big.NewInt(123450000 + int64(addrDesc[0])), nil } -func (c *fakeBlockChainEthereumType) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { +func (c *fakeBlockChainEthereumType) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool, privatePendingNonces ...uint64) (uint64, uint64, bool, error) { // pending and confirmed are equal in the fake; production fetches them from // distinct block tags ("pending" vs "latest"), and only fetches confirmed when requested. - return uint64(addrDesc[0]), uint64(addrDesc[0]), withConfirmed, nil + pending := uint64(addrDesc[0]) + // Walk the pending nonce across the declared slots the way production does (see + // raiseToPendingFloor), so a server-level test can observe through the response that a request's + // privatePending declaration actually crossed the AddressFilter -> worker seam; the parameter is + // an optional variadic, so dropping it anywhere on that path compiles and nothing else notices. + declared := make(map[uint64]struct{}, len(privatePendingNonces)) + for _, n := range privatePendingNonces { + declared[n] = struct{}{} + } + for { + if _, occupied := declared[pending]; !occupied { + break + } + pending++ + } + return pending, uint64(addrDesc[0]), withConfirmed, nil } func (c *fakeBlockChainEthereumType) GetContractInfo(contractDesc bchain.AddressDescriptor) (*bchain.ContractInfo, error) { diff --git a/tests/dbtestdata/fakechain_tron.go b/tests/dbtestdata/fakechain_tron.go index 2b3a7ba705..5e7c6aa585 100644 --- a/tests/dbtestdata/fakechain_tron.go +++ b/tests/dbtestdata/fakechain_tron.go @@ -188,6 +188,7 @@ func (c *fakeBlockChainTronType) GetAddressChainExtraData(addrDesc bchain.Addres UnclaimedReward: "42767", DelegatedBalanceEnergy: "3210000", DelegatedBalanceBandwidth: "654000", + LatestWithdrawTime: 1777018452, } payload, err := json.Marshal(extra) diff --git a/tests/openapi/fixtures/bcash.json b/tests/openapi/fixtures/bcash.json new file mode 100644 index 0000000000..960de34912 --- /dev/null +++ b/tests/openapi/fixtures/bcash.json @@ -0,0 +1,10 @@ +{ + "xpubFixtures": [ + { + "name": "test-seed-bip44", + "descriptor": "xpub6DFYZ2FZwJHL4WULnRKTyMAaE9sM5Vi3QoWW9kYWGzR4HxDJ42Gbbdj7bpBAtATpaNeSVqSD3gdFFmZZYK9BVo96rhxPY7SWZWsfmdHpZ7e", + "minTxs": 9, + "minUsedAddresses": 6 + } + ] +} diff --git a/tests/openapi/fixtures/bitcoin.json b/tests/openapi/fixtures/bitcoin.json new file mode 100644 index 0000000000..a0e2bf0cd2 --- /dev/null +++ b/tests/openapi/fixtures/bitcoin.json @@ -0,0 +1,22 @@ +{ + "xpubFixtures": [ + { + "name": "test-seed-bip49", + "descriptor": "ypub6XKbB5DSkq8Royg8isNtGktj6bmEfGJXDs83Ad5CZ5tpDV8QofwSWQFTWP2Pv24vNdrPhquehL7vRMvSTj2GpKv6UaTQCBKZALm6RJAmxG6", + "minTxs": 42, + "minUsedAddresses": 21 + }, + { + "name": "test-seed-bip84", + "descriptor": "zpub6rszzdAK6RuafeRwyN8z1cgWcXCuKbLmjjfnrW4fWKtcoXQ8787214pNJjnBG5UATyghuNzjn6Lfp5k5xymrLFJnCy46bMYJPyZsbpFGagT", + "minTxs": 47, + "minUsedAddresses": 17 + }, + { + "name": "test-seed-bip86-descriptor", + "descriptor": "tr(xpub6Bw885JisRbcKmowfBvMmCxaFHodKn1VpmRmctmJJoM8D4DzyP4qJv8ZdD9V9r3SSGjmK2KJEDnvLH6f1Q4HrobEvnCeKydNvf1eir3RHZk)", + "minTxs": 4, + "minUsedAddresses": 1 + } + ] +} diff --git a/tests/openapi/fixtures/bitcoin_testnet.json b/tests/openapi/fixtures/bitcoin_testnet.json new file mode 100644 index 0000000000..38f7799a26 --- /dev/null +++ b/tests/openapi/fixtures/bitcoin_testnet.json @@ -0,0 +1,16 @@ +{ + "xpubFixtures": [ + { + "name": "test-seed-bip44", + "descriptor": "tpubDDKn3FtHc74CaRrRbi1WFdJNaaenZkDWqq9NsEhcafnDZ4VuKeuLG2aKHm5SuwuLgAhRkkfHqcCxpnVNSrs5kJYZXwa6Ud431VnevzzzK3U", + "minTxs": 178, + "minUsedAddresses": 56 + }, + { + "name": "test-seed-bip86-descriptor", + "descriptor": "tr(tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN)", + "minTxs": 67, + "minUsedAddresses": 35 + } + ] +} diff --git a/tests/openapi/fixtures/dogecoin.json b/tests/openapi/fixtures/dogecoin.json new file mode 100644 index 0000000000..1b2f7c71b0 --- /dev/null +++ b/tests/openapi/fixtures/dogecoin.json @@ -0,0 +1,10 @@ +{ + "xpubFixtures": [ + { + "name": "test-seed-bip44", + "descriptor": "dgub8sbe5Mi8LA4dXB9zPfLZW8armvaRkaqT37y6xfT1QA12ErATDf8iGEG8y7kamAnjLuEk9AMpTMMp6EK9Vjp2HHx91xdDEmWYpmD49fpoUYF", + "minTxs": 23, + "minUsedAddresses": 7 + } + ] +} diff --git a/tests/openapi/fixtures/litecoin.json b/tests/openapi/fixtures/litecoin.json new file mode 100644 index 0000000000..52f1e08890 --- /dev/null +++ b/tests/openapi/fixtures/litecoin.json @@ -0,0 +1,22 @@ +{ + "xpubFixtures": [ + { + "name": "test-seed-bip44", + "descriptor": "Ltub2Y8PyEMWQVgiX4L4gVzU8PakBTQ2WBxFdS6tJARQeasUUfXmBut2jGShnQyD3jgyBf7mmvs5jPNgmgXad5J6M8a8FiZK78dbT21fYtTAC9a", + "minTxs": 13, + "minUsedAddresses": 7 + }, + { + "name": "test-seed-bip49", + "descriptor": "Mtub2syZtptY6mWCxNgC59ji2ELzxifydR33xbfT5w1AcQKfEd1BrsnzraY9asxRJcQbUYuFPHGfa8opGaXQQiGEPXKDcDEjUPFXTzS1gQMYtnX", + "minTxs": 22, + "minUsedAddresses": 14 + }, + { + "name": "test-seed-bip84", + "descriptor": "zpub6qhzAgn63nQAJZiY829GUUb1eXFDqpLbeShTdAbTEKyZa8n2VbkLwbRhq9xqPyj5Yet5TBdbCZDgiw53NQ2qwib4mygybFq66i7ZcXUcwgU", + "minTxs": 4, + "minUsedAddresses": 1 + } + ] +} diff --git a/tests/openapi/fixtures/zcash.json b/tests/openapi/fixtures/zcash.json new file mode 100644 index 0000000000..71e1d26e66 --- /dev/null +++ b/tests/openapi/fixtures/zcash.json @@ -0,0 +1,10 @@ +{ + "xpubFixtures": [ + { + "name": "test-seed-bip44", + "descriptor": "xpub6CQdEahwhKRSn9BFc7oWpzNoeqG2ygv3xdofyk7He93NMjvDpGvcQ2o4dZfBNXpqzKydaHp5rhXRT3zYhRYJAErXxarH37f9hgRZ6UPiqfg", + "minTxs": 9, + "minUsedAddresses": 6 + } + ] +} diff --git a/tests/openapi/package-lock.json b/tests/openapi/package-lock.json index 0a4c1610e5..a12e02ab27 100644 --- a/tests/openapi/package-lock.json +++ b/tests/openapi/package-lock.json @@ -6,16 +6,16 @@ "": { "name": "blockbook-openapi-tests", "devDependencies": { - "@redocly/cli": "2.11.1", + "@redocly/cli": "2.51.2", "@types/ws": "8.18.1", "ajv": "8.17.1", "ajv-formats": "3.0.1", "https-proxy-agent": "7.0.6", "openapi-typescript": "7.10.1", - "tsx": "4.21.0", + "tsx": "4.22.4", "typescript": "5.9.3", - "undici": "6.25.0", - "ws": "8.18.3", + "undici": "6.28.0", + "ws": "8.21.0", "yaml": "2.6.1" } }, @@ -44,37 +44,10 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", - "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -89,9 +62,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -106,9 +79,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -123,9 +96,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -140,9 +113,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -157,9 +130,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -174,9 +147,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -191,9 +164,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -208,9 +181,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -225,9 +198,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -242,9 +215,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -259,9 +232,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -276,9 +249,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -293,9 +266,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -310,9 +283,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -327,9 +300,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -344,9 +317,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -361,9 +334,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -378,9 +351,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -395,9 +368,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -412,9 +385,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -429,9 +402,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -446,9 +419,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -463,9 +436,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -480,9 +453,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -497,9 +470,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -513,3072 +486,460 @@ "node": ">=18" } }, - "node_modules/@exodus/schemasafe": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", - "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "node_modules/@redocly/cli": { + "version": "2.51.2", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.51.2.tgz", + "integrity": "sha512-pviW1gfsjCAuIVutmQcihlhlgoivfNzisBIR61EwSSREHGZ08Sbtjp/h/HPDkVavWwdzR/gs2wgHrdXLd6qU1A==", "dev": true, - "license": "MIT" + "license": "MIT", + "bin": { + "openapi": "bin/cli.js", + "redocly": "bin/cli.js" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } }, - "node_modules/@faker-js/faker": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", - "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.0.0", - "npm": ">=6.0.0" + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@humanwhocodes/momoa": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-2.0.4.tgz", - "integrity": "sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.10.0" + "license": "MIT", + "dependencies": { + "@types/node": "*" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 14" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://paulmillr.com/funding/" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true } - ], - "license": "MIT" - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.202.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.202.0.tgz", - "integrity": "sha512-fTBjMqKCfotFWfLzaKyhjLvyEyq5vDKTTFfBmx21btv3gvy8Lq6N5Dh2OzqeuN4DjtpSvNT1uNVfg08eD2Rfxw==", + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, + "license": "MIT", "engines": { - "node": ">=8.0.0" + "node": ">=6" } }, - "node_modules/@opentelemetry/context-async-hooks": { + "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.1.tgz", - "integrity": "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } + "license": "Python-2.0" }, - "node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } + "license": "MIT" }, - "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.202.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.202.0.tgz", - "integrity": "sha512-/hKE8DaFCJuaQqE1IxpgkcjOolUIwgi3TgHElPVKGdGRBSmJMTmN/cr6vWa55pCJIXPyhKvcMrbrya7DZ3VmzA==", + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/otlp-exporter-base": "0.202.0", - "@opentelemetry/otlp-transformer": "0.202.0", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-trace-base": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } + "license": "MIT" }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.202.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.202.0.tgz", - "integrity": "sha512-nMEOzel+pUFYuBJg2znGmHJWbmvMbdX5/RhoKNKowguMbURhz0fwik5tUKplLcUtl8wKPL1y9zPnPxeBn65N0Q==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/otlp-transformer": "0.202.0" + "ms": "^2.1.3" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">=6.0" }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.202.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.202.0.tgz", - "integrity": "sha512-5XO77QFzs9WkexvJQL9ksxL8oVFb/dfi9NWQSq7Sv0Efr9x3N+nb1iklP1TeVgxqJ7m1xWiC/Uv3wupiQGevMw==", + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.202.0", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-logs": "0.202.0", - "@opentelemetry/sdk-metrics": "2.0.1", - "@opentelemetry/sdk-trace-base": "2.0.1", - "protobufjs": "^7.3.0" + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">=18" }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } + "license": "MIT" }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.202.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.202.0.tgz", - "integrity": "sha512-pv8QiQLQzk4X909YKm0lnW4hpuQg4zHwJ4XBd5bZiXcd9urvrJNoNVKnxGHPiDVX/GiLFvr5DMYsDBQbZCypRQ==", + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.202.0", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", - "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", - "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">= 14" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.0.1.tgz", - "integrity": "sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA==", + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.0.1", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/sdk-trace-base": "2.0.1" - }, + "license": "MIT", "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">=18" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", - "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==", + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=0.10.0" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "MIT" }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, - "license": "BSD-3-Clause" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@redocly/ajv": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.3.tgz", - "integrity": "sha512-l42u0of3hY98sN2A+M4qTX1O/KrpgGH32Hu9kP2GtHyD5Dfqq86PKFLe5dwaD8DEnNmlOlll2BAmeEtf0DaySg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@redocly/cli": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.11.1.tgz", - "integrity": "sha512-doNs+sdrFzzXmyf1yIeJbPh8OChacHWkvTE9N0QbuCmnYQ4k0v1IMP20qsitkwR+fK8O1hXSnFnSTVvIunMVVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@opentelemetry/exporter-trace-otlp-http": "0.202.0", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-trace-node": "2.0.1", - "@opentelemetry/semantic-conventions": "1.34.0", - "@redocly/openapi-core": "2.11.1", - "@redocly/respect-core": "2.11.1", - "abort-controller": "^3.0.0", - "chokidar": "^3.5.1", - "colorette": "^1.2.0", - "cookie": "^0.7.2", - "dotenv": "16.4.7", - "form-data": "^4.0.4", - "glob": "^11.0.1", - "handlebars": "^4.7.6", - "https-proxy-agent": "^7.0.5", - "mobx": "^6.0.4", - "pluralize": "^8.0.0", - "react": "^17.0.0 || ^18.2.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.2.0 || ^19.0.0", - "redoc": "2.5.1", - "semver": "^7.5.2", - "set-cookie-parser": "^2.3.5", - "simple-websocket": "^9.0.0", - "styled-components": "^6.0.7", - "undici": "^6.21.3", - "yargs": "17.0.1" - }, - "bin": { - "openapi": "bin/cli.js", - "redocly": "bin/cli.js" - }, - "engines": { - "node": ">=22.12.0 || >=20.19.0 <21.0.0", - "npm": ">=10" - } - }, - "node_modules/@redocly/config": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.38.0.tgz", - "integrity": "sha512-kSgMG3rRzgXIP/6gWMRuWbu9/ms0Cyuphcx19dPR9qlgc1tt9IKYPsFQ+KhJuEtqd3bcY/+Uflysf33dQkZWVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "2.7.2" - } - }, - "node_modules/@redocly/openapi-core": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-2.11.1.tgz", - "integrity": "sha512-FVCDnZxaoUJwLQxfW4inCojxUO56J3ntu7dDAE2qyWd6tJBK45CnXMQQUxpqeRTeXROr3jYQoApAw+GCEnyBeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/ajv": "^8.11.4", - "@redocly/config": "^0.38.0", - "ajv-formats": "^2.1.1", - "colorette": "^1.2.0", - "js-levenshtein": "^1.1.6", - "js-yaml": "^4.1.0", - "picomatch": "^4.0.3", - "pluralize": "^8.0.0", - "yaml-ast-parser": "0.0.43" - }, - "engines": { - "node": ">=22.12.0 || >=20.19.0 <21.0.0", - "npm": ">=10" - } - }, - "node_modules/@redocly/openapi-core/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@redocly/respect-core": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@redocly/respect-core/-/respect-core-2.11.1.tgz", - "integrity": "sha512-jSMJvCJeo5gmhQfg82AhuwCG0h8gbW5vqHyRITBu8KHVsBiQTgvfhXepu8SKHeJu0OexYtEc0nUnGLJlefevYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@faker-js/faker": "^7.6.0", - "@noble/hashes": "^1.8.0", - "@redocly/ajv": "8.11.4", - "@redocly/openapi-core": "2.11.1", - "better-ajv-errors": "^1.2.0", - "colorette": "^2.0.20", - "json-pointer": "^0.6.2", - "jsonpath-rfc9535": "1.3.0", - "openapi-sampler": "^1.6.1", - "outdent": "^0.8.0" - }, - "engines": { - "node": ">=22.12.0 || >=20.19.0 <21.0.0", - "npm": ">=10" - } - }, - "node_modules/@redocly/respect-core/node_modules/@redocly/ajv": { - "version": "8.11.4", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.4.tgz", - "integrity": "sha512-77MhyFgZ1zGMwtCpqsk532SJEc3IJmSOXKTCeWoMTAvPnQOkuOgxEip1n5pG5YX1IzCTJ4kCvPKr8xYyzWFdhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js-replace": "^1.0.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@redocly/respect-core/node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/better-ajv-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/better-ajv-errors/-/better-ajv-errors-1.2.0.tgz", - "integrity": "sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@babel/code-frame": "^7.16.0", - "@humanwhocodes/momoa": "^2.0.2", - "chalk": "^4.1.2", - "jsonpointer": "^5.0.0", - "leven": "^3.1.0 < 4" - }, - "engines": { - "node": ">= 12.13.0" - }, - "peerDependencies": { - "ajv": "4.11.8 - 8" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/change-case": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=4" - } - }, - "node_modules/css-to-react-native": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", - "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decko": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decko/-/decko-1.2.0.tgz", - "integrity": "sha512-m8FnyHXV1QX+S1cl+KPFDIl6NMkxtKsy6+U/aYyjrOqWMuwAwYWu7ePqrsUHtDR5Y8Yk2pi/KIDSgF+vT4cPOQ==", - "dev": true - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dompurify": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", - "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", - "dev": true, - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", - "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.2.0", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.3.0", - "xml-naming": "^0.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/foreach": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", - "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==", - "dev": true, - "license": "MIT" - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http2-client": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", - "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==", - "dev": true, - "license": "MIT" - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/js-levenshtein": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-pointer": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", - "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "foreach": "^2.0.4" - } - }, - "node_modules/json-schema-to-ts": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.7.2.tgz", - "integrity": "sha512-R1JfqKqbBR4qE8UyBR56Ms30LL62/nlhoz+1UkfI/VE7p54Awu919FZ6ZUPG8zIa3XB65usPJgr1ONVncUGSaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@types/json-schema": "^7.0.9", - "ts-algebra": "^1.2.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonpath-rfc9535": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/jsonpath-rfc9535/-/jsonpath-rfc9535-1.3.0.tgz", - "integrity": "sha512-3jFHya7oZ45aDxIIdx+/zQARahHXxFSMWBkcBUldfXpLS9VCXDJyTKt35kQfEXLqh0K3Ixw/9xFnvcDStaxh7Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/jsonpointer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", - "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/lunr": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", - "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/marked": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", - "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mobx": { - "version": "6.15.4", - "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.4.tgz", - "integrity": "sha512-do+2UsEKRVT70W/QqP2F2sju2x4p2xZo+5/azXqKjXgTk2jfmzsLjzwW0YI8CBEjy4ZUdU8EunXocXXwJdCrtw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" - } - }, - "node_modules/mobx-react": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-9.2.0.tgz", - "integrity": "sha512-dkGWCx+S0/1mfiuFfHRH8D9cplmwhxOV5CkXMp38u6rQGG2Pv3FWYztS0M7ncR6TyPRQKaTG/pnitInoYE9Vrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mobx-react-lite": "^4.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" - }, - "peerDependencies": { - "mobx": "^6.9.0", - "react": "^16.8.0 || ^17 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/mobx-react-lite": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz", - "integrity": "sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.4.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" - }, - "peerDependencies": { - "mobx": "^6.9.0", - "react": "^16.8.0 || ^17 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch-h2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", - "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "http2-client": "^1.2.5" - }, - "engines": { - "node": "4.x || >=6.0.0" - } - }, - "node_modules/node-readfiles": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", - "integrity": "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es6-promise": "^3.2.1" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/oas-kit-common": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", - "integrity": "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "fast-safe-stringify": "^2.0.7" - } - }, - "node_modules/oas-linter": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz", - "integrity": "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@exodus/schemasafe": "^1.0.0-rc.2", - "should": "^13.2.1", - "yaml": "^1.10.0" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-linter/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/oas-resolver": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", - "integrity": "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "node-fetch-h2": "^2.3.0", - "oas-kit-common": "^1.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - }, - "bin": { - "resolve": "resolve.js" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-resolver/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/oas-schema-walker": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", - "integrity": "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==", - "dev": true, - "license": "BSD-3-Clause", - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-validator": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz", - "integrity": "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "call-me-maybe": "^1.0.1", - "oas-kit-common": "^1.0.8", - "oas-linter": "^3.2.2", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "reftools": "^1.1.9", - "should": "^13.2.1", - "yaml": "^1.10.0" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-validator/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/openapi-sampler": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.7.3.tgz", - "integrity": "sha512-Qgy2+Z7xR3l7kXurtzi1PCtzAINkFKhBADBe/8cidC2fQrLUQTudLiJjQDnqJXoisWAR6zaHhC0hP6Hn5vja+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.7", - "fast-xml-parser": "^5.5.1", - "json-pointer": "0.6.2" - } - }, - "node_modules/openapi-typescript": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.10.1.tgz", - "integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/openapi-core": "^1.34.5", - "ansi-colors": "^4.1.3", - "change-case": "^5.4.4", - "parse-json": "^8.3.0", - "supports-color": "^10.2.2", - "yargs-parser": "^21.1.1" - }, - "bin": { - "openapi-typescript": "bin/cli.js" - }, - "peerDependencies": { - "typescript": "^5.x" - } - }, - "node_modules/openapi-typescript/node_modules/@redocly/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js-replace": "^1.0.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/openapi-typescript/node_modules/@redocly/config": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", - "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/openapi-typescript/node_modules/@redocly/openapi-core": { - "version": "1.34.14", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.14.tgz", - "integrity": "sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/ajv": "8.11.2", - "@redocly/config": "0.22.0", - "colorette": "1.4.0", - "https-proxy-agent": "7.0.6", - "js-levenshtein": "1.1.6", - "js-yaml": "4.1.1", - "minimatch": "5.1.9", - "pluralize": "8.0.0", - "yaml-ast-parser": "0.0.43" - }, - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - } - }, - "node_modules/openapi-typescript/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/openapi-typescript/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/openapi-typescript/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/openapi-typescript/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/outdent": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", - "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==", - "dev": true, - "license": "MIT" - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/perfect-scrollbar": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.6.tgz", - "integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/polished": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", - "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", - "dev": true, - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.6" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-tabs": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-6.1.1.tgz", - "integrity": "sha512-CPiuKoMFf89B7QlbFfdBD9XmUWiE3qudQputMVZB8GQvPJZRX/gqjDaDWOPDwGinEfpJKEuBCkGt83Tt4efeyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "prop-types": "^15.5.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/redoc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/redoc/-/redoc-2.5.1.tgz", - "integrity": "sha512-LmqA+4A3CmhTllGG197F0arUpmChukAj9klfSdxNRemT9Hr07xXr7OGKu4PHzBs359sgrJ+4JwmOlM7nxLPGMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/openapi-core": "^1.4.0", - "classnames": "^2.3.2", - "decko": "^1.2.0", - "dompurify": "^3.2.4", - "eventemitter3": "^5.0.1", - "json-pointer": "^0.6.2", - "lunr": "^2.3.9", - "mark.js": "^8.11.1", - "marked": "^4.3.0", - "mobx-react": "9.2.0", - "openapi-sampler": "^1.5.0", - "path-browserify": "^1.0.1", - "perfect-scrollbar": "^1.5.5", - "polished": "^4.2.2", - "prismjs": "^1.29.0", - "prop-types": "^15.8.1", - "react-tabs": "^6.0.2", - "slugify": "~1.4.7", - "stickyfill": "^1.1.1", - "swagger2openapi": "^7.0.8", - "url-template": "^2.0.8" - }, - "engines": { - "node": ">=6.9", - "npm": ">=3.0.0" - }, - "peerDependencies": { - "core-js": "^3.1.4", - "mobx": "^6.0.4", - "react": "^16.8.4 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.4 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "styled-components": "^4.1.1 || ^5.1.1 || ^6.0.5" - } - }, - "node_modules/redoc/node_modules/@redocly/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js-replace": "^1.0.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/redoc/node_modules/@redocly/config": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", - "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/redoc/node_modules/@redocly/openapi-core": { - "version": "1.34.14", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.14.tgz", - "integrity": "sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/ajv": "8.11.2", - "@redocly/config": "0.22.0", - "colorette": "1.4.0", - "https-proxy-agent": "7.0.6", - "js-levenshtein": "1.1.6", - "js-yaml": "4.1.1", - "minimatch": "5.1.9", - "pluralize": "8.0.0", - "yaml-ast-parser": "0.0.43" - }, - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - } - }, - "node_modules/redoc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/redoc/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/redoc/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/reftools": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", - "integrity": "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==", - "dev": true, - "license": "BSD-3-Clause", - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-pkg-maps": { + "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/should": { - "version": "13.2.3", - "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", - "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "should-equal": "^2.0.0", - "should-format": "^3.0.3", - "should-type": "^1.4.0", - "should-type-adaptors": "^1.0.1", - "should-util": "^1.0.0" - } - }, - "node_modules/should-equal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", - "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "should-type": "^1.4.0" - } - }, - "node_modules/should-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", - "integrity": "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "should-type": "^1.3.0", - "should-type-adaptors": "^1.0.1" - } - }, - "node_modules/should-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", - "integrity": "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, - "node_modules/should-type-adaptors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", - "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "node_modules/openapi-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.10.1.tgz", + "integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==", "dev": true, "license": "MIT", "dependencies": { - "should-type": "^1.3.0", - "should-util": "^1.0.0" - } - }, - "node_modules/should-util": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", - "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", - "dev": true, - "license": "MIT" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" + "@redocly/openapi-core": "^1.34.5", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" } }, - "node_modules/simple-websocket": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/simple-websocket/-/simple-websocket-9.1.0.tgz", - "integrity": "sha512-8MJPnjRN6A8UCp1I+H/dSFyjwJhp6wta4hsVRhjf8w9qBHRzxYt14RaOcjvQnhD1N4yKOddEjflwMnQM4VtXjQ==", + "node_modules/openapi-typescript/node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", "dependencies": { - "debug": "^4.3.1", - "queue-microtask": "^1.2.2", - "randombytes": "^2.1.0", - "readable-stream": "^3.6.0", - "ws": "^7.4.2" + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/simple-websocket/node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "node_modules/openapi-typescript/node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } + "license": "MIT" }, - "node_modules/slugify": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.4.7.tgz", - "integrity": "sha512-tf+h5W1IrjNm/9rKKj0JU2MDMruiopx0jjVA5zCdBtcGjfp0+c5rHw/zADLC3IeKlGHtVbHtpfzvYA0OYT+HKg==", + "node_modules/openapi-typescript/node_modules/@redocly/openapi-core": { + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", "dev": true, "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, "engines": { - "node": ">=8.0.0" + "node": ">=18.17.0", + "npm": ">=9.5.0" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/openapi-typescript/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stickyfill": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stickyfill/-/stickyfill-1.1.1.tgz", - "integrity": "sha512-GCp7vHAfpao+Qh/3Flh9DXEJ/qSi0KJwJw6zYlZOtRYXWUIpMM6mC2rIep/dK8RQqwW0KxGJIllmjPIBOGN8AA==", - "dev": true + "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/openapi-typescript/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "~5.2.0" + "balanced-match": "^1.0.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/openapi-typescript/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/styled-components": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.2.tgz", - "integrity": "sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg==", + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, "license": "MIT", "dependencies": { - "@emotion/is-prop-valid": "1.4.0", - "css-to-react-native": "3.2.0", - "csstype": "3.2.3", - "stylis": "4.3.6" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { - "node": ">= 16" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/styled-components" - }, - "peerDependencies": { - "css-to-react-native": ">= 3.2.0", - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0", - "react-native": ">= 0.68.0" - }, - "peerDependenciesMeta": { - "css-to-react-native": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/swagger2openapi": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", - "integrity": "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "call-me-maybe": "^1.0.1", - "node-fetch": "^2.6.1", - "node-fetch-h2": "^2.3.0", - "node-readfiles": "^0.2.0", - "oas-kit-common": "^1.0.8", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "oas-validator": "^5.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - }, - "bin": { - "boast": "boast.js", - "oas-validate": "oas-validate.js", - "swagger2openapi": "swagger2openapi.js" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/swagger2openapi/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", "engines": { - "node": ">= 6" + "node": ">=4" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, "engines": { - "node": ">=8.0" + "node": ">=0.10.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-algebra": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz", - "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==", - "dev": true, - "license": "MIT" - }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -3617,24 +978,10 @@ "node": ">=14.17" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, "license": "MIT", "engines": { @@ -3655,93 +1002,10 @@ "dev": true, "license": "MIT" }, - "node_modules/url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", - "dev": true, - "license": "BSD" - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -3760,32 +1024,6 @@ } } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yaml": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", @@ -3806,25 +1044,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/yargs": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.0.1.tgz", - "integrity": "sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -3834,16 +1053,6 @@ "engines": { "node": ">=12" } - }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } } } } diff --git a/tests/openapi/package.json b/tests/openapi/package.json index cce6f3ffec..b115964c48 100644 --- a/tests/openapi/package.json +++ b/tests/openapi/package.json @@ -8,17 +8,23 @@ "typecheck": "tsc --noEmit", "e2e": "tsx src/e2e.ts" }, + "overrides": { + "fast-uri": "3.1.3", + "js-yaml": "4.3.0", + "brace-expansion@2": "2.1.2", + "brace-expansion@>=3": "5.0.7" + }, "devDependencies": { - "@redocly/cli": "2.11.1", + "@redocly/cli": "2.51.2", "@types/ws": "8.18.1", "ajv": "8.17.1", "ajv-formats": "3.0.1", "https-proxy-agent": "7.0.6", "openapi-typescript": "7.10.1", - "tsx": "4.21.0", + "tsx": "4.22.4", "typescript": "5.9.3", - "undici": "6.25.0", - "ws": "8.18.3", + "undici": "6.28.0", + "ws": "8.21.0", "yaml": "2.6.1" } } diff --git a/tests/openapi/src/client.ts b/tests/openapi/src/client.ts index 28a405bd97..873af379aa 100644 --- a/tests/openapi/src/client.ts +++ b/tests/openapi/src/client.ts @@ -1,3 +1,4 @@ +import { userAgentHeaders } from "./config.js"; import { OpenApiContract, preview } from "./openapi.js"; import type { paths } from "../.generated/blockbook.js"; @@ -57,15 +58,16 @@ export class OpenApiFetchClient { async getMaybe

(operationPath: P, actualPath: string): Promise>> { const url = this.resolveUrl(actualPath); let lastError: unknown; - for (let attempt = 1; attempt <= 2; attempt++) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const response = await fetch(url, { method: "GET", - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(25_000), + headers: userAgentHeaders(), }); const body = await response.text(); - if (attempt < 2 && isRetryableHTTPStatus(response.status)) { - await delay(attempt * 300); + if (attempt < maxAttempts && isRetryableHTTPStatus(response.status)) { + await delay(retryDelayMs(attempt)); continue; } const data = parseJSON(body); @@ -79,8 +81,8 @@ export class OpenApiFetchClient { }; } catch (error) { lastError = error; - if (attempt < 2 && isRetryableError(error)) { - await delay(attempt * 300); + if (attempt < maxAttempts && isRetryableError(error)) { + await delay(retryDelayMs(attempt)); continue; } throw error; @@ -101,6 +103,14 @@ export class OpenApiFetchClient { } } +// Edge/LB 5xx bursts in front of an instance last seconds, not one round-trip; back off across a +// few attempts so a single blip does not fail a whole coin run at the status preflight. +const maxAttempts = 4; + +function retryDelayMs(attempt: number) { + return 500 * 2 ** (attempt - 1); +} + function parseJSON(body: string): unknown { if (body.trim() === "") { return undefined; diff --git a/tests/openapi/src/config.ts b/tests/openapi/src/config.ts index 5a9c47ac68..c30700d161 100644 --- a/tests/openapi/src/config.ts +++ b/tests/openapi/src/config.ts @@ -28,6 +28,17 @@ export function fiatMaxAgeSeconds() { return DEFAULT_FIAT_MAX_AGE_SECONDS; } +// userAgent returns the User-Agent to send, or "" for the runtime default. Public instances sit +// behind an edge that blocks non-browser agents, so local runs against them set OPENAPI_USER_AGENT. +export function userAgent() { + return process.env.OPENAPI_USER_AGENT?.trim() ?? ""; +} + +export function userAgentHeaders(): Record | undefined { + const ua = userAgent(); + return ua ? { "User-Agent": ua } : undefined; +} + // allowOutOfSync lets dev runs proceed against a node still catching up to its backend. Off by // default so CI fails loudly on a stale tip (which makes every recent-block sample unreliable). // Set OPENAPI_ALLOW_OUT_OF_SYNC=1 to downgrade the inSync assertion in TestContext.getStatus. @@ -193,7 +204,7 @@ function normalizeWSURL(raw: string) { } async function fetchText(url: string, timeoutMs: number) { - const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: userAgentHeaders() }); return { status: response.status, body: await response.text(), diff --git a/tests/openapi/src/constants.ts b/tests/openapi/src/constants.ts index 0fc1fe54d7..d8a1350570 100644 --- a/tests/openapi/src/constants.ts +++ b/tests/openapi/src/constants.ts @@ -1,9 +1,17 @@ -export const wsDialTimeoutMs = 5_000; -export const wsMessageTimeoutMs = 15_000; -export const txSearchWindow = 12; +export const wsDialTimeoutMs = 3_000; +export const wsMessageTimeoutMs = 10_000; +export const txSearchWindow = 20; export const blockPageSize = 1; export const sampleBlockPageSize = 3; -export const sampleBlockProbeMax = 3; +// Low-activity chains (e.g. ethereum-classic, ~73% empty blocks) need a wide window to find a block +// that actually carries transactions; 3 blocks failed ~42% of the time. +export const sampleBlockProbeMax = txSearchWindow; +// Address sampling: the first sender of a random tx on a busy EVM chain can be a bot with 10^8 txs, +// which makes even a bounded balance history unservable. Prefer a participant below this tx count. +export const sampleAddressMaxTxs = 10_000; +// Probe budget: txs inspected and participants checked per tx (one details=basic request each). +export const sampleAddressProbeMax = 8; +export const sampleAddressCandidatesPerTx = 4; export const sciNotationWindow = 40; export const sciNotationTxLimit = 8; export const addressPage = 1; diff --git a/tests/openapi/src/context.ts b/tests/openapi/src/context.ts index 9165bc03f8..bee9744eb9 100644 --- a/tests/openapi/src/context.ts +++ b/tests/openapi/src/context.ts @@ -3,9 +3,9 @@ import { HttpsProxyAgent } from "https-proxy-agent"; import { OpenApiFetchClient } from "./client.js"; import { OpenApiContract, preview } from "./openapi.js"; -import { allowOutOfSync, resolveHTTPBase, resolveWSURL } from "./config.js"; +import { allowOutOfSync, resolveHTTPBase, resolveWSURL, userAgentHeaders } from "./config.js"; import { SkipTest } from "./errors.js"; -import { addressPage, addressPageSize, blockPageSize, sampleAddrTxProbeMax, sampleBlockPageSize, sampleBlockProbeMax, sciNotationTxLimit, sciNotationWindow, scientificNotationPattern, txSearchWindow, wsDialTimeoutMs, wsMessageTimeoutMs } from "./constants.js"; +import { addressPage, addressPageSize, blockPageSize, sampleAddressCandidatesPerTx, sampleAddressMaxTxs, sampleAddressProbeMax, sampleAddrTxProbeMax, sampleBlockPageSize, sampleBlockProbeMax, sciNotationTxLimit, sciNotationWindow, scientificNotationPattern, txSearchWindow, wsDialTimeoutMs, wsMessageTimeoutMs } from "./constants.js"; import { assertAddressMatches, buildAddressDetailsPath, @@ -15,6 +15,7 @@ import { extractTxIDs, firstAddressFromTx, firstAddressFromTxPreferVin, + sampleAddressCandidatesFromTx, isAddressCandidate, isEVMAddress as isEVMAddressValue, isFiatDataUnavailable, @@ -31,6 +32,135 @@ import { import type { Capability, AddressResponse, BlockHashResponse, BlockResponse, BlockSummary, FiatTickerResponse, StatusResponse, TxResponse, UtxoResponse, WsEnvelope, WsInfoResponse, WsMethod, WsResponse } from "./types.js"; +// --------------------------------------------------------------------------- +// WebSocket connection +// --------------------------------------------------------------------------- + +// wsConnection wraps a single persistent WebSocket and multiplexes requests +// over it by matching responses to the originating request ID. +class wsConnection { + private ws: WebSocket | null = null; + private pendings = new Map< + string, + { resolve: (value: unknown) => void; reject: (reason: unknown) => void; timer: NodeJS.Timeout } + >(); + private closed = false; + + constructor( + private readonly url: string, + private readonly contract: OpenApiContract, + ) {} + + get isClosed(): boolean { + return this.closed; + } + + connect(): Promise { + return new Promise((resolve, reject) => { + // Guard against hangs below the ws handshake layer (DNS / TCP). + let settled = false; + const guard = setTimeout(() => { + if (settled) { return; } + settled = true; + this.ws?.terminate(); + reject(new Error(`websocket connect timed out for ${this.url}`)); + }, wsDialTimeoutMs); + + this.ws = new WebSocket(this.url, { + handshakeTimeout: wsDialTimeoutMs, + rejectUnauthorized: process.env.OPENAPI_INSECURE_TLS === "0", + agent: wsProxyAgent(), + headers: userAgentHeaders(), + }); + this.ws.on("open", () => { + if (settled) { return; } + settled = true; + clearTimeout(guard); + resolve(); + }); + this.ws.on("error", (err) => { + if (settled) { return; } + settled = true; + clearTimeout(guard); + this.closed = true; + reject(err); + }); + this.ws.on("close", () => { + if (!settled) { + settled = true; + clearTimeout(guard); + reject(new Error("websocket closed before handshake completed")); + } + this.closed = true; + for (const [, p] of this.pendings) { + clearTimeout(p.timer); + p.reject(new Error("websocket connection closed")); + } + this.pendings.clear(); + }); + this.ws.on("message", (data) => { + let response: WsResponse; + try { + response = JSON.parse(data.toString()) as WsResponse; + } catch { + return; + } + const pending = this.pendings.get(response.id); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendings.delete(response.id); + if (isWsError(response.data)) { + pending.reject( + new Error(`websocket ${response.id} returned error: ${response.data.error.message}`), + ); + return; + } + pending.resolve(response.data); + }); + }); + } + + async request(request: WsEnvelope, dataSchemaRef?: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendings.delete(request.id); + reject(new Error(`websocket ${request.method} timed out for ${this.url}`)); + }, wsMessageTimeoutMs); + this.pendings.set(request.id, { + resolve: (value) => { + if (dataSchemaRef) { + this.contract.validateSchemaRef(dataSchemaRef, `WS ${request.method} response data`, value); + } + resolve(value as T); + }, + reject, + timer, + }); + if (this.ws) { + try { + this.ws.send(JSON.stringify(request)); + } catch (err) { + clearTimeout(timer); + this.pendings.delete(request.id); + reject(err); + } + } else { + clearTimeout(timer); + this.pendings.delete(request.id); + reject(new Error("websocket not connected")); + } + }); + } + + close(): void { + this.closed = true; + this.ws?.close(); + this.ws = null; + } +} + // proxyFromEnv returns the configured egress proxy URL (sandboxed/corporate networks that only allow // outbound traffic via HTTP(S)_PROXY), or "" when none is set. Shared by the fetch dispatcher setup // in runner.ts and the ws client below. @@ -51,6 +181,8 @@ function wsProxyAgent(): HttpsProxyAgent | undefined { export class TestContext { readonly client: OpenApiFetchClient; + private wsConn_: wsConnection | undefined; + private wsConnInit_: Promise | undefined; private status?: NonNullable; private nextWSReq = 0; @@ -139,6 +271,24 @@ export class TestContext { return found; } + // preloadSamples eagerly resolves the most commonly-needed sample types so + // that downstream tests hit the cache rather than triggering redundant probe + // chains. getSampleAddressTx is deliberately excluded here — it can probe + // 40+ transactions with paginated address lookups and is only needed by a + // handful of tests; it stays lazy. + async preloadSamples() { + await this.getSampleIndexedBlock(); + await this.getSampleTxID(); + await this.getSampleAddress(); + try { + await this.sampleFiatTickerOrSkip(); + } catch (error) { + if (!(error instanceof SkipTest)) { + throw error; + } + } + } + async getSampleIndexedHeight() { if (this.sampleIndexResolved) { return this.sampleIndexHash ? { height: this.sampleIndexHeight, hash: this.sampleIndexHash } : undefined; @@ -194,6 +344,17 @@ export class TestContext { return undefined; } + // sampleIndexedBlockOrFail is for tests that need a block carrying transactions; the failure + // names the real cause (an empty-block run), not the block index. + async sampleIndexedBlockOrFail() { + const sample = await this.getSampleIndexedBlock(); + if (!sample) { + const status = await this.getStatus(); + throw new Error(`no block with transactions in the last ${sampleBlockProbeMax} blocks below height ${status.bestHeight ?? 0}`); + } + return sample; + } + async getSampleTxID() { if (this.sampleTxResolved) { return this.sampleTxID || undefined; @@ -243,12 +404,66 @@ export class TestContext { return undefined; } - this.sampleAddress = this.isEVMTxID(txid) - ? firstAddressFromTxPreferVin(tx) - : firstAddressFromTx(tx); + const preferVin = this.isEVMTxID(txid); + const fallback = sampleAddressCandidatesFromTx(tx, preferVin)[0] ?? ""; + this.sampleAddress = (await this.findBoundedAddress(tx, preferVin)) ?? fallback; return this.sampleAddress || undefined; } + // findBoundedAddress walks the sample tx, then the other txs of its block and the blocks below, + // for a participant whose total tx count stays under sampleAddressMaxTxs. Address-history tests + // otherwise land on a bot (a random BSC sender had 4.9e8 txs) whose even time-bounded history + // is unservable. Returns undefined when the probe budget runs out. + private async findBoundedAddress(sampleTx: TxResponse, preferVin: boolean) { + let probed = 0; + const probeTx = async (tx: TxResponse) => { + probed++; + for (const address of sampleAddressCandidatesFromTx(tx, preferVin).slice(0, sampleAddressCandidatesPerTx)) { + if (await this.isBoundedAddress(address)) { + return address; + } + } + return undefined; + }; + + const found = await probeTx(sampleTx); + if (found) { + return found; + } + const top = positiveNumber(sampleTx.blockHeight) ? sampleTx.blockHeight : 0; + const lower = Math.max(1, top - txSearchWindow + 1); + for (let height = top; height >= lower && probed < sampleAddressProbeMax; height--) { + const hash = height === sampleTx.blockHeight && sampleTx.blockHash ? sampleTx.blockHash : await this.getBlockHashForHeight(height, false); + if (!hash) { + continue; + } + const block = await this.getBlockByHashForSampling(hash, false); + for (const txid of block?.txIDs ?? []) { + if (!txid || txid === sampleTx.txid) { + continue; + } + if (probed >= sampleAddressProbeMax) { + break; + } + const tx = await this.getTransactionByID(txid, false); + const address = tx ? await probeTx(tx) : undefined; + if (address) { + return address; + } + } + } + return undefined; + } + + private async isBoundedAddress(address: string) { + const result = await this.client.getMaybe( + "/api/v2/address/{address}", + buildAddressDetailsPath(address, "basic", addressPage, addressPageSize), + ); + const txs = result.status === 200 ? result.data?.txs : undefined; + return typeof txs === "number" && txs <= sampleAddressMaxTxs; + } + async sampleAddressOrSkip() { const address = await this.getSampleAddress(); if (!address) { @@ -498,18 +713,52 @@ export class TestContext { } async wsCallWithID(id: string, method: WsMethod, params: unknown, dataSchemaRef?: string) { + const conn = await this.ensureWSConnection(); const request: WsEnvelope = { id, method, params }; - try { - return await this.wsCallOnce(this.wsURL, request, dataSchemaRef); - } catch (error) { - const upgraded = upgradeWSBaseToWSS(this.wsURL); - if (!upgraded) { - throw error; - } - const result = await this.wsCallOnce(upgraded, request, dataSchemaRef); - this.wsURL = upgraded; - return result; + return conn.request(request, dataSchemaRef); + } + + // ensureWSConnection lazily dials the shared WebSocket connection (so WS + // tests pay a single handshake per coin, not one per call) and redials when + // the previous connection died. The connection multiplexes concurrent + // requests by ID, so callers share it freely. The first dial tries the + // configured ws:// URL; if that fails it tries wss:// and updates this.wsURL + // so later dials skip the fallback. wsConnInit_ guards against concurrent + // calls dialing twice; a failed dial is not cached, so a transient outage + // does not poison every later WS test of the coin. + private ensureWSConnection(): Promise { + if (this.wsConn_ && !this.wsConn_.isClosed) { + return Promise.resolve(this.wsConn_); + } + if (!this.wsConnInit_) { + this.wsConnInit_ = (async () => { + const conn = new wsConnection(this.wsURL, this.contract); + try { + await conn.connect(); + return conn; + } catch (firstError) { + const upgraded = upgradeWSBaseToWSS(this.wsURL); + if (!upgraded) { + throw firstError; + } + const conn2 = new wsConnection(upgraded, this.contract); + await conn2.connect(); + this.wsURL = upgraded; + return conn2; + } + })().then( + (conn) => { + this.wsConn_ = conn; + this.wsConnInit_ = undefined; + return conn; + }, + (error) => { + this.wsConnInit_ = undefined; + throw error; + }, + ); } + return this.wsConnInit_; } isEVMTxID(txid: string) { @@ -521,6 +770,12 @@ export class TestContext { return isEVMAddressValue(address) || (this.coin === "tron" && isTronAddress(address)); } + close() { + this.wsConn_?.close(); + this.wsConn_ = undefined; + this.wsConnInit_ = undefined; + } + private async probeCapabilities() { return { utxo: await this.probeUTXOSupport(), @@ -660,41 +915,5 @@ export class TestContext { return summary; } - private wsCallOnce(wsURL: string, request: WsEnvelope, dataSchemaRef?: string) { - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsURL, { - handshakeTimeout: wsDialTimeoutMs, - rejectUnauthorized: process.env.OPENAPI_INSECURE_TLS === "0", - agent: wsProxyAgent(), - }); - const timeout = setTimeout(() => { - ws.terminate(); - reject(new Error(`websocket ${request.method} timed out for ${wsURL}`)); - }, wsMessageTimeoutMs); - ws.on("open", () => { - ws.send(JSON.stringify(request)); - }); - ws.on("message", (data) => { - const response = JSON.parse(data.toString()) as WsResponse; - if (response.id !== request.id) { - return; - } - clearTimeout(timeout); - ws.close(); - if (isWsError(response.data)) { - reject(new Error(`websocket ${request.method} returned error: ${response.data.error.message}`)); - return; - } - if (dataSchemaRef) { - this.contract.validateSchemaRef(dataSchemaRef, `WS ${request.method} response data`, response.data); - } - resolve(response.data as T); - }); - ws.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - }); - } } diff --git a/tests/openapi/src/fixtures.ts b/tests/openapi/src/fixtures.ts index cd31a75d20..9dcf78ab00 100644 --- a/tests/openapi/src/fixtures.ts +++ b/tests/openapi/src/fixtures.ts @@ -5,7 +5,11 @@ import { repoRoot } from "./config.js"; import type { ApiTestData } from "./types.js"; -export function loadAPITestData(coin: string) { +export function loadAPITestData(coin: string): ApiTestData { const file = path.join(repoRoot, "tests", "openapi", "fixtures", `${coin}.json`); + // no fixture file means no fixtures: fixture-driven tests then skip instead of failing on ENOENT + if (!fs.existsSync(file)) { + return {}; + } return JSON.parse(fs.readFileSync(file, "utf8")) as ApiTestData; } diff --git a/tests/openapi/src/parity.ts b/tests/openapi/src/parity.ts index 0e5a9a6137..cc2b95932f 100644 --- a/tests/openapi/src/parity.ts +++ b/tests/openapi/src/parity.ts @@ -97,6 +97,7 @@ const _WsReq: Compat = true; const _WsRes: Compat = true; const _WsAccountInfoReq: Compat = true; +const _WsPrivatePending: Compat = true; const _WsContractInfoReq: Compat = true; const _WsBackendInfo: Compat = true; const _WsInfoRes: Compat = true; @@ -113,6 +114,8 @@ const _WsEstimateFeeReq: Compat = true; const _Eip1559Fees: Compat = true; const _WsEstimateFeeRes: Compat = true; +const _EthereumGasData: Compat = true; +const _WsNewBlock: Compat = true; const _WsSendTransactionReq: Compat = true; const _WsSubscribeAddressesReq: Compat = true; const _WsSubscribeFiatRatesReq: Compat = true; @@ -148,10 +151,11 @@ void [ _BackendInfo, _InternalStateColumn, _BlockbookInfo, _SystemInfo, _FiatTicker, _FiatTickers, _AvailableVsCurrencies, _WsReq, _WsRes, - _WsAccountInfoReq, _WsContractInfoReq, _WsBackendInfo, _WsInfoRes, + _WsAccountInfoReq, _WsPrivatePending, _WsContractInfoReq, _WsBackendInfo, _WsInfoRes, _WsBlockHashReq, _WsBlockHashRes, _WsBlockReq, _WsBlockFilterReq, _WsBlockFiltersBatchReq, _WsAccountUtxoReq, _WsBalanceHistoryReq, _WsTransactionReq, _WsTransactionSpecificReq, _WsEstimateFeeReq, _Eip1559Fee, _Eip1559Fees, _WsEstimateFeeRes, + _EthereumGasData, _WsNewBlock, _WsSendTransactionReq, _WsSubscribeAddressesReq, _WsSubscribeFiatRatesReq, _WsCurrentFiatRatesReq, _WsFiatRatesForTimestampsReq, _WsFiatRatesTickersListReq, _WsMempoolFiltersReq, _WsRpcCallReq, _WsRpcCallRes, diff --git a/tests/openapi/src/registry.ts b/tests/openapi/src/registry.ts index e0fcf24a46..776ebb33c6 100644 --- a/tests/openapi/src/registry.ts +++ b/tests/openapi/src/registry.ts @@ -2,6 +2,7 @@ import { commonTests } from "./tests/common.js"; import { evmOnlyTests } from "./tests/evm.js"; import { utxoOnlyTests } from "./tests/utxo.js"; import { wsEVMTests, wsOnlyTests, wsUTXOTests } from "./tests/websocket.js"; +import { wsXpubTests, xpubTests } from "./tests/xpub.js"; import type { TestContext } from "./context.js"; import type { Capability } from "./types.js"; @@ -26,6 +27,8 @@ function buildTestRegistry() { }); addTests(registry, "ws-only", undefined, wsOnlyTests); addTests(registry, "ws-utxo", "utxo", wsUTXOTests); + addTests(registry, "xpub", "utxo", xpubTests); + addTests(registry, "ws-xpub", "utxo", wsXpubTests); return registry; } diff --git a/tests/openapi/src/runner.ts b/tests/openapi/src/runner.ts index b774ee69dd..c3cb663843 100644 --- a/tests/openapi/src/runner.ts +++ b/tests/openapi/src/runner.ts @@ -34,10 +34,31 @@ export async function runOpenApiE2E() { return; } + // Run coins in parallel — they connect to independent Blockbook instances, + // so there is no shared state to contend over. Each coin buffers its own + // console output and flushes it immediately upon completion so that even + // if another coin hangs the completed results are still visible. const summaries: CoinSummary[] = []; - for (const coin of selectedCoins) { - summaries.push(await runCoin(coin, contract)); - } + await Promise.all( + selectedCoins.map(async (coin) => { + try { + const { summary, output } = await runCoin(coin, contract); + for (const line of output) { + console.log(line); + } + summaries.push(summary); + } catch (error) { + // A rejection here means the coin aborted outside runCoin's own error + // handling. Record it as a coin-level failure so the run cannot go + // green while a selected coin was never tested. + const message = errorMessage(error); + console.error(`\nOpenAPI e2e ${coin}: aborted: ${message}`); + summaries.push( + summarize(coin, [{ coin, name: "CoinRun", group: "run", status: "fail", durationMs: 0, message }]), + ); + } + }), + ); // Per-coin and aggregate summary so a run that is green-but-empty (everything skipped) is // visible at a glance, not hidden behind a zero-failure exit. @@ -78,17 +99,22 @@ export async function runOpenApiE2E() { console.log(`\nOpenAPI e2e passed for ${summaries.length} coin(s): ${selectedCoins.join(", ")}`); } -async function runCoin(coin: string, contract: OpenApiContract): Promise { +async function runCoin(coin: string, contract: OpenApiContract): Promise<{ summary: CoinSummary; output: string[] }> { const testsConfig = loadTestsConfig(); const apiTests = testsConfig[coin]?.api ?? []; const results: TestResult[] = []; + const output: string[] = []; + + const emit = (msg: string) => output.push(msg); + if (apiTests.length === 0) { - console.log(`OpenAPI e2e ${coin}: no api tests configured, skipping.`); - return summarize(coin, results); + emit(`OpenAPI e2e ${coin}: no api tests configured, skipping.`); + return { summary: summarize(coin, results), output }; } const ctx = await TestContext.create(coin, contract); - console.log(`\nOpenAPI e2e ${coin}: ${apiTests.length} tests`); + try { + emit(`\nOpenAPI e2e ${coin}: ${apiTests.length} tests`); // Status preflight: if the node is unreachable or not in sync, every sample-derived test would // fail or skip downstream for the same root cause. Surface it once as a single coin-level failure @@ -97,16 +123,29 @@ async function runCoin(coin: string, contract: OpenApiContract): Promise 0) { const message = `coin ${coin} had 0 passing tests (${summary.skip} skipped) — likely not in sync or the recent-block window yielded no usable sample data`; - console.error(` fail (coin health): ${message}`); + emit(` fail (coin health): ${message}`); results.push({ coin, name: "CoinHealth", group: "health", status: "fail", durationMs: 0, message }); - return summarize(coin, results); + return { summary: summarize(coin, results), output }; } - return summary; + return { summary, output }; + } finally { + ctx.close(); + } } diff --git a/tests/openapi/src/support.ts b/tests/openapi/src/support.ts index 01235716eb..f73a8ac455 100644 --- a/tests/openapi/src/support.ts +++ b/tests/openapi/src/support.ts @@ -62,6 +62,33 @@ export function firstAddressFromTxPreferVin(tx: TxResponse) { // come first: on account-based chains a token transfer's vin is the (often high-traffic) sender and // its vout is the token contract (huge history), while the token recipient - and the output of a // plain transfer - is usually low-traffic and reliably lists the tx on its first page. +// sampleAddressCandidatesFromTx lists a tx's participants in sampling preference order: senders +// first on account-based chains (recipients are often contracts, which several tests reject), +// outputs first on UTXO chains, token-transfer parties last. +export function sampleAddressCandidatesFromTx(tx: TxResponse, preferVin: boolean): string[] { + const ordered: string[] = []; + const add = (value?: string) => { + const trimmed = (value ?? "").trim(); + if (trimmed && isAddressCandidate(trimmed)) { + ordered.push(trimmed); + } + }; + const vin = () => tx.vin?.forEach((input) => input.addresses?.forEach(add)); + const vout = () => tx.vout?.forEach((output) => output.addresses?.forEach(add)); + if (preferVin) { + vin(); + vout(); + } else { + vout(); + vin(); + } + for (const transfer of tx.tokenTransfers ?? []) { + add(transfer.from); + add(transfer.to); + } + return [...new Set(ordered)]; +} + export function candidateAddressesFromTx(tx: TxResponse): string[] { const ordered: string[] = []; const add = (value?: string) => { diff --git a/tests/openapi/src/tests/common.ts b/tests/openapi/src/tests/common.ts index 0447adaea3..4102004df5 100644 --- a/tests/openapi/src/tests/common.ts +++ b/tests/openapi/src/tests/common.ts @@ -38,11 +38,7 @@ async function testGetBlockIndex(ctx: TestContext) { } async function testGetBlock(ctx: TestContext) { - const sample = await ctx.getSampleIndexedBlock(); - if (!sample) { - const status = await ctx.getStatus(); - throw new Error(`missing indexed block hash in recent height window near ${status.bestHeight ?? 0}`); - } + const sample = await ctx.sampleIndexedBlockOrFail(); const block = await ctx.getBlockByHash(sample.hash, true); if (!block) { throw new Error(`missing block for hash ${sample.hash}`); @@ -57,11 +53,7 @@ async function testGetBlock(ctx: TestContext) { } async function testGetBlockByHeight(ctx: TestContext) { - const sample = await ctx.getSampleIndexedBlock(); - if (!sample) { - const status = await ctx.getStatus(); - throw new Error(`missing indexed block hash in recent height window near ${status.bestHeight ?? 0}`); - } + const sample = await ctx.sampleIndexedBlockOrFail(); const path = `/api/v2/block/${sample.height}?page=1&pageSize=${blockPageSize}`; const block = await ctx.client.getJson("/api/v2/block/{blockId}", path); diff --git a/tests/openapi/src/tests/evm.ts b/tests/openapi/src/tests/evm.ts index 1867ac705d..83e545762b 100644 --- a/tests/openapi/src/tests/evm.ts +++ b/tests/openapi/src/tests/evm.ts @@ -4,8 +4,8 @@ import { addressPage, addressPageSize, evmHistoryPage, evmHistoryPageSize } from import { assertAddressMatches, assertErc4626Payload, - assertEVMTokenBalancesHaveHoldingsFields, assertEVMBasicAddressPayload, + assertEVMTokenBalancesHaveHoldingsFields, assertEVMTokenBalancesPayload, assertEVMTokenListContractsMatch, assertEqualString, @@ -18,6 +18,7 @@ import { encodePathSegment, equalFold, isObject, + optionalBigInt, positiveNumber, txIDsFromTransactions, } from "../support.js"; @@ -276,6 +277,57 @@ async function testGetTransactionEVMShape(ctx: TestContext) { ); } +// testGetTransactionFeeConsistencyEVM samples a mined transaction and checks that the +// API's reported fee is derived from the receipt's effectiveGasPrice (the price actually +// paid on L2 rollups) rather than the transaction's bid gasPrice — the fix for #1227. +// It re-derives the fee from the exposed components and compares it to `fees`, mirroring +// api/worker.go getEthereumFeesSat exactly, so a regression back to the bid gasPrice (which +// differs from effectiveGasPrice on L2s) or a dropped l1Fee is caught end-to-end. +async function testGetTransactionFeeConsistencyEVM(ctx: TestContext) { + const txid = await ctx.sampleEVMTxIDOrSkip(); + const tx = await ctx.client.getJson( + "/api/v2/tx/{txid}", + `/api/v2/tx/${encodePathSegment(txid)}`, + ); + const eth = tx.ethereumSpecific; + if (!isObject(eth)) { + throw new SkipTest(`GetTransactionFeeConsistencyEVM: ${txid} has no ethereumSpecific`); + } + // gasUsed and l1Fee are exposed as JSON numbers. gasUsed is always well within range, + // but l1Fee (wei) can exceed JS safe-integer precision during L1 fee spikes; re-deriving + // the fee from a rounded number would diverge from the server's exact big-int result and + // flake. Skip such a tx (deterministic: correct code passes every tx we can represent + // exactly, so this never turns a good server red — it only declines to check). + if (typeof eth.gasUsed === "number" && !Number.isSafeInteger(eth.gasUsed)) { + throw new SkipTest(`GetTransactionFeeConsistencyEVM: ${txid} gasUsed=${eth.gasUsed} exceeds safe-integer precision`); + } + if (typeof eth.l1Fee === "number" && !Number.isSafeInteger(eth.l1Fee)) { + throw new SkipTest(`GetTransactionFeeConsistencyEVM: ${txid} l1Fee=${eth.l1Fee} exceeds safe-integer precision`); + } + const gasUsed = optionalBigInt(eth.gasUsed, "GetTransactionFeeConsistencyEVM.gasUsed"); + const fees = optionalBigInt(tx.fees, "GetTransactionFeeConsistencyEVM.fees"); + // The fee is only defined for mined transactions (gasUsed known); skip pending or + // zero-gas system transactions (e.g. the OP-stack L1-attributes deposit tx). + if (gasUsed === undefined || gasUsed === 0n || fees === undefined) { + throw new SkipTest(`GetTransactionFeeConsistencyEVM: ${txid} has no mined fee to verify`); + } + // Prefer the receipt effectiveGasPrice when present and positive, else the bid gasPrice, + // then add any separately reported L1 data fee (OP stack). Mirrors getEthereumFeesSat. + const effectiveGasPrice = optionalBigInt(eth.effectiveGasPrice, "GetTransactionFeeConsistencyEVM.effectiveGasPrice"); + const gasPrice = optionalBigInt(eth.gasPrice, "GetTransactionFeeConsistencyEVM.gasPrice"); + const perGas = effectiveGasPrice !== undefined && effectiveGasPrice > 0n ? effectiveGasPrice : (gasPrice ?? 0n); + const l1Fee = optionalBigInt(eth.l1Fee, "GetTransactionFeeConsistencyEVM.l1Fee") ?? 0n; + const expected = perGas * gasUsed + l1Fee; + if (fees !== expected) { + throw new Error( + `GetTransactionFeeConsistencyEVM: fee mismatch for ${txid}: api fees=${fees}, expected ` + + `(effectiveGasPrice||gasPrice)*gasUsed + l1Fee = ${expected} ` + + `[effectiveGasPrice=${eth.effectiveGasPrice ?? "none"}, gasPrice=${eth.gasPrice ?? "none"}, ` + + `gasUsed=${gasUsed}, l1Fee=${l1Fee}]`, + ); + } +} + export async function assertErc4626FixturesInAccountInfo( ctx: TestContext, testName: string, @@ -343,6 +395,18 @@ export async function assertContractInfoFixturesFetched( } } +async function testEnsResolutionEVM(ctx: TestContext) { + const resp = await ctx.client.getJson( + "/api/v2/address/{address}", + `/api/v2/address/${encodeURIComponent("vitalik.eth")}?details=basic`, + ); + assertNonEmptyString(resp.address, "EnsResolutionEVM.address"); + if (!equalFold(resp.address, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")) { + throw new Error(`EnsResolutionEVM.address mismatch: got ${resp.address}, want 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045`); + } + assertNonEmptyString(resp.balance, "EnsResolutionEVM.balance"); +} + export const evmOnlyTests: Record = { GetAddressBasicEVM: testGetAddressBasicEVM, GetAddressConfirmedNonceEVM: testGetAddressConfirmedNonceEVM, @@ -358,4 +422,6 @@ export const evmOnlyTests: Record = { GetAddressTxsPaginationEVM: testGetAddressTxsPaginationEVM, GetAddressContractFilterEVM: testGetAddressContractFilterEVM, GetTransactionEVMShape: testGetTransactionEVMShape, + GetTransactionFeeConsistencyEVM: testGetTransactionFeeConsistencyEVM, + EnsResolutionEVM: testEnsResolutionEVM, }; diff --git a/tests/openapi/src/tests/utxo.ts b/tests/openapi/src/tests/utxo.ts index c731891e02..6551c7bdd6 100644 --- a/tests/openapi/src/tests/utxo.ts +++ b/tests/openapi/src/tests/utxo.ts @@ -57,7 +57,7 @@ function assertUTXOListConfirmed(utxos: UtxoResponse[], context: string) { }); } -function assertUTXOSetsEqualByOutpoint(got: UtxoResponse[], want: UtxoResponse[], context: string) { +export function assertUTXOSetsEqualByOutpoint(got: UtxoResponse[], want: UtxoResponse[], context: string) { const gotSet = utxoSetByOutpoint(got, `${context}.got`); const wantSet = utxoSetByOutpoint(want, `${context}.want`); if (gotSet.size !== wantSet.size) { @@ -70,7 +70,7 @@ function assertUTXOSetsEqualByOutpoint(got: UtxoResponse[], want: UtxoResponse[] } } -function assertConfirmedUTXOsIncludedByOutpoint(mixed: UtxoResponse[], confirmed: UtxoResponse[], context: string) { +export function assertConfirmedUTXOsIncludedByOutpoint(mixed: UtxoResponse[], confirmed: UtxoResponse[], context: string) { const confirmedSet = utxoSetByOutpoint(confirmed, `${context}.confirmed`); for (const utxo of mixed) { if (isUnconfirmedUtxo(utxo)) { @@ -106,11 +106,11 @@ function utxoSetByOutpoint(utxos: UtxoResponse[], context: string) { return set; } -function utxoOutpointKey(utxo: UtxoResponse) { +export function utxoOutpointKey(utxo: UtxoResponse) { return `${stringValue(utxo.txid).trim().toLowerCase()}:${String(utxo.vout ?? 0)}`; } -function isUnconfirmedUtxo(utxo: UtxoResponse) { +export function isUnconfirmedUtxo(utxo: UtxoResponse) { return (utxo.confirmations ?? 0) <= 0 || (utxo.height ?? 0) <= 0; } diff --git a/tests/openapi/src/tests/websocket.ts b/tests/openapi/src/tests/websocket.ts index a44bde0b98..a8a26cb7bb 100644 --- a/tests/openapi/src/tests/websocket.ts +++ b/tests/openapi/src/tests/websocket.ts @@ -342,11 +342,7 @@ function skipWsUnsupportedOrRethrow(error: unknown, needles: string[], reason: s } async function testWsGetBlock(ctx: TestContext) { - const sample = await ctx.getSampleIndexedBlock(); - if (!sample) { - const status = await ctx.getStatus(); - throw new Error(`missing indexed block hash in recent height window near ${status.bestHeight ?? 0}`); - } + const sample = await ctx.sampleIndexedBlockOrFail(); let block: BlockResponse; try { @@ -444,7 +440,8 @@ async function testWsGetBlockFilter(ctx: TestContext) { if (!scriptType) { throw new SkipTest(`${ctx.coin} has no block_filter_scripts configured`); } - const sample = await ctx.getSampleIndexedBlock(); + // A block filter exists for empty blocks too, so any indexed block will do. + const sample = await ctx.getSampleIndexedHeight(); if (!sample) { const status = await ctx.getStatus(); throw new Error(`missing indexed block near ${status.bestHeight ?? 0}`); diff --git a/tests/openapi/src/tests/xpub.ts b/tests/openapi/src/tests/xpub.ts new file mode 100644 index 0000000000..2c742250ad --- /dev/null +++ b/tests/openapi/src/tests/xpub.ts @@ -0,0 +1,483 @@ +import { SkipTest } from "../errors.js"; +import { loadAPITestData } from "../fixtures.js"; +import { preview } from "../openapi.js"; +import { + assertEqualString, + assertNonEmptyString, + assertPageMeta, + assertPageSizeUpperBound, + assertStringSlicesEqual, + encodePathSegment, + equalFold, + optionalBigInt, + positiveNumber, + txIDsFromTransactions, +} from "../support.js"; +import { assertConfirmedUTXOsIncludedByOutpoint, assertUTXOSetsEqualByOutpoint, isUnconfirmedUtxo, utxoOutpointKey } from "./utxo.js"; + +import type { TestContext } from "../context.js"; +import type { AddressResponse, BalanceHistoryResponse, TokenResponse, TxResponse, UtxoResponse, XpubFixture } from "../types.js"; + +type TestFunction = (ctx: TestContext) => Promise; + +// Small page so even the lightest fixture (a handful of txs) still exercises the paging arithmetic. +const xpubPageSize = 5; +// One bucket per year: the balance-history test only needs lifetime totals, not the curve. +const lifetimeGroupBy = 365 * 24 * 3600; +// m/purpose'/coin'/account'/change/index, as emitted by api/xpub.go tokenFromXpubAddress. +const derivationPathPattern = /^m\/\d+'\/\d+'\/\d+'\/\d+\/\d+$/; +const extendedKeyPattern = /[1-9A-HJ-NP-Za-km-z]{100,120}/; + +function xpubFixturesOrSkip(ctx: TestContext): XpubFixture[] { + const fixtures = loadAPITestData(ctx.coin).xpubFixtures ?? []; + if (fixtures.length === 0) { + throw new SkipTest(`openapi/fixtures/${ctx.coin}.json has no xpubFixtures entries`); + } + return fixtures; +} + +function xpubPath(descriptor: string, query: string) { + return `/api/v2/xpub/${encodePathSegment(descriptor)}?${query}`; +} + +function getXpub(ctx: TestContext, descriptor: string, query: string) { + return ctx.client.getJson("/api/v2/xpub/{xpub}", xpubPath(descriptor, query)); +} + +function getXpubUtxos(ctx: TestContext, descriptor: string, confirmedOnly: boolean) { + const suffix = confirmedOnly ? "?confirmed=true" : ""; + return ctx.client.getJson("/api/v2/utxo/{descriptor}", `/api/v2/utxo/${encodePathSegment(descriptor)}${suffix}`); +} + +function requiredBigInt(value: unknown, context: string) { + const parsed = optionalBigInt(value, context); + if (parsed === undefined) { + throw new Error(`${context} is missing`); + } + return parsed; +} + +function nonNegativeInt(value: unknown, context: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw new Error(`${context} is not a non-negative integer: ${String(value)}`); + } + return value; +} + +function sumBigInt(values: bigint[]) { + return values.reduce((acc, value) => acc + value, 0n); +} + +// Shared shape check for every xpub account response: lifetime totals reconcile with the +// balance and the fixture floors hold. usedTokens and addrTxCount are omitted when zero. +function assertXpubAccount(resp: AddressResponse, fixture: XpubFixture, context: string) { + assertEqualString(resp.address, fixture.descriptor, `${context}.address`); + const balance = requiredBigInt(resp.balance, `${context}.balance`); + const received = requiredBigInt(resp.totalReceived, `${context}.totalReceived`); + const sent = requiredBigInt(resp.totalSent, `${context}.totalSent`); + if (received - sent !== balance) { + throw new Error(`${context} totals do not reconcile: totalReceived ${received} - totalSent ${sent} != balance ${balance}`); + } + nonNegativeInt(resp.unconfirmedTxs, `${context}.unconfirmedTxs`); + const usedTokens = nonNegativeInt(resp.usedTokens ?? 0, `${context}.usedTokens`); + if (usedTokens < fixture.minUsedAddresses) { + throw new Error(`${context} usedTokens ${usedTokens} is below the fixture floor ${fixture.minUsedAddresses} (${fixture.name}); is the index missing history?`); + } + // addrTxCount sums per-address counts, so it can never drop below the exact tx count the fixture pins. + const addrTxCount = nonNegativeInt(resp.addrTxCount ?? 0, `${context}.addrTxCount`); + if (addrTxCount < fixture.minTxs) { + throw new Error(`${context} addrTxCount ${addrTxCount} is below the fixture floor ${fixture.minTxs} (${fixture.name}); is the index missing history?`); + } + if (nonNegativeInt(resp.txs, `${context}.txs`) > addrTxCount) { + throw new Error(`${context} txs ${resp.txs} exceeds addrTxCount ${addrTxCount}`); + } + return { balance, received, sent, usedTokens }; +} + +// A derived-address row must carry a resolvable address and a full derivation path; an address +// that was never used cannot hold funds. +function assertXpubAddressToken(token: TokenResponse, context: string) { + if (token.type !== "XPUBAddress" || token.standard !== "XPUBAddress") { + throw new Error(`${context} is not an XPUBAddress row: type=${token.type} standard=${token.standard}`); + } + assertNonEmptyString(token.name, `${context}.name`); + if (typeof token.path !== "string" || !derivationPathPattern.test(token.path)) { + throw new Error(`${context}.path is not a full derivation path: ${String(token.path)}`); + } + const transfers = nonNegativeInt(token.transfers, `${context}.transfers`); + nonNegativeInt(token.decimals, `${context}.decimals`); + const balance = optionalBigInt(token.balance, `${context}.balance`) ?? 0n; + if (transfers === 0 && balance !== 0n) { + throw new Error(`${context} has balance ${balance} without any transfer`); + } + return { transfers, balance }; +} + +function tokenNames(tokens: TokenResponse[]) { + return tokens.map((token) => token.name); +} + +function assertUniquePaths(tokens: TokenResponse[], context: string) { + const seen = new Set(); + for (const token of tokens) { + const path = token.path ?? ""; + if (seen.has(path)) { + throw new Error(`${context} lists derivation path ${path} twice`); + } + seen.add(path); + } +} + +function txAddresses(tx: TxResponse) { + const addresses: string[] = []; + for (const input of tx.vin) { + addresses.push(...(input.addresses ?? [])); + } + for (const output of tx.vout) { + addresses.push(...(output.addresses ?? [])); + } + return addresses; +} + +// Every transaction listed for an account must touch at least one of its own addresses. +function assertTxsTouchOwnAddresses(txs: TxResponse[], own: Set, context: string) { + txs.forEach((tx, index) => { + if (!txAddresses(tx).some((address) => own.has(address))) { + throw new Error(`${context}.transactions[${index}] ${tx.txid} touches none of the account's addresses`); + } + }); +} + +function assertDisjointTxids(a: string[], b: string[], context: string) { + const set = new Set(a.map((txid) => txid.toLowerCase())); + for (const txid of b) { + if (set.has(txid.toLowerCase())) { + throw new Error(`${context} repeats txid ${txid} across pages`); + } + } +} + +function assertXpubUtxo(utxo: UtxoResponse, own: Set, context: string) { + assertNonEmptyString(utxo.txid, `${context}.txid`); + nonNegativeInt(utxo.vout, `${context}.vout`); + requiredBigInt(utxo.value, `${context}.value`); + assertNonEmptyString(utxo.address, `${context}.address`); + if (!own.has(utxo.address)) { + throw new Error(`${context} address ${utxo.address} is not one of the account's used addresses`); + } + if (typeof utxo.path !== "string" || !derivationPathPattern.test(utxo.path)) { + throw new Error(`${context}.path is not a full derivation path: ${String(utxo.path)}`); + } +} + +async function usedAddressSet(ctx: TestContext, descriptor: string) { + const resp = await getXpub(ctx, descriptor, "details=tokens&tokens=used"); + return new Set(tokenNames(resp.tokens ?? [])); +} + +async function testGetXpubBasic(ctx: TestContext) { + for (const fixture of xpubFixturesOrSkip(ctx)) { + const resp = await getXpub(ctx, fixture.descriptor, "details=basic"); + assertXpubAccount(resp, fixture, `GetXpubBasic[${fixture.name}]`); + if (resp.tokens !== undefined || resp.txids !== undefined || resp.transactions !== undefined) { + throw new Error(`GetXpubBasic[${fixture.name}] details=basic leaked tokens/txids/transactions`); + } + // basic skips the per-tx merge and reports the per-address estimate as txs + if (resp.txs !== (resp.addrTxCount ?? 0)) { + throw new Error(`GetXpubBasic[${fixture.name}] details=basic txs ${resp.txs} != addrTxCount ${resp.addrTxCount ?? 0}`); + } + } +} + +// The three tokens filters are views of one derivation: used ⊂ derived, nonzero ⊂ used, the +// nonzero balances sum to the account balance, and usedTokens is the same total under every filter. +async function testGetXpubTokens(ctx: TestContext) { + for (const fixture of xpubFixturesOrSkip(ctx)) { + const context = `GetXpubTokens[${fixture.name}]`; + const used = await getXpub(ctx, fixture.descriptor, "details=tokenBalances&tokens=used"); + const derived = await getXpub(ctx, fixture.descriptor, "details=tokenBalances&tokens=derived"); + const nonzero = await getXpub(ctx, fixture.descriptor, "details=tokenBalances&tokens=nonzero"); + const account = assertXpubAccount(used, fixture, `${context}.used`); + assertXpubAccount(derived, fixture, `${context}.derived`); + assertXpubAccount(nonzero, fixture, `${context}.nonzero`); + if (derived.usedTokens !== used.usedTokens || nonzero.usedTokens !== used.usedTokens) { + throw new Error(`${context} usedTokens differs across filters: used=${used.usedTokens} derived=${derived.usedTokens} nonzero=${nonzero.usedTokens}`); + } + + const usedTokens = used.tokens ?? []; + usedTokens.forEach((token, index) => { + const { transfers, balance } = assertXpubAddressToken(token, `${context}.used.tokens[${index}]`); + if (transfers === 0) { + throw new Error(`${context}.used.tokens[${index}] ${token.name} has no transfers`); + } + const received = requiredBigInt(token.totalReceived, `${context}.used.tokens[${index}].totalReceived`); + const sent = requiredBigInt(token.totalSent, `${context}.used.tokens[${index}].totalSent`); + if (received - sent !== balance) { + throw new Error(`${context}.used.tokens[${index}] ${token.name} totals do not reconcile: ${received} - ${sent} != ${balance}`); + } + }); + assertUniquePaths(usedTokens, `${context}.used`); + if (usedTokens.length !== account.usedTokens) { + throw new Error(`${context} tokens=used returned ${usedTokens.length} rows but usedTokens=${account.usedTokens}`); + } + if (sumBigInt(usedTokens.map((token) => optionalBigInt(token.balance, `${context}.used.balance`) ?? 0n)) !== account.balance) { + throw new Error(`${context} used address balances do not sum to the account balance ${account.balance}`); + } + + const derivedTokens = derived.tokens ?? []; + derivedTokens.forEach((token, index) => assertXpubAddressToken(token, `${context}.derived.tokens[${index}]`)); + assertUniquePaths(derivedTokens, `${context}.derived`); + // derived adds the unused gap addresses after the last used one on every chain + if (derivedTokens.length <= usedTokens.length) { + throw new Error(`${context} tokens=derived returned ${derivedTokens.length} rows, expected more than the ${usedTokens.length} used`); + } + const derivedNames = new Set(tokenNames(derivedTokens)); + for (const name of tokenNames(usedTokens)) { + if (!derivedNames.has(name)) { + throw new Error(`${context} used address ${name} is missing from tokens=derived`); + } + } + + const usedNames = new Set(tokenNames(usedTokens)); + const nonzeroTokens = nonzero.tokens ?? []; + const nonzeroBalances = nonzeroTokens.map((token, index) => { + const { balance } = assertXpubAddressToken(token, `${context}.nonzero.tokens[${index}]`); + if (!usedNames.has(token.name)) { + throw new Error(`${context}.nonzero.tokens[${index}] ${token.name} is not a used address`); + } + if (balance <= 0n) { + throw new Error(`${context}.nonzero.tokens[${index}] ${token.name} has non-positive balance ${balance}`); + } + return balance; + }); + if (sumBigInt(nonzeroBalances) !== account.balance) { + throw new Error(`${context} nonzero balances sum to ${sumBigInt(nonzeroBalances)}, account balance is ${account.balance}`); + } + } +} + +// Paging over the merged per-address history: exact page count, no repeats across pages, the +// last page holds the remainder, and details=txs pages identically to details=txids. +async function testGetXpubTxsPagination(ctx: TestContext) { + let validated = 0; + for (const fixture of xpubFixturesOrSkip(ctx)) { + const context = `GetXpubTxsPagination[${fixture.name}]`; + const page1 = await getXpub(ctx, fixture.descriptor, `details=txids&page=1&pageSize=${xpubPageSize}`); + assertXpubAccount(page1, fixture, `${context}.page1`); + if (page1.unconfirmedTxs > 0) { + // mempool txs are prepended to the paged sequence, which makes the counts below unstable + continue; + } + assertPageMeta(page1.page, page1.itemsOnPage, page1.totalPages, page1.txs, `${context}.page1`); + if (page1.txs < fixture.minTxs) { + throw new Error(`${context} txs ${page1.txs} is below the fixture floor ${fixture.minTxs}`); + } + const totalPages = Math.max(1, Math.ceil(page1.txs / xpubPageSize)); + if (page1.totalPages !== totalPages) { + throw new Error(`${context} totalPages ${page1.totalPages ?? 0} != ceil(${page1.txs}/${xpubPageSize}) = ${totalPages}`); + } + const txids1 = page1.txids ?? []; + assertPageSizeUpperBound(txids1.length, page1.itemsOnPage ?? 0, xpubPageSize, `${context}.page1.txids`); + if (txids1.length !== Math.min(xpubPageSize, page1.txs)) { + throw new Error(`${context}.page1 returned ${txids1.length} txids, want ${Math.min(xpubPageSize, page1.txs)}`); + } + txids1.forEach((txid) => assertNonEmptyString(txid, `${context}.page1.txids`)); + + const txsPage1 = await getXpub(ctx, fixture.descriptor, `details=txs&tokens=used&page=1&pageSize=${xpubPageSize}`); + assertXpubAccount(txsPage1, fixture, `${context}.txs.page1`); + assertStringSlicesEqual(txIDsFromTransactions(txsPage1.transactions ?? [], `${context}.txs.page1`), txids1, `${context} details=txs vs details=txids`); + assertTxsTouchOwnAddresses(txsPage1.transactions ?? [], new Set(tokenNames(txsPage1.tokens ?? [])), `${context}.txs.page1`); + + if (totalPages > 1) { + const last = await getXpub(ctx, fixture.descriptor, `details=txids&page=${totalPages}&pageSize=${xpubPageSize}`); + if (last.page !== totalPages) { + throw new Error(`${context}.last page ${last.page ?? 0} != requested ${totalPages}`); + } + const txidsLast = last.txids ?? []; + const remainder = page1.txs - (totalPages - 1) * xpubPageSize; + if (txidsLast.length !== remainder) { + throw new Error(`${context}.last returned ${txidsLast.length} txids, want the remainder ${remainder}`); + } + assertDisjointTxids(txids1, txidsLast, `${context} page1 vs last`); + } + validated++; + } + if (validated === 0) { + throw new SkipTest("GetXpubTxsPagination: every fixture had mempool activity, paging counts unstable"); + } +} + +// Confirmed UTXOs are the balance: their values sum to it exactly, each one belongs to a used +// address with its derivation path, and the unconfirmed view is a superset. +async function testGetXpubUtxo(ctx: TestContext) { + for (const fixture of xpubFixturesOrSkip(ctx)) { + const context = `GetXpubUtxo[${fixture.name}]`; + const own = await usedAddressSet(ctx, fixture.descriptor); + + let basic = await getXpub(ctx, fixture.descriptor, "details=basic"); + let confirmed = await getXpubUtxos(ctx, fixture.descriptor, true); + const utxoSum = (utxos: UtxoResponse[]) => sumBigInt(utxos.map((utxo) => requiredBigInt(utxo.value, `${context}.value`))); + if (utxoSum(confirmed) !== requiredBigInt(basic.balance, `${context}.balance`)) { + // a block landing between the two calls moves both; re-read once before judging + basic = await getXpub(ctx, fixture.descriptor, "details=basic"); + confirmed = await getXpubUtxos(ctx, fixture.descriptor, true); + } + const balance = assertXpubAccount(basic, fixture, `${context}.basic`).balance; + confirmed.forEach((utxo, index) => { + assertXpubUtxo(utxo, own, `${context}.confirmed[${index}]`); + if (isUnconfirmedUtxo(utxo)) { + throw new Error(`${context}.confirmed[${index}] ${utxoOutpointKey(utxo)} is unconfirmed`); + } + }); + if (utxoSum(confirmed) !== balance) { + throw new Error(`${context} confirmed UTXOs sum to ${utxoSum(confirmed)}, balance is ${balance}`); + } + + const all = await getXpubUtxos(ctx, fixture.descriptor, false); + all.forEach((utxo, index) => assertXpubUtxo(utxo, own, `${context}.all[${index}]`)); + assertConfirmedUTXOsIncludedByOutpoint(all, confirmed, `${context}.all-vs-confirmed`); + } +} + +// Lifetime received minus sent over the whole history must land exactly on the confirmed balance +// and the buckets must account for every confirmed transaction. +async function testGetXpubBalanceHistory(ctx: TestContext) { + for (const fixture of xpubFixturesOrSkip(ctx)) { + const context = `GetXpubBalanceHistory[${fixture.name}]`; + const account = await getXpub(ctx, fixture.descriptor, "details=txids&page=1&pageSize=1"); + const { balance } = assertXpubAccount(account, fixture, `${context}.account`); + + const result = await ctx.client.getMaybe( + "/api/v2/balancehistory/{descriptor}", + `/api/v2/balancehistory/${encodePathSegment(fixture.descriptor)}?groupBy=${lifetimeGroupBy}`, + ); + if (result.status !== 200 || result.data === undefined) { + if (result.body.includes("spans more than")) { + throw new SkipTest(`${context}: history exceeds the instance's balance-history tx cap`); + } + throw new Error(`${context} returned HTTP ${result.status}: ${preview(result.body)}`); + } + const history: BalanceHistoryResponse[] = result.data; + let previousTime = 0; + let txs = 0; + let net = 0n; + history.forEach((entry, index) => { + if (!positiveNumber(entry.time) || entry.time <= previousTime) { + throw new Error(`${context}[${index}] time ${String(entry.time)} is not increasing`); + } + previousTime = entry.time; + const bucketTxs = nonNegativeInt(entry.txs, `${context}[${index}].txs`); + if (bucketTxs === 0) { + throw new Error(`${context}[${index}] is an empty bucket`); + } + txs += bucketTxs; + net += requiredBigInt(entry.received, `${context}[${index}].received`) - requiredBigInt(entry.sent, `${context}[${index}].sent`); + optionalBigInt(entry.sentToSelf, `${context}[${index}].sentToSelf`); + }); + if (net !== balance) { + throw new Error(`${context} lifetime received - sent = ${net}, balance is ${balance}`); + } + // txs from details=txids is the exact confirmed count; the history buckets must cover it + if (txs !== account.txs) { + throw new Error(`${context} buckets account for ${txs} txs, history has ${account.txs}`); + } + } +} + +// A malformed xpub is caller input: it must come back as HTTP 400 with the public error +// envelope, never as a 500 "Internal server error". +async function testGetXpubInvalid(ctx: TestContext) { + const [fixture] = xpubFixturesOrSkip(ctx); + const key = extendedKeyPattern.exec(fixture.descriptor)?.[0]; + if (!key) { + throw new Error(`GetXpubInvalid: fixture ${fixture.name} has no extended key to corrupt`); + } + const lastChar = key.at(-1) === "1" ? "2" : "1"; + const corrupted = fixture.descriptor.replace(key, key.slice(0, -1) + lastChar); + for (const bad of ["not-an-xpub", corrupted]) { + const result = await ctx.client.getMaybe("/api/v2/xpub/{xpub}", xpubPath(bad, "details=basic")); + if (result.status !== 400) { + throw new Error(`GetXpubInvalid: ${bad} returned HTTP ${result.status}, want 400: ${preview(result.body)}`); + } + ctx.contract.validateSchemaRef("#/components/schemas/ErrorResponse", `GetXpubInvalid ${bad} error body`, result.data); + assertNonEmptyString((result.data as { error?: unknown }).error, "GetXpubInvalid.error"); + } +} + +function sameXpubAccount(a: AddressResponse, b: AddressResponse) { + return a.balance === b.balance && a.totalReceived === b.totalReceived && a.totalSent === b.totalSent + && a.txs === b.txs && a.addrTxCount === b.addrTxCount && a.usedTokens === b.usedTokens + && a.unconfirmedTxs === b.unconfirmedTxs; +} + +// HTTP and WebSocket getAccountInfo share the xpub cache, so the same request must yield the same +// totals and the same used-address rows. Re-read once when they differ: a block landing between +// the two calls legitimately changes both. +async function testWsGetAccountInfoXpub(ctx: TestContext) { + for (const fixture of xpubFixturesOrSkip(ctx)) { + const context = `WsGetAccountInfoXpub[${fixture.name}]`; + const fetchBoth = async () => { + const http = await getXpub(ctx, fixture.descriptor, "details=tokenBalances&tokens=used"); + const ws = await ctx.wsCall( + "getAccountInfo", + { descriptor: fixture.descriptor, details: "tokenBalances", tokens: "used" }, + "#/components/schemas/Address", + ); + return { http, ws }; + }; + let { http, ws } = await fetchBoth(); + if (!sameXpubAccount(ws, http)) { + ({ http, ws } = await fetchBoth()); + } + assertXpubAccount(ws, fixture, `${context}.ws`); + assertXpubAccount(http, fixture, `${context}.http`); + if (!sameXpubAccount(ws, http)) { + throw new Error(`${context} totals differ: ws=${JSON.stringify({ balance: ws.balance, txs: ws.txs, usedTokens: ws.usedTokens })} http=${JSON.stringify({ balance: http.balance, txs: http.txs, usedTokens: http.usedTokens })}`); + } + const rows = (tokens: TokenResponse[]) => tokens.map((token) => `${token.path ?? ""} ${token.name} ${token.balance ?? ""} ${token.transfers}`).sort(); + assertStringSlicesEqual(rows(ws.tokens ?? []), rows(http.tokens ?? []), `${context} used address rows`); + } +} + +// HTTP and WebSocket UTXO views of an xpub must agree outpoint by outpoint, including the +// derived address and path attached to each one. +async function testWsGetAccountUtxoXpub(ctx: TestContext) { + for (const fixture of xpubFixturesOrSkip(ctx)) { + const context = `WsGetAccountUtxoXpub[${fixture.name}]`; + const fetchBoth = async () => { + const http = await getXpubUtxos(ctx, fixture.descriptor, false); + const ws = await ctx.wsCall("getAccountUtxo", { descriptor: fixture.descriptor }); + return { http, ws }; + }; + let { http, ws } = await fetchBoth(); + if (!Array.isArray(ws)) { + throw new Error(`${context} ws response is not an array`); + } + if (ws.length !== http.length) { + ({ http, ws } = await fetchBoth()); + } + ws.forEach((utxo, index) => ctx.contract.validateSchemaRef("#/components/schemas/Utxo", `${context}.ws[${index}]`, utxo)); + assertUTXOSetsEqualByOutpoint(ws, http, `${context} outpoints`); + const byOutpoint = new Map(http.map((utxo) => [utxoOutpointKey(utxo), utxo])); + ws.forEach((utxo, index) => { + const twin = byOutpoint.get(utxoOutpointKey(utxo)); + if (!twin || !equalFold(twin.address, utxo.address) || twin.path !== utxo.path || twin.value !== utxo.value) { + throw new Error(`${context}.ws[${index}] ${utxoOutpointKey(utxo)} differs from HTTP: ws=${JSON.stringify(utxo)} http=${JSON.stringify(twin)}`); + } + }); + } +} + +export const xpubTests: Record = { + GetXpubBasic: testGetXpubBasic, + GetXpubTokens: testGetXpubTokens, + GetXpubTxsPagination: testGetXpubTxsPagination, + GetXpubUtxo: testGetXpubUtxo, + GetXpubBalanceHistory: testGetXpubBalanceHistory, + GetXpubInvalid: testGetXpubInvalid, +}; + +export const wsXpubTests: Record = { + WsGetAccountInfoXpub: testWsGetAccountInfoXpub, + WsGetAccountUtxoXpub: testWsGetAccountUtxoXpub, +}; diff --git a/tests/openapi/src/types.ts b/tests/openapi/src/types.ts index 3d07d84e9b..ca4228199c 100644 --- a/tests/openapi/src/types.ts +++ b/tests/openapi/src/types.ts @@ -56,9 +56,19 @@ export type Erc4626Fixture = { contract: string; }; +// An xpub or output descriptor with on-chain history. The floors are taken from the live chain when +// the fixture is written; history only grows, so a lower value means the index is missing data. +export type XpubFixture = { + name: string; + descriptor: string; + minTxs: number; + minUsedAddresses: number; +}; + export type ApiTestData = { erc4626Fixtures?: Erc4626Fixture[]; nonVaultContracts?: string[]; + xpubFixtures?: XpubFixture[]; }; export type Capability = "utxo" | "evm"; diff --git a/tests/rpc/rpc.go b/tests/rpc/rpc.go index 6c68b7038f..577869a3b1 100644 --- a/tests/rpc/rpc.go +++ b/tests/rpc/rpc.go @@ -35,6 +35,8 @@ var testMap = map[string]func(t *testing.T, th *TestHandler){ "GetBlockHeader": testGetBlockHeader, "EthCallBatch": testEthCallBatch, "EthCallErc4626": testEthCallErc4626, + "EnsNameExpires": testEnsNameExpires, + "EnsResolution": testEnsResolution, } type TestHandler struct { @@ -836,3 +838,63 @@ func containsTx(o []bchain.Outpoint, tx string) bool { } return false } + +// testEnsNameExpires verifies that nameExpires(uint256) on the ENS Base Registrar +// returns a valid non-zero expiration when called with the correct selector +// (0xd6e4fa86). The wrong selector reverts; a wrong tokenID returns 0. Asserting +// a non-zero result guards both regressions. +func testEnsNameExpires(t *testing.T, h *TestHandler) { + // keccak256("vitalik") as tokenID (the Base Registrar token for vitalik.eth) + tokenID := "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc" + // nameExpires(uint256) on the ENS Base Registrar. Using the production + // constants makes this guard the exact selector/address regression the fix + // addresses (a wrong constant would fail here, not silently pass). + result, err := h.Chain.EthereumTypeRpcCall(eth.ENSExpirationFunctionSelector+tokenID[2:], eth.ENSBaseRegistrarAddress, "") + if err != nil { + t.Fatalf("nameExpires eth_call failed (wrong selector?): %v", err) + } + if result == "0x" || result == "0x0" || result == "0x0000000000000000000000000000000000000000000000000000000000000000" { + t.Fatalf("nameExpires returned zero for vitalik.eth (wrong tokenID?): %s", result) + } + t.Logf("vitalik.eth nameExpires: %s", result) +} + +// testEnsResolution verifies full ENS resolution: resolver + addr against real backend. +func testEnsResolution(t *testing.T, h *TestHandler) { + // namehash("vitalik.eth") = 0xee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835 + namehash := "0xee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835" + // resolver(bytes32) on the ENS registry + resolverResult, err := h.Chain.EthereumTypeRpcCall(eth.ENSResolverFunctionSelector+namehash[2:], eth.ENSRegistryAddress, "") + if err != nil { + t.Fatalf("resolver eth_call failed: %v", err) + } + if resolverResult == "0x" || resolverResult == "0x0" || resolverResult == "0x0000000000000000000000000000000000000000000000000000000000000000" { + t.Fatal("resolver returned zero — vitalik.eth should have a resolver set") + } + t.Logf("vitalik.eth resolver: %s", resolverResult) + + // addr(bytes32) on the resolver. Extract the resolver address from the + // ABI-encoded result (last 40 hex chars) and 0x-prefix it. Guard the slice + // against a short non-zero result that slips past the zero-checks above. + if len(resolverResult) < 42 { + t.Fatalf("unexpected resolver result: %s", resolverResult) + } + resolverAddr := "0x" + resolverResult[len(resolverResult)-40:] + addrResult, err := h.Chain.EthereumTypeRpcCall(eth.ENSAddrFunctionSelector+namehash[2:], resolverAddr, "") + if err != nil { + t.Fatalf("addr eth_call failed: %v", err) + } + if addrResult == "0x" || addrResult == "0x0" || addrResult == "0x0000000000000000000000000000000000000000000000000000000000000000" { + t.Fatal("addr returned zero — vitalik.eth should resolve to an address") + } + // Extract address (last 40 hex chars) and verify it matches Vitalik's known address. + // eth_call returns lowercase hex; use case-insensitive comparison. + if len(addrResult) < 42 { + t.Fatalf("unexpected addr result: %s", addrResult) + } + addr := "0x" + addrResult[len(addrResult)-40:] + if !strings.EqualFold(addr, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") { + t.Fatalf("vitalik.eth resolved to %s, want 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", addr) + } + t.Logf("vitalik.eth -> %s ✓", addr) +} diff --git a/tests/rpc/testdata/hyperevm_archive.json b/tests/rpc/testdata/hyperevm_archive.json new file mode 100644 index 0000000000..558b6b093a --- /dev/null +++ b/tests/rpc/testdata/hyperevm_archive.json @@ -0,0 +1,48 @@ +{ + "blockHeight": 42176091, + "blockHash": "0x14e51adc4102d5f50f46e71f5268c2134a42f7a11103f3769917451d20405217", + "blockTime": 1785740657, + "blockSize": 4251, + "blockTxs": [ + "0xec9e0382c1dd6c748e4187405bc3064e7ea2a73808e1a705c15f2b9e095a54e4", + "0x98e344ebf02aee64e387b250e30e11975e4d65aa63b0f6286e517f7b85cb436c", + "0x8c5b3cc5bcb88563f1d8b1a5691c81400009160ae941441d3bf1f4dc62c5d73f", + "0x41a890bfc45f31dba4d70d616d2479939a1be3b933a04e32496664fc1602b452", + "0xde011d6d8124f1769f2197e12ef526c239a65d0f269fefd4747b708cc55bbee4", + "0xc023c9a60b9a26c5626a1d3e786610be62697f8f56821ad0f4d1bfe50c2d56ea", + "0xae5b160693bad41b9869cddc92958c7a6c527c65a207f9e2c4f78ce1cd539f83" + ], + "ethCallBatch": { + "address": "0xd2be32dbf454ad8ff57851e9762cac12536ebee4", + "contracts": [ + "0x5555555555555555555555555555555555555555", + "0xb88339cb7199b77e23db6e890353e22632ba630f", + "0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb", + "0x9fdbda0a5e284c32744d2f17ee5c74b284993463" + ] + }, + "txDetails": { + "0xec9e0382c1dd6c748e4187405bc3064e7ea2a73808e1a705c15f2b9e095a54e4": { + "txid": "0xec9e0382c1dd6c748e4187405bc3064e7ea2a73808e1a705c15f2b9e095a54e4", + "blockTime": 1785740657, + "time": 1785740657, + "vin": [ + { + "addresses": [ + "0x2b2dcd4b52b959c8c2bdd7b9f7bf35fe1a16aace" + ] + } + ], + "vout": [ + { + "value": "0", + "scriptPubKey": { + "addresses": [ + "0x8549fd7ffc092f8366e416e129a622ec060104ea" + ] + } + } + ] + } + } +} diff --git a/tests/rpc/testdata/robinhood_archive.json b/tests/rpc/testdata/robinhood_archive.json new file mode 100644 index 0000000000..a819759e39 --- /dev/null +++ b/tests/rpc/testdata/robinhood_archive.json @@ -0,0 +1,56 @@ +{ + "blockHeight": 5936636, + "blockHash": "0xf2b52fee138f75c1010e4d7e4d6c4e182b64d4deab88758bcc404650a5707605", + "blockTime": 1783672186, + "blockSize": 8627, + "blockTxs": [ + "0x66882ab1248b3b65d9805fb8ac778c3aaa6d3c03dbc9453ebca188ddfd48254c", + "0xb6b183f876dce0cc2fcee0e8a8ad1cf854b47c1aad8ee7262b338f0b79e875a0", + "0xebfb4b3de3a7ba79a56041e9773042204c05c53cebfefcaffbb647956cafba11", + "0x2f1490c54dfc6c9e7f6e0397da2b04da9206d4403db4658ab86fb9947d5b2f5b", + "0x04290dab3e33194e285789721f391f8775b51d32f8b41c879c051c3e273946e5", + "0x3a7aba5be0416db2bb5deb5a4e70223c46fb39a47e7fb46161c340cf05b3c385", + "0x2a8c42c19b5f9b888afd0f4c726b6f838dcd06c0dcf6d22d0d40f72d30c4ff25", + "0xd77e7b3e9a3611c1624bd284a73f8c436a8e11b5f864c5c0f3923bcd48a4cba1", + "0x46ca9fd75e25dc2a8f52fee8a30cc2e81bf648bd55b7bb93664b739009438ce2", + "0x58c4c5c9733edaf21f61d9327ae12bea650bcf02626ca8a58f5fe2e91efeee12", + "0xb04a9887e27dea8c5d7d66b39648e874dcb53fc1f246759ee773f3361f830725", + "0xe61f4bb6159a13e30cbbbefdd304319c0abbcdd9be50f9db0519cf351a3e0707", + "0x4b8815ad83a25c9171687cdc7e7dfef1f27b0780d8182f828e387a3f668fce86", + "0x0dea870dd77205561f34b891441594937a223cad40940cee0916dcbb4b192698", + "0x8822094d1df53933ea139b3d5c1952080bcd39dd3cb5e8135e5282f388c60086" + ], + "ethCallBatch": { + "address": "0x6dcae1cda438c81ab1749e558a0687308b1c2712", + "contracts": [ + "0x0bd7d308f8e1639fab988df18a8011f41eacad73", + "0x5fc5360d0400a0fd4f2af552add042d716f1d168", + "0x9bdedfa869ca8c84a76c9366d4ec7ceaf57b47ff", + "0xa870f8c7cae9705194a141b0c2964b6c79ee3742" + ] + }, + "txDetails": { + "0x46ca9fd75e25dc2a8f52fee8a30cc2e81bf648bd55b7bb93664b739009438ce2": { + "txid": "0x46ca9fd75e25dc2a8f52fee8a30cc2e81bf648bd55b7bb93664b739009438ce2", + "blockTime": 1783672186, + "time": 1783672186, + "vin": [ + { + "addresses": [ + "0x6dcae1cda438c81ab1749e558a0687308b1c2712" + ] + } + ], + "vout": [ + { + "value": "0.642308023304301", + "scriptPubKey": { + "addresses": [ + "0x35e33cd299e1d50bac3a153de9d29e5142e51c29" + ] + } + } + ] + } + } +} diff --git a/tests/rpc/testdata/robinhood_testnet.json b/tests/rpc/testdata/robinhood_testnet.json new file mode 100644 index 0000000000..9d15c1e4e3 --- /dev/null +++ b/tests/rpc/testdata/robinhood_testnet.json @@ -0,0 +1,44 @@ +{ + "blockHeight": 89191018, + "blockHash": "0xf8195adc3c1033aac589134b1d3f551e9bb63d81192008986bdde793c383014b", + "blockTime": 1783670766, + "blockSize": 1105, + "blockTxs": [ + "0xc6152960febf7d97aecf6ec9d25e6831c702100f562a4da98bede8a664f129c6", + "0xead35a3a60c9330d28a046fbedf8b64b140e87b2eb657a2a39f7fb6ec60a7039", + "0x2f734e128c07b9111f50d4bed877120f0195f8a239112c6df2d219fbe42b0c71" + ], + "ethCallBatch": { + "address": "0x1ff625b3493ead1e862f0391b243ca9d67e3bdba", + "contracts": [ + "0x7943e237c7f95da44e0301572d358911207852fa", + "0xf16b7b4442b9fa089aa197ce3d837933a04e1110", + "0xc9f9c86933092bbbfff3ccb4b105a4a94bf3bd4e", + "0x5884ad2f920c162cfbbacc88c9c51aa75ec09e02" + ] + }, + "txDetails": { + "0xead35a3a60c9330d28a046fbedf8b64b140e87b2eb657a2a39f7fb6ec60a7039": { + "txid": "0xead35a3a60c9330d28a046fbedf8b64b140e87b2eb657a2a39f7fb6ec60a7039", + "blockTime": 1783670766, + "time": 1783670766, + "vin": [ + { + "addresses": [ + "0x2da4ddf22df106f58cfeb0af31a8b6d3e61125eb" + ] + } + ], + "vout": [ + { + "value": "0.000029", + "scriptPubKey": { + "addresses": [ + "0xc21df1bb620ebe7f5ae0144df50de28ce0d47ae7" + ] + } + } + ] + } + } +} diff --git a/tests/rpc/testdata/tron.json b/tests/rpc/testdata/tron.json index edce2a91d2..352eac5808 100644 --- a/tests/rpc/testdata/tron.json +++ b/tests/rpc/testdata/tron.json @@ -333,7 +333,7 @@ "chainExtraData": { "contractType": "WithdrawExpireUnfreezeContract", "operation": "withdraw", - "unstakeAmount": "100000000000", + "withdrawnUnfreeze": "100000000000", "bandwidthUsage": "253" } } diff --git a/tests/tests.json b/tests/tests.json index d68cbc57a6..a9cfa3db49 100644 --- a/tests/tests.json +++ b/tests/tests.json @@ -2,14 +2,14 @@ "avalanche": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "bcash": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", "GetXpubBasic", "GetXpubTokens", "GetXpubTxsPagination", "GetXpubUtxo", "GetXpubBalanceHistory", "GetXpubInvalid"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateFee", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -30,8 +30,8 @@ "bitcoin": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", - "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfo", "WsGetAccountInfoBasic", "WsGetAccountUtxo", "WsPing", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific", "WsLongTermFeeRate", "WsGetBlockFilter", "WsGetBlockFiltersBatch", "WsGetMempoolFilters", "GetBlockFilters", "GetBlockFiltersInvalidScriptType"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", "GetXpubBasic", "GetXpubTokens", "GetXpubTxsPagination", "GetXpubUtxo", "GetXpubBalanceHistory", "GetXpubInvalid", + "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfo", "WsGetAccountInfoBasic", "WsGetAccountUtxo", "WsPing", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific", "WsLongTermFeeRate", "WsGetBlockFilter", "WsGetBlockFiltersBatch", "WsGetMempoolFilters", "GetBlockFilters", "GetBlockFiltersInvalidScriptType", "WsGetAccountInfoXpub", "WsGetAccountUtxoXpub"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateSmartFee", "EstimateFee", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -47,7 +47,7 @@ "bitcoin_testnet": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", "GetXpubBasic", "GetXpubTokens", "GetXpubTxsPagination", "GetXpubUtxo", "GetXpubBalanceHistory", "GetXpubInvalid"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateSmartFee", "EstimateFee", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -78,7 +78,7 @@ "bsc": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, @@ -124,8 +124,8 @@ "dogecoin": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], - "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "MempoolSync"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", "GetXpubBasic", "GetXpubTokens", "GetXpubTxsPagination", "GetXpubUtxo", "GetXpubBalanceHistory", "GetXpubInvalid"], + "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "MempoolSync", "EstimateSmartFee", "EstimateFee"], "sync": ["ConnectBlocksParallel", "ConnectBlocks"] }, "dogecoin_testnet": { @@ -170,7 +170,7 @@ "litecoin": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetUtxo", "GetUtxoConfirmedFilter", "GetXpubBasic", "GetXpubTokens", "GetXpubTxsPagination", "GetXpubUtxo", "GetXpubBalanceHistory", "GetXpubInvalid"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "EstimateSmartFee", "EstimateFee"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -208,7 +208,7 @@ "zcash": { "connectivity": ["http"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetAddressTxsScientificNotation", "GetUtxo", "GetUtxoConfirmedFilter"], + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressTxids", "GetAddressTxs", "GetAddressTxsScientificNotation", "GetUtxo", "GetUtxoConfirmedFilter", "GetXpubBasic", "GetXpubTokens", "GetXpubTxsPagination", "GetXpubUtxo", "GetXpubBalanceHistory", "GetXpubInvalid"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "GetTransactionForMempool", "MempoolSync", "GetBestBlockHash", "GetBestBlockHeight", "GetBlockHeader"], "sync": ["ConnectBlocksParallel", "ConnectBlocks", "HandleFork"] @@ -300,54 +300,76 @@ "arbitrum": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "base": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressProtocolsEVM", "GetAddressProtocolsOptInEVM", "GetContractInfoEVM", "GetContractInfoOptInEVM", "GetContractInfoNonVaultEVM", "Erc4626FeeInvariantEVM", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressProtocolsEVM", "GetAddressProtocolsOptInEVM", "GetContractInfoEVM", "GetContractInfoOptInEVM", "GetContractInfoNonVaultEVM", "Erc4626FeeInvariantEVM", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsGetAccountInfoProtocolsEVM", "WsGetContractInfoEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, + "robinhood_archive": { + "connectivity": ["http", "ws"], + "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], + "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] + }, + "robinhood_testnet": { + "disabled": true, + "connectivity": ["http", "ws"], + "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", + "GetAddress", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], + "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] + }, + "hyperevm_archive": { + "connectivity": ["http", "ws"], + "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], + "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] + }, "ethereum": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressProtocolsEVM", "GetAddressProtocolsOptInEVM", "GetContractInfoEVM", "GetContractInfoOptInEVM", "GetContractInfoNonVaultEVM", "Erc4626FeeInvariantEVM", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressProtocolsEVM", "GetAddressProtocolsOptInEVM", "GetContractInfoEVM", "GetContractInfoOptInEVM", "GetContractInfoNonVaultEVM", "Erc4626FeeInvariantEVM", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "EnsResolutionEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsGetAccountInfoProtocolsEVM", "WsGetContractInfoEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], - "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader"] + "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EnsNameExpires", "EnsResolution"] }, "ethereum_testnet_sepolia": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetContractInfoNonVaultEVM", "GetTransactionEVMShape", + "GetAddress", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetContractInfoNonVaultEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"] }, "ethereum_testnet_hoodi": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"] }, "ethereum-classic": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader"] }, "optimism": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] }, "polygon": { "connectivity": ["http", "ws"], "api": ["Status", "GetBlockIndex", "GetBlockByHeight", "GetBlock", "GetTransaction", "GetTransactionSpecific", - "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", + "GetAddress", "GetCurrentFiatRates", "GetTickersList", "GetMultiTickers", "GetBalanceHistory", "GetAddressBasicEVM", "GetAddressConfirmedNonceEVM","GetAddressTokensEVM", "GetAddressTokenBalances", "GetAddressTxidsPaginationEVM", "GetAddressTxsPaginationEVM", "GetAddressContractFilterEVM", "GetTransactionEVMShape", "GetTransactionFeeConsistencyEVM", "WsGetInfo", "WsGetBlockHash", "WsGetTransaction", "WsGetAccountInfoBasicEVM", "WsGetAccountInfoConfirmedNonceEVM","WsGetAccountInfoEVM", "WsGetAccountInfoTxidsConsistencyEVM", "WsGetAccountInfoTxsConsistencyEVM", "WsGetAccountInfoContractFilterEVM", "WsPing", "WsGetCurrentFiatRates", "WsGetFiatRatesForTimestamps", "WsGetFiatRatesTickersList", "WsGetBlock", "WsGetBalanceHistory", "WsGetTransactionSpecific"], "rpc": ["GetBlock", "GetBlockHash", "GetTransaction", "EstimateFee", "GetBlockHeader", "EthCallBatch"] },