From 8314002b7bd510927940527fe08f15987186a486 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 21 May 2026 09:30:41 +0100 Subject: [PATCH 1/2] feat: branch-per-major release model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors genlayer-js#172 — branches per major (v0.18 today), no main, releases via scripts/release.sh + Claude skill, publish.yml only fires on tag push. What changes: - publish.yml: trigger `push: main` → `push: tags v*`. The workflow no longer runs python-semantic-release in CI; it sanity-checks tag == pyproject.toml version, builds, publishes to PyPI, cuts the GitHub Release. - scripts/release.sh: new release entry point. Refuses minor/major bumps without --allow-major because we're on 0.x (semver-zero: minor is the breaking-change boundary). Uses python-semantic-release with an explicit version + --no-push --no-vcs-release so it just bumps, changelogs, commits, and tags locally; the script pushes branch + tag explicitly. - .claude/skills/release/SKILL.md: documents the flow for Claude. - .github/e2e-track: main → v0.5 (runner's current stable matrix track). - CONTRIBUTING.md: branch model + semver-zero note + release pointer. Follow-up (separate PRs): - Switch default branch to v0.18, delete origin/main. - Same shape for genlayer-cli, genlayer-testing-suite, genlayer-explorer. - Runner matrix v0.5.yaml: genlayer-py pin switches from v0.18.0 tag to v0.18 branch. --- .claude/skills/release/SKILL.md | 94 +++++++++++++++++ .github/e2e-track | 1 + .github/workflows/publish.yml | 131 +++++++++--------------- CONTRIBUTING.md | 22 ++++ scripts/release.sh | 174 ++++++++++++++++++++++++++++++++ 5 files changed, 337 insertions(+), 85 deletions(-) create mode 100644 .claude/skills/release/SKILL.md create mode 100644 .github/e2e-track create mode 100755 scripts/release.sh diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..7221bc0 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,94 @@ +--- +name: release +description: Cut a release of genlayer-py. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. Use when a human asks "release v0.18.x" or "ship a new version". +--- + +# Release skill — genlayer-py + +This repo follows a branch-per-major release model. There is no auto-bump on push. A release happens when a human (or you on their behalf) runs `scripts/release.sh` on the target stable branch. + +## When to use this skill + +User asks anything like: +- "release v0.18.1" +- "ship a patch" +- "tag the latest fix as a release" + +If they ask "publish to PyPI directly" — refuse and point at this flow. The repo doesn't have an unprotected PyPI push path; the tag is the only release entry point. + +## What this repo's release model expects + +- Branches are named after the major they ship: `v0.18` (current stable). When `v0.19` opens, the previous `v0.18` stays read-only for back-ports. +- Tags live within those branches: `v0.18.1`, `v0.18.2`, ... +- **Semver-zero rule**: this package is still on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` IS a major bump. `scripts/release.sh` refuses both `minor` and `major` keywords without `--allow-major` while we're on 0.x. +- A major (= minor on 0.x) bump means cutting a new branch (`v0.19`) — not tagging on top of the current one. +- `CHANGELOG.md` is updated in the release commit (python-semantic-release with explicit version). +- `publish.yml` fires on the tag push and does the PyPI publish + GitHub Release. + +## Steps + +1. **Confirm intent with the user.** + - Which version? If unspecified, ask whether it's patch or explicit. + - If they say "minor" or "major" while we're on 0.x, surface that this means cutting a new branch — confirm before proceeding. + +2. **Switch to the target branch + sync.** + ```bash + git checkout v0.18 + git pull --ff-only origin v0.18 + ``` + If the working tree isn't clean, stop and surface what's there. + +3. **Verify the head is shippable.** + - Latest CI green: + ```bash + gh run list --branch v0.18 --commit "$(git rev-parse HEAD)" --limit 1 + ``` + - Inspect commits since the previous tag for surprises: + ```bash + git log "$(git describe --tags --abbrev=0)..HEAD" --oneline + ``` + +4. **Run the release script.** + ```bash + scripts/release.sh # or patch + ``` + It bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): vX.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. + +5. **Watch the publish workflow.** + ```bash + gh run watch + ``` + If `publish.yml` fails (typical: tag/pyproject mismatch, expired `PYPI_API_TOKEN`, build failure), report verbatim and stop. Don't retry blindly. + +6. **Confirm on PyPI.** + ```bash + pip index versions genlayer-py + ``` + The latest version should match. Report back with the version and the GitHub Release URL. + +## Things to refuse + +- **Minor or major bump on 0.x without `--allow-major`**. Those are major bumps in semver-zero and belong on a new branch. +- **Releasing from `main`** — `main` is retired. +- **Hand-editing `pyproject.toml` to bump the version** — the script keeps pyproject, the CHANGELOG entry, the commit message, and the tag in lockstep. +- **Publishing a tag where `publish.yml` failed** — fix the underlying issue, re-cut the release (delete the bad tag locally and on origin, re-run the script). + +## Roll-back + +If a release shipped but is broken: + +1. **Don't yank from PyPI** unless someone with elevated permissions has assessed the impact — PyPI yank is reversible but signals "skip this" to installers and you'll want a follow-up patch up first. +2. **Ship a follow-up patch** via the same flow (`scripts/release.sh patch`). +3. After the fixed version is live, optionally yank the bad version: + ```bash + pip install pkginfo twine + # use pypi.org web UI to yank — there's no CLI in current PyPI flow + ``` + +## Why no auto-bump? + +Previously `push: main` triggered `python-semantic-release`, which would auto-bump and tag whenever a `feat:`/`fix:` commit landed. Two failure modes that fix-on-merge can't address: +- Conflated decisions — "merge this PR" silently meant "ship to PyPI". +- Major bumps that slip through (`BREAKING CHANGE` in a PR body produces a 0.X → 0.X+1 bump while on 0.x, which is a major). + +Manual + scripted puts a checkpoint between the two without losing the bump-tag automation. diff --git a/.github/e2e-track b/.github/e2e-track new file mode 100644 index 0000000..83b4ac5 --- /dev/null +++ b/.github/e2e-track @@ -0,0 +1 @@ +v0.5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 48b8dbd..c33afa3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,111 +1,56 @@ -name: Continuous Delivery - +name: Publish Package to PyPI + +# Tag-driven publish. The release is cut by a human (or Claude via the +# release skill) running scripts/release.sh on the target stable branch +# — that script bumps pyproject.toml, updates CHANGELOG.md, commits, +# tags vX.Y.Z, and pushes both the branch commit and the tag. This +# workflow fires on the tag push, runs tests, sanity-checks the tag +# matches pyproject.toml, builds, and publishes to PyPI. It never +# bumps or tags by itself. on: workflow_dispatch: push: - branches: - - main + tags: + - "v*" jobs: run-tests: name: Run Tests uses: ./.github/workflows/tests.yml - release-and-upload: - name: Release and Upload Artifacts - runs-on: ubuntu-latest + publish-to-pypi: + name: Publish Package to PyPI needs: run-tests - environment: Publish - - concurrency: - group: ${{ github.workflow }}-release-${{ github.ref_name }} - cancel-in-progress: false - - permissions: - contents: write - - outputs: - released: ${{ steps.release.outputs.released }} - commit_sha: ${{ steps.get-commit.outputs.commit_sha }} + runs-on: ubuntu-latest steps: - - name: Setup | Get CI Bot Token - uses: actions/create-github-app-token@v3 - id: ci_bot_token - with: - client-id: ${{ vars.PUBLISH_CI_APP_CLIENT_ID }} - private-key: ${{ secrets.PUBLISH_CI_APP_KEY }} - - - name: Setup | Checkout Repository + - name: Checkout tag uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - token: ${{ steps.ci_bot_token.outputs.token }} - - - name: Check | Verify Upstream Unchanged - shell: bash - run: | - chmod +x scripts/verify-upstream.sh - ./scripts/verify-upstream.sh ${{ github.sha }} - - name: Setup | Initialize Git User - run: | - git config --global user.email "github-actions[bot]@genlayerlabs.com" - git config --global user.name "github-actions[bot]" - - - name: Setup | Install uv + - name: Install uv uses: astral-sh/setup-uv@v5 - - name: Setup | Install Python + - name: Install Python run: uv python install 3.12 - - name: Action | Semantic Version Release - id: release - env: - GH_TOKEN: ${{ steps.ci_bot_token.outputs.token }} + - name: Verify tag matches pyproject.toml version run: | - chmod +x scripts/semantic-version-release.sh - ./scripts/semantic-version-release.sh releaserc.toml - - - name: Get | Current Commit SHA - id: get-commit - run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag ($TAG_VERSION) and pyproject.toml ($PKG_VERSION) disagree — refusing to publish." >&2 + echo "Re-cut the release via scripts/release.sh so the tag and the committed version match." >&2 + exit 1 + fi + echo "Tag $GITHUB_REF_NAME matches pyproject.toml $PKG_VERSION." - - name: Build | Clean Previous Builds - if: steps.release.outputs.released == 'true' - run: | - rm -rf -- dist build *.egg-info + - name: Clean previous builds + run: rm -rf -- dist build *.egg-info - - name: Build | Create Distribution Package - if: steps.release.outputs.released == 'true' + - name: Build distribution run: uv build - - name: Upload | Distribution Artifacts - if: steps.release.outputs.released == 'true' - uses: actions/upload-artifact@v4 - with: - name: distribution-artifacts - path: dist - if-no-files-found: error - - publish-to-pypi: - name: Publish Package to PyPI - needs: release-and-upload - runs-on: ubuntu-latest - if: ${{ needs.release-and-upload.outputs.released == 'true' }} - - steps: - - name: Setup | Install uv - uses: astral-sh/setup-uv@v5 - - - name: Download | Distribution Artifacts - uses: actions/download-artifact@v4 - with: - name: distribution-artifacts - path: dist - - - name: Publish | Upload to PyPI + - name: Publish to PyPI run: | if [ -z "${{ secrets.PYPI_API_TOKEN }}" ]; then echo "Missing PyPI API token"; exit 1; @@ -113,3 +58,19 @@ jobs: uv publish dist/* env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NOTES="$(awk -v ver="$GITHUB_REF_NAME" ' + $0 ~ "^## \\[?" substr(ver, 2) {capture=1; next} + capture && /^## / {exit} + capture {print} + ' CHANGELOG.md)" + if [ -z "$NOTES" ]; then + NOTES="Release $GITHUB_REF_NAME" + fi + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes "$NOTES" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cbeab2..17b6afe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,28 @@ Have ideas for new features or use cases? We're eager to hear them! But first: - Ensure you have the SDK installed to explore existing use cases. - After familiarizing yourself with the SDK, contribute your unique use case and share your ideas in our [Discord channel](https://discord.gg/8Jm4v89VAu). +## Branch model + +This repo uses a branch-per-major release model. There is no `main`. + +- **`v0.18`** — current stable major (semver-zero, so 0.18 IS the major; 0.19 would be a major bump that gets its own branch). PRs for bug fixes / non-breaking features target this branch. +- **`v-dev`** — when next-major (i.e. next-minor on 0.x) work is in progress, this branch is open for breaking changes. PRs introducing them target this branch, not `v0.18`. +- **Older majors** stay on the repo for back-ports and security patches. Default branch on github.com is whichever major is current stable. + +When you fork or clone, the default branch is `v0.18` today. If you have a `main` branch from a previous checkout, delete it locally: + +```sh +git checkout v0.18 +git branch -D main +git remote prune origin +``` + +## Releases + +Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. See `.claude/skills/release/SKILL.md` for the full flow. + +**Semver-zero rule**: this package is on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` is a major bump and needs a new branch — the script refuses `minor`/`major` keywords without `--allow-major`. + ### Bug fixing and Feature development #### 1. Set yourself up to start coding diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..becd986 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Cut a release on the current stable branch. +# +# Bumps pyproject.toml, updates CHANGELOG.md via python-semantic-release, +# commits, tags vX.Y.Z, and pushes both the branch commit and the tag. +# publish.yml takes over from the tag push (build → PyPI publish → +# GitHub Release). +# +# Releases are deliberate. There is no auto-bump on push; only this +# script is supposed to create release tags. Run from the major branch +# you want to ship a release on (e.g. v0.18 for v0.18.x). +# +# Usage: +# scripts/release.sh # explicit semver — recommended +# scripts/release.sh patch # 0.18.0 → 0.18.1 +# scripts/release.sh minor # 0.18.0 → 0.19.0 — refused unless --allow-major (see below) +# scripts/release.sh major # 0.18.0 → 1.0.0 — refused unless --allow-major +# scripts/release.sh --allow-major +# +# Semver-zero rule: while the major is 0, the MINOR is the breaking- +# change boundary (per semver). 0.18 → 0.19 IS a major bump. The script +# refuses both `minor` and `major` keywords without --allow-major while +# the current major is 0. Patches stay automatic-friendly. +# +# Pre-flight (each check refuses to proceed on failure): +# - On a v[.] branch (refuses on main / feature branches) +# - Working tree clean +# - Local HEAD matches origin/ +# - Latest CI run on HEAD is green + +set -euo pipefail + +ALLOW_MAJOR=0 +if [ "${1:-}" = "--allow-major" ]; then + ALLOW_MAJOR=1 + shift +fi + +VERSION_ARG="${1:-}" +if [ -z "$VERSION_ARG" ]; then + echo "Usage: $0 [--allow-major] |patch|minor|major" >&2 + exit 2 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +branch="$(git rev-parse --abbrev-ref HEAD)" +if ! [[ "$branch" =~ ^v[0-9]+(\.[0-9]+)?(-dev)?$ ]]; then + cat >&2 <&2 + exit 1 +fi + +git fetch --tags origin "$branch" +local_sha="$(git rev-parse HEAD)" +remote_sha="$(git rev-parse "origin/$branch")" +if [ "$local_sha" != "$remote_sha" ]; then + cat >&2 </dev/null 2>&1; then + status="$(gh run list --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" + case "$status" in + success) ;; + "" ) + echo "Warning: no CI run found for $local_sha on $branch. Continuing anyway." >&2 + ;; + *) + echo "Latest CI on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 + exit 1 + ;; + esac +fi + +current_version="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" + +# Resolve to a concrete X.Y.Z so the major-bump guard can compare. +case "$VERSION_ARG" in + major|minor|patch) + next_version="$(python3 - "$current_version" "$VERSION_ARG" <<'PY' +import sys +cur = sys.argv[1].split(".") +kind = sys.argv[2] +major, minor, patch = int(cur[0]), int(cur[1]), int(cur[2]) +if kind == "major": + print(f"{major+1}.0.0") +elif kind == "minor": + print(f"{major}.{minor+1}.0") +elif kind == "patch": + print(f"{major}.{minor}.{patch+1}") +PY +)" + ;; + *) + next_version="$VERSION_ARG" + ;; +esac + +if ! [[ "$next_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Not a valid semver: $next_version" >&2 + exit 2 +fi + +cur_major="${current_version%%.*}" +next_major="${next_version%%.*}" +cur_minor="$(echo "$current_version" | cut -d. -f2)" +next_minor="$(echo "$next_version" | cut -d. -f2)" + +# Semver-zero: while major == 0, MINOR bumps are major bumps. +if [ "$cur_major" = "0" ]; then + if [ "$next_major" != "0" ] || [ "$next_minor" != "$cur_minor" ]; then + if [ "$ALLOW_MAJOR" -ne 1 ]; then + cat >&2 <&2 < Date: Tue, 2 Jun 2026 03:49:53 +0100 Subject: [PATCH 2/2] feat: add fee-aware transaction helpers --- README.md | 161 ++ genlayer_py/client/genlayer_client.py | 112 ++ .../consensus/consensus_main/decoder.py | 42 +- .../consensus/consensus_main/encoder.py | 28 + genlayer_py/contracts/actions.py | 553 ++++++- genlayer_py/transactions/__init__.py | 33 + genlayer_py/transactions/fees.py | 1301 +++++++++++++++++ tests/unit/consensus/test_consensus_main.py | 46 + tests/unit/contracts/test_contract_actions.py | 855 +++++++++++ uv.lock | 2 +- 10 files changed, 3114 insertions(+), 19 deletions(-) create mode 100644 genlayer_py/transactions/fees.py diff --git a/README.md b/README.md index 29c25a4..476569d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,167 @@ receipt = client.wait_for_transaction_receipt( ) ``` +### Fee presets for transactions + +Apps can build a trusted fee preset once they know the transaction shape, then +submit the same preset with the transaction. The user may still override these +values in wallet or app UI before signing. + +```python +estimate = client.estimate_transaction_fees( + { + "leaderTimeunitsAllocation": 100, + "validatorTimeunitsAllocation": 200, + "rotations": [0], + } +) + +tx_hash = client.write_contract( + account=account, + address=contract_address, + function_name="update_storage", + args=["new_storage"], + fees={ + "distribution": estimate["distribution"], + "feeValue": estimate["feeValue"], + }, +) +``` + +If `fees["distribution"]` is provided without `feeValue`, the SDK derives the +fee deposit from FeeManager on network backends, or from `sim_getFeeConfig` on +Studio. Use `messageAllocations` with `estimate_transaction_fees` for +transactions that can emit funded messages. For method-specific message +budgets, derive the same call key GenVM reports in fee accounting: + +```python +from genlayer_py.transactions import ( + MessageType, + derive_external_message_call_key, + derive_internal_message_call_key, + encode_external_message_fee_params, + encode_internal_message_fee_params, +) + +estimate = client.estimate_transaction_fees( + { + "messageAllocations": [ + { + "messageType": MessageType.Internal, + "onAcceptance": True, + "recipient": contract_address, + "callKey": derive_internal_message_call_key("update_storage"), + "budget": 55, + "feeParams": encode_internal_message_fee_params(), + }, + { + "messageType": MessageType.External, + "onAcceptance": False, + "recipient": "0x3333333333333333333333333333333333333333", + "callKey": derive_external_message_call_key("0xaabbccdd"), + "budget": 210_000, + "feeParams": encode_external_message_fee_params( + {"gasLimit": 21_000, "maxGasPrice": 10} + ), + }, + ], + } +) +``` + +For a concrete Studio/localnet write, use the one-call helper. The SDK sends an +initial fee budget to `sim_estimateTransactionFees`; Studio simulates the write +without committing state and returns the authoritative recommended preset. + +```python +recommended = client.estimate_transaction_fees_for_write( + account=account, + address=contract_address, + function_name="update_storage", + args=["new_storage"], +) + +tx_hash = client.write_contract( + account=account, + address=contract_address, + function_name="update_storage", + args=["new_storage"], + fees={ + "distribution": recommended["distribution"], + "messageAllocations": recommended.get("messageAllocations"), + "feeValue": recommended["feeValue"], + }, +) +``` + +For tests or tools that need to inspect the raw simulation, use the explicit +two-step flow. `simulate_write_contract` uses `sim_call`; the returned receipt +includes the fee accounting report produced by GenVM and Studio: + +```python +simulation = client.simulate_write_contract( + account=account, + address=contract_address, + function_name="update_storage", + args=["new_storage"], + fees={ + "distribution": estimate["distribution"], + "feeValue": estimate["feeValue"], + }, +) + +print(simulation["genvm_result"]["fee_accounting"]) +``` + +To reuse a representative Studio simulation as the trusted preset source, pass +the simulation result into `estimate_transaction_fees_from_simulation`: + +```python +estimate = client.estimate_transaction_fees_from_simulation( + { + "simulation": simulation, + } +) + +tx_hash = client.write_contract( + account=account, + address=contract_address, + function_name="update_storage", + args=["new_storage"], + fees={ + "distribution": estimate["distribution"], + "messageAllocations": estimate.get("messageAllocations"), + "feeValue": estimate["feeValue"], + }, +) +``` + +For transactions that are already submitted, use the fee-management helpers: + +```python +client.top_up_fees( + transaction_id=tx_hash, + value=1_100, + distribution={ + "leaderTimeunitsAllocation": 100, + "validatorTimeunitsAllocation": 200, + "rotations": [0], + }, +) + +client.top_up_and_submit_appeal( + transaction_id=tx_hash, + value=1_400, + distribution={ + "appealRounds": 1, + "rotations": [0, 0], + }, +) +``` + +`top_up_fees` returns the backend RPC hash. On network backends this is the EVM +transaction hash; on Studio/localnet it is the target GenLayer transaction id. + ### Checking execution results A transaction can be finalized by consensus but still have a failed execution. Always check `tx_execution_result` before reading contract state: diff --git a/genlayer_py/client/genlayer_client.py b/genlayer_py/client/genlayer_client.py index 2c2e052..aae6275 100644 --- a/genlayer_py/client/genlayer_client.py +++ b/genlayer_py/client/genlayer_client.py @@ -22,6 +22,8 @@ write_contract, deploy_contract, appeal_transaction, + top_up_fees, + top_up_and_submit_appeal, get_round_number, get_round_data, get_last_round_data, @@ -30,6 +32,11 @@ get_contract_schema, get_contract_schema_for_code, simulate_write_contract, + get_current_fee_policy, + estimate_fees_distribution, + estimate_transaction_fees, + estimate_transaction_fees_from_simulation, + estimate_transaction_fees_for_write, ) from genlayer_py.chains.actions import initialize_consensus_smart_contract from genlayer_py.transactions.actions import ( @@ -60,6 +67,12 @@ delegator_min_stake, ) from genlayer_py.config import transaction_config +from genlayer_py.transactions.fees import ( + FeeEstimateOptions, + FeesDistributionInput, + TransactionFeeOptions, + SimulationFeeEstimateOptions, +) class GenLayerClient(Eth): @@ -139,6 +152,8 @@ def write_contract( args: Optional[List[CalldataEncodable]] = None, kwargs: Optional[Dict[str, CalldataEncodable]] = None, sim_config: Optional[SimConfig] = None, + valid_until: Optional[int] = None, + fees: Optional[TransactionFeeOptions] = None, ): """Executes a state-modifying function on a contract through consensus. Returns the transaction hash.""" return write_contract( @@ -152,6 +167,8 @@ def write_contract( args=args, kwargs=kwargs, sim_config=sim_config, + valid_until=valid_until, + fees=fees, ) def simulate_write_contract( @@ -161,6 +178,9 @@ def simulate_write_contract( account: Optional[LocalAccount] = None, args: Optional[List[CalldataEncodable]] = None, kwargs: Optional[Dict[str, CalldataEncodable]] = None, + value: int = 0, + leader_only: bool = False, + fees: Optional[TransactionFeeOptions] = None, sim_config: Optional[SimConfig] = None, transaction_hash_variant: TransactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, ): @@ -172,6 +192,9 @@ def simulate_write_contract( args=args, kwargs=kwargs, account=account, + value=value, + leader_only=leader_only, + fees=fees, sim_config=sim_config, transaction_hash_variant=transaction_hash_variant, ) @@ -185,6 +208,8 @@ def deploy_contract( consensus_max_rotations: Optional[int] = None, leader_only: bool = False, sim_config: Optional[SimConfig] = None, + valid_until: Optional[int] = None, + fees: Optional[TransactionFeeOptions] = None, ): """Deploys a new intelligent contract to GenLayer. Returns the transaction hash.""" return deploy_contract( @@ -196,6 +221,8 @@ def deploy_contract( consensus_max_rotations=consensus_max_rotations, leader_only=leader_only, sim_config=sim_config, + valid_until=valid_until, + fees=fees, ) def get_contract_schema( @@ -218,6 +245,91 @@ def get_contract_schema_for_code( contract_code=contract_code, ) + def get_current_fee_policy(self): + """Returns the active fee price policy used to build user-side caps.""" + return get_current_fee_policy(self=self) + + def estimate_fees_distribution( + self, + options: Optional[FeeEstimateOptions] = None, + ): + """Builds a fee distribution with caps derived from the active fee policy.""" + return estimate_fees_distribution(self=self, options=options) + + def estimate_transaction_fees( + self, + options: Optional[FeeEstimateOptions] = None, + ): + """Builds a complete transaction fees object, including feeValue.""" + return estimate_transaction_fees(self=self, options=options) + + def estimate_transaction_fees_from_simulation( + self, + options: SimulationFeeEstimateOptions, + ): + """Builds a complete transaction fees object from a representative simulation.""" + return estimate_transaction_fees_from_simulation(self=self, options=options) + + def estimate_transaction_fees_for_write( + self, + address: Union[Address, ChecksumAddress], + function_name: str, + account: Optional[LocalAccount] = None, + args: Optional[List[CalldataEncodable]] = None, + kwargs: Optional[Dict[str, CalldataEncodable]] = None, + value: int = 0, + leader_only: bool = False, + options: Optional[FeeEstimateOptions] = None, + sim_config: Optional[SimConfig] = None, + transaction_hash_variant: TransactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, + ): + """Builds a complete transaction fees object for a concrete write call.""" + return estimate_transaction_fees_for_write( + self=self, + address=address, + function_name=function_name, + account=account, + args=args, + kwargs=kwargs, + value=value, + leader_only=leader_only, + options=options, + sim_config=sim_config, + transaction_hash_variant=transaction_hash_variant, + ) + + def top_up_fees( + self, + transaction_id: HexStr, + distribution: FeesDistributionInput, + account: Optional[LocalAccount] = None, + value: int = 0, + ) -> HexStr: + """Deposits additional fee budget for an existing consensus transaction.""" + return top_up_fees( + self=self, + transaction_id=transaction_id, + distribution=distribution, + account=account, + value=value, + ) + + def top_up_and_submit_appeal( + self, + transaction_id: HexStr, + distribution: FeesDistributionInput, + account: Optional[LocalAccount] = None, + value: int = 0, + ) -> HexStr: + """Deposits appeal fee budget and submits an appeal in one consensus call.""" + return top_up_and_submit_appeal( + self=self, + transaction_id=transaction_id, + distribution=distribution, + account=account, + value=value, + ) + # Transaction actions def wait_for_transaction_receipt( self, diff --git a/genlayer_py/consensus/consensus_main/decoder.py b/genlayer_py/consensus/consensus_main/decoder.py index 5adf935..b0fbea3 100644 --- a/genlayer_py/consensus/consensus_main/decoder.py +++ b/genlayer_py/consensus/consensus_main/decoder.py @@ -4,15 +4,31 @@ from eth_abi import decode as abi_decode from genlayer_py.consensus.abi import CONSENSUS_MAIN_ABI from genlayer_py.abi import calldata +from genlayer_py.transactions.fees import ( + ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES, + ADD_TRANSACTION_WITH_FEES_SELECTOR, + decode_fees_distribution_tuple, + decode_message_allocation_tuple, +) def decode_add_transaction_data(encoded_data): w3 = Web3() + selector = encoded_data[2:10] if encoded_data.startswith("0x") else encoded_data[:8] + payload = encoded_data[10:] if encoded_data.startswith("0x") else encoded_data[8:] + + if selector == ADD_TRANSACTION_WITH_FEES_SELECTOR: + abi_decoded = abi_decode( + ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES, + w3.to_bytes(hexstr=payload), + ) + return _format_fee_aware_add_transaction_data(abi_decoded[0]) + consensus_main_contract = w3.eth.contract(abi=CONSENSUS_MAIN_ABI) contract_fn = consensus_main_contract.get_function_by_name("addTransaction") abi_decoded = abi_decode( contract_fn.argument_types, - w3.to_bytes(hexstr=encoded_data[10:]), + w3.to_bytes(hexstr=payload), ) encoded_tx_data_bytes = abi_decoded[4] encoded_tx_data = Web3.to_hex(encoded_tx_data_bytes) @@ -30,6 +46,30 @@ def decode_add_transaction_data(encoded_data): } +def _format_fee_aware_add_transaction_data(params): + encoded_tx_data_bytes = params[8] + encoded_tx_data = Web3.to_hex(encoded_tx_data_bytes) + decoded_tx_data = decode_tx_data(encoded_tx_data_bytes) + return { + "sender_address": params[0], + "recipient_address": params[1], + "num_of_initial_validators": params[2], + "max_rotations": params[3], + "tx_data": { + "encoded": encoded_tx_data, + "decoded": decoded_tx_data, + }, + "valid_until": params[4], + "salt_nonce": params[5], + "user_value": params[6], + "fees_distribution": decode_fees_distribution_tuple(params[7]), + "message_allocations": [ + decode_message_allocation_tuple(allocation) + for allocation in params[9] + ], + } + + def decode_tx_data(encoded_data_bytes: bytes): try: deserialized_data = rlp.decode(encoded_data_bytes) diff --git a/genlayer_py/consensus/consensus_main/encoder.py b/genlayer_py/consensus/consensus_main/encoder.py index d96d341..7332203 100644 --- a/genlayer_py/consensus/consensus_main/encoder.py +++ b/genlayer_py/consensus/consensus_main/encoder.py @@ -1,3 +1,4 @@ +import time from typing import Optional, List, Dict from genlayer_py.types import CalldataEncodable from genlayer_py.abi.transactions import serialize @@ -8,6 +9,11 @@ from eth_typing import HexStr import eth_utils from genlayer_py.consensus.abi import CONSENSUS_MAIN_ABI +from genlayer_py.transactions.fees import ( + TransactionFeeOptions, + encode_fee_aware_add_transaction_data, + normalize_transaction_fees, +) def encode_add_transaction_data( @@ -17,9 +23,31 @@ def encode_add_transaction_data( max_rotations: int, tx_data: HexStr, valid_until: int = 0, + user_value: int = 0, + salt_nonce: int = 0, + fees: Optional[TransactionFeeOptions] = None, + fee_aware: bool = False, ): w3 = Web3() consensus_main_contract = w3.eth.contract(abi=CONSENSUS_MAIN_ABI) + transaction_fees = normalize_transaction_fees(fees) + + if ( + fee_aware + or user_value != 0 + or transaction_fees["requires_fee_aware_transaction"] + ): + return encode_fee_aware_add_transaction_data( + sender_address=sender_address, + recipient_address=recipient_address, + num_of_initial_validators=num_of_initial_validators, + max_rotations=max_rotations, + tx_data=tx_data, + valid_until=valid_until or int(time.time()) + 3600, + salt_nonce=salt_nonce, + user_value=user_value, + transaction_fees=transaction_fees, + ) contract_fn = consensus_main_contract.get_function_by_name("addTransaction") add_transaction_args = [ diff --git a/genlayer_py/contracts/actions.py b/genlayer_py/contracts/actions.py index 7c2f6e4..f2f62fc 100644 --- a/genlayer_py/contracts/actions.py +++ b/genlayer_py/contracts/actions.py @@ -1,4 +1,5 @@ from __future__ import annotations +import time from eth_account.signers.local import LocalAccount import eth_utils from eth_abi import encode as abi_encode @@ -17,6 +18,28 @@ from web3.constants import ADDRESS_ZERO from web3.logs import DISCARD from genlayer_py.contracts.utils import make_calldata_object +from genlayer_py.transactions.fees import ( + FeeEstimateOptions, + FeePolicyQuote, + FeesDistributionInput, + FeesDistribution, + FEES_DISTRIBUTION_ABI_TYPE, + NormalizedTransactionFees, + SimulationFeeEstimateOptions, + TransactionFeeEstimate, + TransactionFeeOptions, + build_estimated_fees_distribution, + build_estimated_fees_options_from_simulation, + calculate_local_round_fees, + create_fees_distribution, + encode_fee_aware_add_transaction_data, + extract_studio_fee_policy, + fees_distribution_to_abi_tuple, + normalize_transaction_fees, + requires_fee_deposit_calculation, + to_uint, + transaction_fee_estimate_from_studio_estimate, +) if TYPE_CHECKING: from genlayer_py.client import GenLayerClient @@ -102,6 +125,8 @@ def write_contract( args: Optional[List[CalldataEncodable]] = None, kwargs: Optional[Dict[str, CalldataEncodable]] = None, sim_config: Optional[SimConfig] = None, + valid_until: Optional[int] = None, + fees: Optional[TransactionFeeOptions] = None, ): if consensus_max_rotations is None: consensus_max_rotations = self.chain.default_consensus_max_rotations @@ -114,18 +139,26 @@ def write_contract( ] sender_account = account if account is not None else self.local_account serialized_data = serialize(data) + transaction_fees = _resolve_transaction_fees( + self=self, + fees=fees, + num_of_initial_validators=self.chain.default_number_of_initial_validators, + ) encoded_data = _encode_add_transaction_data( self=self, sender_account=sender_account, recipient=address, consensus_max_rotations=consensus_max_rotations, data=serialized_data, + valid_until=valid_until, + user_value=value, + transaction_fees=transaction_fees, ) return _send_transaction( self=self, encoded_data=encoded_data, sender_account=sender_account, - value=value, + value=value + (transaction_fees["fee_value"] or 0), sim_config=sim_config, ) @@ -139,6 +172,8 @@ def deploy_contract( consensus_max_rotations: Optional[int] = None, leader_only: bool = False, sim_config: Optional[SimConfig] = None, + valid_until: Optional[int] = None, + fees: Optional[TransactionFeeOptions] = None, ): if consensus_max_rotations is None: consensus_max_rotations = self.chain.default_consensus_max_rotations @@ -150,6 +185,11 @@ def deploy_contract( ] serialized_data = serialize(data) sender_account = account if account is not None else self.local_account + transaction_fees = _resolve_transaction_fees( + self=self, + fees=fees, + num_of_initial_validators=self.chain.default_number_of_initial_validators, + ) encoded_data = _encode_add_transaction_data( self=self, @@ -157,11 +197,15 @@ def deploy_contract( recipient=ADDRESS_ZERO, consensus_max_rotations=consensus_max_rotations, data=serialized_data, + valid_until=valid_until, + user_value=0, + transaction_fees=transaction_fees, ) return _send_transaction( self=self, encoded_data=encoded_data, sender_account=sender_account, + value=transaction_fees["fee_value"] or 0, sim_config=sim_config, ) @@ -185,23 +229,70 @@ def appeal_transaction( encoded_data = _encode_submit_appeal_data(self=self, transaction_id=transaction_id) - transaction = _prepare_transaction( + _send_consensus_call( self=self, - sender=sender_account.address, - recipient=self.chain.consensus_main_contract["address"], - data=encoded_data, + encoded_data=encoded_data, + sender_account=sender_account, value=value, + operation_name="Appeal", ) - signed_transaction = sender_account.sign_transaction(transaction) - serialized_transaction = self.w3.to_hex(signed_transaction.raw_transaction) - tx_hash = self.provider.make_request( - method="eth_sendRawTransaction", params=[serialized_transaction] - )["result"] - tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) - if tx_receipt.status != 1: - raise GenLayerError(f"Appeal reverted: EVM tx {tx_hash}") + return transaction_id + + +def top_up_fees( + self: GenLayerClient, + transaction_id: HexStr, + distribution: FeesDistributionInput, + account: Optional[LocalAccount] = None, + value: int = 0, +) -> HexStr: + """Deposits additional fee budget for an existing consensus transaction. + Returns the backend RPC hash: an EVM transaction hash on network backends, + or the target GenLayer tx id on Studio/localnet. + """ + sender_account = account if account is not None else self.local_account + encoded_data = _encode_fee_management_data( + self=self, + function_name="topUpFees", + transaction_id=transaction_id, + distribution=distribution, + ) + return _send_consensus_call( + self=self, + encoded_data=encoded_data, + sender_account=sender_account, + value=value, + operation_name="Top up fees", + ) + + +def top_up_and_submit_appeal( + self: GenLayerClient, + transaction_id: HexStr, + distribution: FeesDistributionInput, + account: Optional[LocalAccount] = None, + value: int = 0, +) -> HexStr: + """Deposits appeal fee budget and submits an appeal in one consensus call. + + Returns the original GenLayer transaction id, matching appeal_transaction. + """ + sender_account = account if account is not None else self.local_account + encoded_data = _encode_fee_management_data( + self=self, + function_name="topUpAndSubmitAppeal", + transaction_id=transaction_id, + distribution=distribution, + ) + _send_consensus_call( + self=self, + encoded_data=encoded_data, + sender_account=sender_account, + value=value, + operation_name="Top up and submit appeal", + ) return transaction_id @@ -300,9 +391,12 @@ def simulate_write_contract( account: Optional[LocalAccount] = None, args: Optional[List[CalldataEncodable]] = None, kwargs: Optional[Dict[str, CalldataEncodable]] = None, + value: int = 0, + leader_only: bool = False, + fees: Optional[TransactionFeeOptions] = None, sim_config: Optional[SimConfig] = None, transaction_hash_variant: TransactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, -) -> CalldataEncodable: +) -> dict: if self.chain.id != localnet.id: raise GenLayerError("Client is not connected to the localnet") if account is None and self.local_account is None: @@ -312,7 +406,7 @@ def simulate_write_contract( calldata.encode( make_calldata_object(method=function_name, args=args, kwargs=kwargs) ), - b"\x00", + leader_only, ] serialized_data = serialize(data) request_params = { @@ -322,6 +416,11 @@ def simulate_write_contract( "data": serialized_data, "transaction_hash_variant": transaction_hash_variant.value, } + if value > 0: + request_params["value"] = hex(value) + rpc_fees = _transaction_fees_to_rpc(fees) + if rpc_fees is not None: + request_params["fees"] = rpc_fees if sim_config is not None: request_params["sim_config"] = sim_config receipt = self.provider.make_request( @@ -331,6 +430,21 @@ def simulate_write_contract( return receipt +def _transaction_fees_to_rpc( + fees: Optional[TransactionFeeOptions], +) -> Optional[dict]: + if fees is None: + return None + normalized = normalize_transaction_fees(fees) + rpc_fees = { + "distribution": normalized["distribution"], + "messageAllocations": normalized["message_allocations"], + } + if normalized["fee_value"] is not None: + rpc_fees["feeValue"] = normalized["fee_value"] + return rpc_fees + + def _encode_submit_appeal_data( self: GenLayerClient, transaction_id: HexStr, @@ -352,18 +466,386 @@ def _encode_submit_appeal_data( return encoded_data +FEE_MANAGEMENT_ARGUMENT_TYPES = ("bytes32", FEES_DISTRIBUTION_ABI_TYPE) + + +def _encode_fee_management_data( + self: GenLayerClient, + function_name: str, + transaction_id: HexStr, + distribution: FeesDistributionInput, +): + if function_name not in ("topUpFees", "topUpAndSubmitAppeal"): + raise ValueError(f"Unsupported fee management function: {function_name}") + + tx_bytes = _to_bytes32(self, transaction_id) + fees_distribution = create_fees_distribution(distribution) + params = abi_encode( + FEE_MANAGEMENT_ARGUMENT_TYPES, + [ + tx_bytes, + fees_distribution_to_abi_tuple(fees_distribution), + ], + ) + signature = f"{function_name}(bytes32,{FEES_DISTRIBUTION_ABI_TYPE})" + function_selector = eth_utils.keccak(text=signature)[:4].hex() + return "0x" + function_selector + params.hex() + + +FEE_MANAGER_CALCULATE_ROUND_FEES_ABI = [ + { + "type": "function", + "name": "GENPerTimeUnit", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "type": "function", + "name": "storageUnitPrice", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "type": "function", + "name": "quoteGasPrice", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "type": "function", + "name": "messageFeeParamsBudgetFloor", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "type": "function", + "name": "calculateRoundFees", + "stateMutability": "view", + "inputs": [ + { + "name": "_feesDistribution", + "type": "tuple", + "components": [ + {"name": "leaderTimeunitsAllocation", "type": "uint256"}, + {"name": "validatorTimeunitsAllocation", "type": "uint256"}, + {"name": "appealRounds", "type": "uint256"}, + {"name": "executionBudgetPerRound", "type": "uint256"}, + {"name": "executionConsumed", "type": "uint256"}, + {"name": "totalMessageFees", "type": "uint256"}, + {"name": "rotations", "type": "uint256[]"}, + {"name": "maxPriceGenPerTimeUnit", "type": "uint256"}, + {"name": "storageFeeMaxGasPrice", "type": "uint256"}, + {"name": "receiptFeeMaxGasPrice", "type": "uint256"}, + ], + }, + {"name": "_numOfValidators", "type": "uint256"}, + {"name": "round", "type": "uint256"}, + ], + "outputs": [{"name": "totalFeesToPay", "type": "uint256"}], + }, +] + + +def _get_add_transaction_abi_version(abi: Optional[list]) -> str: + if not abi: + return "v5" + + for entry in abi: + if not isinstance(entry, dict): + continue + if entry.get("type") != "function" or entry.get("name") != "addTransaction": + continue + + inputs = entry.get("inputs", []) + if len(inputs) == 1 and inputs[0].get("type") == "tuple": + return "fees" + if len(inputs) >= 6: + return "v6" + return "v5" + + return "v5" + + +def _get_default_valid_until() -> int: + return int(time.time()) + 3600 + + +def get_current_fee_policy(self: GenLayerClient) -> FeePolicyQuote: + if self.chain.fee_manager_contract and self.chain.fee_manager_contract.get("address"): + fee_manager_contract = self.w3.eth.contract( + address=self.w3.to_checksum_address(self.chain.fee_manager_contract["address"]), + abi=FEE_MANAGER_CALCULATE_ROUND_FEES_ABI, + ) + gen_per_time_unit = fee_manager_contract.functions.GENPerTimeUnit().call() + storage_unit_price = fee_manager_contract.functions.storageUnitPrice().call() + receipt_gas_price = fee_manager_contract.functions.quoteGasPrice().call() + execution_budget_floor = ( + fee_manager_contract.functions.messageFeeParamsBudgetFloor().call() + ) + return { + "enabled": ( + gen_per_time_unit > 0 + or storage_unit_price > 0 + or receipt_gas_price > 0 + ), + "genPerTimeUnit": gen_per_time_unit, + "storageUnitPrice": storage_unit_price, + "receiptGasPrice": receipt_gas_price, + "executionBudgetFloor": execution_budget_floor, + } + + try: + response = self.provider.make_request( + method="sim_getFeeConfig", + params=[], + ) + return extract_studio_fee_policy(response["result"]) + except Exception as exc: + raise GenLayerError( + "Fee policy estimation is not supported on this chain " + "(missing fee_manager_contract and sim_getFeeConfig)." + ) from exc + + +def estimate_fees_distribution( + self: GenLayerClient, + options: Optional[FeeEstimateOptions] = None, +) -> FeesDistribution: + policy = get_current_fee_policy(self) + return build_estimated_fees_distribution(options, policy) + + +def estimate_transaction_fees( + self: GenLayerClient, + options: Optional[FeeEstimateOptions] = None, +) -> TransactionFeeEstimate: + policy = get_current_fee_policy(self) + return _estimate_transaction_fees_with_policy(self, options, policy) + + +def _estimate_transaction_fees_with_policy( + self: GenLayerClient, + options: Optional[FeeEstimateOptions], + policy: FeePolicyQuote, +) -> TransactionFeeEstimate: + distribution = build_estimated_fees_distribution(options, policy) + + if self.chain.fee_manager_contract and self.chain.fee_manager_contract.get("address"): + fee_manager_contract = self.w3.eth.contract( + address=self.w3.to_checksum_address(self.chain.fee_manager_contract["address"]), + abi=FEE_MANAGER_CALCULATE_ROUND_FEES_ABI, + ) + round_fees = fee_manager_contract.functions.calculateRoundFees( + fees_distribution_to_abi_tuple(distribution), + self.chain.default_number_of_initial_validators, + 0, + ).call() + fee_value = round_fees + distribution["totalMessageFees"] + else: + fee_value = ( + calculate_local_round_fees( + distribution, + self.chain.default_number_of_initial_validators, + policy, + ) + + distribution["totalMessageFees"] + ) + + estimate: TransactionFeeEstimate = { + "distribution": distribution, + "feeValue": fee_value, + "fee_value": fee_value, + "policy": policy, + } + message_allocations = None + if options: + message_allocations = options.get( + "messageAllocations", + options.get("message_allocations"), + ) + if message_allocations is not None: + estimate["messageAllocations"] = message_allocations + estimate["message_allocations"] = message_allocations + return estimate + + +def estimate_transaction_fees_from_simulation( + self: GenLayerClient, + options: SimulationFeeEstimateOptions, +) -> TransactionFeeEstimate: + policy = get_current_fee_policy(self) + preset = build_estimated_fees_options_from_simulation(options, policy) + estimate = _estimate_transaction_fees_with_policy( + self, + preset["estimateOptions"], + policy, + ) + estimate["observed"] = preset["observed"] + message_allocations = preset.get("messageAllocations") + if message_allocations is not None: + estimate["messageAllocations"] = message_allocations + estimate["message_allocations"] = message_allocations + return estimate + + +def estimate_transaction_fees_for_write( + self: GenLayerClient, + address: Union[Address, ChecksumAddress], + function_name: str, + account: Optional[LocalAccount] = None, + args: Optional[List[CalldataEncodable]] = None, + kwargs: Optional[Dict[str, CalldataEncodable]] = None, + value: int = 0, + leader_only: bool = False, + options: Optional[FeeEstimateOptions] = None, + sim_config: Optional[SimConfig] = None, + transaction_hash_variant: TransactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, +) -> TransactionFeeEstimate: + if self.chain.id != localnet.id: + raise GenLayerError("Target write fee estimation is only supported on localnet") + if account is None and self.local_account is None: + raise GenLayerError("No account provided and no account is connected") + + policy = get_current_fee_policy(self) + initial_estimate = _estimate_transaction_fees_with_policy(self, options, policy) + initial_fees: TransactionFeeOptions = { + "distribution": initial_estimate["distribution"], + "feeValue": initial_estimate["feeValue"], + } + message_allocations = initial_estimate.get("messageAllocations") + if message_allocations is not None: + initial_fees["messageAllocations"] = message_allocations + + sender_address = self.local_account.address if account is None else account.address + data = [ + calldata.encode( + make_calldata_object(method=function_name, args=args, kwargs=kwargs) + ), + leader_only, + ] + request_params = { + "type": "write", + "to": address, + "from": sender_address, + "data": serialize(data), + "transaction_hash_variant": transaction_hash_variant.value, + "fees": _transaction_fees_to_rpc(initial_fees), + } + if value > 0: + request_params["value"] = hex(value) + if sim_config is not None: + request_params["sim_config"] = sim_config + + estimate_result = self.provider.make_request( + method="sim_estimateTransactionFees", + params=[request_params], + )["result"] + authoritative_estimate = transaction_fee_estimate_from_studio_estimate( + estimate_result, + policy, + ) + if authoritative_estimate is not None: + return authoritative_estimate + + simulation = self.provider.make_request( + method="sim_call", + params=[request_params], + )["result"] + simulation_options: SimulationFeeEstimateOptions = dict(options or {}) + simulation_options["simulation"] = simulation + return estimate_transaction_fees_from_simulation( + self, + simulation_options, + ) + + +def _resolve_transaction_fees( + self: GenLayerClient, + fees: Optional[TransactionFeeOptions], + num_of_initial_validators: int, +) -> NormalizedTransactionFees: + transaction_fees = normalize_transaction_fees(fees) + if ( + transaction_fees["fee_value"] is not None + or not requires_fee_deposit_calculation(transaction_fees["distribution"]) + ): + transaction_fees["fee_value"] = transaction_fees["fee_value"] or 0 + return transaction_fees + + if not self.chain.fee_manager_contract or not self.chain.fee_manager_contract.get("address"): + try: + policy = get_current_fee_policy(self) + except GenLayerError as exc: + raise GenLayerError( + "fees.feeValue is required when the chain does not expose a fee_manager_contract." + ) from exc + transaction_fees["fee_value"] = ( + calculate_local_round_fees( + transaction_fees["distribution"], + num_of_initial_validators, + policy, + ) + + transaction_fees["distribution"]["totalMessageFees"] + if policy["enabled"] + else 0 + ) + return transaction_fees + + fee_manager_contract = self.w3.eth.contract( + address=self.w3.to_checksum_address(self.chain.fee_manager_contract["address"]), + abi=FEE_MANAGER_CALCULATE_ROUND_FEES_ABI, + ) + round_fees = fee_manager_contract.functions.calculateRoundFees( + fees_distribution_to_abi_tuple(transaction_fees["distribution"]), + num_of_initial_validators, + 0, + ).call() + transaction_fees["fee_value"] = ( + round_fees + transaction_fees["distribution"]["totalMessageFees"] + ) + return transaction_fees + + def _encode_add_transaction_data( self: GenLayerClient, sender_account, recipient, consensus_max_rotations, data, - valid_until: int = 0, + valid_until: Optional[int] = None, + user_value: int = 0, + transaction_fees: Optional[NormalizedTransactionFees] = None, ): consensus_main_contract = self.w3.eth.contract( abi=self.chain.consensus_main_contract["abi"] ) contract_fn = consensus_main_contract.get_function_by_name("addTransaction") + abi_version = _get_add_transaction_abi_version(self.chain.consensus_main_contract["abi"]) + transaction_fees = transaction_fees or normalize_transaction_fees() + use_fee_aware_transaction = ( + transaction_fees["requires_fee_aware_transaction"] or abi_version == "fees" + ) + normalized_valid_until = to_uint( + valid_until, + "valid_until", + _get_default_valid_until() if use_fee_aware_transaction else 0, + ) + + if use_fee_aware_transaction: + return encode_fee_aware_add_transaction_data( + sender_address=sender_account.address, + recipient_address=recipient, + num_of_initial_validators=self.chain.default_number_of_initial_validators, + max_rotations=consensus_max_rotations, + tx_data=data, + valid_until=normalized_valid_until, + user_value=user_value, + transaction_fees=transaction_fees, + ) add_transaction_args = [ sender_account.address, @@ -373,7 +855,7 @@ def _encode_add_transaction_data( self.w3.to_bytes(hexstr=data), ] if len(contract_fn.argument_types) >= 6: - add_transaction_args.append(valid_until) + add_transaction_args.append(normalized_valid_until) params = abi_encode( contract_fn.argument_types, @@ -423,6 +905,43 @@ def _prepare_transaction( return transaction +def _send_consensus_call( + self: GenLayerClient, + encoded_data: HexStr, + sender_account: Optional[LocalAccount] = None, + value: int = 0, + operation_name: str = "Consensus call", +) -> HexStr: + if sender_account is None: + raise GenLayerError( + "No account set. Configure the client with an account or pass an account to this function." + ) + if self.chain.consensus_main_contract is None: + raise GenLayerError("Consensus main contract not configured.") + + transaction = _prepare_transaction( + self=self, + sender=sender_account.address, + recipient=self.chain.consensus_main_contract["address"], + data=encoded_data, + value=value, + ) + signed_transaction = sender_account.sign_transaction(transaction) + serialized_transaction = self.w3.to_hex(signed_transaction.raw_transaction) + tx_hash = self.provider.make_request( + method="eth_sendRawTransaction", params=[serialized_transaction] + )["result"] + if self.chain.id == localnet.id: + return tx_hash + + tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + + if tx_receipt.status != 1: + raise GenLayerError(f"{operation_name} reverted: EVM tx {tx_hash}") + + return tx_hash + + def _send_transaction( self: GenLayerClient, encoded_data: HexStr, diff --git a/genlayer_py/transactions/__init__.py b/genlayer_py/transactions/__init__.py index e69de29..3674413 100644 --- a/genlayer_py/transactions/__init__.py +++ b/genlayer_py/transactions/__init__.py @@ -0,0 +1,33 @@ +from .fees import ( + CALL_KEY_DEPLOY, + CALL_KEY_UNNAMED, + CALL_KEY_WILDCARD, + MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, + DEFAULT_FEES_DISTRIBUTION, + MessageType, + build_estimated_fees_distribution, + calculate_local_round_fees, + create_fees_distribution, + derive_external_message_call_key, + derive_internal_message_call_key, + encode_external_message_fee_params, + encode_internal_message_fee_params, + extract_studio_fee_policy, +) + +__all__ = [ + "CALL_KEY_DEPLOY", + "CALL_KEY_UNNAMED", + "CALL_KEY_WILDCARD", + "MESSAGE_ALLOCATION_ROOT_PARENT_INDEX", + "DEFAULT_FEES_DISTRIBUTION", + "MessageType", + "build_estimated_fees_distribution", + "calculate_local_round_fees", + "create_fees_distribution", + "derive_external_message_call_key", + "derive_internal_message_call_key", + "encode_external_message_fee_params", + "encode_internal_message_fee_params", + "extract_studio_fee_policy", +] diff --git a/genlayer_py/transactions/fees.py b/genlayer_py/transactions/fees.py new file mode 100644 index 0000000..e05c529 --- /dev/null +++ b/genlayer_py/transactions/fees.py @@ -0,0 +1,1301 @@ +from __future__ import annotations + +from enum import IntEnum +from typing import Any, Optional, TypedDict, Union + +from eth_abi import encode as abi_encode +from eth_typing import HexStr +from eth_utils.crypto import keccak +from web3 import Web3 + + +BigNumberish = Union[int, str] + + +class MessageType(IntEnum): + External = 0 + Internal = 1 + + +class FeesDistributionInput(TypedDict, total=False): + leaderTimeunitsAllocation: BigNumberish + leader_timeunits_allocation: BigNumberish + validatorTimeunitsAllocation: BigNumberish + validator_timeunits_allocation: BigNumberish + appealRounds: BigNumberish + appeal_rounds: BigNumberish + executionBudgetPerRound: BigNumberish + execution_budget_per_round: BigNumberish + executionConsumed: BigNumberish + execution_consumed: BigNumberish + totalMessageFees: BigNumberish + total_message_fees: BigNumberish + rotations: list[BigNumberish] + maxPriceGenPerTimeUnit: BigNumberish + max_price_gen_per_time_unit: BigNumberish + storageFeeMaxGasPrice: BigNumberish + storage_fee_max_gas_price: BigNumberish + receiptFeeMaxGasPrice: BigNumberish + receipt_fee_max_gas_price: BigNumberish + + +class FeesDistribution(TypedDict): + leaderTimeunitsAllocation: int + validatorTimeunitsAllocation: int + appealRounds: int + executionBudgetPerRound: int + executionConsumed: int + totalMessageFees: int + rotations: list[int] + maxPriceGenPerTimeUnit: int + storageFeeMaxGasPrice: int + receiptFeeMaxGasPrice: int + + +class InternalMessageFeeParamsInput(TypedDict, total=False): + leaderTimeunitsAllocation: BigNumberish + leader_timeunits_allocation: BigNumberish + validatorTimeunitsAllocation: BigNumberish + validator_timeunits_allocation: BigNumberish + appealRounds: BigNumberish + appeal_rounds: BigNumberish + executionBudgetPerRound: BigNumberish + execution_budget_per_round: BigNumberish + rotations: list[BigNumberish] + + +class ExternalMessageFeeParamsInput(TypedDict, total=False): + gasLimit: BigNumberish + gas_limit: BigNumberish + maxGasPrice: BigNumberish + max_gas_price: BigNumberish + + +class MessageFeeAllocationInput(TypedDict, total=False): + messageType: Union[MessageType, int, str] + message_type: Union[MessageType, int, str] + onAcceptance: bool + on_acceptance: bool + parentIndex: BigNumberish + parent_index: BigNumberish + recipient: str + callKey: Union[str, bytes] + call_key: Union[str, bytes] + budget: BigNumberish + feeParams: Union[str, bytes] + fee_params: Union[str, bytes] + + +class MessageFeeAllocationNode(TypedDict): + messageType: int + onAcceptance: bool + parentIndex: int + recipient: str + callKey: bytes + budget: int + feeParams: bytes + + +class TransactionFeeOptions(TypedDict, total=False): + distribution: FeesDistributionInput + messageAllocations: list[MessageFeeAllocationInput] + message_allocations: list[MessageFeeAllocationInput] + feeValue: BigNumberish + fee_value: BigNumberish + + +class FeePolicyQuote(TypedDict): + enabled: bool + genPerTimeUnit: int + storageUnitPrice: int + receiptGasPrice: int + executionBudgetFloor: int + + +class FeeEstimateOptions(FeesDistributionInput, total=False): + priceCapHeadroomBps: BigNumberish + price_cap_headroom_bps: BigNumberish + messageAllocations: list[MessageFeeAllocationInput] + message_allocations: list[MessageFeeAllocationInput] + executionHeadroomBps: BigNumberish + execution_headroom_bps: BigNumberish + messageHeadroomBps: BigNumberish + message_headroom_bps: BigNumberish + + +class TransactionFeeEstimate(TypedDict, total=False): + distribution: FeesDistribution + messageAllocations: list[MessageFeeAllocationInput] + message_allocations: list[MessageFeeAllocationInput] + feeValue: int + fee_value: int + policy: FeePolicyQuote + observed: dict[str, int] + + +class SimulationFeeEstimateOptions(FeeEstimateOptions, total=False): + simulation: Any + + +class SimulationFeePreset(TypedDict, total=False): + estimateOptions: FeeEstimateOptions + estimate_options: FeeEstimateOptions + observed: dict[str, int] + messageAllocations: list[MessageFeeAllocationInput] + message_allocations: list[MessageFeeAllocationInput] + + +class NormalizedTransactionFees(TypedDict): + distribution: FeesDistribution + message_allocations: list[MessageFeeAllocationNode] + fee_value: Optional[int] + requires_fee_aware_transaction: bool + + +MESSAGE_ALLOCATION_ROOT_PARENT_INDEX = (1 << 256) - 1 +CALL_KEY_WILDCARD = b"\x00" * 32 +CALL_KEY_UNNAMED = HexStr("0x" + ("0" * 64)) +CALL_KEY_DEPLOY = CALL_KEY_UNNAMED + +FEES_DISTRIBUTION_ABI_TYPE = ( + "(uint256,uint256,uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)" +) +MESSAGE_FEE_ALLOCATION_ABI_TYPE = "(uint8,bool,uint256,address,bytes32,uint256,bytes)" +ADD_TRANSACTION_PARAMS_ABI_TYPE = ( + f"(address,address,uint256,uint256,uint256,uint256,uint256," + f"{FEES_DISTRIBUTION_ABI_TYPE},bytes,{MESSAGE_FEE_ALLOCATION_ABI_TYPE}[])" +) +ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES = (ADD_TRANSACTION_PARAMS_ABI_TYPE,) +ADD_TRANSACTION_WITH_FEES_SIGNATURE = f"addTransaction({ADD_TRANSACTION_PARAMS_ABI_TYPE})" +DEPLOY_SALTED_WITH_FEES_SIGNATURE = f"deploySalted({ADD_TRANSACTION_PARAMS_ABI_TYPE})" +ADD_TRANSACTION_WITH_FEES_SELECTOR = keccak( + text=ADD_TRANSACTION_WITH_FEES_SIGNATURE +)[:4].hex() +DEPLOY_SALTED_WITH_FEES_SELECTOR = keccak( + text=DEPLOY_SALTED_WITH_FEES_SIGNATURE +)[:4].hex() + + +DEFAULT_FEES_DISTRIBUTION: FeesDistribution = { + "leaderTimeunitsAllocation": 0, + "validatorTimeunitsAllocation": 0, + "appealRounds": 0, + "executionBudgetPerRound": 0, + "executionConsumed": 0, + "totalMessageFees": 0, + "rotations": [0], + "maxPriceGenPerTimeUnit": 0, + "storageFeeMaxGasPrice": 0, + "receiptFeeMaxGasPrice": 0, +} + +DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000 +DEFAULT_LEADER_TIMEUNITS_ALLOCATION = 100 +DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200 +DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 500_000 +MIN_RECEIPT_BYTES = 512 +DEFAULT_RECEIPT_SLOTS_CHANGED = 7 +DEFAULT_INTRINSIC_GAS = 21_000 +DEFAULT_BOOTLOADER_OVERHEAD = 60_000 +DEFAULT_FIXED_PROPOSE_RECEIPT_GAS = 210_000 +DEFAULT_GAS_PER_CHANGED_SLOT = 1_000 +DEFAULT_CALLDATA_GAS_PER_BYTE = 16 +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + +VALIDATORS_PER_ROUND = [ + 5, + 7, + 11, + 13, + 23, + 25, + 47, + 49, + 95, + 97, + 191, + 193, + 383, + 385, + 767, + 769, + 1535, + 1537, +] + + +def _get(mapping: Optional[dict], *names: str, default: Any = None) -> Any: + if not mapping: + return default + for name in names: + if name in mapping: + return mapping[name] + return default + + +def _dict_or_none(value: Any) -> Optional[dict]: + return value if isinstance(value, dict) else None + + +def _hex_from_unknown(value: Any, field_name: str, default: str = "0x") -> str: + if value is None: + return default + if isinstance(value, bytes): + return "0x" + value.hex() + if isinstance(value, bytearray): + return "0x" + bytes(value).hex() + if isinstance(value, str): + return value if value.startswith("0x") else "0x" + value + raise ValueError(f"{field_name} must be a 0x-prefixed hex string or bytes.") + + +def to_uint(value: Optional[BigNumberish], field_name: str, fallback: int = 0) -> int: + if value is None: + return fallback + if isinstance(value, bool): + raise ValueError(f"{field_name} must be an integer.") + normalized = int(value) + if normalized < 0: + raise ValueError(f"{field_name} must be greater than or equal to zero.") + return normalized + + +def _normalize_hex_bytes(value: Union[str, bytes, bytearray], field_name: str) -> bytes: + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if not isinstance(value, str) or not value.startswith("0x"): + raise ValueError(f"{field_name} must be a 0x-prefixed hex string or bytes.") + return Web3.to_bytes(hexstr=value) + + +def _normalize_bytes32(value: Union[str, bytes, bytearray], field_name: str) -> bytes: + normalized = _normalize_hex_bytes(value, field_name) + if len(normalized) != 32: + raise ValueError(f"{field_name} must be exactly 32 bytes.") + return normalized + + +def _bytes_to_padded_call_key(value: bytes) -> HexStr: + if len(value) > 32: + raise ValueError("call key source bytes must be 32 bytes or fewer.") + return HexStr("0x" + value.hex().ljust(64, "0")) + + +def derive_internal_message_call_key(method_name: str = "") -> HexStr: + method_bytes = method_name.encode("utf-8") + if len(method_bytes) < 32: + return _bytes_to_padded_call_key(method_bytes) + + hashed = bytearray(keccak(method_bytes)) + hashed[-1] |= 1 + return HexStr("0x" + bytes(hashed).hex()) + + +def derive_external_message_call_key( + selector_or_calldata: Union[str, bytes, bytearray, None] = "0x", +) -> HexStr: + calldata = _normalize_hex_bytes( + selector_or_calldata if selector_or_calldata is not None else "0x", + "selector_or_calldata", + ) + if len(calldata) < 4: + return CALL_KEY_UNNAMED + return _bytes_to_padded_call_key(calldata[:4]) + + +def _normalize_message_type(value: Union[MessageType, int, str]) -> int: + if isinstance(value, MessageType): + return int(value) + if isinstance(value, str): + lowered = value.lower() + if lowered == "external": + return int(MessageType.External) + if lowered == "internal": + return int(MessageType.Internal) + normalized = int(value) + if normalized not in (int(MessageType.External), int(MessageType.Internal)): + raise ValueError("messageType must be External/Internal or 0/1.") + return normalized + + +def _normalize_rotations( + rotations: Optional[list[BigNumberish]], + appeal_rounds: int, + field_name: str, +) -> list[int]: + expected_length = appeal_rounds + 1 + if rotations is None: + return [0 for _ in range(expected_length)] + normalized = [ + to_uint(rotation, f"{field_name}[{index}]") + for index, rotation in enumerate(rotations) + ] + if len(normalized) != expected_length: + raise ValueError(f"{field_name} must contain appealRounds + 1 entries.") + return normalized + + +def create_fees_distribution( + fee_distribution: Optional[FeesDistributionInput] = None, +) -> FeesDistribution: + appeal_rounds = to_uint( + _get(fee_distribution, "appealRounds", "appeal_rounds"), + "fees.distribution.appealRounds", + ) + return { + "leaderTimeunitsAllocation": to_uint( + _get( + fee_distribution, + "leaderTimeunitsAllocation", + "leader_timeunits_allocation", + ), + "fees.distribution.leaderTimeunitsAllocation", + ), + "validatorTimeunitsAllocation": to_uint( + _get( + fee_distribution, + "validatorTimeunitsAllocation", + "validator_timeunits_allocation", + ), + "fees.distribution.validatorTimeunitsAllocation", + ), + "appealRounds": appeal_rounds, + "executionBudgetPerRound": to_uint( + _get( + fee_distribution, + "executionBudgetPerRound", + "execution_budget_per_round", + ), + "fees.distribution.executionBudgetPerRound", + ), + "executionConsumed": to_uint( + _get(fee_distribution, "executionConsumed", "execution_consumed"), + "fees.distribution.executionConsumed", + ), + "totalMessageFees": to_uint( + _get(fee_distribution, "totalMessageFees", "total_message_fees"), + "fees.distribution.totalMessageFees", + ), + "rotations": _normalize_rotations( + _get(fee_distribution, "rotations"), + appeal_rounds, + "fees.distribution.rotations", + ), + "maxPriceGenPerTimeUnit": to_uint( + _get( + fee_distribution, + "maxPriceGenPerTimeUnit", + "max_price_gen_per_time_unit", + ), + "fees.distribution.maxPriceGenPerTimeUnit", + ), + "storageFeeMaxGasPrice": to_uint( + _get( + fee_distribution, + "storageFeeMaxGasPrice", + "storage_fee_max_gas_price", + ), + "fees.distribution.storageFeeMaxGasPrice", + ), + "receiptFeeMaxGasPrice": to_uint( + _get( + fee_distribution, + "receiptFeeMaxGasPrice", + "receipt_fee_max_gas_price", + ), + "fees.distribution.receiptFeeMaxGasPrice", + ), + } + + +def encode_internal_message_fee_params( + params: Optional[InternalMessageFeeParamsInput] = None, +) -> HexStr: + appeal_rounds = to_uint( + _get(params, "appealRounds", "appeal_rounds"), + "internalMessageFeeParams.appealRounds", + ) + encoded = abi_encode( + ("(uint256,uint256,uint256,uint256,uint256[])",), + ( + ( + to_uint( + _get( + params, + "leaderTimeunitsAllocation", + "leader_timeunits_allocation", + ), + "internalMessageFeeParams.leaderTimeunitsAllocation", + ), + to_uint( + _get( + params, + "validatorTimeunitsAllocation", + "validator_timeunits_allocation", + ), + "internalMessageFeeParams.validatorTimeunitsAllocation", + ), + appeal_rounds, + to_uint( + _get( + params, + "executionBudgetPerRound", + "execution_budget_per_round", + ), + "internalMessageFeeParams.executionBudgetPerRound", + ), + _normalize_rotations( + _get(params, "rotations"), + appeal_rounds, + "internalMessageFeeParams.rotations", + ), + ), + ), + ) + return HexStr("0x" + encoded.hex()) + + +def encode_external_message_fee_params( + params: Optional[ExternalMessageFeeParamsInput] = None, +) -> HexStr: + encoded = abi_encode( + ("(uint256,uint256)",), + ( + ( + to_uint( + _get(params, "gasLimit", "gas_limit"), + "externalMessageFeeParams.gasLimit", + ), + to_uint( + _get(params, "maxGasPrice", "max_gas_price"), + "externalMessageFeeParams.maxGasPrice", + ), + ), + ), + ) + return HexStr("0x" + encoded.hex()) + + +def normalize_message_fee_allocations( + allocations: Optional[list[MessageFeeAllocationInput]] = None, +) -> list[MessageFeeAllocationNode]: + normalized: list[MessageFeeAllocationNode] = [] + for index, allocation in enumerate(allocations or []): + recipient = _get(allocation, "recipient") + if not recipient: + raise ValueError(f"fees.messageAllocations[{index}].recipient is required.") + message_type = _normalize_message_type( + _get(allocation, "messageType", "message_type", default=MessageType.External) + ) + normalized.append( + { + "messageType": message_type, + "onAcceptance": bool( + _get( + allocation, + "onAcceptance", + "on_acceptance", + default=message_type != int(MessageType.External), + ) + ), + "parentIndex": to_uint( + _get(allocation, "parentIndex", "parent_index"), + f"fees.messageAllocations[{index}].parentIndex", + MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, + ), + "recipient": recipient, + "callKey": _normalize_bytes32( + _get( + allocation, + "callKey", + "call_key", + default=CALL_KEY_WILDCARD, + ), + f"fees.messageAllocations[{index}].callKey", + ), + "budget": to_uint( + _get(allocation, "budget"), + f"fees.messageAllocations[{index}].budget", + ), + "feeParams": _normalize_hex_bytes( + _get(allocation, "feeParams", "fee_params", default="0x"), + f"fees.messageAllocations[{index}].feeParams", + ), + } + ) + return normalized + + +def fees_distribution_to_abi_tuple(distribution: FeesDistribution) -> tuple: + return ( + distribution["leaderTimeunitsAllocation"], + distribution["validatorTimeunitsAllocation"], + distribution["appealRounds"], + distribution["executionBudgetPerRound"], + distribution["executionConsumed"], + distribution["totalMessageFees"], + distribution["rotations"], + distribution["maxPriceGenPerTimeUnit"], + distribution["storageFeeMaxGasPrice"], + distribution["receiptFeeMaxGasPrice"], + ) + + +def message_allocation_to_abi_tuple(allocation: MessageFeeAllocationNode) -> tuple: + return ( + allocation["messageType"], + allocation["onAcceptance"], + allocation["parentIndex"], + allocation["recipient"], + allocation["callKey"], + allocation["budget"], + allocation["feeParams"], + ) + + +def _has_non_default_fees_distribution(distribution: FeesDistribution) -> bool: + return distribution != DEFAULT_FEES_DISTRIBUTION + + +def normalize_transaction_fees( + fees: Optional[TransactionFeeOptions] = None, +) -> NormalizedTransactionFees: + distribution = create_fees_distribution(_get(fees, "distribution")) + message_allocations = normalize_message_fee_allocations( + _get(fees, "messageAllocations", "message_allocations") + ) + raw_fee_value = _get(fees, "feeValue", "fee_value") + fee_value = ( + None + if raw_fee_value is None + else to_uint(raw_fee_value, "fees.feeValue") + ) + + return { + "distribution": distribution, + "message_allocations": message_allocations, + "fee_value": fee_value, + "requires_fee_aware_transaction": ( + _has_non_default_fees_distribution(distribution) + or len(message_allocations) > 0 + or (fee_value or 0) != 0 + ), + } + + +def requires_fee_deposit_calculation(distribution: FeesDistribution) -> bool: + return ( + distribution["leaderTimeunitsAllocation"] != 0 + or distribution["validatorTimeunitsAllocation"] != 0 + or distribution["executionBudgetPerRound"] != 0 + or distribution["totalMessageFees"] != 0 + ) + + +def _with_cap_headroom(value: int, headroom_bps: int) -> int: + if value == 0: + return 0 + return (value * headroom_bps + 9_999) // 10_000 + + +def _int_from_unknown(value: Any, field_name: str) -> int: + if value is None: + return 0 + if isinstance(value, bool): + raise ValueError(f"{field_name} is not an integer value.") + normalized = int(value) + if normalized < 0: + raise ValueError(f"{field_name} must be greater than or equal to zero.") + return normalized + + +def extract_studio_fee_policy(config: Any) -> FeePolicyQuote: + if not isinstance(config, dict): + raise ValueError("sim_getFeeConfig did not return an object.") + + enabled = config.get("enabled") + if enabled is not None and not isinstance(enabled, bool): + raise ValueError("sim_getFeeConfig enabled flag is not a boolean.") + + policy = config.get("policy") + if not isinstance(policy, dict): + raise ValueError("sim_getFeeConfig did not expose a policy object.") + + gen_per_time_unit = _int_from_unknown( + policy.get("genPerTimeUnit"), + "policy.genPerTimeUnit", + ) + storage_unit_price = _int_from_unknown( + policy.get("storageUnitPrice"), + "policy.storageUnitPrice", + ) + receipt_gas_price = _int_from_unknown( + policy.get("receiptGasPrice"), + "policy.receiptGasPrice", + ) + intrinsic_gas = _int_from_unknown( + policy.get("intrinsicGas", DEFAULT_INTRINSIC_GAS), + "policy.intrinsicGas", + ) + bootloader_overhead = _int_from_unknown( + policy.get("bootloaderOverhead", DEFAULT_BOOTLOADER_OVERHEAD), + "policy.bootloaderOverhead", + ) + gas_per_changed_slot = _int_from_unknown( + policy.get("gasPerChangedSlot", DEFAULT_GAS_PER_CHANGED_SLOT), + "policy.gasPerChangedSlot", + ) + calldata_gas_per_byte = _int_from_unknown( + policy.get("calldataGasPerByte", DEFAULT_CALLDATA_GAS_PER_BYTE), + "policy.calldataGasPerByte", + ) + fixed_propose_receipt_gas = _int_from_unknown( + policy.get("fixedProposeReceiptGas", DEFAULT_FIXED_PROPOSE_RECEIPT_GAS), + "policy.fixedProposeReceiptGas", + ) + explicit_budget_floor = policy.get( + "messageFeeParamsBudgetFloor", + config.get("messageFeeParamsBudgetFloor"), + ) + execution_budget_floor = ( + _int_from_unknown( + explicit_budget_floor, + "policy.messageFeeParamsBudgetFloor", + ) + if explicit_budget_floor is not None + else receipt_gas_price + * ( + fixed_propose_receipt_gas + + intrinsic_gas + + bootloader_overhead + + (MIN_RECEIPT_BYTES * calldata_gas_per_byte) + + (DEFAULT_RECEIPT_SLOTS_CHANGED * gas_per_changed_slot) + ) + ) + + return { + "enabled": bool( + enabled + if enabled is not None + else gen_per_time_unit > 0 + or storage_unit_price > 0 + or receipt_gas_price > 0 + ), + "genPerTimeUnit": gen_per_time_unit, + "storageUnitPrice": storage_unit_price, + "receiptGasPrice": receipt_gas_price, + "executionBudgetFloor": execution_budget_floor, + } + + +def _default_execution_budget_per_round(policy: FeePolicyQuote) -> int: + if ( + not policy["enabled"] + or ( + policy["storageUnitPrice"] == 0 + and policy["receiptGasPrice"] == 0 + ) + ): + return 0 + + return max( + DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, + policy["executionBudgetFloor"], + ) + + +def fee_accounting_from_simulation(simulation: Any) -> Optional[dict]: + simulation_record = _dict_or_none(simulation) + if not simulation_record: + return None + + direct = _dict_or_none( + _get(simulation_record, "feeAccounting", "fee_accounting") + ) + if direct: + return direct + + receipt = _dict_or_none(simulation_record.get("receipt")) or simulation_record + genvm_result = _dict_or_none( + _get(receipt, "genvm_result", "genvmResult") + ) + if not genvm_result: + return None + + return _dict_or_none( + _get(genvm_result, "fee_accounting", "feeAccounting") + ) + + +def fee_report_from_simulation(simulation: Any, accounting: Optional[dict]) -> Optional[dict]: + simulation_record = _dict_or_none(simulation) + direct = _dict_or_none( + _get(simulation_record, "feeReport", "fee_report") if simulation_record else None + ) + if direct: + return direct + return _dict_or_none( + _get(accounting, "execution_fee_report", "executionFeeReport") + ) + + +def message_allocations_from_accounting( + accounting: Optional[dict], +) -> Optional[list[MessageFeeAllocationInput]]: + raw_allocations = _get(accounting, "message_allocations", "messageAllocations") + if raw_allocations is None: + return None + if not isinstance(raw_allocations, list): + raise ValueError("simulation.feeAccounting.message_allocations must be a list.") + + allocations: list[MessageFeeAllocationInput] = [] + for index, allocation in enumerate(raw_allocations): + allocation_record = _dict_or_none(allocation) + if not allocation_record: + raise ValueError( + f"simulation.feeAccounting.message_allocations[{index}] must be an object." + ) + allocations.append( + { + "messageType": _normalize_message_type( + _get( + allocation_record, + "messageType", + "message_type", + default=MessageType.External, + ) + ), + "onAcceptance": bool( + _get( + allocation_record, + "onAcceptance", + "on_acceptance", + ) + ), + "parentIndex": to_uint( + _get( + allocation_record, + "parentIndex", + "parent_index", + ), + f"simulation.feeAccounting.message_allocations[{index}].parentIndex", + MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, + ), + "recipient": str( + _get( + allocation_record, + "recipient", + default=ZERO_ADDRESS, + ) + ), + "callKey": _hex_from_unknown( + _get( + allocation_record, + "callKey", + "call_key", + default=CALL_KEY_WILDCARD, + ), + f"simulation.feeAccounting.message_allocations[{index}].callKey", + "0x" + CALL_KEY_WILDCARD.hex(), + ), + "budget": to_uint( + _get(allocation_record, "budget"), + f"simulation.feeAccounting.message_allocations[{index}].budget", + ), + "feeParams": _hex_from_unknown( + _get( + allocation_record, + "feeParams", + "fee_params", + default="0x", + ), + f"simulation.feeAccounting.message_allocations[{index}].feeParams", + ), + } + ) + return allocations + + +def _message_is_internal(message: dict) -> bool: + message_type = _get(message, "messageType", "message_type") + if isinstance(message_type, str): + return message_type.lower() == "internal" or message_type == "1" + if message_type is None: + return False + return int(message_type) == int(MessageType.Internal) + + +def observed_simulation_fee_usage( + options: SimulationFeeEstimateOptions, + policy: FeePolicyQuote, +) -> dict[str, int]: + simulation = options.get("simulation") + accounting = fee_accounting_from_simulation(simulation) or {} + report = fee_report_from_simulation(simulation, accounting) or {} + execution_headroom_bps = to_uint( + _get(options, "executionHeadroomBps", "execution_headroom_bps"), + "executionHeadroomBps", + DEFAULT_PRICE_CAP_HEADROOM_BPS, + ) + message_headroom_bps = to_uint( + _get(options, "messageHeadroomBps", "message_headroom_bps"), + "messageHeadroomBps", + DEFAULT_PRICE_CAP_HEADROOM_BPS, + ) + + execution_fee_consumed = _int_from_unknown( + _get(accounting, "execution_fee_consumed", "executionFeeConsumed"), + "simulation.feeAccounting.execution_fee_consumed", + ) + execution_fee_report_total = _int_from_unknown( + _get(report, "totalEstimatedFee", "total_estimated_fee"), + "simulation.feeReport.totalEstimatedFee", + ) + observed_execution_budget = execution_fee_consumed + execution_fee_report_total + recommended_execution_budget = ( + max( + _default_execution_budget_per_round(policy), + _with_cap_headroom(observed_execution_budget, execution_headroom_bps), + ) + if observed_execution_budget > 0 + else 0 + ) + + message_fee_consumed = _int_from_unknown( + _get(accounting, "message_fee_consumed", "messageFeeConsumed"), + "simulation.feeAccounting.message_fee_consumed", + ) + genvm_message_fee_consumed = _int_from_unknown( + _get(accounting, "genvm_message_fee_consumed", "genvmMessageFeeConsumed"), + "simulation.feeAccounting.genvm_message_fee_consumed", + ) + message_fee_budget = _int_from_unknown( + _get(accounting, "message_fee_budget", "messageFeeBudget"), + "simulation.feeAccounting.message_fee_budget", + ) + message_fee_refunded = _int_from_unknown( + _get(accounting, "message_fee_refunded", "messageFeeRefunded"), + "simulation.feeAccounting.message_fee_refunded", + ) + external_message_reserved = _int_from_unknown( + _get(accounting, "external_message_fee_reserved", "externalMessageReserved"), + "simulation.feeAccounting.external_message_fee_reserved", + ) + external_message_reimbursed = _int_from_unknown( + _get(accounting, "external_message_fee_reimbursed", "externalMessageReimbursed"), + "simulation.feeAccounting.external_message_fee_reimbursed", + ) + external_message_remainder = _int_from_unknown( + _get(accounting, "external_message_fee_remainder", "externalMessageRemainder"), + "simulation.feeAccounting.external_message_fee_remainder", + ) + + message_reveal = _dict_or_none( + _get(report, "messageReveal", "message_reveal") + ) or {} + messages = message_reveal.get("messages") or [] + internal_declared_budget = 0 + if isinstance(messages, list): + for index, message in enumerate(messages): + message_record = _dict_or_none(message) + if message_record and _message_is_internal(message_record): + internal_declared_budget += _int_from_unknown( + _get( + message_record, + "declaredBudget", + "declared_budget", + ), + f"simulation.feeReport.messageReveal.messages[{index}].declaredBudget", + ) + + observed_message_budget = max( + message_fee_consumed, + internal_declared_budget + external_message_reimbursed, + ) + + return { + "executionFeeConsumed": execution_fee_consumed, + "executionFeeReportTotal": execution_fee_report_total, + "recommendedExecutionBudgetPerRound": recommended_execution_budget, + "genvmMessageFeeConsumed": genvm_message_fee_consumed, + "messageFeeBudget": message_fee_budget, + "messageFeeConsumed": message_fee_consumed, + "messageFeeRefunded": message_fee_refunded, + "internalDeclaredBudget": internal_declared_budget, + "externalMessageReserved": external_message_reserved, + "externalMessageReimbursed": external_message_reimbursed, + "externalMessageRemainder": external_message_remainder, + "recommendedTotalMessageFees": ( + _with_cap_headroom(observed_message_budget, message_headroom_bps) + if observed_message_budget > 0 + else 0 + ), + } + + +def transaction_fee_estimate_from_studio_estimate( + result: Any, + policy: FeePolicyQuote, +) -> Optional[TransactionFeeEstimate]: + estimate = _dict_or_none(result) + if estimate is None: + return None + + preset = _dict_or_none(_get(estimate, "recommendedPreset", "recommended_preset")) + if preset is None: + return None + distribution_input = _dict_or_none(_get(preset, "distribution")) + fee_value = _get(preset, "feeValue", "fee_value") + if distribution_input is None or fee_value is None: + return None + + accounting = _dict_or_none(_get(estimate, "feeAccounting", "fee_accounting")) + fee_report = _dict_or_none(_get(estimate, "feeReport", "fee_report")) + if fee_report is None and accounting is not None: + fee_report = _dict_or_none( + _get(accounting, "execution_fee_report", "executionFeeReport") + ) + + simulation = { + "feeAccounting": accounting, + "feeReport": fee_report, + } + transaction_estimate: TransactionFeeEstimate = { + "distribution": create_fees_distribution(distribution_input), + "feeValue": to_uint(fee_value, "recommendedPreset.feeValue"), + "fee_value": to_uint(fee_value, "recommendedPreset.feeValue"), + "policy": policy, + "observed": observed_simulation_fee_usage( + {"simulation": simulation}, + policy, + ), + } + + message_allocations = _get( + preset, + "messageAllocations", + "message_allocations", + ) + if message_allocations is not None: + normalized_allocations = message_allocations_from_accounting( + {"message_allocations": message_allocations} + ) + if normalized_allocations is not None: + transaction_estimate["messageAllocations"] = normalized_allocations + transaction_estimate["message_allocations"] = normalized_allocations + + return transaction_estimate + + +def build_estimated_fees_options_from_simulation( + options: SimulationFeeEstimateOptions, + policy: FeePolicyQuote, +) -> SimulationFeePreset: + if not isinstance(options, dict) or "simulation" not in options: + raise ValueError("simulation is required.") + + accounting = fee_accounting_from_simulation(options["simulation"]) + observed = observed_simulation_fee_usage(options, policy) + message_allocations = _get(options, "messageAllocations", "message_allocations") + if message_allocations is None: + message_allocations = message_allocations_from_accounting(accounting) + + estimate_options: FeeEstimateOptions = { + key: value + for key, value in options.items() + if key + not in { + "simulation", + "executionHeadroomBps", + "execution_headroom_bps", + "messageHeadroomBps", + "message_headroom_bps", + } + } + if message_allocations is not None: + estimate_options["messageAllocations"] = message_allocations + estimate_options.pop("message_allocations", None) + + if ( + _get( + estimate_options, + "executionBudgetPerRound", + "execution_budget_per_round", + ) + is None + and observed["recommendedExecutionBudgetPerRound"] > 0 + ): + estimate_options["executionBudgetPerRound"] = observed[ + "recommendedExecutionBudgetPerRound" + ] + + if ( + _get(estimate_options, "totalMessageFees", "total_message_fees") is None + and message_allocations is None + and observed["recommendedTotalMessageFees"] > 0 + ): + estimate_options["totalMessageFees"] = observed["recommendedTotalMessageFees"] + + preset: SimulationFeePreset = { + "estimateOptions": estimate_options, + "estimate_options": estimate_options, + "observed": observed, + } + if message_allocations is not None: + preset["messageAllocations"] = message_allocations + preset["message_allocations"] = message_allocations + return preset + + +def build_estimated_fees_distribution( + options: Optional[FeeEstimateOptions], + policy: FeePolicyQuote, +) -> FeesDistribution: + headroom_bps = to_uint( + _get(options, "priceCapHeadroomBps", "price_cap_headroom_bps"), + "priceCapHeadroomBps", + DEFAULT_PRICE_CAP_HEADROOM_BPS, + ) + execution_budget_default = _default_execution_budget_per_round(policy) + + total_message_fees = _get(options, "totalMessageFees", "total_message_fees") + message_allocations = _get(options, "messageAllocations", "message_allocations") + if total_message_fees is None and message_allocations is not None: + total_message_fees = sum( + allocation["budget"] + for allocation in normalize_message_fee_allocations(message_allocations) + if allocation["messageType"] == int(MessageType.External) + or allocation["parentIndex"] == MESSAGE_ALLOCATION_ROOT_PARENT_INDEX + ) + + return create_fees_distribution( + { + "leaderTimeunitsAllocation": _get( + options, + "leaderTimeunitsAllocation", + "leader_timeunits_allocation", + default=DEFAULT_LEADER_TIMEUNITS_ALLOCATION + if policy["enabled"] + else 0, + ), + "validatorTimeunitsAllocation": _get( + options, + "validatorTimeunitsAllocation", + "validator_timeunits_allocation", + default=DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION + if policy["enabled"] + else 0, + ), + "appealRounds": _get(options, "appealRounds", "appeal_rounds"), + "executionBudgetPerRound": _get( + options, + "executionBudgetPerRound", + "execution_budget_per_round", + default=execution_budget_default, + ), + "executionConsumed": _get( + options, + "executionConsumed", + "execution_consumed", + ), + "totalMessageFees": total_message_fees, + "rotations": _get(options, "rotations"), + "maxPriceGenPerTimeUnit": _get( + options, + "maxPriceGenPerTimeUnit", + "max_price_gen_per_time_unit", + default=_with_cap_headroom( + policy["genPerTimeUnit"], + headroom_bps, + ), + ), + "storageFeeMaxGasPrice": _get( + options, + "storageFeeMaxGasPrice", + "storage_fee_max_gas_price", + default=_with_cap_headroom( + policy["storageUnitPrice"], + headroom_bps, + ), + ), + "receiptFeeMaxGasPrice": _get( + options, + "receiptFeeMaxGasPrice", + "receipt_fee_max_gas_price", + default=_with_cap_headroom( + policy["receiptGasPrice"], + headroom_bps, + ), + ), + } + ) + + +def _validator_index(num_of_validators: int) -> int: + for index, validators in enumerate(VALIDATORS_PER_ROUND): + if validators == num_of_validators: + return index + raise ValueError("InvalidNumOfValidators") + + +def _calculate_fee_for_round( + num_of_validators: int, + rotations: int, + leader_timeunits_allocation: int, + validator_timeunits_allocation: int, +) -> int: + return rotations * ( + leader_timeunits_allocation + + num_of_validators * validator_timeunits_allocation + ) + + +def calculate_local_round_fees( + distribution: FeesDistribution, + num_of_initial_validators: int, + policy: FeePolicyQuote, +) -> int: + if distribution["appealRounds"] != len(distribution["rotations"]) - 1: + raise ValueError("InvalidAppealRounds") + if ( + distribution["maxPriceGenPerTimeUnit"] > 0 + and policy["genPerTimeUnit"] > distribution["maxPriceGenPerTimeUnit"] + ): + raise ValueError("MaxPriceExceeded") + if ( + distribution["storageFeeMaxGasPrice"] > 0 + and policy["storageUnitPrice"] > distribution["storageFeeMaxGasPrice"] + ): + raise ValueError("MaxPriceExceeded") + if ( + distribution["receiptFeeMaxGasPrice"] > 0 + and policy["receiptGasPrice"] > distribution["receiptFeeMaxGasPrice"] + ): + raise ValueError("MaxPriceExceeded") + + start_index = _validator_index(num_of_initial_validators) + if start_index + distribution["appealRounds"] * 2 >= len(VALIDATORS_PER_ROUND): + raise ValueError("InvalidNumOfValidators") + + total = _calculate_fee_for_round( + VALIDATORS_PER_ROUND[start_index], + distribution["rotations"][0] + 1, + distribution["leaderTimeunitsAllocation"], + distribution["validatorTimeunitsAllocation"], + ) + rotations_index = 1 + rotations_this_round = 1 + for offset in range(1, distribution["appealRounds"] * 2 + 1): + if offset % 2 == 0 and rotations_index < len(distribution["rotations"]): + rotations_this_round = distribution["rotations"][rotations_index] + 1 + rotations_index += 1 + elif offset % 2 == 1: + rotations_this_round = 1 + + total += _calculate_fee_for_round( + VALIDATORS_PER_ROUND[start_index + offset], + rotations_this_round, + distribution["leaderTimeunitsAllocation"], + distribution["validatorTimeunitsAllocation"], + ) + + if policy["genPerTimeUnit"] > 0: + total *= policy["genPerTimeUnit"] + + leader_rounds = sum( + rotations + 1 + for rotations in distribution["rotations"] + ) + distribution["appealRounds"] + total += distribution["executionBudgetPerRound"] * leader_rounds + return total + + +def build_add_transaction_params_tuple( + *, + sender_address: str, + recipient_address: str, + num_of_initial_validators: int, + max_rotations: int, + valid_until: int, + salt_nonce: int, + user_value: int, + tx_data: Union[HexStr, str, bytes], + transaction_fees: NormalizedTransactionFees, +) -> tuple: + tx_calldata = ( + tx_data + if isinstance(tx_data, bytes) + else Web3.to_bytes(hexstr=tx_data) + ) + return ( + sender_address, + recipient_address, + num_of_initial_validators, + max_rotations, + valid_until, + salt_nonce, + user_value, + fees_distribution_to_abi_tuple(transaction_fees["distribution"]), + tx_calldata, + [ + message_allocation_to_abi_tuple(allocation) + for allocation in transaction_fees["message_allocations"] + ], + ) + + +def encode_fee_aware_add_transaction_data( + *, + sender_address: str, + recipient_address: str, + num_of_initial_validators: int, + max_rotations: int, + tx_data: Union[HexStr, str, bytes], + valid_until: int, + salt_nonce: int = 0, + user_value: int = 0, + transaction_fees: Optional[NormalizedTransactionFees] = None, +) -> HexStr: + normalized_fees = transaction_fees or normalize_transaction_fees() + params_tuple = build_add_transaction_params_tuple( + sender_address=sender_address, + recipient_address=recipient_address, + num_of_initial_validators=num_of_initial_validators, + max_rotations=max_rotations, + valid_until=valid_until, + salt_nonce=salt_nonce, + user_value=user_value, + tx_data=tx_data, + transaction_fees=normalized_fees, + ) + encoded = abi_encode(ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES, [params_tuple]) + return HexStr("0x" + ADD_TRANSACTION_WITH_FEES_SELECTOR + encoded.hex()) + + +def decode_fees_distribution_tuple(values: tuple) -> FeesDistribution: + return { + "leaderTimeunitsAllocation": values[0], + "validatorTimeunitsAllocation": values[1], + "appealRounds": values[2], + "executionBudgetPerRound": values[3], + "executionConsumed": values[4], + "totalMessageFees": values[5], + "rotations": list(values[6]), + "maxPriceGenPerTimeUnit": values[7], + "storageFeeMaxGasPrice": values[8], + "receiptFeeMaxGasPrice": values[9], + } + + +def decode_message_allocation_tuple(values: tuple) -> MessageFeeAllocationNode: + return { + "messageType": values[0], + "onAcceptance": values[1], + "parentIndex": values[2], + "recipient": values[3], + "callKey": values[4], + "budget": values[5], + "feeParams": values[6], + } diff --git a/tests/unit/consensus/test_consensus_main.py b/tests/unit/consensus/test_consensus_main.py index d55a965..dd37be8 100644 --- a/tests/unit/consensus/test_consensus_main.py +++ b/tests/unit/consensus/test_consensus_main.py @@ -4,6 +4,7 @@ encode_tx_data_deploy, ) from genlayer_py.consensus.consensus_main import decode_add_transaction_data +from genlayer_py.transactions import MessageType from web3 import Web3 @@ -113,3 +114,48 @@ def test_codec_add_transaction_data_3(): ) assert decoded_data["tx_data"]["decoded"]["leader_only"] is False assert decoded_data["tx_data"]["decoded"]["type"] == "deploy" + + +def test_codec_fee_aware_add_transaction_data_round_trip(): + tx_data = encode_tx_data_call( + function_name="set_store", + leader_only=False, + args=["value"], + kwargs={}, + ) + encoded_data = encode_add_transaction_data( + sender_address="0x3d338dea364bdac3c4c3036a38766870b98c4320", + recipient_address="0x7f3ebb777cd2ae9c266d6cea2c7a3ed81c30ddc2", + num_of_initial_validators=5, + max_rotations=1, + tx_data=tx_data, + valid_until=123, + user_value=7, + fees={ + "fee_value": 11, + "distribution": { + "total_message_fees": 11, + }, + "message_allocations": [ + { + "message_type": MessageType.Internal, + "on_acceptance": False, + "recipient": "0x7f3ebb777cd2ae9c266d6cea2c7a3ed81c30ddc2", + "budget": 11, + "fee_params": "0x1234", + } + ], + }, + ) + + decoded_data = decode_add_transaction_data(encoded_data) + assert ( + decoded_data["sender_address"].lower() + == "0x3d338dea364bdac3c4c3036a38766870b98c4320" + ) + assert decoded_data["valid_until"] == 123 + assert decoded_data["user_value"] == 7 + assert decoded_data["fees_distribution"]["totalMessageFees"] == 11 + assert decoded_data["message_allocations"][0]["messageType"] == MessageType.Internal + assert decoded_data["message_allocations"][0]["onAcceptance"] is False + assert decoded_data["tx_data"]["decoded"]["call_data"]["method"] == "set_store" diff --git a/tests/unit/contracts/test_contract_actions.py b/tests/unit/contracts/test_contract_actions.py index f6de40b..cc58d44 100644 --- a/tests/unit/contracts/test_contract_actions.py +++ b/tests/unit/contracts/test_contract_actions.py @@ -2,9 +2,30 @@ from unittest.mock import Mock import eth_utils +from eth_abi import decode as abi_decode from web3 import Web3 import genlayer_py.contracts.actions as contract_actions +from genlayer_py.chains import localnet +from genlayer_py.transactions.fees import ( + ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES, + ADD_TRANSACTION_WITH_FEES_SELECTOR, + CALL_KEY_DEPLOY, + CALL_KEY_UNNAMED, + CALL_KEY_WILDCARD, + FEES_DISTRIBUTION_ABI_TYPE, + MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, + MessageType, + build_estimated_fees_distribution, + calculate_local_round_fees, + create_fees_distribution, + derive_external_message_call_key, + derive_internal_message_call_key, + encode_external_message_fee_params, + encode_internal_message_fee_params, + extract_studio_fee_policy, + requires_fee_deposit_calculation, +) ADD_TRANSACTION_ABI_V5 = [ @@ -40,8 +61,255 @@ } ] +ADD_TRANSACTION_ABI_WITH_FEES = [ + { + "type": "function", + "name": "addTransaction", + "stateMutability": "payable", + "inputs": [ + { + "name": "_params", + "type": "tuple", + "components": [ + {"name": "sender", "type": "address"}, + {"name": "recipient", "type": "address"}, + {"name": "numOfInitialValidators", "type": "uint256"}, + {"name": "maxRotations", "type": "uint256"}, + {"name": "validUntil", "type": "uint256"}, + {"name": "saltNonce", "type": "uint256"}, + {"name": "userValue", "type": "uint256"}, + { + "name": "feesDistribution", + "type": "tuple", + "components": [ + {"name": "leaderTimeunitsAllocation", "type": "uint256"}, + {"name": "validatorTimeunitsAllocation", "type": "uint256"}, + {"name": "appealRounds", "type": "uint256"}, + {"name": "executionBudgetPerRound", "type": "uint256"}, + {"name": "executionConsumed", "type": "uint256"}, + {"name": "totalMessageFees", "type": "uint256"}, + {"name": "rotations", "type": "uint256[]"}, + {"name": "maxPriceGenPerTimeUnit", "type": "uint256"}, + {"name": "storageFeeMaxGasPrice", "type": "uint256"}, + {"name": "receiptFeeMaxGasPrice", "type": "uint256"}, + ], + }, + {"name": "txCalldata", "type": "bytes"}, + { + "name": "messageAllocations", + "type": "tuple[]", + "components": [ + {"name": "messageType", "type": "uint8"}, + {"name": "onAcceptance", "type": "bool"}, + {"name": "parentIndex", "type": "uint256"}, + {"name": "recipient", "type": "address"}, + {"name": "callKey", "type": "bytes32"}, + {"name": "budget", "type": "uint256"}, + {"name": "feeParams", "type": "bytes"}, + ], + }, + ], + }, + ], + "outputs": [], + } +] + SENDER = "0x1111111111111111111111111111111111111111" RECIPIENT = "0x2222222222222222222222222222222222222222" +TX_ID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + +def test_encode_internal_message_fee_params_uses_consensus_tuple_shape(): + encoded = encode_internal_message_fee_params( + { + "leaderTimeunitsAllocation": 5, + "validatorTimeunitsAllocation": 10, + "appealRounds": 1, + "executionBudgetPerRound": 20, + "rotations": [2, 3], + } + ) + + decoded = abi_decode( + ("(uint256,uint256,uint256,uint256,uint256[])",), + Web3.to_bytes(hexstr=encoded), + )[0] + assert decoded == (5, 10, 1, 20, (2, 3)) + + +def test_derive_internal_message_call_key_for_short_method_name(): + assert derive_internal_message_call_key("update_storage") == ( + "0x7570646174655f73746f72616765000000000000000000000000000000000000" + ) + + +def test_derive_internal_message_call_key_hashes_exact_32_byte_method_name(): + method_name = "a" * 32 + expected = bytearray(eth_utils.keccak(text=method_name)) + expected[-1] |= 1 + assert derive_internal_message_call_key(method_name) == "0x" + bytes(expected).hex() + + +def test_derive_message_call_key_constants_for_deploy_and_unnamed(): + assert CALL_KEY_DEPLOY == "0x" + "00" * 32 + assert CALL_KEY_UNNAMED == "0x" + "00" * 32 + assert derive_internal_message_call_key("") == CALL_KEY_UNNAMED + + +def test_derive_external_message_call_key_uses_selector_prefix(): + assert derive_external_message_call_key("0xaabbccdd0102") == ( + "0xaabbccdd00000000000000000000000000000000000000000000000000000000" + ) + + +def test_derive_external_message_call_key_keeps_unnamed_for_short_calldata(): + assert derive_external_message_call_key("0xaabbcc") == CALL_KEY_UNNAMED + + +def test_encode_external_message_fee_params_uses_consensus_tuple_shape(): + encoded = encode_external_message_fee_params( + { + "gasLimit": 21_000, + "maxGasPrice": 10, + } + ) + + decoded = abi_decode( + ("(uint256,uint256)",), + Web3.to_bytes(hexstr=encoded), + )[0] + assert decoded == (21_000, 10) + + +def test_encode_top_up_fees_uses_consensus_tuple_shape(): + client = _make_client(ADD_TRANSACTION_ABI_WITH_FEES) + + encoded = contract_actions._encode_fee_management_data( + self=client, + function_name="topUpFees", + transaction_id=TX_ID, + distribution={ + "leaderTimeunitsAllocation": 100, + "validatorTimeunitsAllocation": 200, + "appealRounds": 1, + "executionBudgetPerRound": 500_000, + "totalMessageFees": 30, + "rotations": [0, 2], + "maxPriceGenPerTimeUnit": 12, + "storageFeeMaxGasPrice": 24, + "receiptFeeMaxGasPrice": 36, + }, + ) + + selector = eth_utils.keccak( + text=f"topUpFees(bytes32,{FEES_DISTRIBUTION_ABI_TYPE})" + )[:4].hex() + assert encoded.startswith(f"0x{selector}") + decoded_tx_id, distribution = abi_decode( + ("bytes32", FEES_DISTRIBUTION_ABI_TYPE), + Web3.to_bytes(hexstr=encoded[10:]), + ) + assert decoded_tx_id == Web3.to_bytes(hexstr=TX_ID) + assert distribution == (100, 200, 1, 500_000, 0, 30, (0, 2), 12, 24, 36) + + +def test_top_up_fees_sends_consensus_call(monkeypatch): + client = _make_client(ADD_TRANSACTION_ABI_WITH_FEES) + captured = {} + + def fake_send_consensus_call(**kwargs): + captured.update(kwargs) + return "0xevmtx" + + monkeypatch.setattr( + contract_actions, + "_send_consensus_call", + fake_send_consensus_call, + ) + + result = contract_actions.top_up_fees( + self=client, + transaction_id=TX_ID, + value=999, + distribution={"totalMessageFees": 30}, + ) + + assert result == "0xevmtx" + assert captured["sender_account"] is client.local_account + assert captured["value"] == 999 + assert captured["operation_name"] == "Top up fees" + assert captured["encoded_data"].startswith("0x") + + +def test_top_up_and_submit_appeal_sends_consensus_call_and_returns_tx_id(monkeypatch): + client = _make_client(ADD_TRANSACTION_ABI_WITH_FEES) + captured = {} + + def fake_send_consensus_call(**kwargs): + captured.update(kwargs) + return "0xevmtx" + + monkeypatch.setattr( + contract_actions, + "_send_consensus_call", + fake_send_consensus_call, + ) + + result = contract_actions.top_up_and_submit_appeal( + self=client, + transaction_id=TX_ID, + value=1234, + distribution={"appealRounds": 1, "rotations": [0, 1]}, + ) + + selector = eth_utils.keccak( + text=f"topUpAndSubmitAppeal(bytes32,{FEES_DISTRIBUTION_ABI_TYPE})" + )[:4].hex() + assert result == TX_ID + assert captured["value"] == 1234 + assert captured["operation_name"] == "Top up and submit appeal" + assert captured["encoded_data"].startswith(f"0x{selector}") + + +def test_send_consensus_call_returns_localnet_rpc_hash_without_waiting(monkeypatch): + wait_for_transaction_receipt = Mock() + sign_transaction = Mock( + return_value=SimpleNamespace(raw_transaction=b"\x12\x34") + ) + client = SimpleNamespace( + chain=SimpleNamespace( + id=localnet.id, + consensus_main_contract={ + "address": "0x3333333333333333333333333333333333333333", + }, + ), + provider=SimpleNamespace( + make_request=Mock(return_value={"result": TX_ID}) + ), + w3=SimpleNamespace( + to_hex=Mock(return_value="0xsigned"), + eth=SimpleNamespace(wait_for_transaction_receipt=wait_for_transaction_receipt), + ), + ) + account = SimpleNamespace(address=SENDER, sign_transaction=sign_transaction) + + monkeypatch.setattr( + contract_actions, + "_prepare_transaction", + Mock(return_value={"from": SENDER}), + ) + + result = contract_actions._send_consensus_call( + self=client, + encoded_data="0x1234", + sender_account=account, + value=1, + operation_name="Top up fees", + ) + + assert result == TX_ID + wait_for_transaction_receipt.assert_not_called() def _make_client(add_transaction_abi): @@ -62,6 +330,63 @@ def _make_client(add_transaction_abi): ) +def test_simulate_write_contract_passes_fee_policy_and_value_to_sim_call(): + make_request = Mock( + return_value={ + "result": { + "data": "0x", + "genvm_result": {"fee_accounting": {"status": "active"}}, + } + } + ) + client = SimpleNamespace( + chain=SimpleNamespace(id=localnet.id), + local_account=SimpleNamespace(address=SENDER), + provider=SimpleNamespace(make_request=make_request), + ) + + result = contract_actions.simulate_write_contract( + self=client, + address=RECIPIENT, + function_name="update_storage", + args=["simulated"], + value=12, + leader_only=True, + fees={ + "feeValue": 123, + "distribution": { + "leaderTimeunitsAllocation": 100, + "validatorTimeunitsAllocation": 200, + "totalMessageFees": 5, + "rotations": [0], + }, + "messageAllocations": [ + { + "messageType": MessageType.Internal, + "recipient": RECIPIENT, + "budget": 5, + "feeParams": "0x1234", + } + ], + }, + ) + + request_params = make_request.call_args.kwargs["params"][0] + assert make_request.call_args.kwargs["method"] == "sim_call" + assert result["genvm_result"]["fee_accounting"]["status"] == "active" + assert request_params["type"] == "write" + assert request_params["value"] == "0xc" + assert request_params["fees"]["feeValue"] == 123 + assert request_params["fees"]["distribution"]["leaderTimeunitsAllocation"] == 100 + assert request_params["fees"]["distribution"]["validatorTimeunitsAllocation"] == 200 + assert request_params["fees"]["distribution"]["totalMessageFees"] == 5 + assert request_params["fees"]["distribution"]["rotations"] == [0] + assert request_params["fees"]["messageAllocations"][0]["messageType"] == int( + MessageType.Internal + ) + assert request_params["fees"]["messageAllocations"][0]["budget"] == 5 + + def test_encode_add_transaction_uses_v5_signature_when_abi_has_5_inputs(): client = _make_client(ADD_TRANSACTION_ABI_V5) @@ -96,6 +421,536 @@ def test_encode_add_transaction_uses_v6_signature_when_abi_has_6_inputs(): assert encoded.startswith(f"0x{selector}") +def test_encode_add_transaction_uses_fee_signature_when_abi_has_tuple_input(): + client = _make_client(ADD_TRANSACTION_ABI_WITH_FEES) + + encoded = contract_actions._encode_add_transaction_data( + self=client, + sender_account=client.local_account, + recipient=RECIPIENT, + consensus_max_rotations=3, + data="0x", + valid_until=123, + user_value=7, + ) + + assert encoded.startswith(f"0x{ADD_TRANSACTION_WITH_FEES_SELECTOR}") + params = abi_decode( + ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES, + Web3.to_bytes(hexstr=encoded[10:]), + )[0] + assert params[0].lower() == SENDER.lower() + assert params[1].lower() == RECIPIENT.lower() + assert params[4] == 123 + assert params[6] == 7 + assert params[7][6] == (0,) + assert params[9] == () + + +def test_write_contract_separates_user_value_from_fee_deposit(monkeypatch): + client = _make_client(ADD_TRANSACTION_ABI_WITH_FEES) + client.initialize_consensus_smart_contract = Mock() + + captured = {} + + def fake_send_transaction(**kwargs): + captured.update(kwargs) + return "0xdeadbeef" + + monkeypatch.setattr(contract_actions, "_send_transaction", fake_send_transaction) + + result = contract_actions.write_contract( + self=client, + address=RECIPIENT, + function_name="ping", + account=client.local_account, + value=5, + valid_until=123, + fees={ + "feeValue": 123, + "distribution": {"totalMessageFees": 123}, + "messageAllocations": [ + { + "messageType": MessageType.Internal, + "onAcceptance": False, + "recipient": RECIPIENT, + "budget": 123, + "feeParams": "0x1234", + } + ], + }, + ) + + assert result == "0xdeadbeef" + assert captured["value"] == 128 + + params = abi_decode( + ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES, + Web3.to_bytes(hexstr=captured["encoded_data"][10:]), + )[0] + assert params[6] == 5 + assert params[7][5] == 123 + assert params[9][0][0] == MessageType.Internal + assert params[9][0][1] is False + assert params[9][0][2] == MESSAGE_ALLOCATION_ROOT_PARENT_INDEX + assert params[9][0][4] == CALL_KEY_WILDCARD + assert params[9][0][5] == 123 + assert params[9][0][6] == b"\x12\x34" + + +def test_write_contract_defaults_external_message_allocations_to_finalization(monkeypatch): + client = _make_client(ADD_TRANSACTION_ABI_WITH_FEES) + client.initialize_consensus_smart_contract = Mock() + + captured = {} + + def fake_send_transaction(**kwargs): + captured.update(kwargs) + return "0xdeadbeef" + + monkeypatch.setattr(contract_actions, "_send_transaction", fake_send_transaction) + + contract_actions.write_contract( + self=client, + address=RECIPIENT, + function_name="ping", + account=client.local_account, + valid_until=123, + fees={ + "feeValue": 210_000, + "distribution": {"totalMessageFees": 210_000}, + "messageAllocations": [ + { + "messageType": MessageType.External, + "recipient": RECIPIENT, + "budget": 210_000, + "feeParams": encode_external_message_fee_params( + {"gasLimit": 21_000, "maxGasPrice": 10} + ), + } + ], + }, + ) + + params = abi_decode( + ADD_TRANSACTION_WITH_FEES_ARGUMENT_TYPES, + Web3.to_bytes(hexstr=captured["encoded_data"][10:]), + )[0] + assert params[9][0][0] == MessageType.External + assert params[9][0][1] is False + + +def test_execution_budget_requires_fee_deposit_calculation(): + distribution = create_fees_distribution({"executionBudgetPerRound": 500_000}) + + assert requires_fee_deposit_calculation(distribution) is True + + +def test_build_estimated_fees_distribution_adds_caps_and_message_bucket(): + policy = { + "enabled": True, + "genPerTimeUnit": 10, + "storageUnitPrice": 20, + "receiptGasPrice": 30, + "executionBudgetFloor": 1_234, + } + + distribution = build_estimated_fees_distribution( + { + "messageAllocations": [ + { + "messageType": MessageType.Internal, + "recipient": RECIPIENT, + "budget": 50, + "feeParams": "0x1234", + }, + { + "messageType": MessageType.Internal, + "parentIndex": 0, + "recipient": RECIPIENT, + "budget": 10, + "feeParams": "0x1234", + }, + { + "messageType": MessageType.External, + "recipient": RECIPIENT, + "budget": 30, + "feeParams": "0x1234", + }, + ] + }, + policy, + ) + + assert distribution["leaderTimeunitsAllocation"] == 100 + assert distribution["validatorTimeunitsAllocation"] == 200 + assert distribution["executionBudgetPerRound"] == 500_000 + assert distribution["totalMessageFees"] == 80 + assert distribution["maxPriceGenPerTimeUnit"] == 12 + assert distribution["storageFeeMaxGasPrice"] == 24 + assert distribution["receiptFeeMaxGasPrice"] == 36 + + +def test_calculate_local_round_fees_matches_consensus_initial_round(): + distribution = create_fees_distribution( + { + "leaderTimeunitsAllocation": 100, + "validatorTimeunitsAllocation": 200, + "maxPriceGenPerTimeUnit": 10, + } + ) + policy = { + "enabled": True, + "genPerTimeUnit": 10, + "storageUnitPrice": 0, + "receiptGasPrice": 0, + "executionBudgetFloor": 0, + } + + assert calculate_local_round_fees(distribution, 5, policy) == 11_000 + + +def test_estimate_transaction_fees_uses_studio_fee_config(): + client = SimpleNamespace( + chain=SimpleNamespace( + fee_manager_contract=None, + default_number_of_initial_validators=5, + ), + provider=SimpleNamespace( + make_request=Mock( + return_value={ + "result": { + "enabled": True, + "policy": { + "genPerTimeUnit": "10", + "storageUnitPrice": "20", + "receiptGasPrice": "30", + }, + } + } + ) + ), + ) + + estimate = contract_actions.estimate_transaction_fees( + self=client, + options={"priceCapHeadroomBps": 10_000}, + ) + + assert estimate["policy"] == extract_studio_fee_policy( + { + "enabled": True, + "policy": { + "genPerTimeUnit": "10", + "storageUnitPrice": "20", + "receiptGasPrice": "30", + }, + } + ) + assert estimate["distribution"]["executionBudgetPerRound"] == 9_185_760 + assert estimate["feeValue"] == 9_196_760 + + +def test_estimate_transaction_fees_derives_message_bucket_from_allocations(): + client = SimpleNamespace( + chain=SimpleNamespace( + fee_manager_contract=None, + default_number_of_initial_validators=5, + ), + provider=SimpleNamespace( + make_request=Mock( + return_value={ + "result": { + "enabled": True, + "policy": { + "genPerTimeUnit": "10", + "storageUnitPrice": "20", + "receiptGasPrice": "30", + }, + } + } + ) + ), + ) + message_allocations = [ + { + "messageType": MessageType.Internal, + "recipient": RECIPIENT, + "budget": 50, + "feeParams": "0x1234", + }, + { + "messageType": MessageType.Internal, + "parentIndex": 0, + "recipient": RECIPIENT, + "budget": 10, + "feeParams": "0x1234", + }, + { + "messageType": MessageType.External, + "recipient": RECIPIENT, + "budget": 30, + "feeParams": encode_external_message_fee_params( + {"gasLimit": 3, "maxGasPrice": 10} + ), + }, + ] + + estimate = contract_actions.estimate_transaction_fees( + self=client, + options={"messageAllocations": message_allocations}, + ) + + assert estimate["distribution"]["totalMessageFees"] == 80 + assert estimate["feeValue"] == 9_196_840 + assert estimate["messageAllocations"] == message_allocations + assert estimate["message_allocations"] == message_allocations + + +def test_estimate_transaction_fees_from_simulation_builds_trusted_preset(): + client = SimpleNamespace( + chain=SimpleNamespace( + fee_manager_contract=None, + default_number_of_initial_validators=5, + ), + provider=SimpleNamespace( + make_request=Mock( + return_value={ + "result": { + "enabled": True, + "policy": { + "genPerTimeUnit": "10", + "storageUnitPrice": "0", + "receiptGasPrice": "0", + "messageFeeParamsBudgetFloor": "400000", + }, + } + } + ) + ), + ) + + estimate = contract_actions.estimate_transaction_fees_from_simulation( + self=client, + options={ + "simulation": { + "feeAccounting": { + "execution_fee_consumed": "100", + "genvm_message_fee_consumed": "5", + "message_fee_budget": "10", + "message_fee_consumed": "5", + "message_fee_refunded": "0", + "external_message_fee_reserved": "0", + "external_message_fee_reimbursed": "0", + "external_message_fee_remainder": "0", + "execution_fee_report": { + "messageReveal": { + "messages": [ + { + "messageType": "Internal", + "declaredBudget": "5", + } + ] + }, + "totalEstimatedFee": "501664", + }, + } + }, + "priceCapHeadroomBps": 10_000, + }, + ) + + assert estimate["observed"] == { + "executionFeeConsumed": 100, + "executionFeeReportTotal": 501_664, + "recommendedExecutionBudgetPerRound": 602_117, + "genvmMessageFeeConsumed": 5, + "messageFeeBudget": 10, + "messageFeeConsumed": 5, + "messageFeeRefunded": 0, + "internalDeclaredBudget": 5, + "externalMessageReserved": 0, + "externalMessageReimbursed": 0, + "externalMessageRemainder": 0, + "recommendedTotalMessageFees": 6, + } + assert estimate["distribution"]["executionBudgetPerRound"] == 602_117 + assert estimate["distribution"]["totalMessageFees"] == 6 + assert estimate["feeValue"] == 613_123 + + +def test_estimate_transaction_fees_for_write_uses_studio_estimate_rpc(): + fee_params = encode_internal_message_fee_params( + { + "leaderTimeunitsAllocation": 5, + "validatorTimeunitsAllocation": 10, + } + ) + + def make_request(method, params): + if method == "sim_getFeeConfig": + return { + "result": { + "enabled": True, + "policy": { + "genPerTimeUnit": "10", + "storageUnitPrice": "0", + "receiptGasPrice": "1", + "messageFeeParamsBudgetFloor": "400000", + }, + } + } + if method == "sim_estimateTransactionFees": + return { + "result": { + "feeAccounting": { + "execution_fee_consumed": "100", + "message_fee_consumed": "50", + "message_fee_budget": "110", + "message_allocations": [ + { + "messageType": MessageType.Internal, + "onAcceptance": True, + "parentIndex": str(MESSAGE_ALLOCATION_ROOT_PARENT_INDEX), + "recipient": RECIPIENT, + "callKey": "0x" + "00" * 32, + "budget": "110", + "feeParams": fee_params, + } + ], + }, + "feeReport": { + "totalEstimatedFee": "501664", + }, + "recommendedPreset": { + "distribution": { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", + "appealRounds": "0", + "executionBudgetPerRound": "700000", + "executionConsumed": "0", + "totalMessageFees": "110", + "rotations": ["0"], + "maxPriceGenPerTimeUnit": "10", + "storageFeeMaxGasPrice": "0", + "receiptFeeMaxGasPrice": "1", + }, + "messageAllocations": [ + { + "messageType": MessageType.Internal, + "onAcceptance": True, + "parentIndex": str(MESSAGE_ALLOCATION_ROOT_PARENT_INDEX), + "recipient": RECIPIENT, + "callKey": "0x" + "00" * 32, + "budget": "110", + "feeParams": fee_params, + } + ], + "feeValue": "711110", + }, + } + } + raise AssertionError(f"unexpected method {method}") + + client = _make_client(ADD_TRANSACTION_ABI_WITH_FEES) + client.chain.fee_manager_contract = None + client.provider = SimpleNamespace(make_request=Mock(side_effect=make_request)) + + estimate = contract_actions.estimate_transaction_fees_for_write( + self=client, + address=RECIPIENT, + function_name="update_storage", + args=["after"], + value=7, + options={ + "priceCapHeadroomBps": 10_000, + "messageAllocations": [ + { + "messageType": MessageType.Internal, + "recipient": RECIPIENT, + "budget": 110, + "feeParams": fee_params, + } + ], + }, + ) + + sim_call = next( + call + for call in client.provider.make_request.call_args_list + if call.kwargs["method"] == "sim_estimateTransactionFees" + ) + request_params = sim_call.kwargs["params"][0] + assert request_params["value"] == hex(7) + assert request_params["fees"]["feeValue"] == 511_110 + assert request_params["fees"]["distribution"]["totalMessageFees"] == 110 + assert request_params["fees"]["messageAllocations"][0]["budget"] == 110 + assert estimate["observed"]["recommendedExecutionBudgetPerRound"] == 602_117 + assert estimate["observed"]["messageFeeBudget"] == 110 + assert estimate["observed"]["messageFeeConsumed"] == 50 + assert estimate["distribution"]["executionBudgetPerRound"] == 700_000 + assert estimate["distribution"]["totalMessageFees"] == 110 + assert estimate["messageAllocations"][0]["budget"] == 110 + assert estimate["feeValue"] == 711_110 + + +def test_estimate_transaction_fees_from_simulation_preserves_mode2_allocations(): + fee_params = encode_internal_message_fee_params( + { + "leaderTimeunitsAllocation": 5, + "validatorTimeunitsAllocation": 10, + } + ) + client = SimpleNamespace( + chain=SimpleNamespace( + fee_manager_contract=None, + default_number_of_initial_validators=5, + ), + provider=SimpleNamespace( + make_request=Mock( + return_value={ + "result": { + "enabled": True, + "policy": { + "genPerTimeUnit": "10", + "storageUnitPrice": "0", + "receiptGasPrice": "0", + }, + } + } + ) + ), + ) + + estimate = contract_actions.estimate_transaction_fees_from_simulation( + self=client, + options={ + "simulation": { + "feeAccounting": { + "message_fee_consumed": "20", + "message_allocations": [ + { + "messageType": MessageType.Internal, + "onAcceptance": False, + "parentIndex": str(MESSAGE_ALLOCATION_ROOT_PARENT_INDEX), + "recipient": RECIPIENT, + "callKey": "0x" + "00" * 32, + "budget": "50", + "feeParams": fee_params, + } + ], + } + }, + "priceCapHeadroomBps": 10_000, + }, + ) + + assert estimate["messageAllocations"][0]["budget"] == 50 + assert estimate["messageAllocations"][0]["feeParams"] == fee_params + assert estimate["distribution"]["totalMessageFees"] == 50 + assert estimate["feeValue"] == 11_050 + + def test_write_contract_refreshes_consensus_abi_before_add_transaction_encoding( monkeypatch, ): diff --git a/uv.lock b/uv.lock index 9c5651b..f958102 100644 --- a/uv.lock +++ b/uv.lock @@ -434,7 +434,7 @@ wheels = [ [[package]] name = "genlayer-py" -version = "0.16.3" +version = "0.18.0" source = { editable = "." } dependencies = [ { name = "web3" },