diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f4f9293..e547ab7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,30 @@ on: types: [opened, synchronize, reopened] jobs: + transaction-history: + name: Transaction History (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install SDK + run: python -m pip install . + + - name: Run transaction-history regressions + run: python -m unittest discover -s tests -v + + - name: Check request counts and timestamp correctness over loopback HTTP + run: python -m benchmarks.transaction_history --repeats 1 + version: name: Verify Version Bump runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa7c8d..651d88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `status-python-sdk` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.1] - 2026-10-21 + +### Fixed + +- `Account.get_transactions` resolves fallback block timestamps on each transfer's chain and keeps equal block heights on different chains separate. + +### Changed + +- Request block timestamps as transfer metadata, avoiding separate block requests when metadata is available. Missing or invalid metadata falls back to one lookup per distinct chain and block. +- Add credential-free transaction-history regression tests and a loopback HTTP request-count benchmark. + ## [1.4.0] - 2026-10-19 ### Added diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..82e08de --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Credential-free benchmarks for reproducible SDK behavior.""" diff --git a/benchmarks/transaction_history.py b/benchmarks/transaction_history.py new file mode 100644 index 0000000..295d0a2 --- /dev/null +++ b/benchmarks/transaction_history.py @@ -0,0 +1,279 @@ +"""Run Account.get_transactions against controlled JSON-RPC over loopback HTTP. + +This measures request counts and output correctness, not production latency. +Run this candidate-owned script with --sdk-root to measure an unmodified checkout. +An oracle failure still produces JSON and returns a nonzero exit status. +""" + +from __future__ import annotations + +import argparse +from collections import Counter +from contextlib import contextmanager +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import importlib +import importlib.util +import json +from pathlib import Path +import platform +import sys +from threading import Thread +from unittest.mock import patch +from urllib.parse import urlparse + + +HERE = Path(__file__).resolve().parents[1] +OUTPUT_COLUMNS = [ + "timestamp", "block_number", "trx_hash", "from_address", "to_address", + "amount", "symbol", "trx_type", "chain_id", +] + + +@contextmanager +def loopback_transport(account_module, responder): + """Retain actual requests encoding/HTTP/response decoding without external I/O.""" + import requests + + records = [] + handler_errors = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + try: + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + original_url = self.headers["X-Fixture-Alchemy-URL"] + records.append({"domain": urlparse(original_url).hostname, "payload": payload}) + response = responder(original_url, json=payload).json() + encoded = json.dumps(response).encode() + self.send_response(200) + except Exception as error: + handler_errors.append(f"{type(error).__name__}: {error}") + encoded = json.dumps({"error": {"message": str(error)}}).encode() + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, *_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + session = requests.Session() + session.trust_env = False + local_url = f"http://127.0.0.1:{server.server_port}/json-rpc" + + def post(url, **kwargs): + parsed = urlparse(url) + if parsed.scheme != "https" or not parsed.hostname or not parsed.hostname.endswith(".g.alchemy.com"): + raise AssertionError(f"Unexpected SDK destination: {url}") + headers = dict(kwargs.pop("headers", {})) + headers["X-Fixture-Alchemy-URL"] = url + kwargs.setdefault("timeout", 5) + return session.post(local_url, headers=headers, **kwargs) + + try: + with patch.object(account_module.requests, "post", post): + yield records, handler_errors + finally: + session.close() + server.shutdown() + server.server_close() + thread.join(timeout=5) + if thread.is_alive(): + raise RuntimeError("Loopback fixture server did not stop") + + +def validate_frame(frame, expected): + """Use fixture-defined outcomes independent of the SDK timestamp derivation.""" + failures = [] + columns = list(frame.columns) + if columns != OUTPUT_COLUMNS: + failures.append(f"schema: expected {OUTPUT_COLUMNS!r}, got {columns!r}") + actual_rows = frame.to_dict("records") + if len(actual_rows) != len(expected): + failures.append(f"row count: expected {len(expected)}, got {len(actual_rows)}") + observed = Counter((int(row["chain_id"]), row["trx_hash"]) for row in actual_rows) + desired = Counter(expected.keys()) + if observed != desired: + failures.append("transaction chain/hash multiset does not match fixture") + wrong_timestamps = 0 + timestamp_mismatches = [] + wrong_amounts = 0 + wrong_directions = 0 + wrong_blocks = 0 + for row in actual_rows: + oracle = expected.get((int(row["chain_id"]), row["trx_hash"])) + if oracle is None: + continue + timestamp = row["timestamp"] + if timestamp.tzinfo is None or int(timestamp.timestamp()) != oracle["timestamp"]: + wrong_timestamps += 1 + timestamp_mismatches.append({ + "chain_id": int(row["chain_id"]), "transaction_hash": row["trx_hash"], + "expected_unix_seconds": oracle["timestamp"], "actual": str(timestamp), + }) + if row["amount"] != oracle["amount"]: + wrong_amounts += 1 + if row["trx_type"] != oracle["direction"]: + wrong_directions += 1 + if row["block_number"] != oracle["block_number"]: + wrong_blocks += 1 + for count, label in [ + (wrong_timestamps, "timestamps"), (wrong_amounts, "signed amounts"), + (wrong_directions, "sent/received classifications"), (wrong_blocks, "block numbers"), + ]: + if count: + failures.append(f"{count} incorrect {label}") + return { + "rows": len(actual_rows), "columns": columns, + "incorrect_timestamps": wrong_timestamps, "incorrect_signed_amounts": wrong_amounts, + "incorrect_directions": wrong_directions, "incorrect_block_numbers": wrong_blocks, + "timestamp_mismatches": timestamp_mismatches, + "failures": failures, + } + + +def make_scenarios(fixtures): + def timestamp_metadata(seconds): + return {"blockTimestamp": datetime.fromtimestamp(seconds, timezone.utc).isoformat().replace("+00:00", "Z")} + + def new_scenario(name, description, chains, expected_requests): + return {"name": name, "description": description, "chains": chains, + "expected_requests": expected_requests, "pages": {}, "blocks": {}, "expected": {}} + + def add(scenario, domain, chain_id, block, seconds, direction, tx_number, metadata=True): + sender = fixtures.WALLET_ADDRESS if direction == "sent" else fixtures.OTHER_ADDRESS + recipient = fixtures.OTHER_ADDRESS if direction == "sent" else fixtures.WALLET_ADDRESS + tx_hash = f"0x{tx_number:064x}" + values = {"block": hex(block), "transaction_hash": tx_hash, "sender": sender, + "recipient": recipient, "value": 1.25} + if metadata: + values["metadata"] = timestamp_metadata(seconds) + item = fixtures.transfer(**values) + query_direction = "fromAddress" if direction == "sent" else "toAddress" + scenario["pages"].setdefault((domain, query_direction, ""), {"transfers": []})["transfers"].append(item) + scenario["blocks"][(domain, hex(block))] = hex(seconds) + scenario["expected"][(chain_id, tx_hash)] = { + "timestamp": seconds, "amount": -1.25 if direction == "sent" else 1.25, + "direction": direction, "block_number": block, + } + + metadata = new_scenario( + "ethereum_40_blocks_80_transfers_with_metadata", + "40 sent and 40 received distinct transactions across 40 Ethereum blocks; every response includes timestamp metadata when requested", + {"eth-mainnet": 1}, 2, + ) + for index in range(40): + for offset, direction in enumerate(("sent", "received")): + add(metadata, "eth-mainnet", 1, 100 + index, 1_700_000_000 + 12 * index, + direction, index * 2 + offset + 1) + + collision = new_scenario( + "same_height_different_chain_timestamps", + "Ethereum and Base have equal numeric block heights but different timestamps; metadata is available for both", + {"eth-mainnet": 1, "base-mainnet": 8453}, 4, + ) + add(collision, "eth-mainnet", 1, 100, 1_700_000_000, "sent", 1_001) + add(collision, "base-mainnet", 8453, 100, 1_800_000_000, "received", 1_002) + + fallback = new_scenario( + "mixed_metadata_chain_scoped_fallback", + "Metadata timestamps are retained; four missing metadata entries share two chain/block pairs and require exactly one fallback per pair", + {"eth-mainnet": 1, "base-mainnet": 8453}, 6, + ) + for index, (domain, chain_id, start) in enumerate([ + ("eth-mainnet", 1, 1_700_000_000), ("base-mainnet", 8453, 1_800_000_000), + ]): + add(fallback, domain, chain_id, 100, start, "sent", 2_000 + 10 * index) + add(fallback, domain, chain_id, 101, start + 12, "sent", 2_001 + 10 * index, metadata=False) + add(fallback, domain, chain_id, 101, start + 12, "received", 2_002 + 10 * index, metadata=False) + return [metadata, collision, fallback] + + +def load_sdk(sdk_root): + sys.path.insert(0, str(sdk_root)) + module = importlib.import_module("status_sdk.account") + source = Path(module.__file__).resolve() + requested = (sdk_root / "status_sdk" / "account.py").resolve() + if source != requested: + raise RuntimeError(f"Wrong source imported: {source}; requested {requested}") + spec = importlib.util.spec_from_file_location( + "history_benchmark_fixtures", HERE / "tests" / "transaction_fixtures.py", + ) + fixtures = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = fixtures + spec.loader.exec_module(fixtures) + return module, fixtures, source + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sdk-root", type=Path, default=HERE, + help="Checkout whose actual status_sdk.account is imported") + parser.add_argument("--output", type=Path, help="Also save the JSON report here") + parser.add_argument("--repeats", type=int, default=3) + args = parser.parse_args(argv) + if args.repeats < 1: + parser.error("--repeats must be positive") + module, fixtures, source = load_sdk(args.sdk_root.resolve()) + report = { + "format_version": 1, + "scope": "Controlled JSON-RPC responses over loopback HTTP; no real Alchemy, credentials, or Status backend", + "measurement": "Actual HTTP request counts and fixture-oracle output correctness; no production latency claim", + "source": {"file": "status_sdk/account.py", "sha256": hashlib.sha256(source.read_bytes()).hexdigest()}, + "python": platform.python_version(), "repeats": args.repeats, "scenarios": [], + } + for scenario in make_scenarios(fixtures): + runs = [] + for repetition in range(args.repeats): + responder = fixtures.RecordingAlchemy(deepcopy(scenario["pages"]), deepcopy(scenario["blocks"])) + account = fixtures.make_account() + result = {"repetition": repetition + 1, "failures": []} + with patch.dict(module.constants.ALCHEMY_CHAIN_IDS, scenario["chains"], clear=True): + with loopback_transport(module, responder) as (records, handler_errors): + try: + frame = account.get_transactions(refresh=True) + result.update(validate_frame(frame, scenario["expected"])) + except Exception as error: + result["failures"].append(f"SDK raised {type(error).__name__}: {error}") + by_method = Counter(record["payload"].get("method") for record in records) + by_domain = Counter(record["domain"] for record in records) + metadata_requested = sum( + record["payload"].get("method") == "alchemy_getAssetTransfers" + and record["payload"]["params"][0].get("withMetadata") is True + for record in records + ) + result.update({"request_count": len(records), "requests_by_method": dict(by_method), + "requests_by_domain": dict(by_domain), "metadata_transfer_requests": metadata_requested, + "block_lookups": [ + {"domain": record["domain"], "block_number": record["payload"]["params"][0]} + for record in records if record["payload"].get("method") == "eth_getBlockByNumber" + ], + "http_handler_errors": handler_errors}) + result["failures"].extend(handler_errors) + if len(records) != scenario["expected_requests"]: + result["failures"].append( + f"request budget: expected {scenario['expected_requests']}, got {len(records)}" + ) + result["passed"] = not result["failures"] + runs.append(result) + report["scenarios"].append({"name": scenario["name"], "description": scenario["description"], + "expected_requests": scenario["expected_requests"], "runs": runs, + "passed": all(run["passed"] for run in runs)}) + report["passed"] = all(scenario["passed"] for scenario in report["scenarios"]) + encoded = json.dumps(report, indent=2) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(encoded) + print(encoded, end="") + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/account.md b/docs/account.md index 3e39926..f75bee1 100644 --- a/docs/account.md +++ b/docs/account.md @@ -1348,29 +1348,29 @@ Returns `pd.DataFrame`. #### `get_transactions(refresh=False)` -Retrieve the historical transactions for the **logged-in account wallet** across all chains in [`chains`](./account.md#chains). Data is fetched from the [Alchemy REST API](https://www.alchemy.com/) using the `alchemy_token` provided during [`login`](./account.md#loginpassword-key_uidnone-namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) and combines three transaction types into a single `DataFrame`: regular transactions (`transaction`), internal transactions (`internal`) and ERC-20 token transfers (`ERC-20`). +Retrieve historical transfers for the **logged-in account wallet** from the endpoints configured in [`ALCHEMY_CHAIN_IDS`](../status_sdk/constants.py). Data is fetched from the [Alchemy REST API](https://www.alchemy.com/) using the `alchemy_token` provided during [`login`](./account.md#loginpassword-key_uidnone-namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone). The requested categories are `external`, `internal` and `erc20`; the returned `trx_type` classifies each transfer as `sent` or `received`. | Name | Type | Required | Description | |-----|-----|-----|-------------| | `refresh` | `bool` | No | When `True`, the full transaction history is refetched from Alchemy and the cache is replaced. When `False` (default), the cached `DataFrame` from the first call is returned. | -Returns `pd.DataFrame`, sorted by `timestamp` in descending order (newest first). +Returns `pd.DataFrame`, retaining the existing descending `block_number` sort. Heights are not comparable across chains; callers needing chronological order can sort the result by `timestamp`. + +Transaction requests include Alchemy's `withMetadata` option so that block timestamps can be read from the transfer response without extra block requests. If timestamp metadata is missing or invalid, the SDK looks up each distinct `(chain_id, block_number)` on that chain's endpoint. Cached calls retain the existing `refresh=False` behavior. See [the credential-free regression tests and request-count benchmark](./transaction-history-testing.md). | Column | Type | Description | |--------|------|-------------| -| `timestamp` | `datetime.datetime` | Block timestamp when the transaction was confirmed. | -| `trx_type` | `str` | Type of transaction: `transaction` (regular EOA call), `internal` (contract-initiated transfer) or `ERC-20` (token transfer). | -| `trx_hash` | `str` | Unique transaction hash. Can be appended to `https://etherscan.io/tx/` to inspect the transaction on Etherscan. | +| `timestamp` | UTC datetime | Block timestamp on the transaction's chain. | +| `block_number` | `int` | Block height on that chain. | +| `trx_hash` | `str` | Transaction hash. Use the corresponding chain's explorer to inspect it. | | `from_address` | `str` | Wallet address that initiated the transaction. | | `to_address` | `str` | Wallet address that received the transaction. | -| `movement` | `str` | `sent` if the wallet has made the transaction, otherwise `received`. | -| `token_address` | `str` | Token contract address. | -| `token_symbol` | `str` | Token symbol (e.g. `ETH`, `SNT`, `USDT`). | -| `amount` | `float` | Decimal-adjusted transaction amount. **Negative** when `movement` is `sent`, **positive** when `received`. | -| `is_error` | `bool` | `True` if the transaction failed on-chain. | +| `amount` | `float` | Decimal-adjusted transaction amount. **Negative** when `trx_type` is `sent`, **positive** when `received`. | +| `symbol` | `str` | Asset symbol from the transfer response. | +| `trx_type` | `str` | `sent` if the wallet initiated the transfer, otherwise `received`. | | `chain_id` | `int` | Chain ID where the transaction occurred. Matches values from [`chains`](./account.md#chains). | -| `decimals` | `int` | Number of decimals used to scale `amount`. Defaults to `18` for native-token transfers. | -| `trx_fee` | `float` | Gas fee paid in the chain's native token, computed as `gas_price * gas_used / 10**18`. Populated only for `sent` rows; `0` for `received` rows since the receiver does not pay gas. | + +This method does not currently return token contract addresses, transaction fees, error status or a separate transfer-category column. These output fields and the existing sorting behavior are unchanged by the timestamp optimization. ```python from status_sdk import Account diff --git a/docs/transaction-history-testing.md b/docs/transaction-history-testing.md new file mode 100644 index 0000000..3cdce22 --- /dev/null +++ b/docs/transaction-history-testing.md @@ -0,0 +1,88 @@ +# Transaction-history tests and request counts + +`Account.get_transactions` requests Alchemy transfer metadata and uses each +transfer's `metadata.blockTimestamp`. If it is missing, malformed, or lacks a +timezone, the method falls back to `eth_getBlockByNumber` on the transfer's chain. +Contradictory metadata for the same chain/block pair also triggers a lookup. +Fallback lookups are deduplicated by `(chain_id, block_number)`. Timestamps retain +the existing integer-second, UTC representation. + +The metadata option is documented in the [Alchemy transfer API](https://www.alchemy.com/docs/data/transfers-api/transfers-endpoints/alchemy-get-asset-transfers) +and its [official request/response reference](https://github.com/alchemyplatform/skills/blob/main/skills/alchemy-api/references/data-transfers-api.md). + +## Run without credentials + +From a source checkout, using Python 3.11 or later: + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install . +.venv/bin/python -m unittest discover -s tests -v +.venv/bin/python -m benchmarks.transaction_history --repeats 3 --output transaction-history.json +``` + +No Alchemy token, wallet keys, logged-in account, or running Status backend is +needed. Tests construct an `Account` without its backend initializer and execute +the imported production method. The recording fixture replaces the external +response boundary. It returns metadata only when the request opts in. + +The benchmark sends real JSON-RPC HTTP requests to a temporary server bound to +`127.0.0.1`. It redirects the SDK's configured Alchemy destinations to that +server, preserving the original destination in a fixture header. Responses and +wallet addresses are synthetic. A constrained runner must permit loopback +binding; the server closes when each run finishes. + +## What is checked + +- Metadata is requested on every page; usable timestamps avoid block requests. +- Equal heights on Ethereum and Base retain distinct timestamps. +- Missing or invalid metadata uses the matching chain, with one lookup per pair. +- A Base height unavailable on Ethereum does not query Ethereum. +- Pagination, signed amounts, existing output columns, height sorting, cache + copies, refreshes and raised transport failures retain their behavior. + +The benchmark also checks the transaction chain/hash multiset, signed amounts, +directions, heights and timestamps against fixture-defined outcomes. Every JSON +report includes the SHA256 of the imported `account.py`. It writes results and +returns a nonzero exit status if a correctness or request-count check fails. + +| Controlled scenario | Expected requests after this change | +| --- | ---: | +| 80 transfers across 40 Ethereum blocks, all with metadata | 2 transfer requests, 0 block requests | +| Ethereum and Base at the same height with distinct metadata timestamps | 4 transfer requests, 0 block requests | +| Mixed metadata, with missing entries at two distinct chain/block pairs | 4 transfer requests, 2 block requests | + +Each repetition uses a fresh `Account`, so the request counts measure a cold +history fetch. Warm-cache behavior is checked separately in the unit tests. +There are two direction queries per configured chain, plus any pagination; +the first scenario deliberately configures only Ethereum. + +## Compare an unmodified checkout + +The benchmark can import another checkout's SDK while retaining the same +candidate-owned fixtures and oracles: + +```sh +.venv/bin/python -m benchmarks.transaction_history \ + --sdk-root /absolute/path/to/unmodified/status-python-sdk \ + --repeats 3 --output baseline-history.json +``` + +The unmodified source used during development was commit +`6eddfc2ea56da2d23d535d143243515ebd751772`. It made 42 requests in the +metadata-rich scenario and reused Ethereum timestamps for the Base examples. +The baseline benchmark is expected to fail the candidate acceptance criteria; +its JSON retains the actual incorrect timestamps and lookup destinations. + +## Bounds + +This is a request-count and output-correctness measurement under controlled +responses. It does not measure live Alchemy availability, production latency, +rate limits, or supported transfer categories across every configured network. +Elapsed local execution time is not a wallet performance claim. + +The public method's output fields and block-height sorting remain unchanged; +height order is not chronological order across chains. This change does not +implement the native `wallet_` endpoint requested in +[status-go #7883](https://github.com/status-im/status-go/issues/7883), add token +addresses, or change provider error/retry policies. diff --git a/pyproject.toml b/pyproject.toml index 864285e..2ca7beb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "status-sdk" -version = "1.4.0" +version = "1.4.1" description = "Private chat. Communities. Multi-chain wallet. Browser. dApps all in one app, powered by SNT." readme = "README.md" requires-python = ">=3.11" diff --git a/status_sdk/account.py b/status_sdk/account.py index 2bf9044..baaaa6e 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -1723,6 +1723,7 @@ def get_transactions(self, refresh: bool = False) -> pd.DataFrame: key: wallet_address, "maxCount": hex(1_000), "pageKey": page_key if isinstance(page_key, str) else None, + "withMetadata": True, "category": ["external", "internal", "erc20"] } ] @@ -1752,21 +1753,46 @@ def get_transactions(self, refresh: bool = False) -> pd.DataFrame: "category": "trx_type", "chain_id": "chain_id" } + metadata = final.get("metadata", [None] * len(final)) final = final[list(columns.keys())].rename(columns=columns) block_mapping = {} - for block_number in final["block_number"].unique(): + conflicting_blocks = set() + for chain_id, block_number, transfer_metadata in zip(final["chain_id"], final["block_number"], metadata): + if not isinstance(transfer_metadata, dict): + continue + value = transfer_metadata.get("blockTimestamp") + if not isinstance(value, str): + continue + try: + timestamp = datetime.datetime.fromisoformat(value) + if timestamp.utcoffset() is not None: + key = (chain_id, block_number) + seconds = int(timestamp.timestamp()) + if key in block_mapping and block_mapping[key] != seconds: + conflicting_blocks.add(key) + block_mapping[key] = seconds + except (ValueError, OverflowError, OSError): + # Missing or invalid metadata uses the same chain-scoped fallback. + continue + + chain_domains = {chain_id: domain for domain, chain_id in constants.ALCHEMY_CHAIN_IDS.items()} + for chain_id, block_number in final[["chain_id", "block_number"]].drop_duplicates().itertuples(index=False, name=None): + key = (chain_id, block_number) + if key in block_mapping and key not in conflicting_blocks: + continue payload = { "jsonrpc": "2.0", "id": 1, "method": "eth_getBlockByNumber", "params": [block_number, False], } - url = f"https://eth-mainnet.g.alchemy.com/v2/{self.__alchemy_token}" + url = f"https://{chain_domains[chain_id]}.g.alchemy.com/v2/{self.__alchemy_token}" response = requests.post(url, json=payload) - block_mapping[block_number] = int(response.json()["result"]["timestamp"], 16) + block_mapping[key] = int(response.json()["result"]["timestamp"], 16) - final.insert(0, "timestamp", pd.to_datetime(final["block_number"].map(block_mapping), unit="s", utc=True)) + timestamps = [block_mapping[key] for key in zip(final["chain_id"], final["block_number"])] + final.insert(0, "timestamp", pd.to_datetime(timestamps, unit="s", utc=True)) final = final.assign( block_number = final["block_number"].apply(lambda value: int(value, 16)), trx_type = final.apply(lambda row: "sent" if row["from_address"].lower() == wallet_address.lower() else "received", axis=1), diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f8cfb4c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Credential-free regression tests for the Status SDK.""" diff --git a/tests/test_transactions.py b/tests/test_transactions.py new file mode 100644 index 0000000..b672686 --- /dev/null +++ b/tests/test_transactions.py @@ -0,0 +1,254 @@ +"""Run with: python -m unittest discover -s tests -v.""" + +import unittest +from unittest.mock import patch + +import pandas as pd +import requests + +from status_sdk import exceptions +from tests.transaction_fixtures import ( + CHAINS, OTHER_ADDRESS, WALLET_ADDRESS, RecordingAlchemy, make_account, transfer, +) + + +class TransactionHistoryTests(unittest.TestCase): + def setUp(self): + self.chain_patch = patch("status_sdk.account.constants.ALCHEMY_CHAIN_IDS", CHAINS) + self.chain_patch.start() + self.addCleanup(self.chain_patch.stop) + + def fetch(self, fixture, account=None, refresh=False): + account = account if account is not None else make_account() + with patch("status_sdk.account.requests.post", fixture): + return account.get_transactions(refresh=refresh) + + def test_metadata_avoids_block_requests(self): + fixture = RecordingAlchemy(pages={ + ("base-mainnet", "fromAddress", ""): {"transfers": [ + transfer(metadata={"blockTimestamp": "2024-06-15T10:30:00.000Z"}), + ]}, + }) + result = self.fetch(fixture) + self.assertEqual(result.iloc[0]["timestamp"], pd.Timestamp("2024-06-15T10:30:00Z")) + self.assertEqual(fixture.block_calls, []) + + def test_every_page_requests_metadata(self): + fixture = RecordingAlchemy(pages={ + ("eth-mainnet", "fromAddress", ""): {"transfers": [], "pageKey": "next"}, + ("eth-mainnet", "fromAddress", "next"): {"transfers": []}, + }) + self.fetch(fixture) + self.assertEqual(len(fixture.transfer_calls), 5) + self.assertTrue(all(call["params"][0].get("withMetadata") is True + for call in fixture.transfer_calls)) + + def test_metadata_same_height_on_different_chains_stays_distinct(self): + fixture = RecordingAlchemy(pages={ + ("eth-mainnet", "fromAddress", ""): {"transfers": [ + transfer(transaction_hash="eth", metadata={"blockTimestamp": "2024-01-01T00:00:00Z"}), + ]}, + ("base-mainnet", "fromAddress", ""): {"transfers": [ + transfer(transaction_hash="base", metadata={"blockTimestamp": "2025-01-01T00:00:00Z"}), + ]}, + }) + result = self.fetch(fixture).set_index("trx_hash") + self.assertEqual(result.loc["eth", "timestamp"], pd.Timestamp("2024-01-01T00:00:00Z")) + self.assertEqual(result.loc["base", "timestamp"], pd.Timestamp("2025-01-01T00:00:00Z")) + self.assertEqual(fixture.block_calls, []) + + def test_metadata_timezones_normalize_to_utc(self): + fixture = RecordingAlchemy(pages={ + ("eth-mainnet", "fromAddress", ""): {"transfers": [ + transfer(transaction_hash="z", metadata={"blockTimestamp": "2024-06-15T10:30:00.000Z"}), + transfer(transaction_hash="offset", metadata={"blockTimestamp": "2024-06-15T12:30:00+02:00"}), + ]}, + }) + result = self.fetch(fixture) + self.assertTrue(all(value == pd.Timestamp("2024-06-15T10:30:00Z") for value in result.timestamp)) + self.assertEqual(str(result.timestamp.dt.tz), "UTC") + self.assertEqual(fixture.block_calls, []) + + def test_missing_metadata_uses_correct_chain_and_distinct_pair_keys(self): + fixture = RecordingAlchemy(pages={ + ("eth-mainnet", "fromAddress", ""): {"transfers": [transfer(transaction_hash="eth")]}, + ("base-mainnet", "fromAddress", ""): {"transfers": [transfer(transaction_hash="base")]}, + }, blocks={("eth-mainnet", "0x64"): "0x3e8", ("base-mainnet", "0x64"): "0x7d0"}) + result = self.fetch(fixture).set_index("trx_hash") + self.assertEqual(result.loc["eth", "timestamp"], pd.Timestamp(1000, unit="s", tz="UTC")) + self.assertEqual(result.loc["base", "timestamp"], pd.Timestamp(2000, unit="s", tz="UTC")) + self.assertEqual({(call["domain"], call["params"][0]) for call in fixture.block_calls}, + {("eth-mainnet", "0x64"), ("base-mainnet", "0x64")}) + + def test_base_height_above_ethereum_head_does_not_query_ethereum(self): + fixture = RecordingAlchemy(pages={ + ("base-mainnet", "toAddress", ""): {"transfers": [ + transfer(block="0x2000000", sender=OTHER_ADDRESS, recipient=WALLET_ADDRESS), + ]}, + }, blocks={("eth-mainnet", "0x2000000"): None, ("base-mainnet", "0x2000000"): "0x7d0"}) + result = self.fetch(fixture) + self.assertEqual(result.iloc[0]["timestamp"], pd.Timestamp(2000, unit="s", tz="UTC")) + self.assertEqual([call["domain"] for call in fixture.block_calls], ["base-mainnet"]) + + def test_missing_null_and_invalid_metadata_share_one_fallback(self): + fixture = RecordingAlchemy(pages={ + ("base-mainnet", "fromAddress", ""): {"transfers": [ + transfer(transaction_hash="missing"), + transfer(transaction_hash="null", metadata=None), + transfer(transaction_hash="invalid", metadata={"blockTimestamp": "not-a-date"}), + transfer(transaction_hash="empty", metadata={}), + ]}, + }, blocks={("base-mainnet", "0x64"): "0x7d0"}) + result = self.fetch(fixture) + self.assertEqual(len(result), 4) + self.assertTrue(all(value == pd.Timestamp(2000, unit="s", tz="UTC") for value in result.timestamp)) + self.assertEqual(len(fixture.block_calls), 1) + self.assertEqual(fixture.block_calls[0]["domain"], "base-mainnet") + + def test_later_metadata_for_same_chain_block_avoids_fallback(self): + fixture = RecordingAlchemy(pages={ + ("base-mainnet", "fromAddress", ""): {"transfers": [transfer(transaction_hash="missing")]}, + ("base-mainnet", "toAddress", ""): {"transfers": [ + transfer(transaction_hash="known", sender=OTHER_ADDRESS, recipient=WALLET_ADDRESS, + metadata={"blockTimestamp": "2024-01-01T00:00:00Z"}), + ]}, + }) + result = self.fetch(fixture) + self.assertEqual(len(result), 2) + self.assertTrue(all(value == pd.Timestamp("2024-01-01T00:00:00Z") for value in result.timestamp)) + self.assertEqual(fixture.block_calls, []) + + def test_unusable_metadata_timestamp_values_use_chain_fallback(self): + for value in [None, 123, True, [], "2024-01-01T00:00:00", "not-a-date"]: + with self.subTest(value=value): + fixture = RecordingAlchemy(pages={ + ("base-mainnet", "fromAddress", ""): {"transfers": [ + transfer(metadata={"blockTimestamp": value}), + ]}, + }, blocks={("base-mainnet", "0x64"): "0x7d0"}) + result = self.fetch(fixture) + self.assertEqual(result.iloc[0]["timestamp"], pd.Timestamp(2000, unit="s", tz="UTC")) + self.assertEqual(len(fixture.block_calls), 1) + self.assertEqual(fixture.block_calls[0]["domain"], "base-mainnet") + + def test_conflicting_metadata_for_same_chain_block_uses_one_lookup(self): + fixture = RecordingAlchemy(pages={ + ("base-mainnet", "fromAddress", ""): {"transfers": [ + transfer(transaction_hash="first", metadata={"blockTimestamp": "2024-01-01T00:00:00Z"}), + transfer(transaction_hash="second", metadata={"blockTimestamp": "2025-01-01T00:00:00Z"}), + transfer(transaction_hash="third", metadata={"blockTimestamp": "2024-01-01T00:00:00Z"}), + ]}, + }, blocks={("base-mainnet", "0x64"): "0x7d0"}) + result = self.fetch(fixture) + self.assertTrue(all(value == pd.Timestamp(2000, unit="s", tz="UTC") for value in result.timestamp)) + self.assertEqual(len(fixture.block_calls), 1) + self.assertEqual(fixture.block_calls[0]["domain"], "base-mainnet") + + def test_distinct_missing_blocks_each_looked_up_once(self): + fixture = RecordingAlchemy(pages={ + ("eth-mainnet", "fromAddress", ""): {"transfers": [ + transfer(block="0x64", transaction_hash="a"), + transfer(block="0x64", transaction_hash="b"), + transfer(block="0x65", transaction_hash="c"), + ]}, + }, blocks={("eth-mainnet", "0x64"): "0x3e8", ("eth-mainnet", "0x65"): "0x3f4"}) + self.fetch(fixture) + self.assertEqual(len(fixture.block_calls), 2) + + def test_pagination_schema_signs_and_existing_height_sort_are_preserved(self): + stamp = {"blockTimestamp": "2024-01-01T00:00:00Z"} + fixture = RecordingAlchemy(pages={ + ("eth-mainnet", "fromAddress", ""): {"transfers": [ + transfer(block="0x64", transaction_hash="sent", value=2, metadata=stamp), + ], "pageKey": "next"}, + ("eth-mainnet", "fromAddress", "next"): {"transfers": [ + transfer(block="0x66", transaction_hash="self", recipient=WALLET_ADDRESS, metadata=stamp), + ]}, + ("base-mainnet", "toAddress", ""): {"transfers": [ + transfer(block="0x65", transaction_hash="received", sender=OTHER_ADDRESS, + recipient=WALLET_ADDRESS, value=3, symbol="USDC", metadata=stamp), + ]}, + }) + result = self.fetch(fixture) + self.assertEqual(result.columns.tolist(), ["timestamp", "block_number", "trx_hash", "from_address", + "to_address", "amount", "symbol", "trx_type", "chain_id"]) + self.assertEqual(result.trx_hash.tolist(), ["self", "received", "sent"]) + self.assertEqual(result.block_number.tolist(), [102, 101, 100]) + by_hash = result.set_index("trx_hash") + self.assertEqual(by_hash.loc["sent", "amount"], -2) + self.assertEqual(by_hash.loc["received", "amount"], 3) + self.assertEqual(by_hash.loc["received", "trx_type"], "received") + self.assertEqual(by_hash.loc["received", "chain_id"], 8453) + self.assertEqual(by_hash.loc["self", "trx_type"], "sent") + self.assertEqual(len(fixture.transfer_calls), 5) + + def test_cache_returns_copies_without_network_calls(self): + fixture = RecordingAlchemy(pages={ + ("eth-mainnet", "fromAddress", ""): {"transfers": [ + transfer(metadata={"blockTimestamp": "2024-01-01T00:00:00Z"}), + ]}, + }) + account = make_account() + first = self.fetch(fixture, account) + request_count = len(fixture.calls) + first.loc[0, "amount"] = 999 + second = self.fetch(fixture, account) + self.assertEqual(second.loc[0, "amount"], -1.25) + second.loc[0, "amount"] = 888 + self.assertEqual(self.fetch(fixture, account).loc[0, "amount"], -1.25) + self.assertEqual(len(fixture.calls), request_count) + + def test_refresh_refetches_and_replaces_cache(self): + key = ("eth-mainnet", "fromAddress", "") + fixture = RecordingAlchemy(pages={key: {"transfers": [ + transfer(transaction_hash="first", metadata={"blockTimestamp": "2024-01-01T00:00:00Z"}), + ]}}) + account = make_account() + self.fetch(fixture, account) + fixture.pages[key] = {"transfers": [ + transfer(transaction_hash="second", metadata={"blockTimestamp": "2025-01-01T00:00:00Z"}), + ]} + result = self.fetch(fixture, account, refresh=True) + self.assertEqual(result.trx_hash.tolist(), ["second"]) + self.assertEqual(len(fixture.transfer_calls), 8) + self.assertEqual(self.fetch(fixture, account).trx_hash.tolist(), ["second"]) + + def test_failed_refresh_preserves_previous_cached_snapshot(self): + previous = pd.DataFrame({"existing": ["keep"]}) + account = make_account(transactions=previous.copy()) + fixture = RecordingAlchemy(fail_on={ + ("eth-mainnet", "alchemy_getAssetTransfers", ("fromAddress", "")): + requests.exceptions.ConnectionError("fixture offline"), + }) + with self.assertRaises(requests.exceptions.ConnectionError): + self.fetch(fixture, account, refresh=True) + pd.testing.assert_frame_equal(self.fetch(fixture, account), previous) + + def test_failed_block_lookup_does_not_cache_partial_history(self): + account = make_account() + fixture = RecordingAlchemy(pages={ + ("base-mainnet", "fromAddress", ""): {"transfers": [transfer()]}, + }, fail_on={ + ("base-mainnet", "eth_getBlockByNumber", "0x64"): + requests.exceptions.Timeout("fixture timed out"), + }) + with self.assertRaises(requests.exceptions.Timeout): + self.fetch(fixture, account) + self.assertIsNone(account._Account__transactions) + + def test_empty_history_preserves_existing_empty_output(self): + fixture = RecordingAlchemy() + result = self.fetch(fixture) + self.assertTrue(result.empty) + self.assertEqual(result.columns.tolist(), []) + self.assertEqual(fixture.block_calls, []) + + def test_missing_token_rejected_before_any_request(self): + fixture = RecordingAlchemy() + with self.assertRaises(exceptions.WalletNotConfiguredError): + self.fetch(fixture, make_account(token=None)) + self.assertEqual(fixture.calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/transaction_fixtures.py b/tests/transaction_fixtures.py new file mode 100644 index 0000000..1b94437 --- /dev/null +++ b/tests/transaction_fixtures.py @@ -0,0 +1,103 @@ +"""Recording, credential-free Alchemy responses for real Account method tests.""" + +from copy import deepcopy +from urllib.parse import urlparse + +from status_sdk.account import Account + + +WALLET_ADDRESS = "0x1111111111111111111111111111111111111111" +OTHER_ADDRESS = "0x2222222222222222222222222222222222222222" +CHAINS = {"eth-mainnet": 1, "base-mainnet": 8453} +UNSET = object() + + +def make_account(token="fixture-key", transactions=None): + """Avoid login/backend initialization; exercise the imported production method.""" + account = Account.__new__(Account) + account._Account__alchemy_token = token + account._Account__transactions = transactions + account._Account__info = {"wallet_address": WALLET_ADDRESS} + return account + + +def transfer(block="0x64", transaction_hash="0xabc", sender=WALLET_ADDRESS, + recipient=OTHER_ADDRESS, value=1.25, metadata=UNSET, symbol="ETH"): + result = { + "blockNum": block, + "hash": transaction_hash, + "from": sender, + "to": recipient, + "value": value, + "asset": symbol, + "category": "external", + "rawContract": {"address": None, "decimal": "0x12", "value": "0x1"}, + } + if metadata is not UNSET: + result["metadata"] = metadata + return result + + +class JsonResponse: + def __init__(self, body): + self.body = body + + def json(self): + return deepcopy(self.body) + + +class RecordingAlchemy: + """Callable requests.post replacement, also reusable by a loopback server. + + pages maps (domain, direction, page_key) to result dictionaries; direction is + fromAddress or toAddress, and the initial page key is ''. Unspecified pages + return no transfers. blocks maps (domain, hexadecimal height) to hexadecimal + timestamps or None. fail_on maps (domain, method, selector) to exceptions: + selector is (direction, page_key) for transfers, or the height for blocks. + """ + + def __init__(self, pages=None, blocks=None, fail_on=None): + self.pages = deepcopy(pages or {}) + self.blocks = dict(blocks or {}) + self.fail_on = dict(fail_on or {}) + self.calls = [] + + def __call__(self, url, json, **kwargs): + domain = urlparse(url).hostname.split(".")[0] + method = json["method"] + params = deepcopy(json["params"]) + self.calls.append({"domain": domain, "method": method, "params": params}) + if method == "alchemy_getAssetTransfers": + query = params[0] + direction = "fromAddress" if "fromAddress" in query else "toAddress" + page_key = query.get("pageKey") or "" + selector = (direction, page_key) + key = (domain, direction, page_key) + failure = self.fail_on.get((domain, method, selector)) + if failure is not None: + raise failure + result = deepcopy(self.pages.get(key, {"transfers": []})) + if query.get("withMetadata") is not True: + for item in result.get("transfers", []): + item.pop("metadata", None) + elif method == "eth_getBlockByNumber": + block_number = params[0] + failure = self.fail_on.get((domain, method, block_number)) + if failure is not None: + raise failure + key = (domain, block_number) + if key not in self.blocks: + raise AssertionError(f"Unexpected block lookup: {key}") + timestamp = self.blocks[key] + result = None if timestamp is None else {"timestamp": timestamp} + else: + raise AssertionError(f"Unexpected RPC method: {method}") + return JsonResponse({"jsonrpc": "2.0", "id": json.get("id", 1), "result": result}) + + @property + def transfer_calls(self): + return [call for call in self.calls if call["method"] == "alchemy_getAssetTransfers"] + + @property + def block_calls(self): + return [call for call in self.calls if call["method"] == "eth_getBlockByNumber"]