diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a631146..c3b80ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: make abi git diff --exit-code -- docs/abi.json + - name: Storage rent estimator golden test + run: bash scripts/test_storage_rent_estimator.sh + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/docs/STORAGE_RENT_ESTIMATOR.md b/docs/STORAGE_RENT_ESTIMATOR.md index 63709d0..ddccb06 100644 --- a/docs/STORAGE_RENT_ESTIMATOR.md +++ b/docs/STORAGE_RENT_ESTIMATOR.md @@ -147,6 +147,11 @@ from the current Stellar protocol (write fee, rent rate, TTL). Those prices are Machine-readable copy: [`docs/storage-rent-estimator.inputs.v1.json`](storage-rent-estimator.inputs.v1.json). +CLI: [`scripts/storage_rent_estimator.py`](../scripts/storage_rent_estimator.py) +consumes the v1 JSON and prints entry counts for a given contributor count +(`python3 scripts/storage_rent_estimator.py --users 250`). It warns if +`chunk_size` in the JSON drifts from `CHUNK_SIZE` in `src/storage.rs`. + Logical schema: ```json diff --git a/scripts/README.md b/scripts/README.md index 10dc6d2..f98970a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -15,6 +15,7 @@ out of the repo, and default to **testnet** (never mainnet). | `bulk_verify.sh` / `bulk_revoke.sh` | Batched verify / revoke from a username list, with RPC pacing. | | `simulate_pause.sh` | Exercise the pause / unpause lifecycle. | | `futurenet_smoke_test.sh` | End-to-end smoke test against futurenet. | +| `storage_rent_estimator.py` | Estimate on-chain storage entry counts from `docs/storage-rent-estimator.inputs.v1.json`; warns on `CHUNK_SIZE` drift vs `src/storage.rs` (Issue #290). | --- @@ -103,3 +104,24 @@ Each line of `events-.jsonl`: key described in `docs/DASHBOARD_SYNC.md`. Topic/value XDR decoding is left to the consumer (`stellar xdr decode`, or the SDK of your dashboard's language) — this script is deliberately decode-agnostic so it stays dependency-light. + +--- + +## `storage_rent_estimator.py` — on-chain storage rent estimator + +Turns `docs/storage-rent-estimator.inputs.v1.json` (spec: `docs/STORAGE_RENT_ESTIMATOR.md`) +into concrete entry counts so you do not have to reverse-engineer the docs. + +```bash +# Persistent + instance entry counts for 250 contributors, 3 role holders: +python3 scripts/storage_rent_estimator.py --users 250 --roles 3 --lastact 100 + +python3 scripts/storage_rent_estimator.py --users 1000 --json +``` + +The estimator is a pure function over the versioned JSON: it counts entries +only (XLM conversion needs operator-supplied `network_rent_params`). It also +compares `chunk_size` in the inputs JSON against `CHUNK_SIZE` in +`src/storage.rs` and prints a warning to stderr on any drift. + +Golden-output test: `bash scripts/test_storage_rent_estimator.sh`. diff --git a/scripts/storage_rent_estimator.py b/scripts/storage_rent_estimator.py new file mode 100755 index 0000000..b6386ed --- /dev/null +++ b/scripts/storage_rent_estimator.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Estimate on-chain Soroban storage rent from the versioned estimator inputs. + +Consumes ``docs/storage-rent-estimator.inputs.v1.json`` (see +``docs/STORAGE_RENT_ESTIMATOR.md``) and prints the persistent/instance entry +counts for a given contributor count ``N``. Entry counts only — converting to +XLM requires operator-supplied protocol rent params and is out of scope here. + +The estimator also cross-checks ``CHUNK_SIZE`` in the inputs JSON against the +value pinned in ``src/storage.rs`` and warns on any mismatch. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_INPUTS = REPO_ROOT / "docs" / "storage-rent-estimator.inputs.v1.json" +DEFAULT_STORAGE_RS = REPO_ROOT / "src" / "storage.rs" +SUPPORTED_INPUTS_VERSION = 1 + +_CHUNK_SIZE_RE = re.compile(r"pub\s+const\s+CHUNK_SIZE\s*:\s*u32\s*=\s*(\d+)") + + +def parse_storage_chunk_size(storage_rs: Path) -> int | None: + """Return the ``CHUNK_SIZE`` constant from ``storage.rs``, or ``None``.""" + match = _CHUNK_SIZE_RE.search(storage_rs.read_text()) + return int(match.group(1)) if match else None + + +def estimate(inputs: dict, n: int, roles: int, lastact: int) -> dict: + """Pure function: entry counts for ``n`` contributors. + + ``roles`` is the number of role holders (independent of ``n``); ``lastact`` + is the number of contributors with a cooldown timestamp entry (``0..n``). + """ + chunk_size = int(inputs["on_chain"]["layout"]["chunk_size"]) + instance_keys = inputs["on_chain"]["layout"]["instance_keys"] + + reg_entries = n + chunk_entries = math.ceil(n / chunk_size) if n > 0 else 0 + lastact_entries = min(max(lastact, 0), n) + role_entries = max(roles, 0) + persistent_total = reg_entries + chunk_entries + lastact_entries + role_entries + + return { + "users": n, + "chunk_size": chunk_size, + "reg_entries": reg_entries, + "chunk_entries": chunk_entries, + "lastact_entries": lastact_entries, + "role_entries": role_entries, + "persistent_entry_total": persistent_total, + "instance_entry_count": len(instance_keys), + } + + +def format_report(result: dict, mismatch: tuple[int, int] | None) -> str: + lines = [ + "TrustBridge on-chain storage rent estimate", + "=========================================", + f"contributors (N) : {result['users']}", + f"chunk size : {result['chunk_size']}", + "", + f"reg entries (N) : {result['reg_entries']}", + f"index chunks ceil(N/CS) : {result['chunk_entries']}", + f"lastact entries (0..N) : {result['lastact_entries']}", + f"role entries (R) : {result['role_entries']}", + f"persistent entry total : {result['persistent_entry_total']}", + f"instance entries (fixed) : {result['instance_entry_count']}", + "", + "XLM conversion needs operator-supplied network_rent_params; not computed here.", + ] + if mismatch is not None: + json_cs, code_cs = mismatch + lines.append("") + lines.append( + f"WARNING: chunk_size in inputs JSON ({json_cs}) != CHUNK_SIZE in " + f"src/storage.rs ({code_cs}); regenerate the inputs JSON." + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--users", "-n", type=int, required=True, help="live contributor count N") + parser.add_argument("--roles", "-r", type=int, default=0, help="role holders R (default 0)") + parser.add_argument( + "--lastact", type=int, default=0, help="contributors with a lastact entry, 0..N (default 0)" + ) + parser.add_argument("--inputs", type=Path, default=DEFAULT_INPUTS, help="estimator inputs JSON") + parser.add_argument( + "--storage-rs", type=Path, default=DEFAULT_STORAGE_RS, help="path to src/storage.rs" + ) + parser.add_argument("--json", action="store_true", help="emit JSON instead of a text report") + args = parser.parse_args() + + if args.users < 0: + parser.error("--users must be >= 0") + + inputs = json.loads(args.inputs.read_text()) + version = inputs.get("estimator_inputs_version") + if version != SUPPORTED_INPUTS_VERSION: + parser.error( + f"unsupported estimator_inputs_version {version!r}; expected {SUPPORTED_INPUTS_VERSION}" + ) + + result = estimate(inputs, args.users, args.roles, args.lastact) + + mismatch = None + code_chunk_size = parse_storage_chunk_size(args.storage_rs) if args.storage_rs.exists() else None + if code_chunk_size is not None and code_chunk_size != result["chunk_size"]: + mismatch = (result["chunk_size"], code_chunk_size) + + if args.json: + payload = dict(result) + payload["chunk_size_matches_storage_rs"] = mismatch is None + if mismatch is not None: + payload["storage_rs_chunk_size"] = mismatch[1] + print(json.dumps(payload, indent=2)) + else: + print(format_report(result, mismatch)) + + if mismatch is not None: + print( + f"WARNING: chunk_size mismatch (inputs={mismatch[0]}, storage.rs={mismatch[1]})", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_storage_rent_estimator.sh b/scripts/test_storage_rent_estimator.sh new file mode 100755 index 0000000..af8668f --- /dev/null +++ b/scripts/test_storage_rent_estimator.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Golden-output test for scripts/storage_rent_estimator.py (Issue #290). +# +# Runs the estimator with a fixed input and diffs stdout against the checked-in +# golden fixture. Also asserts the chunk-size mismatch warning fires when the +# inputs JSON disagrees with src/storage.rs. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +GOLDEN="scripts/testdata/storage-rent-estimator.golden.txt" + +actual="$(python3 scripts/storage_rent_estimator.py --users 250 --roles 3 --lastact 100)" +if ! diff -u "$GOLDEN" <(printf '%s\n' "$actual"); then + echo "FAIL: estimator output does not match $GOLDEN" >&2 + exit 1 +fi + +# Mismatch detection: feed a storage.rs stand-in with a different CHUNK_SIZE. +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +printf 'pub const CHUNK_SIZE: u32 = 99;\n' > "$tmp" +if python3 scripts/storage_rent_estimator.py --users 10 --storage-rs "$tmp" 2>&1 >/dev/null \ + | grep -q "chunk_size mismatch"; then + : +else + echo "FAIL: expected a chunk_size mismatch warning" >&2 + exit 1 +fi + +echo "PASS: storage_rent_estimator golden output and mismatch warning" diff --git a/scripts/testdata/storage-rent-estimator.golden.txt b/scripts/testdata/storage-rent-estimator.golden.txt new file mode 100644 index 0000000..d88bc57 --- /dev/null +++ b/scripts/testdata/storage-rent-estimator.golden.txt @@ -0,0 +1,13 @@ +TrustBridge on-chain storage rent estimate +========================================= +contributors (N) : 250 +chunk size : 50 + +reg entries (N) : 250 +index chunks ceil(N/CS) : 5 +lastact entries (0..N) : 100 +role entries (R) : 3 +persistent entry total : 358 +instance entries (fixed) : 9 + +XLM conversion needs operator-supplied network_rent_params; not computed here.