Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions benchmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Credential-free benchmarks for reproducible SDK behavior."""
279 changes: 279 additions & 0 deletions benchmarks/transaction_history.py
Original file line number Diff line number Diff line change
@@ -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())
24 changes: 12 additions & 12 deletions docs/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading