Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/STORAGE_RENT_ESTIMATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

---

Expand Down Expand Up @@ -103,3 +104,24 @@ Each line of `events-<network>.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`.
138 changes: 138 additions & 0 deletions scripts/storage_rent_estimator.py
Original file line number Diff line number Diff line change
@@ -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())
32 changes: 32 additions & 0 deletions scripts/test_storage_rent_estimator.sh
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 13 additions & 0 deletions scripts/testdata/storage-rent-estimator.golden.txt
Original file line number Diff line number Diff line change
@@ -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.
Loading