From 75f90e2c47297416b7273b0fa3967e207c4c3a6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Mon, 8 Jun 2026 17:06:11 +0100 Subject: [PATCH 01/25] feat(fees): integrate v0.6 tooling (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Release v1.1.7 [skip ci] * ci: scope down release/sync-docs to dedicated GitHub Apps (#168) Replace the over-privileged shared CI bot with two dedicated, minimally scoped GitHub Apps gated behind protected environments. Mirrors the same change in genlayer-testing-suite#78 and genlayer-cli#297. - publish.yml: rename environment npm -> Publish, switch from tibdex/github-app-token@v1 (archived) to actions/create-github-app-token@v3 with vars.PUBLISH_CI_APP_CLIENT_ID + secrets.PUBLISH_CI_APP_KEY. - sync-docs.yml: bump create-github-app-token to @v3, switch to client-id, gate behind the Sync-docs environment with vars.DOCS_SYNC_APP_CLIENT_ID + secrets.DOCS_SYNC_APP_KEY. Each App should be installed only on the repos it needs (Publish: this repo only; Sync-docs: this repo + genlayer-docs) with Contents: read & write as the only permission. * fix(ci): add trigger comment to publish workflow (#170) No-op patch release to verify the publish pipeline end-to-end after the env scope-down (#168) and npm trusted publisher update. * Release v1.1.8 [skip ci] * feat: branch-per-major release model (#172) Aligns this repo with genlayer-node + genvm: branches per major (v1 today; v2 / v2-dev when fees lands), no main, deliberate releases via script + skill. What changes: - publish.yml fires on `push: tags v*` instead of `push: main`. The workflow no longer bumps versions — it just sanity-checks that the tag matches package.json, builds, publishes to npm, and creates the GitHub Release. No commits back to the branch. - scripts/release.sh is the new release entry point. Pre-flight checks: on a v branch, clean tree, in sync with origin, latest CI green. Refuses major bumps without --allow-major (those need a new branch in this model). Then runs release-it with an explicit version to bump package.json, prepend CHANGELOG.md, commit, tag, push — but with npm/github plugins disabled so the dev machine doesn't need npm auth and the GH release is cut by CI from the tag. - .claude/skills/release/SKILL.md documents the flow for Claude: when to use it, what to confirm with the user, what to refuse, and the roll-back path (deprecate, never unpublish blind). - CONTRIBUTING.md explains the branch model + points at the skill. - npm run release → ./scripts/release.sh so the convenience script routes through pre-flight checks (the previous `release-it --ci` invocation bypassed every guard). - .github/e2e-track on this branch points at runner track v0.5 (current stable). v2-dev (when it exists) will point at v0.6-dev. Why kill auto-bump: - Twice landed accidental majors when a BREAKING CHANGE footer in a PR body triggered the conventional-commit bump algorithm — once on this repo (0.28.7 → 1.0.0), once on genlayer-testing-suite (yanked v1/v2 tags). The whatBump cap mitigated future jumps but didn't address the underlying issue: shipping a release shouldn't be a side-effect of merging a PR. - The new flow puts a human checkpoint between "code lands" and "users get it" without losing the bump-and-tag automation. Follow-up (separate PRs): - After this lands: switch default branch on github.com from main to v1, then delete origin/main. - Same shape applied to genlayer-py, genlayer-cli, genlayer-testing-suite, genlayer-explorer (per their existing major). - Runner matrix updates: matrix/v0.5.yaml tooling refs switch from tag pins to branch pins (genlayer-js: v1). * fix: run CI on v1 branch (#173) * fix: run CI on v1 branch * fix: sync testnet consensus abi * ci(workflows): sync e2e.yml from genlayer-e2e (#174) Co-authored-by: ci-core-e2e-runner[bot] <263344042+ci-core-e2e-runner[bot]@users.noreply.github.com> * ci(workflows): sync e2e-housekeeper.yml from genlayer-e2e (#175) Co-authored-by: ci-core-e2e-runner[bot] <263344042+ci-core-e2e-runner[bot]@users.noreply.github.com> * ci(workflows): sync e2e.yml from genlayer-e2e (#176) Co-authored-by: ci-core-e2e-runner[bot] <263344042+ci-core-e2e-runner[bot]@users.noreply.github.com> * ci(workflows): sync e2e-housekeeper.yml from genlayer-e2e (#177) Co-authored-by: ci-core-e2e-runner[bot] <263344042+ci-core-e2e-runner[bot]@users.noreply.github.com> * feat: add v0.6 fee-aware transactions * fix: handle studio raw transaction hashes * fix: align studio fee floor fallback * fix: satisfy fee estimate typecheck --------- Co-authored-by: github-actions[bot] Co-authored-by: Rafał Rabenda <99408377+rrabenda@users.noreply.github.com> Co-authored-by: ci-core-e2e-runner[bot] <263344042+ci-core-e2e-runner[bot]@users.noreply.github.com> --- .claude/skills/release/SKILL.md | 99 ++ .github/e2e-track | 2 +- .github/workflows/chains-drift.yml | 4 +- .github/workflows/e2e-housekeeper.yml | 84 ++ .github/workflows/e2e.yml | 938 ++++++++++++---- .github/workflows/publish.yml | 69 +- .github/workflows/sync-docs.yml | 5 +- .github/workflows/test.yml | 4 +- CHANGELOG.md | 9 + CONTRIBUTING.md | 22 + README.md | 155 +++ package-lock.json | 4 +- package.json | 4 +- scripts/release.sh | 148 +++ src/chains/testnetAsimov.ts | 25 + src/chains/testnetBradbury.ts | 25 + src/contracts/actions.ts | 1480 +++++++++++++++++++++++-- src/index.ts | 1 + src/transactions/fees.ts | 226 ++++ src/types/clients.ts | 57 +- src/types/transactions.ts | 269 ++++- tests/contracts-actions.test.ts | 1243 ++++++++++++++++++++- 22 files changed, 4485 insertions(+), 388 deletions(-) create mode 100644 .claude/skills/release/SKILL.md create mode 100644 .github/workflows/e2e-housekeeper.yml create mode 100755 scripts/release.sh create mode 100644 src/transactions/fees.ts diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..dc916d8 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,99 @@ +--- +name: release +description: Cut a release of genlayer-js. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to npm and creates the GitHub Release. Use when a human asks "release v1.x.y" or "ship a new version". +--- + +# Release skill — genlayer-js + +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 v1.2.0" +- "ship a patch" +- "cut a new minor" +- "tag the latest fix as a release" + +If they ask "publish to npm directly" — refuse and point at this flow. The repo doesn't have an unprotected npm 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: `v1` (current stable), `v2-dev` / `v2` (next major when it exists). +- Tags live within those branches: `v1.1.9`, `v1.2.0`, ... +- A major bump means **cutting a new branch**, not tagging on the current one. The release script refuses major bumps unless `--allow-major` is passed. +- `CHANGELOG.md` is updated in the release commit (release-it via `@release-it/conventional-changelog`). +- `publish.yml` fires on the tag push and does the npm publish + GitHub Release. + +## Steps + +1. **Confirm intent with the user.** + - Which version? If unspecified, ask whether it's patch / minor / explicit. + - Which branch? Default `v1`. If they're shipping a back-port to an older major, the branch is `v`. + +2. **Switch to the target branch + sync.** + ```bash + git checkout v1 + git pull --ff-only origin v1 + ``` + If the working tree isn't clean, stop and surface what's there — never stash and ship. + +3. **Verify the head is shippable.** + - Latest CI run on this commit is green (the release script also checks, but check first so you don't half-run the script): + ```bash + gh run list --branch v1 --commit "$(git rev-parse HEAD)" --limit 1 + ``` + - Inspect the last few commits since the previous tag for surprises: + ```bash + git log "$(git describe --tags --abbrev=0)..HEAD" --oneline + ``` + - If anything looks unexpected (e.g. an in-flight refactor accidentally landed), surface it and wait for the user's call. + +4. **Run the release script.** + ```bash + scripts/release.sh # or patch / minor + ``` + It will: bump `package.json`, prepend `CHANGELOG.md`, commit `Release v [skip ci]`, tag `v`, and push both the branch commit and the tag. It will NOT publish to npm — CI handles that. + +5. **Watch the publish workflow.** + ```bash + gh run watch + ``` + or + ```bash + gh run list --workflow=publish.yml --limit 1 + ``` + If `publish.yml` fails (typical causes: tag/package.json mismatch — caused by hand-editing `package.json` outside the script; `NPM_TOKEN` rotated; npm provenance check), report the failure verbatim and stop. Do not retry blindly. + +6. **Confirm on npm.** + ```bash + npm view genlayer-js dist-tags + ``` + The `latest` tag should show the new version. Report back to the user with the version and the GitHub Release URL. + +## Things to refuse + +- **Major bump on the current branch** without `--allow-major`. The right move for a major is a new branch + new track in the runner matrix (separate workflow). +- **Releasing from `main`** — `main` is retired. If somehow `main` exists locally, the script will refuse; explain why. +- **Hand-editing `package.json` to bump the version** instead of running the script. The script keeps `package.json`, the CHANGELOG entry, the commit message, and the tag in lockstep; doing it by hand drifts them. +- **Publishing a tag where `publish.yml` failed** — fix the underlying issue, re-cut the release (delete the bad tag both locally and on origin, re-run the script). Don't manually `npm publish`. + +## Roll-back + +If a release shipped but is broken: + +1. **Don't unpublish from npm** unless someone with elevated permissions has assessed the impact — npm unpublish has a 72-hour window and a deprecation path that consumers prefer. +2. **Deprecate the bad version**: + ```bash + npm deprecate "genlayer-js@" "broken release; install or later" + ``` +3. **Ship a follow-up patch** via the same flow (`scripts/release.sh patch`). + +## Why no auto-bump? + +The previous flow auto-bumped on every push to `main`, which: +- Twice landed accidental major bumps (`0.28.7 → 1.0.0`, `v1-prerelease → v2-yanked` in testing-suite) because conventional-commit `BREAKING CHANGE` notes are too easy to drop into a PR. +- Tied "shipping a release" to "merging a PR", which conflated two decisions. +- Left no human checkpoint between "code lands" and "users get it". + +Manual + scripted is the trade we made: small overhead per release in exchange for never shipping a surprise. diff --git a/.github/e2e-track b/.github/e2e-track index 4d490aa..74d5120 100644 --- a/.github/e2e-track +++ b/.github/e2e-track @@ -1 +1 @@ -v0.6-dev +v0.6 diff --git a/.github/workflows/chains-drift.yml b/.github/workflows/chains-drift.yml index a97289e..55a1d22 100644 --- a/.github/workflows/chains-drift.yml +++ b/.github/workflows/chains-drift.yml @@ -3,10 +3,10 @@ name: Chains Drift Check on: pull_request: branches: - - main + - v1 push: branches: - - main + - v1 schedule: # Daily at 07:00 UTC. Catches upstream contract deployments even when no # PRs are open against the SDK. diff --git a/.github/workflows/e2e-housekeeper.yml b/.github/workflows/e2e-housekeeper.yml new file mode 100644 index 0000000..50e64a6 --- /dev/null +++ b/.github/workflows/e2e-housekeeper.yml @@ -0,0 +1,84 @@ +name: E2E Housekeeper + +# =========================================================================== +# Source-of-truth E2E housekeeper workflow. +# +# This file is BOTH: +# - The workflow that runs in this repo (genlayer-e2e) for genlayer- +# e2e's own cache pool. +# - The file synced byte-identically to every consumer repo as +# `.github/workflows/e2e-housekeeper.yml` (sync-template.yaml owns +# the fan-out). +# +# Mirrors the architecture of e2e-pipeline.yml — one file in the source +# repo, copied verbatim to consumers, no wrapper layer. Adding a new +# scheduled upkeep step (artifact pruning, runner sweep, …) only needs +# editing this file; sync-template.yaml opens a PR in each consumer. +# +# Cache storage is per-repo, so the eviction step runs in the CALLER's +# context: `gh cache list` / `gh cache delete` operate on the caller's +# pool via the inherited GITHUB_TOKEN. The runner's filesystem starts +# empty, so step 1 checks out genlayer-e2e to access the extracted +# `evict-stale-caches.sh` script — github.token in a synced consumer +# context still has cross-org read access on the org's private repos. +# =========================================================================== + +on: + schedule: + - cron: '0 6 * * *' # daily 06:00 UTC + # No inputs: production triggers (schedule today, PR-merged later) + # don't pass them, and the script's defaults (24h idle / 200 page + # cap) are stable. workflow_dispatch stays as a no-arg "run the + # cron now" button — handy in genlayer-e2e while iterating. + workflow_dispatch: + +# Least-privilege. `evict-stale-caches.sh` invokes `gh cache list` +# (read) and `gh cache delete` (write) against the caller's cache pool +# via secrets.GITHUB_TOKEN. The GHA Cache API lives under the actions: +# permission namespace, so deletion requires actions:write. `contents: +# read` covers the checkout that fetches this repo's scripts. +permissions: + actions: write + contents: read + +# Serialize runs so two overlapping firings (manual dispatch + schedule) +# don't both try to delete the same entry. +concurrency: + group: cache-cleanup + cancel-in-progress: false + +jobs: + evict-stale: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # Pull the extracted script. Explicit `repository:` because — when + # this file is synced to a consumer — actions/checkout's default + # target is the CONSUMER's repo, not genlayer-e2e. github.token + # in the consumer context has org-wide read on private repos so + # the clone works without an App token. + - name: Checkout genlayer-e2e + uses: actions/checkout@v6 + with: + repository: genlayerlabs/genlayer-e2e + token: ${{ github.token }} + + - name: Delete idle caches + env: + # `gh cache delete` consumes GH_TOKEN. github.repository + # resolves to the caller's repo (where the cron fires + the + # cache pool lives). + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: ./taskfiles/housekeeper/scripts/evict-stale-caches.sh --age 24 --limit 200 + + # Companion sweep for artifacts (logs, shard outputs, per-component + # summaries). Same 24h idle window so the two sweeps stay in sync. + # `if: always()` so an early cache-sweep failure doesn't skip the + # artifact pass — they're independent. + - name: Delete idle artifacts + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: ./taskfiles/housekeeper/scripts/evict-stale-artifacts.sh --age 24 --limit 1000 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 509f1ea..7ffb8e3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,292 +1,740 @@ -# Reference caller workflow for any component repo (genlayer-node, -# -consensus, -js, -py, -cli, -explorer, -testing-suite). Copy to -# .github/workflows/e2e.yml in the target repo. +# Source-of-truth E2E pipeline workflow. # -# Triggers: -# 1. /run-e2e comment by a member/collaborator → runs against stable -# track by default, optional track selection via `/run-e2e ` -# or `/run-e2e `. -# 2. PR opened / synchronized / reopened / ready_for_review — AUTO-GATE. -# Runs E2E as a required check. Track defaults to `main`. -# 3. PR approved — auto-runs E2E (dedup if a prior run covered the SHA). +# This file is BOTH: +# - The workflow that runs in this repo (genlayer-e2e) for dispatch / +# local testing. +# - The file synced verbatim to every consumer repo as +# .github/workflows/e2e.yml (sync-template.yaml owns the fan-out). # -# Track = which branch of ci-core-e2e-runner to run against. `main` is the -# stable track; `v0.6-dev` etc. are in-flight tracks. Each track ships -# its own matrix/ file, tests, and resolver. +# Consumers don't carry a separate thin caller — keeping the consumer's +# workflow file BYTE-IDENTICAL to this one means: +# - Sidebar tiles stay flat: `acknowledge / register`, `plan / action`, +# `build / discover`, `genlayer-core / shard ... / e2e`, `result` — +# no wrapper-job prefix. A consumer-side thin caller would force +# ` /` on every inner tile (see #386 follow-up where +# deleting e2e-harness.yml restored the flat layout). +# - Behavior changes ship via one sync PR per consumer, instead of +# each consumer hand-editing their wrapper. +# +# Two trigger paths: +# - `issue_comment` — PR comment `/run-e2e [profile] [track] [scope]` +# on the consumer's repo. The acknowledge job's `if:` gates on +# author-association so only members/owners/collaborators can fire +# the pipeline. +# - `workflow_dispatch` — manual / debug in this repo (UI dropdowns). +# Acknowledge is skipped (no PR comment context); plan/build/waves +# run with workflow_dispatch input values. +# +# All internal `uses:` references are cross-repo +# (`genlayerlabs/genlayer-e2e/.github/workflows/X.yml@main`) so the same +# file works whether it lives in this repo or in a synced consumer. +# Feature-branch testing requires sedding `@main` → `@` on the +# inner uses; see feedback_branch_pin_for_testing.md. +# +# Sync-time hack — `on: issue_comment:` injection +# ------------------------------------------------ +# The source-of-truth file in genlayer-e2e does NOT declare an +# `on: issue_comment:` trigger. If it did, this workflow would fire on +# EVERY comment in genlayer-e2e's own PRs (coderabbit, dependabot, …) +# and consume a 3-second skipped "noop" run per comment — visible +# clutter on the Actions list. +# +# `sync-templates.sh` injects the `issue_comment:` trigger block at +# sync time at the `# SYNC_INJECT(issue_comment)` marker below. The +# synced consumer copy gets the trigger; genlayer-e2e's source never +# does. Manual debug here still works via the `workflow_dispatch:` +# trigger which IS declared. +# +# The runtime expressions below (run-name, concurrency, acknowledge's +# `if:`, build/wave `pr-number` / `comment-id` coalesces) all reference +# `github.event_name == 'issue_comment'` and `github.event.comment.*` +# — those evaluate cleanly to false / empty on the workflow_dispatch +# path in genlayer-e2e, so they stay unconditional and work for both +# consumer and source contexts. Only the trigger declaration itself +# is injected. + +name: E2E Pipeline -name: E2E Tests +# Tag every run with a descriptive title: +# issue_comment /run-e2e: PR # /run-e2e +# workflow_dispatch: E2E Test // +# anything else: noop +# +# Manual dispatch is only available on the source repo (genlayer-e2e +# itself, never on a synced consumer), and is intentionally unrestricted +# — there's no per-actor concurrency group, so a developer can fire +# several test runs side-by-side. Putting profile/track/scope in the +# title makes those runs distinguishable at a glance on the Actions +# list (where the bare run_id was previously opaque). +run-name: >- + ${{ (github.event_name == 'issue_comment' + && startsWith(github.event.comment.body, '/run-e2e') + && format('PR #{0} /run-e2e', github.event.issue.number)) + || (github.event_name == 'workflow_dispatch' + && format('E2E Test {0}/{1}/{2}/{3}', inputs.profile, inputs.track, inputs.scope, inputs.stack)) + || format('noop {0}', github.run_id) }} on: issue_comment: types: [created] - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - pull_request_review: - types: [submitted] + workflow_dispatch: + inputs: + profile: + description: Profile name (must be a top-level key in profiles.json) + required: false + default: default + type: choice + options: + - default + - testnet + track: + description: Matrix track (must exist as matrix/.yaml) + required: false + default: v0.5 + type: choice + options: + - v0.5 + scope: + description: > + Wave-plan scope filter. `all` runs every wave (default); + `core` runs only wave-1 (genlayer-node / test-core); + `tooling` runs waves 2-4 (SDKs + explorer + testing-suite + wallet). + Out-of-scope waves cascade-skip with reason "scope". + required: false + default: all + type: choice + options: + - all + - core + - tooling + stack: + description: > + Stack-target filter. `all` exercises every declared stack + (default); `dev-env` runs only the genlayer-dev-env variants; + `studio` runs only the GenLayer Studio variants. Components + whose declared `stack-targets` don't include the chosen + stack cascade-skip with reason "stack". + required: false + default: all # SYNC_INJECT(default_stack) + type: choice + options: + - all + - dev-env + - studio permissions: contents: read id-token: write issues: write checks: write + actions: write pull-requests: write -# Per-PR group so pushes cancel in-flight E2E on the same PR. +# /run-e2e triggers share a per-PR group so a new comment cancels the +# previous in-progress run. Other issue_comment events (and +# workflow_dispatch) get a unique group so they never queue behind a +# hung /run-e2e. concurrency: - group: e2e-${{ github.repository }}-pr-${{ github.event.issue.number || github.event.pull_request.number }} + group: >- + ${{ (github.event_name == 'issue_comment' + && startsWith(github.event.comment.body, '/run-e2e')) + && format('e2e-{0}-pr-{1}', github.repository, github.event.issue.number) + || format('e2e-noop-{0}', github.run_id) }} cancel-in-progress: true env: GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID || 'devops-infra-428314' }} GCP_WIF_PROVIDER: ${{ vars.GCP_WIF_PROVIDER || 'projects/795309574007/locations/global/workloadIdentityPools/ci-core-e2e-runner/providers/ci-core-e2e-runner' }} GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT || 'ci-core-e2e-runner@devops-workload-identities.iam.gserviceaccount.com' }} - GCP_SECRET_APP_ID: ${{ vars.GCP_SECRET_APP_ID || 'ci-core-e2e-runner-app-id' }} + GCP_SECRET_APP_CLIENT_ID: ${{ vars.GCP_SECRET_APP_CLIENT_ID || 'ci-core-e2e-runner-app-client-id' }} GCP_SECRET_APP_PRIVATE_KEY: ${{ vars.GCP_SECRET_APP_PRIVATE_KEY || 'ci-core-e2e-runner-app-private-key' }} + # Suppress notify-outcome's compact PR banner. Detailed results + # table still posted. Remove to re-enable. + E2E_REPORT_SKIP_RESULT: 'true' jobs: # =========================================================================== - # Job 0: Parse trigger event → compute PR context, runner-ref, matrix-file - # Gates on author association (members/collaborators/owners only), draft - # status (skip drafts), and fork status (skip external PRs — they can't - # safely access secrets on pull_request; the commenter path re-gates). + # acknowledge — PR-side bookkeeping and comment tokenization. + # + # The `if:` guard lives here (not inside e2e-acknowledge.yml) because + # only the caller can guard on `github.event_name` + comment author + # association before the reusable workflow is even resolved. On + # workflow_dispatch, `github.event.issue` is null → guard false → + # acknowledge is skipped (and the plan job's `if:` opts back in for + # the dispatch path). + # =========================================================================== + acknowledge: + # PR-state guard: refuse /run-e2e on closed / merged PRs. After a + # PR merges, its head branch is typically deleted, which breaks + # every "content at PR head" fetch (third_party version files, + # target-repo matrix.yaml) — the resolver silently falls through + # to the baseline matrix and downstream cache keys drift off + # whatever the baseline's branch tip happens to be at that moment. + # If you actually need to retest, re-run on main or open a fresh + # PR with the same content. + if: >- + github.event.issue.pull_request + && github.event.issue.state == 'open' + && startsWith(github.event.comment.body, '/run-e2e') + && contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association) + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-acknowledge.yml@main + with: + comment-id: ${{ github.event.comment.id }} + comment-body: ${{ github.event.comment.body }} + issue-number: ${{ github.event.issue.number }} + target-repo: ${{ github.repository }} + server-url: ${{ github.server_url }} + run-id: ${{ github.run_id }} + secrets: inherit + # =========================================================================== - parse: + # plan — pin profile / track / matrix-refs / wave-plans / cache-key. + # Dual-path internally (PR vs dispatch); see e2e-planner.yml. + # + # `if:` opts back in on workflow_dispatch where acknowledge is + # intentionally skipped. PR-comment path with an unauthorized author + # (acknowledge skipped because guard failed) does NOT re-enter here — + # the `github.event_name == 'workflow_dispatch'` clause filters it out. + # =========================================================================== + plan: + needs: acknowledge if: | - (github.event_name == 'issue_comment' - && github.event.issue.pull_request - && startsWith(github.event.comment.body, '/run-e2e') - && contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)) - || - (github.event_name == 'pull_request' - && !github.event.pull_request.draft - && github.event.pull_request.head.repo.full_name == github.repository - && contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.pull_request.author_association)) - || - (github.event_name == 'pull_request_review' - && github.event.review.state == 'approved' - && github.event.pull_request.head.repo.full_name == github.repository - && contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.review.author_association)) - runs-on: ubuntu-latest - outputs: - pr-number: ${{ steps.parse.outputs.pr_number }} - comment-id: ${{ steps.parse.outputs.comment_id }} - profile: ${{ steps.parse.outputs.profile }} - runner-ref: ${{ steps.parse.outputs.runner_ref }} - matrix-file: ${{ steps.parse.outputs.matrix_file }} - steps: - - name: Parse trigger - id: parse - env: - EVENT_NAME: ${{ github.event_name }} - COMMENT_BODY: ${{ github.event.comment.body }} - GH_TOKEN: ${{ github.token }} - # Allowlist of valid track names. Mirrors the convention - # documented in ci-core-e2e-runner README.md (Participation - # section). Reject anything else — the resolved track ends up - # as both the matrix-fetch ref and the runner-checkout ref, so - # arbitrary input would let a comment author point at any - # branch/SHA on the runner repo. - TRACK_PATTERN: '^(main|v[0-9]+(\.[0-9]+)?(-dev)?)$' - run: | - set -euo pipefail + !cancelled() && + (needs.acknowledge.result == 'success' || + (needs.acknowledge.result == 'skipped' && github.event_name == 'workflow_dispatch')) + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-planner.yml@main + with: + # Coalesce: acknowledge tokens win on the PR-comment path; on + # workflow_dispatch the tokens are empty and we fall back to + # the manual choice inputs. + profile: ${{ needs.acknowledge.outputs.profile-token || inputs.profile }} + track: ${{ needs.acknowledge.outputs.track-token || inputs.track }} + scope: ${{ needs.acknowledge.outputs.scope-token || inputs.scope }} + # SYNC_INJECT(default_stack_fallback) rewrites the literal 'all' on + # consumer copies (e.g. 'dev-env' for genlayer-node), so an + # issue_comment `/run-e2e` with no stack token still routes to + # the per-consumer default — inputs.stack is workflow_dispatch- + # only and resolves empty on the issue_comment path. + stack: ${{ needs.acknowledge.outputs.stack-token || inputs.stack || 'all' }} # SYNC_INJECT(default_stack_fallback) + target-repo: ${{ github.repository }} + pr-number: ${{ github.event.issue.number || '' }} + comment-id: ${{ github.event.comment.id || '' }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + # acknowledge picks the layered-cache cleavage based on the + # consumer repo. On workflow_dispatch acknowledge is skipped + # and its output is empty — the planner's empty default then + # disables layering (today's pre-Slice-A behaviour). + pre-build-cache: ${{ needs.acknowledge.outputs.pre-build-cache || '' }} - # ----- 1. Identify the PR + base ref + comment override (if any) ----- - override_track="" - case "${EVENT_NAME}" in - issue_comment) - pr_number='${{ github.event.issue.number }}' - comment_id='${{ github.event.comment.id }}' - # /run-e2e [] [] - # token 1 = /run-e2e; tokens after = profile and/or track. - # A token starting with 'v' or ending in '-dev' is the track, - # else it's the profile name. Either may be omitted. - args=$(echo "${COMMENT_BODY}" | head -1) - profile="default" - for tok in ${args}; do - [[ "${tok}" == "/run-e2e" ]] && continue - if [[ "${tok}" =~ ^(v[0-9]|main$|.*-dev$) ]]; then - override_track="${tok}" - else - profile="${tok}" - fi - done - # Need to fetch the PR to learn its base ref for the file lookup. - base_ref=$(gh api "/repos/${{ github.repository }}/pulls/${pr_number}" --jq '.base.ref') - ;; - pull_request|pull_request_review) - pr_number='${{ github.event.pull_request.number }}' - comment_id='' - profile='default' - base_ref='${{ github.event.pull_request.base.ref }}' - ;; - esac + # =========================================================================== + # build — full stack up + pack to cache. Synthetic PR context on the + # dispatch path: run_id stands in for pr-number; comment-id / + # check-run-id stay empty so downstream PR-facing steps are no-ops. + # =========================================================================== + build: + name: build (dev-env) + needs: [acknowledge, plan] + # Without an explicit `if:`, GHA's implicit `success()` would require + # acknowledge to have succeeded — but acknowledge is intentionally + # skipped on the workflow_dispatch path. Mirror the wave jobs and + # gate only on plan succeeding. + # + # Skip when the stack filter omits dev-env. `/run-e2e studio` makes + # every wave row's stack-target='studio' (build-studio handles + # those), so the dev-env bundle isn't needed. Mirrors build-studio's + # inverse gate below. + if: | + !cancelled() && + needs.plan.result == 'success' && + contains(needs.plan.outputs.stack-config, '"target":"dev-env"') + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-build.yml@main + with: + track: ${{ github.ref_name }} + profile: ${{ needs.plan.outputs.profile }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + consensus-sha: ${{ needs.plan.outputs.consensus-sha }} + genlayer-node-sha: ${{ needs.plan.outputs.genlayer-node-sha }} + resolved-shas-json: ${{ needs.plan.outputs.resolved-shas-json }} + build-cache-key: ${{ needs.plan.outputs.build-cache-key }} + full-cache-key: ${{ needs.plan.outputs.full-cache-key }} + pre-build-cache: ${{ needs.plan.outputs.pre-build-cache }} + # Mirrors matrix/.yaml shape ({core, harness, tooling}). The + # conclusion job's Emit build summary step parses this with jq + # to render the full component Refs sub-list in the Execution + # block. Trailing JSON commas would be invalid — keep the same + # field set as the matrix file. + matrix-json: >- + {"core":{"genlayer-node":"${{ needs.plan.outputs.genlayer-node-ref }}","genlayer-consensus":"${{ needs.plan.outputs.consensus-ref }}","genvm":"${{ needs.plan.outputs.genvm-version }}"},"harness":{"genlayer-dev-env":"${{ needs.plan.outputs.harness-ref }}"},"tooling":{"genlayer-js":"${{ needs.plan.outputs.genlayer-js-ref }}","genlayer-py":"${{ needs.plan.outputs.genlayer-py-ref }}","genlayer-cli":"${{ needs.plan.outputs.genlayer-cli-ref }}","genlayer-studio":"${{ needs.plan.outputs.genlayer-studio-ref }}","genlayer-explorer":"${{ needs.plan.outputs.genlayer-explorer-ref }}","genlayer-testing-suite":"${{ needs.plan.outputs.genlayer-testing-suite-ref }}","genvm-linter":"${{ needs.plan.outputs.genvm-linter-ref }}","genlayer-wallet":"${{ needs.plan.outputs.genlayer-wallet-ref }}"}} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} - # ----- 2. Resolve track: comment override > .github/e2e-track > main ----- - # The base branch's `.github/e2e-track` file is the source of - # truth for which version-matrix track this branch's PRs run - # against. Each long-lived branch (v1, v2-dev, etc.) declares - # its target in this file. See the runner repo's README, - # "Participation" section, for the lifecycle. - if [[ -n "${override_track}" ]]; then - track="${override_track}" - track_source="comment override" - else - track=$(gh api "/repos/${{ github.repository }}/contents/.github/e2e-track?ref=${base_ref}" \ - --jq '.content' 2>/dev/null | base64 -d | tr -d '[:space:]' || echo "") - if [[ -n "${track}" ]]; then - track_source=".github/e2e-track on ${base_ref}" - else - track="main" - track_source="fallback (no .github/e2e-track on ${base_ref})" - echo "::warning::No .github/e2e-track on ${base_ref}. Falling back to main track. Add the file to make this branch's track explicit — see ci-core-e2e-runner README." - fi - fi + # =========================================================================== + # build-studio — bring up a Studio stack and pack it for Studio-target shards. + # Runs in parallel with the dev-env build. Devenv-target wave rows continue + # to consume the existing full-cache-key; Studio-target rows restore the + # per-run Studio bundle produced here. + # =========================================================================== + build-studio: + name: build (studio) + needs: [acknowledge, plan] + if: | + !cancelled() && + needs.plan.result == 'success' && + contains(needs.plan.outputs.stack-config, '"target":"studio"') + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-build-studio.yml@main + with: + track: ${{ github.ref_name }} + profile: ${{ needs.plan.outputs.profile }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} - # ----- 3. Validate track against allowlist ----- - if ! [[ "${track}" =~ ${TRACK_PATTERN} ]]; then - echo "::error::Invalid track '${track}' (source: ${track_source}). Must match ${TRACK_PATTERN}." - exit 1 - fi + # =========================================================================== + # Wave jobs — cascade pattern with sentinel-as-skip. Each wave matrix + # comes from `needs.plan.outputs.wave-plans` (one JSON object folding + # every per-wave plan array). + # =========================================================================== + wave-1: + # `name:` overrides GHA's default `wave-1 (component, test-task, …)` + # matrix-tuple display. Reads matrix.job-name (resolved from + # components.yaml's `job-name` field, falling back to the + # component key) so the listing reads e.g. `genlayer-core / shard + # (shard-1, true) / e2e`. + # + # When build fails, append "(skipped - build fails)" to the tile so + # the UI attributes the skip to its root-cause layer. The + # `matrix.component != 'none'` guard prevents existing sentinel rows + # (e.g., scope-filtered or impacted-set empty waves) from being + # relabelled — those keep their baked-in "(skipped - manual)" / + # "(skipped - scope)" / "(skipped - no scenarios)" label unchanged. + # Waves 2-4 mirror this pattern with extra upstream-wave clauses + # reading `needs.wave-K.outputs.failure-label` (the per-component + # layer tag emitted by e2e-run.yml when that wave's run failed). + name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || matrix.job-name }} + needs: [acknowledge, plan, build, build-studio] + # No `build-status == 'success'` gate — when build fails we want + # wave-1 to RUN (so the `name:` expression evaluates and tiles + # render cleanly) but no-op via the sentinel-component override + # below. Same mechanism as the existing wave-2/3/4 cascade. + # + # `!cancelled() &&` bypasses GHA's implicit needs-failure-cascade: + # when an upstream `needs:` job (here, `build`) fails, GHA defaults + # to auto-skipping downstream jobs unless their `if:` explicitly + # opts in via `always()` / `!cancelled()` / `failure()`. A skipped + # job does NOT have its `name:` expression evaluated, so without + # this prefix wave-1's tile shows the raw `${{ ... }}` text on + # build failure. Waves 2-4 already carry the same prefix. + # + # resolve-components / build-wave-plans emit a sentinel-row plan + # (component='none') when no impacted component maps to this wave, + # so the matrix always has at least one row and GHA can render + # `${{ matrix.job-name }}` for the placeholder tile (per + # actions/runner#1985, the unresolved name expression needs a + # matrix row to bind to). The sentinel's empty features-source / + # tags / test-task naturally cascade through e2e-run.yml's + # allocate → shard → conclusion chain to a no-op success — see + # resolve-components/action.yml for the full cascade explanation. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-1'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: if build did NOT produce a clean verdict, + # force the sentinel path so wave-1 no-ops cleanly. Same + # mechanism waves 2-4 use for upstream-wave failures — see + # e2e-run.yml's sentinel branches. + component: ${{ ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) && 'none' || matrix.component }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} - # ----- 4. Map track → matrix file ----- - # Matrix file convention: matrix/.yaml — with the - # historical exception that `main` track reads the v0.5 - # stable matrix until v0.6 becomes stable. - case "${track}" in - main) matrix_file='matrix/v0.5.yaml' ;; - *) matrix_file="matrix/${track}.yaml" ;; - esac + wave-2: + # Cascade-skip label rules (walked in order, first match wins): + # 1. build failed → "(skipped - build fails)" + # 2. wave-1's failure-label is non-empty (e.g. "core") → + # "(skipped - {label} fails)" + # 3. wave-1 failed but emitted no tag (failure-label empty) → + # generic "(skipped - fails)" + # else → matrix.job-name (real run or sentinel-baked label) + # + # `failure-label` is e2e-run.yml's per-component output, populated + # from `inputs.failure-tag` only when the run's test-conclusion is + # 'failure'. Empty otherwise — so a successful or sentinel-skipped + # wave doesn't carry a stale tag forward. + name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || (matrix.component != 'none' && needs.wave-1.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-1.outputs.failure-label) || (matrix.component != 'none' && needs.wave-1.result == 'failure') && format('{0} (skipped - fails)', matrix.job-name) || matrix.job-name }} + needs: [acknowledge, plan, build, build-studio, wave-1] + # No cascade gate — wave-2 always runs when plan is OK. If + # build or wave-1 failed, with.component is overridden to 'none' + # below, tripping e2e-run.yml's sentinel path (allocate/shard/ + # check-run/retry/cleanup-artifacts skip; conclusion emits + # 'skipped'). Same flow as manual skip:true. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-2'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: force sentinel path when any upstream layer + # (build / wave-1) failed. `.result == 'failure'` is the + # reliable trip signal — failure-label is only used for the + # tile label content above, not the trip decision. + component: ${{ (((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) || needs.wave-1.result == 'failure') && 'none' || matrix.component }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} - # ----- 5. Emit outputs + summary ----- - { - echo "pr_number=${pr_number}" - echo "comment_id=${comment_id}" - echo "profile=${profile}" - echo "runner_ref=${track}" - echo "matrix_file=${matrix_file}" - } >> "$GITHUB_OUTPUT" + wave-3: + # See wave-2 for the cascade-skip label semantics. Walk order + # (first match wins): build → wave-1 failure-label → wave-2 + # failure-label → generic-fallback. The `.result == 'failure'` + # fallback catches the rare case where an upstream wave failed + # but didn't emit a failure-tag (e.g. internal job-level error + # before conclusion ran). + name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || (matrix.component != 'none' && needs.wave-1.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-1.outputs.failure-label) || (matrix.component != 'none' && needs.wave-2.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-2.outputs.failure-label) || (matrix.component != 'none' && (needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure')) && format('{0} (skipped - fails)', matrix.job-name) || matrix.job-name }} + needs: [acknowledge, plan, build, build-studio, wave-1, wave-2] + # No cascade gate — wave-3 always runs. If build or any upstream + # wave failed, with.component is overridden to 'none' below, + # tripping e2e-run.yml's sentinel path (no GCE provisioned, + # conclusion emits 'skipped'). See wave-2 for the full rationale. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-3'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: force sentinel path when any upstream layer + # (build / wave-1 / wave-2) failed. Uses `.result == 'failure'` + # for reliability — failure-label is only consumed for the + # tile label content above, not the trip decision. + component: ${{ (((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) || needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure') && 'none' || matrix.component }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} - echo "Event: ${EVENT_NAME}" - echo "PR: ${pr_number}" - echo "Base ref: ${base_ref}" - echo "Profile: ${profile}" - echo "Track: ${track} (source: ${track_source})" - echo "Matrix file: ${matrix_file}" + wave-4: + # See wave-2 for the cascade-skip label semantics. Walk order + # (first match wins): build → wave-1 → wave-2 → wave-3 failure- + # label, then generic-fallback via `.result == 'failure'`. + name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || (matrix.component != 'none' && needs.wave-1.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-1.outputs.failure-label) || (matrix.component != 'none' && needs.wave-2.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-2.outputs.failure-label) || (matrix.component != 'none' && needs.wave-3.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-3.outputs.failure-label) || (matrix.component != 'none' && (needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure' || needs.wave-3.result == 'failure')) && format('{0} (skipped - fails)', matrix.job-name) || matrix.job-name }} + needs: [acknowledge, plan, build, build-studio, wave-1, wave-2, wave-3] + # No cascade gate — wave-4 always runs. See wave-2 for the + # cascade-as-sentinel rationale. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-4'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: force sentinel path when any upstream layer + # (build / wave-1 / wave-2 / wave-3) failed. + component: ${{ (((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) || needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure' || needs.wave-3.result == 'failure') && 'none' || matrix.component }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} # =========================================================================== - # Job 1: Generate feedback (eyes reaction, check run), resolve versions + # result — final guard that makes the workflow's conclusion reflect + # the REAL outcome. Mirrors `plan` at the start (plan → build → + # waves → result). + # + # Per-wave verdict comes from each wave's `test-conclusion` output + # (sourced from e2e-run.yml's `conclusion` job), which reflects retry + # recovery — so a first-try shard failure that retry recovered + # counts as 'success'. When test-conclusion is empty (wave was + # skipped or never reached the conclusion job), fall back to GHA's + # `.result` for the rough verdict. # =========================================================================== - resolve: - needs: parse + result: + needs: + - acknowledge + - plan + - build + - build-studio + - wave-1 + - wave-2 + - wave-3 + - wave-4 + # `always() && plan.result != 'skipped'` rather than bare `always()`: + # noop issue_comments (any non-/run-e2e comment in this repo, since + # the workflow lives at `on: issue_comment`) skip acknowledge → plan + # → build → waves all the way down. Without this guard, result still + # runs, and its env block resolves + # `toJson(fromJson(needs.plan.outputs.wave-plans)['wave-N'])` against + # plan's empty output → fromJson('') fails the template at + # job-start (see run 26223668789). Letting result skip on + # plan==skipped keeps the noop pipeline clean — `acknowledge / plan / + # build / waves / result` all skipped, no red marker. + if: always() && needs.plan.result != 'skipped' runs-on: ubuntu-latest - outputs: - profile: ${{ steps.resolve.outputs.profile }} - pr-number: ${{ steps.resolve.outputs.pr-number }} - comment-id: ${{ steps.resolve.outputs.comment-id }} - repo: ${{ steps.resolve.outputs.repo }} - genvm-version: ${{ steps.resolve.outputs.genvm-version }} - genlayer-node-ref: ${{ steps.resolve.outputs.genlayer-node-ref }} - consensus-ref: ${{ steps.resolve.outputs.consensus-ref }} - genlayer-js-ref: ${{ steps.resolve.outputs.genlayer-js-ref }} - genlayer-py-ref: ${{ steps.resolve.outputs.genlayer-py-ref }} - genlayer-cli-ref: ${{ steps.resolve.outputs.genlayer-cli-ref }} - genlayer-explorer-ref: ${{ steps.resolve.outputs.genlayer-explorer-ref }} - genlayer-testing-suite-ref: ${{ steps.resolve.outputs.genlayer-testing-suite-ref }} - tag-filter: ${{ steps.resolve.outputs.tag-filter }} - impacted-components: ${{ steps.resolve.outputs.impacted-components }} - check-run-id: ${{ steps.check-run.outputs.check_run_id }} - head-sha: ${{ steps.check-run.outputs.head_sha }} steps: - # Check out ci-core-e2e-runner at the parsed track ref BEFORE anything - # else so subsequent `uses:` can reference local paths. This is the - # workaround for GitHub Actions not allowing expressions in action - # `uses:` refs — we can't write `...@${{ needs.parse.outputs.runner-ref }}` - # on a composite action, but we CAN check out the repo at an - # arbitrary ref and call `./path/to/action` locally. - # - # DOCS_REPO_TOKEN (PAT scoped to ci-core-e2e-runner) is the bootstrap - # auth. After this step, subsequent gh/git calls use the short-lived - # App token generated by ./runner/.github/actions/gcp-app-token. - - name: Checkout ci-core-e2e-runner at ${{ needs.parse.outputs.runner-ref }} - uses: actions/checkout@v4 - with: - repository: genlayerlabs/ci-core-e2e-runner - ref: ${{ needs.parse.outputs.runner-ref }} - token: ${{ secrets.DOCS_REPO_TOKEN }} - path: runner - + # App token for the final notify-outcome — completing the + # check-run + swapping the 👀 reaction requires the App token + # (consumer's GITHUB_TOKEN can't update a check-run the App + # created). Cross-repo `uses:` because no checkout has run yet. - name: Generate GitHub App token id: app-token - uses: ./runner/.github/actions/gcp-app-token + uses: genlayerlabs/genlayer-e2e/.github/actions/gcp-app-token@main - - name: React with eyes on trigger comment - if: needs.parse.outputs.comment-id != '' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - gh api --method POST \ - "/repos/${{ github.repository }}/issues/comments/${{ needs.parse.outputs.comment-id }}/reactions" \ - -f content='eyes' + # App token, not the default github.token: when this workflow is + # synced to a consumer, github.token is the CONSUMER's repo-scoped + # token and can't read the private genlayer-e2e repo (exit 128 + # auth failure on the actions/checkout step — observed on + # genlayer-node run 26240246363). + - uses: actions/checkout@v6 + with: + repository: genlayerlabs/genlayer-e2e + ref: main + token: ${{ steps.app-token.outputs.token }} - - name: Create check run - id: check-run - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - head_sha=$(gh api "/repos/${{ github.repository }}/pulls/${{ needs.parse.outputs.pr-number }}" --jq '.head.sha') - echo "head_sha=${head_sha}" >> "$GITHUB_OUTPUT" + # Per-(component, stack-target) verdict artifacts uploaded by + # each e2e-run.yml conclusion job. One artifact per matrix row + # — pattern flattens them into a single directory keyed by + # artifact-name (= component). Aggregate reads this directory + # to build per-wave verdicts without relying on + # `needs.wave-N.outputs.test-conclusion` (which is racy under + # matrix fan-out — last write wins). + - name: Download test-conclusion artifacts + id: download-conclusions + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: e2e-test-conclusion-*-pr${{ github.event.issue.number || github.run_id }} + path: /tmp/test-conclusions - check_run_id=$(gh api --method POST \ - "/repos/${{ github.repository }}/check-runs" \ - -f name='E2E Tests' \ - -f head_sha="${head_sha}" \ - -f status='in_progress' \ - -f "details_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ - --jq '.id') - echo "check_run_id=${check_run_id}" >> "$GITHUB_OUTPUT" + - name: Aggregate outcomes + id: aggregate + # `continue-on-error: true` so a failed aggregation (exit 1 on + # any wave failure) doesn't shortcut the notify-outcome step + # below. The Enforce step at the end re-propagates failure to + # the workflow conclusion. + continue-on-error: true + env: + PLAN_RESULT: ${{ needs.plan.result }} + # BUILD_STATUS is the AND of the two stack-scoped builds, + # but a build that wasn't requested (its stack filtered out + # by /run-e2e ) counts as success — its job is + # `skipped`, not `failure`. The contains() checks gate each + # build's contribution by whether its stack-target appears + # in the wave-plans output. + BUILD_STATUS: ${{ ((!contains(needs.plan.outputs.stack-config, '"target":"dev-env"') || needs.build.outputs.build-status == 'success') && (!contains(needs.plan.outputs.stack-config, '"target":"studio"') || needs.build-studio.outputs.build-status == 'success')) && 'success' || 'failure' }} + BUILD_RESULT: ${{ format('dev-env={0}, studio={1}', needs.build.result, needs.build-studio.result) }} + # Classifier-emitted stage + detail surfaced in the build row's + # Notes column when BUILD_STATUS != success. Empty when build + # was a cache-hit (no execute job ran) — the aggregate script + # falls back to the legacy verdict-word render. + BUILD_STAGE: ${{ (contains(needs.plan.outputs.stack-config, '"target":"studio"') && needs.build-studio.outputs.build-status != 'success') && 'build:studio' || needs.build.outputs.build-stage || '' }} + BUILD_DETAIL: ${{ (contains(needs.plan.outputs.stack-config, '"target":"studio"') && needs.build-studio.outputs.build-status != 'success') && 'Studio build failed' || needs.build.outputs.build-detail || '' }} + WAVE_1_RESULT: ${{ needs.wave-1.result }} + WAVE_1_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-1']) }} + WAVE_2_RESULT: ${{ needs.wave-2.result }} + WAVE_2_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-2']) }} + WAVE_3_RESULT: ${{ needs.wave-3.result }} + WAVE_3_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-3']) }} + WAVE_4_RESULT: ${{ needs.wave-4.result }} + WAVE_4_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-4']) }} + # Per-(component, stack-target) verdict directory (one file + # per artifact, name == component, content ∈ {success, + # failure, skipped}). Replaces the legacy single-value + # WAVE_N_TEST_CONCLUSION env which collapses under matrix + # fan-out (last write wins) — see Download test-conclusion + # artifacts step above. PR_NUMBER lets the script construct + # the exact artifact path; on workflow_dispatch (no PR) the + # run_id stands in, matching the wave jobs' pr-number input. + CONCLUSIONS_DIR: /tmp/test-conclusions + PR_NUMBER: ${{ github.event.issue.number || github.run_id }} + run: ./taskfiles/runner/scripts/aggregate-wave-outcomes.sh - # Local path — resolver comes from the track branch, not main. - # Track-specific changes (new matrix schema, new components, - # new Depends-On logic) live on the track branch and are picked - # up automatically. - - uses: ./runner/actions/e2e-resolve - id: resolve + # Final notify-outcome — flips 👀 → 🚀 (overall pass) or 👎 + # (any wave failed), completes the "E2E Tests" check-run, and + # (with E2E_REPORT_SKIP_RESULT="true" suppressing the compact + # banner) skips posting a duplicate PR comment. Per-component + # PR comments were already posted by each wave's e2e-report.sh. + # Gated on check-run-id presence so workflow_dispatch (no + # acknowledge → no check-run) skips silently. + # + # Also skip when plan failed: the planner's own "Notify resolve + # failure" step already posted the structured error message + # (e.g. "Malformed Depends-On line: ...") and updated the + # check-run + reaction. The aggregate's failed-pipeline banner + # here would be a half-empty table that just says "plan failed" + # — no extra signal, and it buries the specific error message + # under a generic-looking second comment. + - name: Notify final outcome + if: always() && needs.acknowledge.outputs.check-run-id != '' && needs.plan.result != 'failure' + continue-on-error: true + uses: genlayerlabs/genlayer-e2e/.github/actions/notify-outcome@main with: - cross-repo-token: ${{ steps.app-token.outputs.token }} - profile: ${{ needs.parse.outputs.profile }} - pr-number: ${{ needs.parse.outputs.pr-number }} - comment-id: ${{ needs.parse.outputs.comment-id }} - matrix-file: ${{ needs.parse.outputs.matrix-file }} - matrix-ref: ${{ needs.parse.outputs.runner-ref }} + outcome: ${{ steps.aggregate.outcome == 'success' && 'success' || 'failure' }} + check-run-title: 'E2E Tests' + repo: ${{ github.repository }} + comment-id: ${{ github.event.comment.id }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id }} + pr-number: ${{ github.event.issue.number }} + github-token: ${{ steps.app-token.outputs.token }} - # =========================================================================== - # Job 2: Run E2E tests on self-hosted runner (reusable workflow) - # Workflow invocation pinned to @main because GitHub Actions does not - # allow expressions (not `needs`, `inputs`, or `vars`) in - # `jobs..uses`. See: - # https://docs.github.com/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_iduses - # - # Track-aware behaviour still flows through: the resolver + matrix - # are loaded from the track ref via gh api, and the tests/taskfile - # that shard-run.yml invokes come from the track branch (shard-run - # clones the runner repo at the caller's configured ref). - # =========================================================================== - e2e: - needs: [parse, resolve] - uses: genlayerlabs/ci-core-e2e-runner/.github/workflows/e2e-run.yml@feat/version-matrix-phase1 - with: - genvm-version: ${{ needs.resolve.outputs.genvm-version }} - genlayer-node-ref: ${{ needs.resolve.outputs.genlayer-node-ref }} - consensus-ref: ${{ needs.resolve.outputs.consensus-ref }} - genlayer-js-ref: ${{ needs.resolve.outputs.genlayer-js-ref }} - genlayer-py-ref: ${{ needs.resolve.outputs.genlayer-py-ref }} - genlayer-cli-ref: ${{ needs.resolve.outputs.genlayer-cli-ref }} - genlayer-explorer-ref: ${{ needs.resolve.outputs.genlayer-explorer-ref }} - genlayer-testing-suite-ref: ${{ needs.resolve.outputs.genlayer-testing-suite-ref }} - tag-filter: ${{ needs.resolve.outputs.tag-filter }} - profile: ${{ needs.resolve.outputs.profile }} - pr-number: ${{ needs.resolve.outputs.pr-number }} - comment-id: ${{ needs.resolve.outputs.comment-id }} - target-repo: ${{ needs.resolve.outputs.repo }} - check-run-id: ${{ needs.resolve.outputs.check-run-id }} - head-sha: ${{ needs.resolve.outputs.head-sha }} + # Re-propagate the aggregate's exit status to the workflow + # conclusion. Without this, the result job would always succeed + # (because aggregate has `continue-on-error: true`) and the + # whole pipeline would render as green even on real failures. + - name: Enforce overall outcome + if: steps.aggregate.outcome == 'failure' + run: | + echo "::error::Pipeline failed — see ::error:: lines in Aggregate outcomes step" + exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 35096d0..340aa70 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,36 +1,28 @@ -name: Release & Publish Package to NPM +name: Publish Package to NPM +# 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 package.json, writes the CHANGELOG, commits, tags +# vX.Y.Z, and pushes both the branch commit and the tag. This workflow +# fires on the tag push, builds, sanity-checks the tag matches +# package.json, and publishes to npm. It never bumps or tags by itself. on: workflow_dispatch: push: - branches: - - main + tags: + - "v*" permissions: contents: write id-token: write jobs: - release: + publish: runs-on: ubuntu-latest - environment: npm + environment: Publish steps: - - name: Get CI Bot Token - uses: tibdex/github-app-token@v1 - id: ci_bot_token - with: - app_id: ${{ secrets.CI_BOT_APP_ID }} - private_key: ${{ secrets.CI_BOT_SECRET }} - - - name: Checkout source code + - name: Checkout tag uses: actions/checkout@v4 - with: - token: ${{ steps.ci_bot_token.outputs.token }} - - - name: Initialize Git User - run: | - git config --global user.email "github-actions[bot]@genlayer.com" - git config --global user.name "github-actions[bot]" - uses: actions/setup-node@v4 with: @@ -39,7 +31,38 @@ jobs: - run: npm ci - - name: Release - run: npm run release + - name: Verify tag matches package.json version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag ($TAG_VERSION) and package.json ($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 package.json $PKG_VERSION." + + - run: npm run build + + - name: Publish to npm + run: npm publish --provenance --access public env: - GITHUB_TOKEN: ${{ steps.ci_bot_token.outputs.token }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Pull the changelog block for this version out of CHANGELOG.md. + # Falls back to a generic body if the section isn't found. + 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/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 5e1e4e8..5ff3ec7 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -9,6 +9,7 @@ jobs: generate-and-sync: if: github.event_name != 'release' || !contains(github.event.release.tag_name, '-') runs-on: ubuntu-latest + environment: Sync-docs permissions: contents: read steps: @@ -38,9 +39,9 @@ jobs: - name: Generate docs repo token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: - app-id: ${{ vars.DOCS_SYNC_APP_ID }} + client-id: ${{ vars.DOCS_SYNC_APP_CLIENT_ID }} private-key: ${{ secrets.DOCS_SYNC_APP_KEY }} repositories: genlayer-docs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 696828a..1b215a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,10 +3,10 @@ name: Run Tests on: push: branches: - - main + - v1 pull_request: branches: - - main + - v1 jobs: test: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce7579..f8643e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ +## 1.1.8 (2026-05-06) + + +### Bug Fixes + +* **ci:** add trigger comment to publish workflow ([#170](https://github.com/genlayerlabs/genlayer-js/issues/170)) ([a76bec3](https://github.com/genlayerlabs/genlayer-js/commit/a76bec395aaa927720ee0ce364899a64044dd43e)), closes [#168](https://github.com/genlayerlabs/genlayer-js/issues/168) + +## 1.1.7 (2026-04-25) + ## 1.1.6 (2026-04-24) ## 1.1.5 (2026-04-24) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f4344f..160a7e2 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`. + +- **`v1`** — current stable major. PRs for bug fixes / non-breaking features target this branch. Releases are cut from here (see `scripts/release.sh`). +- **`v-dev`** — when a major bump is in progress (e.g. `v2-dev`), this branch is open for the new major's work. PRs introducing breaking changes target this branch, not `v1`. +- **Older majors** (`v0`, etc.) 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 `v1` today. If you have a `main` branch from a previous checkout, delete it locally: + +```sh +git checkout v1 +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 npm. See `.claude/skills/release/SKILL.md` for the full flow. + +If you're using Claude Code, ask it to "release a patch" (or "release v1.2.0") and the skill walks through the pre-flight checks before invoking the script. + ### Bug fixing and Feature development #### 1. Set yourself up to start coding diff --git a/README.md b/README.md index d99e336..39934b9 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,161 @@ const receipt = await client.waitForTransactionReceipt({ ``` +### 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. + +```typescript +const estimate = await client.estimateTransactionFees({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + rotations: [0n], +}); + +const txHash = await client.writeContract({ + account, + address: contractAddress, + functionName: "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 `estimateTransactionFees` for transactions +that can emit funded messages. + +Use the SDK call-key helpers when targeting a specific emitted message. Internal +messages are keyed by the GenVM method name; external EVM messages are keyed by +the first 4 bytes of the calldata selector. + +```typescript +import { + MessageType, + deriveExternalMessageCallKey, + deriveInternalMessageCallKey, + encodeExternalMessageFeeParams, + encodeInternalMessageFeeParams, +} from "genlayer-js"; + +const estimate = await client.estimateTransactionFees({ + messageAllocations: [ + { + messageType: MessageType.Internal, + recipient: childContractAddress, + callKey: deriveInternalMessageCallKey("settle_campaign"), + budget: 700_000n, + feeParams: encodeInternalMessageFeeParams({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + }), + }, + { + messageType: MessageType.External, + recipient: tokenAddress, + callKey: deriveExternalMessageCallKey("0xa9059cbb"), + budget: 210_000n, + feeParams: encodeExternalMessageFeeParams({ + gasLimit: 21_000n, + maxGasPrice: 10n, + }), + }, + ], +}); +``` + +You can also pass the same preset to Studio/localnet simulation. On Studio, +`includeReceipt` uses `sim_call` so the returned object includes the GenVM +receipt and fee accounting report: + +```typescript +const recommended = await client.estimateTransactionFeesForWrite({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], +}); + +await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: recommended.distribution, + messageAllocations: recommended.messageAllocations, + feeValue: recommended.feeValue, + }, +}); +``` + +For tests or tools that need to inspect the raw simulation, use the explicit +two-step flow: + +```typescript +const simulation = await client.simulateWriteContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: estimate.distribution, + feeValue: estimate.feeValue, + }, + includeReceipt: true, +}); + +console.log(simulation.feeAccounting); + +const recommended = await client.estimateTransactionFeesFromSimulation({ + simulation, +}); + +await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: recommended.distribution, + messageAllocations: recommended.messageAllocations, + feeValue: recommended.feeValue, + }, +}); +``` + +For transactions that are already submitted, use the fee-management helpers: + +```typescript +await client.topUpFees({ + txId, + value: 1_100n, + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + rotations: [0n], + }, +}); + +await client.topUpAndSubmitAppeal({ + txId, + value: 1_400n, + distribution: { + appealRounds: 1n, + rotations: [0n, 0n], + }, +}); +``` + +`topUpFees` 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 `txExecutionResult` before reading contract state: diff --git a/package-lock.json b/package-lock.json index cac205c..42fbf8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer-js", - "version": "1.1.6", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer-js", - "version": "1.1.6", + "version": "1.1.8", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/package.json b/package.json index 49990ab..23c6e3a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "genlayer-js", "type": "module", - "version": "1.1.6", + "version": "1.1.8", "description": "GenLayer JavaScript SDK", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -18,7 +18,7 @@ "test:watch": "vitest --watch", "lint": "eslint . --fix --ext .ts", "check:chains": "node scripts/check-chains-drift.mjs", - "release": "release-it --ci", + "release": "./scripts/release.sh", "docs": "node scripts/generate-api-docs.mjs" }, "exports": { diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..0e81522 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Cut a release on the current stable branch. +# +# Bumps package.json, prepends CHANGELOG.md, commits, tags vX.Y.Z, and +# pushes both the branch commit and the tag. publish.yml takes over from +# the tag push (build → npm publish → GitHub Release). +# +# Releases are deliberate. There is no auto-bump on push; only this +# script (or `npm version` invoked equivalently) is supposed to create +# release tags. Run from the major branch you want to ship a release on +# (e.g. v1 for v1.x.y, v0.18 for v0.18.x once that branch exists). +# +# Usage: +# scripts/release.sh # explicit semver — recommended +# scripts/release.sh patch # 1.1.8 → 1.1.9 +# scripts/release.sh minor # 1.1.8 → 1.2.0 +# scripts/release.sh major # 1.1.8 → 2.0.0 (refuses unless --allow-major) +# scripts/release.sh --allow-major +# +# 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/ (no unpushed work, no missed pulls) +# - Latest CI run on HEAD is green (so we don't ship a broken main) +# - Major bumps require --allow-major OR explicit X.0.0 with --allow-major +# since major = new branch in this repo's release model + +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="$(node -p "require('./package.json').version")" + +# Resolve to a concrete X.Y.Z so the major-bump guard can compare. +case "$VERSION_ARG" in + major|minor|patch) + next_version="$(node -e " + const semver = require('semver'); + const cur = require('./package.json').version; + const inc = '$VERSION_ARG'; + const out = semver.inc(cur, inc); + if (!out) { console.error('semver.inc failed for', cur, inc); process.exit(1); } + console.log(out); + ")" + ;; + *) + next_version="$VERSION_ARG" + ;; +esac + +# Validate semver shape early so we don't half-bump. +if ! node -e "if (!require('semver').valid('$next_version')) process.exit(1)"; then + echo "Not a valid semver: $next_version" >&2 + exit 2 +fi + +cur_major="${current_version%%.*}" +next_major="${next_version%%.*}" +if [ "$next_major" != "$cur_major" ] && [ "$ALLOW_MAJOR" -ne 1 ]; then + cat >&2 < { + return (hex.startsWith("0x") ? hex : `0x${hex}`) as `0x${string}`; +}; /** - * Extract hex data from a gen_call result. + * Extract hex data from a simulation result. * Some RPCs return a bare hex string, others return an object like - * { data: "hex...", status: { code, message }, ... }. + * { data: "hex...", status: { code, message }, ... }. Studio's `sim_call` + * returns the full receipt with `result` as base64-encoded GenVM result bytes, + * where byte 0 is the result code and the payload starts at byte 1. */ function extractGenCallResult(result: unknown): `0x${string}` { if (typeof result === "string") { - return `0x${result}` as `0x${string}`; + return prefixHex(result); } if (result && typeof result === "object" && "data" in result) { const obj = result as {data: string; status?: {code: number; message: string}}; if (obj.status && obj.status.code !== 0) { throw new Error(`gen_call failed: ${obj.status.message}`); } - return `0x${obj.data}` as `0x${string}`; + return prefixHex(obj.data); } - throw new Error(`Unexpected gen_call response: ${JSON.stringify(result)}`); + if (result && typeof result === "object" && "result" in result) { + const obj = result as {result: string; execution_result?: string}; + if (obj.execution_result && obj.execution_result !== "SUCCESS") { + throw new Error(`sim_call failed: ${obj.execution_result}`); + } + if (typeof obj.result === "string" && obj.result.startsWith("0x")) { + return prefixHex(obj.result); + } + const resultBytes = b64ToArray(obj.result); + if (resultBytes.length === 0) { + throw new Error("sim_call returned an empty result payload"); + } + return toHex(resultBytes.slice(1)); + } + throw new Error(`Unexpected simulation response: ${JSON.stringify(result)}`); +} + +function normalizeGenCallReceipt(result: unknown, data: `0x${string}`): Record { + if (result && typeof result === "object" && !Array.isArray(result)) { + return result as Record; + } + return {data}; +} + +function extractGenCallFeeAccounting(result: unknown): StudioFeeAccounting | undefined { + if (!result || typeof result !== "object" || Array.isArray(result)) return undefined; + const genvmResult = (result as Record).genvm_result; + if (!genvmResult || typeof genvmResult !== "object" || Array.isArray(genvmResult)) return undefined; + const feeAccounting = (genvmResult as Record).fee_accounting; + if (!feeAccounting || typeof feeAccounting !== "object" || Array.isArray(feeAccounting)) return undefined; + return feeAccounting as StudioFeeAccounting; +} + +function extractGenCallFeeReport(feeAccounting?: StudioFeeAccounting): StudioExecutionFeeReport | undefined { + const report = feeAccounting?.execution_fee_report; + if (!report || typeof report !== "object" || Array.isArray(report)) return undefined; + return report; +} + +function transactionFeesToRpc(fees?: TransactionFeeOptions) { + if (!fees) return undefined; + const normalized = normalizeTransactionFees(fees); + return { + distribution: { + leaderTimeunitsAllocation: normalized.distribution.leaderTimeunitsAllocation.toString(), + validatorTimeunitsAllocation: normalized.distribution.validatorTimeunitsAllocation.toString(), + appealRounds: normalized.distribution.appealRounds.toString(), + executionBudgetPerRound: normalized.distribution.executionBudgetPerRound.toString(), + executionConsumed: normalized.distribution.executionConsumed.toString(), + totalMessageFees: normalized.distribution.totalMessageFees.toString(), + rotations: normalized.distribution.rotations.map((rotation) => rotation.toString()), + maxPriceGenPerTimeUnit: normalized.distribution.maxPriceGenPerTimeUnit.toString(), + storageFeeMaxGasPrice: normalized.distribution.storageFeeMaxGasPrice.toString(), + receiptFeeMaxGasPrice: normalized.distribution.receiptFeeMaxGasPrice.toString(), + }, + messageAllocations: normalized.messageAllocations.map((allocation) => ({ + messageType: allocation.messageType, + onAcceptance: allocation.onAcceptance, + parentIndex: allocation.parentIndex.toString(), + recipient: allocation.recipient, + callKey: allocation.callKey, + budget: allocation.budget.toString(), + feeParams: allocation.feeParams, + })), + ...(normalized.feeValue === undefined ? {} : {feeValue: normalized.feeValue.toString()}), + }; } export const contractActions = (client: GenLayerClient, publicClient: PublicClient) => { + const estimateFeeValue = async ( + distribution: FeesDistribution, + policy?: FeePolicyQuote, + ): Promise => { + if (client.chain.feeManagerContract?.address) { + const roundFees = await publicClient.readContract({ + address: client.chain.feeManagerContract.address as `0x${string}`, + abi: FEE_MANAGER_CALCULATE_ROUND_FEES_ABI as any, + functionName: "calculateRoundFees", + args: [ + distribution, + BigInt(client.chain.defaultNumberOfInitialValidators), + 0n, + ], + }) as bigint; + return roundFees + distribution.totalMessageFees; + } + + if (client.chain.isStudio) { + const studioPolicy = policy ?? await readCurrentFeePolicy(client, publicClient); + return calculateLocalRoundFees( + distribution, + client.chain.defaultNumberOfInitialValidators, + studioPolicy, + ) + distribution.totalMessageFees; + } + + throw new Error("Fee value estimation is not supported on this chain (missing feeManagerContract)."); + }; + return { /** Retrieves the source code of a deployed contract. */ getContractCode: async (address: Address): Promise => { @@ -139,22 +263,32 @@ export const contractActions = (client: GenLayerClient, publicCli return toJsonSafeDeep(decoded) as any; }, /** Simulates a state-modifying contract call without executing on-chain. */ - simulateWriteContract: async (args: { + simulateWriteContract: async < + RawReturn extends boolean | undefined = undefined, + IncludeReceipt extends boolean | undefined = undefined, + >(args: { account?: Account; address: Address; functionName: string; args?: CalldataEncodable[]; kwargs?: Map | {[key: string]: CalldataEncodable}; rawReturn?: RawReturn; + includeReceipt?: IncludeReceipt; + value?: BigNumberish; leaderOnly?: boolean; + fees?: TransactionFeeOptions; transactionHashVariant?: TransactionHashVariant; - }): Promise => { + }): Promise + : RawReturn extends true ? `0x${string}` : CalldataEncodable> => { const { account, address, functionName, args: callArgs, kwargs, + value, + fees, leaderOnly = false, transactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, } = args; @@ -164,24 +298,51 @@ export const contractActions = (client: GenLayerClient, publicCli const senderAddress = account?.address ?? client.account?.address ?? zeroAddress; - const requestParams = { + const requestParams: Record = { type: "write", to: address, from: senderAddress, data: serializedData, transaction_hash_variant: transactionHashVariant, }; + const userValue = toUInt(value, "value", 0n); + if (userValue > 0n) { + requestParams.value = toHex(userValue); + } + const rpcFees = transactionFeesToRpc(fees); + if (rpcFees) { + requestParams.fees = rpcFees; + } + const simulationMethod = args.includeReceipt && client.chain.isStudio ? "sim_call" : "gen_call"; const result = await client.request({ - method: "gen_call", + method: simulationMethod, params: [requestParams], }); const prefixedResult = extractGenCallResult(result); + let decodedResult: `0x${string}` | CalldataEncodable; if (args.rawReturn) { - return prefixedResult; + decodedResult = prefixedResult; + } else { + const resultBinary = fromHex(prefixedResult, "bytes"); + decodedResult = calldata.decode(resultBinary) as any; } - const resultBinary = fromHex(prefixedResult, "bytes"); - return calldata.decode(resultBinary) as any; + + if (args.includeReceipt) { + const feeAccounting = extractGenCallFeeAccounting(result); + return { + result: decodedResult, + receipt: normalizeGenCallReceipt(result, prefixedResult), + feeAccounting, + feeReport: extractGenCallFeeReport(feeAccounting), + } as IncludeReceipt extends true + ? SimulateWriteContractResult + : RawReturn extends true ? `0x${string}` : CalldataEncodable; + } + + return decodedResult as IncludeReceipt extends true + ? SimulateWriteContractResult + : RawReturn extends true ? `0x${string}` : CalldataEncodable; }, /** Executes a state-modifying function on a contract through consensus. Returns the transaction hash. */ writeContract: async (args: { @@ -190,9 +351,11 @@ export const contractActions = (client: GenLayerClient, publicCli functionName: string; args?: CalldataEncodable[]; kwargs?: Map | {[key: string]: CalldataEncodable}; - value: bigint; + value?: bigint; leaderOnly?: boolean; consensusMaxRotations?: number; + validUntil?: BigNumberish; + fees?: TransactionFeeOptions; }): Promise<`0x${string}`> => { const { account, @@ -203,25 +366,34 @@ export const contractActions = (client: GenLayerClient, publicCli value = 0n, leaderOnly = false, consensusMaxRotations = client.chain.defaultConsensusMaxRotations, + validUntil, + fees, } = args; const data = [calldata.encode(calldata.makeCalldataObject(functionName, callArgs, kwargs)), leaderOnly]; const serializedData = serialize(data); const senderAccount = account || client.account; - const {primaryEncodedData, fallbackEncodedData} = _encodeAddTransactionData({ + const transactionFees = await _resolveTransactionFees({ + client, + publicClient, + fees, + numOfInitialValidators: client.chain.defaultNumberOfInitialValidators, + }); + const transactionVariants = _encodeAddTransactionData({ client, senderAccount, recipient: address, data: serializedData, consensusMaxRotations, + validUntil, + userValue: value, + transactionFees, }); return _sendTransaction({ client, publicClient, - encodedData: primaryEncodedData, - fallbackEncodedData, + transactionVariants, senderAccount, - value, }); }, /** Deploys a new intelligent contract to GenLayer. Returns the transaction hash. */ @@ -232,6 +404,8 @@ export const contractActions = (client: GenLayerClient, publicCli kwargs?: Map | {[key: string]: CalldataEncodable}; leaderOnly?: boolean; consensusMaxRotations?: number; + validUntil?: BigNumberish; + fees?: TransactionFeeOptions; }) => { const { account, @@ -240,6 +414,8 @@ export const contractActions = (client: GenLayerClient, publicCli kwargs, leaderOnly = false, consensusMaxRotations = client.chain.defaultConsensusMaxRotations, + validUntil, + fees, } = args; const data = [ @@ -249,21 +425,178 @@ export const contractActions = (client: GenLayerClient, publicCli ]; const serializedData = serialize(data); const senderAccount = account || client.account; - const {primaryEncodedData, fallbackEncodedData} = _encodeAddTransactionData({ + const transactionFees = await _resolveTransactionFees({ + client, + publicClient, + fees, + numOfInitialValidators: client.chain.defaultNumberOfInitialValidators, + }); + const transactionVariants = _encodeAddTransactionData({ client, senderAccount, recipient: zeroAddress, data: serializedData, consensusMaxRotations, + validUntil, + userValue: 0n, + transactionFees, }); return _sendTransaction({ client, publicClient, - encodedData: primaryEncodedData, - fallbackEncodedData, + transactionVariants, senderAccount, }); }, + /** Returns the active fee price policy used to build user-side caps. */ + getCurrentFeePolicy: async (): Promise => { + return readCurrentFeePolicy(client, publicClient); + }, + /** Builds a fee distribution with caps derived from the active fee policy. */ + estimateFeesDistribution: async (args?: FeeEstimateOptions): Promise => { + const policy = await readCurrentFeePolicy(client, publicClient); + return buildEstimatedFeesDistribution(args, policy); + }, + /** + * Builds a complete transaction `fees` object, including feeValue. + * Studio has no on-chain FeeManager in the chain definition, so this uses + * the same deterministic round-fee math as Studio trusted mode there. + */ + estimateTransactionFees: async (args?: FeeEstimateOptions): Promise => { + const policy = await readCurrentFeePolicy(client, publicClient); + const distribution = buildEstimatedFeesDistribution(args, policy); + + return { + distribution, + messageAllocations: args?.messageAllocations, + feeValue: await estimateFeeValue(distribution, policy), + policy, + }; + }, + /** + * Builds a trusted fee preset from a representative Studio simulation. + * This turns the returned fee accounting/report into execution and message + * budgets while preserving mode-2 message allocations when the simulation + * was run with them. + */ + estimateTransactionFeesFromSimulation: async ( + args: SimulationFeeEstimateOptions, + ): Promise => { + const policy = await readCurrentFeePolicy(client, publicClient); + const {estimateOptions, observed, messageAllocations} = + buildEstimatedFeesOptionsFromSimulation(args, policy); + const distribution = buildEstimatedFeesDistribution(estimateOptions, policy); + + return { + distribution, + messageAllocations, + feeValue: await estimateFeeValue(distribution, policy), + policy, + observed, + }; + }, + /** + * Builds a trusted fee preset for a concrete write call in one step. + * The method first gives the simulation a baseline fee budget, then uses + * the returned Studio/GenVM fee accounting to derive the preset the dapp + * should pass with the real transaction. + */ + estimateTransactionFeesForWrite: async ( + args: WriteFeeEstimateOptions, + ): Promise => { + const { + account, + address, + functionName, + args: callArgs, + kwargs, + value, + leaderOnly = false, + transactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, + executionHeadroomBps, + messageHeadroomBps, + ...feeOptions + } = args; + + const policy = await readCurrentFeePolicy(client, publicClient); + const initialDistribution = buildEstimatedFeesDistribution(feeOptions, policy); + const initialEstimate: TransactionFeeEstimate = { + distribution: initialDistribution, + messageAllocations: feeOptions.messageAllocations, + feeValue: await estimateFeeValue(initialDistribution, policy), + policy, + }; + + const encodedData = [ + calldata.encode(calldata.makeCalldataObject(functionName, callArgs, kwargs)), + leaderOnly, + ]; + const serializedData = serialize(encodedData); + const senderAddress = account?.address ?? client.account?.address ?? zeroAddress; + const requestParams: Record = { + type: "write", + to: address, + from: senderAddress, + data: serializedData, + transaction_hash_variant: transactionHashVariant, + }; + const userValue = toUInt(value, "value", 0n); + if (userValue > 0n) { + requestParams.value = toHex(userValue); + } + const rpcFees = transactionFeesToRpc({ + distribution: initialEstimate.distribution, + messageAllocations: initialEstimate.messageAllocations, + feeValue: initialEstimate.feeValue, + }); + if (rpcFees) { + requestParams.fees = rpcFees; + } + + if (client.chain.isStudio) { + const studioEstimate = await client.request({ + method: "sim_estimateTransactionFees", + params: [requestParams], + }); + const authoritativeEstimate = transactionFeeEstimateFromStudioEstimate( + studioEstimate, + policy, + ); + if (authoritativeEstimate) { + return authoritativeEstimate; + } + } + + const simulationResult = await client.request({ + method: "gen_call", + params: [requestParams], + }); + extractGenCallResult(simulationResult); + const feeAccounting = extractGenCallFeeAccounting(simulationResult); + const simulation = { + feeAccounting, + feeReport: extractGenCallFeeReport(feeAccounting), + }; + const {estimateOptions, observed, messageAllocations} = + buildEstimatedFeesOptionsFromSimulation( + { + ...feeOptions, + executionHeadroomBps, + messageHeadroomBps, + simulation, + }, + policy, + ); + const distribution = buildEstimatedFeesDistribution(estimateOptions, policy); + + return { + distribution, + messageAllocations, + feeValue: await estimateFeeValue(distribution, policy), + policy, + observed, + }; + }, /** Calculates the minimum bond required to appeal a transaction. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { const {txId} = args; @@ -346,30 +679,7 @@ export const contractActions = (client: GenLayerClient, publicCli value?: bigint; }) => { const {account, txId} = args; - let {value} = args; - - if (value === undefined) { - if (client.chain.feeManagerContract?.address && client.chain.roundsStorageContract?.address) { - const roundNumber = await publicClient.readContract({ - address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getRoundNumber", - args: [txId], - }) as bigint; - - const transaction = await client.getTransaction({hash: txId as TransactionHash}); - const txStatus = Number(transaction.status); - - value = await publicClient.readContract({ - address: client.chain.feeManagerContract.address as `0x${string}`, - abi: client.chain.feeManagerContract.abi as Abi, - functionName: "calculateMinAppealBond", - args: [txId, roundNumber, txStatus], - }) as bigint; - } else { - value = 0n; - } - } + const value = await _resolveAppealValue({client, publicClient, txId, value: args.value}); const senderAccount = account || client.account; const encodedData = _encodeSubmitAppealData({client, txId}); @@ -386,6 +696,54 @@ export const contractActions = (client: GenLayerClient, publicCli }); return txId; }, + /** + * 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. + */ + topUpFees: async (args: { + account?: Account; + txId: `0x${string}`; + distribution: FeesDistributionInput; + value: bigint; + }): Promise<`0x${string}`> => { + const {account, txId, distribution, value} = args; + const senderAccount = account || client.account; + const encodedData = _encodeTopUpFeesData({txId, distribution}); + return _sendConsensusCall({ + client, + publicClient, + encodedData, + senderAccount, + value, + operationName: "Top up fees", + }); + }, + /** + * Deposits appeal fee budget and submits an appeal in the same consensus call. + * Returns the existing GenLayer transaction id, matching appealTransaction. + */ + topUpAndSubmitAppeal: async (args: { + account?: Account; + txId: `0x${string}`; + distribution: FeesDistributionInput; + value?: bigint; + }): Promise<`0x${string}`> => { + const {account, txId, distribution} = args; + const value = await _resolveAppealValue({client, publicClient, txId, value: args.value}); + + const senderAccount = account || client.account; + const encodedData = _encodeTopUpAndSubmitAppealData({txId, distribution}); + await _sendConsensusCall({ + client, + publicClient, + encodedData, + senderAccount, + value, + operationName: "Top up and submit appeal", + }); + return txId; + }, /** Finalizes a single GenLayer transaction that is ready to be finalized. Returns the EVM transaction hash. */ finalizeTransaction: async (args: { account?: Account; @@ -473,7 +831,7 @@ const ADD_TRANSACTION_ABI_V6 = [ { type: "function", name: "addTransaction", - stateMutability: "nonpayable", + stateMutability: "payable", inputs: [ {name: "_sender", type: "address"}, {name: "_recipient", type: "address"}, @@ -486,9 +844,124 @@ const ADD_TRANSACTION_ABI_V6 = [ }, ] as const; -const getAddTransactionInputCount = (abi: readonly unknown[] | undefined): number => { +const FEES_DISTRIBUTION_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"}, +] as const; + +const MESSAGE_FEE_ALLOCATION_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"}, +] as const; + +const ADD_TRANSACTION_PARAMS_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: FEES_DISTRIBUTION_COMPONENTS}, + {name: "txCalldata", type: "bytes"}, + {name: "messageAllocations", type: "tuple[]", components: MESSAGE_FEE_ALLOCATION_COMPONENTS}, +] as const; + +const ADD_TRANSACTION_ABI_WITH_FEES = [ + { + type: "function", + name: "addTransaction", + stateMutability: "payable", + inputs: [ + {name: "_params", type: "tuple", components: ADD_TRANSACTION_PARAMS_COMPONENTS}, + ], + outputs: [], + }, +] as const; + +const CONSENSUS_FEE_MANAGEMENT_ABI = [ + { + type: "function", + name: "topUpFees", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, + { + type: "function", + name: "topUpAndSubmitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + +const 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: FEES_DISTRIBUTION_COMPONENTS}, + {name: "_numOfValidators", type: "uint256"}, + {name: "round", type: "uint256"}, + ], + outputs: [{name: "totalFeesToPay", type: "uint256"}], + }, +] as const; + +type AddTransactionAbiVersion = "fees" | "v6" | "v5"; + +const getAddTransactionAbiVersion = (abi: readonly unknown[] | undefined): AddTransactionAbiVersion => { if (!abi || !Array.isArray(abi)) { - return 0; + return "v5"; } const addTransactionFunction = abi.find(item => { @@ -498,9 +971,652 @@ const getAddTransactionInputCount = (abi: readonly unknown[] | undefined): numbe const candidate = item as {type?: string; name?: string}; return candidate.type === "function" && candidate.name === "addTransaction"; - }) as {inputs?: readonly unknown[]} | undefined; + }) as {inputs?: readonly {type?: string; components?: readonly unknown[]}[]} | undefined; + + const inputs = addTransactionFunction?.inputs; + if (!Array.isArray(inputs)) { + return "v5"; + } + + if (inputs.length === 1 && inputs[0]?.type === "tuple") { + return "fees"; + } + + return inputs.length >= 6 ? "v6" : "v5"; +}; + +type EncodedTransactionVariant = { + encodedData: `0x${string}`; + value: bigint; +}; + +const toUInt = (value: BigNumberish | undefined, fieldName: string, fallback: bigint): bigint => { + if (value === undefined) { + return fallback; + } + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error(`${fieldName} must be a safe integer when provided as a number.`); + } + const normalized = BigInt(value); + if (normalized < 0n) { + throw new Error(`${fieldName} must be greater than or equal to zero.`); + } + return normalized; +}; + +const getDefaultValidUntil = () => BigInt(Math.floor(Date.now() / 1000) + 3600); + +const requiresFeeDepositCalculation = (distribution: FeesDistribution): boolean => ( + distribution.leaderTimeunitsAllocation !== 0n || + distribution.validatorTimeunitsAllocation !== 0n || + distribution.executionBudgetPerRound !== 0n || + distribution.totalMessageFees !== 0n +); + +const DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000n; +const DEFAULT_LEADER_TIMEUNITS_ALLOCATION = 100n; +const DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200n; +const DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 500_000n; +const DEFAULT_RECEIPT_SLOTS_CHANGED = 7n; +const DEFAULT_INTRINSIC_GAS = 21_000n; +const DEFAULT_BOOTLOADER_OVERHEAD = 60_000n; +const DEFAULT_GAS_PER_CHANGED_SLOT = 1_000n; +const DEFAULT_CALLDATA_GAS_PER_BYTE = 16n; +const DEFAULT_FIXED_PROPOSE_RECEIPT_GAS = 210_000n; +const DEFAULT_FIXED_MESSAGE_REVEAL_GAS = 100_000n; +const DEFAULT_MESSAGE_REVEAL_LENGTH_SLOTS = 32n; +const DEFAULT_NONDET_OUTPUT_LENGTH_BYTES = 32n; +const TRANSACTION_GAS_HEADROOM_BPS = 20_000n; +const DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM = 10_000n; +const VALIDATORS_PER_ROUND = [ + 5n, + 7n, + 11n, + 13n, + 23n, + 25n, + 47n, + 49n, + 95n, + 97n, + 191n, + 193n, + 383n, + 385n, + 767n, + 769n, + 1535n, + 1537n, +] as const; + +const withCapHeadroom = (value: bigint, headroomBps: bigint): bigint => { + if (value === 0n) return 0n; + return (value * headroomBps + 9_999n) / 10_000n; +}; + +const withTransactionGasHeadroom = (value: bigint): bigint => { + if (value === 0n) return 0n; + return (value * TRANSACTION_GAS_HEADROOM_BPS + 9_999n) / 10_000n; +}; - return Array.isArray(addTransactionFunction?.inputs) ? addTransactionFunction.inputs.length : 0; +const bigintFromUnknown = (value: unknown, fieldName: string, fallback = 0n): bigint => { + if (value == null) return fallback; + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value); + if (typeof value === "string" && value.trim() !== "") return BigInt(value); + throw new Error(`${fieldName} is not an integer value.`); +}; + +const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { + const configRecord = config && typeof config === "object" && !Array.isArray(config) + ? config as Record + : undefined; + const policy = configRecord?.policy; + const enabled = configRecord?.enabled; + if (enabled !== undefined && typeof enabled !== "boolean") { + throw new Error(`sim_getFeeConfig enabled flag is not a boolean.`); + } + + const policyRecord = policy && typeof policy === "object" && !Array.isArray(policy) + ? policy as Record + : undefined; + if (!policyRecord) { + throw new Error(`sim_getFeeConfig did not expose a policy object.`); + } + const genPerTimeUnit = bigintFromUnknown(policyRecord.genPerTimeUnit, "policy.genPerTimeUnit"); + const storageUnitPrice = bigintFromUnknown(policyRecord.storageUnitPrice, "policy.storageUnitPrice"); + const receiptGasPrice = bigintFromUnknown(policyRecord.receiptGasPrice, "policy.receiptGasPrice"); + const intrinsicGas = bigintFromUnknown(policyRecord.intrinsicGas, "policy.intrinsicGas", DEFAULT_INTRINSIC_GAS); + const bootloaderOverhead = bigintFromUnknown( + policyRecord.bootloaderOverhead, + "policy.bootloaderOverhead", + DEFAULT_BOOTLOADER_OVERHEAD, + ); + const gasPerChangedSlot = bigintFromUnknown( + policyRecord.gasPerChangedSlot, + "policy.gasPerChangedSlot", + DEFAULT_GAS_PER_CHANGED_SLOT, + ); + const calldataGasPerByte = bigintFromUnknown( + policyRecord.calldataGasPerByte, + "policy.calldataGasPerByte", + DEFAULT_CALLDATA_GAS_PER_BYTE, + ); + const fixedProposeReceiptGas = bigintFromUnknown( + policyRecord.fixedProposeReceiptGas, + "policy.fixedProposeReceiptGas", + DEFAULT_FIXED_PROPOSE_RECEIPT_GAS, + ); + const fixedMessageRevealGas = bigintFromUnknown( + policyRecord.fixedMessageRevealGas, + "policy.fixedMessageRevealGas", + DEFAULT_FIXED_MESSAGE_REVEAL_GAS, + ); + const executionBudgetFloor = policyRecord.messageFeeParamsBudgetFloor == null + ? receiptGasPrice * ( + fixedProposeReceiptGas + + intrinsicGas + + bootloaderOverhead + + (DEFAULT_RECEIPT_SLOTS_CHANGED * gasPerChangedSlot) + + fixedMessageRevealGas + + intrinsicGas + + bootloaderOverhead + + (DEFAULT_MESSAGE_REVEAL_LENGTH_SLOTS * gasPerChangedSlot) + + (DEFAULT_NONDET_OUTPUT_LENGTH_BYTES * calldataGasPerByte) + ) + : bigintFromUnknown( + policyRecord.messageFeeParamsBudgetFloor, + "policy.messageFeeParamsBudgetFloor", + ); + + return { + enabled: enabled ?? ( + genPerTimeUnit > 0n || + storageUnitPrice > 0n || + receiptGasPrice > 0n + ), + genPerTimeUnit, + storageUnitPrice, + receiptGasPrice, + executionBudgetFloor, + }; +}; + +const readCurrentFeePolicy = async ( + client: GenLayerClient, + publicClient: PublicClient, +): Promise => { + if (client.chain.isStudio) { + const config = await client.request({method: "sim_getFeeConfig", params: []}); + return extractStudioFeePolicy(config); + } + + if (!client.chain.feeManagerContract?.address) { + throw new Error("Fee policy estimation is not supported on this chain (missing feeManagerContract)."); + } + + const address = client.chain.feeManagerContract.address as `0x${string}`; + const abi = FEE_MANAGER_CALCULATE_ROUND_FEES_ABI as any; + const [genPerTimeUnit, storageUnitPrice, receiptGasPrice, executionBudgetFloor] = await Promise.all([ + publicClient.readContract({address, abi, functionName: "GENPerTimeUnit", args: []}) as Promise, + publicClient.readContract({address, abi, functionName: "storageUnitPrice", args: []}) as Promise, + publicClient.readContract({address, abi, functionName: "quoteGasPrice", args: []}) as Promise, + publicClient.readContract({address, abi, functionName: "messageFeeParamsBudgetFloor", args: []}) as Promise, + ]); + + return { + enabled: genPerTimeUnit > 0n || storageUnitPrice > 0n || receiptGasPrice > 0n, + genPerTimeUnit, + storageUnitPrice, + receiptGasPrice, + executionBudgetFloor, + }; +}; + +const maxBigint = (...values: bigint[]): bigint => values.reduce( + (max, value) => value > max ? value : max, + 0n, +); + +const defaultExecutionBudgetPerRound = (policy: FeePolicyQuote): bigint => { + if (!policy.enabled || (policy.storageUnitPrice === 0n && policy.receiptGasPrice === 0n)) { + return 0n; + } + + return maxBigint( + DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, + policy.executionBudgetFloor, + ); +}; + +const buildEstimatedFeesDistribution = ( + options: FeeEstimateOptions | undefined, + policy: FeePolicyQuote, +): FeesDistribution => { + const headroomBps = toUInt( + options?.priceCapHeadroomBps, + "priceCapHeadroomBps", + DEFAULT_PRICE_CAP_HEADROOM_BPS, + ); + const baseExecutionBudgetDefault = defaultExecutionBudgetPerRound(policy); + const messageAllocations = options?.messageAllocations + ? normalizeMessageFeeAllocations(options.messageAllocations) + : undefined; + const totalMessageFees = options?.totalMessageFees ?? ( + messageAllocations + ? messageAllocations.reduce( + (sum, allocation) => { + if ( + allocation.messageType === MessageType.External || + allocation.parentIndex === MESSAGE_ALLOCATION_ROOT_PARENT_INDEX + ) { + return sum + allocation.budget; + } + return sum; + }, + 0n, + ) + : undefined + ); + const emitsMessages = (messageAllocations?.length ?? 0) > 0 || ( + totalMessageFees !== undefined && toUInt(totalMessageFees, "totalMessageFees", 0n) > 0n + ); + const executionBudgetDefault = emitsMessages + ? baseExecutionBudgetDefault + (policy.receiptGasPrice * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM) + : baseExecutionBudgetDefault; + + return createFeesDistribution({ + leaderTimeunitsAllocation: options?.leaderTimeunitsAllocation ?? ( + policy.enabled ? DEFAULT_LEADER_TIMEUNITS_ALLOCATION : 0n + ), + validatorTimeunitsAllocation: options?.validatorTimeunitsAllocation ?? ( + policy.enabled ? DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION : 0n + ), + appealRounds: options?.appealRounds, + executionBudgetPerRound: options?.executionBudgetPerRound ?? executionBudgetDefault, + executionConsumed: options?.executionConsumed, + totalMessageFees, + rotations: options?.rotations, + maxPriceGenPerTimeUnit: + options?.maxPriceGenPerTimeUnit ?? withCapHeadroom(policy.genPerTimeUnit, headroomBps), + storageFeeMaxGasPrice: + options?.storageFeeMaxGasPrice ?? withCapHeadroom(policy.storageUnitPrice, headroomBps), + receiptFeeMaxGasPrice: + options?.receiptFeeMaxGasPrice ?? withCapHeadroom(policy.receiptGasPrice, headroomBps), + }); +}; + +const asRecord = (value: unknown): Record | undefined => ( + value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined +); + +const feeAccountingFromSimulation = ( + simulation: SimulationFeeEstimateOptions["simulation"], +): StudioFeeAccounting | undefined => { + const direct = simulation.feeAccounting; + if (direct) return direct; + + const receipt = asRecord((simulation as {receipt?: unknown}).receipt); + const genvmResult = asRecord(receipt?.genvm_result); + const feeAccounting = asRecord(genvmResult?.fee_accounting); + return feeAccounting as StudioFeeAccounting | undefined; +}; + +const messageAllocationsFromAccounting = ( + accounting: StudioFeeAccounting | undefined, +): MessageFeeAllocationInput[] | undefined => { + if (!Array.isArray(accounting?.message_allocations) || accounting.message_allocations.length === 0) { + return undefined; + } + + return accounting.message_allocations.map((raw, index) => { + const allocation = asRecord(raw); + if (!allocation) { + throw new Error(`simulation.feeAccounting.message_allocations[${index}] must be an object.`); + } + return { + messageType: Number(toUInt( + allocation.messageType as BigNumberish | undefined, + `simulation.feeAccounting.message_allocations[${index}].messageType`, + 0n, + )) as MessageType, + onAcceptance: Boolean(allocation.onAcceptance), + parentIndex: toUInt( + allocation.parentIndex as BigNumberish | undefined, + `simulation.feeAccounting.message_allocations[${index}].parentIndex`, + MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, + ), + recipient: String(allocation.recipient ?? zeroAddress) as Address, + callKey: prefixHex(String(allocation.callKey ?? CALL_KEY_WILDCARD)) as `0x${string}`, + budget: toUInt( + allocation.budget as BigNumberish | undefined, + `simulation.feeAccounting.message_allocations[${index}].budget`, + 0n, + ), + feeParams: prefixHex(String(allocation.feeParams ?? "0x")) as `0x${string}`, + }; + }); +}; + +const observedSimulationFeeUsage = ( + args: SimulationFeeEstimateOptions, + policy: FeePolicyQuote, +): SimulationFeeUsage => { + const accounting = feeAccountingFromSimulation(args.simulation); + const report = args.simulation.feeReport ?? accounting?.execution_fee_report; + const executionHeadroomBps = toUInt( + args.executionHeadroomBps, + "executionHeadroomBps", + DEFAULT_PRICE_CAP_HEADROOM_BPS, + ); + const messageHeadroomBps = toUInt( + args.messageHeadroomBps, + "messageHeadroomBps", + DEFAULT_PRICE_CAP_HEADROOM_BPS, + ); + + const executionFeeConsumed = bigintFromUnknown( + accounting?.execution_fee_consumed, + "simulation.feeAccounting.execution_fee_consumed", + ); + const executionFeeReportTotal = bigintFromUnknown( + report?.totalEstimatedFee, + "simulation.feeReport.totalEstimatedFee", + ); + const observedExecutionBudget = executionFeeConsumed + executionFeeReportTotal; + const recommendedExecutionBudgetPerRound = observedExecutionBudget > 0n + ? maxBigint( + defaultExecutionBudgetPerRound(policy), + withCapHeadroom(observedExecutionBudget, executionHeadroomBps), + ) + : 0n; + + const messageFeeConsumed = bigintFromUnknown( + accounting?.message_fee_consumed, + "simulation.feeAccounting.message_fee_consumed", + ); + const genvmMessageFeeConsumed = bigintFromUnknown( + accounting?.genvm_message_fee_consumed, + "simulation.feeAccounting.genvm_message_fee_consumed", + ); + const messageFeeBudget = bigintFromUnknown( + accounting?.message_fee_budget, + "simulation.feeAccounting.message_fee_budget", + ); + const externalMessageReimbursed = bigintFromUnknown( + accounting?.external_message_fee_reimbursed, + "simulation.feeAccounting.external_message_fee_reimbursed", + ); + const messageFeeRefunded = bigintFromUnknown( + accounting?.message_fee_refunded, + "simulation.feeAccounting.message_fee_refunded", + ); + const externalMessageReserved = bigintFromUnknown( + accounting?.external_message_fee_reserved, + "simulation.feeAccounting.external_message_fee_reserved", + ); + const externalMessageRemainder = bigintFromUnknown( + accounting?.external_message_fee_remainder, + "simulation.feeAccounting.external_message_fee_remainder", + ); + const internalDeclaredBudget = (report?.messageReveal?.messages ?? []).reduce( + (sum, message, index) => ( + message.messageType === "Internal" + ? sum + bigintFromUnknown( + message.declaredBudget, + `simulation.feeReport.messageReveal.messages[${index}].declaredBudget`, + ) + : sum + ), + 0n, + ); + const observedMessageBudget = maxBigint( + messageFeeConsumed, + internalDeclaredBudget + externalMessageReimbursed, + ); + + return { + executionFeeConsumed, + executionFeeReportTotal, + recommendedExecutionBudgetPerRound, + genvmMessageFeeConsumed, + messageFeeBudget, + messageFeeConsumed, + messageFeeRefunded, + internalDeclaredBudget, + externalMessageReserved, + externalMessageReimbursed, + externalMessageRemainder, + recommendedTotalMessageFees: observedMessageBudget > 0n + ? withCapHeadroom(observedMessageBudget, messageHeadroomBps) + : 0n, + }; +}; + +const transactionFeeEstimateFromStudioEstimate = ( + result: unknown, + policy: FeePolicyQuote, +): TransactionFeeEstimate | undefined => { + const estimate = asRecord(result); + const preset = asRecord(estimate?.recommendedPreset); + const distributionInput = asRecord(preset?.distribution); + if (!preset || !distributionInput || preset.feeValue === undefined) { + return undefined; + } + + const rawAllocations = Array.isArray(preset.messageAllocations) + ? preset.messageAllocations as MessageFeeAllocationInput[] + : undefined; + const feeAccounting = asRecord(estimate?.feeAccounting) as StudioFeeAccounting | undefined; + const feeReport = ( + asRecord(estimate?.feeReport) ?? + asRecord(feeAccounting?.execution_fee_report) + ) as StudioExecutionFeeReport | undefined; + + return { + distribution: createFeesDistribution(distributionInput as FeesDistributionInput), + messageAllocations: rawAllocations && rawAllocations.length > 0 + ? normalizeMessageFeeAllocations(rawAllocations) + : undefined, + feeValue: bigintFromUnknown(preset.feeValue, "recommendedPreset.feeValue"), + policy, + observed: observedSimulationFeeUsage( + { + simulation: { + feeAccounting, + feeReport, + }, + }, + policy, + ), + }; +}; + +const buildEstimatedFeesOptionsFromSimulation = ( + args: SimulationFeeEstimateOptions, + policy: FeePolicyQuote, +): { + estimateOptions: FeeEstimateOptions; + observed: SimulationFeeUsage; + messageAllocations?: MessageFeeAllocationInput[]; +} => { + const { + simulation, + executionHeadroomBps, + messageHeadroomBps, + ...feeOptions + } = args; + void simulation; + void executionHeadroomBps; + void messageHeadroomBps; + + const accounting = feeAccountingFromSimulation(args.simulation); + const observed = observedSimulationFeeUsage(args, policy); + const messageAllocations = + feeOptions.messageAllocations ?? messageAllocationsFromAccounting(accounting); + + return { + estimateOptions: { + ...feeOptions, + messageAllocations, + executionBudgetPerRound: feeOptions.executionBudgetPerRound ?? ( + observed.recommendedExecutionBudgetPerRound > 0n + ? observed.recommendedExecutionBudgetPerRound + : undefined + ), + totalMessageFees: feeOptions.totalMessageFees ?? ( + messageAllocations + ? undefined + : observed.recommendedTotalMessageFees > 0n + ? observed.recommendedTotalMessageFees + : undefined + ), + }, + observed, + messageAllocations, + }; +}; + +const validatorIndex = (numOfValidators: number): number => { + const needle = BigInt(numOfValidators); + const index = VALIDATORS_PER_ROUND.findIndex((validators) => validators === needle); + if (index < 0) { + throw new Error(`InvalidNumOfValidators: ${numOfValidators}`); + } + return index; +}; + +const calculateFeeForRound = ( + numOfValidators: bigint, + rotations: bigint, + leaderTimeunitsAllocation: bigint, + validatorTimeunitsAllocation: bigint, +): bigint => rotations * ( + leaderTimeunitsAllocation + (numOfValidators * validatorTimeunitsAllocation) +); + +const calculateLocalRoundFees = ( + distribution: FeesDistribution, + numOfInitialValidators: number, + policy: FeePolicyQuote, +): bigint => { + if (distribution.appealRounds !== BigInt(distribution.rotations.length - 1)) { + throw new Error("InvalidAppealRounds"); + } + if ( + distribution.maxPriceGenPerTimeUnit > 0n && + policy.genPerTimeUnit > distribution.maxPriceGenPerTimeUnit + ) { + throw new Error("MaxPriceExceeded"); + } + if ( + distribution.storageFeeMaxGasPrice > 0n && + policy.storageUnitPrice > distribution.storageFeeMaxGasPrice + ) { + throw new Error("MaxPriceExceeded"); + } + if ( + distribution.receiptFeeMaxGasPrice > 0n && + policy.receiptGasPrice > distribution.receiptFeeMaxGasPrice + ) { + throw new Error("MaxPriceExceeded"); + } + + const startIndex = validatorIndex(numOfInitialValidators); + if (startIndex + Number(distribution.appealRounds * 2n) >= VALIDATORS_PER_ROUND.length) { + throw new Error("InvalidNumOfValidators"); + } + + let total = calculateFeeForRound( + VALIDATORS_PER_ROUND[startIndex], + distribution.rotations[0] + 1n, + distribution.leaderTimeunitsAllocation, + distribution.validatorTimeunitsAllocation, + ); + let rotationsIndex = 1; + let rotationsThisRound = 1n; + for (let offset = 1; offset <= Number(distribution.appealRounds * 2n); offset++) { + if (offset % 2 === 0 && rotationsIndex < distribution.rotations.length) { + rotationsThisRound = distribution.rotations[rotationsIndex] + 1n; + rotationsIndex += 1; + } else if (offset % 2 === 1) { + rotationsThisRound = 1n; + } + + total += calculateFeeForRound( + VALIDATORS_PER_ROUND[startIndex + offset], + rotationsThisRound, + distribution.leaderTimeunitsAllocation, + distribution.validatorTimeunitsAllocation, + ); + } + + if (policy.genPerTimeUnit > 0n) { + total *= policy.genPerTimeUnit; + } + + const leaderRounds = distribution.rotations.reduce( + (sum, rotations) => sum + rotations + 1n, + distribution.appealRounds, + ); + total += distribution.executionBudgetPerRound * leaderRounds; + return total; +}; + +const _resolveTransactionFees = async ({ + client, + publicClient, + fees, + numOfInitialValidators, +}: { + client: GenLayerClient; + publicClient: PublicClient; + fees?: TransactionFeeOptions; + numOfInitialValidators: number; +}): Promise => { + const transactionFees = normalizeTransactionFees(fees); + if (transactionFees.feeValue !== undefined || !requiresFeeDepositCalculation(transactionFees.distribution)) { + return { + ...transactionFees, + feeValue: transactionFees.feeValue ?? 0n, + }; + } + + if (!client.chain.feeManagerContract?.address) { + if (client.chain.isStudio) { + const policy = await readCurrentFeePolicy(client, publicClient); + return { + ...transactionFees, + feeValue: policy.enabled + ? calculateLocalRoundFees( + transactionFees.distribution, + numOfInitialValidators, + policy, + ) + transactionFees.distribution.totalMessageFees + : 0n, + }; + } + + throw new Error("fees.feeValue is required when the chain does not expose a feeManagerContract."); + } + + const roundFees = await publicClient.readContract({ + address: client.chain.feeManagerContract.address as `0x${string}`, + abi: FEE_MANAGER_CALCULATE_ROUND_FEES_ABI as any, + functionName: "calculateRoundFees", + args: [ + transactionFees.distribution, + BigInt(numOfInitialValidators), + 0n, + ], + }) as bigint; + + return { + ...transactionFees, + feeValue: roundFees + transactionFees.distribution.totalMessageFees, + }; }; const _encodeAddTransactionData = ({ @@ -509,58 +1625,97 @@ const _encodeAddTransactionData = ({ recipient, data, consensusMaxRotations = client.chain.defaultConsensusMaxRotations, + validUntil, + userValue = 0n, + transactionFees, }: { client: GenLayerClient; senderAccount?: Account; recipient?: `0x${string}`; data?: `0x${string}`; consensusMaxRotations?: number; -}): {primaryEncodedData: `0x${string}`; fallbackEncodedData: `0x${string}`} => { + validUntil?: BigNumberish; + userValue?: bigint; + transactionFees: NormalizedTransactionFees; +}): EncodedTransactionVariant[] => { const validatedSenderAccount = validateAccount(senderAccount); + const txCalldata = data ?? "0x"; + const txRecipient = recipient ?? zeroAddress; + const txValidUntil = toUInt(validUntil, "validUntil", getDefaultValidUntil()); + const feeValue = transactionFees.feeValue ?? 0n; const addTransactionArgs: [ Address, - `0x${string}` | undefined, + `0x${string}`, + number, number, - number | undefined, - `0x${string}` | undefined, + `0x${string}`, ] = [ validatedSenderAccount.address, - recipient, + txRecipient, client.chain.defaultNumberOfInitialValidators, consensusMaxRotations, - data, + txCalldata, ]; - const encodedDataV5 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V5 as any, - functionName: "addTransaction", - args: addTransactionArgs, - }); + const buildVariant = (abiVersion: AddTransactionAbiVersion): EncodedTransactionVariant => { + if (abiVersion === "fees") { + const params = { + sender: validatedSenderAccount.address, + recipient: txRecipient, + numOfInitialValidators: BigInt(client.chain.defaultNumberOfInitialValidators), + maxRotations: BigInt(consensusMaxRotations), + validUntil: txValidUntil, + saltNonce: 0n, + userValue, + feesDistribution: transactionFees.distribution, + txCalldata, + messageAllocations: transactionFees.messageAllocations, + }; - // `_validUntil = 0` is treated as "expired" by the on-chain consensus - // contract, so every submission with a v6 signature would revert. Use - // `now + 1 hour` — enough buffer for wallet confirmation + mining, short - // enough that stale signed txs don't hang around forever. - const validUntil = BigInt(Math.floor(Date.now() / 1000) + 3600); + return { + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + functionName: "addTransaction", + args: [params], + }), + value: userValue + feeValue, + }; + } - const encodedDataV6 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V6 as any, - functionName: "addTransaction", - args: [...addTransactionArgs, validUntil], - }); + if (abiVersion === "v6") { + return { + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_V6 as any, + functionName: "addTransaction", + args: [...addTransactionArgs, txValidUntil], + }), + value: userValue, + }; + } - if (getAddTransactionInputCount(client.chain.consensusMainContract?.abi) >= 6) { return { - primaryEncodedData: encodedDataV6, - fallbackEncodedData: encodedDataV5, + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_V5 as any, + functionName: "addTransaction", + args: addTransactionArgs, + }), + value: userValue, }; + }; + + if (transactionFees.requiresFeeAwareTransaction) { + return [buildVariant("fees")]; } - return { - primaryEncodedData: encodedDataV5, - fallbackEncodedData: encodedDataV6, + const detectedVersion = getAddTransactionAbiVersion(client.chain.consensusMainContract?.abi); + const orderByDetectedVersion: Record = { + fees: ["fees", "v6", "v5"], + v6: ["v6", "v5", "fees"], + v5: ["v5", "v6", "fees"], }; + + return orderByDetectedVersion[detectedVersion].map(buildVariant); }; const _encodeSubmitAppealData = ({ @@ -577,12 +1732,78 @@ const _encodeSubmitAppealData = ({ }); }; +const _resolveAppealValue = async ({ + client, + publicClient, + txId, + value, +}: { + client: GenLayerClient; + publicClient: PublicClient; + txId: `0x${string}`; + value?: bigint; +}): Promise => { + if (value !== undefined) { + return value; + } + + if (!client.chain.feeManagerContract?.address || !client.chain.roundsStorageContract?.address) { + return 0n; + } + + const roundNumber = await publicClient.readContract({ + address: client.chain.roundsStorageContract.address as `0x${string}`, + abi: client.chain.roundsStorageContract.abi as Abi, + functionName: "getRoundNumber", + args: [txId], + }) as bigint; + + const transaction = await client.getTransaction({hash: txId as TransactionHash}); + const txStatus = Number(transaction.status); + + return publicClient.readContract({ + address: client.chain.feeManagerContract.address as `0x${string}`, + abi: client.chain.feeManagerContract.abi as Abi, + functionName: "calculateMinAppealBond", + args: [txId, roundNumber, txStatus], + }) as Promise; +}; + +const _encodeTopUpFeesData = ({ + txId, + distribution, +}: { + txId: `0x${string}`; + distribution: FeesDistributionInput; +}): `0x${string}` => { + return encodeFunctionData({ + abi: CONSENSUS_FEE_MANAGEMENT_ABI, + functionName: "topUpFees", + args: [txId, createFeesDistribution(distribution)], + }); +}; + +const _encodeTopUpAndSubmitAppealData = ({ + txId, + distribution, +}: { + txId: `0x${string}`; + distribution: FeesDistributionInput; +}): `0x${string}` => { + return encodeFunctionData({ + abi: CONSENSUS_FEE_MANAGEMENT_ABI, + functionName: "topUpAndSubmitAppeal", + args: [txId, createFeesDistribution(distribution)], + }); +}; + /** * Sends a pre-encoded call to the consensus main contract, bypassing the * NewTransaction/CreatedTransaction log extraction used by _sendTransaction. * Used for consensus admin calls (appeal, finalize, etc.) that operate on * existing GenLayer transactions rather than creating new ones. - * Returns the EVM transaction hash. + * Returns the backend RPC hash: an EVM transaction hash on network backends, + * or the target GenLayer tx id on Studio/localnet fee-management calls. */ const _sendConsensusCall = async ({ client, @@ -636,6 +1857,9 @@ const _sendConsensusCall = async ({ }; const serializedTransaction = await validatedAccount.signTransaction(txRequest); const evmHash = await client.sendRawTransaction({serializedTransaction}); + if (client.chain.isStudio) { + return evmHash; + } const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); if (receipt.status === "reverted") { throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); @@ -653,6 +1877,9 @@ const _sendConsensusCall = async ({ gas: `0x${estimatedGas.toString(16)}` as `0x${string}`, }], })) as `0x${string}`; + if (client.chain.isStudio) { + return evmHash; + } const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); if (receipt.status === "reverted") { throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); @@ -734,35 +1961,73 @@ const extractTxIdFromLogs = ( const _sendTransaction = async ({ client, publicClient, - encodedData, - fallbackEncodedData, + transactionVariants, senderAccount, - value = 0n, }: { client: GenLayerClient; publicClient: PublicClient; - encodedData: `0x${string}`; - fallbackEncodedData?: `0x${string}`; + transactionVariants: EncodedTransactionVariant[]; senderAccount?: Account; - value?: bigint; }) => { if (!client.chain.consensusMainContract?.address) { throw new Error(`Consensus main contract address not found in chain config for "${client.chain.name}".`); } + if (transactionVariants.length === 0) { + throw new Error("No transaction variants available to send."); + } const validatedSenderAccount = validateAccount(senderAccount); const nonce = await client.getCurrentNonce({address: validatedSenderAccount.address}); - const sendWithEncodedData = async (encodedDataForSend: `0x${string}`) => { + const knownRevertSelectorNames: Record = { + "0x8d53e553": "InsufficientFees", + "0xb4132db3": "MaxPriceExceeded", + "0x57df8523": "ExecutionBudgetExceeded", + }; + + const stringifyRpcError = (error: unknown): string => { + const parts: string[] = []; + if (error instanceof Error) { + parts.push(error.message); + } + const record = error && typeof error === "object" ? error as Record : {}; + for (const key of ["details", "shortMessage", "data"]) { + const value = record[key]; + if (typeof value === "string" && value.trim() !== "") { + parts.push(value); + } + } + const cause = record.cause; + if (cause && typeof cause === "object") { + const causeRecord = cause as Record; + for (const key of ["message", "data"]) { + const value = causeRecord[key]; + if (typeof value === "string" && value.trim() !== "") { + parts.push(value); + } + } + } + const text = Array.from(new Set(parts)).join(" "); + const selectorName = Object.entries(knownRevertSelectorNames) + .find(([selector]) => text.includes(selector))?.[1]; + return selectorName && !text.includes(selectorName) + ? `${text} (${selectorName})` + : text; + }; + + const sendWithEncodedData = async (transactionVariant: EncodedTransactionVariant) => { let estimatedGas: bigint; + let gasEstimationError: string | undefined; try { estimatedGas = await client.estimateTransactionGas({ from: validatedSenderAccount.address, to: client.chain.consensusMainContract?.address as Address, - data: encodedDataForSend, - value: value, + data: transactionVariant.encodedData, + value: transactionVariant.value, }); + estimatedGas = withTransactionGasHeadroom(estimatedGas); } catch (err) { + gasEstimationError = stringifyRpcError(err); console.error("Gas estimation failed, using default 200_000:", err); estimatedGas = 200_000n; } @@ -781,10 +2046,10 @@ const _sendTransaction = async ({ const transactionRequest = { account: validatedSenderAccount, to: client.chain.consensusMainContract?.address as Address, - data: encodedDataForSend, + data: transactionVariant.encodedData, type: "legacy" as const, nonce: Number(nonce), - value: value, + value: transactionVariant.value, gas: estimatedGas, gasPrice: BigInt(gasPriceHex), chainId: client.chain.id, @@ -792,10 +2057,22 @@ const _sendTransaction = async ({ const serializedTransaction = await validatedSenderAccount.signTransaction(transactionRequest); const txHash = await client.sendRawTransaction({serializedTransaction: serializedTransaction}); + + if (client.chain.isStudio) { + // Studio RPCs process eth_sendRawTransaction internally. The returned + // hash is already the GenLayer tx hash; there is no separate EVM + // receipt to wait for or consensus event to extract. + return txHash; + } + const receipt = await publicClient.waitForTransactionReceipt({hash: txHash}); if (receipt.status === "reverted") { - throw new Error(`Transaction reverted: EVM tx ${txHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.`); + throw new Error( + `Transaction reverted: EVM tx ${txHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.${ + gasEstimationError ? ` Gas estimation error: ${gasEstimationError}` : "" + }`, + ); } const txId = extractTxIdFromLogs(client, receipt.logs); @@ -833,8 +2110,8 @@ const _sendTransaction = async ({ const formattedRequest = { from: validatedSenderAccount.address, to: client.chain.consensusMainContract?.address as Address, - data: encodedDataForSend, - value: `0x${value.toString(16)}`, + data: transactionVariant.encodedData, + value: `0x${transactionVariant.value.toString(16)}`, gas: `0x${estimatedGas.toString(16)}`, nonce: `0x${nonceBigInt.toString(16)}`, type: "0x0", // legacy tx @@ -858,7 +2135,11 @@ const _sendTransaction = async ({ const externalReceipt = await publicClient.waitForTransactionReceipt({hash: evmTxHash}); if (externalReceipt.status === "reverted") { - throw new Error(`Transaction reverted: EVM tx ${evmTxHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.`); + throw new Error( + `Transaction reverted: EVM tx ${evmTxHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.${ + gasEstimationError ? ` Gas estimation error: ${gasEstimationError}` : "" + }`, + ); } const externalTxId = extractTxIdFromLogs(client, externalReceipt.logs); @@ -871,12 +2152,15 @@ const _sendTransaction = async ({ return externalTxId; }; - try { - return await sendWithEncodedData(encodedData); - } catch (error) { - if (!fallbackEncodedData || !isAddTransactionAbiMismatchError(error)) { - throw error; + for (let i = 0; i < transactionVariants.length; i++) { + try { + return await sendWithEncodedData(transactionVariants[i]); + } catch (error) { + if (i === transactionVariants.length - 1 || !isAddTransactionAbiMismatchError(error)) { + throw error; + } } - return await sendWithEncodedData(fallbackEncodedData); } + + throw new Error("Unable to send transaction."); }; diff --git a/src/index.ts b/src/index.ts index d39d818..507a1c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,6 @@ export { } from "./transactions/decoders"; export * as chains from "./chains"; export * as abi from "./abi"; +export * from "./transactions/fees"; export {parseStakingAmount, formatStakingAmount} from "./staking"; export {buildGenVmPositionalArgs} from "./contracts/schema"; diff --git a/src/transactions/fees.ts b/src/transactions/fees.ts new file mode 100644 index 0000000..a4c1cfc --- /dev/null +++ b/src/transactions/fees.ts @@ -0,0 +1,226 @@ +import {encodeAbiParameters, hexToBytes, keccak256, toHex, type Hex} from "viem"; + +import { + BigNumberish, + ExternalMessageFeeParamsInput, + FeesDistribution, + FeesDistributionInput, + InternalMessageFeeParamsInput, + MessageFeeAllocationInput, + MessageFeeAllocationNode, + MessageType, + TransactionFeeOptions, +} from "@/types"; + +export const MESSAGE_ALLOCATION_ROOT_PARENT_INDEX = (1n << 256n) - 1n; +export const CALL_KEY_WILDCARD = "0x0000000000000000000000000000000000000000000000000000000000000000" as const; +export const CALL_KEY_UNNAMED = CALL_KEY_WILDCARD; +export const CALL_KEY_DEPLOY = CALL_KEY_WILDCARD; + +const bytesToPaddedCallKey = (bytes: Uint8Array): Hex => { + if (bytes.length > 32) { + throw new Error("call key source bytes must be 32 bytes or fewer."); + } + return `0x${toHex(bytes).slice(2).padEnd(64, "0")}` as Hex; +}; + +export const deriveInternalMessageCallKey = (methodName = ""): Hex => { + const methodBytes = new TextEncoder().encode(methodName); + if (methodBytes.length < 32) { + return bytesToPaddedCallKey(methodBytes); + } + + const hashed = keccak256(methodBytes); + const lastByte = Number.parseInt(hashed.slice(-2), 16) | 1; + return `${hashed.slice(0, -2)}${lastByte.toString(16).padStart(2, "0")}` as Hex; +}; + +export const deriveExternalMessageCallKey = (selectorOrCalldata: Hex | Uint8Array = "0x"): Hex => { + const bytes = typeof selectorOrCalldata === "string" + ? hexToBytes(selectorOrCalldata) + : selectorOrCalldata; + + if (bytes.length < 4) { + return CALL_KEY_UNNAMED; + } + + return bytesToPaddedCallKey(bytes.slice(0, 4)); +}; + +export const DEFAULT_FEES_DISTRIBUTION: FeesDistribution = { + leaderTimeunitsAllocation: 0n, + validatorTimeunitsAllocation: 0n, + appealRounds: 0n, + executionBudgetPerRound: 0n, + executionConsumed: 0n, + totalMessageFees: 0n, + rotations: [0n], + maxPriceGenPerTimeUnit: 0n, + storageFeeMaxGasPrice: 0n, + receiptFeeMaxGasPrice: 0n, +}; + +export type NormalizedTransactionFees = { + distribution: FeesDistribution; + messageAllocations: MessageFeeAllocationNode[]; + feeValue?: bigint; + requiresFeeAwareTransaction: boolean; +}; + +const toUInt = (value: BigNumberish | undefined, fieldName: string, fallback = 0n): bigint => { + if (value === undefined) { + return fallback; + } + + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error(`${fieldName} must be a safe integer when provided as a number.`); + } + + const normalized = BigInt(value); + if (normalized < 0n) { + throw new Error(`${fieldName} must be greater than or equal to zero.`); + } + return normalized; +}; + +const normalizeRotations = ( + rotations: BigNumberish[] | undefined, + appealRounds: bigint, + fieldName: string, +): bigint[] => { + const expectedLength = Number(appealRounds + 1n); + if (!Number.isSafeInteger(expectedLength)) { + throw new Error(`${fieldName} appealRounds is too large.`); + } + + if (!rotations) { + return Array.from({length: expectedLength}, () => 0n); + } + + const normalized = rotations.map((rotation, index) => toUInt(rotation, `${fieldName}[${index}]`)); + if (normalized.length !== expectedLength) { + throw new Error(`${fieldName} must contain appealRounds + 1 entries.`); + } + return normalized; +}; + +const hasNonDefaultFeesDistribution = (distribution: FeesDistribution): boolean => { + return ( + distribution.leaderTimeunitsAllocation !== 0n || + distribution.validatorTimeunitsAllocation !== 0n || + distribution.appealRounds !== 0n || + distribution.executionBudgetPerRound !== 0n || + distribution.executionConsumed !== 0n || + distribution.totalMessageFees !== 0n || + distribution.rotations.length !== 1 || + distribution.rotations[0] !== 0n || + distribution.maxPriceGenPerTimeUnit !== 0n || + distribution.storageFeeMaxGasPrice !== 0n || + distribution.receiptFeeMaxGasPrice !== 0n + ); +}; + +export const createFeesDistribution = (input: FeesDistributionInput = {}): FeesDistribution => { + const appealRounds = toUInt(input.appealRounds, "fees.distribution.appealRounds"); + return { + leaderTimeunitsAllocation: toUInt(input.leaderTimeunitsAllocation, "fees.distribution.leaderTimeunitsAllocation"), + validatorTimeunitsAllocation: toUInt(input.validatorTimeunitsAllocation, "fees.distribution.validatorTimeunitsAllocation"), + appealRounds, + executionBudgetPerRound: toUInt(input.executionBudgetPerRound, "fees.distribution.executionBudgetPerRound"), + executionConsumed: toUInt(input.executionConsumed, "fees.distribution.executionConsumed"), + totalMessageFees: toUInt(input.totalMessageFees, "fees.distribution.totalMessageFees"), + rotations: normalizeRotations(input.rotations, appealRounds, "fees.distribution.rotations"), + maxPriceGenPerTimeUnit: toUInt(input.maxPriceGenPerTimeUnit, "fees.distribution.maxPriceGenPerTimeUnit"), + storageFeeMaxGasPrice: toUInt(input.storageFeeMaxGasPrice, "fees.distribution.storageFeeMaxGasPrice"), + receiptFeeMaxGasPrice: toUInt(input.receiptFeeMaxGasPrice, "fees.distribution.receiptFeeMaxGasPrice"), + }; +}; + +export const encodeInternalMessageFeeParams = (input: InternalMessageFeeParamsInput = {}) => { + const appealRounds = toUInt(input.appealRounds, "internalMessageFeeParams.appealRounds"); + return encodeAbiParameters( + [ + { + name: "params", + type: "tuple", + components: [ + {name: "leaderTimeunitsAllocation", type: "uint256"}, + {name: "validatorTimeunitsAllocation", type: "uint256"}, + {name: "appealRounds", type: "uint256"}, + {name: "executionBudgetPerRound", type: "uint256"}, + {name: "rotations", type: "uint256[]"}, + ], + }, + ], + [ + { + leaderTimeunitsAllocation: toUInt(input.leaderTimeunitsAllocation, "internalMessageFeeParams.leaderTimeunitsAllocation"), + validatorTimeunitsAllocation: toUInt(input.validatorTimeunitsAllocation, "internalMessageFeeParams.validatorTimeunitsAllocation"), + appealRounds, + executionBudgetPerRound: toUInt(input.executionBudgetPerRound, "internalMessageFeeParams.executionBudgetPerRound"), + rotations: normalizeRotations(input.rotations, appealRounds, "internalMessageFeeParams.rotations"), + }, + ], + ); +}; + +export const encodeExternalMessageFeeParams = (input: ExternalMessageFeeParamsInput = {}) => { + return encodeAbiParameters( + [ + { + name: "params", + type: "tuple", + components: [ + {name: "gasLimit", type: "uint256"}, + {name: "maxGasPrice", type: "uint256"}, + ], + }, + ], + [ + { + gasLimit: toUInt(input.gasLimit, "externalMessageFeeParams.gasLimit"), + maxGasPrice: toUInt(input.maxGasPrice, "externalMessageFeeParams.maxGasPrice"), + }, + ], + ); +}; + +export const normalizeMessageFeeAllocations = ( + allocations: MessageFeeAllocationInput[] = [], +): MessageFeeAllocationNode[] => { + return allocations.map((allocation, index) => ({ + messageType: allocation.messageType, + onAcceptance: allocation.onAcceptance ?? allocation.messageType !== MessageType.External, + parentIndex: toUInt( + allocation.parentIndex, + `fees.messageAllocations[${index}].parentIndex`, + MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, + ), + recipient: allocation.recipient, + callKey: allocation.callKey ?? CALL_KEY_WILDCARD, + budget: toUInt(allocation.budget, `fees.messageAllocations[${index}].budget`), + feeParams: allocation.feeParams ?? "0x", + })); +}; + +export const normalizeTransactionFees = (fees?: TransactionFeeOptions): NormalizedTransactionFees => { + const distribution = createFeesDistribution(fees?.distribution); + const messageAllocations = normalizeMessageFeeAllocations(fees?.messageAllocations); + const feeValue = fees?.feeValue === undefined + ? undefined + : toUInt(fees.feeValue, "fees.feeValue"); + + return { + distribution, + messageAllocations, + feeValue, + requiresFeeAwareTransaction: + hasNonDefaultFeesDistribution(distribution) || + messageAllocations.length > 0 || + (feeValue ?? 0n) !== 0n, + }; +}; + +export { + MessageType, +}; diff --git a/src/types/clients.ts b/src/types/clients.ts index fc06534..e50433d 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -1,5 +1,20 @@ import {Transport, Client, PublicActions, WalletActions} from "viem"; -import {GenLayerTransaction, TransactionHash, TransactionStatus, TransactionHashVariant, DebugTraceResult} from "./transactions"; +import { + GenLayerTransaction, + TransactionHash, + TransactionStatus, + TransactionHashVariant, + DebugTraceResult, + TransactionFeeOptions, + TransactionFeeEstimate, + FeeEstimateOptions, + SimulationFeeEstimateOptions, + WriteFeeEstimateOptions, + FeePolicyQuote, + BigNumberish, + FeesDistributionInput, + SimulateWriteContractResult, +} from "./transactions"; import {GenLayerChain} from "./chains"; import {Address, Account} from "./accounts"; import {CalldataEncodable} from "./calldata"; @@ -21,7 +36,10 @@ export type GenLayerMethod = | {method: "eth_getTransactionCount"; params: [address: Address, block: string]} | {method: "eth_estimateGas"; params: [transactionParams: any]} | {method: "gen_call"; params: [requestParams: any]} - | {method: "sim_cancelTransaction"; params: [hash: TransactionHash, signature?: string, adminKey?: string]}; + | {method: "sim_call"; params: [requestParams: any]} + | {method: "sim_estimateTransactionFees"; params: [requestParams: any]} + | {method: "sim_cancelTransaction"; params: [hash: TransactionHash, signature?: string, adminKey?: string]} + | {method: "sim_getFeeConfig"; params: []}; /* Take all the properties from Client @@ -58,20 +76,30 @@ export type GenLayerClient = Omit< functionName: string; args?: CalldataEncodable[]; kwargs?: Map | {[key: string]: CalldataEncodable}; - value: bigint; + value?: bigint; leaderOnly?: boolean; consensusMaxRotations?: number; + validUntil?: BigNumberish; + fees?: TransactionFeeOptions; }) => Promise; - simulateWriteContract: (args: { + simulateWriteContract: < + RawReturn extends boolean | undefined = undefined, + IncludeReceipt extends boolean | undefined = undefined, + >(args: { account?: Account; address: Address; functionName: string; args?: CalldataEncodable[]; kwargs?: Map | { [key: string]: CalldataEncodable }; rawReturn?: RawReturn; + includeReceipt?: IncludeReceipt; + value?: BigNumberish; leaderOnly?: boolean; + fees?: TransactionFeeOptions; transactionHashVariant?: TransactionHashVariant; - }) => Promise; + }) => Promise + : RawReturn extends true ? `0x${string}` : CalldataEncodable>; deployContract: (args: { account?: Account; code: string | Uint8Array; @@ -79,6 +107,8 @@ export type GenLayerClient = Omit< kwargs?: Map | {[key: string]: CalldataEncodable}; leaderOnly?: boolean; consensusMaxRotations?: number; + validUntil?: BigNumberish; + fees?: TransactionFeeOptions; }) => Promise<`0x${string}`>; getTransaction: (args: {hash: TransactionHash}) => Promise; getCurrentNonce: (args: {address: Address}) => Promise; @@ -114,6 +144,18 @@ export type GenLayerClient = Omit< txId: `0x${string}`; value?: bigint; }) => Promise; + topUpFees: (args: { + account?: Account; + txId: `0x${string}`; + distribution: FeesDistributionInput; + value: bigint; + }) => Promise<`0x${string}`>; + topUpAndSubmitAppeal: (args: { + account?: Account; + txId: `0x${string}`; + distribution: FeesDistributionInput; + value?: bigint; + }) => Promise<`0x${string}`>; finalizeTransaction: (args: { account?: Account; txId: `0x${string}`; @@ -123,4 +165,9 @@ export type GenLayerClient = Omit< txIds: readonly `0x${string}`[]; }) => Promise<`0x${string}`>; getMinAppealBond: (args: {txId: `0x${string}`}) => Promise; + getCurrentFeePolicy: () => Promise; + estimateFeesDistribution: (args?: FeeEstimateOptions) => Promise; + estimateTransactionFees: (args?: FeeEstimateOptions) => Promise; + estimateTransactionFeesFromSimulation: (args: SimulationFeeEstimateOptions) => Promise; + estimateTransactionFeesForWrite: (args: WriteFeeEstimateOptions) => Promise; } & StakingActions; diff --git a/src/types/transactions.ts b/src/types/transactions.ts index db3cb4a..9e24526 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -1,8 +1,10 @@ import {Hex} from "viem"; -import {Address} from "./accounts"; +import {Account, Address} from "./accounts"; +import type {CalldataEncodable} from "./calldata"; export type Hash = `0x${string}` & {length: 66}; export type TransactionHash = Hash; +export type BigNumberish = bigint | number | string; export enum TransactionStatus { UNINITIALIZED = "UNINITIALIZED", @@ -151,6 +153,271 @@ export enum TransactionHashVariant { LATEST_NONFINAL = "latest-nonfinal", } +export enum MessageType { + External = 0, + Internal = 1, +} + +export type FeesDistribution = { + leaderTimeunitsAllocation: bigint; + validatorTimeunitsAllocation: bigint; + appealRounds: bigint; + executionBudgetPerRound: bigint; + executionConsumed: bigint; + totalMessageFees: bigint; + rotations: bigint[]; + maxPriceGenPerTimeUnit: bigint; + storageFeeMaxGasPrice: bigint; + receiptFeeMaxGasPrice: bigint; +}; + +export type FeesDistributionInput = { + leaderTimeunitsAllocation?: BigNumberish; + validatorTimeunitsAllocation?: BigNumberish; + appealRounds?: BigNumberish; + executionBudgetPerRound?: BigNumberish; + executionConsumed?: BigNumberish; + totalMessageFees?: BigNumberish; + rotations?: BigNumberish[]; + maxPriceGenPerTimeUnit?: BigNumberish; + storageFeeMaxGasPrice?: BigNumberish; + receiptFeeMaxGasPrice?: BigNumberish; +}; + +export type InternalMessageFeeParamsInput = { + leaderTimeunitsAllocation?: BigNumberish; + validatorTimeunitsAllocation?: BigNumberish; + appealRounds?: BigNumberish; + executionBudgetPerRound?: BigNumberish; + rotations?: BigNumberish[]; +}; + +export type ExternalMessageFeeParamsInput = { + gasLimit?: BigNumberish; + maxGasPrice?: BigNumberish; +}; + +export type MessageFeeAllocationNode = { + messageType: MessageType; + onAcceptance: boolean; + parentIndex: bigint; + recipient: Address; + callKey: Hex; + budget: bigint; + feeParams: Hex; +}; + +export type MessageFeeAllocationInput = { + messageType: MessageType; + onAcceptance?: boolean; + parentIndex?: BigNumberish; + recipient: Address; + callKey?: Hex; + budget?: BigNumberish; + feeParams?: Hex; +}; + +export type TransactionFeeOptions = { + distribution?: FeesDistributionInput; + messageAllocations?: MessageFeeAllocationInput[]; + feeValue?: BigNumberish; +}; + +export type FeePolicyQuote = { + enabled: boolean; + genPerTimeUnit: bigint; + storageUnitPrice: bigint; + receiptGasPrice: bigint; + executionBudgetFloor: bigint; +}; + +export type FeeEstimateOptions = FeesDistributionInput & { + /** + * Basis-points multiplier applied to current network prices when filling + * unset cap fields. Defaults to 12000 (20% headroom). + */ + priceCapHeadroomBps?: BigNumberish; + messageAllocations?: MessageFeeAllocationInput[]; +}; + +export type TransactionFeeEstimate = { + distribution: FeesDistribution; + messageAllocations?: MessageFeeAllocationInput[]; + feeValue: bigint; + policy: FeePolicyQuote; + observed?: SimulationFeeUsage; +}; + +export type SimulateWriteContractReceipt = Record; + +export type StudioExecutionFeeReportMessage = { + messageFeeMode?: "mode1" | "mode2" | "external"; + messageType: "External" | "Internal"; + recipient: Address; + value: BigNumberish; + dataBytes: BigNumberish; + onAcceptance: boolean; + saltNonce: BigNumberish; + feeParams?: Hex; + feeParamsDecoded?: InternalMessageFeeParamsInput | ExternalMessageFeeParamsInput | null; + feeParamsBytes: BigNumberish; + declaredBudget: BigNumberish; + allocationSubtree?: Hex; + allocationSubtreeBytes: BigNumberish; + callKey: Hex; +}; + +export type StudioGenvmFeeBucket = { + index?: BigNumberish; + name?: string; + consumed?: BigNumberish; +}; + +export type StudioGenvmFeeBucketReport = { + receiptAndNondetOutput?: BigNumberish; + storage?: BigNumberish; + message?: BigNumberish; + totalExecution?: BigNumberish; + totalWithMessage?: BigNumberish; + executionBudgetPerRound?: BigNumberish; + executionBudgetRemaining?: BigNumberish; + executionBudgetOverrun?: BigNumberish; + executionBudgetExceeded?: boolean; + buckets?: StudioGenvmFeeBucket[]; +}; + +export type StudioExecutionFeeReport = { + receiptGasPrice?: BigNumberish; + budgetExhaustionReason?: string | null; + proposalReceipt?: { + eqBlocksOutputsLength: BigNumberish; + receiptBytes: BigNumberish; + estimatedGas: BigNumberish; + fee: BigNumberish; + }; + messageReveal?: { + messageBytes: BigNumberish; + messageCount: BigNumberish; + estimatedGas: BigNumberish; + fee: BigNumberish; + consensusAdditionalGas?: BigNumberish; + consensusAdditionalFee?: BigNumberish; + studioFixedOverheadGas?: BigNumberish; + studioFixedOverheadFee?: BigNumberish; + messages?: StudioExecutionFeeReportMessage[]; + }; + genvmBuckets?: StudioGenvmFeeBucketReport; + chargeableExecution?: StudioGenvmFeeBucketReport; + executionMetering?: { + chargeableExecutionFee?: BigNumberish; + genvmReportedExecution?: BigNumberish; + genvmDeltaFromChargeable?: BigNumberish; + }; + messageFees?: { + budget?: BigNumberish; + declaredConsumed?: BigNumberish; + genvmMeteredConsumed?: BigNumberish; + externalReserved?: BigNumberish; + externalReimbursed?: BigNumberish; + externalRemainder?: BigNumberish; + totalConsumed?: BigNumberish; + declaredRefunded?: BigNumberish; + remaining?: BigNumberish; + meteringDelta?: BigNumberish; + reportedTotal?: BigNumberish; + }; + totalEstimatedFee?: BigNumberish; + totalStudioMeteredFee?: BigNumberish; +}; + +export type StudioFeeAccounting = Record & { + paid_fee_value?: BigNumberish; + required_fee_value?: BigNumberish; + primary_fee_required?: BigNumberish; + primary_fee_budget?: BigNumberish; + primary_fee_spent?: BigNumberish; + primary_fee_refunded?: BigNumberish; + execution_budget_total?: BigNumberish; + execution_fee_consumed?: BigNumberish; + execution_fee_consumed_buckets?: BigNumberish[]; + genvm_fee_consumed_buckets?: BigNumberish[]; + genvm_fee_bucket_report?: StudioGenvmFeeBucketReport; + genvm_message_fee_consumed?: BigNumberish; + message_fee_budget?: BigNumberish; + message_fee_consumed?: BigNumberish; + message_fee_refunded?: BigNumberish; + external_message_fee_reserved?: BigNumberish; + external_message_fee_reimbursed?: BigNumberish; + external_message_fee_remainder?: BigNumberish; + appeal_bonds_total?: BigNumberish; + total_refunded?: BigNumberish; + fees_distribution?: FeesDistributionInput; + message_allocations?: MessageFeeAllocationInput[]; + execution_fee_report?: StudioExecutionFeeReport; +}; + +export type SimulateWriteContractResult< + RawReturn extends boolean | undefined = undefined, +> = { + result: RawReturn extends true ? Hex : CalldataEncodable; + receipt: SimulateWriteContractReceipt; + feeAccounting?: StudioFeeAccounting; + feeReport?: StudioExecutionFeeReport; +}; + +export type SimulationFeeUsage = { + executionFeeConsumed: bigint; + executionFeeReportTotal: bigint; + recommendedExecutionBudgetPerRound: bigint; + genvmMessageFeeConsumed: bigint; + messageFeeBudget: bigint; + messageFeeConsumed: bigint; + messageFeeRefunded: bigint; + internalDeclaredBudget: bigint; + externalMessageReserved: bigint; + externalMessageReimbursed: bigint; + externalMessageRemainder: bigint; + recommendedTotalMessageFees: bigint; +}; + +export type SimulationFeeEstimateOptions = FeeEstimateOptions & { + simulation: Pick< + SimulateWriteContractResult, + "feeAccounting" | "feeReport" + >; + /** + * Basis-points multiplier applied to observed execution fee usage. + * Defaults to 12000 (20% headroom). + */ + executionHeadroomBps?: BigNumberish; + /** + * Basis-points multiplier applied to observed mode-1 message fee usage. + * Defaults to 12000 (20% headroom). + */ + messageHeadroomBps?: BigNumberish; +}; + +export type WriteFeeEstimateOptions = FeeEstimateOptions & { + account?: Account; + address: Address; + functionName: string; + args?: CalldataEncodable[]; + kwargs?: Map | {[key: string]: CalldataEncodable}; + value?: BigNumberish; + leaderOnly?: boolean; + transactionHashVariant?: TransactionHashVariant; + /** + * Basis-points multiplier applied to observed execution fee usage. + * Defaults to 12000 (20% headroom). + */ + executionHeadroomBps?: BigNumberish; + /** + * Basis-points multiplier applied to observed mode-1 message fee usage. + * Defaults to 12000 (20% headroom). + */ + messageHeadroomBps?: BigNumberish; +}; + export type DecodedDeployData = { code?: Hex; constructorArgs?: any; // Type this more strictly if possible diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 876a20a..239711a 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1,6 +1,15 @@ import {describe, it, expect, vi} from "vitest"; -import {encodeFunctionData, keccak256, toHex} from "viem"; +import {decodeAbiParameters, decodeFunctionData, encodeFunctionData, keccak256, toHex} from "viem"; import {contractActions} from "../src/contracts/actions"; +import { + CALL_KEY_DEPLOY, + CALL_KEY_WILDCARD, + deriveExternalMessageCallKey, + deriveInternalMessageCallKey, + encodeExternalMessageFeeParams, + encodeInternalMessageFeeParams, + MessageType, +} from "../src/transactions/fees"; const MAIN_CONTRACT_ADDRESS = "0x0000000000000000000000000000000000000001"; const SENDER_ADDRESS = "0x0000000000000000000000000000000000000002"; @@ -81,6 +90,81 @@ const ADD_TRANSACTION_ABI_V6 = [ }, ] as const; +const FEES_DISTRIBUTION_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"}, +] as const; + +const MESSAGE_FEE_ALLOCATION_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"}, +] as const; + +const INTERNAL_MESSAGE_FEE_PARAMS_ABI = [ + { + name: "params", + type: "tuple", + components: [ + {name: "leaderTimeunitsAllocation", type: "uint256"}, + {name: "validatorTimeunitsAllocation", type: "uint256"}, + {name: "appealRounds", type: "uint256"}, + {name: "executionBudgetPerRound", type: "uint256"}, + {name: "rotations", type: "uint256[]"}, + ], + }, +] as const; + +const EXTERNAL_MESSAGE_FEE_PARAMS_ABI = [ + { + name: "params", + type: "tuple", + components: [ + {name: "gasLimit", type: "uint256"}, + {name: "maxGasPrice", type: "uint256"}, + ], + }, +] as const; + +const 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: FEES_DISTRIBUTION_COMPONENTS}, + {name: "txCalldata", type: "bytes"}, + {name: "messageAllocations", type: "tuple[]", components: MESSAGE_FEE_ALLOCATION_COMPONENTS}, + ], + }, + ], + outputs: [], + }, +] as const; + const selectorForV5 = encodeFunctionData({ abi: ADD_TRANSACTION_ABI_V5 as any, functionName: "addTransaction", @@ -93,12 +177,48 @@ const selectorForV6 = encodeFunctionData({ args: [SENDER_ADDRESS, RECIPIENT_ADDRESS, 5, 3, "0x", 0n], }).slice(0, 10); +const selectorForFees = encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + functionName: "addTransaction", + args: [{ + sender: SENDER_ADDRESS, + recipient: RECIPIENT_ADDRESS, + numOfInitialValidators: 5n, + maxRotations: 3n, + validUntil: 1n, + saltNonce: 0n, + userValue: 0n, + feesDistribution: { + leaderTimeunitsAllocation: 0n, + validatorTimeunitsAllocation: 0n, + appealRounds: 0n, + executionBudgetPerRound: 0n, + executionConsumed: 0n, + totalMessageFees: 0n, + rotations: [0n], + maxPriceGenPerTimeUnit: 0n, + storageFeeMaxGasPrice: 0n, + receiptFeeMaxGasPrice: 0n, + }, + txCalldata: "0x", + messageAllocations: [], + }], +}).slice(0, 10); + const setupWriteContractHarness = ({ initialAbi, signTransactionMock, + publicClient = {}, + feeManagerAddress, + isStudio = false, + requestMock, }: { initialAbi: readonly unknown[]; signTransactionMock?: ReturnType; + publicClient?: Record; + feeManagerAddress?: string; + isStudio?: boolean; + requestMock?: ReturnType; }) => { const estimateTransactionGas = vi.fn().mockResolvedValue(21_000n); const signTransaction = signTransactionMock ?? vi.fn().mockRejectedValue(new Error("stop_after_encoding")); @@ -106,6 +226,7 @@ const setupWriteContractHarness = ({ const client = { chain: { id: 61_127, + isStudio, defaultNumberOfInitialValidators: 5, defaultConsensusMaxRotations: 3, consensusMainContract: { @@ -113,6 +234,12 @@ const setupWriteContractHarness = ({ abi: [...initialAbi], bytecode: "0x", }, + feeManagerContract: feeManagerAddress + ? { + address: feeManagerAddress, + abi: [], + } + : null, }, account: { address: SENDER_ADDRESS, @@ -122,7 +249,7 @@ const setupWriteContractHarness = ({ initializeConsensusSmartContract: vi.fn().mockResolvedValue(undefined), getCurrentNonce: vi.fn().mockResolvedValue(0n), estimateTransactionGas, - request: vi.fn().mockImplementation(async ({method}: {method: string}) => { + request: requestMock ?? vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") { return "0x1"; } @@ -130,12 +257,189 @@ const setupWriteContractHarness = ({ }), }; - const actions = contractActions(client as any, {} as any); + const actions = contractActions(client as any, publicClient as any); - return {actions, estimateTransactionGas, client, signTransaction}; + return {actions, estimateTransactionGas, client, signTransaction, publicClient}; }; describe("contractActions addTransaction ABI compatibility", () => { + it("passes trusted fees and user value through Studio simulateWriteContract sim_call", async () => { + const request = vi.fn().mockResolvedValue({ + result: Buffer.from([0, 0xab, 0xcd]).toString("base64"), + execution_result: "SUCCESS", + genvm_result: { + fee_accounting: { + status: "active", + primary_fee_budget: "123", + execution_fee_report: { + receiptGasPrice: "1", + proposalReceipt: { + eqBlocksOutputsLength: "10", + receiptBytes: "1034", + estimatedGas: "314544", + fee: "314544", + }, + messageReveal: { + messageBytes: "320", + messageCount: "1", + estimatedGas: "187120", + fee: "187120", + messages: [ + { + messageType: "Internal", + recipient: RECIPIENT_ADDRESS, + value: "0", + dataBytes: "2", + onAcceptance: true, + saltNonce: "0", + feeParamsBytes: "2", + declaredBudget: "5", + allocationSubtreeBytes: "0", + callKey: `0x${"12".repeat(32)}`, + }, + ], + }, + totalEstimatedFee: "501664", + }, + }, + }, + }); + const actions = contractActions({ + chain: { + id: 61_127, + defaultNumberOfInitialValidators: 5, + defaultConsensusMaxRotations: 3, + isStudio: true, + }, + account: { + address: SENDER_ADDRESS, + }, + request, + } as any, {} as any); + + const result = await actions.simulateWriteContract({ + address: RECIPIENT_ADDRESS, + functionName: "update_storage", + args: ["simulated"], + rawReturn: true, + includeReceipt: true, + value: 12n, + fees: { + feeValue: 123n, + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + totalMessageFees: 5n, + rotations: [0n], + }, + messageAllocations: [ + { + messageType: MessageType.Internal, + recipient: RECIPIENT_ADDRESS, + budget: 5n, + feeParams: "0x1234", + }, + ], + }, + }); + + expect(request.mock.calls[0][0].method).toBe("sim_call"); + const params = request.mock.calls[0][0].params[0]; + expect(result.result).toBe("0xabcd"); + expect(result.feeAccounting).toEqual({ + status: "active", + primary_fee_budget: "123", + execution_fee_report: { + receiptGasPrice: "1", + proposalReceipt: { + eqBlocksOutputsLength: "10", + receiptBytes: "1034", + estimatedGas: "314544", + fee: "314544", + }, + messageReveal: { + messageBytes: "320", + messageCount: "1", + estimatedGas: "187120", + fee: "187120", + messages: [ + { + messageType: "Internal", + recipient: RECIPIENT_ADDRESS, + value: "0", + dataBytes: "2", + onAcceptance: true, + saltNonce: "0", + feeParamsBytes: "2", + declaredBudget: "5", + allocationSubtreeBytes: "0", + callKey: `0x${"12".repeat(32)}`, + }, + ], + }, + totalEstimatedFee: "501664", + }, + }); + expect(result.feeReport?.messageReveal?.messages?.[0].declaredBudget).toBe("5"); + expect(params.value).toBe("0xc"); + expect(params.fees.feeValue).toBe("123"); + expect(params.fees.distribution.leaderTimeunitsAllocation).toBe("100"); + expect(params.fees.distribution.validatorTimeunitsAllocation).toBe("200"); + expect(params.fees.distribution.totalMessageFees).toBe("5"); + expect(params.fees.distribution.rotations).toEqual(["0"]); + expect(params.fees.messageAllocations[0].messageType).toBe(MessageType.Internal); + expect(params.fees.messageAllocations[0].budget).toBe("5"); + }); + + it("encodes internal message fee params as the consensus tuple", () => { + const encoded = encodeInternalMessageFeeParams({ + leaderTimeunitsAllocation: 5n, + validatorTimeunitsAllocation: 10n, + appealRounds: 1n, + executionBudgetPerRound: 20n, + rotations: [2n, 3n], + }); + + const [decoded] = decodeAbiParameters(INTERNAL_MESSAGE_FEE_PARAMS_ABI, encoded) as any; + expect(decoded.leaderTimeunitsAllocation).toBe(5n); + expect(decoded.validatorTimeunitsAllocation).toBe(10n); + expect(decoded.appealRounds).toBe(1n); + expect(decoded.executionBudgetPerRound).toBe(20n); + expect(decoded.rotations).toEqual([2n, 3n]); + }); + + it("encodes external message fee params as the consensus tuple", () => { + const encoded = encodeExternalMessageFeeParams({ + gasLimit: 21_000n, + maxGasPrice: 10n, + }); + + const [decoded] = decodeAbiParameters(EXTERNAL_MESSAGE_FEE_PARAMS_ABI, encoded) as any; + expect(decoded.gasLimit).toBe(21_000n); + expect(decoded.maxGasPrice).toBe(10n); + }); + + it("derives GenVM-compatible message call keys", () => { + const shortInternal = deriveInternalMessageCallKey("update_storage"); + expect(shortInternal).toBe( + `0x${Buffer.from("update_storage", "utf8").toString("hex").padEnd(64, "0")}`, + ); + + const exactLengthMethod = "a".repeat(32); + const hashed = keccak256(toHex(new TextEncoder().encode(exactLengthMethod))); + const lastByte = Number.parseInt(hashed.slice(-2), 16) | 1; + expect(deriveInternalMessageCallKey(exactLengthMethod)).toBe( + `${hashed.slice(0, -2)}${lastByte.toString(16).padStart(2, "0")}`, + ); + + expect(deriveInternalMessageCallKey()).toBe(CALL_KEY_WILDCARD); + expect(CALL_KEY_DEPLOY).toBe(CALL_KEY_WILDCARD); + expect(deriveExternalMessageCallKey("0xaabbccdd11223344")).toBe( + `0xaabbccdd${"0".repeat(56)}`, + ); + expect(deriveExternalMessageCallKey("0x123456")).toBe(CALL_KEY_WILDCARD); + }); + it("encodes addTransaction with 5 args when ABI has 5 inputs", async () => { const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V5, @@ -170,6 +474,691 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(encodedData.slice(0, 10)).toBe(selectorForV6); }); + it("encodes addTransaction with v0.6 fee params when ABI has tuple input", async () => { + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + }); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + value: 7n, + validUntil: 123n, + }), + ).rejects.toThrow("stop_after_encoding"); + + const estimateParams = estimateTransactionGas.mock.calls[0][0]; + const encodedData = estimateParams.data as `0x${string}`; + expect(encodedData.slice(0, 10)).toBe(selectorForFees); + expect(estimateParams.value).toBe(7n); + + const decoded = decodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + data: encodedData, + }); + const params = decoded.args[0] as any; + expect(params.sender).toBe(SENDER_ADDRESS); + expect(params.recipient).toBe(RECIPIENT_ADDRESS); + expect(params.numOfInitialValidators).toBe(5n); + expect(params.maxRotations).toBe(3n); + expect(params.validUntil).toBe(123n); + expect(params.userValue).toBe(7n); + expect(params.feesDistribution.rotations).toEqual([0n]); + expect(params.messageAllocations).toEqual([]); + }); + + it("separates user value from v0.6 fee deposit value", async () => { + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + }); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + value: 5n, + validUntil: 123n, + fees: { + feeValue: 123n, + distribution: { + totalMessageFees: 123n, + }, + messageAllocations: [{ + messageType: MessageType.Internal, + onAcceptance: false, + recipient: RECIPIENT_ADDRESS, + budget: 123n, + feeParams: "0x1234", + }], + }, + }), + ).rejects.toThrow("stop_after_encoding"); + + const estimateParams = estimateTransactionGas.mock.calls[0][0]; + expect(estimateParams.value).toBe(128n); + + const decoded = decodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + data: estimateParams.data as `0x${string}`, + }); + const params = decoded.args[0] as any; + expect(params.userValue).toBe(5n); + expect(params.feesDistribution.totalMessageFees).toBe(123n); + expect(params.messageAllocations).toHaveLength(1); + expect(params.messageAllocations[0].messageType).toBe(MessageType.Internal); + expect(params.messageAllocations[0].onAcceptance).toBe(false); + expect(params.messageAllocations[0].budget).toBe(123n); + expect(params.messageAllocations[0].feeParams).toBe("0x1234"); + }); + + it("defaults external message allocations to on-finalization", async () => { + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + }); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + validUntil: 123n, + fees: { + feeValue: 210_000n, + distribution: { + totalMessageFees: 210_000n, + }, + messageAllocations: [{ + messageType: MessageType.External, + recipient: RECIPIENT_ADDRESS, + budget: 210_000n, + feeParams: encodeExternalMessageFeeParams({ + gasLimit: 21_000n, + maxGasPrice: 10n, + }), + }], + }, + }), + ).rejects.toThrow("stop_after_encoding"); + + const decoded = decodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + data: estimateTransactionGas.mock.calls[0][0].data as `0x${string}`, + }); + const params = decoded.args[0] as any; + expect(params.messageAllocations[0].messageType).toBe(MessageType.External); + expect(params.messageAllocations[0].onAcceptance).toBe(false); + }); + + it("calculates v0.6 fee deposit from FeeManager when feeValue is omitted", async () => { + const publicClient = { + readContract: vi.fn().mockResolvedValue(77n), + }; + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + value: 2n, + validUntil: 123n, + fees: { + distribution: { + leaderTimeunitsAllocation: 10n, + totalMessageFees: 3n, + }, + }, + }), + ).rejects.toThrow("stop_after_encoding"); + + expect(publicClient.readContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: "calculateRoundFees", + args: [expect.any(Object), 5n, 0n], + })); + expect(estimateTransactionGas.mock.calls[0][0].value).toBe(82n); + }); + + it("calculates v0.6 fee deposit from FeeManager when only execution budget is provided", async () => { + const publicClient = { + readContract: vi.fn().mockResolvedValue(500_000n), + }; + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + validUntil: 123n, + fees: { + distribution: { + executionBudgetPerRound: 500_000n, + }, + }, + }), + ).rejects.toThrow("stop_after_encoding"); + + expect(publicClient.readContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: "calculateRoundFees", + args: [expect.any(Object), 5n, 0n], + })); + expect(estimateTransactionGas.mock.calls[0][0].value).toBe(500_000n); + }); + + it("calculates v0.6 fee deposit locally on Studio when feeValue is omitted", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions, estimateTransactionGas} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + await expect( + actions.writeContract({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + validUntil: 123n, + fees: { + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + maxPriceGenPerTimeUnit: 10n, + }, + }, + }), + ).rejects.toThrow("stop_after_encoding"); + + expect(estimateTransactionGas.mock.calls[0][0].value).toBe(11_000n); + }); + + it("estimates fee distribution caps and fee value from FeeManager prices", async () => { + const publicClient = { + readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "GENPerTimeUnit") return 10n; + if (functionName === "storageUnitPrice") return 20n; + if (functionName === "quoteGasPrice") return 30n; + if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "calculateRoundFees") return 77n; + throw new Error(`unexpected readContract ${functionName}`); + }), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + const fees = await actions.estimateTransactionFees({totalMessageFees: 5n}); + + expect(fees.policy).toEqual({ + enabled: true, + genPerTimeUnit: 10n, + storageUnitPrice: 20n, + receiptGasPrice: 30n, + executionBudgetFloor: 1_234n, + }); + expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(12n); + expect(fees.distribution.storageFeeMaxGasPrice).toBe(24n); + expect(fees.distribution.receiptFeeMaxGasPrice).toBe(36n); + expect(fees.distribution.executionBudgetPerRound).toBe(800_000n); + expect(fees.feeValue).toBe(82n); + }); + + it("defaults total message fees from root and external message allocation budgets", async () => { + const publicClient = { + readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "GENPerTimeUnit") return 10n; + if (functionName === "storageUnitPrice") return 20n; + if (functionName === "quoteGasPrice") return 30n; + if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "calculateRoundFees") return 77n; + throw new Error(`unexpected readContract ${functionName}`); + }), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + const fees = await actions.estimateTransactionFees({ + messageAllocations: [ + { + messageType: MessageType.Internal, + recipient: RECIPIENT_ADDRESS, + budget: 50n, + feeParams: "0x1234", + }, + { + messageType: MessageType.Internal, + parentIndex: 0n, + recipient: RECIPIENT_ADDRESS, + budget: 10n, + feeParams: "0x1234", + }, + { + messageType: MessageType.External, + recipient: RECIPIENT_ADDRESS, + budget: 30n, + feeParams: "0x1234", + }, + ], + }); + + expect(fees.distribution.totalMessageFees).toBe(80n); + expect(fees.feeValue).toBe(157n); + }); + + it("estimates Studio fee value from sim_getFeeConfig when no FeeManager contract exists", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFees({priceCapHeadroomBps: 10_000n}); + + expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(10n); + expect(fees.distribution.storageFeeMaxGasPrice).toBe(20n); + expect(fees.distribution.receiptFeeMaxGasPrice).toBe(30n); + expect(fees.distribution.executionBudgetPerRound).toBe(15_345_360n); + expect(fees.feeValue).toBe(15_356_360n); + }); + + it("prefers Studio's exposed message fee budget floor over local fallback math", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + fixedProposeReceiptGas: "1", + messageFeeParamsBudgetFloor: "700000", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFees({priceCapHeadroomBps: 10_000n}); + + expect(fees.policy.executionBudgetFloor).toBe(700_000n); + expect(fees.distribution.executionBudgetPerRound).toBe(700_000n); + expect(fees.feeValue).toBe(711_000n); + }); + + it("builds a Studio trusted fee preset from a simulation fee report", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + messageFeeParamsBudgetFloor: "400000", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesFromSimulation({ + 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: { + receiptGasPrice: "30", + proposalReceipt: { + eqBlocksOutputsLength: "10", + receiptBytes: "1034", + estimatedGas: "314544", + fee: "314544", + }, + messageReveal: { + messageBytes: "320", + messageCount: "1", + estimatedGas: "187120", + fee: "187120", + consensusAdditionalGas: "87120", + consensusAdditionalFee: "87120", + studioFixedOverheadGas: "100000", + studioFixedOverheadFee: "100000", + messages: [ + { + messageFeeMode: "mode1", + messageType: "Internal", + recipient: RECIPIENT_ADDRESS, + value: "0", + dataBytes: "2", + onAcceptance: true, + saltNonce: "0", + feeParams: "0x1234", + feeParamsDecoded: null, + feeParamsBytes: "2", + declaredBudget: "5", + allocationSubtree: "0x", + allocationSubtreeBytes: "0", + callKey: `0x${"12".repeat(32)}`, + }, + ], + }, + chargeableExecution: { + receiptAndNondetOutput: "501664", + storage: "0", + message: "0", + totalExecution: "501664", + totalWithMessage: "501664", + executionBudgetPerRound: "600000", + executionBudgetRemaining: "98336", + executionBudgetOverrun: "0", + executionBudgetExceeded: false, + }, + genvmBuckets: { + receiptAndNondetOutput: "100", + storage: "0", + message: "5", + totalExecution: "100", + totalWithMessage: "105", + executionBudgetPerRound: "600000", + executionBudgetRemaining: "599900", + executionBudgetOverrun: "0", + executionBudgetExceeded: false, + buckets: [ + {index: "0", name: "receiptAndNondetOutput", consumed: "100"}, + {index: "1", name: "storage", consumed: "0"}, + {index: "2", name: "message", consumed: "5"}, + ], + }, + executionMetering: { + chargeableExecutionFee: "501664", + genvmReportedExecution: "100", + genvmDeltaFromChargeable: "-501564", + }, + messageFees: { + budget: "10", + declaredConsumed: "5", + genvmMeteredConsumed: "5", + declaredRefunded: "0", + remaining: "5", + meteringDelta: "0", + reportedTotal: "5", + }, + totalEstimatedFee: "501664", + totalStudioMeteredFee: "688784", + }, + }, + }, + priceCapHeadroomBps: 10_000n, + }); + + expect(fees.observed).toEqual({ + executionFeeConsumed: 100n, + executionFeeReportTotal: 501_664n, + recommendedExecutionBudgetPerRound: 602_117n, + genvmMessageFeeConsumed: 5n, + messageFeeBudget: 10n, + messageFeeConsumed: 5n, + messageFeeRefunded: 0n, + internalDeclaredBudget: 5n, + externalMessageReserved: 0n, + externalMessageReimbursed: 0n, + externalMessageRemainder: 0n, + recommendedTotalMessageFees: 6n, + }); + expect(fees.distribution.executionBudgetPerRound).toBe(602_117n); + expect(fees.distribution.totalMessageFees).toBe(6n); + expect(fees.feeValue).toBe(613_123n); + }); + + it("builds a Studio trusted fee preset for a target write in one call", async () => { + const feeParams = encodeInternalMessageFeeParams({ + leaderTimeunitsAllocation: 5n, + validatorTimeunitsAllocation: 10n, + }); + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + messageFeeParamsBudgetFloor: "400000", + }, + }; + } + if (method === "sim_estimateTransactionFees") { + return { + feeAccounting: { + execution_fee_consumed: "100", + message_fee_consumed: "50", + message_fee_budget: "110", + message_allocations: [ + { + messageType: MessageType.Internal, + onAcceptance: true, + parentIndex: ((1n << 256n) - 1n).toString(), + recipient: RECIPIENT_ADDRESS, + callKey: `0x${"00".repeat(32)}`, + budget: "110", + feeParams, + }, + ], + execution_fee_report: { + totalEstimatedFee: "501664", + }, + }, + feeReport: { + totalEstimatedFee: "501664", + }, + recommendedPreset: { + distribution: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + appealRounds: "0", + executionBudgetPerRound: "700000", + executionConsumed: "0", + totalMessageFees: "110", + rotations: ["0"], + maxPriceGenPerTimeUnit: "10", + storageFeeMaxGasPrice: "20", + receiptFeeMaxGasPrice: "30", + }, + messageAllocations: [ + { + messageType: MessageType.Internal, + onAcceptance: true, + parentIndex: ((1n << 256n) - 1n).toString(), + recipient: RECIPIENT_ADDRESS, + callKey: `0x${"00".repeat(32)}`, + budget: "110", + feeParams, + }, + ], + feeValue: "711110", + }, + }; + } + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesForWrite({ + address: RECIPIENT_ADDRESS, + functionName: "update_storage", + args: ["after"], + value: 7n, + priceCapHeadroomBps: 10_000n, + messageAllocations: [ + { + messageType: MessageType.Internal, + recipient: RECIPIENT_ADDRESS, + budget: 110n, + feeParams, + }, + ], + }); + + const simCall = requestMock.mock.calls.find(([call]) => call.method === "sim_estimateTransactionFees")?.[0]; + expect(simCall).toBeDefined(); + expect(simCall.params[0].value).toBe("0x7"); + expect(simCall.params[0].fees.feeValue).toBe("811110"); + expect(simCall.params[0].fees.distribution.totalMessageFees).toBe("110"); + expect(simCall.params[0].fees.messageAllocations[0].budget).toBe("110"); + expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(602_117n); + expect(fees.observed?.messageFeeBudget).toBe(110n); + expect(fees.observed?.messageFeeConsumed).toBe(50n); + expect(fees.distribution.executionBudgetPerRound).toBe(700_000n); + expect(fees.distribution.totalMessageFees).toBe(110n); + expect(fees.messageAllocations?.[0].budget).toBe(110n); + expect(fees.feeValue).toBe(711_110n); + }); + + it("preserves mode-2 message allocations from simulation fee accounting", async () => { + const feeParams = encodeInternalMessageFeeParams({ + leaderTimeunitsAllocation: 5n, + validatorTimeunitsAllocation: 10n, + }); + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + messageFeeParamsBudgetFloor: "400000", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesFromSimulation({ + simulation: { + feeAccounting: { + message_fee_consumed: "20", + message_allocations: [ + { + messageType: MessageType.Internal, + onAcceptance: false, + parentIndex: ((1n << 256n) - 1n).toString(), + recipient: RECIPIENT_ADDRESS, + callKey: `0x${"00".repeat(32)}`, + budget: "50", + feeParams, + }, + ], + }, + }, + priceCapHeadroomBps: 10_000n, + }); + + expect(fees.messageAllocations).toHaveLength(1); + expect(fees.messageAllocations?.[0].budget).toBe(50n); + expect(fees.messageAllocations?.[0].feeParams).toBe(feeParams); + expect(fees.distribution.totalMessageFees).toBe(50n); + expect(fees.feeValue).toBe(811_050n); + }); + + it("keeps Studio fee estimation gasless when sim_getFeeConfig is disabled", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFees(); + + expect(fees.policy.enabled).toBe(false); + expect(fees.distribution.leaderTimeunitsAllocation).toBe(0n); + expect(fees.distribution.validatorTimeunitsAllocation).toBe(0n); + expect(fees.distribution.executionBudgetPerRound).toBe(0n); + expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(0n); + expect(fees.distribution.storageFeeMaxGasPrice).toBe(0n); + expect(fees.distribution.receiptFeeMaxGasPrice).toBe(0n); + expect(fees.feeValue).toBe(0n); + }); + it("retries with v6 signature when v5 signature fails with ABI mismatch", async () => { const signTransaction = vi .fn() @@ -307,7 +1296,7 @@ describe("contractActions addTransaction ABI compatibility", () => { from: SENDER_ADDRESS, to: MAIN_CONTRACT_ADDRESS, value: "0x0", - gas: "0x5208", + gas: "0xa410", nonce: "0x0", type: "0x0", chainId: "0xeec7", @@ -471,6 +1460,29 @@ const FINALIZE_TX_ABI = [ }, ]; +const FEE_MANAGEMENT_ABI = [ + { + type: "function" as const, + name: "topUpFees", + stateMutability: "payable" as const, + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, + { + type: "function" as const, + name: "topUpAndSubmitAppeal", + stateMutability: "payable" as const, + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + const finalizeTransactionSelector = encodeFunctionData({ abi: FINALIZE_TX_ABI as any, functionName: "finalizeTransaction", @@ -483,6 +1495,46 @@ const finalizeIdlenessSelector = encodeFunctionData({ args: [[MOCK_GENLAYER_TX_ID]], }).slice(0, 10); +const topUpFeesSelector = encodeFunctionData({ + abi: FEE_MANAGEMENT_ABI as any, + functionName: "topUpFees", + args: [ + MOCK_GENLAYER_TX_ID, + { + leaderTimeunitsAllocation: 0n, + validatorTimeunitsAllocation: 0n, + appealRounds: 0n, + executionBudgetPerRound: 0n, + executionConsumed: 0n, + totalMessageFees: 0n, + rotations: [0n], + maxPriceGenPerTimeUnit: 0n, + storageFeeMaxGasPrice: 0n, + receiptFeeMaxGasPrice: 0n, + }, + ], +}).slice(0, 10); + +const topUpAndSubmitAppealSelector = encodeFunctionData({ + abi: FEE_MANAGEMENT_ABI as any, + functionName: "topUpAndSubmitAppeal", + args: [ + MOCK_GENLAYER_TX_ID, + { + leaderTimeunitsAllocation: 0n, + validatorTimeunitsAllocation: 0n, + appealRounds: 0n, + executionBudgetPerRound: 0n, + executionConsumed: 0n, + totalMessageFees: 0n, + rotations: [0n], + maxPriceGenPerTimeUnit: 0n, + storageFeeMaxGasPrice: 0n, + receiptFeeMaxGasPrice: 0n, + }, + ], +}).slice(0, 10); + const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: string} = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); @@ -518,6 +1570,48 @@ const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: stri return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client}; }; +const setupFeeManagementHarness = ({ + receiptStatus = "success", + isStudio = false, +}: { + receiptStatus?: string; + isStudio?: boolean; +} = {}) => { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); + const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: receiptStatus, logs: []}); + const estimateTransactionGas = vi.fn().mockResolvedValue(21_000n); + + const client = { + chain: { + id: 61_127, + isStudio, + consensusMainContract: { + address: MAIN_CONTRACT_ADDRESS, + abi: FEE_MANAGEMENT_ABI, + bytecode: "0x", + }, + }, + account: { + address: SENDER_ADDRESS, + type: "local", + signTransaction, + }, + getCurrentNonce: vi.fn().mockResolvedValue(0n), + estimateTransactionGas, + sendRawTransaction, + request: vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + + const publicClient = {waitForTransactionReceipt}; + const actions = contractActions(client as any, publicClient as any); + + return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client}; +}; + describe("contractActions getContractCode", () => { const SOURCE = '# v0.1.0\n# { "Depends": "py-genlayer:test" }\n\nfrom genlayer import *\n'; const SOURCE_B64 = Buffer.from(SOURCE, "utf-8").toString("base64"); @@ -648,6 +1742,145 @@ describe("contractActions getContractSchemaForCode", () => { }); }); +describe("contractActions fee management", () => { + it("encodes topUpFees(bytes32, FeesDistribution) and returns the EVM tx hash", async () => { + const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); + + const evmHash = await actions.topUpFees({ + txId: MOCK_GENLAYER_TX_ID, + value: 999n, + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + appealRounds: 1n, + executionBudgetPerRound: 500_000n, + totalMessageFees: 30n, + rotations: [0n, 2n], + maxPriceGenPerTimeUnit: 12n, + storageFeeMaxGasPrice: 24n, + receiptFeeMaxGasPrice: 36n, + }, + }); + + expect(evmHash).toBe(MOCK_EVM_TX_HASH); + expect(sendRawTransaction).toHaveBeenCalledWith({serializedTransaction: "0xsigned"}); + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); + expect(txRequest.value).toBe(999n); + expect(txRequest.data.slice(0, 10)).toBe(topUpFeesSelector); + + const decoded = decodeFunctionData({ + abi: FEE_MANAGEMENT_ABI as any, + data: txRequest.data, + }); + const [txId, distribution] = decoded.args as any[]; + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(distribution.leaderTimeunitsAllocation).toBe(100n); + expect(distribution.validatorTimeunitsAllocation).toBe(200n); + expect(distribution.appealRounds).toBe(1n); + expect(distribution.executionBudgetPerRound).toBe(500_000n); + expect(distribution.totalMessageFees).toBe(30n); + expect(distribution.rotations).toEqual([0n, 2n]); + expect(distribution.maxPriceGenPerTimeUnit).toBe(12n); + expect(distribution.storageFeeMaxGasPrice).toBe(24n); + expect(distribution.receiptFeeMaxGasPrice).toBe(36n); + }); + + it("encodes topUpAndSubmitAppeal(bytes32, FeesDistribution) and returns the GenLayer tx id", async () => { + const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); + + const txId = await actions.topUpAndSubmitAppeal({ + txId: MOCK_GENLAYER_TX_ID, + value: 1234n, + distribution: { + appealRounds: 1n, + rotations: [0n, 1n], + }, + }); + + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(sendRawTransaction).toHaveBeenCalledWith({serializedTransaction: "0xsigned"}); + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); + expect(txRequest.value).toBe(1234n); + expect(txRequest.data.slice(0, 10)).toBe(topUpAndSubmitAppealSelector); + + const decoded = decodeFunctionData({ + abi: FEE_MANAGEMENT_ABI as any, + data: txRequest.data, + }); + const [decodedTxId, distribution] = decoded.args as any[]; + expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(distribution.appealRounds).toBe(1n); + expect(distribution.rotations).toEqual([0n, 1n]); + }); + + it("throws when a fee management consensus call is reverted", async () => { + const {actions} = setupFeeManagementHarness({receiptStatus: "reverted"}); + + await expect( + actions.topUpFees({ + txId: MOCK_GENLAYER_TX_ID, + value: 1n, + distribution: {}, + }), + ).rejects.toThrow(/Top up fees reverted/); + }); + + it("returns the Studio RPC hash for fee management calls without waiting for an EVM receipt", async () => { + const {actions, waitForTransactionReceipt} = setupFeeManagementHarness({isStudio: true}); + + const hash = await actions.topUpFees({ + txId: MOCK_GENLAYER_TX_ID, + value: 1n, + distribution: {}, + }); + + expect(hash).toBe(MOCK_EVM_TX_HASH); + expect(waitForTransactionReceipt).not.toHaveBeenCalled(); + }); + + it("returns the Studio RPC hash for external-wallet fee management calls without waiting for an EVM receipt", async () => { + const request = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "eth_gasPrice") return "0x1"; + if (method === "eth_sendTransaction") return MOCK_EVM_TX_HASH; + throw new Error(`Unexpected RPC method: ${method}`); + }); + const waitForTransactionReceipt = vi.fn(); + const client = { + chain: { + id: 61_127, + isStudio: true, + consensusMainContract: { + address: MAIN_CONTRACT_ADDRESS, + abi: FEE_MANAGEMENT_ABI, + bytecode: "0x", + }, + }, + account: { + address: SENDER_ADDRESS, + type: "json-rpc", + }, + getCurrentNonce: vi.fn().mockResolvedValue(0n), + estimateTransactionGas: vi.fn().mockResolvedValue(21_000n), + request, + }; + const actions = contractActions(client as any, {waitForTransactionReceipt} as any); + + const hash = await actions.topUpFees({ + txId: MOCK_GENLAYER_TX_ID, + value: 1n, + distribution: {}, + }); + + expect(hash).toBe(MOCK_EVM_TX_HASH); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ + method: "eth_sendTransaction", + })); + expect(waitForTransactionReceipt).not.toHaveBeenCalled(); + }); +}); + describe("contractActions finalizeTransaction", () => { it("encodes finalizeTransaction(bytes32) and returns EVM tx hash", async () => { const {actions, signTransaction, sendRawTransaction} = setupFinalizeHarness(); From 02eeabc9765de3768724a18f7c4fd9c1db0bb667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 9 Jun 2026 23:41:23 +0100 Subject: [PATCH 02/25] fix: include execution budget floor in fee defaults (#186) --- src/contracts/actions.ts | 2 ++ src/types/clients.ts | 1 + tests/contracts-actions.test.ts | 30 +++++++++++++++--------------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index dc36299..75170e3 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -1017,6 +1017,7 @@ const DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000n; const DEFAULT_LEADER_TIMEUNITS_ALLOCATION = 100n; const DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200n; const DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 500_000n; +const DEFAULT_TRANSACTION_EXECUTION_GAS = 100_000_000n; const DEFAULT_RECEIPT_SLOTS_CHANGED = 7n; const DEFAULT_INTRINSIC_GAS = 21_000n; const DEFAULT_BOOTLOADER_OVERHEAD = 60_000n; @@ -1186,6 +1187,7 @@ const defaultExecutionBudgetPerRound = (policy: FeePolicyQuote): bigint => { return maxBigint( DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, policy.executionBudgetFloor, + policy.receiptGasPrice * DEFAULT_TRANSACTION_EXECUTION_GAS, ); }; diff --git a/src/types/clients.ts b/src/types/clients.ts index e50433d..46f9b13 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -123,6 +123,7 @@ export type GenLayerClient = Omit< status?: TransactionStatus; interval?: number; retries?: number; + fullTransaction?: boolean; }) => Promise; getContractSchema: (address: Address) => Promise; getContractSchemaForCode: (contractCode: string | Uint8Array) => Promise; diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 239711a..dff8c45 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -719,7 +719,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(12n); expect(fees.distribution.storageFeeMaxGasPrice).toBe(24n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(36n); - expect(fees.distribution.executionBudgetPerRound).toBe(800_000n); + expect(fees.distribution.executionBudgetPerRound).toBe(3_000_300_000n); expect(fees.feeValue).toBe(82n); }); @@ -794,8 +794,8 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(10n); expect(fees.distribution.storageFeeMaxGasPrice).toBe(20n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(30n); - expect(fees.distribution.executionBudgetPerRound).toBe(15_345_360n); - expect(fees.feeValue).toBe(15_356_360n); + expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); + expect(fees.feeValue).toBe(3_000_011_000n); }); it("prefers Studio's exposed message fee budget floor over local fallback math", async () => { @@ -824,8 +824,8 @@ describe("contractActions addTransaction ABI compatibility", () => { const fees = await actions.estimateTransactionFees({priceCapHeadroomBps: 10_000n}); expect(fees.policy.executionBudgetFloor).toBe(700_000n); - expect(fees.distribution.executionBudgetPerRound).toBe(700_000n); - expect(fees.feeValue).toBe(711_000n); + expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); + expect(fees.feeValue).toBe(3_000_011_000n); }); it("builds a Studio trusted fee preset from a simulation fee report", async () => { @@ -949,7 +949,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.observed).toEqual({ executionFeeConsumed: 100n, executionFeeReportTotal: 501_664n, - recommendedExecutionBudgetPerRound: 602_117n, + recommendedExecutionBudgetPerRound: 3_000_000_000n, genvmMessageFeeConsumed: 5n, messageFeeBudget: 10n, messageFeeConsumed: 5n, @@ -960,9 +960,9 @@ describe("contractActions addTransaction ABI compatibility", () => { externalMessageRemainder: 0n, recommendedTotalMessageFees: 6n, }); - expect(fees.distribution.executionBudgetPerRound).toBe(602_117n); + expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); expect(fees.distribution.totalMessageFees).toBe(6n); - expect(fees.feeValue).toBe(613_123n); + expect(fees.feeValue).toBe(3_000_011_006n); }); it("builds a Studio trusted fee preset for a target write in one call", async () => { @@ -1011,7 +1011,7 @@ describe("contractActions addTransaction ABI compatibility", () => { leaderTimeunitsAllocation: "100", validatorTimeunitsAllocation: "200", appealRounds: "0", - executionBudgetPerRound: "700000", + executionBudgetPerRound: "3000000000", executionConsumed: "0", totalMessageFees: "110", rotations: ["0"], @@ -1030,7 +1030,7 @@ describe("contractActions addTransaction ABI compatibility", () => { feeParams, }, ], - feeValue: "711110", + feeValue: "3000011110", }, }; } @@ -1061,16 +1061,16 @@ describe("contractActions addTransaction ABI compatibility", () => { const simCall = requestMock.mock.calls.find(([call]) => call.method === "sim_estimateTransactionFees")?.[0]; expect(simCall).toBeDefined(); expect(simCall.params[0].value).toBe("0x7"); - expect(simCall.params[0].fees.feeValue).toBe("811110"); + expect(simCall.params[0].fees.feeValue).toBe("3000311110"); expect(simCall.params[0].fees.distribution.totalMessageFees).toBe("110"); expect(simCall.params[0].fees.messageAllocations[0].budget).toBe("110"); - expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(602_117n); + expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(3_000_000_000n); expect(fees.observed?.messageFeeBudget).toBe(110n); expect(fees.observed?.messageFeeConsumed).toBe(50n); - expect(fees.distribution.executionBudgetPerRound).toBe(700_000n); + expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); expect(fees.distribution.totalMessageFees).toBe(110n); expect(fees.messageAllocations?.[0].budget).toBe(110n); - expect(fees.feeValue).toBe(711_110n); + expect(fees.feeValue).toBe(3_000_011_110n); }); it("preserves mode-2 message allocations from simulation fee accounting", async () => { @@ -1123,7 +1123,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.messageAllocations?.[0].budget).toBe(50n); expect(fees.messageAllocations?.[0].feeParams).toBe(feeParams); expect(fees.distribution.totalMessageFees).toBe(50n); - expect(fees.feeValue).toBe(811_050n); + expect(fees.feeValue).toBe(3_000_311_050n); }); it("keeps Studio fee estimation gasless when sim_getFeeConfig is disabled", async () => { From 2af6928e2587091fb33f5f703160c8a160606048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 13:06:17 +0100 Subject: [PATCH 03/25] feat(fees)!: estimation correctness, wait-for-decided semantics, v0.6 enum completeness (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(fees)!: estimation correctness, wait-for-decided semantics, v0.6 enum completeness Estimation (designer rulings): - receipt gas price: max(quoteGasPrice(), eth_gasPrice) — quoteGasPrice() reads tx.gasprice which is ~0 under plain eth_call on chain-derived networks; a zero effective price on an enabled policy now throws instead of producing a zero cap (consensus rejects those with FeeValueMustBeNonZero(6)) - executionBudgetFloor: max(on-chain view, local recompute at effective price) — the on-chain view multiplies by the same ~0 quoteGasPrice; local formula pins estimateProposeReceiptGas(MIN_RECEIPT_BYTES=512) = 306,192 gas - simulation-derived budgets no longer clobbered by the blanket default: recommended = max(floor, observed × headroom); DEFAULT_TRANSACTION_EXECUTION_GAS (100M) applies only to the no-simulation fallback, exported with provenance + TODO(data) Wait semantics (designer: 'wait for decided', legacy naming was wrong): - waitUntil: 'decided' | 'finalized' replaces status; legacy status param maps ACCEPTED→decided / FINALIZED→finalized with one-time deprecation warning - new isSuccessful(tx): status ∈ {ACCEPTED, FINALIZED} && FINISHED_WITH_RETURN v0.6 ABI completeness: - status 14 LEADER_REVEALING; VoteType 3 TIMEOUT / 4 NONDET_DISAGREE; ResultType remapped to v0.6 (dead v0.5 6/7 entries removed) - revert selectors: BudgetTooLow, RollupBudgetBelowFloor, FeeValueMustBeNonZero - DEPLOY_CALL_KEY = bytes32(1) (deploy callKey sentinel, pending GenVM emit); fullTransaction/waitUntil added to ITransactionActions * ci: restore unit-test and chains-drift CI for the v2 line; add prepare script - test.yml and chains-drift.yml only triggered on v1 — PRs to v2-dev ran nothing but the smoke workflow, so the entire fee rework would have merged with zero unit-test CI (lost in the branching-strategy transition) - prepare script (npm run build) makes git-ref installs usable: dependents (genlayer-cli CI) can pin github:genlayerlabs/genlayer-js# until the v2 npm release exists --- .github/workflows/chains-drift.yml | 4 + .github/workflows/test.yml | 4 + package.json | 1 + src/contracts/actions.ts | 36 +++++- src/index.ts | 1 + src/transactions/ITransactionActions.ts | 9 +- src/transactions/actions.ts | 90 ++++++++++++-- src/transactions/fees.ts | 5 +- src/types/clients.ts | 3 + src/types/transactions.ts | 26 ++-- tests/client.test-d.ts | 6 + tests/contracts-actions.test.ts | 159 +++++++++++++++++++++++- tests/transactions.test.ts | 104 +++++++++++++++- 13 files changed, 411 insertions(+), 37 deletions(-) diff --git a/.github/workflows/chains-drift.yml b/.github/workflows/chains-drift.yml index 55a1d22..c01af3f 100644 --- a/.github/workflows/chains-drift.yml +++ b/.github/workflows/chains-drift.yml @@ -4,9 +4,13 @@ on: pull_request: branches: - v1 + - v2-dev + - v2 push: branches: - v1 + - v2-dev + - v2 schedule: # Daily at 07:00 UTC. Catches upstream contract deployments even when no # PRs are open against the SDK. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b215a1..78f88b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,9 +4,13 @@ on: push: branches: - v1 + - v2-dev + - v2 pull_request: branches: - v1 + - v2-dev + - v2 jobs: test: diff --git a/package.json b/package.json index 23c6e3a..ff044a9 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "LICENSE" ], "scripts": { + "prepare": "npm run build", "build": "tsup src/index.ts src/chains/index.ts src/types/index.ts --format cjs,esm --dts --splitting --out-dir dist --no-treeshake", "build:watch": "tsup src/index.ts src/chains/index.ts src/types/index.ts --format cjs,esm --dts --splitting --out-dir dist --no-treeshake --watch", "test": "vitest --typecheck", diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 75170e3..be1afb3 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -1017,7 +1017,9 @@ const DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000n; const DEFAULT_LEADER_TIMEUNITS_ALLOCATION = 100n; const DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200n; const DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 500_000n; -const DEFAULT_TRANSACTION_EXECUTION_GAS = 100_000_000n; +// Provisional heuristic sized ~20x observed dev-env consumption (~5M gas-equivalent). +// TODO(data): replace with telemetry-derived default (p99 x margin) once fee consumption telemetry is collected. +export const DEFAULT_TRANSACTION_EXECUTION_GAS = 100_000_000n; const DEFAULT_RECEIPT_SLOTS_CHANGED = 7n; const DEFAULT_INTRINSIC_GAS = 21_000n; const DEFAULT_BOOTLOADER_OVERHEAD = 60_000n; @@ -1025,6 +1027,8 @@ const DEFAULT_GAS_PER_CHANGED_SLOT = 1_000n; const DEFAULT_CALLDATA_GAS_PER_BYTE = 16n; const DEFAULT_FIXED_PROPOSE_RECEIPT_GAS = 210_000n; const DEFAULT_FIXED_MESSAGE_REVEAL_GAS = 100_000n; +// ConsensusHelpers.MIN_RECEIPT_BYTES — smallest receipt payload the on-chain budget floor prices. +const DEFAULT_MIN_RECEIPT_BYTES = 512n; const DEFAULT_MESSAGE_REVEAL_LENGTH_SLOTS = 32n; const DEFAULT_NONDET_OUTPUT_LENGTH_BYTES = 32n; const TRANSACTION_GAS_HEADROOM_BPS = 20_000n; @@ -1158,19 +1162,38 @@ const readCurrentFeePolicy = async ( const address = client.chain.feeManagerContract.address as `0x${string}`; const abi = FEE_MANAGER_CALCULATE_ROUND_FEES_ABI as any; - const [genPerTimeUnit, storageUnitPrice, receiptGasPrice, executionBudgetFloor] = await Promise.all([ + const [genPerTimeUnit, storageUnitPrice, quotedReceiptGasPrice, executionBudgetFloor] = await Promise.all([ publicClient.readContract({address, abi, functionName: "GENPerTimeUnit", args: []}) as Promise, publicClient.readContract({address, abi, functionName: "storageUnitPrice", args: []}) as Promise, publicClient.readContract({address, abi, functionName: "quoteGasPrice", args: []}) as Promise, publicClient.readContract({address, abi, functionName: "messageFeeParamsBudgetFloor", args: []}) as Promise, ]); + const enabled = genPerTimeUnit > 0n || storageUnitPrice > 0n || quotedReceiptGasPrice > 0n; + const networkReceiptGasPrice = enabled ? await publicClient.getGasPrice() : 0n; + const receiptGasPrice = maxBigint(quotedReceiptGasPrice, networkReceiptGasPrice); + if (enabled && receiptGasPrice === 0n) { + throw new Error("receipt gas price quoted as zero; refusing to build a zero price cap"); + } + + // messageFeeParamsBudgetFloor() multiplies by quoteGasPrice() on-chain, which reads + // tx.gasprice ~ 0 under a plain eth_call — so the view can report a zero floor on + // chain-derived networks while the real submission-time floor is non-zero. Recompute + // the floor locally at the effective receipt price (FeeManager.estimateProposeReceiptGas + // at ConsensusHelpers.MIN_RECEIPT_BYTES) and take the max. + const localExecutionBudgetFloor = receiptGasPrice * ( + DEFAULT_FIXED_PROPOSE_RECEIPT_GAS + + DEFAULT_INTRINSIC_GAS + + DEFAULT_BOOTLOADER_OVERHEAD + + (DEFAULT_MIN_RECEIPT_BYTES * DEFAULT_CALLDATA_GAS_PER_BYTE) + + (DEFAULT_RECEIPT_SLOTS_CHANGED * DEFAULT_GAS_PER_CHANGED_SLOT) + ); return { - enabled: genPerTimeUnit > 0n || storageUnitPrice > 0n || receiptGasPrice > 0n, + enabled, genPerTimeUnit, storageUnitPrice, receiptGasPrice, - executionBudgetFloor, + executionBudgetFloor: maxBigint(executionBudgetFloor, localExecutionBudgetFloor), }; }; @@ -1330,7 +1353,7 @@ const observedSimulationFeeUsage = ( const observedExecutionBudget = executionFeeConsumed + executionFeeReportTotal; const recommendedExecutionBudgetPerRound = observedExecutionBudget > 0n ? maxBigint( - defaultExecutionBudgetPerRound(policy), + policy.executionBudgetFloor, withCapHeadroom(observedExecutionBudget, executionHeadroomBps), ) : 0n; @@ -1985,6 +2008,9 @@ const _sendTransaction = async ({ "0x8d53e553": "InsufficientFees", "0xb4132db3": "MaxPriceExceeded", "0x57df8523": "ExecutionBudgetExceeded", + "0x305e533c": "BudgetTooLow", + "0xa70732ee": "RollupBudgetBelowFloor", + "0x632be5a1": "FeeValueMustBeNonZero", }; const stringifyRpcError = (error: unknown): string => { diff --git a/src/index.ts b/src/index.ts index 507a1c7..52530c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,5 +10,6 @@ export { export * as chains from "./chains"; export * as abi from "./abi"; export * from "./transactions/fees"; +export {isSuccessful} from "./transactions/actions"; export {parseStakingAmount, formatStakingAmount} from "./staking"; export {buildGenVmPositionalArgs} from "./contracts/schema"; diff --git a/src/transactions/ITransactionActions.ts b/src/transactions/ITransactionActions.ts index 1f054d0..9b08606 100644 --- a/src/transactions/ITransactionActions.ts +++ b/src/transactions/ITransactionActions.ts @@ -1,15 +1,20 @@ -import {TransactionHash, TransactionStatus, GenLayerTransaction} from "@/types"; +import {TransactionHash, TransactionStatus, GenLayerTransaction, TransactionReceiptWaitUntil} from "@/types"; export type ITransactionActions = { waitForTransactionReceipt: ({ hash, status, + waitUntil, interval, retries, + fullTransaction, }: { hash: TransactionHash; - status: TransactionStatus; + /** @deprecated Use waitUntil: "decided" or waitUntil: "finalized" instead. */ + status?: TransactionStatus; + waitUntil?: TransactionReceiptWaitUntil; interval?: number; retries?: number; + fullTransaction?: boolean; }) => Promise; }; diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index da32faa..491067a 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -4,9 +4,13 @@ import { TransactionStatus, GenLayerTransaction, GenLayerRawTransaction, + ExecutionResult, transactionsStatusNameToNumber, + transactionsStatusNumberToName, + executionResultNumberToName, isDecidedState, DebugTraceResult, + TransactionReceiptWaitUntil, } from "../types/transactions"; import {transactionsConfig} from "../config/transactions"; import {sleep} from "../utils/async"; @@ -14,21 +18,92 @@ import {GenLayerChain} from "@/types"; import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes} from "viem"; import {decodeLocalnetTransaction, decodeTransaction, simplifyTransactionReceipt} from "./decoders"; +let didWarnWaitForTransactionReceiptStatus = false; + +const warnDeprecatedReceiptStatus = () => { + if (didWarnWaitForTransactionReceiptStatus) return; + didWarnWaitForTransactionReceiptStatus = true; + console.warn("waitForTransactionReceipt({ status }) is deprecated; use waitUntil: 'decided' or waitUntil: 'finalized' instead."); +}; + +const resolveWaitTarget = ( + status: TransactionStatus | undefined, + waitUntil: TransactionReceiptWaitUntil | undefined, +): { + waitUntil?: TransactionReceiptWaitUntil; + legacyStatus?: TransactionStatus; + label: string; +} => { + if (waitUntil) { + return {waitUntil, label: waitUntil}; + } + if (!status) { + return {waitUntil: "decided", label: "decided"}; + } + + warnDeprecatedReceiptStatus(); + if (status === TransactionStatus.ACCEPTED) { + return {waitUntil: "decided", label: "decided"}; + } + if (status === TransactionStatus.FINALIZED) { + return {waitUntil: "finalized", label: "finalized"}; + } + return {legacyStatus: status, label: status}; +}; + +const hasReachedWaitTarget = ( + transactionStatusString: string, + target: ReturnType, +): boolean => { + if (target.waitUntil === "decided") { + return isDecidedState(transactionStatusString); + } + if (target.waitUntil === "finalized") { + return transactionStatusString === transactionsStatusNameToNumber[TransactionStatus.FINALIZED]; + } + if (!target.legacyStatus) return false; + return transactionStatusString === transactionsStatusNameToNumber[target.legacyStatus]; +}; + +export const isSuccessful = (transaction: GenLayerTransaction): boolean => { + const statusName = transaction.statusName ?? ( + typeof transaction.status === "string" && transaction.status in TransactionStatus + ? transaction.status as TransactionStatus + : transaction.status === undefined + ? undefined + : transactionsStatusNumberToName[String(transaction.status) as keyof typeof transactionsStatusNumberToName] + ); + const executionResultName = transaction.txExecutionResultName ?? ( + transaction.txExecutionResult === undefined + ? undefined + : executionResultNumberToName[String(transaction.txExecutionResult) as keyof typeof executionResultNumberToName] + ); + + return ( + (statusName === TransactionStatus.ACCEPTED || statusName === TransactionStatus.FINALIZED) && + executionResultName === ExecutionResult.FINISHED_WITH_RETURN + ); +}; + export const receiptActions = (client: GenLayerClient, publicClient: PublicClient) => ({ /** Polls until a transaction reaches the specified status. Returns the transaction receipt. */ waitForTransactionReceipt: async ({ hash, - status = TransactionStatus.ACCEPTED, + status, + waitUntil, interval = transactionsConfig.waitInterval, retries = transactionsConfig.retries, fullTransaction = false, }: { hash: TransactionHash; - status: TransactionStatus; + /** @deprecated Use waitUntil: "decided" or waitUntil: "finalized" instead. */ + status?: TransactionStatus; + waitUntil?: TransactionReceiptWaitUntil; interval?: number; retries?: number; fullTransaction?: boolean; }): Promise => { + const target = resolveWaitTarget(status, waitUntil); const transaction = await client.getTransaction({ hash, }); @@ -37,11 +112,7 @@ export const receiptActions = (client: GenLayerClient, publicClie throw new Error(`Transaction not found: ${hash}`); } const transactionStatusString = String(transaction.status); - const requestedStatus = transactionsStatusNameToNumber[status]; - if ( - transactionStatusString === requestedStatus || - (status === TransactionStatus.ACCEPTED && isDecidedState(transactionStatusString)) - ) { + if (hasReachedWaitTarget(transactionStatusString, target)) { let finalTransaction = transaction; if (client.chain.isStudio) { finalTransaction = decodeLocalnetTransaction(transaction as unknown as GenLayerTransaction); @@ -53,13 +124,14 @@ export const receiptActions = (client: GenLayerClient, publicClie } if (retries === 0) { - throw new Error(`Timed out waiting for transaction ${hash} to reach status "${status}" (current status: ${transactionStatusString}).`); + throw new Error(`Timed out waiting for transaction ${hash} to reach "${target.label}" (current status: ${transactionStatusString}).`); } await sleep(interval); return receiptActions(client, publicClient).waitForTransactionReceipt({ hash, - status, + waitUntil: target.waitUntil, + status: target.legacyStatus, interval, retries: retries - 1, fullTransaction, diff --git a/src/transactions/fees.ts b/src/transactions/fees.ts index a4c1cfc..69b7bb9 100644 --- a/src/transactions/fees.ts +++ b/src/transactions/fees.ts @@ -15,7 +15,10 @@ import { export const MESSAGE_ALLOCATION_ROOT_PARENT_INDEX = (1n << 256n) - 1n; export const CALL_KEY_WILDCARD = "0x0000000000000000000000000000000000000000000000000000000000000000" as const; export const CALL_KEY_UNNAMED = CALL_KEY_WILDCARD; -export const CALL_KEY_DEPLOY = CALL_KEY_WILDCARD; +export const DEPLOY_CALL_KEY = "0x0000000000000000000000000000000000000000000000000000000000000001" as const; +export const CALL_KEY_DEPLOY = DEPLOY_CALL_KEY; + +export const deployCallKey = (): Hex => DEPLOY_CALL_KEY; const bytesToPaddedCallKey = (bytes: Uint8Array): Hex => { if (bytes.length > 32) { diff --git a/src/types/clients.ts b/src/types/clients.ts index 46f9b13..336a449 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -3,6 +3,7 @@ import { GenLayerTransaction, TransactionHash, TransactionStatus, + TransactionReceiptWaitUntil, TransactionHashVariant, DebugTraceResult, TransactionFeeOptions, @@ -120,7 +121,9 @@ export type GenLayerClient = Omit< }) => Promise; waitForTransactionReceipt: (args: { hash: TransactionHash; + /** @deprecated Use waitUntil: "decided" or waitUntil: "finalized" instead. */ status?: TransactionStatus; + waitUntil?: TransactionReceiptWaitUntil; interval?: number; retries?: number; fullTransaction?: boolean; diff --git a/src/types/transactions.ts b/src/types/transactions.ts index 9e24526..f4ad76e 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -21,6 +21,7 @@ export enum TransactionStatus { READY_TO_FINALIZE = "READY_TO_FINALIZE", VALIDATORS_TIMEOUT = "VALIDATORS_TIMEOUT", LEADER_TIMEOUT = "LEADER_TIMEOUT", + LEADER_REVEALING = "LEADER_REVEALING", } export enum TransactionResult { @@ -37,6 +38,7 @@ export enum TransactionResult { NO_MAJORITY = "NO_MAJORITY", MAJORITY_AGREE = "MAJORITY_AGREE", MAJORITY_DISAGREE = "MAJORITY_DISAGREE", + MAJORITY_TIMEOUT = "MAJORITY_TIMEOUT", } export const transactionsStatusNumberToName = { @@ -54,6 +56,7 @@ export const transactionsStatusNumberToName = { "11": TransactionStatus.READY_TO_FINALIZE, "12": TransactionStatus.VALIDATORS_TIMEOUT, "13": TransactionStatus.LEADER_TIMEOUT, + "14": TransactionStatus.LEADER_REVEALING, }; export const transactionsStatusNameToNumber = { @@ -71,6 +74,7 @@ export const transactionsStatusNameToNumber = { [TransactionStatus.READY_TO_FINALIZE]: "11", [TransactionStatus.VALIDATORS_TIMEOUT]: "12", [TransactionStatus.LEADER_TIMEOUT]: "13", + [TransactionStatus.LEADER_REVEALING]: "14", }; export const DECIDED_STATES = [ @@ -90,36 +94,36 @@ export function isDecidedState(status: string): boolean { export const transactionResultNumberToName = { "0": TransactionResult.IDLE, - "1": TransactionResult.AGREE, - "2": TransactionResult.DISAGREE, - "3": TransactionResult.TIMEOUT, + "1": TransactionResult.MAJORITY_AGREE, + "2": TransactionResult.MAJORITY_DISAGREE, + "3": TransactionResult.MAJORITY_TIMEOUT, "4": TransactionResult.DETERMINISTIC_VIOLATION, "5": TransactionResult.NO_MAJORITY, - "6": TransactionResult.MAJORITY_AGREE, - "7": TransactionResult.MAJORITY_DISAGREE, }; export const TransactionResultNameToNumber = { [TransactionResult.IDLE]: "0", - [TransactionResult.AGREE]: "1", - [TransactionResult.DISAGREE]: "2", - [TransactionResult.TIMEOUT]: "3", + [TransactionResult.MAJORITY_AGREE]: "1", + [TransactionResult.MAJORITY_DISAGREE]: "2", + [TransactionResult.MAJORITY_TIMEOUT]: "3", [TransactionResult.DETERMINISTIC_VIOLATION]: "4", [TransactionResult.NO_MAJORITY]: "5", - [TransactionResult.MAJORITY_AGREE]: "6", - [TransactionResult.MAJORITY_DISAGREE]: "7", }; export enum ExecutionResult { NOT_VOTED = "NOT_VOTED", FINISHED_WITH_RETURN = "FINISHED_WITH_RETURN", FINISHED_WITH_ERROR = "FINISHED_WITH_ERROR", + TIMEOUT = "TIMEOUT", + NONDET_DISAGREE = "NONDET_DISAGREE", } export const executionResultNumberToName = { "0": ExecutionResult.NOT_VOTED, "1": ExecutionResult.FINISHED_WITH_RETURN, "2": ExecutionResult.FINISHED_WITH_ERROR, + "3": ExecutionResult.TIMEOUT, + "4": ExecutionResult.NONDET_DISAGREE, }; export enum VoteType { @@ -153,6 +157,8 @@ export enum TransactionHashVariant { LATEST_NONFINAL = "latest-nonfinal", } +export type TransactionReceiptWaitUntil = "decided" | "finalized"; + export enum MessageType { External = 0, Internal = 1, diff --git a/tests/client.test-d.ts b/tests/client.test-d.ts index 72661b5..09f0cd5 100644 --- a/tests/client.test-d.ts +++ b/tests/client.test-d.ts @@ -42,6 +42,12 @@ test("type checks", () => { hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, }); + void client.waitForTransactionReceipt({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + waitUntil: "decided", + fullTransaction: true, + }); + void client.waitForTransactionReceipt({ hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, status: TransactionStatus.FINALIZED, diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index dff8c45..98a5ab6 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -4,6 +4,8 @@ import {contractActions} from "../src/contracts/actions"; import { CALL_KEY_DEPLOY, CALL_KEY_WILDCARD, + DEPLOY_CALL_KEY, + deployCallKey, deriveExternalMessageCallKey, deriveInternalMessageCallKey, encodeExternalMessageFeeParams, @@ -433,7 +435,9 @@ describe("contractActions addTransaction ABI compatibility", () => { ); expect(deriveInternalMessageCallKey()).toBe(CALL_KEY_WILDCARD); - expect(CALL_KEY_DEPLOY).toBe(CALL_KEY_WILDCARD); + expect(DEPLOY_CALL_KEY).toBe("0x0000000000000000000000000000000000000000000000000000000000000001"); + expect(CALL_KEY_DEPLOY).toBe(DEPLOY_CALL_KEY); + expect(deployCallKey()).toBe(DEPLOY_CALL_KEY); expect(deriveExternalMessageCallKey("0xaabbccdd11223344")).toBe( `0xaabbccdd${"0".repeat(56)}`, ); @@ -700,6 +704,7 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), + getGasPrice: vi.fn().mockResolvedValue(1n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -709,12 +714,18 @@ describe("contractActions addTransaction ABI compatibility", () => { const fees = await actions.estimateTransactionFees({totalMessageFees: 5n}); + // Effective floor = max(on-chain view (1,234 — reads ~0-priced quoteGasPrice under + // eth_call), local recompute at the effective receipt price). Local formula pins + // FeeManager.estimateProposeReceiptGas(MIN_RECEIPT_BYTES=512): + // 210,000 + 21,000 + 60,000 + 512*16 + 7*1,000 = 306,192 gas. + const expectedLocalFloor = 30n * (210_000n + 21_000n + 60_000n + 512n * 16n + 7n * 1_000n); + expect(expectedLocalFloor).toBe(30n * 306_192n); expect(fees.policy).toEqual({ enabled: true, genPerTimeUnit: 10n, storageUnitPrice: 20n, receiptGasPrice: 30n, - executionBudgetFloor: 1_234n, + executionBudgetFloor: expectedLocalFloor, }); expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(12n); expect(fees.distribution.storageFeeMaxGasPrice).toBe(24n); @@ -723,6 +734,78 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.feeValue).toBe(82n); }); + it("uses the network gas price when FeeManager quotes a lower receipt gas price", async () => { + const publicClient = { + readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "GENPerTimeUnit") return 10n; + if (functionName === "storageUnitPrice") return 20n; + if (functionName === "quoteGasPrice") return 0n; + if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "calculateRoundFees") return 77n; + throw new Error(`unexpected readContract ${functionName}`); + }), + getGasPrice: vi.fn().mockResolvedValue(25n), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + const fees = await actions.estimateTransactionFees({priceCapHeadroomBps: 10_000n}); + + expect(publicClient.getGasPrice).toHaveBeenCalledOnce(); + expect(fees.policy.receiptGasPrice).toBe(25n); + expect(fees.distribution.receiptFeeMaxGasPrice).toBe(25n); + }); + + it("throws instead of building a zero receipt gas price cap when policy is enabled", async () => { + const publicClient = { + readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "GENPerTimeUnit") return 10n; + if (functionName === "storageUnitPrice") return 0n; + if (functionName === "quoteGasPrice") return 0n; + if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + throw new Error(`unexpected readContract ${functionName}`); + }), + getGasPrice: vi.fn().mockResolvedValue(0n), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + await expect(actions.estimateTransactionFees()).rejects.toThrow( + "receipt gas price quoted as zero; refusing to build a zero price cap", + ); + }); + + it("does not fetch network gas price when FeeManager policy is disabled", async () => { + const publicClient = { + readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "GENPerTimeUnit") return 0n; + if (functionName === "storageUnitPrice") return 0n; + if (functionName === "quoteGasPrice") return 0n; + if (functionName === "messageFeeParamsBudgetFloor") return 0n; + if (functionName === "calculateRoundFees") return 0n; + throw new Error(`unexpected readContract ${functionName}`); + }), + getGasPrice: vi.fn().mockResolvedValue(0n), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + const fees = await actions.estimateTransactionFees(); + + expect(publicClient.getGasPrice).not.toHaveBeenCalled(); + expect(fees.policy.enabled).toBe(false); + expect(fees.distribution.receiptFeeMaxGasPrice).toBe(0n); + }); + it("defaults total message fees from root and external message allocation budgets", async () => { const publicClient = { readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { @@ -733,6 +816,7 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), + getGasPrice: vi.fn().mockResolvedValue(1n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -946,10 +1030,17 @@ describe("contractActions addTransaction ABI compatibility", () => { priceCapHeadroomBps: 10_000n, }); + const observedExecutionBudget = 100n + 501_664n; + const observedExecutionBudgetWithHeadroom = (observedExecutionBudget * 12_000n + 9_999n) / 10_000n; + const expectedExecutionBudgetPerRound = observedExecutionBudgetWithHeadroom > 400_000n + ? observedExecutionBudgetWithHeadroom + : 400_000n; + expect(expectedExecutionBudgetPerRound).toBe(602_117n); + expect(fees.observed).toEqual({ executionFeeConsumed: 100n, executionFeeReportTotal: 501_664n, - recommendedExecutionBudgetPerRound: 3_000_000_000n, + recommendedExecutionBudgetPerRound: expectedExecutionBudgetPerRound, genvmMessageFeeConsumed: 5n, messageFeeBudget: 10n, messageFeeConsumed: 5n, @@ -960,9 +1051,47 @@ describe("contractActions addTransaction ABI compatibility", () => { externalMessageRemainder: 0n, recommendedTotalMessageFees: 6n, }); - expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); + expect(fees.distribution.executionBudgetPerRound).toBe(expectedExecutionBudgetPerRound); expect(fees.distribution.totalMessageFees).toBe(6n); - expect(fees.feeValue).toBe(3_000_011_006n); + expect(fees.feeValue).toBe(613_123n); + }); + + it("uses the execution budget floor for simulation recommendations when observed usage is lower", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + messageFeeParamsBudgetFloor: "400000", + }, + }; + } + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesFromSimulation({ + simulation: { + feeAccounting: { + execution_fee_consumed: "100", + execution_fee_report: { + totalEstimatedFee: "100", + }, + }, + }, + }); + + const observedExecutionBudgetWithHeadroom = ((100n + 100n) * 12_000n + 9_999n) / 10_000n; + expect(observedExecutionBudgetWithHeadroom).toBe(240n); + expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(400_000n); + expect(fees.distribution.executionBudgetPerRound).toBe(400_000n); }); it("builds a Studio trusted fee preset for a target write in one call", async () => { @@ -1064,7 +1193,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(simCall.params[0].fees.feeValue).toBe("3000311110"); expect(simCall.params[0].fees.distribution.totalMessageFees).toBe("110"); expect(simCall.params[0].fees.messageAllocations[0].budget).toBe("110"); - expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(3_000_000_000n); + expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(602_117n); expect(fees.observed?.messageFeeBudget).toBe(110n); expect(fees.observed?.messageFeeConsumed).toBe(50n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); @@ -1406,6 +1535,24 @@ describe("contractActions addTransaction ABI compatibility", () => { ).rejects.toThrow("Transaction reverted"); }); + it("decodes BudgetTooLow selector from gas estimation failures", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const {actions, estimateTransactionGas, client} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + signTransactionMock: signTransaction, + publicClient: makeMockPublicClient({status: "reverted", logs: []}), + }); + estimateTransactionGas.mockRejectedValueOnce(new Error("execution reverted: 0x305e533c")); + (client as any).sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); + + await expect( + actions.writeContract({address: RECIPIENT_ADDRESS, functionName: "ping", value: 0n}), + ).rejects.toThrow(/BudgetTooLow/); + + consoleError.mockRestore(); + }); + it("throws when external wallet receipt has no NewTransaction event", async () => { const request = vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index fd1e82b..6ad9094 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { TransactionStatus, DECIDED_STATES, isDecidedState } from "../src/types/transactions"; -import { receiptActions, transactionActions } from "../src/transactions/actions"; +import { + TransactionStatus, + TransactionResult, + ExecutionResult, + DECIDED_STATES, + isDecidedState, + transactionsStatusNumberToName, + transactionResultNumberToName, + executionResultNumberToName, +} from "../src/types/transactions"; +import { receiptActions, transactionActions, isSuccessful } from "../src/transactions/actions"; import { decodeTransaction, simplifyTransactionReceipt } from "../src/transactions/decoders"; import { localnet } from "../src/chains/localnet"; import type { GenLayerRawTransaction } from "../src/types/transactions"; @@ -33,7 +42,7 @@ describe("isDecidedState utility function", () => { }); it("should return false for non-decided states", () => { - const nonDecidedStatusNumbers = ["0", "1", "2", "3", "4", "9", "10", "11"]; // UNINITIALIZED, PENDING, PROPOSING, COMMITTING, REVEALING, APPEAL_REVEALING, APPEAL_COMMITTING, READY_TO_FINALIZE + const nonDecidedStatusNumbers = ["0", "1", "2", "3", "4", "9", "10", "11", "14"]; // transient states nonDecidedStatusNumbers.forEach(statusNum => { expect(isDecidedState(statusNum)).toBe(false); @@ -49,11 +58,98 @@ describe("isDecidedState utility function", () => { }); }); +describe("transaction enum maps", () => { + it("maps v0.6 transaction status, vote type, and result type values", () => { + expect(transactionsStatusNumberToName["14"]).toBe(TransactionStatus.LEADER_REVEALING); + expect(isDecidedState("14")).toBe(false); + expect(executionResultNumberToName["3"]).toBe(ExecutionResult.TIMEOUT); + expect(executionResultNumberToName["4"]).toBe(ExecutionResult.NONDET_DISAGREE); + expect(transactionResultNumberToName).toEqual({ + "0": TransactionResult.IDLE, + "1": TransactionResult.MAJORITY_AGREE, + "2": TransactionResult.MAJORITY_DISAGREE, + "3": TransactionResult.MAJORITY_TIMEOUT, + "4": TransactionResult.DETERMINISTIC_VIOLATION, + "5": TransactionResult.NO_MAJORITY, + }); + }); +}); + +describe("isSuccessful", () => { + it("returns true only for accepted/finalized transactions that finished with return", () => { + expect(isSuccessful({ + statusName: TransactionStatus.ACCEPTED, + txExecutionResultName: ExecutionResult.FINISHED_WITH_RETURN, + } as any)).toBe(true); + expect(isSuccessful({ + status: 7, + txExecutionResult: 1, + } as any)).toBe(true); + expect(isSuccessful({ + statusName: TransactionStatus.UNDETERMINED, + txExecutionResultName: ExecutionResult.FINISHED_WITH_RETURN, + } as any)).toBe(false); + expect(isSuccessful({ + statusName: TransactionStatus.ACCEPTED, + txExecutionResultName: ExecutionResult.FINISHED_WITH_ERROR, + } as any)).toBe(false); + expect(isSuccessful({ + statusName: TransactionStatus.CANCELED, + txExecutionResultName: ExecutionResult.FINISHED_WITH_RETURN, + } as any)).toBe(false); + }); +}); + describe("waitForTransactionReceipt with DECIDED_STATES", () => { beforeEach(() => { mockFetch.mockReset(); }); + it("resolves waitUntil decided on UNDETERMINED", async () => { + const mockTransaction = { + hash: "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6", + status: "6", + }; + const mockClient = { + chain: localnet, + getTransaction: vi.fn().mockResolvedValue(mockTransaction) + }; + + const actions = receiptActions(mockClient as any, {} as any); + const result = await actions.waitForTransactionReceipt({ + hash: "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6" as any, + waitUntil: "decided", + }); + + expect(result).toEqual(mockTransaction); + }); + + it("keeps legacy ACCEPTED status behavior and warns once", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mockTransaction = { + hash: "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6", + status: "6", + }; + const mockClient = { + chain: localnet, + getTransaction: vi.fn().mockResolvedValue(mockTransaction) + }; + const actions = receiptActions(mockClient as any, {} as any); + + await actions.waitForTransactionReceipt({ + hash: "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6" as any, + status: TransactionStatus.ACCEPTED, + }); + await actions.waitForTransactionReceipt({ + hash: "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6" as any, + status: TransactionStatus.ACCEPTED, + }); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toContain("waitForTransactionReceipt({ status }) is deprecated"); + consoleWarn.mockRestore(); + }); + it("should accept all decided states when waiting for ACCEPTED", async () => { const decidedStatusNumbers = ["5", "6", "13", "12", "8", "7"]; // All decided states @@ -270,7 +366,7 @@ describe("decodeTransaction", () => { expect(decoded.numOfInitialValidators).toBe("3"); expect(decoded.txSlot).toBe("5"); expect(decoded.statusName).toBe("ACCEPTED"); - expect(decoded.resultName).toBe("AGREE"); + expect(decoded.resultName).toBe("MAJORITY_AGREE"); }); it("should handle Bradbury field: initialRotations instead of numOfInitialValidators", () => { From 1b7f50a3a3f2963ea857941b0fb386081dd5c326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 15:45:30 +0100 Subject: [PATCH 04/25] ci: keep main forwarded to active dev branch (#189) --- .github/scripts/validate-branch-policy.sh | 107 ++++++++++++++++++++++ .github/workflows/branch-policy.yml | 24 +++++ .github/workflows/fast-forward-main.yaml | 57 ++++++++++++ .github/workflows/retarget-main-prs.yaml | 53 +++++++++++ support/ci/ACTIVE_DEV_BRANCH | 1 + 5 files changed, 242 insertions(+) create mode 100755 .github/scripts/validate-branch-policy.sh create mode 100644 .github/workflows/branch-policy.yml create mode 100644 .github/workflows/fast-forward-main.yaml create mode 100644 .github/workflows/retarget-main-prs.yaml create mode 100644 support/ci/ACTIVE_DEV_BRANCH diff --git a/.github/scripts/validate-branch-policy.sh b/.github/scripts/validate-branch-policy.sh new file mode 100755 index 0000000..0fb7ee2 --- /dev/null +++ b/.github/scripts/validate-branch-policy.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +failed=0 + +error() { + echo "::error::$*" + failed=1 +} + +warning() { + echo "::warning::$*" +} + +active_branch_file="support/ci/ACTIVE_DEV_BRANCH" +if [[ ! -f "${active_branch_file}" ]]; then + error "${active_branch_file} is required." + active_branch="" +else + active_branch="$(tr -d '[:space:]' < "${active_branch_file}")" +fi + +if [[ -z "${active_branch}" ]]; then + error "${active_branch_file} must not be empty." +elif [[ "${active_branch}" == "main" ]]; then + error "${active_branch_file} must point to a dev branch, not main." +elif [[ "${active_branch}" != *-dev ]]; then + warning "${active_branch_file} should normally point to a -dev branch; got ${active_branch}." +fi + +release_branch="${active_branch%-dev}" +default_branch="${GITHUB_DEFAULT_BRANCH:-}" +event_name="${GITHUB_EVENT_NAME:-local}" +base_ref="${GITHUB_BASE_REF:-}" +head_ref="${GITHUB_HEAD_REF:-}" +ref_name="${GITHUB_REF_NAME:-}" +actor="${GITHUB_ACTOR:-}" + +if [[ -n "${default_branch}" && "${default_branch}" != "main" ]]; then + warning "Repository default branch should be main after branch-policy rollout; currently ${default_branch}." +fi + +if [[ -n "${base_ref}" && "${base_ref}" == "main" ]]; then + warning "PR targets main; retarget-main-prs should move it to ${active_branch}." +fi + +if [[ -n "${base_ref}" && -n "${active_branch}" ]]; then + if [[ "${base_ref}" == "${release_branch}" && "${head_ref}" != "${active_branch}" && "${ALLOW_DIRECT_RELEASE_PR:-false}" != "true" ]]; then + error "PRs into ${release_branch} must come from ${active_branch}. Merge feature work into ${active_branch}, then promote ${active_branch} -> ${release_branch}." + fi +fi + +if [[ "${event_name}" == "push" && "${ref_name}" == "main" ]]; then + case "${actor}" in + github-actions[bot]|ci-core-e2e-runner[bot]) + ;; + *) + error "main should only move by automation from ${active_branch}; direct push actor was ${actor:-unknown}." + ;; + esac +fi + +if [[ ! -f ".github/workflows/fast-forward-main.yaml" ]]; then + error ".github/workflows/fast-forward-main.yaml is required." +fi + +if [[ ! -f ".github/workflows/retarget-main-prs.yaml" ]]; then + error ".github/workflows/retarget-main-prs.yaml is required." +fi + +if [[ -f ".github/workflows/release-from-main.yml" ]]; then + error ".github/workflows/release-from-main.yml is forbidden. Releases must be tag/version-branch driven." +fi + +if [[ -f "release.config.js" ]]; then + error "release.config.js is forbidden in versioned tooling branches; semantic-release-on-main must not be restored." +fi + +if [[ -f ".github/workflows/release-from-tag.yml" ]]; then + if ! grep -Fq 'v*.*.*' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must trigger only from version tags matching v*.*.*." + fi + if ! grep -Fq 'refs/remotes/origin/${version_branch}' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'tag_commit' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'branch_head' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must verify the tag commit is the current matching version branch head." + fi +fi + +if [[ -f ".github/workflows/manual-docker-release.yml" ]]; then + if ! grep -Fq 'expected_branch=' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must derive and enforce the expected version branch from the tag." + fi + if ! grep -Fq './.github/workflows/release-from-tag.yml' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must delegate image promotion to release-from-tag.yml." + fi +fi + +if [[ "${failed}" -ne 0 ]]; then + exit 1 +fi + +if [[ -n "${base_ref}" ]]; then + echo "Branch policy ok for PR ${head_ref} -> ${base_ref}; active dev branch is ${active_branch}." +else + echo "Branch policy ok for ${event_name} on ${ref_name:-detached ref}; active dev branch is ${active_branch}." +fi diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 0000000..7759870 --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,24 @@ +name: Branch Policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + push: + branches: + - "**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + branch-policy: + name: Validate branch policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate branch policy + env: + GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: ./.github/scripts/validate-branch-policy.sh diff --git a/.github/workflows/fast-forward-main.yaml b/.github/workflows/fast-forward-main.yaml new file mode 100644 index 0000000..688e997 --- /dev/null +++ b/.github/workflows/fast-forward-main.yaml @@ -0,0 +1,57 @@ +name: Fast-forward main + +# main is the static/default branch for GitHub UX and tools that assume a +# stable default branch. It is not the integration target. On each push to the +# configured active dev branch, fast-forward main to that commit. + +on: + push: + branches: ["**"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: fast-forward-main-${{ github.repository }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + fast-forward: + if: github.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fast-forward main to active dev branch + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + if [[ "${GITHUB_REF_NAME}" != "${active_branch}" ]]; then + echo "Push was to ${GITHUB_REF_NAME}; active dev branch is ${active_branch}. Nothing to do." + exit 0 + fi + + if git ls-remote --exit-code --heads origin main >/dev/null 2>&1; then + git fetch origin main + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "::error::main has diverged from ${active_branch}; refusing non-fast-forward update" + exit 1 + fi + else + echo "main does not exist yet; creating it at ${GITHUB_SHA}." + fi + + git push origin "HEAD:refs/heads/main" diff --git a/.github/workflows/retarget-main-prs.yaml b/.github/workflows/retarget-main-prs.yaml new file mode 100644 index 0000000..37a066f --- /dev/null +++ b/.github/workflows/retarget-main-prs.yaml @@ -0,0 +1,53 @@ +name: Retarget main PRs + +# main is a static/default alias of the active dev branch. Contributions should +# target the active dev branch directly; PRs opened against main are retargeted +# automatically so required checks and release-train rules run in the right +# branch context. +# +# pull_request_target is used for the write-scoped token. This workflow never +# checks out or executes PR head code; it reads only trusted base-branch files. + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +defaults: + run: + shell: bash + +jobs: + retarget: + if: github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - name: Retarget PR to active dev branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --base "${active_branch}" + + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body "$(cat < Date: Thu, 11 Jun 2026 17:09:07 +0100 Subject: [PATCH 05/25] docs: add branching guide (#191) --- CONTRIBUTING.md | 20 +++++----------- docs/BRANCHING.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 docs/BRANCHING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 160a7e2..9efa733 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,19 +30,11 @@ Have ideas for new features or use cases? We're eager to hear them! But first: ## Branch model -This repo uses a branch-per-major release model. There is no `main`. - -- **`v1`** — current stable major. PRs for bug fixes / non-breaking features target this branch. Releases are cut from here (see `scripts/release.sh`). -- **`v-dev`** — when a major bump is in progress (e.g. `v2-dev`), this branch is open for the new major's work. PRs introducing breaking changes target this branch, not `v1`. -- **Older majors** (`v0`, etc.) 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 `v1` today. If you have a `main` branch from a previous checkout, delete it locally: - -```sh -git checkout v1 -git branch -D main -git remote prune origin -``` +See [docs/BRANCHING.md](docs/BRANCHING.md) for the current release-train model. +In short: independently releasable work may target the stable branch directly; +multi-feature or cross-repo train work uses the active `*-dev` integration +branch and is promoted to the matching stable branch when ready. `main` is only +the default/static GitHub branch. ## Releases @@ -106,4 +98,4 @@ Connect with the GenLayer community to discuss, collaborate, and share insights: - **[Discord Channel](https://discord.gg/8Jm4v89VAu)**: Our primary hub for discussions, support, and announcements. - **[Telegram Group](https://t.me/genlayer)**: For more informal chats and quick updates. -Your continuous feedback drives better product development. Please engage with us regularly to test, discuss, and improve the GenLayerJS SDK. \ No newline at end of file +Your continuous feedback drives better product development. Please engage with us regularly to test, discuss, and improve the GenLayerJS SDK. diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md new file mode 100644 index 0000000..c03c3ae --- /dev/null +++ b/docs/BRANCHING.md @@ -0,0 +1,58 @@ +# Branching and Release Trains + +This repo follows the GenLayer release-train model. + +## Current Train + +- Current stable branch: `v1` +- Active integration branch: `v2-dev` +- Next stable target: `v2` +- `main`: default/static branch alias for the active integration branch + +## Stable Branches + +Stable branches are long-lived release lines. They are named after the version +line they ship, for example `v1` or `v2`. + +PRs may target a stable branch directly when the merged result should be +releasable immediately. This is appropriate for bug fixes, small non-breaking +features, isolated release fixes, or a breaking change that is intentionally +shipping as the next version by itself. + +Stable branches must remain releasable. PRs into stable branches are expected to +pass the required cross-repo `E2E Tests` gate before merge. + +## Integration Branches + +Integration branches are optional. Use one when multiple changes need to +accumulate before release, especially for cross-repo work, dependent features, +breaking changes that must ship together, or a train that needs advisory E2E +while still expected to be red. + +Integration branches are named after the target stable branch plus `-dev`, for +example `v2-dev`. Feature PRs for that train target the integration branch. + +PRs into integration branches may run `E2E Tests` as advisory checks. They are +not the release gate. + +## Promotion and Release + +When an integration train is ready, open a promotion PR from the integration +branch to the matching stable branch, for example `v2-dev` to `v2`. + +That promotion PR is the release-readiness gate and must pass required +cross-repo `E2E Tests`. The actual package release is cut from the stable branch +using a version tag after the stable branch is ready. + +## `main` + +`main` exists for GitHub UX and tools that require a stable default branch. It is +not a release branch and it is not the integration target. + +This repo keeps `main` forwarded to the active integration branch using +automation. PRs opened against `main` are automatically retargeted to the branch +listed in `support/ci/ACTIVE_DEV_BRANCH`. + +When changing the active integration branch, update +`support/ci/ACTIVE_DEV_BRANCH`, the repo docs, and the corresponding +`genlayer-e2e` release-train matrix in the same change set. From 7679ea064f6a3e8348b485d5b76013791c6fbd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 11 Jun 2026 22:44:59 +0100 Subject: [PATCH 06/25] fix(fees)!: move wildcard callKey sentinel to keccak256(empty), deploy key = bytes32(0) (#190) The wildcard sentinel previously shared bytes32(0) with GenVM's natural empty-method-name key (deploy, emit_transfer), making deploy-specific Mode-2 allocations impossible. Per cross-team decision, the wildcard moves to the untagged keccak256 of empty bytes (0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470), which is provably outside the derived-key space. DEPLOY_CALL_KEY drops the bytes32(1) sentinel (GenVM ask withdrawn) and now equals CALL_KEY_UNNAMED = bytes32(0), matching what GenVM actually emits. Must land together with the consensus CALL_KEY_WILDCARD constant and the node Mode-2 tree decode change in v0.6. --- src/transactions/fees.ts | 10 +++++++--- tests/contracts-actions.test.ts | 13 ++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/transactions/fees.ts b/src/transactions/fees.ts index 69b7bb9..9aa35b9 100644 --- a/src/transactions/fees.ts +++ b/src/transactions/fees.ts @@ -13,9 +13,13 @@ import { } from "@/types"; export const MESSAGE_ALLOCATION_ROOT_PARENT_INDEX = (1n << 256n) - 1n; -export const CALL_KEY_WILDCARD = "0x0000000000000000000000000000000000000000000000000000000000000000" as const; -export const CALL_KEY_UNNAMED = CALL_KEY_WILDCARD; -export const DEPLOY_CALL_KEY = "0x0000000000000000000000000000000000000000000000000000000000000001" as const; +// Wildcard sentinel = keccak256 of empty bytes, untagged. Reserved: it can never be a +// derived key — short names (<32B) are left-aligned with a zero tail byte, long names +// get the low bit forced to 1, and this hash has neither. +export const CALL_KEY_WILDCARD = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" as const; +// Empty method name derives bytes32(0); GenVM emits it for deploy and emit_transfer. +export const CALL_KEY_UNNAMED = "0x0000000000000000000000000000000000000000000000000000000000000000" as const; +export const DEPLOY_CALL_KEY = CALL_KEY_UNNAMED; export const CALL_KEY_DEPLOY = DEPLOY_CALL_KEY; export const deployCallKey = (): Hex => DEPLOY_CALL_KEY; diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 98a5ab6..8dc7919 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -3,6 +3,7 @@ import {decodeAbiParameters, decodeFunctionData, encodeFunctionData, keccak256, import {contractActions} from "../src/contracts/actions"; import { CALL_KEY_DEPLOY, + CALL_KEY_UNNAMED, CALL_KEY_WILDCARD, DEPLOY_CALL_KEY, deployCallKey, @@ -434,14 +435,20 @@ describe("contractActions addTransaction ABI compatibility", () => { `${hashed.slice(0, -2)}${lastByte.toString(16).padStart(2, "0")}`, ); - expect(deriveInternalMessageCallKey()).toBe(CALL_KEY_WILDCARD); - expect(DEPLOY_CALL_KEY).toBe("0x0000000000000000000000000000000000000000000000000000000000000001"); + // Wildcard is the untagged hash of empty bytes — outside the derived-key space. + expect(CALL_KEY_WILDCARD).toBe(keccak256(new Uint8Array(0))); + expect(CALL_KEY_WILDCARD).toBe("0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"); + + // Empty name derives bytes32(0): the natural key for deploy and emit_transfer. + expect(deriveInternalMessageCallKey()).toBe(CALL_KEY_UNNAMED); + expect(CALL_KEY_UNNAMED).toBe(`0x${"00".repeat(32)}`); + expect(DEPLOY_CALL_KEY).toBe(CALL_KEY_UNNAMED); expect(CALL_KEY_DEPLOY).toBe(DEPLOY_CALL_KEY); expect(deployCallKey()).toBe(DEPLOY_CALL_KEY); expect(deriveExternalMessageCallKey("0xaabbccdd11223344")).toBe( `0xaabbccdd${"0".repeat(56)}`, ); - expect(deriveExternalMessageCallKey("0x123456")).toBe(CALL_KEY_WILDCARD); + expect(deriveExternalMessageCallKey("0x123456")).toBe(CALL_KEY_UNNAMED); }); it("encodes addTransaction with 5 args when ABI has 5 inputs", async () => { From 60e8ca3c253baa66f62e570ee4b4fdbd50cfa447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 7 Jul 2026 10:55:07 +0100 Subject: [PATCH 07/25] feat(sdk): developer-NFT read/claim actions + nonce coercion fix (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sdk): developer-NFT read/claim actions Expose the on-chain developer-NFT reward flow: getDeveloperNft, getClaimableRewardsFromFees, getClaimableRewardsFromInflation, claimNft rewards/epochs. NFTMinter ABI + AddressManager-based address resolution. Mirrors existing action patterns; unit tests added. (Codex-generated; build + 90 tests green. Base is fix/callkey-wildcard-sentinel — rebase onto v2-dev before PR.) * fix(nonce): coerce eth_getTransactionCount hex string to number getCurrentNonce returned the RPC's hex quantity string while the declared return type (and callers) expect a number. On the local-account signing path the raw string flows into viem's transaction serializer, which encodes the ASCII characters as the nonce bytes — producing an unmineable transaction and an infinite receipt wait. Affects any local-account consensus call (appeals, top-ups) on network backends. --- src/abi/index.ts | 1 + src/abi/nftMinter.ts | 92 +++++++++++ src/accounts/actions.ts | 8 +- src/contracts/actions.ts | 274 ++++++++++++++++++++++++++++++++ src/types/clients.ts | 17 +- src/types/contracts.ts | 10 ++ tests/contracts-actions.test.ts | 195 +++++++++++++++++++++++ 7 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 src/abi/nftMinter.ts diff --git a/src/abi/index.ts b/src/abi/index.ts index 890c1ec..a683689 100644 --- a/src/abi/index.ts +++ b/src/abi/index.ts @@ -4,3 +4,4 @@ import * as tx from "./transactions" export const calldata = cd; export const transactions = tx; export {STAKING_ABI, VALIDATOR_WALLET_ABI} from "./staking"; +export {ADDRESS_MANAGER_ABI, NFT_MINTER_ABI} from "./nftMinter"; diff --git a/src/abi/nftMinter.ts b/src/abi/nftMinter.ts new file mode 100644 index 0000000..91ec062 --- /dev/null +++ b/src/abi/nftMinter.ts @@ -0,0 +1,92 @@ +export const ADDRESS_MANAGER_ABI = [ + { + inputs: [{internalType: "string", name: "key", type: "string"}], + name: "getAddressNonZero", + outputs: [{internalType: "address", name: "addr", type: "address"}], + stateMutability: "view", + type: "function", + }, +] as const; + +export const NFT_MINTER_ABI = [ + { + inputs: [{internalType: "uint256", name: "nftId", type: "uint256"}], + name: "claim", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + {internalType: "uint256", name: "nftId", type: "uint256"}, + {internalType: "uint256", name: "numberOfEpochsToClaim", type: "uint256"}, + ], + name: "claimEpochs", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{internalType: "address", name: "developer", type: "address"}], + name: "developerToNFT", + outputs: [{internalType: "uint256", name: "nftId", type: "uint256"}], + stateMutability: "view", + type: "function", + }, + { + inputs: [{internalType: "uint256", name: "nftId", type: "uint256"}], + name: "getClaimableRewardsFromFees", + outputs: [{internalType: "uint256", name: "", type: "uint256"}], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + {internalType: "uint256", name: "nftId", type: "uint256"}, + {internalType: "uint256", name: "numberOfEpochsToClaim", type: "uint256"}, + ], + name: "getClaimableRewardsFromInflation", + outputs: [{internalType: "uint256", name: "amount", type: "uint256"}], + stateMutability: "view", + type: "function", + }, + { + inputs: [{internalType: "uint256", name: "nftId", type: "uint256"}], + name: "getGhostsForNFT", + outputs: [{internalType: "address[]", name: "", type: "address[]"}], + stateMutability: "view", + type: "function", + }, + { + inputs: [{internalType: "uint256", name: "nftId", type: "uint256"}], + name: "getLastClaimedEpoch", + outputs: [{internalType: "uint256", name: "", type: "uint256"}], + stateMutability: "view", + type: "function", + }, + { + inputs: [{internalType: "uint256", name: "nftId", type: "uint256"}], + name: "getNumberOfEpochsToClaim", + outputs: [{internalType: "uint256", name: "", type: "uint256"}], + stateMutability: "view", + type: "function", + }, + { + inputs: [{internalType: "address", name: "developer", type: "address"}], + name: "hasNFT", + outputs: [{internalType: "bool", name: "", type: "bool"}], + stateMutability: "view", + type: "function", + }, + { + inputs: [{internalType: "uint256", name: "nftId", type: "uint256"}], + name: "nfts", + outputs: [ + {internalType: "address", name: "developer", type: "address"}, + {internalType: "uint256", name: "claimableRewards", type: "uint256"}, + {internalType: "uint256", name: "lastClaimedEpoch", type: "uint256"}, + ], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/src/accounts/actions.ts b/src/accounts/actions.ts index 25ba5fa..28fb04b 100644 --- a/src/accounts/actions.ts +++ b/src/accounts/actions.ts @@ -36,10 +36,14 @@ export function accountActions(client: GenLayerClient) { if (!addressToUse) { throw new Error("No address provided and no account is connected"); } - return client.request({ + const count = await client.request({ method: "eth_getTransactionCount", params: [addressToUse, block], - }) as Promise; + }); + // The RPC returns a hex quantity string; callers (and the declared return + // type) expect a number. Passing the raw string into viem's transaction + // serializer encodes the ASCII characters as the nonce bytes. + return Number(count); }, }; } diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index be1afb3..93a8f35 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -1,9 +1,11 @@ import * as calldata from "@/abi/calldata"; import {serialize} from "@/abi/transactions"; +import {ADDRESS_MANAGER_ABI, NFT_MINTER_ABI} from "@/abi/nftMinter"; import { Account, ContractSchema, + DeveloperNft, GenLayerChain, GenLayerClient, CalldataEncodable, @@ -672,6 +674,124 @@ export const contractActions = (client: GenLayerClient, publicCli args: [args.txId], }) as Promise; }, + /** Returns a developer's NFT reward record, or null when no NFT is registered. */ + getDeveloperNft: async (args: {developer: Address}): Promise => { + const nftMinterAddress = await _resolveNftMinterAddress({client, publicClient}); + const nftId = await publicClient.readContract({ + address: nftMinterAddress, + abi: NFT_MINTER_ABI, + functionName: "developerToNFT", + args: [args.developer], + }) as bigint; + + if (nftId === 0n) { + return null; + } + + const [nftData, ghosts] = await Promise.all([ + publicClient.readContract({ + address: nftMinterAddress, + abi: NFT_MINTER_ABI, + functionName: "nfts", + args: [nftId], + }), + publicClient.readContract({ + address: nftMinterAddress, + abi: NFT_MINTER_ABI, + functionName: "getGhostsForNFT", + args: [nftId], + }), + ]) as [ + readonly [Address, bigint, bigint] & { + developer?: Address; + claimableRewards?: bigint; + lastClaimedEpoch?: bigint; + }, + Address[], + ]; + + return { + nftId, + developer: nftData.developer ?? nftData[0], + claimableRewards: nftData.claimableRewards ?? nftData[1], + lastClaimedEpoch: nftData.lastClaimedEpoch ?? nftData[2], + ghosts, + }; + }, + /** Returns claimable developer-NFT rewards accrued from transaction fees. */ + getClaimableRewardsFromFees: async (args: {nftId: BigNumberish}): Promise => { + const nftMinterAddress = await _resolveNftMinterAddress({client, publicClient}); + const nftId = toUInt(args.nftId, "nftId", 0n); + return publicClient.readContract({ + address: nftMinterAddress, + abi: NFT_MINTER_ABI, + functionName: "getClaimableRewardsFromFees", + args: [nftId], + }) as Promise; + }, + /** Returns claimable developer-NFT rewards accrued from inflation. */ + getClaimableRewardsFromInflation: async (args: { + nftId: BigNumberish; + numberOfEpochsToClaim: BigNumberish; + }): Promise => { + const nftMinterAddress = await _resolveNftMinterAddress({client, publicClient}); + const nftId = toUInt(args.nftId, "nftId", 0n); + const numberOfEpochsToClaim = toUInt( + args.numberOfEpochsToClaim, + "numberOfEpochsToClaim", + 0n, + ); + return publicClient.readContract({ + address: nftMinterAddress, + abi: NFT_MINTER_ABI, + functionName: "getClaimableRewardsFromInflation", + args: [nftId, numberOfEpochsToClaim], + }) as Promise; + }, + /** Claims all currently available rewards for a developer NFT. Returns the EVM transaction hash. */ + claimNftRewards: async (args: { + account?: Account; + nftId: BigNumberish; + }): Promise<`0x${string}`> => { + const nftMinterAddress = await _resolveNftMinterAddress({client, publicClient}); + const encodedData = encodeFunctionData({ + abi: NFT_MINTER_ABI, + functionName: "claim", + args: [toUInt(args.nftId, "nftId", 0n)], + }); + return _sendEvmContractCall({ + client, + publicClient, + to: nftMinterAddress, + encodedData, + senderAccount: args.account || client.account, + operationName: "Claim NFT rewards", + }); + }, + /** Claims a bounded number of reward epochs for a developer NFT. Returns the EVM transaction hash. */ + claimNftEpochs: async (args: { + account?: Account; + nftId: BigNumberish; + numberOfEpochsToClaim: BigNumberish; + }): Promise<`0x${string}`> => { + const nftMinterAddress = await _resolveNftMinterAddress({client, publicClient}); + const encodedData = encodeFunctionData({ + abi: NFT_MINTER_ABI, + functionName: "claimEpochs", + args: [ + toUInt(args.nftId, "nftId", 0n), + toUInt(args.numberOfEpochsToClaim, "numberOfEpochsToClaim", 0n), + ], + }); + return _sendEvmContractCall({ + client, + publicClient, + to: nftMinterAddress, + encodedData, + senderAccount: args.account || client.account, + operationName: "Claim NFT epochs", + }); + }, /** Appeals a consensus transaction to trigger a new round of validation. */ appealTransaction: async (args: { account?: Account; @@ -1004,6 +1124,77 @@ const toUInt = (value: BigNumberish | undefined, fieldName: string, fallback: bi return normalized; }; +const hasAbiFunction = (abi: readonly unknown[] | undefined, functionName: string): boolean => { + if (!Array.isArray(abi)) { + return false; + } + return abi.some(item => { + if (!item || typeof item !== "object") { + return false; + } + const candidate = item as {type?: string; name?: string}; + return candidate.type === "function" && candidate.name === functionName; + }); +}; + +const _resolveAddressManagerAddress = async ({ + client, + publicClient, +}: { + client: GenLayerClient; + publicClient: PublicClient; +}): Promise<`0x${string}`> => { + const consensusMainContract = client.chain.consensusMainContract; + if (!consensusMainContract?.address) { + throw new Error("NFTMinter address resolution not supported on this chain (missing consensusMainContract)."); + } + + const functionName = hasAbiFunction(consensusMainContract.abi, "getAddressManager") + ? "getAddressManager" + : hasAbiFunction(consensusMainContract.abi, "addressManager") + ? "addressManager" + : undefined; + + if (!functionName) { + throw new Error("NFTMinter address resolution not supported on this chain (missing AddressManager getter)."); + } + + const addressManagerAddress = await publicClient.readContract({ + address: consensusMainContract.address as `0x${string}`, + abi: consensusMainContract.abi as Abi, + functionName, + args: [], + }) as Address; + + if (addressManagerAddress.toLowerCase() === zeroAddress) { + throw new Error("NFTMinter address resolution failed: AddressManager is zero."); + } + + return addressManagerAddress as `0x${string}`; +}; + +const _resolveNftMinterAddress = async ({ + client, + publicClient, +}: { + client: GenLayerClient; + publicClient: PublicClient; +}): Promise<`0x${string}`> => { + const addressManagerAddress = await _resolveAddressManagerAddress({client, publicClient}); + const nftMinterAddress = await publicClient.readContract({ + address: addressManagerAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddressNonZero", + args: ["NFTMinter"], + }) as Address; + + if (nftMinterAddress.toLowerCase() === zeroAddress) { + throw new Error("NFTMinter address resolution failed: AddressManager returned zero."); + } + + return nftMinterAddress as `0x${string}`; +}; + const getDefaultValidUntil = () => BigInt(Math.floor(Date.now() / 1000) + 3600); const requiresFeeDepositCalculation = (distribution: FeesDistribution): boolean => ( @@ -1822,6 +2013,89 @@ const _encodeTopUpAndSubmitAppealData = ({ }); }; +const _sendEvmContractCall = async ({ + client, + publicClient, + to, + encodedData, + senderAccount, + value = 0n, + operationName = "Contract call", +}: { + client: GenLayerClient; + publicClient: PublicClient; + to: Address; + encodedData: `0x${string}`; + senderAccount?: Account; + value?: bigint; + operationName?: string; +}): Promise<`0x${string}`> => { + const validatedAccount = validateAccount(senderAccount); + const nonce = await client.getCurrentNonce({address: validatedAccount.address}); + + let estimatedGas: bigint; + try { + estimatedGas = await client.estimateTransactionGas({ + from: validatedAccount.address, + to, + data: encodedData, + value, + }); + } catch (err) { + console.error("Gas estimation failed, using default 200_000:", err); + estimatedGas = 200_000n; + } + + const gasPriceHex = (await client.request({method: "eth_gasPrice"})) as string; + + if (validatedAccount.type === "local") { + if (!validatedAccount.signTransaction) { + throw new Error("Local account does not support signTransaction."); + } + const txRequest = { + account: validatedAccount, + to, + data: encodedData, + value, + gas: estimatedGas, + gasPrice: BigInt(gasPriceHex), + nonce, + chainId: client.chain.id, + }; + const serializedTransaction = await validatedAccount.signTransaction(txRequest); + const evmHash = await client.sendRawTransaction({serializedTransaction}); + if (client.chain.isStudio) { + return evmHash; + } + const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); + if (receipt.status === "reverted") { + throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); + } + return evmHash; + } + + const evmHash = (await client.request({ + method: "eth_sendTransaction", + params: [{ + from: validatedAccount.address, + to, + data: encodedData, + value: value ? (`0x${value.toString(16)}` as `0x${string}`) : undefined, + gas: `0x${estimatedGas.toString(16)}` as `0x${string}`, + nonce: `0x${BigInt(nonce).toString(16)}` as `0x${string}`, + gasPrice: gasPriceHex as `0x${string}`, + }], + })) as `0x${string}`; + if (client.chain.isStudio) { + return evmHash; + } + const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); + if (receipt.status === "reverted") { + throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); + } + return evmHash; +}; + /** * Sends a pre-encoded call to the consensus main contract, bypassing the * NewTransaction/CreatedTransaction log extraction used by _sendTransaction. diff --git a/src/types/clients.ts b/src/types/clients.ts index 336a449..3b45b4a 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -19,7 +19,7 @@ import { import {GenLayerChain} from "./chains"; import {Address, Account} from "./accounts"; import {CalldataEncodable} from "./calldata"; -import {ContractSchema} from "./contracts"; +import {ContractSchema, DeveloperNft} from "./contracts"; import {Network} from "./network"; import {SnapSource} from "@/types/snapSource"; import {MetaMaskClientResult} from "@/types/metamaskClientResult"; @@ -143,6 +143,21 @@ export type GenLayerClient = Omit< getRoundData: (args: {txId: `0x${string}`; round: bigint}) => Promise; getLastRoundData: (args: {txId: `0x${string}`}) => Promise; canAppeal: (args: {txId: `0x${string}`}) => Promise; + getDeveloperNft: (args: {developer: Address}) => Promise; + getClaimableRewardsFromFees: (args: {nftId: BigNumberish}) => Promise; + getClaimableRewardsFromInflation: (args: { + nftId: BigNumberish; + numberOfEpochsToClaim: BigNumberish; + }) => Promise; + claimNftRewards: (args: { + account?: Account; + nftId: BigNumberish; + }) => Promise<`0x${string}`>; + claimNftEpochs: (args: { + account?: Account; + nftId: BigNumberish; + numberOfEpochsToClaim: BigNumberish; + }) => Promise<`0x${string}`>; appealTransaction: (args: { account?: Account; txId: `0x${string}`; diff --git a/src/types/contracts.ts b/src/types/contracts.ts index 860ab2b..103dfbe 100644 --- a/src/types/contracts.ts +++ b/src/types/contracts.ts @@ -1,3 +1,5 @@ +import {Address} from "./accounts"; + export type ContractParamsArraySchemaElement = ContractParamsSchema | {$rep: ContractParamsSchema}; export type ContractParamsSchema = @@ -30,3 +32,11 @@ export type ContractSchema = { ctor: ContractMethodBase; methods: Record; }; + +export interface DeveloperNft { + nftId: bigint; + developer: Address; + claimableRewards: bigint; + lastClaimedEpoch: bigint; + ghosts: Address[]; +} diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 8dc7919..67791f2 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1,6 +1,7 @@ import {describe, it, expect, vi} from "vitest"; import {decodeAbiParameters, decodeFunctionData, encodeFunctionData, keccak256, toHex} from "viem"; import {contractActions} from "../src/contracts/actions"; +import {NFT_MINTER_ABI} from "../src/abi/nftMinter"; import { CALL_KEY_DEPLOY, CALL_KEY_UNNAMED, @@ -17,6 +18,8 @@ import { const MAIN_CONTRACT_ADDRESS = "0x0000000000000000000000000000000000000001"; const SENDER_ADDRESS = "0x0000000000000000000000000000000000000002"; const RECIPIENT_ADDRESS = "0x0000000000000000000000000000000000000003"; +const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000004"; +const NFT_MINTER_ADDRESS = "0x0000000000000000000000000000000000000005"; const MOCK_GENLAYER_TX_ID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const MOCK_EVM_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; @@ -34,6 +37,16 @@ const NEW_TRANSACTION_EVENT_TOPIC = keccak256( toHex(new TextEncoder().encode("NewTransaction(bytes32,address,address)")), ); +const CONSENSUS_ADDRESS_MANAGER_ABI = [ + { + type: "function", + name: "getAddressManager", + stateMutability: "view", + inputs: [], + outputs: [{name: "", type: "address"}], + }, +] as const; + const makeMockReceiptWithNewTxEvent = (txId: string = MOCK_GENLAYER_TX_ID) => ({ status: "success" as const, logs: [ @@ -265,6 +278,188 @@ const setupWriteContractHarness = ({ return {actions, estimateTransactionGas, client, signTransaction, publicClient}; }; +const setupDeveloperNftHarness = ({ + readContractMock, + signTransactionMock, +}: { + readContractMock?: ReturnType; + signTransactionMock?: ReturnType; +} = {}) => { + const estimateTransactionGas = vi.fn().mockResolvedValue(21_000n); + const signTransaction = signTransactionMock ?? vi.fn().mockRejectedValue(new Error("stop_after_encoding")); + const readContract = readContractMock ?? vi.fn().mockImplementation(async ({ + functionName, + args, + }: { + functionName: string; + args?: readonly unknown[]; + }) => { + if (functionName === "getAddressManager") return ADDRESS_MANAGER_ADDRESS; + if (functionName === "getAddressNonZero") { + expect(args).toEqual(["NFTMinter"]); + return NFT_MINTER_ADDRESS; + } + if (functionName === "developerToNFT") return 7n; + if (functionName === "nfts") return [SENDER_ADDRESS, 123n, 5n]; + if (functionName === "getGhostsForNFT") return [RECIPIENT_ADDRESS]; + if (functionName === "getClaimableRewardsFromFees") return 123n; + if (functionName === "getClaimableRewardsFromInflation") return 456n; + throw new Error(`Unexpected readContract ${functionName}`); + }); + + const publicClient = { + readContract, + waitForTransactionReceipt: vi.fn().mockResolvedValue({status: "success"}), + }; + const client = { + chain: { + id: 61_127, + isStudio: false, + consensusMainContract: { + address: MAIN_CONTRACT_ADDRESS, + abi: CONSENSUS_ADDRESS_MANAGER_ABI, + bytecode: "0x", + }, + }, + account: { + address: SENDER_ADDRESS, + type: "local", + signTransaction, + }, + getCurrentNonce: vi.fn().mockResolvedValue(0n), + estimateTransactionGas, + request: vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "eth_gasPrice") { + return "0x1"; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + sendRawTransaction: vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH), + }; + + const actions = contractActions(client as any, publicClient as any); + + return {actions, client, estimateTransactionGas, publicClient, readContract, signTransaction}; +}; + +describe("contractActions developer NFT actions", () => { + it("returns developer NFT data from the AddressManager-resolved NFTMinter", async () => { + const {actions, readContract} = setupDeveloperNftHarness(); + + const nft = await actions.getDeveloperNft({developer: SENDER_ADDRESS}); + + expect(nft).toEqual({ + nftId: 7n, + developer: SENDER_ADDRESS, + claimableRewards: 123n, + lastClaimedEpoch: 5n, + ghosts: [RECIPIENT_ADDRESS], + }); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: MAIN_CONTRACT_ADDRESS, + functionName: "getAddressManager", + args: [], + })); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: ADDRESS_MANAGER_ADDRESS, + functionName: "getAddressNonZero", + args: ["NFTMinter"], + })); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: NFT_MINTER_ADDRESS, + functionName: "developerToNFT", + args: [SENDER_ADDRESS], + })); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: NFT_MINTER_ADDRESS, + functionName: "nfts", + args: [7n], + })); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: NFT_MINTER_ADDRESS, + functionName: "getGhostsForNFT", + args: [7n], + })); + }); + + it("returns null when a developer has no NFT", async () => { + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "getAddressManager") return ADDRESS_MANAGER_ADDRESS; + if (functionName === "getAddressNonZero") return NFT_MINTER_ADDRESS; + if (functionName === "developerToNFT") return 0n; + throw new Error(`Unexpected readContract ${functionName}`); + }); + const {actions} = setupDeveloperNftHarness({readContractMock: readContract}); + + await expect(actions.getDeveloperNft({developer: SENDER_ADDRESS})).resolves.toBeNull(); + expect(readContract).not.toHaveBeenCalledWith(expect.objectContaining({ + functionName: "nfts", + })); + }); + + it("reads claimable rewards from fees and inflation on NFTMinter", async () => { + const {actions, readContract} = setupDeveloperNftHarness(); + + await expect(actions.getClaimableRewardsFromFees({nftId: 7n})).resolves.toBe(123n); + await expect( + actions.getClaimableRewardsFromInflation({ + nftId: 7n, + numberOfEpochsToClaim: 3n, + }), + ).resolves.toBe(456n); + + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: NFT_MINTER_ADDRESS, + functionName: "getClaimableRewardsFromFees", + args: [7n], + })); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: NFT_MINTER_ADDRESS, + functionName: "getClaimableRewardsFromInflation", + args: [7n, 3n], + })); + }); + + it("encodes claimNftRewards to the AddressManager-resolved NFTMinter", async () => { + const {actions, estimateTransactionGas} = setupDeveloperNftHarness(); + + await expect(actions.claimNftRewards({nftId: 7n})).rejects.toThrow("stop_after_encoding"); + + const expectedData = encodeFunctionData({ + abi: NFT_MINTER_ABI, + functionName: "claim", + args: [7n], + }); + expect(estimateTransactionGas).toHaveBeenCalledWith(expect.objectContaining({ + to: NFT_MINTER_ADDRESS, + data: expectedData, + value: 0n, + })); + }); + + it("encodes claimNftEpochs to the AddressManager-resolved NFTMinter", async () => { + const {actions, estimateTransactionGas} = setupDeveloperNftHarness(); + + await expect( + actions.claimNftEpochs({ + nftId: 7n, + numberOfEpochsToClaim: 3n, + }), + ).rejects.toThrow("stop_after_encoding"); + + const expectedData = encodeFunctionData({ + abi: NFT_MINTER_ABI, + functionName: "claimEpochs", + args: [7n, 3n], + }); + expect(estimateTransactionGas).toHaveBeenCalledWith(expect.objectContaining({ + to: NFT_MINTER_ADDRESS, + data: expectedData, + value: 0n, + })); + }); +}); + describe("contractActions addTransaction ABI compatibility", () => { it("passes trusted fees and user value through Studio simulateWriteContract sim_call", async () => { const request = vi.fn().mockResolvedValue({ From 6f691ce704092992216dce83e54447dbdeb8c3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 7 Jul 2026 21:21:22 +0100 Subject: [PATCH 08/25] feat: vesting delegator actions + ABI (#193) * feat: add vesting delegator actions * feat(vesting): validator-leg actions (join/deposit/exit/claim, operator transfer, identity, wallet reads) Implements the CON-607 Vesting.sol validator surface (consensus branch con-607-vesting-lock-then-validate): vestingValidatorJoin/Deposit/Exit/ Claim, operator-transfer initiate/complete/cancel, setIdentity, and getValidatorWallets/validatorWalletCount/validatorDeposited/ isValidatorWallet reads. Replaces the #1154 stub in vesting/validator.ts. --- src/abi/index.ts | 3 +- src/abi/vesting.ts | 237 +++++++++++++ src/client/client.ts | 2 + src/index.ts | 1 + src/types/clients.ts | 3 +- src/types/index.ts | 1 + src/types/vesting.ts | 231 +++++++++++++ src/vesting/actions.ts | 612 ++++++++++++++++++++++++++++++++++ src/vesting/index.ts | 2 + src/vesting/validator.ts | 10 + tests/vesting-actions.test.ts | 345 +++++++++++++++++++ 11 files changed, 1445 insertions(+), 2 deletions(-) create mode 100644 src/abi/vesting.ts create mode 100644 src/types/vesting.ts create mode 100644 src/vesting/actions.ts create mode 100644 src/vesting/index.ts create mode 100644 src/vesting/validator.ts create mode 100644 tests/vesting-actions.test.ts diff --git a/src/abi/index.ts b/src/abi/index.ts index a683689..dc26821 100644 --- a/src/abi/index.ts +++ b/src/abi/index.ts @@ -4,4 +4,5 @@ import * as tx from "./transactions" export const calldata = cd; export const transactions = tx; export {STAKING_ABI, VALIDATOR_WALLET_ABI} from "./staking"; -export {ADDRESS_MANAGER_ABI, NFT_MINTER_ABI} from "./nftMinter"; +export {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI, VESTING_ABI, VESTING_FACTORY_ABI} from "./vesting"; +export {NFT_MINTER_ABI} from "./nftMinter"; diff --git a/src/abi/vesting.ts b/src/abi/vesting.ts new file mode 100644 index 0000000..0dc7adc --- /dev/null +++ b/src/abi/vesting.ts @@ -0,0 +1,237 @@ +export const CONSENSUS_ADDRESS_MANAGER_ABI = [ + { + name: "getAddressManager", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{name: "", type: "address"}], + }, +] as const; + +export const ADDRESS_MANAGER_ABI = [ + {name: "ZeroAddress", type: "error", inputs: [{name: "key", type: "string"}]}, + {name: "ArrayLengthMismatch", type: "error", inputs: []}, + { + name: "AddressUpdated", + type: "event", + inputs: [ + {name: "key", type: "string", indexed: true}, + {name: "oldAddr", type: "address", indexed: true}, + {name: "newAddr", type: "address", indexed: true}, + ], + }, + {name: "getAddress", type: "function", stateMutability: "view", inputs: [{name: "key", type: "string"}], outputs: [{name: "", type: "address"}]}, + {name: "getAddressNonZero", type: "function", stateMutability: "view", inputs: [{name: "key", type: "string"}], outputs: [{name: "", type: "address"}]}, + {name: "addressBook", type: "function", stateMutability: "view", inputs: [{name: "key", type: "string"}], outputs: [{name: "", type: "address"}]}, + { + name: "getAllContractAddresses", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{name: "", type: "tuple[]", components: [{name: "key", type: "string"}, {name: "addr", type: "address"}]}], + }, + {name: "getContractKeyCount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, +] as const; + +export const VESTING_ABI = [ + {name: "NotBeneficiary", type: "error", inputs: []}, + {name: "NotRevoker", type: "error", inputs: []}, + {name: "NotCreator", type: "error", inputs: []}, + {name: "NotRevocable", type: "error", inputs: []}, + {name: "AlreadyRevoked", type: "error", inputs: []}, + {name: "NotRevoked", type: "error", inputs: []}, + {name: "AlreadyUnlocked", type: "error", inputs: []}, + {name: "ManualUnlockNotRequired", type: "error", inputs: []}, + {name: "WithdrawExceedsVested", type: "error", inputs: []}, + {name: "InsufficientContractBalance", type: "error", inputs: []}, + {name: "ZeroAmount", type: "error", inputs: []}, + {name: "TransferFailed", type: "error", inputs: []}, + {name: "VestingAlreadyStopped", type: "error", inputs: []}, + {name: "VestingNotStopped", type: "error", inputs: []}, + {name: "InvalidStopTimestamp", type: "error", inputs: []}, + {name: "NoValidatorWallet", type: "error", inputs: []}, + {name: "InvalidAddress", type: "error", inputs: []}, + {name: "VestingAlreadyExists", type: "error", inputs: []}, + {name: "VestingDeploymentFailed", type: "error", inputs: []}, + {name: "BeaconNotDeployed", type: "error", inputs: []}, + {name: "BeaconAlreadyDeployed", type: "error", inputs: []}, + {name: "FundingMismatch", type: "error", inputs: []}, + {name: "InvalidCliffUnlockBps", type: "error", inputs: []}, + {name: "InvalidPeriodDuration", type: "error", inputs: []}, + { + name: "VestingInitialized", + type: "event", + inputs: [ + {name: "name", type: "string", indexed: false}, + {name: "beneficiary", type: "address", indexed: true}, + {name: "totalAmount", type: "uint256", indexed: false}, + {name: "startDate", type: "uint256", indexed: false}, + {name: "category", type: "uint8", indexed: false}, + ], + }, + {name: "TokensWithdrawn", type: "event", inputs: [{name: "beneficiary", type: "address", indexed: true}, {name: "amount", type: "uint256", indexed: false}]}, + {name: "DelegatorJoined", type: "event", inputs: [{name: "validator", type: "address", indexed: true}, {name: "amount", type: "uint256", indexed: false}]}, + {name: "DelegatorExited", type: "event", inputs: [{name: "validator", type: "address", indexed: true}, {name: "shares", type: "uint256", indexed: false}]}, + {name: "DelegatorClaimed", type: "event", inputs: [{name: "validator", type: "address", indexed: true}, {name: "returned", type: "uint256", indexed: false}, {name: "rewardOrLoss", type: "int256", indexed: false}]}, + {name: "ValidatorJoined", type: "event", inputs: [{name: "wallet", type: "address", indexed: true}, {name: "operator", type: "address", indexed: true}, {name: "amount", type: "uint256", indexed: false}]}, + {name: "ValidatorDeposited", type: "event", inputs: [{name: "wallet", type: "address", indexed: true}, {name: "amount", type: "uint256", indexed: false}]}, + {name: "ValidatorExited", type: "event", inputs: [{name: "wallet", type: "address", indexed: true}, {name: "shares", type: "uint256", indexed: false}]}, + {name: "ValidatorClaimed", type: "event", inputs: [{name: "wallet", type: "address", indexed: true}, {name: "returned", type: "uint256", indexed: false}, {name: "rewardOrLoss", type: "int256", indexed: false}]}, + {name: "Revoked", type: "event", inputs: [{name: "vestedAtRevocation", type: "uint256", indexed: false}, {name: "totalAmountAtRevocation", type: "uint256", indexed: false}]}, + {name: "VestingStopped", type: "event", inputs: [{name: "stopTimestamp", type: "uint256", indexed: false}]}, + {name: "VestingResumed", type: "event", inputs: []}, + {name: "ManuallyUnlocked", type: "event", inputs: []}, + {name: "TopUp", type: "event", inputs: [{name: "amount", type: "uint256", indexed: false}, {name: "reason", type: "string", indexed: false}]}, + {name: "FoundationSwept", type: "event", inputs: [{name: "amount", type: "uint256", indexed: false}]}, + {name: "NameSet", type: "event", inputs: [{name: "oldName", type: "string", indexed: false}, {name: "newName", type: "string", indexed: false}]}, + {name: "CategorySet", type: "event", inputs: [{name: "oldCategory", type: "uint8", indexed: false}, {name: "newCategory", type: "uint8", indexed: false}]}, + {name: "StartDateSet", type: "event", inputs: [{name: "oldStartDate", type: "uint256", indexed: false}, {name: "newStartDate", type: "uint256", indexed: false}]}, + {name: "CliffDurationSet", type: "event", inputs: [{name: "oldCliffDuration", type: "uint256", indexed: false}, {name: "newCliffDuration", type: "uint256", indexed: false}]}, + {name: "PeriodDurationSet", type: "event", inputs: [{name: "oldPeriodDuration", type: "uint256", indexed: false}, {name: "newPeriodDuration", type: "uint256", indexed: false}]}, + {name: "NumberOfPeriodsSet", type: "event", inputs: [{name: "oldNumberOfPeriods", type: "uint256", indexed: false}, {name: "newNumberOfPeriods", type: "uint256", indexed: false}]}, + {name: "CliffUnlockBpsSet", type: "event", inputs: [{name: "oldCliffUnlockBps", type: "uint256", indexed: false}, {name: "newCliffUnlockBps", type: "uint256", indexed: false}]}, + {name: "BeneficiarySet", type: "event", inputs: [{name: "oldBeneficiary", type: "address", indexed: true}, {name: "newBeneficiary", type: "address", indexed: true}]}, + {name: "CreatorSet", type: "event", inputs: [{name: "oldCreator", type: "address", indexed: true}, {name: "newCreator", type: "address", indexed: true}]}, + + {name: "name", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "string"}]}, + {name: "category", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint8"}]}, + {name: "beneficiary", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "creator", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "revoker", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "factory", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "addressManager", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "totalAmount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "startDate", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "cliffDuration", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "periodDuration", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "numberOfPeriods", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "cliffUnlockBps", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "needsManualUnlock", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "bool"}]}, + {name: "manualUnlocked", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "bool"}]}, + {name: "revoked", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "bool"}]}, + {name: "vestingStopped", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "bool"}]}, + {name: "totalWithdrawn", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "vestedAtRevocation", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "totalAmountAtRevocation", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "revokedAt", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "vestingStoppedAt", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "vestedAtStop", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "postRevocationBeneficiaryRewards", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "postRevocationBeneficiaryLosses", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "depositedPerValidator", type: "function", stateMutability: "view", inputs: [{name: "validator", type: "address"}], outputs: [{name: "", type: "uint256"}]}, + {name: "pendingExitDeposited", type: "function", stateMutability: "view", inputs: [{name: "validator", type: "address"}], outputs: [{name: "", type: "uint256"}]}, + {name: "accumulatedRewards", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "accumulatedLosses", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "validatorWallets", type: "function", stateMutability: "view", inputs: [{name: "index", type: "uint256"}], outputs: [{name: "", type: "address"}]}, + {name: "isValidatorWallet", type: "function", stateMutability: "view", inputs: [{name: "wallet", type: "address"}], outputs: [{name: "", type: "bool"}]}, + {name: "validatorDeposited", type: "function", stateMutability: "view", inputs: [{name: "wallet", type: "address"}], outputs: [{name: "", type: "uint256"}]}, + {name: "getValidatorWallets", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address[]"}]}, + {name: "validatorWalletCount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "vestedAmount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "unvestedAmount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "withdrawableAmount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + + {name: "vestingWithdraw", type: "function", stateMutability: "nonpayable", inputs: [{name: "amount", type: "uint256"}], outputs: []}, + {name: "vestingDelegatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, + {name: "vestingDelegatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, + {name: "vestingDelegatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}], outputs: []}, + {name: "vestingValidatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "operator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, + {name: "vestingValidatorDeposit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, + {name: "vestingValidatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, + {name: "vestingValidatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}], outputs: []}, + {name: "vestingValidatorInitiateOperatorTransfer", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "newOperator", type: "address"}], outputs: []}, + {name: "vestingValidatorCompleteOperatorTransfer", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}], outputs: []}, + {name: "vestingValidatorCancelOperatorTransfer", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}], outputs: []}, + { + name: "vestingValidatorSetIdentity", + type: "function", + stateMutability: "nonpayable", + inputs: [ + {name: "wallet", type: "address"}, + {name: "moniker", type: "string"}, + {name: "logoUri", type: "string"}, + {name: "website", type: "string"}, + {name: "description", type: "string"}, + {name: "email", type: "string"}, + {name: "twitter", type: "string"}, + {name: "telegram", type: "string"}, + {name: "github", type: "string"}, + {name: "extraCid", type: "bytes"}, + ], + outputs: [], + }, + {name: "revoke", type: "function", stateMutability: "nonpayable", inputs: [], outputs: []}, + {name: "stopVesting", type: "function", stateMutability: "nonpayable", inputs: [], outputs: []}, + {name: "resumeVesting", type: "function", stateMutability: "nonpayable", inputs: [], outputs: []}, + {name: "foundationSweep", type: "function", stateMutability: "nonpayable", inputs: [], outputs: []}, + {name: "manualUnlock", type: "function", stateMutability: "nonpayable", inputs: [], outputs: []}, + {name: "topUp", type: "function", stateMutability: "payable", inputs: [{name: "reason", type: "string"}], outputs: []}, + {name: "setName", type: "function", stateMutability: "nonpayable", inputs: [{name: "_name", type: "string"}], outputs: []}, + {name: "setCategory", type: "function", stateMutability: "nonpayable", inputs: [{name: "_category", type: "uint8"}], outputs: []}, + {name: "setStartDate", type: "function", stateMutability: "nonpayable", inputs: [{name: "_startDate", type: "uint256"}], outputs: []}, + {name: "setCliffDuration", type: "function", stateMutability: "nonpayable", inputs: [{name: "_cliffDuration", type: "uint256"}], outputs: []}, + {name: "setPeriodDuration", type: "function", stateMutability: "nonpayable", inputs: [{name: "_periodDuration", type: "uint256"}], outputs: []}, + {name: "setNumberOfPeriods", type: "function", stateMutability: "nonpayable", inputs: [{name: "_numberOfPeriods", type: "uint256"}], outputs: []}, + {name: "setCliffUnlockBps", type: "function", stateMutability: "nonpayable", inputs: [{name: "_cliffUnlockBps", type: "uint256"}], outputs: []}, + {name: "setBeneficiary", type: "function", stateMutability: "nonpayable", inputs: [{name: "_beneficiary", type: "address"}], outputs: []}, + {name: "setCreator", type: "function", stateMutability: "nonpayable", inputs: [{name: "_creator", type: "address"}], outputs: []}, +] as const; + +export const VESTING_FACTORY_ABI = [ + {name: "InvalidAddress", type: "error", inputs: []}, + {name: "VestingAlreadyExists", type: "error", inputs: []}, + {name: "VestingDeploymentFailed", type: "error", inputs: []}, + {name: "BeaconNotDeployed", type: "error", inputs: []}, + {name: "BeaconAlreadyDeployed", type: "error", inputs: []}, + {name: "FundingMismatch", type: "error", inputs: []}, + {name: "InvalidCliffUnlockBps", type: "error", inputs: []}, + {name: "InvalidPeriodDuration", type: "error", inputs: []}, + {name: "ZeroAmount", type: "error", inputs: []}, + {name: "OwnableUnauthorizedAccount", type: "error", inputs: [{name: "account", type: "address"}]}, + {name: "OwnableInvalidOwner", type: "error", inputs: [{name: "owner", type: "address"}]}, + {name: "VestingCreated", type: "event", inputs: [{name: "beneficiary", type: "address", indexed: true}, {name: "vestingContract", type: "address", indexed: true}, {name: "totalAmount", type: "uint256", indexed: false}, {name: "category", type: "uint8", indexed: false}]}, + {name: "BeaconDeployed", type: "event", inputs: [{name: "beacon", type: "address", indexed: true}, {name: "implementation", type: "address", indexed: true}]}, + {name: "BeaconUpgraded", type: "event", inputs: [{name: "newImplementation", type: "address", indexed: true}]}, + {name: "VestingBlueprintSet", type: "event", inputs: [{name: "blueprint", type: "address", indexed: true}]}, + {name: "INITIALIZE_SELECTOR", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "bytes4"}]}, + {name: "addressManager", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "vestingBlueprint", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "vestingBeacon", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address"}]}, + {name: "beneficiaryToVesting", type: "function", stateMutability: "view", inputs: [{name: "", type: "address"}], outputs: [{name: "", type: "address"}]}, + {name: "isVestingContract", type: "function", stateMutability: "view", inputs: [{name: "", type: "address"}], outputs: [{name: "", type: "bool"}]}, + {name: "totalVestingsDeployed", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + { + name: "createVesting", + type: "function", + stateMutability: "payable", + inputs: [ + {name: "_name", type: "string"}, {name: "_beneficiary", type: "address"}, {name: "_revoker", type: "address"}, + {name: "_startDate", type: "uint256"}, {name: "_cliffDuration", type: "uint256"}, {name: "_periodDuration", type: "uint256"}, + {name: "_numberOfPeriods", type: "uint256"}, {name: "_cliffUnlockBps", type: "uint256"}, {name: "_needsManualUnlock", type: "bool"}, + {name: "_totalAmount", type: "uint256"}, {name: "_category", type: "uint8"}, + ], + outputs: [{name: "vestingContract", type: "address"}], + }, + { + name: "createBatchVesting", + type: "function", + stateMutability: "payable", + inputs: [{name: "params", type: "tuple[]", components: [ + {name: "name", type: "string"}, {name: "beneficiary", type: "address"}, {name: "revoker", type: "address"}, + {name: "startDate", type: "uint256"}, {name: "cliffDuration", type: "uint256"}, {name: "periodDuration", type: "uint256"}, + {name: "numberOfPeriods", type: "uint256"}, {name: "cliffUnlockBps", type: "uint256"}, {name: "needsManualUnlock", type: "bool"}, + {name: "totalAmount", type: "uint256"}, {name: "category", type: "uint8"}, + ]}], + outputs: [{name: "vestingContracts", type: "address[]"}], + }, + {name: "deployNewBeacon", type: "function", stateMutability: "nonpayable", inputs: [], outputs: []}, + {name: "upgradeBeacon", type: "function", stateMutability: "nonpayable", inputs: [{name: "newImplementation", type: "address"}], outputs: []}, + {name: "setVestingBlueprint", type: "function", stateMutability: "nonpayable", inputs: [{name: "_blueprint", type: "address"}], outputs: []}, + {name: "setAddressManager", type: "function", stateMutability: "nonpayable", inputs: [{name: "_addressManager", type: "address"}], outputs: []}, + {name: "getVesting", type: "function", stateMutability: "view", inputs: [{name: "_beneficiary", type: "address"}], outputs: [{name: "", type: "address"}]}, + {name: "getMyVestings", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address[]"}]}, + {name: "getMyVestingCount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "getAllVestings", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address[]"}]}, + {name: "getAllVestingsCount", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}]}, + {name: "isVestingAddress", type: "function", stateMutability: "view", inputs: [{name: "_wallet", type: "address"}], outputs: [{name: "", type: "bool"}]}, +] as const; diff --git a/src/client/client.ts b/src/client/client.ts index 757a662..0350073 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -15,6 +15,7 @@ import {contractActions} from "../contracts/actions"; import {receiptActions, transactionActions} from "../transactions/actions"; import {walletActions as genlayerWalletActions} from "../wallet/actions"; import {stakingActions} from "../staking/actions"; +import {vestingActions} from "../vesting/actions"; import {GenLayerClient, GenLayerChain} from "@/types"; import {chainActions} from "@/chains/actions"; import {localnet} from "@/chains"; @@ -169,6 +170,7 @@ export const createClient = (config: ClientConfig = {chain: localnet}): GenLayer const finalClient = { ...clientWithReceiptActions, ...stakingActions(clientWithReceiptActions as unknown as GenLayerClient, publicClient), + ...vestingActions(clientWithReceiptActions as unknown as GenLayerClient, publicClient), } as unknown as GenLayerClient; return finalClient; diff --git a/src/index.ts b/src/index.ts index 52530c7..fe3a8f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,4 +12,5 @@ export * as abi from "./abi"; export * from "./transactions/fees"; export {isSuccessful} from "./transactions/actions"; export {parseStakingAmount, formatStakingAmount} from "./staking"; +export {vestingActions} from "./vesting"; export {buildGenVmPositionalArgs} from "./contracts/schema"; diff --git a/src/types/clients.ts b/src/types/clients.ts index 3b45b4a..e0d3115 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -24,6 +24,7 @@ import {Network} from "./network"; import {SnapSource} from "@/types/snapSource"; import {MetaMaskClientResult} from "@/types/metamaskClientResult"; import {StakingActions} from "./staking"; +import {VestingActions} from "./vesting"; export type GenLayerMethod = | {method: "sim_fundAccount"; params: [address: Address, amount: number]} @@ -189,4 +190,4 @@ export type GenLayerClient = Omit< estimateTransactionFees: (args?: FeeEstimateOptions) => Promise; estimateTransactionFeesFromSimulation: (args: SimulationFeeEstimateOptions) => Promise; estimateTransactionFeesForWrite: (args: WriteFeeEstimateOptions) => Promise; - } & StakingActions; + } & StakingActions & VestingActions; diff --git a/src/types/index.ts b/src/types/index.ts index 0f878a0..ab18707 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,3 +7,4 @@ export * from "./transactions"; export * from "./network"; export * from "./snapSource"; export * from "./staking"; +export * from "./vesting"; diff --git a/src/types/vesting.ts b/src/types/vesting.ts new file mode 100644 index 0000000..1c3d3ae --- /dev/null +++ b/src/types/vesting.ts @@ -0,0 +1,231 @@ +import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, PublicClient, Transport} from "viem"; +import {Address} from "./accounts"; +import {VESTING_ABI, VESTING_FACTORY_ABI} from "@/abi/vesting"; + +type WalletClientWithAccount = Client; + +type VestingKeyedClient = { + public: PublicClient; + wallet: WalletClientWithAccount; +}; + +export type VestingContract = GetContractReturnType; +export type VestingFactoryContract = GetContractReturnType; + +export type VestingCategory = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export interface VestingTransactionResult { + transactionHash: `0x${string}`; + blockNumber: bigint; + gasUsed: bigint; +} + +export interface VestingDelegatorJoinOptions { + vesting: Address; + validator: Address; + amount: bigint | string; +} + +export interface VestingDelegatorJoinResult extends VestingTransactionResult { + vesting: Address; + validator: Address; + beneficiary: Address; + amount: string; + amountRaw: bigint; +} + +export interface VestingDelegatorExitOptions { + vesting: Address; + validator: Address; + shares: bigint | string; +} + +export interface VestingDelegatorClaimOptions { + vesting: Address; + validator: Address; +} + +export interface VestingValidatorJoinOptions { + vesting: Address; + operator: Address; + amount: bigint | string; +} + +export interface VestingValidatorJoinResult extends VestingTransactionResult { + vesting: Address; + operator: Address; + beneficiary: Address; + amount: string; + amountRaw: bigint; +} + +export interface VestingValidatorDepositOptions { + vesting: Address; + wallet: Address; + amount: bigint | string; +} + +export interface VestingValidatorExitOptions { + vesting: Address; + wallet: Address; + shares: bigint | string; +} + +export interface VestingValidatorClaimOptions { + vesting: Address; + wallet: Address; +} + +export interface VestingValidatorInitiateOperatorTransferOptions { + vesting: Address; + wallet: Address; + newOperator: Address; +} + +export interface VestingValidatorWalletOptions { + vesting: Address; + wallet: Address; +} + +export interface VestingValidatorSetIdentityOptions { + vesting: Address; + wallet: Address; + moniker: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; + extraCid?: string; +} + +export interface VestingWithdrawOptions { + vesting: Address; + amount: bigint | string; +} + +export interface VestingWithdrawResult extends VestingTransactionResult { + vesting: Address; + beneficiary: Address; + amount: string; + amountRaw: bigint; +} + +export interface VestingFactoryLookupOptions { + /** Optional explicit VestingFactory address for custom deployments or tests. */ + factory?: Address; + /** Optional explicit AddressManager address. Defaults to consensusMainContract.getAddressManager(). */ + addressManager?: Address; +} + +export interface VestingSchedule { + startDate: bigint; + cliffDuration: bigint; + periodDuration: bigint; + numberOfPeriods: bigint; + cliffUnlockBps: bigint; + needsManualUnlock: boolean; +} + +export interface VestingState extends VestingSchedule { + name: string; + category: VestingCategory; + beneficiary: Address; + creator: Address; + revoker: Address; + factory: Address; + addressManager: Address; + totalAmount: string; + totalAmountRaw: bigint; + manualUnlocked: boolean; + revoked: boolean; + vestingStopped: boolean; + totalWithdrawn: string; + totalWithdrawnRaw: bigint; + vestedAtRevocation: string; + vestedAtRevocationRaw: bigint; + totalAmountAtRevocation: string; + totalAmountAtRevocationRaw: bigint; + revokedAt: bigint; + vestingStoppedAt: bigint; + vestedAtStop: string; + vestedAtStopRaw: bigint; + postRevocationBeneficiaryRewards: string; + postRevocationBeneficiaryRewardsRaw: bigint; + postRevocationBeneficiaryLosses: string; + postRevocationBeneficiaryLossesRaw: bigint; + accumulatedRewards: string; + accumulatedRewardsRaw: bigint; + accumulatedLosses: string; + accumulatedLossesRaw: bigint; + vestedAmount: string; + vestedAmountRaw: bigint; + unvestedAmount: string; + unvestedAmountRaw: bigint; + withdrawableAmount: string; + withdrawableAmountRaw: bigint; +} + +export interface VestingActions { + vestingDelegatorJoin: (options: VestingDelegatorJoinOptions) => Promise; + vestingDelegatorExit: (options: VestingDelegatorExitOptions) => Promise; + vestingDelegatorClaim: (options: VestingDelegatorClaimOptions) => Promise; + vestingValidatorJoin: (options: VestingValidatorJoinOptions) => Promise; + vestingValidatorDeposit: (options: VestingValidatorDepositOptions) => Promise; + vestingValidatorExit: (options: VestingValidatorExitOptions) => Promise; + vestingValidatorClaim: (options: VestingValidatorClaimOptions) => Promise; + vestingValidatorInitiateOperatorTransfer: (options: VestingValidatorInitiateOperatorTransferOptions) => Promise; + vestingValidatorCompleteOperatorTransfer: (options: VestingValidatorWalletOptions) => Promise; + vestingValidatorCancelOperatorTransfer: (options: VestingValidatorWalletOptions) => Promise; + vestingValidatorSetIdentity: (options: VestingValidatorSetIdentityOptions) => Promise; + vestingWithdraw: (options: VestingWithdrawOptions) => Promise; + + getVestingFactoryAddress: (options?: Omit) => Promise
; + getVestingForBeneficiary: (beneficiary: Address, options?: VestingFactoryLookupOptions) => Promise
; + getBeneficiaryVestings: (beneficiary: Address, options?: VestingFactoryLookupOptions) => Promise; + isVestingAddress: (address: Address, options?: VestingFactoryLookupOptions) => Promise; + getVestingContract: (vesting: Address) => VestingContract; + getVestingFactoryContract: (factory: Address) => VestingFactoryContract; + + vestedAmount: (vesting: Address) => Promise; + unvestedAmount: (vesting: Address) => Promise; + withdrawableAmount: (vesting: Address) => Promise; + getVestingSchedule: (vesting: Address) => Promise; + getVestingState: (vesting: Address) => Promise; + + vestingName: (vesting: Address) => Promise; + vestingCategory: (vesting: Address) => Promise; + vestingBeneficiary: (vesting: Address) => Promise
; + vestingCreator: (vesting: Address) => Promise
; + vestingRevoker: (vesting: Address) => Promise
; + vestingFactory: (vesting: Address) => Promise
; + vestingAddressManager: (vesting: Address) => Promise
; + vestingTotalAmount: (vesting: Address) => Promise; + vestingStartDate: (vesting: Address) => Promise; + vestingCliffDuration: (vesting: Address) => Promise; + vestingPeriodDuration: (vesting: Address) => Promise; + vestingNumberOfPeriods: (vesting: Address) => Promise; + vestingCliffUnlockBps: (vesting: Address) => Promise; + vestingNeedsManualUnlock: (vesting: Address) => Promise; + vestingManualUnlocked: (vesting: Address) => Promise; + vestingRevoked: (vesting: Address) => Promise; + vestingStopped: (vesting: Address) => Promise; + vestingTotalWithdrawn: (vesting: Address) => Promise; + vestingVestedAtRevocation: (vesting: Address) => Promise; + vestingTotalAmountAtRevocation: (vesting: Address) => Promise; + vestingRevokedAt: (vesting: Address) => Promise; + vestingStoppedAt: (vesting: Address) => Promise; + vestingVestedAtStop: (vesting: Address) => Promise; + vestingPostRevocationBeneficiaryRewards: (vesting: Address) => Promise; + vestingPostRevocationBeneficiaryLosses: (vesting: Address) => Promise; + vestingDepositedPerValidator: (vesting: Address, validator: Address) => Promise; + vestingPendingExitDeposited: (vesting: Address, validator: Address) => Promise; + getValidatorWallets: (vesting: Address) => Promise; + validatorWalletCount: (vesting: Address) => Promise; + validatorDeposited: (vesting: Address, wallet: Address) => Promise; + isValidatorWallet: (vesting: Address, wallet: Address) => Promise; + vestingAccumulatedRewards: (vesting: Address) => Promise; + vestingAccumulatedLosses: (vesting: Address) => Promise; +} diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts new file mode 100644 index 0000000..42110dc --- /dev/null +++ b/src/vesting/actions.ts @@ -0,0 +1,612 @@ +import { + Account, + Address as ViemAddress, + BaseError, + Chain, + Client, + ContractFunctionRevertedError, + decodeErrorResult, + encodeFunctionData, + getContract, + PublicClient, + RawContractError, + toHex, + Transport, + zeroAddress, +} from "viem"; +import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI, VESTING_ABI, VESTING_FACTORY_ABI} from "@/abi/vesting"; +import {STAKING_ABI} from "@/abi/staking"; +import {Address, GenLayerChain, GenLayerClient} from "@/types"; +import { + VestingCategory, + VestingContract, + VestingDelegatorClaimOptions, + VestingDelegatorExitOptions, + VestingDelegatorJoinOptions, + VestingDelegatorJoinResult, + VestingFactoryContract, + VestingFactoryLookupOptions, + VestingSchedule, + VestingState, + VestingTransactionResult, + VestingValidatorClaimOptions, + VestingValidatorDepositOptions, + VestingValidatorExitOptions, + VestingValidatorInitiateOperatorTransferOptions, + VestingValidatorJoinOptions, + VestingValidatorJoinResult, + VestingValidatorSetIdentityOptions, + VestingValidatorWalletOptions, + VestingWithdrawOptions, + VestingWithdrawResult, +} from "@/types/vesting"; +import {formatStakingAmount, parseStakingAmount} from "@/staking/utils"; + +type WalletClientWithAccount = Client; + +const FALLBACK_GAS = 1000000n; +const GAS_BUFFER_MULTIPLIER = 2n; +const VESTING_FACTORY_KEY = "VestingFactory"; +const COMBINED_ERROR_ABI = [...VESTING_ABI, ...VESTING_FACTORY_ABI, ...ADDRESS_MANAGER_ABI, ...STAKING_ABI] as const; + +function extractRevertReason(err: unknown): string { + if (err instanceof BaseError) { + const rawError = err.walk(e => e instanceof RawContractError); + if (rawError instanceof RawContractError && rawError.data && typeof rawError.data === "string") { + try { + const decoded = decodeErrorResult({abi: COMBINED_ERROR_ABI, data: rawError.data as `0x${string}`}); + return decoded.errorName; + } catch { + // Fall through to other methods. + } + } + + let current: unknown = err; + while (current) { + if (current && typeof current === "object") { + const obj = current as Record; + if (obj.data && typeof obj.data === "string" && obj.data.startsWith("0x")) { + try { + const decoded = decodeErrorResult({abi: COMBINED_ERROR_ABI, data: obj.data as `0x${string}`}); + return decoded.errorName; + } catch { + // Continue searching. + } + } + current = obj.cause; + } else { + break; + } + } + + const revertError = err.walk(e => e instanceof ContractFunctionRevertedError); + if (revertError instanceof ContractFunctionRevertedError) { + if (revertError.data?.errorName) { + return revertError.data.errorName; + } + return revertError.reason || "Unknown reason"; + } + if (err.shortMessage) return err.shortMessage; + } + if (err instanceof Error) return err.message; + return "Unknown reason"; +} + +function encodeExtraCid(extraCid?: string): `0x${string}` { + if (!extraCid) return "0x"; + if (extraCid.startsWith("0x")) return extraCid as `0x${string}`; + return toHex(new TextEncoder().encode(extraCid)); +} + +export const vestingActions = ( + client: GenLayerClient, + publicClient: PublicClient, +) => { + const executeWrite = async (options: { + to: ViemAddress; + data: `0x${string}`; + value?: bigint; + gas?: bigint; + }): Promise => { + if (!client.account) { + throw new Error("Account is required for write operations. Initialize client with a wallet account."); + } + const account = client.account; + + try { + await publicClient.call({ + account, + to: options.to, + data: options.data, + value: options.value, + }); + } catch (err: unknown) { + const revertReason = extractRevertReason(err); + throw new Error(`Transaction would revert: ${revertReason}`); + } + + let gasLimit = options.gas; + if (!gasLimit) { + try { + const estimated = await publicClient.estimateGas({ + account, + to: options.to, + data: options.data, + value: options.value, + }); + gasLimit = estimated * GAS_BUFFER_MULTIPLIER; + } catch { + gasLimit = FALLBACK_GAS; + } + } + + const nonce = await publicClient.getTransactionCount({address: account.address as ViemAddress}); + + const txRequest = await publicClient.prepareTransactionRequest({ + account, + to: options.to, + data: options.data, + value: options.value, + type: "legacy", + nonce, + gas: gasLimit, + chain: client.chain, + }); + + const signTransaction = account.signTransaction; + if (!signTransaction) { + throw new Error("Account does not support signing transactions"); + } + const serializedTx = await signTransaction(txRequest as Parameters[0]); + const hash = await publicClient.sendRawTransaction({serializedTransaction: serializedTx}); + const receipt = await publicClient.waitForTransactionReceipt({hash}); + + if (receipt.status === "reverted") { + let revertReason = "Unknown reason"; + try { + await publicClient.call({ + account, + to: options.to, + data: options.data, + value: options.value, + blockNumber: receipt.blockNumber, + }); + const gasUsed = receipt.gasUsed; + if (gasUsed >= gasLimit - 1000n) { + revertReason = `Out of gas (used ${gasUsed}, limit ${gasLimit})`; + } else { + revertReason = `Unknown (simulation passes but tx reverts). Gas: ${gasUsed}/${gasLimit}`; + } + } catch (err: unknown) { + revertReason = extractRevertReason(err); + } + throw new Error(`Transaction reverted: ${revertReason} (tx: ${hash})`); + } + + return { + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber, + gasUsed: receipt.gasUsed, + }; + }; + + const readVesting = async (vesting: Address, functionName: string, args: readonly unknown[] = []): Promise => { + return publicClient.readContract({ + address: vesting as ViemAddress, + abi: VESTING_ABI, + functionName, + args, + } as any) as Promise; + }; + + const readFactory = async (factory: Address, functionName: string, args: readonly unknown[] = []): Promise => { + return publicClient.readContract({ + address: factory as ViemAddress, + abi: VESTING_FACTORY_ABI, + functionName, + args, + } as any) as Promise; + }; + + const getAddressManagerAddress = async (addressManager?: Address): Promise
=> { + if (addressManager) return addressManager; + + const consensusMain = client.chain.consensusMainContract; + if (!consensusMain?.address || consensusMain.address === zeroAddress) { + throw new Error("Cannot discover VestingFactory without a consensus main contract or explicit addressManager."); + } + + return publicClient.readContract({ + address: consensusMain.address as ViemAddress, + abi: CONSENSUS_ADDRESS_MANAGER_ABI, + functionName: "getAddressManager", + }) as Promise
; + }; + + const resolveVestingFactoryAddress = async (options?: VestingFactoryLookupOptions): Promise
=> { + if (options?.factory) return options.factory; + + const addressManager = await getAddressManagerAddress(options?.addressManager); + const factory = await publicClient.readContract({ + address: addressManager as ViemAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddress", + args: [VESTING_FACTORY_KEY], + }) as Address; + + if (!factory || factory === zeroAddress) { + throw new Error(`VestingFactory is not registered in AddressManager under key ${VESTING_FACTORY_KEY}.`); + } + return factory; + }; + + const getVestingContract = (vesting: Address): VestingContract => { + return getContract({ + address: vesting as ViemAddress, + abi: VESTING_ABI, + client: {public: publicClient, wallet: client as unknown as WalletClientWithAccount}, + }); + }; + + const getVestingFactoryContract = (factory: Address): VestingFactoryContract => { + return getContract({ + address: factory as ViemAddress, + abi: VESTING_FACTORY_ABI, + client: publicClient, + }); + }; + + return { + /** Delegates vesting-held tokens to a validator. Must be called by the vesting beneficiary. */ + vestingDelegatorJoin: async (options: VestingDelegatorJoinOptions): Promise => { + const amount = parseStakingAmount(options.amount); + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingDelegatorJoin", + args: [options.validator as ViemAddress, amount], + }); + const result = await executeWrite({to: options.vesting as ViemAddress, data}); + + return { + ...result, + vesting: options.vesting, + validator: options.validator, + beneficiary: client.account!.address as Address, + amount: formatStakingAmount(amount), + amountRaw: amount, + }; + }, + + /** Exits a vesting contract's delegation by burning shares. Must be called by the vesting beneficiary. */ + vestingDelegatorExit: async (options: VestingDelegatorExitOptions): Promise => { + const shares = typeof options.shares === "string" ? BigInt(options.shares) : options.shares; + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingDelegatorExit", + args: [options.validator as ViemAddress, shares], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Claims exited delegation funds back into the vesting contract. Must be called by the vesting beneficiary. */ + vestingDelegatorClaim: async (options: VestingDelegatorClaimOptions): Promise => { + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingDelegatorClaim", + args: [options.validator as ViemAddress], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Creates a validator wallet and self-stakes vesting-held tokens. Must be called by the vesting beneficiary. */ + vestingValidatorJoin: async (options: VestingValidatorJoinOptions): Promise => { + const amount = parseStakingAmount(options.amount); + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorJoin", + args: [options.operator as ViemAddress, amount], + }); + const result = await executeWrite({to: options.vesting as ViemAddress, data}); + + return { + ...result, + vesting: options.vesting, + operator: options.operator, + beneficiary: client.account!.address as Address, + amount: formatStakingAmount(amount), + amountRaw: amount, + }; + }, + + /** Adds more vesting-held self-stake to one of the vesting's validator wallets. */ + vestingValidatorDeposit: async (options: VestingValidatorDepositOptions): Promise => { + const amount = parseStakingAmount(options.amount); + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorDeposit", + args: [options.wallet as ViemAddress, amount], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Exits validator self-stake by burning shares from a vesting-owned validator wallet. */ + vestingValidatorExit: async (options: VestingValidatorExitOptions): Promise => { + const shares = typeof options.shares === "string" ? BigInt(options.shares) : options.shares; + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorExit", + args: [options.wallet as ViemAddress, shares], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Claims exited validator self-stake back into the vesting contract. */ + vestingValidatorClaim: async (options: VestingValidatorClaimOptions): Promise => { + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorClaim", + args: [options.wallet as ViemAddress], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Begins a two-step operator transfer for a vesting-owned validator wallet. */ + vestingValidatorInitiateOperatorTransfer: async (options: VestingValidatorInitiateOperatorTransferOptions): Promise => { + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorInitiateOperatorTransfer", + args: [options.wallet as ViemAddress, options.newOperator as ViemAddress], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Completes a pending operator transfer for a vesting-owned validator wallet. */ + vestingValidatorCompleteOperatorTransfer: async (options: VestingValidatorWalletOptions): Promise => { + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorCompleteOperatorTransfer", + args: [options.wallet as ViemAddress], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Cancels a pending operator transfer for a vesting-owned validator wallet. */ + vestingValidatorCancelOperatorTransfer: async (options: VestingValidatorWalletOptions): Promise => { + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorCancelOperatorTransfer", + args: [options.wallet as ViemAddress], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Sets validator identity metadata on a vesting-owned validator wallet. */ + vestingValidatorSetIdentity: async (options: VestingValidatorSetIdentityOptions): Promise => { + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingValidatorSetIdentity", + args: [ + options.wallet as ViemAddress, + options.moniker, + options.logoUri || "", + options.website || "", + options.description || "", + options.email || "", + options.twitter || "", + options.telegram || "", + options.github || "", + encodeExtraCid(options.extraCid), + ], + }); + return executeWrite({to: options.vesting as ViemAddress, data}); + }, + + /** Withdraws vested tokens to the beneficiary. Must be called by the vesting beneficiary. */ + vestingWithdraw: async (options: VestingWithdrawOptions): Promise => { + const amount = parseStakingAmount(options.amount); + const data = encodeFunctionData({ + abi: VESTING_ABI, + functionName: "vestingWithdraw", + args: [amount], + }); + const result = await executeWrite({to: options.vesting as ViemAddress, data}); + + return { + ...result, + vesting: options.vesting, + beneficiary: client.account!.address as Address, + amount: formatStakingAmount(amount), + amountRaw: amount, + }; + }, + + /** Resolves VestingFactory from AddressManager key "VestingFactory". */ + getVestingFactoryAddress: async (options?: Omit): Promise
=> { + return resolveVestingFactoryAddress(options); + }, + + /** Returns the Vesting contract for a beneficiary, or null when none is registered. */ + getVestingForBeneficiary: async (beneficiary: Address, options?: VestingFactoryLookupOptions): Promise
=> { + const factory = await resolveVestingFactoryAddress(options); + const vesting = await readFactory
(factory, "getVesting", [beneficiary as ViemAddress]); + return vesting === zeroAddress ? null : vesting; + }, + + /** Returns the beneficiary's vesting contracts. v0.6-dev permits one vesting per beneficiary. */ + getBeneficiaryVestings: async (beneficiary: Address, options?: VestingFactoryLookupOptions): Promise => { + const vesting = await resolveVestingFactoryAddress(options).then(factory => readFactory
(factory, "getVesting", [beneficiary as ViemAddress])); + return vesting === zeroAddress ? [] : [vesting]; + }, + + /** Checks whether an address is registered as a Vesting contract by the factory. */ + isVestingAddress: async (address: Address, options?: VestingFactoryLookupOptions): Promise => { + const factory = await resolveVestingFactoryAddress(options); + return readFactory(factory, "isVestingAddress", [address as ViemAddress]); + }, + + getVestingContract, + getVestingFactoryContract, + + vestedAmount: (vesting: Address): Promise => readVesting(vesting, "vestedAmount"), + unvestedAmount: (vesting: Address): Promise => readVesting(vesting, "unvestedAmount"), + withdrawableAmount: (vesting: Address): Promise => readVesting(vesting, "withdrawableAmount"), + + getVestingSchedule: async (vesting: Address): Promise => { + const [startDate, cliffDuration, periodDuration, numberOfPeriods, cliffUnlockBps, needsManualUnlock] = await Promise.all([ + readVesting(vesting, "startDate"), + readVesting(vesting, "cliffDuration"), + readVesting(vesting, "periodDuration"), + readVesting(vesting, "numberOfPeriods"), + readVesting(vesting, "cliffUnlockBps"), + readVesting(vesting, "needsManualUnlock"), + ]); + + return {startDate, cliffDuration, periodDuration, numberOfPeriods, cliffUnlockBps, needsManualUnlock}; + }, + + getVestingState: async (vesting: Address): Promise => { + const [ + name, + category, + beneficiary, + creator, + revoker, + factory, + addressManager, + totalAmount, + startDate, + cliffDuration, + periodDuration, + numberOfPeriods, + cliffUnlockBps, + needsManualUnlock, + manualUnlocked, + revoked, + vestingStopped, + totalWithdrawn, + vestedAtRevocation, + totalAmountAtRevocation, + revokedAt, + vestingStoppedAt, + vestedAtStop, + postRevocationBeneficiaryRewards, + postRevocationBeneficiaryLosses, + accumulatedRewards, + accumulatedLosses, + vested, + unvested, + withdrawable, + ] = await Promise.all([ + readVesting(vesting, "name"), + readVesting(vesting, "category"), + readVesting
(vesting, "beneficiary"), + readVesting
(vesting, "creator"), + readVesting
(vesting, "revoker"), + readVesting
(vesting, "factory"), + readVesting
(vesting, "addressManager"), + readVesting(vesting, "totalAmount"), + readVesting(vesting, "startDate"), + readVesting(vesting, "cliffDuration"), + readVesting(vesting, "periodDuration"), + readVesting(vesting, "numberOfPeriods"), + readVesting(vesting, "cliffUnlockBps"), + readVesting(vesting, "needsManualUnlock"), + readVesting(vesting, "manualUnlocked"), + readVesting(vesting, "revoked"), + readVesting(vesting, "vestingStopped"), + readVesting(vesting, "totalWithdrawn"), + readVesting(vesting, "vestedAtRevocation"), + readVesting(vesting, "totalAmountAtRevocation"), + readVesting(vesting, "revokedAt"), + readVesting(vesting, "vestingStoppedAt"), + readVesting(vesting, "vestedAtStop"), + readVesting(vesting, "postRevocationBeneficiaryRewards"), + readVesting(vesting, "postRevocationBeneficiaryLosses"), + readVesting(vesting, "accumulatedRewards"), + readVesting(vesting, "accumulatedLosses"), + readVesting(vesting, "vestedAmount"), + readVesting(vesting, "unvestedAmount"), + readVesting(vesting, "withdrawableAmount"), + ]); + + return { + name, + category, + beneficiary, + creator, + revoker, + factory, + addressManager, + totalAmount: formatStakingAmount(totalAmount), + totalAmountRaw: totalAmount, + startDate, + cliffDuration, + periodDuration, + numberOfPeriods, + cliffUnlockBps, + needsManualUnlock, + manualUnlocked, + revoked, + vestingStopped, + totalWithdrawn: formatStakingAmount(totalWithdrawn), + totalWithdrawnRaw: totalWithdrawn, + vestedAtRevocation: formatStakingAmount(vestedAtRevocation), + vestedAtRevocationRaw: vestedAtRevocation, + totalAmountAtRevocation: formatStakingAmount(totalAmountAtRevocation), + totalAmountAtRevocationRaw: totalAmountAtRevocation, + revokedAt, + vestingStoppedAt, + vestedAtStop: formatStakingAmount(vestedAtStop), + vestedAtStopRaw: vestedAtStop, + postRevocationBeneficiaryRewards: formatStakingAmount(postRevocationBeneficiaryRewards), + postRevocationBeneficiaryRewardsRaw: postRevocationBeneficiaryRewards, + postRevocationBeneficiaryLosses: formatStakingAmount(postRevocationBeneficiaryLosses), + postRevocationBeneficiaryLossesRaw: postRevocationBeneficiaryLosses, + accumulatedRewards: formatStakingAmount(accumulatedRewards), + accumulatedRewardsRaw: accumulatedRewards, + accumulatedLosses: formatStakingAmount(accumulatedLosses), + accumulatedLossesRaw: accumulatedLosses, + vestedAmount: formatStakingAmount(vested), + vestedAmountRaw: vested, + unvestedAmount: formatStakingAmount(unvested), + unvestedAmountRaw: unvested, + withdrawableAmount: formatStakingAmount(withdrawable), + withdrawableAmountRaw: withdrawable, + }; + }, + + vestingName: (vesting: Address): Promise => readVesting(vesting, "name"), + vestingCategory: (vesting: Address): Promise => readVesting(vesting, "category"), + vestingBeneficiary: (vesting: Address): Promise
=> readVesting
(vesting, "beneficiary"), + vestingCreator: (vesting: Address): Promise
=> readVesting
(vesting, "creator"), + vestingRevoker: (vesting: Address): Promise
=> readVesting
(vesting, "revoker"), + vestingFactory: (vesting: Address): Promise
=> readVesting
(vesting, "factory"), + vestingAddressManager: (vesting: Address): Promise
=> readVesting
(vesting, "addressManager"), + vestingTotalAmount: (vesting: Address): Promise => readVesting(vesting, "totalAmount"), + vestingStartDate: (vesting: Address): Promise => readVesting(vesting, "startDate"), + vestingCliffDuration: (vesting: Address): Promise => readVesting(vesting, "cliffDuration"), + vestingPeriodDuration: (vesting: Address): Promise => readVesting(vesting, "periodDuration"), + vestingNumberOfPeriods: (vesting: Address): Promise => readVesting(vesting, "numberOfPeriods"), + vestingCliffUnlockBps: (vesting: Address): Promise => readVesting(vesting, "cliffUnlockBps"), + vestingNeedsManualUnlock: (vesting: Address): Promise => readVesting(vesting, "needsManualUnlock"), + vestingManualUnlocked: (vesting: Address): Promise => readVesting(vesting, "manualUnlocked"), + vestingRevoked: (vesting: Address): Promise => readVesting(vesting, "revoked"), + vestingStopped: (vesting: Address): Promise => readVesting(vesting, "vestingStopped"), + vestingTotalWithdrawn: (vesting: Address): Promise => readVesting(vesting, "totalWithdrawn"), + vestingVestedAtRevocation: (vesting: Address): Promise => readVesting(vesting, "vestedAtRevocation"), + vestingTotalAmountAtRevocation: (vesting: Address): Promise => readVesting(vesting, "totalAmountAtRevocation"), + vestingRevokedAt: (vesting: Address): Promise => readVesting(vesting, "revokedAt"), + vestingStoppedAt: (vesting: Address): Promise => readVesting(vesting, "vestingStoppedAt"), + vestingVestedAtStop: (vesting: Address): Promise => readVesting(vesting, "vestedAtStop"), + vestingPostRevocationBeneficiaryRewards: (vesting: Address): Promise => readVesting(vesting, "postRevocationBeneficiaryRewards"), + vestingPostRevocationBeneficiaryLosses: (vesting: Address): Promise => readVesting(vesting, "postRevocationBeneficiaryLosses"), + vestingDepositedPerValidator: (vesting: Address, validator: Address): Promise => readVesting(vesting, "depositedPerValidator", [validator as ViemAddress]), + vestingPendingExitDeposited: (vesting: Address, validator: Address): Promise => readVesting(vesting, "pendingExitDeposited", [validator as ViemAddress]), + getValidatorWallets: (vesting: Address): Promise => readVesting(vesting, "getValidatorWallets"), + validatorWalletCount: (vesting: Address): Promise => readVesting(vesting, "validatorWalletCount"), + validatorDeposited: (vesting: Address, wallet: Address): Promise => readVesting(vesting, "validatorDeposited", [wallet as ViemAddress]), + isValidatorWallet: (vesting: Address, wallet: Address): Promise => readVesting(vesting, "isValidatorWallet", [wallet as ViemAddress]), + vestingAccumulatedRewards: (vesting: Address): Promise => readVesting(vesting, "accumulatedRewards"), + vestingAccumulatedLosses: (vesting: Address): Promise => readVesting(vesting, "accumulatedLosses"), + }; +}; diff --git a/src/vesting/index.ts b/src/vesting/index.ts new file mode 100644 index 0000000..b036132 --- /dev/null +++ b/src/vesting/index.ts @@ -0,0 +1,2 @@ +export {vestingActions} from "./actions"; +export * from "./validator"; diff --git a/src/vesting/validator.ts b/src/vesting/validator.ts new file mode 100644 index 0000000..fa01a00 --- /dev/null +++ b/src/vesting/validator.ts @@ -0,0 +1,10 @@ +export type { + VestingValidatorClaimOptions, + VestingValidatorDepositOptions, + VestingValidatorExitOptions, + VestingValidatorInitiateOperatorTransferOptions, + VestingValidatorJoinOptions, + VestingValidatorJoinResult, + VestingValidatorSetIdentityOptions, + VestingValidatorWalletOptions, +} from "@/types/vesting"; diff --git a/tests/vesting-actions.test.ts b/tests/vesting-actions.test.ts new file mode 100644 index 0000000..6d03546 --- /dev/null +++ b/tests/vesting-actions.test.ts @@ -0,0 +1,345 @@ +import {describe, expect, it, vi} from "vitest"; +import {decodeFunctionData, parseEther, toHex, zeroAddress} from "viem"; +import {VESTING_ABI} from "../src/abi/vesting"; +import {vestingActions} from "../src/vesting/actions"; + +const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; +const BENEFICIARY_ADDRESS = ACCOUNT_ADDRESS; +const VESTING_ADDRESS = "0x0000000000000000000000000000000000000022"; +const VALIDATOR_ADDRESS = "0x0000000000000000000000000000000000000033"; +const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; +const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; +const NEW_OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000bb"; +const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000044"; +const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000055"; +const FACTORY_ADDRESS = "0x0000000000000000000000000000000000000066"; +const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; + +const makeReceipt = () => ({ + status: "success" as const, + transactionHash: MOCK_TX_HASH as `0x${string}`, + blockNumber: 12n, + gasUsed: 345n, + logs: [], +}); + +const makeHarness = () => { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const client = { + account: { + address: ACCOUNT_ADDRESS, + type: "local", + signTransaction, + }, + chain: { + id: 1, + name: "test", + nativeCurrency: {name: "GEN", symbol: "GEN", decimals: 18}, + rpcUrls: {default: {http: ["http://127.0.0.1"]}}, + isStudio: false, + consensusMainContract: {address: CONSENSUS_MAIN_ADDRESS, abi: [], bytecode: "0x"}, + consensusDataContract: null, + stakingContract: null, + feeManagerContract: null, + roundsStorageContract: null, + appealsContract: null, + defaultNumberOfInitialValidators: 5, + defaultConsensusMaxRotations: 3, + }, + }; + const publicClient = { + call: vi.fn().mockResolvedValue("0x"), + estimateGas: vi.fn().mockResolvedValue(21000n), + getTransactionCount: vi.fn().mockResolvedValue(7), + prepareTransactionRequest: vi.fn().mockImplementation(async request => request), + sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + readContract: vi.fn(), + }; + + return { + actions: vestingActions(client as any, publicClient as any), + client, + publicClient, + signTransaction, + }; +}; + +const decodedWrite = (publicClient: ReturnType["publicClient"]) => { + const data = publicClient.call.mock.calls[0][0].data; + return decodeFunctionData({abi: VESTING_ABI, data}); +}; + +describe("vestingActions", () => { + it("encodes vestingDelegatorJoin against the beneficiary vesting contract", async () => { + const {actions, publicClient, signTransaction} = makeHarness(); + + const result = await actions.vestingDelegatorJoin({ + vesting: VESTING_ADDRESS, + validator: VALIDATOR_ADDRESS, + amount: "2gen", + }); + + expect(publicClient.call.mock.calls[0][0].to).toBe(VESTING_ADDRESS); + expect(publicClient.call.mock.calls[0][0].value).toBeUndefined(); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingDelegatorJoin", + args: [VALIDATOR_ADDRESS, parseEther("2")], + }); + expect(signTransaction).toHaveBeenCalledTimes(1); + expect(publicClient.sendRawTransaction).toHaveBeenCalledWith({serializedTransaction: "0xsigned"}); + expect(result).toMatchObject({ + transactionHash: MOCK_TX_HASH, + blockNumber: 12n, + gasUsed: 345n, + vesting: VESTING_ADDRESS, + validator: VALIDATOR_ADDRESS, + beneficiary: ACCOUNT_ADDRESS, + amount: "2 GEN", + amountRaw: parseEther("2"), + }); + }); + + it("encodes vesting exit, claim, and withdraw calls with contract signatures", async () => { + const {actions, publicClient} = makeHarness(); + + await actions.vestingDelegatorExit({vesting: VESTING_ADDRESS, validator: VALIDATOR_ADDRESS, shares: "42"}); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingDelegatorExit", + args: [VALIDATOR_ADDRESS, 42n], + }); + + publicClient.call.mockClear(); + await actions.vestingDelegatorClaim({vesting: VESTING_ADDRESS, validator: VALIDATOR_ADDRESS}); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingDelegatorClaim", + args: [VALIDATOR_ADDRESS], + }); + + publicClient.call.mockClear(); + const result = await actions.vestingWithdraw({vesting: VESTING_ADDRESS, amount: "1gen"}); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingWithdraw", + args: [parseEther("1")], + }); + expect(result).toMatchObject({ + vesting: VESTING_ADDRESS, + beneficiary: ACCOUNT_ADDRESS, + amount: "1 GEN", + amountRaw: parseEther("1"), + }); + }); + + it("encodes vesting validator join and deposit without caller value", async () => { + const {actions, publicClient} = makeHarness(); + + const result = await actions.vestingValidatorJoin({ + vesting: VESTING_ADDRESS, + operator: OPERATOR_ADDRESS, + amount: "3gen", + }); + + expect(publicClient.call.mock.calls[0][0].to).toBe(VESTING_ADDRESS); + expect(publicClient.call.mock.calls[0][0].value).toBeUndefined(); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorJoin", + args: [OPERATOR_ADDRESS, parseEther("3")], + }); + expect(result).toMatchObject({ + vesting: VESTING_ADDRESS, + operator: OPERATOR_ADDRESS, + beneficiary: ACCOUNT_ADDRESS, + amount: "3 GEN", + amountRaw: parseEther("3"), + }); + + publicClient.call.mockClear(); + await actions.vestingValidatorDeposit({ + vesting: VESTING_ADDRESS, + wallet: VALIDATOR_WALLET_ADDRESS, + amount: "4gen", + }); + expect(publicClient.call.mock.calls[0][0].value).toBeUndefined(); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorDeposit", + args: [VALIDATOR_WALLET_ADDRESS, parseEther("4")], + }); + }); + + it("encodes vesting validator exit, claim, and operator transfer calls", async () => { + const {actions, publicClient} = makeHarness(); + + await actions.vestingValidatorExit({vesting: VESTING_ADDRESS, wallet: VALIDATOR_WALLET_ADDRESS, shares: "42"}); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorExit", + args: [VALIDATOR_WALLET_ADDRESS, 42n], + }); + + publicClient.call.mockClear(); + await actions.vestingValidatorClaim({vesting: VESTING_ADDRESS, wallet: VALIDATOR_WALLET_ADDRESS}); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorClaim", + args: [VALIDATOR_WALLET_ADDRESS], + }); + + publicClient.call.mockClear(); + await actions.vestingValidatorInitiateOperatorTransfer({ + vesting: VESTING_ADDRESS, + wallet: VALIDATOR_WALLET_ADDRESS, + newOperator: NEW_OPERATOR_ADDRESS, + }); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorInitiateOperatorTransfer", + args: [VALIDATOR_WALLET_ADDRESS, NEW_OPERATOR_ADDRESS], + }); + + publicClient.call.mockClear(); + await actions.vestingValidatorCompleteOperatorTransfer({vesting: VESTING_ADDRESS, wallet: VALIDATOR_WALLET_ADDRESS}); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorCompleteOperatorTransfer", + args: [VALIDATOR_WALLET_ADDRESS], + }); + + publicClient.call.mockClear(); + await actions.vestingValidatorCancelOperatorTransfer({vesting: VESTING_ADDRESS, wallet: VALIDATOR_WALLET_ADDRESS}); + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorCancelOperatorTransfer", + args: [VALIDATOR_WALLET_ADDRESS], + }); + }); + + it("encodes vesting validator identity with optional fields and bytes extraCid", async () => { + const {actions, publicClient} = makeHarness(); + + await actions.vestingValidatorSetIdentity({ + vesting: VESTING_ADDRESS, + wallet: VALIDATOR_WALLET_ADDRESS, + moniker: "validator-one", + website: "https://example.com", + twitter: "@genlayer", + extraCid: "cid-bytes", + }); + + expect(decodedWrite(publicClient)).toEqual({ + functionName: "vestingValidatorSetIdentity", + args: [ + VALIDATOR_WALLET_ADDRESS, + "validator-one", + "", + "https://example.com", + "", + "", + "@genlayer", + "", + "", + toHex(new TextEncoder().encode("cid-bytes")), + ], + }); + }); + + it("discovers a beneficiary vesting contract through AddressManager and VestingFactory", async () => { + const {actions, publicClient} = makeHarness(); + publicClient.readContract.mockImplementation(async ({functionName, args}: any) => { + if (functionName === "getAddressManager") return ADDRESS_MANAGER_ADDRESS; + if (functionName === "getAddress") { + expect(args).toEqual(["VestingFactory"]); + return FACTORY_ADDRESS; + } + if (functionName === "getVesting") { + expect(args).toEqual([BENEFICIARY_ADDRESS]); + return VESTING_ADDRESS; + } + throw new Error(`Unexpected read: ${functionName}`); + }); + + await expect(actions.getVestingFactoryAddress()).resolves.toBe(FACTORY_ADDRESS); + await expect(actions.getVestingForBeneficiary(BENEFICIARY_ADDRESS)).resolves.toBe(VESTING_ADDRESS); + await expect(actions.getBeneficiaryVestings(BENEFICIARY_ADDRESS)).resolves.toEqual([VESTING_ADDRESS]); + }); + + it("supports explicit factory lookup and returns empty beneficiary vestings for zero address", async () => { + const {actions, publicClient} = makeHarness(); + publicClient.readContract.mockImplementation(async ({address, functionName}: any) => { + expect(address).toBe(FACTORY_ADDRESS); + if (functionName === "getVesting") return zeroAddress; + if (functionName === "isVestingAddress") return true; + throw new Error(`Unexpected read: ${functionName}`); + }); + + await expect(actions.getVestingForBeneficiary(BENEFICIARY_ADDRESS, {factory: FACTORY_ADDRESS})).resolves.toBeNull(); + await expect(actions.getBeneficiaryVestings(BENEFICIARY_ADDRESS, {factory: FACTORY_ADDRESS})).resolves.toEqual([]); + await expect(actions.isVestingAddress(VESTING_ADDRESS, {factory: FACTORY_ADDRESS})).resolves.toBe(true); + }); + + it("reads vesting schedule and state getters", async () => { + const {actions, publicClient} = makeHarness(); + const values: Record = { + name: "Team Vesting", + category: 0, + beneficiary: BENEFICIARY_ADDRESS, + creator: "0x0000000000000000000000000000000000000077", + revoker: "0x0000000000000000000000000000000000000088", + factory: FACTORY_ADDRESS, + addressManager: ADDRESS_MANAGER_ADDRESS, + totalAmount: parseEther("100"), + startDate: 1000n, + cliffDuration: 200n, + periodDuration: 30n, + numberOfPeriods: 12n, + cliffUnlockBps: 1000n, + needsManualUnlock: true, + manualUnlocked: false, + revoked: false, + vestingStopped: false, + totalWithdrawn: parseEther("1"), + vestedAtRevocation: 0n, + totalAmountAtRevocation: 0n, + revokedAt: 0n, + vestingStoppedAt: 0n, + vestedAtStop: 0n, + postRevocationBeneficiaryRewards: 0n, + postRevocationBeneficiaryLosses: 0n, + accumulatedRewards: parseEther("3"), + accumulatedLosses: parseEther("2"), + vestedAmount: parseEther("10"), + unvestedAmount: parseEther("90"), + withdrawableAmount: parseEther("9"), + depositedPerValidator: parseEther("25"), + pendingExitDeposited: parseEther("5"), + getValidatorWallets: [VALIDATOR_WALLET_ADDRESS], + validatorWalletCount: 1n, + validatorDeposited: parseEther("30"), + isValidatorWallet: true, + }; + publicClient.readContract.mockImplementation(async ({functionName}: any) => values[functionName]); + + await expect(actions.vestedAmount(VESTING_ADDRESS)).resolves.toBe(parseEther("10")); + await expect(actions.vestingDepositedPerValidator(VESTING_ADDRESS, VALIDATOR_ADDRESS)).resolves.toBe(parseEther("25")); + await expect(actions.vestingPendingExitDeposited(VESTING_ADDRESS, VALIDATOR_ADDRESS)).resolves.toBe(parseEther("5")); + await expect(actions.getValidatorWallets(VESTING_ADDRESS)).resolves.toEqual([VALIDATOR_WALLET_ADDRESS]); + await expect(actions.validatorWalletCount(VESTING_ADDRESS)).resolves.toBe(1n); + await expect(actions.validatorDeposited(VESTING_ADDRESS, VALIDATOR_WALLET_ADDRESS)).resolves.toBe(parseEther("30")); + await expect(actions.isValidatorWallet(VESTING_ADDRESS, VALIDATOR_WALLET_ADDRESS)).resolves.toBe(true); + await expect(actions.getVestingSchedule(VESTING_ADDRESS)).resolves.toEqual({ + startDate: 1000n, + cliffDuration: 200n, + periodDuration: 30n, + numberOfPeriods: 12n, + cliffUnlockBps: 1000n, + needsManualUnlock: true, + }); + + await expect(actions.getVestingState(VESTING_ADDRESS)).resolves.toMatchObject({ + name: "Team Vesting", + beneficiary: BENEFICIARY_ADDRESS, + totalAmount: "100 GEN", + totalAmountRaw: parseEther("100"), + accumulatedRewards: "3 GEN", + accumulatedRewardsRaw: parseEther("3"), + vestedAmount: "10 GEN", + vestedAmountRaw: parseEther("10"), + withdrawableAmount: "9 GEN", + withdrawableAmountRaw: parseEther("9"), + }); + }); +}); From a338ad5646b4b53cb2df418a25dbef20a8cb5f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 00:22:27 +0100 Subject: [PATCH 09/25] feat: epoch-zero SDK helpers (getCurrentEpoch, isValidatorBelowMinStake) (#194) * feat: getCurrentEpoch + isValidatorBelowMinStake epoch-zero helpers Convenience reads for epoch-0-aware tooling: getCurrentEpoch() (bootstrap epoch where min stake is not enforced) and isValidatorBelowMinStake() so callers can present a below-min validator as bootstrapping vs faulted while epoch is 0. Additive; existing signatures unchanged. * fix(types): add epoch-zero fields to ValidatorInfo getValidatorInfo returns currentEpoch, validatorMinStake, validatorMinStakeRaw, and belowMin, but the ValidatorInfo interface was never extended to include them, breaking the dts build (TS2353) and failing the drift, test, and smoke checks. --- src/staking/actions.ts | 23 ++++++++++++++++++++++- src/types/staking.ts | 4 ++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/staking/actions.ts b/src/staking/actions.ts index 649db10..b65a83a 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -421,12 +421,13 @@ export const stakingActions = ( }); // Fetch all data in parallel - const [view, owner, operator, identityRaw, currentEpoch] = await Promise.all([ + const [view, owner, operator, identityRaw, currentEpoch, validatorMinStake] = await Promise.all([ contract.read.validatorView([validator as ViemAddress]) as Promise, walletContract.read.owner() as Promise
, walletContract.read.operator() as Promise
, walletContract.read.getIdentity().catch(() => null) as Promise, contract.read.epoch() as Promise, + contract.read.validatorMinStake() as Promise, ]); // Parse identity if available @@ -501,12 +502,32 @@ export const stakingActions = ( banned: view.eBanned > 0n, bannedEpoch: view.eBanned > 0n ? view.eBanned : undefined, needsPriming, + currentEpoch, + validatorMinStake: formatStakingAmount(validatorMinStake), + validatorMinStakeRaw: validatorMinStake, + belowMin: view.vStake < validatorMinStake, identity, pendingDeposits, pendingWithdrawals, }; }, + /** Returns the current epoch number. */ + getCurrentEpoch: async (): Promise => { + const contract = getReadOnlyStakingContract(); + return (await contract.read.epoch()) as bigint; + }, + + /** Checks whether a validator's self-stake is below the configured validator minimum. */ + isValidatorBelowMin: async (validator: Address): Promise => { + const contract = getReadOnlyStakingContract(); + const [view, minStake] = await Promise.all([ + contract.read.validatorView([validator as ViemAddress]) as Promise<{vStake: bigint}>, + contract.read.validatorMinStake() as Promise, + ]); + return view.vStake < minStake; + }, + /** Returns delegation stake information for a delegator-validator pair. */ getStakeInfo: async (delegator: Address, validator: Address): Promise => { const contract = getReadOnlyStakingContract(); diff --git a/src/types/staking.ts b/src/types/staking.ts index e9ebc79..9d9c657 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -61,6 +61,10 @@ export interface ValidatorInfo { banned: boolean; bannedEpoch?: bigint; needsPriming: boolean; + currentEpoch: bigint; + validatorMinStake: string; + validatorMinStakeRaw: bigint; + belowMin: boolean; identity?: ValidatorIdentity; pendingDeposits: PendingDeposit[]; pendingWithdrawals: PendingWithdrawal[]; From 666d1156c3c136bf79916c53b9764ff3a2a87a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 09:42:05 +0100 Subject: [PATCH 10/25] chore: untrack vitest-generated tsconfig.vitest-temp.json (#196) This file is a temporary artifact emitted by `vitest --typecheck` and deleted/regenerated on each run, so committing it produced spurious working-tree changes on every test run. It was accidentally committed in #145 (34de39f). Remove it from tracking and ignore `tsconfig.vitest-temp*.json` so the tree stays clean. --- .gitignore | 3 ++- tsconfig.vitest-temp.json | 41 --------------------------------------- 2 files changed, 2 insertions(+), 42 deletions(-) delete mode 100644 tsconfig.vitest-temp.json diff --git a/.gitignore b/.gitignore index 858600f..15cafde 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules .env .DS_Store dist -coverage \ No newline at end of file +coverage +tsconfig.vitest-temp*.json \ No newline at end of file diff --git a/tsconfig.vitest-temp.json b/tsconfig.vitest-temp.json deleted file mode 100644 index f4fc024..0000000 --- a/tsconfig.vitest-temp.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "node", - "paths": { - "@/*": [ - "./src/*" - ], - "@@/tests/*": [ - "./tests/*" - ] - }, - "rootDirs": [ - "./src", - "./tests" - ], - "types": [ - "node", - "jest" - ], - "resolveJsonModule": true, - "sourceMap": true, - "outDir": "./dist", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "emitDeclarationOnly": false, - "incremental": true, - "tsBuildInfoFile": "/Users/edgars/Dev/genlayer-js/node_modules/vitest/dist/chunks/tsconfig.tmp.tsbuildinfo" - }, - "include": [ - "src/**/*", - "tests/**/*", - "src/global.d.ts" - ], - "exclude": [ - "./dist" - ] -} \ No newline at end of file From 289839d13183013eac050e052999d45d392e09e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 21:47:54 +0100 Subject: [PATCH 11/25] fix: make git install build script self contained (#197) --- package.json | 6 +++--- scripts/run-tsup-build.mjs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 scripts/run-tsup-build.mjs diff --git a/package.json b/package.json index ff044a9..25b7b82 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "LICENSE" ], "scripts": { - "prepare": "npm run build", - "build": "tsup src/index.ts src/chains/index.ts src/types/index.ts --format cjs,esm --dts --splitting --out-dir dist --no-treeshake", - "build:watch": "tsup src/index.ts src/chains/index.ts src/types/index.ts --format cjs,esm --dts --splitting --out-dir dist --no-treeshake --watch", + "prepack": "npm run build", + "build": "node scripts/run-tsup-build.mjs", + "build:watch": "node scripts/run-tsup-build.mjs --watch", "test": "vitest --typecheck", "test:smoke": "vitest run --config vitest.smoke.config.ts", "test:watch": "vitest --watch", diff --git a/scripts/run-tsup-build.mjs b/scripts/run-tsup-build.mjs new file mode 100644 index 0000000..4facb84 --- /dev/null +++ b/scripts/run-tsup-build.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +import { build } from 'tsup'; + +const watch = process.argv.includes('--watch'); + +await build({ + entry: ['src/index.ts', 'src/chains/index.ts', 'src/types/index.ts'], + format: ['cjs', 'esm'], + dts: true, + splitting: true, + outDir: 'dist', + treeshake: false, + watch, +}); From 57889281db1e34df49abcf6129ffe6f347e4de4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 22:08:00 +0100 Subject: [PATCH 12/25] fix: build package during git prepare (#198) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 25b7b82..b23d64d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "LICENSE" ], "scripts": { + "prepare": "npm run build", "prepack": "npm run build", "build": "node scripts/run-tsup-build.mjs", "build:watch": "node scripts/run-tsup-build.mjs --watch", From bf42f13a66a2bb762e5ef1065eb89789b9c45d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Fri, 10 Jul 2026 09:07:26 +0100 Subject: [PATCH 13/25] feat(staking,vesting,accounts): provider-lane writes + native transfer (#200) B1: executeWrite in staking + vesting now branches on account.type === "local". The local-key path (getTransactionCount -> prepareTransactionRequest -> sign -> sendRawTransaction) is unchanged and stays the regression gate. Address-only / injected-provider accounts route through client.request eth_sendTransaction (wallet manages nonce + signing), mirroring the proven IC provider lane in src/contracts/actions.ts. Shared preflight simulate + gas estimate and the post-mine receipt wait / revert diagnostics are unchanged around the branch. B2: new accountActions.transfer({to, value}) for native GEN transfers, local-key only, mirroring the executeWrite local lane. accountActions now takes publicClient. Unblocks CLI browser-wallet writes routing through the SDK. --- src/accounts/actions.ts | 57 ++++++++- src/client/client.ts | 2 +- src/staking/actions.ts | 62 ++++++--- src/types/clients.ts | 3 +- src/vesting/actions.ts | 62 ++++++--- tests/accounts-actions.test.ts | 115 ++++++++++++++++- tests/client.test-d.ts | 8 ++ tests/staking-actions.test.ts | 221 +++++++++++++++++++++++++++++++++ tests/vesting-actions.test.ts | 127 +++++++++++++++++++ 9 files changed, 616 insertions(+), 41 deletions(-) create mode 100644 tests/staking-actions.test.ts diff --git a/src/accounts/actions.ts b/src/accounts/actions.ts index 28fb04b..f6827e9 100644 --- a/src/accounts/actions.ts +++ b/src/accounts/actions.ts @@ -1,7 +1,8 @@ +import {Address as ViemAddress, PublicClient, TransactionReceipt} from "viem"; import {GenLayerClient, TransactionHash, GenLayerChain, Address} from "../types"; import {localnet} from "../chains"; -export function accountActions(client: GenLayerClient) { +export function accountActions(client: GenLayerClient, publicClient: PublicClient) { return { fundAccount: async ({address, amount}: {address: Address; amount: number}): Promise => { if (client.chain?.id !== localnet.id) { @@ -45,5 +46,59 @@ export function accountActions(client: GenLayerClient) { // serializer encodes the ASCII characters as the nonce bytes. return Number(count); }, + /** + * Sends a native GEN transfer from the connected account. + * + * Local-key only: mirrors the staking/vesting executeWrite local lane 1:1 + * (estimateGas → pending nonce → legacy prepareTransactionRequest → sign → + * sendRawTransaction → wait for receipt). Address-only / injected-provider + * accounts are intentionally rejected — provider-signed transfers are the + * wallet's own responsibility. + */ + transfer: async ({to, value}: {to: Address; value: bigint}): Promise => { + const account = client.account; + if (!account || account.type !== "local" || !account.signTransaction) { + throw new Error( + "transfer requires a local-key account. Initialize the client with a private-key account created via createAccount().", + ); + } + + let gasLimit: bigint; + try { + gasLimit = await publicClient.estimateGas({ + account, + to: to as ViemAddress, + value, + }); + } catch { + gasLimit = 21000n; + } + + const nonce = await publicClient.getTransactionCount({ + address: account.address as ViemAddress, + blockTag: "pending", + }); + + const txRequest = await publicClient.prepareTransactionRequest({ + account, + to: to as ViemAddress, + value, + type: "legacy", + nonce, + gas: gasLimit, + chain: client.chain, + }); + + const signTransaction = account.signTransaction; + const serializedTx = await signTransaction(txRequest as Parameters[0]); + const hash = await publicClient.sendRawTransaction({serializedTransaction: serializedTx}); + const receipt = await publicClient.waitForTransactionReceipt({hash}); + + if (receipt.status === "reverted") { + throw new Error(`Transfer reverted (tx: ${hash})`); + } + + return receipt; + }, }; } diff --git a/src/client/client.ts b/src/client/client.ts index 0350073..5e46852 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -148,7 +148,7 @@ export const createClient = (config: ClientConfig = {chain: localnet}): GenLayer const clientWithBasicActions = baseClient .extend(publicActions) .extend(walletActions) - .extend(client => accountActions(client as unknown as GenLayerClient)); + .extend(client => accountActions(client as unknown as GenLayerClient, publicClient)); const clientWithTransactionActions = { ...clientWithBasicActions, diff --git a/src/staking/actions.ts b/src/staking/actions.ts index b65a83a..a96f86c 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -131,25 +131,53 @@ export const stakingActions = ( } } - const nonce = await publicClient.getTransactionCount({address: account.address as ViemAddress}); - - const txRequest = await publicClient.prepareTransactionRequest({ - account, - to: options.to, - data: options.data, - value: options.value, - type: "legacy", - nonce, - gas: gasLimit, - chain: client.chain, - }); + let hash: `0x${string}`; + if (account.type === "local") { + const nonce = await publicClient.getTransactionCount({address: account.address as ViemAddress}); + + const txRequest = await publicClient.prepareTransactionRequest({ + account, + to: options.to, + data: options.data, + value: options.value, + type: "legacy", + nonce, + gas: gasLimit, + chain: client.chain, + }); - const signTransaction = account.signTransaction; - if (!signTransaction) { - throw new Error("Account does not support signing transactions"); + const signTransaction = account.signTransaction; + if (!signTransaction) { + throw new Error("Account does not support signing transactions"); + } + const serializedTx = await signTransaction(txRequest as Parameters[0]); + hash = await publicClient.sendRawTransaction({serializedTransaction: serializedTx}); + } else { + // Address-only / injected-provider lane: the connected wallet manages + // nonce and signing. Mirrors the proven IC provider lane in + // src/contracts/actions.ts (~:2169-2178 and :2412-2422). + let gasPrice: `0x${string}` | undefined; + try { + gasPrice = (await client.request({method: "eth_gasPrice"})) as `0x${string}`; + } catch { + // Best-effort: omit gasPrice and let the wallet choose it. + } + hash = (await client.request({ + method: "eth_sendTransaction", + params: [ + { + from: account.address, + to: options.to, + data: options.data, + value: options.value ? (`0x${options.value.toString(16)}` as `0x${string}`) : undefined, + gas: `0x${gasLimit.toString(16)}` as `0x${string}`, + type: "0x0", + ...(gasPrice ? {gasPrice} : {}), + }, + ], + })) as `0x${string}`; } - const serializedTx = await signTransaction(txRequest as Parameters[0]); - const hash = await publicClient.sendRawTransaction({serializedTransaction: serializedTx}); + const receipt = await publicClient.waitForTransactionReceipt({hash}); if (receipt.status === "reverted") { diff --git a/src/types/clients.ts b/src/types/clients.ts index e0d3115..449975c 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -1,4 +1,4 @@ -import {Transport, Client, PublicActions, WalletActions} from "viem"; +import {Transport, Client, PublicActions, WalletActions, TransactionReceipt} from "viem"; import { GenLayerTransaction, TransactionHash, @@ -114,6 +114,7 @@ export type GenLayerClient = Omit< }) => Promise<`0x${string}`>; getTransaction: (args: {hash: TransactionHash}) => Promise; getCurrentNonce: (args: {address: Address}) => Promise; + transfer: (args: {to: Address; value: bigint}) => Promise; estimateTransactionGas: (transactionParams: { from?: Address; to: Address; diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts index 42110dc..5e2cef1 100644 --- a/src/vesting/actions.ts +++ b/src/vesting/actions.ts @@ -140,25 +140,53 @@ export const vestingActions = ( } } - const nonce = await publicClient.getTransactionCount({address: account.address as ViemAddress}); - - const txRequest = await publicClient.prepareTransactionRequest({ - account, - to: options.to, - data: options.data, - value: options.value, - type: "legacy", - nonce, - gas: gasLimit, - chain: client.chain, - }); + let hash: `0x${string}`; + if (account.type === "local") { + const nonce = await publicClient.getTransactionCount({address: account.address as ViemAddress}); + + const txRequest = await publicClient.prepareTransactionRequest({ + account, + to: options.to, + data: options.data, + value: options.value, + type: "legacy", + nonce, + gas: gasLimit, + chain: client.chain, + }); - const signTransaction = account.signTransaction; - if (!signTransaction) { - throw new Error("Account does not support signing transactions"); + const signTransaction = account.signTransaction; + if (!signTransaction) { + throw new Error("Account does not support signing transactions"); + } + const serializedTx = await signTransaction(txRequest as Parameters[0]); + hash = await publicClient.sendRawTransaction({serializedTransaction: serializedTx}); + } else { + // Address-only / injected-provider lane: the connected wallet manages + // nonce and signing. Mirrors the proven IC provider lane in + // src/contracts/actions.ts (~:2169-2178 and :2412-2422). + let gasPrice: `0x${string}` | undefined; + try { + gasPrice = (await client.request({method: "eth_gasPrice"})) as `0x${string}`; + } catch { + // Best-effort: omit gasPrice and let the wallet choose it. + } + hash = (await client.request({ + method: "eth_sendTransaction", + params: [ + { + from: account.address, + to: options.to, + data: options.data, + value: options.value ? (`0x${options.value.toString(16)}` as `0x${string}`) : undefined, + gas: `0x${gasLimit.toString(16)}` as `0x${string}`, + type: "0x0", + ...(gasPrice ? {gasPrice} : {}), + }, + ], + })) as `0x${string}`; } - const serializedTx = await signTransaction(txRequest as Parameters[0]); - const hash = await publicClient.sendRawTransaction({serializedTransaction: serializedTx}); + const receipt = await publicClient.waitForTransactionReceipt({hash}); if (receipt.status === "reverted") { diff --git a/tests/accounts-actions.test.ts b/tests/accounts-actions.test.ts index 53108e4..6b110b8 100644 --- a/tests/accounts-actions.test.ts +++ b/tests/accounts-actions.test.ts @@ -1,6 +1,12 @@ import {describe, expect, it, vi} from "vitest"; +import {parseEther} from "viem"; import {accountActions} from "../src/accounts/actions"; +// Minimal publicClient stub for the getCurrentNonce tests, which never touch it. +// accountActions now requires a publicClient (mandated by the transfer factory +// signature change); these read-path tests are otherwise unchanged. +const noopPublicClient = {} as any; + function makeClient() { const request = vi.fn().mockResolvedValue(42); return { @@ -16,7 +22,7 @@ function makeClient() { describe("accountActions.getCurrentNonce", () => { it("defaults to block=\"pending\" so concurrent submissions do not collide", async () => { const {client, request} = makeClient(); - const actions = accountActions(client); + const actions = accountActions(client, noopPublicClient); await actions.getCurrentNonce({ address: "0x0000000000000000000000000000000000000001", @@ -30,7 +36,7 @@ describe("accountActions.getCurrentNonce", () => { it("honors an explicit block override", async () => { const {client, request} = makeClient(); - const actions = accountActions(client); + const actions = accountActions(client, noopPublicClient); await actions.getCurrentNonce({ address: "0x0000000000000000000000000000000000000001", @@ -50,7 +56,7 @@ describe("accountActions.getCurrentNonce", () => { account: {address: "0x0000000000000000000000000000000000000abc"}, chain: {id: 1}, } as any; - const actions = accountActions(client); + const actions = accountActions(client, noopPublicClient); // Pass empty string, which is falsy per the implementation's fallback chain. await actions.getCurrentNonce({address: "" as any}); @@ -63,10 +69,111 @@ describe("accountActions.getCurrentNonce", () => { it("throws when neither address nor client.account is available", async () => { const {client} = makeClient(); - const actions = accountActions(client); + const actions = accountActions(client, noopPublicClient); await expect(actions.getCurrentNonce({address: "" as any})).rejects.toThrow( /No address provided/, ); }); }); + +const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; +const TO_ADDRESS = "0x0000000000000000000000000000000000000022"; +const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; + +const makeTransferReceipt = () => ({ + status: "success" as const, + transactionHash: MOCK_TX_HASH as `0x${string}`, + blockNumber: 12n, + gasUsed: 21000n, + logs: [], +}); + +function makeTransferHarness() { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const client = { + account: { + address: ACCOUNT_ADDRESS, + type: "local", + signTransaction, + }, + chain: {id: 1, name: "test"}, + } as any; + const publicClient = { + estimateGas: vi.fn().mockResolvedValue(21000n), + getTransactionCount: vi.fn().mockResolvedValue(7), + prepareTransactionRequest: vi.fn().mockImplementation(async (request: any) => request), + sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue(makeTransferReceipt()), + } as any; + + return {actions: accountActions(client, publicClient), client, publicClient, signTransaction}; +} + +describe("accountActions.transfer", () => { + it("signs a legacy transfer with the pending nonce and returns the receipt", async () => { + const {actions, publicClient, signTransaction} = makeTransferHarness(); + + const receipt = await actions.transfer({to: TO_ADDRESS, value: parseEther("1")}); + + // Uses the pending nonce for rapid sequential sends. + expect(publicClient.getTransactionCount).toHaveBeenCalledWith({ + address: ACCOUNT_ADDRESS, + blockTag: "pending", + }); + + const prepared = publicClient.prepareTransactionRequest.mock.calls[0][0]; + expect(prepared).toMatchObject({ + to: TO_ADDRESS, + value: parseEther("1"), + type: "legacy", + nonce: 7, + gas: 21000n, + }); + + expect(signTransaction).toHaveBeenCalledTimes(1); + expect(publicClient.sendRawTransaction).toHaveBeenCalledWith({serializedTransaction: "0xsigned"}); + expect(receipt).toEqual(makeTransferReceipt()); + }); + + it("falls back to a 21000 gas limit when estimateGas rejects", async () => { + const {actions, publicClient} = makeTransferHarness(); + publicClient.estimateGas.mockRejectedValueOnce(new Error("estimate failed")); + + await actions.transfer({to: TO_ADDRESS, value: parseEther("1")}); + + expect(publicClient.prepareTransactionRequest.mock.calls[0][0].gas).toBe(21000n); + }); + + it("throws when the transfer receipt is reverted", async () => { + const {actions, publicClient} = makeTransferHarness(); + publicClient.waitForTransactionReceipt.mockResolvedValueOnce({ + ...makeTransferReceipt(), + status: "reverted", + }); + + await expect(actions.transfer({to: TO_ADDRESS, value: parseEther("1")})).rejects.toThrow( + /Transfer reverted/, + ); + }); + + it("throws when no account is connected", async () => { + const client = {account: undefined, chain: {id: 1}} as any; + const publicClient = {} as any; + const actions = accountActions(client, publicClient); + + await expect(actions.transfer({to: TO_ADDRESS, value: parseEther("1")})).rejects.toThrow( + /requires a local-key account/, + ); + }); + + it("throws for Address-only (json-rpc) accounts", async () => { + const client = {account: {address: ACCOUNT_ADDRESS, type: "json-rpc"}, chain: {id: 1}} as any; + const publicClient = {} as any; + const actions = accountActions(client, publicClient); + + await expect(actions.transfer({to: TO_ADDRESS, value: parseEther("1")})).rejects.toThrow( + /requires a local-key account/, + ); + }); +}); diff --git a/tests/client.test-d.ts b/tests/client.test-d.ts index 09f0cd5..7b375d4 100644 --- a/tests/client.test-d.ts +++ b/tests/client.test-d.ts @@ -34,6 +34,14 @@ test("type checks", () => { params: [exampleAddress, "from"], }); + void client.transfer({ + to: exampleAddress, + value: 1n, + }); + + // @ts-expect-error value must be a bigint + void client.transfer({to: exampleAddress, value: 1}); + void client.getContractSchema(exampleAddress); void client.getContractSchemaForCode("class SomeContract..."); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts new file mode 100644 index 0000000..f58ce33 --- /dev/null +++ b/tests/staking-actions.test.ts @@ -0,0 +1,221 @@ +import {describe, expect, it, vi} from "vitest"; +import {decodeFunctionData, encodeAbiParameters, encodeEventTopics, getAbiItem, parseEther} from "viem"; +import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "../src/abi/staking"; +import {stakingActions} from "../src/staking/actions"; + +const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; +const STAKING_ADDRESS = "0x0000000000000000000000000000000000000044"; +const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; +const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; +const GAS_PRICE_HEX = "0x3b9aca00"; +const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; + +// Build a real ValidatorJoin event log so decodeEventLog resolves it exactly as +// the SDK does on-chain (operator, validator, amount — all non-indexed). +const validatorJoinLog = () => { + const topics = encodeEventTopics({abi: STAKING_ABI, eventName: "ValidatorJoin"}); + const event = getAbiItem({abi: STAKING_ABI, name: "ValidatorJoin"}) as any; + const data = encodeAbiParameters(event.inputs, [OPERATOR_ADDRESS, VALIDATOR_WALLET_ADDRESS, parseEther("2")]); + return {data, topics, address: STAKING_ADDRESS}; +}; + +const makeReceipt = (overrides: Record = {}) => ({ + status: "success" as const, + transactionHash: MOCK_TX_HASH as `0x${string}`, + blockNumber: 12n, + gasUsed: 345n, + logs: [validatorJoinLog()], + ...overrides, +}); + +const baseChain = { + id: 1, + name: "test", + nativeCurrency: {name: "GEN", symbol: "GEN", decimals: 18}, + rpcUrls: {default: {http: ["http://127.0.0.1"]}}, + isStudio: false, + stakingContract: {address: STAKING_ADDRESS}, +}; + +// Local-key harness (byte-for-byte regression anchor for the sign+sendRaw lane). +const makeLocalHarness = () => { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const client = { + account: {address: ACCOUNT_ADDRESS, type: "local", signTransaction}, + chain: baseChain, + }; + const publicClient = { + call: vi.fn().mockResolvedValue("0x"), + estimateGas: vi.fn().mockResolvedValue(21000n), + getTransactionCount: vi.fn().mockResolvedValue(7), + prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), + sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + readContract: vi.fn(), + }; + return {actions: stakingActions(client as any, publicClient as any), client, publicClient, signTransaction}; +}; + +// Provider harness: Address-only account (type: "json-rpc"). signTransaction is +// attached deliberately to prove the discriminator is account.type, not its presence. +const makeProviderHarness = () => { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const request = vi.fn().mockImplementation(async ({method}: any) => { + if (method === "eth_gasPrice") return GAS_PRICE_HEX; + if (method === "eth_sendTransaction") return MOCK_TX_HASH; + throw new Error(`Unexpected request: ${method}`); + }); + const client = { + account: {address: ACCOUNT_ADDRESS, type: "json-rpc", signTransaction}, + chain: baseChain, + request, + }; + const publicClient = { + call: vi.fn().mockResolvedValue("0x"), + estimateGas: vi.fn().mockResolvedValue(21000n), + getTransactionCount: vi.fn().mockResolvedValue(7), + prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), + sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + readContract: vi.fn(), + }; + return {actions: stakingActions(client as any, publicClient as any), client, publicClient, request, signTransaction}; +}; + +const sentTxParams = (request: ReturnType["request"]) => { + const call = request.mock.calls.find(([args]: any) => args.method === "eth_sendTransaction"); + return call![0].params[0]; +}; + +describe("stakingActions local lane", () => { + it("validatorJoin encodes the call, decodes the ValidatorJoin event, and returns the full shape", async () => { + const {actions, publicClient, signTransaction} = makeLocalHarness(); + + const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + + // Encoding routed to the staking contract with msg.value = stake amount. + expect(publicClient.call.mock.calls[0][0].to).toBe(STAKING_ADDRESS); + expect(publicClient.call.mock.calls[0][0].value).toBe(parseEther("2")); + expect(decodeFunctionData({abi: STAKING_ABI, data: publicClient.call.mock.calls[0][0].data})).toEqual({ + functionName: "validatorJoin", + args: [OPERATOR_ADDRESS], + }); + + // Local sign+sendRaw path. + expect(signTransaction).toHaveBeenCalledTimes(1); + expect(publicClient.sendRawTransaction).toHaveBeenCalledWith({serializedTransaction: "0xsigned"}); + + expect(result).toEqual({ + transactionHash: MOCK_TX_HASH, + blockNumber: 12n, + gasUsed: 345n, + validatorWallet: VALIDATOR_WALLET_ADDRESS, + operator: OPERATOR_ADDRESS, + amount: "2 GEN", + amountRaw: parseEther("2"), + }); + }); +}); + +describe("stakingActions provider lane (Address-only)", () => { + it("validatorDeposit (payable) routes through eth_sendTransaction with msg.value", async () => { + const {actions, request, publicClient, signTransaction} = makeProviderHarness(); + + const result = await actions.validatorDeposit({validator: VALIDATOR_WALLET_ADDRESS, amount: "5gen"}); + + const params = sentTxParams(request); + expect(params.from).toBe(ACCOUNT_ADDRESS); + expect(params.to).toBe(VALIDATOR_WALLET_ADDRESS); + expect(decodeFunctionData({abi: VALIDATOR_WALLET_ABI, data: params.data})).toEqual({ + functionName: "validatorDeposit", + args: undefined, + }); + expect(params.type).toBe("0x0"); + expect(params.gas).toBe(`0x${(42000).toString(16)}`); + expect(params.gasPrice).toBe(GAS_PRICE_HEX); + // Payable: value is present as a hex quantity. + expect(params.value).toBe(`0x${parseEther("5").toString(16)}`); + + // Local-lane primitives untouched. + expect(signTransaction).not.toHaveBeenCalled(); + expect(publicClient.sendRawTransaction).not.toHaveBeenCalled(); + expect(publicClient.getTransactionCount).not.toHaveBeenCalled(); + expect(publicClient.prepareTransactionRequest).not.toHaveBeenCalled(); + + expect(result).toEqual({ + transactionHash: MOCK_TX_HASH, + blockNumber: 12n, + gasUsed: 345n, + }); + }); + + it("delegatorExit (non-payable) routes through eth_sendTransaction with value omitted", async () => { + const {actions, request} = makeProviderHarness(); + + await actions.delegatorExit({validator: VALIDATOR_WALLET_ADDRESS, shares: "42"}); + + const params = sentTxParams(request); + expect(params.to).toBe(STAKING_ADDRESS); + expect(decodeFunctionData({abi: STAKING_ABI, data: params.data})).toEqual({ + functionName: "delegatorExit", + args: [VALIDATOR_WALLET_ADDRESS, 42n], + }); + expect(params.value).toBeUndefined(); + }); + + it("validatorJoin decodes the ValidatorJoin event off the provider-returned hash", async () => { + const {actions, request, signTransaction} = makeProviderHarness(); + + const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + + // Sent via the provider lane, not signed locally. + expect(sentTxParams(request).to).toBe(STAKING_ADDRESS); + expect(signTransaction).not.toHaveBeenCalled(); + + // Event decode still works because the follow-up getTransactionReceipt uses + // the same hash the provider returned. + expect(result).toEqual({ + transactionHash: MOCK_TX_HASH, + blockNumber: 12n, + gasUsed: 345n, + validatorWallet: VALIDATOR_WALLET_ADDRESS, + operator: OPERATOR_ADDRESS, + amount: "2 GEN", + amountRaw: parseEther("2"), + }); + }); + + it("still runs the preflight and throws before sending on a would-revert", async () => { + const {actions, publicClient, request} = makeProviderHarness(); + publicClient.call.mockRejectedValueOnce(new Error("boom")); + + await expect(actions.delegatorExit({validator: VALIDATOR_WALLET_ADDRESS, shares: "1"})).rejects.toThrow( + /Transaction would revert/, + ); + expect(request).not.toHaveBeenCalledWith(expect.objectContaining({method: "eth_sendTransaction"})); + }); + + it("throws when the mined receipt is reverted", async () => { + const {actions, publicClient} = makeProviderHarness(); + publicClient.waitForTransactionReceipt.mockResolvedValueOnce(makeReceipt({status: "reverted"})); + + await expect(actions.delegatorExit({validator: VALIDATOR_WALLET_ADDRESS, shares: "1"})).rejects.toThrow( + /Transaction reverted/, + ); + }); + + it("sends without gasPrice when eth_gasPrice rejects", async () => { + const {actions, request} = makeProviderHarness(); + request.mockImplementation(async ({method}: any) => { + if (method === "eth_gasPrice") throw new Error("no gas price"); + if (method === "eth_sendTransaction") return MOCK_TX_HASH; + throw new Error(`Unexpected request: ${method}`); + }); + + await actions.delegatorExit({validator: VALIDATOR_WALLET_ADDRESS, shares: "1"}); + + expect(sentTxParams(request).gasPrice).toBeUndefined(); + }); +}); diff --git a/tests/vesting-actions.test.ts b/tests/vesting-actions.test.ts index 6d03546..ec60a1f 100644 --- a/tests/vesting-actions.test.ts +++ b/tests/vesting-actions.test.ts @@ -343,3 +343,130 @@ describe("vestingActions", () => { }); }); }); + +// Provider lane: Address-only account (type: "json-rpc"). The connected wallet +// manages nonce + signing, so writes route through client.request +// eth_sendTransaction instead of the local sign+sendRaw path. +const GAS_PRICE_HEX = "0x3b9aca00"; + +const makeProviderHarness = () => { + // signTransaction is attached but MUST be ignored: the discriminator is + // account.type === "local", never presence of signTransaction. + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const request = vi.fn().mockImplementation(async ({method}: any) => { + if (method === "eth_gasPrice") return GAS_PRICE_HEX; + if (method === "eth_sendTransaction") return MOCK_TX_HASH; + throw new Error(`Unexpected request: ${method}`); + }); + const client = { + account: {address: ACCOUNT_ADDRESS, type: "json-rpc", signTransaction}, + chain: { + id: 1, + name: "test", + nativeCurrency: {name: "GEN", symbol: "GEN", decimals: 18}, + rpcUrls: {default: {http: ["http://127.0.0.1"]}}, + isStudio: false, + consensusMainContract: {address: CONSENSUS_MAIN_ADDRESS, abi: [], bytecode: "0x"}, + }, + request, + }; + const publicClient = { + call: vi.fn().mockResolvedValue("0x"), + estimateGas: vi.fn().mockResolvedValue(21000n), + getTransactionCount: vi.fn().mockResolvedValue(7), + prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), + sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + readContract: vi.fn(), + }; + + return { + actions: vestingActions(client as any, publicClient as any), + client, + publicClient, + request, + signTransaction, + }; +}; + +const sentTxParams = (request: ReturnType["request"]) => { + const call = request.mock.calls.find(([args]: any) => args.method === "eth_sendTransaction"); + return call![0].params[0]; +}; + +describe("vestingActions provider lane (Address-only)", () => { + it("routes writes through eth_sendTransaction with the expected params", async () => { + const {actions, request, publicClient, signTransaction} = makeProviderHarness(); + + await actions.vestingDelegatorExit({vesting: VESTING_ADDRESS, validator: VALIDATOR_ADDRESS, shares: "42"}); + + const params = sentTxParams(request); + expect(params.from).toBe(ACCOUNT_ADDRESS); + expect(params.to).toBe(VESTING_ADDRESS); + expect(decodeFunctionData({abi: VESTING_ABI, data: params.data})).toEqual({ + functionName: "vestingDelegatorExit", + args: [VALIDATOR_ADDRESS, 42n], + }); + expect(params.type).toBe("0x0"); + // gasLimit = estimateGas(21000) * 2 buffer = 42000 = 0xa410 + expect(params.gas).toBe(`0x${(42000).toString(16)}`); + expect(params.gasPrice).toBe(GAS_PRICE_HEX); + // Vesting writes carry no msg.value (amounts are ABI args), so value is omitted. + expect(params.value).toBeUndefined(); + + // Local-lane primitives must NOT be touched on the provider path. + expect(signTransaction).not.toHaveBeenCalled(); + expect(publicClient.sendRawTransaction).not.toHaveBeenCalled(); + expect(publicClient.getTransactionCount).not.toHaveBeenCalled(); + expect(publicClient.prepareTransactionRequest).not.toHaveBeenCalled(); + }); + + it("returns the same receipt-derived shape as the local lane", async () => { + const {actions} = makeProviderHarness(); + + const result = await actions.vestingWithdraw({vesting: VESTING_ADDRESS, amount: "1gen"}); + + expect(result).toMatchObject({ + transactionHash: MOCK_TX_HASH, + blockNumber: 12n, + gasUsed: 345n, + vesting: VESTING_ADDRESS, + beneficiary: ACCOUNT_ADDRESS, + amount: "1 GEN", + amountRaw: parseEther("1"), + }); + }); + + it("still runs the preflight and throws before sending on a would-revert", async () => { + const {actions, publicClient, request} = makeProviderHarness(); + publicClient.call.mockRejectedValueOnce(new Error("boom")); + + await expect( + actions.vestingDelegatorClaim({vesting: VESTING_ADDRESS, validator: VALIDATOR_ADDRESS}), + ).rejects.toThrow(/Transaction would revert/); + + expect(request).not.toHaveBeenCalledWith(expect.objectContaining({method: "eth_sendTransaction"})); + }); + + it("throws when the mined receipt is reverted", async () => { + const {actions, publicClient} = makeProviderHarness(); + publicClient.waitForTransactionReceipt.mockResolvedValueOnce({...makeReceipt(), status: "reverted"}); + + await expect( + actions.vestingDelegatorClaim({vesting: VESTING_ADDRESS, validator: VALIDATOR_ADDRESS}), + ).rejects.toThrow(/Transaction reverted/); + }); + + it("sends without gasPrice when eth_gasPrice rejects", async () => { + const {actions, request} = makeProviderHarness(); + request.mockImplementation(async ({method}: any) => { + if (method === "eth_gasPrice") throw new Error("no gas price"); + if (method === "eth_sendTransaction") return MOCK_TX_HASH; + throw new Error(`Unexpected request: ${method}`); + }); + + await actions.vestingDelegatorClaim({vesting: VESTING_ADDRESS, validator: VALIDATOR_ADDRESS}); + + expect(sentTxParams(request).gasPrice).toBeUndefined(); + }); +}); From 50a936c4f0e436739851c8a4c47badcdd8c588dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 23 Jul 2026 11:05:26 +0100 Subject: [PATCH 14/25] feat(calldata)!: move method-call key from "method" to "" (empty string) (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.6 genvm-manager renamed the method-call calldata map key from "method" to "" (empty string). The empty key sorts first in canonical calldata map encoding (keys ordered by unicode-codepoint array; the empty array precedes any non-empty key), so the method name becomes a prefix of the encoded binary calldata. makeCalldataObject now writes the method name under ret[""]. The decoder is unchanged: it builds a generic Map from raw key bytes and already reads whatever key is present. No back-compat toggle — there is no calldata versioning mechanism in this repo, so this is a hard cutover. The genvm-manager still auto-remaps "method" -> "" but logs an error each time; this migration stops that error. BREAKING CHANGE: method-call calldata now uses the "" key instead of "method". Encoded bytes for every write/read/view/schema call change. Peers must run the v0.6 genvm-manager (or a node/studio build that expects the "" key). --- src/abi/calldata/encoder.ts | 4 +++- tests/calldata.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/calldata.test.ts diff --git a/src/abi/calldata/encoder.ts b/src/abi/calldata/encoder.ts index 63a792d..b3395db 100644 --- a/src/abi/calldata/encoder.ts +++ b/src/abi/calldata/encoder.ts @@ -142,6 +142,8 @@ export function encode(data: CalldataEncodable): Uint8Array { } // Constructs a calldata object for contract calls, omitting empty args/kwargs for compactness. +// Method names use the empty-string key, which sorts first in canonical calldata encoding +// and makes the method name a prefix of the encoded binary. export function makeCalldataObject( method: string | undefined, args: CalldataEncodable[] | undefined, @@ -150,7 +152,7 @@ export function makeCalldataObject( let ret: {[key: string]: CalldataEncodable} = {}; if (method) { - ret["method"] = method; + ret[""] = method; } if (args && args.length > 0) { diff --git a/tests/calldata.test.ts b/tests/calldata.test.ts new file mode 100644 index 0000000..9b798bc --- /dev/null +++ b/tests/calldata.test.ts @@ -0,0 +1,35 @@ +import {describe, expect, it} from "vitest"; +import {calldata} from "@/abi"; +import type {CalldataEncodable} from "@/types/calldata"; + +function decodeMap(data: Uint8Array): Map { + const decoded = calldata.decode(data); + expect(decoded).toBeInstanceOf(Map); + return decoded as Map; +} + +describe("calldata method-call encoding", () => { + it("encodes method calls with the empty-string method key", () => { + const encoded = calldata.encode(calldata.makeCalldataObject("my_method", [1n, 2n], undefined)); + const decoded = decodeMap(encoded); + + expect(decoded.has("")).toBe(true); + expect(decoded.get("")).toBe("my_method"); + expect(decoded.has("method")).toBe(false); + }); + + it("orders the empty method key before args in the encoded map body", () => { + const encoded = calldata.encode(calldata.makeCalldataObject("my_method", [1n, 2n], undefined)); + const decoded = decodeMap(encoded); + + expect(Array.from(decoded.entries())[0]).toEqual(["", "my_method"]); + }); + + it("orders the empty method key before kwargs in the encoded map body", () => { + const encoded = calldata.encode(calldata.makeCalldataObject("my_method", undefined, {count: 1n})); + const decoded = decodeMap(encoded); + + expect(Array.from(decoded.entries())[0]).toEqual(["", "my_method"]); + expect(decoded.has("kwargs")).toBe(true); + }); +}); From 0b7e3bd63aeab25beaf63c14956af6a458f56b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Sun, 26 Jul 2026 18:56:52 +0100 Subject: [PATCH 15/25] fix: resolve triggered transaction IDs from decision receipts (#202) --- src/transactions/actions.ts | 39 ++++++++++++++++++++++++++---- tests/transactions.test.ts | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 491067a..96b92ea 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -189,22 +189,51 @@ export const transactionActions = (client: GenLayerClient, public const proposalBlock = BigInt(tx.readStateBlockRange?.proposalBlock ?? "0"); if (proposalBlock === BigInt(0)) return []; - const scanRange = BigInt(100); + const scanRange = BigInt(10_000); const latestBlock = await publicClient.getBlockNumber(); const toBlock = proposalBlock + scanRange < latestBlock ? proposalBlock + scanRange : latestBlock; const consensusAddress = client.chain.consensusMainContract?.address as Address; const internalMessageProcessedTopic = keccak256(stringToBytes("InternalMessageProcessed(bytes32,address,address)")); - - const logs = await publicClient.getLogs({ + const transactionAcceptedTopic = keccak256(stringToBytes("TransactionAccepted(bytes32)")); + const transactionFinalizedTopic = keccak256(stringToBytes("TransactionFinalized(bytes32)")); + + // InternalMessageProcessed indexes the child transaction ID, not its + // parent. Find the EVM transactions that decided the parent first, then + // inspect their receipts for the child-message events emitted alongside + // that decision. + const decisionLogs = await publicClient.getLogs({ address: consensusAddress, event: undefined, fromBlock: proposalBlock, toBlock, - topics: [internalMessageProcessedTopic, hash], + topics: [[transactionAcceptedTopic, transactionFinalizedTopic], hash], } as any); - return logs.map(log => log.topics[1] as TransactionHash).filter(Boolean); + const decisionTransactionHashes = [ + ...new Set(decisionLogs.map(log => log.transactionHash).filter(Boolean)), + ]; + const receipts = await Promise.all( + decisionTransactionHashes.map(transactionHash => + publicClient.getTransactionReceipt({hash: transactionHash!}), + ), + ); + const normalizedConsensusAddress = consensusAddress.toLowerCase(); + + return [ + ...new Set( + receipts.flatMap(receipt => + receipt.logs + .filter( + log => + log.address.toLowerCase() === normalizedConsensusAddress && + log.topics[0] === internalMessageProcessedTopic, + ) + .map(log => log.topics[1] as TransactionHash) + .filter(Boolean), + ), + ), + ]; }, /** Fetches the full execution trace including return data, stdout, stderr, and GenVM logs. */ debugTraceTransaction: async ({hash, round = 0}: {hash: TransactionHash; round?: number}): Promise => { diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 6ad9094..6c343c0 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -13,6 +13,7 @@ import { receiptActions, transactionActions, isSuccessful } from "../src/transac import { decodeTransaction, simplifyTransactionReceipt } from "../src/transactions/decoders"; import { localnet } from "../src/chains/localnet"; import type { GenLayerRawTransaction } from "../src/types/transactions"; +import {keccak256, stringToBytes} from "viem"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -359,6 +360,53 @@ const makeRawTx = (overrides: Record = {}): GenLayerRawTransact ...overrides, }); +describe("getTriggeredTransactionIds", () => { + it("finds child transaction IDs in the parent decision receipt", async () => { + const parentHash = ("0x" + "11".repeat(32)) as any; + const childHash = ("0x" + "22".repeat(32)) as any; + const decisionHash = ("0x" + "33".repeat(32)) as any; + const consensusAddress = "0x0000000000000000000000000000000000000010"; + const internalMessageTopic = keccak256( + stringToBytes("InternalMessageProcessed(bytes32,address,address)"), + ); + const readContract = vi + .fn() + .mockResolvedValueOnce(makeRawTx({readStateBlockRange: {proposalBlock: 100n}})) + .mockResolvedValueOnce([{txExecutionResult: 1n}, []]); + const getLogs = vi.fn().mockResolvedValue([{transactionHash: decisionHash}]); + const getTransactionReceipt = vi.fn().mockResolvedValue({ + logs: [ + { + address: consensusAddress, + topics: [internalMessageTopic, childHash], + }, + ], + }); + const publicClient = { + readContract, + getBlockNumber: vi.fn().mockResolvedValue(200n), + getLogs, + getTransactionReceipt, + } as any; + const client = { + chain: { + isStudio: false, + consensusDataContract: {address: consensusAddress, abi: []}, + consensusMainContract: {address: consensusAddress, abi: []}, + }, + } as any; + + const result = await transactionActions(client, publicClient).getTriggeredTransactionIds({ + hash: parentHash, + }); + + expect(result).toEqual([childHash]); + expect(getLogs.mock.calls[0][0].topics[1]).toBe(parentHash); + expect(Array.isArray(getLogs.mock.calls[0][0].topics[0])).toBe(true); + expect(getTransactionReceipt).toHaveBeenCalledWith({hash: decisionHash}); + }); +}); + describe("decodeTransaction", () => { it("should decode standard field names (localnet/asimov)", () => { const tx = makeRawTx(); From 6f1273885567ff5cda77b7459edfd6666c5859d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Sun, 9 Aug 2026 16:11:15 +0100 Subject: [PATCH 16/25] fix: support proof-bearing validator joins (#206) * fix: support proof-bearing vesting validator joins * fix: require operator proofs for validator joins --- README.md | 8 +- docs/api-references/index.md | 8 +- docs/api-references/staking.md | 2 +- .../types.Interface.ValidatorJoinOptions.md | 13 ++- src/abi/staking.ts | 12 +- src/abi/vesting.ts | 12 +- src/index.ts | 15 ++- src/staking/actions.ts | 68 ++++++++--- src/types/staking.ts | 4 +- src/types/vesting.ts | 23 +++- src/vesting/actions.ts | 40 ++++++- src/vesting/operatorRegistration.ts | 110 ++++++++++++++++++ src/vesting/validator.ts | 12 ++ tests/operator-registration.test.ts | 73 ++++++++++++ tests/staking-actions.test.ts | 45 ++++++- tests/vesting-actions.test.ts | 30 ++++- 16 files changed, 426 insertions(+), 49 deletions(-) create mode 100644 src/vesting/operatorRegistration.ts create mode 100644 tests/operator-registration.test.ts diff --git a/README.md b/README.md index 39934b9..fd9aa57 100644 --- a/README.md +++ b/README.md @@ -437,8 +437,12 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires account with funds) -const result = await client.validatorJoin({ amount: "42000gen" }); +// Join as validator (requires an owner account with funds and the operator key) +const registration = await createOperatorRegistration({ + privateKey: operatorPrivateKey, + ...(await client.getValidatorRegistrationContext()), +}); +const result = await client.validatorJoin({ amount: "42000gen", registration }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 232f6bc..c08ef14 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -204,8 +204,12 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires account with funds) -const result = await client.validatorJoin({ amount: "42000gen" }); +// Join as validator (requires an owner account with funds and the operator key) +const registration = await createOperatorRegistration({ + privateKey: operatorPrivateKey, + ...(await client.getValidatorRegistrationContext()), +}); +const result = await client.validatorJoin({ amount: "42000gen", registration }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/staking.md b/docs/api-references/staking.md index 17f05a8..da70e2c 100644 --- a/docs/api-references/staking.md +++ b/docs/api-references/staking.md @@ -9,7 +9,7 @@ Joins as a validator with the specified stake amount. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | amount | `bigint \| string` | yes | | -| operator | `Address` | no | | +| registration | `OperatorRegistrationProof` | yes | | **Returns:** `ValidatorJoinResult` diff --git a/docs/api-references/types.Interface.ValidatorJoinOptions.md b/docs/api-references/types.Interface.ValidatorJoinOptions.md index 3406f19..20518ac 100644 --- a/docs/api-references/types.Interface.ValidatorJoinOptions.md +++ b/docs/api-references/types.Interface.ValidatorJoinOptions.md @@ -1,6 +1,6 @@ # Interface: ValidatorJoinOptions -Defined in: [types/staking.ts:152](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L152) +Defined in: `src/types/staking.ts` ## Properties @@ -8,12 +8,15 @@ Defined in: [types/staking.ts:152](https://github.com/genlayerlabs/genlayer-js/b > **amount**: `string` \| `bigint` -Defined in: [types/staking.ts:153](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L153) +Defined in: `src/types/staking.ts` *** -### operator? +### registration -> `optional` **operator?**: `` `0x${string}` `` +> **registration**: `OperatorRegistrationProof` -Defined in: [types/staking.ts:154](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L154) +Proof-of-possession package bound to this chain, the validator wallet factory, +and the joining owner address. + +Defined in: `src/types/staking.ts` diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 4d25966..ad6e6a7 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -1249,14 +1249,10 @@ export const STAKING_ABI = [ name: "validatorJoin", type: "function", stateMutability: "payable", - inputs: [{name: "_operator", type: "address"}], - outputs: [{name: "", type: "address"}], - }, - { - name: "validatorJoin", - type: "function", - stateMutability: "payable", - inputs: [], + inputs: [ + {name: "_operatorPubKey", type: "uint256[2]"}, + {name: "_possessionProof", type: "bytes"}, + ], outputs: [{name: "", type: "address"}], }, { diff --git a/src/abi/vesting.ts b/src/abi/vesting.ts index 0dc7adc..f874813 100644 --- a/src/abi/vesting.ts +++ b/src/abi/vesting.ts @@ -135,7 +135,17 @@ export const VESTING_ABI = [ {name: "vestingDelegatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingDelegatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingDelegatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}], outputs: []}, - {name: "vestingValidatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "operator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, + { + name: "vestingValidatorJoin", + type: "function", + stateMutability: "nonpayable", + inputs: [ + {name: "operatorPubKey", type: "uint256[2]"}, + {name: "possessionProof", type: "bytes"}, + {name: "amount", type: "uint256"}, + ], + outputs: [], + }, {name: "vestingValidatorDeposit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingValidatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingValidatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}], outputs: []}, diff --git a/src/index.ts b/src/index.ts index fe3a8f9..7e0b13d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,18 @@ export * as abi from "./abi"; export * from "./transactions/fees"; export {isSuccessful} from "./transactions/actions"; export {parseStakingAmount, formatStakingAmount} from "./staking"; -export {vestingActions} from "./vesting"; +export { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorAddressFromPublicKey, + operatorPossessionMessage, + verifyOperatorRegistration, + vestingActions, +} from "./vesting"; +export type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "./vesting"; export {buildGenVmPositionalArgs} from "./contracts/schema"; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index a96f86c..d6eea76 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,7 +1,9 @@ -import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError} from "viem"; +import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; +import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; +import {operatorAddressFromPublicKey, verifyOperatorRegistration} from "@/vesting/operatorRegistration"; import { ValidatorInfo, ValidatorIdentity, @@ -32,6 +34,7 @@ type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; +const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; // Combined ABI for error decoding (both staking and validator wallet errors) const COMBINED_ERROR_ABI = [...STAKING_ABI, ...VALIDATOR_WALLET_ABI]; @@ -236,22 +239,59 @@ export const stakingActions = ( }); }; + const getValidatorRegistrationContext = async () => { + if (!client.account) { + throw new Error("Account is required to resolve validator registration context."); + } + + const consensusMain = client.chain.consensusMainContract; + if (!consensusMain?.address || consensusMain.address === zeroAddress) { + throw new Error("Cannot resolve ValidatorWalletFactory without a consensus main contract."); + } + + const [addressManager, chainId] = await Promise.all([ + publicClient.readContract({ + address: consensusMain.address as ViemAddress, + abi: CONSENSUS_ADDRESS_MANAGER_ABI, + functionName: "getAddressManager", + }) as Promise
, + publicClient.getChainId(), + ]); + const registrar = await publicClient.readContract({ + address: addressManager as ViemAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddress", + args: [VALIDATOR_WALLET_FACTORY_KEY], + }) as Address; + + if (!registrar || registrar === zeroAddress) { + throw new Error( + `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, + ); + } + + return { + registrar, + owner: client.account.address as Address, + chainId: BigInt(chainId), + }; + }; + return { /** Joins as a validator with the specified stake amount. */ validatorJoin: async (options: ValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); const stakingAddress = getStakingAddress(); - - const data = options.operator - ? encodeFunctionData({ - abi: STAKING_ABI, - functionName: "validatorJoin", - args: [options.operator as ViemAddress], - }) - : encodeFunctionData({ - abi: STAKING_ABI, - functionName: "validatorJoin", - }); + const context = await getValidatorRegistrationContext(); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error("Operator registration proof does not match the owner, registrar, chain, or public key."); + } + const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); + const data = encodeFunctionData({ + abi: STAKING_ABI, + functionName: "validatorJoin", + args: [options.registration.operatorPubKey, options.registration.possessionProof], + }); const result = await executeWrite({to: stakingAddress, data, value: amount}); const receipt = await publicClient.getTransactionReceipt({hash: result.transactionHash}); @@ -283,11 +323,13 @@ export const stakingActions = ( blockNumber: receipt.blockNumber, gasUsed: receipt.gasUsed, validatorWallet: validatorWallet!, - operator: options.operator || (client.account!.address as Address), + operator, amount: formatStakingAmount(amount), amountRaw: amount, }; }, + /** Resolves the registrar, owner, and chain binding required to create an operator proof. */ + getValidatorRegistrationContext, /** * Adds additional self-stake to an active validator position. The diff --git a/src/types/staking.ts b/src/types/staking.ts index 9d9c657..237ae4a 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -1,6 +1,7 @@ import {Address} from "./accounts"; import {GetContractReturnType, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress} from "viem"; import {STAKING_ABI} from "@/abi/staking"; +import type {OperatorRegistrationContext, OperatorRegistrationProof} from "./vesting"; type WalletClientWithAccount = Client; @@ -155,7 +156,7 @@ export interface DelegatorJoinResult extends StakingTransactionResult { export interface ValidatorJoinOptions { amount: bigint | string; - operator?: Address; + registration: OperatorRegistrationProof; } export interface ValidatorDepositOptions { @@ -211,6 +212,7 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; + getValidatorRegistrationContext: () => Promise; validatorDeposit: (options: ValidatorDepositOptions) => Promise; validatorExit: (options: ValidatorExitOptions) => Promise; validatorClaim: (options?: ValidatorClaimOptions) => Promise; diff --git a/src/types/vesting.ts b/src/types/vesting.ts index 1c3d3ae..5e32ee2 100644 --- a/src/types/vesting.ts +++ b/src/types/vesting.ts @@ -1,4 +1,4 @@ -import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, PublicClient, Transport} from "viem"; +import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, Hex, PublicClient, Transport} from "viem"; import {Address} from "./accounts"; import {VESTING_ABI, VESTING_FACTORY_ABI} from "@/abi/vesting"; @@ -14,6 +14,24 @@ export type VestingFactoryContract = GetContractReturnType Promise
; vestingFactory: (vesting: Address) => Promise
; vestingAddressManager: (vesting: Address) => Promise
; + getVestingValidatorRegistrationContext: (vesting: Address) => Promise; vestingTotalAmount: (vesting: Address) => Promise; vestingStartDate: (vesting: Address) => Promise; vestingCliffDuration: (vesting: Address) => Promise; diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts index 5e2cef1..86020f6 100644 --- a/src/vesting/actions.ts +++ b/src/vesting/actions.ts @@ -41,12 +41,17 @@ import { VestingWithdrawResult, } from "@/types/vesting"; import {formatStakingAmount, parseStakingAmount} from "@/staking/utils"; +import { + operatorAddressFromPublicKey, + verifyOperatorRegistration, +} from "./operatorRegistration"; type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; const VESTING_FACTORY_KEY = "VestingFactory"; +const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; const COMBINED_ERROR_ABI = [...VESTING_ABI, ...VESTING_FACTORY_ABI, ...ADDRESS_MANAGER_ABI, ...STAKING_ABI] as const; function extractRevertReason(err: unknown): string { @@ -268,6 +273,31 @@ export const vestingActions = ( return factory; }; + const getVestingValidatorRegistrationContext = async (vesting: Address) => { + const [addressManager, chainId] = await Promise.all([ + readVesting
(vesting, "addressManager"), + publicClient.getChainId(), + ]); + const registrar = await publicClient.readContract({ + address: addressManager as ViemAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddress", + args: [VALIDATOR_WALLET_FACTORY_KEY], + }) as Address; + + if (!registrar || registrar === zeroAddress) { + throw new Error( + `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, + ); + } + + return { + registrar, + owner: vesting, + chainId: BigInt(chainId), + }; + }; + const getVestingContract = (vesting: Address): VestingContract => { return getContract({ address: vesting as ViemAddress, @@ -329,17 +359,22 @@ export const vestingActions = ( /** Creates a validator wallet and self-stakes vesting-held tokens. Must be called by the vesting beneficiary. */ vestingValidatorJoin: async (options: VestingValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); + const context = await getVestingValidatorRegistrationContext(options.vesting); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error("Operator registration proof does not match the vesting, registrar, chain, or public key."); + } + const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); const data = encodeFunctionData({ abi: VESTING_ABI, functionName: "vestingValidatorJoin", - args: [options.operator as ViemAddress, amount], + args: [options.registration.operatorPubKey, options.registration.possessionProof, amount], }); const result = await executeWrite({to: options.vesting as ViemAddress, data}); return { ...result, vesting: options.vesting, - operator: options.operator, + operator, beneficiary: client.account!.address as Address, amount: formatStakingAmount(amount), amountRaw: amount, @@ -610,6 +645,7 @@ export const vestingActions = ( vestingRevoker: (vesting: Address): Promise
=> readVesting
(vesting, "revoker"), vestingFactory: (vesting: Address): Promise
=> readVesting
(vesting, "factory"), vestingAddressManager: (vesting: Address): Promise
=> readVesting
(vesting, "addressManager"), + getVestingValidatorRegistrationContext, vestingTotalAmount: (vesting: Address): Promise => readVesting(vesting, "totalAmount"), vestingStartDate: (vesting: Address): Promise => readVesting(vesting, "startDate"), vestingCliffDuration: (vesting: Address): Promise => readVesting(vesting, "cliffDuration"), diff --git a/src/vesting/operatorRegistration.ts b/src/vesting/operatorRegistration.ts new file mode 100644 index 0000000..d7b4442 --- /dev/null +++ b/src/vesting/operatorRegistration.ts @@ -0,0 +1,110 @@ +import { + concatHex, + encodeAbiParameters, + getAddress, + hexToBigInt, + keccak256, + recoverMessageAddress, + sliceHex, + stringToHex, + toHex, + type Address, + type Hex, +} from "viem"; +import {privateKeyToAccount, publicKeyToAddress} from "viem/accounts"; +import type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "@/types/vesting"; + +export type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "@/types/vesting"; + +export const OPERATOR_REGISTRATION_DOMAIN = keccak256( + stringToHex("GenLayer/operatorPubKey/proof-of-possession/v1"), +); + +export function operatorAddressFromPublicKey(operatorPubKey: OperatorPublicKey): Address { + const publicKey = concatHex([ + "0x04", + toHex(operatorPubKey[0], {size: 32}), + toHex(operatorPubKey[1], {size: 32}), + ]); + return getAddress(publicKeyToAddress(publicKey)); +} + +export function operatorPossessionMessage( + operatorPubKey: OperatorPublicKey, + context: OperatorRegistrationContext, +): Hex { + return keccak256( + encodeAbiParameters( + [ + {type: "bytes32"}, + {type: "uint256"}, + {type: "address"}, + {type: "address"}, + {type: "uint256"}, + {type: "uint256"}, + ], + [ + OPERATOR_REGISTRATION_DOMAIN, + context.chainId, + context.registrar, + context.owner, + operatorPubKey[0], + operatorPubKey[1], + ], + ), + ); +} + +/** + * Builds the proof package consumed by proof-bearing validator registration. + * The private key is used only in memory and is never included in the result. + */ +export async function createOperatorRegistration( + options: CreateOperatorRegistrationOptions, +): Promise { + const account = privateKeyToAccount(options.privateKey); + const operatorPubKey: OperatorPublicKey = [ + hexToBigInt(sliceHex(account.publicKey, 1, 33)), + hexToBigInt(sliceHex(account.publicKey, 33, 65)), + ]; + const operator = operatorAddressFromPublicKey(operatorPubKey); + + if (operator !== getAddress(account.address)) { + throw new Error("Operator private key and public key derive different identities."); + } + + const possessionProof = await account.signMessage({ + message: {raw: operatorPossessionMessage(operatorPubKey, options)}, + }); + + return {operator, operatorPubKey, possessionProof}; +} + +/** Validates the key identity and the exact registrar/owner/chain-bound proof. */ +export async function verifyOperatorRegistration( + registration: OperatorRegistrationProof, + context: OperatorRegistrationContext, +): Promise { + try { + const operator = operatorAddressFromPublicKey(registration.operatorPubKey); + if (operator !== getAddress(registration.operator)) return false; + + const recovered = await recoverMessageAddress({ + message: {raw: operatorPossessionMessage(registration.operatorPubKey, context)}, + signature: registration.possessionProof, + }); + return getAddress(recovered) === operator; + } catch { + return false; + } +} diff --git a/src/vesting/validator.ts b/src/vesting/validator.ts index fa01a00..df40501 100644 --- a/src/vesting/validator.ts +++ b/src/vesting/validator.ts @@ -7,4 +7,16 @@ export type { VestingValidatorJoinResult, VestingValidatorSetIdentityOptions, VestingValidatorWalletOptions, + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, } from "@/types/vesting"; + +export { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorAddressFromPublicKey, + operatorPossessionMessage, + verifyOperatorRegistration, +} from "./operatorRegistration"; diff --git a/tests/operator-registration.test.ts b/tests/operator-registration.test.ts new file mode 100644 index 0000000..4a616a0 --- /dev/null +++ b/tests/operator-registration.test.ts @@ -0,0 +1,73 @@ +import {describe, expect, it} from "vitest"; +import {getAddress} from "viem"; +import {privateKeyToAccount} from "viem/accounts"; +import { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorPossessionMessage, + verifyOperatorRegistration, + type OperatorRegistrationContext, +} from "../src/vesting/operatorRegistration"; + +const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; +const OTHER_OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000003"; +const CONTEXT: OperatorRegistrationContext = { + registrar: "0x1111111111111111111111111111111111111111", + owner: "0x2222222222222222222222222222222222222222", + chainId: 61999n, +}; + +describe("operator registration", () => { + it("matches the consensus proof-of-possession vector", async () => { + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + ...CONTEXT, + }); + + expect(OPERATOR_REGISTRATION_DOMAIN).toBe( + "0x56a1f863be2956668ca2fd6b4010d6fde7a54f2b5a02d6c624a2bad7e5fd5ada", + ); + expect(registration.operator).toBe(getAddress("0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF")); + expect(registration.operatorPubKey).toEqual([ + 89565891926547004231252920425935692360644145829622209833684329913297188986597n, + 12158399299693830322967808612713398636155367887041628176798871954788371653930n, + ]); + expect(operatorPossessionMessage(registration.operatorPubKey, CONTEXT)).toBe( + "0x7823e1bdaf3a8cea679a7bafaf8ddc39c379ac690f35696328650c3a712f36e0", + ); + expect(registration.possessionProof).toBe( + "0x30cedc70f8ab478fbc1a13a3f36e7f6a10eed631f59db4c451e38fe6d94dc640586d7a3202471043dbad68a3850655d39114aaca647df5734b171f8db7e88f161c", + ); + await expect(verifyOperatorRegistration(registration, CONTEXT)).resolves.toBe(true); + }); + + it("rejects wrong-key and cross-domain proofs", async () => { + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + ...CONTEXT, + }); + const wrongKey = privateKeyToAccount(OTHER_OPERATOR_KEY); + const wrongKeyProof = await wrongKey.signMessage({ + message: {raw: operatorPossessionMessage(registration.operatorPubKey, CONTEXT)}, + }); + + await expect( + verifyOperatorRegistration({...registration, possessionProof: wrongKeyProof}, CONTEXT), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, { + ...CONTEXT, + registrar: "0x3333333333333333333333333333333333333333", + }), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, { + ...CONTEXT, + owner: "0x4444444444444444444444444444444444444444", + }), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, {...CONTEXT, chainId: CONTEXT.chainId + 1n}), + ).resolves.toBe(false); + }); +}); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index f58ce33..6465852 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -2,11 +2,16 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, encodeAbiParameters, encodeEventTopics, getAbiItem, parseEther} from "viem"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "../src/abi/staking"; import {stakingActions} from "../src/staking/actions"; +import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const STAKING_ADDRESS = "0x0000000000000000000000000000000000000044"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; -const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; +const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000066"; +const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000077"; +const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000088"; +const OPERATOR_PRIVATE_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; +const OPERATOR_ADDRESS = "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF"; const GAS_PRICE_HEX = "0x3b9aca00"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; @@ -35,8 +40,22 @@ const baseChain = { rpcUrls: {default: {http: ["http://127.0.0.1"]}}, isStudio: false, stakingContract: {address: STAKING_ADDRESS}, + consensusMainContract: {address: CONSENSUS_MAIN_ADDRESS}, }; +const makeRegistration = () => createOperatorRegistration({ + privateKey: OPERATOR_PRIVATE_KEY, + registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, + owner: ACCOUNT_ADDRESS, + chainId: BigInt(baseChain.id), +}); + +const readRegistrationContract = vi.fn().mockImplementation(async ({functionName}: any) => { + if (functionName === "getAddressManager") return ADDRESS_MANAGER_ADDRESS; + if (functionName === "getAddress") return VALIDATOR_WALLET_FACTORY_ADDRESS; + throw new Error(`Unexpected read: ${functionName}`); +}); + // Local-key harness (byte-for-byte regression anchor for the sign+sendRaw lane). const makeLocalHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -52,7 +71,8 @@ const makeLocalHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: vi.fn(), + readContract: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, signTransaction}; }; @@ -79,7 +99,8 @@ const makeProviderHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: vi.fn(), + readContract: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, request, signTransaction}; }; @@ -93,14 +114,15 @@ describe("stakingActions local lane", () => { it("validatorJoin encodes the call, decodes the ValidatorJoin event, and returns the full shape", async () => { const {actions, publicClient, signTransaction} = makeLocalHarness(); - const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + const registration = await makeRegistration(); + const result = await actions.validatorJoin({amount: "2gen", registration}); // Encoding routed to the staking contract with msg.value = stake amount. expect(publicClient.call.mock.calls[0][0].to).toBe(STAKING_ADDRESS); expect(publicClient.call.mock.calls[0][0].value).toBe(parseEther("2")); expect(decodeFunctionData({abi: STAKING_ABI, data: publicClient.call.mock.calls[0][0].data})).toEqual({ functionName: "validatorJoin", - args: [OPERATOR_ADDRESS], + args: [registration.operatorPubKey, registration.possessionProof], }); // Local sign+sendRaw path. @@ -117,6 +139,17 @@ describe("stakingActions local lane", () => { amountRaw: parseEther("2"), }); }); + + it("rejects a registration proof that is not bound to the joining owner and registrar", async () => { + const {actions, publicClient} = makeLocalHarness(); + const registration = await makeRegistration(); + + await expect(actions.validatorJoin({ + amount: "2gen", + registration: {...registration, possessionProof: "0x1234"}, + })).rejects.toThrow(/registration proof does not match/i); + expect(publicClient.call).not.toHaveBeenCalled(); + }); }); describe("stakingActions provider lane (Address-only)", () => { @@ -168,7 +201,7 @@ describe("stakingActions provider lane (Address-only)", () => { it("validatorJoin decodes the ValidatorJoin event off the provider-returned hash", async () => { const {actions, request, signTransaction} = makeProviderHarness(); - const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + const result = await actions.validatorJoin({amount: "2gen", registration: await makeRegistration()}); // Sent via the provider lane, not signed locally. expect(sentTxParams(request).to).toBe(STAKING_ADDRESS); diff --git a/tests/vesting-actions.test.ts b/tests/vesting-actions.test.ts index ec60a1f..05f9f9a 100644 --- a/tests/vesting-actions.test.ts +++ b/tests/vesting-actions.test.ts @@ -2,17 +2,19 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, parseEther, toHex, zeroAddress} from "viem"; import {VESTING_ABI} from "../src/abi/vesting"; import {vestingActions} from "../src/vesting/actions"; +import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const BENEFICIARY_ADDRESS = ACCOUNT_ADDRESS; const VESTING_ADDRESS = "0x0000000000000000000000000000000000000022"; const VALIDATOR_ADDRESS = "0x0000000000000000000000000000000000000033"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; -const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; const NEW_OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000bb"; const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000044"; const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000055"; const FACTORY_ADDRESS = "0x0000000000000000000000000000000000000066"; +const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000077"; +const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; const makeReceipt = () => ({ @@ -54,6 +56,7 @@ const makeHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async request => request), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; @@ -131,23 +134,39 @@ describe("vestingActions", () => { }); it("encodes vesting validator join and deposit without caller value", async () => { - const {actions, publicClient} = makeHarness(); + const {actions, client, publicClient} = makeHarness(); + client.chain.id = 999; + publicClient.readContract.mockImplementation(async ({address, functionName, args}: any) => { + if (address === VESTING_ADDRESS && functionName === "addressManager") return ADDRESS_MANAGER_ADDRESS; + if (address === ADDRESS_MANAGER_ADDRESS && functionName === "getAddress") { + expect(args).toEqual(["ValidatorWalletFactory"]); + return VALIDATOR_WALLET_FACTORY_ADDRESS; + } + throw new Error(`Unexpected read: ${functionName}`); + }); + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, + owner: VESTING_ADDRESS, + chainId: 1n, + }); const result = await actions.vestingValidatorJoin({ vesting: VESTING_ADDRESS, - operator: OPERATOR_ADDRESS, + registration, amount: "3gen", }); expect(publicClient.call.mock.calls[0][0].to).toBe(VESTING_ADDRESS); + expect(publicClient.getChainId).toHaveBeenCalledTimes(1); expect(publicClient.call.mock.calls[0][0].value).toBeUndefined(); expect(decodedWrite(publicClient)).toEqual({ functionName: "vestingValidatorJoin", - args: [OPERATOR_ADDRESS, parseEther("3")], + args: [registration.operatorPubKey, registration.possessionProof, parseEther("3")], }); expect(result).toMatchObject({ vesting: VESTING_ADDRESS, - operator: OPERATOR_ADDRESS, + operator: registration.operator, beneficiary: ACCOUNT_ADDRESS, amount: "3 GEN", amountRaw: parseEther("3"), @@ -377,6 +396,7 @@ const makeProviderHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; From 7666f0b73987aa75b7fcd1d62c2759364ce36824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 11 Aug 2026 12:08:12 +0100 Subject: [PATCH 17/25] Revert "fix: support proof-bearing validator joins (#206)" (#207) This reverts commit 6f1273885567ff5cda77b7459edfd6666c5859d0. --- README.md | 8 +- docs/api-references/index.md | 8 +- docs/api-references/staking.md | 2 +- .../types.Interface.ValidatorJoinOptions.md | 13 +-- src/abi/staking.ts | 12 +- src/abi/vesting.ts | 12 +- src/index.ts | 15 +-- src/staking/actions.ts | 68 +++-------- src/types/staking.ts | 4 +- src/types/vesting.ts | 23 +--- src/vesting/actions.ts | 40 +------ src/vesting/operatorRegistration.ts | 110 ------------------ src/vesting/validator.ts | 12 -- tests/operator-registration.test.ts | 73 ------------ tests/staking-actions.test.ts | 45 +------ tests/vesting-actions.test.ts | 30 +---- 16 files changed, 49 insertions(+), 426 deletions(-) delete mode 100644 src/vesting/operatorRegistration.ts delete mode 100644 tests/operator-registration.test.ts diff --git a/README.md b/README.md index fd9aa57..39934b9 100644 --- a/README.md +++ b/README.md @@ -437,12 +437,8 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires an owner account with funds and the operator key) -const registration = await createOperatorRegistration({ - privateKey: operatorPrivateKey, - ...(await client.getValidatorRegistrationContext()), -}); -const result = await client.validatorJoin({ amount: "42000gen", registration }); +// Join as validator (requires account with funds) +const result = await client.validatorJoin({ amount: "42000gen" }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/index.md b/docs/api-references/index.md index c08ef14..232f6bc 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -204,12 +204,8 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires an owner account with funds and the operator key) -const registration = await createOperatorRegistration({ - privateKey: operatorPrivateKey, - ...(await client.getValidatorRegistrationContext()), -}); -const result = await client.validatorJoin({ amount: "42000gen", registration }); +// Join as validator (requires account with funds) +const result = await client.validatorJoin({ amount: "42000gen" }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/staking.md b/docs/api-references/staking.md index da70e2c..17f05a8 100644 --- a/docs/api-references/staking.md +++ b/docs/api-references/staking.md @@ -9,7 +9,7 @@ Joins as a validator with the specified stake amount. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | amount | `bigint \| string` | yes | | -| registration | `OperatorRegistrationProof` | yes | | +| operator | `Address` | no | | **Returns:** `ValidatorJoinResult` diff --git a/docs/api-references/types.Interface.ValidatorJoinOptions.md b/docs/api-references/types.Interface.ValidatorJoinOptions.md index 20518ac..3406f19 100644 --- a/docs/api-references/types.Interface.ValidatorJoinOptions.md +++ b/docs/api-references/types.Interface.ValidatorJoinOptions.md @@ -1,6 +1,6 @@ # Interface: ValidatorJoinOptions -Defined in: `src/types/staking.ts` +Defined in: [types/staking.ts:152](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L152) ## Properties @@ -8,15 +8,12 @@ Defined in: `src/types/staking.ts` > **amount**: `string` \| `bigint` -Defined in: `src/types/staking.ts` +Defined in: [types/staking.ts:153](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L153) *** -### registration +### operator? -> **registration**: `OperatorRegistrationProof` +> `optional` **operator?**: `` `0x${string}` `` -Proof-of-possession package bound to this chain, the validator wallet factory, -and the joining owner address. - -Defined in: `src/types/staking.ts` +Defined in: [types/staking.ts:154](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L154) diff --git a/src/abi/staking.ts b/src/abi/staking.ts index ad6e6a7..4d25966 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -1249,10 +1249,14 @@ export const STAKING_ABI = [ name: "validatorJoin", type: "function", stateMutability: "payable", - inputs: [ - {name: "_operatorPubKey", type: "uint256[2]"}, - {name: "_possessionProof", type: "bytes"}, - ], + inputs: [{name: "_operator", type: "address"}], + outputs: [{name: "", type: "address"}], + }, + { + name: "validatorJoin", + type: "function", + stateMutability: "payable", + inputs: [], outputs: [{name: "", type: "address"}], }, { diff --git a/src/abi/vesting.ts b/src/abi/vesting.ts index f874813..0dc7adc 100644 --- a/src/abi/vesting.ts +++ b/src/abi/vesting.ts @@ -135,17 +135,7 @@ export const VESTING_ABI = [ {name: "vestingDelegatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingDelegatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingDelegatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}], outputs: []}, - { - name: "vestingValidatorJoin", - type: "function", - stateMutability: "nonpayable", - inputs: [ - {name: "operatorPubKey", type: "uint256[2]"}, - {name: "possessionProof", type: "bytes"}, - {name: "amount", type: "uint256"}, - ], - outputs: [], - }, + {name: "vestingValidatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "operator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingValidatorDeposit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingValidatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingValidatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}], outputs: []}, diff --git a/src/index.ts b/src/index.ts index 7e0b13d..fe3a8f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,18 +12,5 @@ export * as abi from "./abi"; export * from "./transactions/fees"; export {isSuccessful} from "./transactions/actions"; export {parseStakingAmount, formatStakingAmount} from "./staking"; -export { - OPERATOR_REGISTRATION_DOMAIN, - createOperatorRegistration, - operatorAddressFromPublicKey, - operatorPossessionMessage, - verifyOperatorRegistration, - vestingActions, -} from "./vesting"; -export type { - CreateOperatorRegistrationOptions, - OperatorPublicKey, - OperatorRegistrationContext, - OperatorRegistrationProof, -} from "./vesting"; +export {vestingActions} from "./vesting"; export {buildGenVmPositionalArgs} from "./contracts/schema"; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index d6eea76..a96f86c 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,9 +1,7 @@ -import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; +import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; -import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; -import {operatorAddressFromPublicKey, verifyOperatorRegistration} from "@/vesting/operatorRegistration"; import { ValidatorInfo, ValidatorIdentity, @@ -34,7 +32,6 @@ type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; -const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; // Combined ABI for error decoding (both staking and validator wallet errors) const COMBINED_ERROR_ABI = [...STAKING_ABI, ...VALIDATOR_WALLET_ABI]; @@ -239,59 +236,22 @@ export const stakingActions = ( }); }; - const getValidatorRegistrationContext = async () => { - if (!client.account) { - throw new Error("Account is required to resolve validator registration context."); - } - - const consensusMain = client.chain.consensusMainContract; - if (!consensusMain?.address || consensusMain.address === zeroAddress) { - throw new Error("Cannot resolve ValidatorWalletFactory without a consensus main contract."); - } - - const [addressManager, chainId] = await Promise.all([ - publicClient.readContract({ - address: consensusMain.address as ViemAddress, - abi: CONSENSUS_ADDRESS_MANAGER_ABI, - functionName: "getAddressManager", - }) as Promise
, - publicClient.getChainId(), - ]); - const registrar = await publicClient.readContract({ - address: addressManager as ViemAddress, - abi: ADDRESS_MANAGER_ABI, - functionName: "getAddress", - args: [VALIDATOR_WALLET_FACTORY_KEY], - }) as Address; - - if (!registrar || registrar === zeroAddress) { - throw new Error( - `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, - ); - } - - return { - registrar, - owner: client.account.address as Address, - chainId: BigInt(chainId), - }; - }; - return { /** Joins as a validator with the specified stake amount. */ validatorJoin: async (options: ValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); const stakingAddress = getStakingAddress(); - const context = await getValidatorRegistrationContext(); - if (!await verifyOperatorRegistration(options.registration, context)) { - throw new Error("Operator registration proof does not match the owner, registrar, chain, or public key."); - } - const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); - const data = encodeFunctionData({ - abi: STAKING_ABI, - functionName: "validatorJoin", - args: [options.registration.operatorPubKey, options.registration.possessionProof], - }); + + const data = options.operator + ? encodeFunctionData({ + abi: STAKING_ABI, + functionName: "validatorJoin", + args: [options.operator as ViemAddress], + }) + : encodeFunctionData({ + abi: STAKING_ABI, + functionName: "validatorJoin", + }); const result = await executeWrite({to: stakingAddress, data, value: amount}); const receipt = await publicClient.getTransactionReceipt({hash: result.transactionHash}); @@ -323,13 +283,11 @@ export const stakingActions = ( blockNumber: receipt.blockNumber, gasUsed: receipt.gasUsed, validatorWallet: validatorWallet!, - operator, + operator: options.operator || (client.account!.address as Address), amount: formatStakingAmount(amount), amountRaw: amount, }; }, - /** Resolves the registrar, owner, and chain binding required to create an operator proof. */ - getValidatorRegistrationContext, /** * Adds additional self-stake to an active validator position. The diff --git a/src/types/staking.ts b/src/types/staking.ts index 237ae4a..9d9c657 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -1,7 +1,6 @@ import {Address} from "./accounts"; import {GetContractReturnType, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress} from "viem"; import {STAKING_ABI} from "@/abi/staking"; -import type {OperatorRegistrationContext, OperatorRegistrationProof} from "./vesting"; type WalletClientWithAccount = Client; @@ -156,7 +155,7 @@ export interface DelegatorJoinResult extends StakingTransactionResult { export interface ValidatorJoinOptions { amount: bigint | string; - registration: OperatorRegistrationProof; + operator?: Address; } export interface ValidatorDepositOptions { @@ -212,7 +211,6 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; - getValidatorRegistrationContext: () => Promise; validatorDeposit: (options: ValidatorDepositOptions) => Promise; validatorExit: (options: ValidatorExitOptions) => Promise; validatorClaim: (options?: ValidatorClaimOptions) => Promise; diff --git a/src/types/vesting.ts b/src/types/vesting.ts index 5e32ee2..1c3d3ae 100644 --- a/src/types/vesting.ts +++ b/src/types/vesting.ts @@ -1,4 +1,4 @@ -import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, Hex, PublicClient, Transport} from "viem"; +import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, PublicClient, Transport} from "viem"; import {Address} from "./accounts"; import {VESTING_ABI, VESTING_FACTORY_ABI} from "@/abi/vesting"; @@ -14,24 +14,6 @@ export type VestingFactoryContract = GetContractReturnType Promise
; vestingFactory: (vesting: Address) => Promise
; vestingAddressManager: (vesting: Address) => Promise
; - getVestingValidatorRegistrationContext: (vesting: Address) => Promise; vestingTotalAmount: (vesting: Address) => Promise; vestingStartDate: (vesting: Address) => Promise; vestingCliffDuration: (vesting: Address) => Promise; diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts index 86020f6..5e2cef1 100644 --- a/src/vesting/actions.ts +++ b/src/vesting/actions.ts @@ -41,17 +41,12 @@ import { VestingWithdrawResult, } from "@/types/vesting"; import {formatStakingAmount, parseStakingAmount} from "@/staking/utils"; -import { - operatorAddressFromPublicKey, - verifyOperatorRegistration, -} from "./operatorRegistration"; type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; const VESTING_FACTORY_KEY = "VestingFactory"; -const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; const COMBINED_ERROR_ABI = [...VESTING_ABI, ...VESTING_FACTORY_ABI, ...ADDRESS_MANAGER_ABI, ...STAKING_ABI] as const; function extractRevertReason(err: unknown): string { @@ -273,31 +268,6 @@ export const vestingActions = ( return factory; }; - const getVestingValidatorRegistrationContext = async (vesting: Address) => { - const [addressManager, chainId] = await Promise.all([ - readVesting
(vesting, "addressManager"), - publicClient.getChainId(), - ]); - const registrar = await publicClient.readContract({ - address: addressManager as ViemAddress, - abi: ADDRESS_MANAGER_ABI, - functionName: "getAddress", - args: [VALIDATOR_WALLET_FACTORY_KEY], - }) as Address; - - if (!registrar || registrar === zeroAddress) { - throw new Error( - `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, - ); - } - - return { - registrar, - owner: vesting, - chainId: BigInt(chainId), - }; - }; - const getVestingContract = (vesting: Address): VestingContract => { return getContract({ address: vesting as ViemAddress, @@ -359,22 +329,17 @@ export const vestingActions = ( /** Creates a validator wallet and self-stakes vesting-held tokens. Must be called by the vesting beneficiary. */ vestingValidatorJoin: async (options: VestingValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); - const context = await getVestingValidatorRegistrationContext(options.vesting); - if (!await verifyOperatorRegistration(options.registration, context)) { - throw new Error("Operator registration proof does not match the vesting, registrar, chain, or public key."); - } - const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); const data = encodeFunctionData({ abi: VESTING_ABI, functionName: "vestingValidatorJoin", - args: [options.registration.operatorPubKey, options.registration.possessionProof, amount], + args: [options.operator as ViemAddress, amount], }); const result = await executeWrite({to: options.vesting as ViemAddress, data}); return { ...result, vesting: options.vesting, - operator, + operator: options.operator, beneficiary: client.account!.address as Address, amount: formatStakingAmount(amount), amountRaw: amount, @@ -645,7 +610,6 @@ export const vestingActions = ( vestingRevoker: (vesting: Address): Promise
=> readVesting
(vesting, "revoker"), vestingFactory: (vesting: Address): Promise
=> readVesting
(vesting, "factory"), vestingAddressManager: (vesting: Address): Promise
=> readVesting
(vesting, "addressManager"), - getVestingValidatorRegistrationContext, vestingTotalAmount: (vesting: Address): Promise => readVesting(vesting, "totalAmount"), vestingStartDate: (vesting: Address): Promise => readVesting(vesting, "startDate"), vestingCliffDuration: (vesting: Address): Promise => readVesting(vesting, "cliffDuration"), diff --git a/src/vesting/operatorRegistration.ts b/src/vesting/operatorRegistration.ts deleted file mode 100644 index d7b4442..0000000 --- a/src/vesting/operatorRegistration.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - concatHex, - encodeAbiParameters, - getAddress, - hexToBigInt, - keccak256, - recoverMessageAddress, - sliceHex, - stringToHex, - toHex, - type Address, - type Hex, -} from "viem"; -import {privateKeyToAccount, publicKeyToAddress} from "viem/accounts"; -import type { - CreateOperatorRegistrationOptions, - OperatorPublicKey, - OperatorRegistrationContext, - OperatorRegistrationProof, -} from "@/types/vesting"; - -export type { - CreateOperatorRegistrationOptions, - OperatorPublicKey, - OperatorRegistrationContext, - OperatorRegistrationProof, -} from "@/types/vesting"; - -export const OPERATOR_REGISTRATION_DOMAIN = keccak256( - stringToHex("GenLayer/operatorPubKey/proof-of-possession/v1"), -); - -export function operatorAddressFromPublicKey(operatorPubKey: OperatorPublicKey): Address { - const publicKey = concatHex([ - "0x04", - toHex(operatorPubKey[0], {size: 32}), - toHex(operatorPubKey[1], {size: 32}), - ]); - return getAddress(publicKeyToAddress(publicKey)); -} - -export function operatorPossessionMessage( - operatorPubKey: OperatorPublicKey, - context: OperatorRegistrationContext, -): Hex { - return keccak256( - encodeAbiParameters( - [ - {type: "bytes32"}, - {type: "uint256"}, - {type: "address"}, - {type: "address"}, - {type: "uint256"}, - {type: "uint256"}, - ], - [ - OPERATOR_REGISTRATION_DOMAIN, - context.chainId, - context.registrar, - context.owner, - operatorPubKey[0], - operatorPubKey[1], - ], - ), - ); -} - -/** - * Builds the proof package consumed by proof-bearing validator registration. - * The private key is used only in memory and is never included in the result. - */ -export async function createOperatorRegistration( - options: CreateOperatorRegistrationOptions, -): Promise { - const account = privateKeyToAccount(options.privateKey); - const operatorPubKey: OperatorPublicKey = [ - hexToBigInt(sliceHex(account.publicKey, 1, 33)), - hexToBigInt(sliceHex(account.publicKey, 33, 65)), - ]; - const operator = operatorAddressFromPublicKey(operatorPubKey); - - if (operator !== getAddress(account.address)) { - throw new Error("Operator private key and public key derive different identities."); - } - - const possessionProof = await account.signMessage({ - message: {raw: operatorPossessionMessage(operatorPubKey, options)}, - }); - - return {operator, operatorPubKey, possessionProof}; -} - -/** Validates the key identity and the exact registrar/owner/chain-bound proof. */ -export async function verifyOperatorRegistration( - registration: OperatorRegistrationProof, - context: OperatorRegistrationContext, -): Promise { - try { - const operator = operatorAddressFromPublicKey(registration.operatorPubKey); - if (operator !== getAddress(registration.operator)) return false; - - const recovered = await recoverMessageAddress({ - message: {raw: operatorPossessionMessage(registration.operatorPubKey, context)}, - signature: registration.possessionProof, - }); - return getAddress(recovered) === operator; - } catch { - return false; - } -} diff --git a/src/vesting/validator.ts b/src/vesting/validator.ts index df40501..fa01a00 100644 --- a/src/vesting/validator.ts +++ b/src/vesting/validator.ts @@ -7,16 +7,4 @@ export type { VestingValidatorJoinResult, VestingValidatorSetIdentityOptions, VestingValidatorWalletOptions, - CreateOperatorRegistrationOptions, - OperatorPublicKey, - OperatorRegistrationContext, - OperatorRegistrationProof, } from "@/types/vesting"; - -export { - OPERATOR_REGISTRATION_DOMAIN, - createOperatorRegistration, - operatorAddressFromPublicKey, - operatorPossessionMessage, - verifyOperatorRegistration, -} from "./operatorRegistration"; diff --git a/tests/operator-registration.test.ts b/tests/operator-registration.test.ts deleted file mode 100644 index 4a616a0..0000000 --- a/tests/operator-registration.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {describe, expect, it} from "vitest"; -import {getAddress} from "viem"; -import {privateKeyToAccount} from "viem/accounts"; -import { - OPERATOR_REGISTRATION_DOMAIN, - createOperatorRegistration, - operatorPossessionMessage, - verifyOperatorRegistration, - type OperatorRegistrationContext, -} from "../src/vesting/operatorRegistration"; - -const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; -const OTHER_OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000003"; -const CONTEXT: OperatorRegistrationContext = { - registrar: "0x1111111111111111111111111111111111111111", - owner: "0x2222222222222222222222222222222222222222", - chainId: 61999n, -}; - -describe("operator registration", () => { - it("matches the consensus proof-of-possession vector", async () => { - const registration = await createOperatorRegistration({ - privateKey: OPERATOR_KEY, - ...CONTEXT, - }); - - expect(OPERATOR_REGISTRATION_DOMAIN).toBe( - "0x56a1f863be2956668ca2fd6b4010d6fde7a54f2b5a02d6c624a2bad7e5fd5ada", - ); - expect(registration.operator).toBe(getAddress("0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF")); - expect(registration.operatorPubKey).toEqual([ - 89565891926547004231252920425935692360644145829622209833684329913297188986597n, - 12158399299693830322967808612713398636155367887041628176798871954788371653930n, - ]); - expect(operatorPossessionMessage(registration.operatorPubKey, CONTEXT)).toBe( - "0x7823e1bdaf3a8cea679a7bafaf8ddc39c379ac690f35696328650c3a712f36e0", - ); - expect(registration.possessionProof).toBe( - "0x30cedc70f8ab478fbc1a13a3f36e7f6a10eed631f59db4c451e38fe6d94dc640586d7a3202471043dbad68a3850655d39114aaca647df5734b171f8db7e88f161c", - ); - await expect(verifyOperatorRegistration(registration, CONTEXT)).resolves.toBe(true); - }); - - it("rejects wrong-key and cross-domain proofs", async () => { - const registration = await createOperatorRegistration({ - privateKey: OPERATOR_KEY, - ...CONTEXT, - }); - const wrongKey = privateKeyToAccount(OTHER_OPERATOR_KEY); - const wrongKeyProof = await wrongKey.signMessage({ - message: {raw: operatorPossessionMessage(registration.operatorPubKey, CONTEXT)}, - }); - - await expect( - verifyOperatorRegistration({...registration, possessionProof: wrongKeyProof}, CONTEXT), - ).resolves.toBe(false); - await expect( - verifyOperatorRegistration(registration, { - ...CONTEXT, - registrar: "0x3333333333333333333333333333333333333333", - }), - ).resolves.toBe(false); - await expect( - verifyOperatorRegistration(registration, { - ...CONTEXT, - owner: "0x4444444444444444444444444444444444444444", - }), - ).resolves.toBe(false); - await expect( - verifyOperatorRegistration(registration, {...CONTEXT, chainId: CONTEXT.chainId + 1n}), - ).resolves.toBe(false); - }); -}); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index 6465852..f58ce33 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -2,16 +2,11 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, encodeAbiParameters, encodeEventTopics, getAbiItem, parseEther} from "viem"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "../src/abi/staking"; import {stakingActions} from "../src/staking/actions"; -import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const STAKING_ADDRESS = "0x0000000000000000000000000000000000000044"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; -const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000066"; -const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000077"; -const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000088"; -const OPERATOR_PRIVATE_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; -const OPERATOR_ADDRESS = "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF"; +const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; const GAS_PRICE_HEX = "0x3b9aca00"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; @@ -40,22 +35,8 @@ const baseChain = { rpcUrls: {default: {http: ["http://127.0.0.1"]}}, isStudio: false, stakingContract: {address: STAKING_ADDRESS}, - consensusMainContract: {address: CONSENSUS_MAIN_ADDRESS}, }; -const makeRegistration = () => createOperatorRegistration({ - privateKey: OPERATOR_PRIVATE_KEY, - registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, - owner: ACCOUNT_ADDRESS, - chainId: BigInt(baseChain.id), -}); - -const readRegistrationContract = vi.fn().mockImplementation(async ({functionName}: any) => { - if (functionName === "getAddressManager") return ADDRESS_MANAGER_ADDRESS; - if (functionName === "getAddress") return VALIDATOR_WALLET_FACTORY_ADDRESS; - throw new Error(`Unexpected read: ${functionName}`); -}); - // Local-key harness (byte-for-byte regression anchor for the sign+sendRaw lane). const makeLocalHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -71,8 +52,7 @@ const makeLocalHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: readRegistrationContract, - getChainId: vi.fn().mockResolvedValue(baseChain.id), + readContract: vi.fn(), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, signTransaction}; }; @@ -99,8 +79,7 @@ const makeProviderHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: readRegistrationContract, - getChainId: vi.fn().mockResolvedValue(baseChain.id), + readContract: vi.fn(), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, request, signTransaction}; }; @@ -114,15 +93,14 @@ describe("stakingActions local lane", () => { it("validatorJoin encodes the call, decodes the ValidatorJoin event, and returns the full shape", async () => { const {actions, publicClient, signTransaction} = makeLocalHarness(); - const registration = await makeRegistration(); - const result = await actions.validatorJoin({amount: "2gen", registration}); + const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); // Encoding routed to the staking contract with msg.value = stake amount. expect(publicClient.call.mock.calls[0][0].to).toBe(STAKING_ADDRESS); expect(publicClient.call.mock.calls[0][0].value).toBe(parseEther("2")); expect(decodeFunctionData({abi: STAKING_ABI, data: publicClient.call.mock.calls[0][0].data})).toEqual({ functionName: "validatorJoin", - args: [registration.operatorPubKey, registration.possessionProof], + args: [OPERATOR_ADDRESS], }); // Local sign+sendRaw path. @@ -139,17 +117,6 @@ describe("stakingActions local lane", () => { amountRaw: parseEther("2"), }); }); - - it("rejects a registration proof that is not bound to the joining owner and registrar", async () => { - const {actions, publicClient} = makeLocalHarness(); - const registration = await makeRegistration(); - - await expect(actions.validatorJoin({ - amount: "2gen", - registration: {...registration, possessionProof: "0x1234"}, - })).rejects.toThrow(/registration proof does not match/i); - expect(publicClient.call).not.toHaveBeenCalled(); - }); }); describe("stakingActions provider lane (Address-only)", () => { @@ -201,7 +168,7 @@ describe("stakingActions provider lane (Address-only)", () => { it("validatorJoin decodes the ValidatorJoin event off the provider-returned hash", async () => { const {actions, request, signTransaction} = makeProviderHarness(); - const result = await actions.validatorJoin({amount: "2gen", registration: await makeRegistration()}); + const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); // Sent via the provider lane, not signed locally. expect(sentTxParams(request).to).toBe(STAKING_ADDRESS); diff --git a/tests/vesting-actions.test.ts b/tests/vesting-actions.test.ts index 05f9f9a..ec60a1f 100644 --- a/tests/vesting-actions.test.ts +++ b/tests/vesting-actions.test.ts @@ -2,19 +2,17 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, parseEther, toHex, zeroAddress} from "viem"; import {VESTING_ABI} from "../src/abi/vesting"; import {vestingActions} from "../src/vesting/actions"; -import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const BENEFICIARY_ADDRESS = ACCOUNT_ADDRESS; const VESTING_ADDRESS = "0x0000000000000000000000000000000000000022"; const VALIDATOR_ADDRESS = "0x0000000000000000000000000000000000000033"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; +const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; const NEW_OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000bb"; const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000044"; const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000055"; const FACTORY_ADDRESS = "0x0000000000000000000000000000000000000066"; -const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000077"; -const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; const makeReceipt = () => ({ @@ -56,7 +54,6 @@ const makeHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async request => request), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; @@ -134,39 +131,23 @@ describe("vestingActions", () => { }); it("encodes vesting validator join and deposit without caller value", async () => { - const {actions, client, publicClient} = makeHarness(); - client.chain.id = 999; - publicClient.readContract.mockImplementation(async ({address, functionName, args}: any) => { - if (address === VESTING_ADDRESS && functionName === "addressManager") return ADDRESS_MANAGER_ADDRESS; - if (address === ADDRESS_MANAGER_ADDRESS && functionName === "getAddress") { - expect(args).toEqual(["ValidatorWalletFactory"]); - return VALIDATOR_WALLET_FACTORY_ADDRESS; - } - throw new Error(`Unexpected read: ${functionName}`); - }); - const registration = await createOperatorRegistration({ - privateKey: OPERATOR_KEY, - registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, - owner: VESTING_ADDRESS, - chainId: 1n, - }); + const {actions, publicClient} = makeHarness(); const result = await actions.vestingValidatorJoin({ vesting: VESTING_ADDRESS, - registration, + operator: OPERATOR_ADDRESS, amount: "3gen", }); expect(publicClient.call.mock.calls[0][0].to).toBe(VESTING_ADDRESS); - expect(publicClient.getChainId).toHaveBeenCalledTimes(1); expect(publicClient.call.mock.calls[0][0].value).toBeUndefined(); expect(decodedWrite(publicClient)).toEqual({ functionName: "vestingValidatorJoin", - args: [registration.operatorPubKey, registration.possessionProof, parseEther("3")], + args: [OPERATOR_ADDRESS, parseEther("3")], }); expect(result).toMatchObject({ vesting: VESTING_ADDRESS, - operator: registration.operator, + operator: OPERATOR_ADDRESS, beneficiary: ACCOUNT_ADDRESS, amount: "3 GEN", amountRaw: parseEther("3"), @@ -396,7 +377,6 @@ const makeProviderHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; From bae1eeeccedf0572047c59fc3d271794f83c4176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 18 Aug 2026 15:13:18 +0100 Subject: [PATCH 18/25] feat: proof-bearing validator joins (restores #206, gated on its 4 companions) (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reapply "fix: support proof-bearing validator joins (#206)" (#207) This reverts commit 7666f0b73987aa75b7fcd1d62c2759364ce36824. * feat(staking): support the two-step operator rotation CON-715 removed ValidatorWalletBlueprint.setOperator in favour of initiateOperatorTransfer + completeOperatorTransfer. The SDK only spoke the single-call surface, so rotating an operator against a consensus deployment that carries the change fails: the selector no longer exists, so viem reverts with no reason and the Python SDK reports ABIFunctionNotFound. Adds both calls plus cancelOperatorTransfer and the getPendingOperator view, and keeps setOperator for deployments that still expose it. The proof binding is the subtle part. validatorJoin's possession proof is verified by the ValidatorWalletFactory and is bound to it; rotation is verified by the wallet, so the registrar is the wallet's own address (PubKeyUtils.validateWithPossession(pubKey, address(this), owner(), proof)). createOperatorRegistration already takes the registrar as a parameter, so no change was needed there — but a join proof silently fails to verify for a rotation, which is the easy mistake to make. getOperatorTransferContext builds the wallet-bound context and reads owner() from the wallet rather than assuming the caller is the owner, so a mismatch surfaces as a clear local error instead of an onlyOwner revert. A test pins that a join-bound proof does not verify for rotation while a wallet-bound one does. Callers still need updating; this only adds the surface they need: genlayer-e2e's driver interface, genlayer-cli's staking set-operator, and genlayer-py's staking_set_operator all remain single-call. Verified: npx tsc --noEmit, npm test -- --run (123/123, no type errors), npm run build. * fix(staking): resolve the Claim/Commit layout from the chain CON-715 widened both staking structs — Claim gained `offset`, Commit gained `outstanding`/`priced`/`fragmented` and narrowed several members — while keeping the same function names and arguments. Static tuples decode positionally, so reading a post-CON-715 chain with the shape in STAKING_ABI does not fail: `commit.input` picks up `claim.commit`. Pending deposits came back as small indices instead of amounts, which is why the e2e delegator scenarios asserted 0.1 GEN and got "0" or "2". Both layouts are deployed — consensus v0.6-dev has the old one, #1290 the new — so neither shape can simply replace the other. STAKING_COMMIT_VIEWS_CURRENT_ABI adds the post-CON-715 shape for the four struct-returning views, and readCommitView resolves which one the chain speaks, once, then caches it for the client: getStakeInfo loops over every pending entry and must not re-probe per entry. The probe only works in one direction, and the tests pin that: decoding a legacy response with the current shape throws (the response is short), while decoding a current response with the legacy shape succeeds and lies. So the current shape is always attempted first and a decode *failure* identifies a legacy chain. If that asymmetry ever breaks, the probe would quietly start reporting wrong balances again — hence the test asserting the exact misread value rather than merely "not equal". Also drops duplicate ValidatorWalletBlueprint entries: the ABI already carried initiateOperatorTransfer/completeOperatorTransfer/cancelOperatorTransfer/ getPendingOperator, with initiateOperatorTransfer declared as taking an address rather than the pubkey and possession proof it actually takes. Verified: npx tsc --noEmit, npm test -- --run (126/126, no type errors), npm run build. --------- Co-authored-by: kirilaa --- README.md | 8 +- docs/api-references/index.md | 8 +- docs/api-references/staking.md | 2 +- .../types.Interface.ValidatorJoinOptions.md | 13 +- src/abi/staking.ts | 171 ++++++++++--- src/abi/vesting.ts | 12 +- src/index.ts | 15 +- src/staking/actions.ts | 230 ++++++++++++++++-- src/types/staking.ts | 33 ++- src/types/vesting.ts | 23 +- src/vesting/actions.ts | 40 ++- src/vesting/operatorRegistration.ts | 110 +++++++++ src/vesting/validator.ts | 12 + tests/operator-registration.test.ts | 106 ++++++++ tests/staking-actions.test.ts | 45 +++- tests/staking-commit-layout.test.ts | 64 +++++ tests/vesting-actions.test.ts | 30 ++- 17 files changed, 836 insertions(+), 86 deletions(-) create mode 100644 src/vesting/operatorRegistration.ts create mode 100644 tests/operator-registration.test.ts create mode 100644 tests/staking-commit-layout.test.ts diff --git a/README.md b/README.md index 39934b9..fd9aa57 100644 --- a/README.md +++ b/README.md @@ -437,8 +437,12 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires account with funds) -const result = await client.validatorJoin({ amount: "42000gen" }); +// Join as validator (requires an owner account with funds and the operator key) +const registration = await createOperatorRegistration({ + privateKey: operatorPrivateKey, + ...(await client.getValidatorRegistrationContext()), +}); +const result = await client.validatorJoin({ amount: "42000gen", registration }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 232f6bc..c08ef14 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -204,8 +204,12 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires account with funds) -const result = await client.validatorJoin({ amount: "42000gen" }); +// Join as validator (requires an owner account with funds and the operator key) +const registration = await createOperatorRegistration({ + privateKey: operatorPrivateKey, + ...(await client.getValidatorRegistrationContext()), +}); +const result = await client.validatorJoin({ amount: "42000gen", registration }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/staking.md b/docs/api-references/staking.md index 17f05a8..da70e2c 100644 --- a/docs/api-references/staking.md +++ b/docs/api-references/staking.md @@ -9,7 +9,7 @@ Joins as a validator with the specified stake amount. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | amount | `bigint \| string` | yes | | -| operator | `Address` | no | | +| registration | `OperatorRegistrationProof` | yes | | **Returns:** `ValidatorJoinResult` diff --git a/docs/api-references/types.Interface.ValidatorJoinOptions.md b/docs/api-references/types.Interface.ValidatorJoinOptions.md index 3406f19..20518ac 100644 --- a/docs/api-references/types.Interface.ValidatorJoinOptions.md +++ b/docs/api-references/types.Interface.ValidatorJoinOptions.md @@ -1,6 +1,6 @@ # Interface: ValidatorJoinOptions -Defined in: [types/staking.ts:152](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L152) +Defined in: `src/types/staking.ts` ## Properties @@ -8,12 +8,15 @@ Defined in: [types/staking.ts:152](https://github.com/genlayerlabs/genlayer-js/b > **amount**: `string` \| `bigint` -Defined in: [types/staking.ts:153](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L153) +Defined in: `src/types/staking.ts` *** -### operator? +### registration -> `optional` **operator?**: `` `0x${string}` `` +> **registration**: `OperatorRegistrationProof` -Defined in: [types/staking.ts:154](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L154) +Proof-of-possession package bound to this chain, the validator wallet factory, +and the joining owner address. + +Defined in: `src/types/staking.ts` diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 4d25966..9f3ea14 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -55,76 +55,83 @@ export const VALIDATOR_WALLET_ABI = [ inputs: [{name: "_operator", type: "address"}], outputs: [], }, + // Two-step operator rotation (CON-715). setOperator above is the single-call + // predecessor and is absent from newer consensus deployments, so callers pick + // whichever the deployed wallet exposes. Unlike validatorJoin, the possession + // proof here is verified by the wallet itself, so its registrar is the wallet + // address rather than the ValidatorWalletFactory. { - name: "setIdentity", + name: "initiateOperatorTransfer", type: "function", stateMutability: "nonpayable", inputs: [ - {name: "moniker", type: "string"}, - {name: "logoUri", type: "string"}, - {name: "website", type: "string"}, - {name: "description", type: "string"}, - {name: "email", type: "string"}, - {name: "twitter", type: "string"}, - {name: "telegram", type: "string"}, - {name: "github", type: "string"}, - {name: "extraCid", type: "bytes"}, + {name: "_newOperatorPubKey", type: "uint256[2]"}, + {name: "_possessionProof", type: "bytes"}, ], outputs: [], }, - // Staking functions (forwarded to staking contract) { - name: "validatorDeposit", + name: "completeOperatorTransfer", type: "function", - stateMutability: "payable", + stateMutability: "nonpayable", inputs: [], outputs: [], }, { - name: "validatorExit", + name: "cancelOperatorTransfer", type: "function", stateMutability: "nonpayable", - inputs: [{name: "_shares", type: "uint256"}], + inputs: [], outputs: [], }, { - name: "validatorClaim", + name: "getPendingOperator", type: "function", - stateMutability: "nonpayable", + stateMutability: "view", inputs: [], - outputs: [], + outputs: [ + {name: "", type: "address"}, + {name: "", type: "uint256"}, + ], }, - // Two-step operator transfer { - name: "initiateOperatorTransfer", + name: "setIdentity", type: "function", stateMutability: "nonpayable", - inputs: [{name: "_newOperator", type: "address"}], + inputs: [ + {name: "moniker", type: "string"}, + {name: "logoUri", type: "string"}, + {name: "website", type: "string"}, + {name: "description", type: "string"}, + {name: "email", type: "string"}, + {name: "twitter", type: "string"}, + {name: "telegram", type: "string"}, + {name: "github", type: "string"}, + {name: "extraCid", type: "bytes"}, + ], outputs: [], }, + // Staking functions (forwarded to staking contract) { - name: "completeOperatorTransfer", + name: "validatorDeposit", type: "function", - stateMutability: "nonpayable", + stateMutability: "payable", inputs: [], outputs: [], }, { - name: "cancelOperatorTransfer", + name: "validatorExit", type: "function", stateMutability: "nonpayable", - inputs: [], + inputs: [{name: "_shares", type: "uint256"}], outputs: [], }, { - name: "getPendingOperator", + name: "validatorClaim", type: "function", - stateMutability: "view", + stateMutability: "nonpayable", inputs: [], - outputs: [ - {name: "", type: "address"}, - {name: "", type: "uint256"}, - ], + outputs: [], }, { name: "getOperator", @@ -1249,14 +1256,10 @@ export const STAKING_ABI = [ name: "validatorJoin", type: "function", stateMutability: "payable", - inputs: [{name: "_operator", type: "address"}], - outputs: [{name: "", type: "address"}], - }, - { - name: "validatorJoin", - type: "function", - stateMutability: "payable", - inputs: [], + inputs: [ + {name: "_operatorPubKey", type: "uint256[2]"}, + {name: "_possessionProof", type: "bytes"}, + ], outputs: [{name: "", type: "address"}], }, { @@ -1513,3 +1516,93 @@ export const SLASH_ABI = [ ], }, ] as const; + +/** + * The staking Claim/Commit views as consensus exposes them after CON-715. + * + * That change widened both structs — Claim gained `offset`, Commit gained + * `outstanding`/`priced`/`fragmented` and narrowed several members — while + * keeping the same function names and argument lists. Static tuples decode + * positionally, so reading a post-CON-715 chain with the older shape in + * STAKING_ABI silently returns the wrong words rather than failing: `commit.input` + * picks up `claim.commit`, which is why pending deposits read back as small + * indices instead of amounts. + * + * Both shapes are still in the wild, so neither can simply replace the other. + * stakingActions probes once per client and then reads with whichever matches. + * Decoding the OLD layout with this one throws (the response is too short), + * which is what makes the probe possible; the reverse direction is the silent + * one, so the current shape must always be tried first. + */ +const CURRENT_CLAIM_COMPONENTS = [ + {name: "quantity", type: "uint96"}, + {name: "offset", type: "uint96"}, + {name: "commit", type: "uint256"}, +] as const; + +const CURRENT_COMMIT_COMPONENTS = [ + {name: "input", type: "uint256"}, + {name: "output", type: "uint256"}, + {name: "outstanding", type: "uint120"}, + {name: "epoch", type: "uint64"}, + {name: "linkToNextCommit", type: "uint56"}, + {name: "priced", type: "bool"}, + {name: "fragmented", type: "bool"}, +] as const; + +export const STAKING_COMMIT_VIEWS_CURRENT_ABI = [ + { + name: "delegatorDeposit", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_delegator", type: "address"}, + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, + { + name: "delegatorWithdrawal", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_delegator", type: "address"}, + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, + { + name: "validatorDeposit", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "epoch_", type: "uint256"}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, + { + name: "validatorWithdrawal", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "epoch_", type: "uint256"}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, +] as const; diff --git a/src/abi/vesting.ts b/src/abi/vesting.ts index 0dc7adc..f874813 100644 --- a/src/abi/vesting.ts +++ b/src/abi/vesting.ts @@ -135,7 +135,17 @@ export const VESTING_ABI = [ {name: "vestingDelegatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingDelegatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingDelegatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}], outputs: []}, - {name: "vestingValidatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "operator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, + { + name: "vestingValidatorJoin", + type: "function", + stateMutability: "nonpayable", + inputs: [ + {name: "operatorPubKey", type: "uint256[2]"}, + {name: "possessionProof", type: "bytes"}, + {name: "amount", type: "uint256"}, + ], + outputs: [], + }, {name: "vestingValidatorDeposit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingValidatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingValidatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}], outputs: []}, diff --git a/src/index.ts b/src/index.ts index fe3a8f9..7e0b13d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,18 @@ export * as abi from "./abi"; export * from "./transactions/fees"; export {isSuccessful} from "./transactions/actions"; export {parseStakingAmount, formatStakingAmount} from "./staking"; -export {vestingActions} from "./vesting"; +export { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorAddressFromPublicKey, + operatorPossessionMessage, + verifyOperatorRegistration, + vestingActions, +} from "./vesting"; +export type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "./vesting"; export {buildGenVmPositionalArgs} from "./contracts/schema"; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index a96f86c..cf46ab1 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,7 +1,9 @@ -import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError} from "viem"; +import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; -import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; +import {STAKING_ABI, VALIDATOR_WALLET_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "@/abi/staking"; +import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; +import {operatorAddressFromPublicKey, verifyOperatorRegistration} from "@/vesting/operatorRegistration"; import { ValidatorInfo, ValidatorIdentity, @@ -18,6 +20,10 @@ import { ValidatorClaimOptions, ValidatorPrimeOptions, SetOperatorOptions, + InitiateOperatorTransferOptions, + CompleteOperatorTransferOptions, + CancelOperatorTransferOptions, + PendingOperatorInfo, SetIdentityOptions, DelegatorJoinOptions, DelegatorExitOptions, @@ -26,12 +32,14 @@ import { PendingDeposit, PendingWithdrawal, } from "@/types/staking"; +import type {OperatorRegistrationContext} from "@/types/vesting"; type ReadOnlyStakingContract = GetContractReturnType; type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; +const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; // Combined ABI for error decoding (both staking and validator wallet errors) const COMBINED_ERROR_ABI = [...STAKING_ABI, ...VALIDATOR_WALLET_ABI]; @@ -236,22 +244,134 @@ export const stakingActions = ( }); }; + const getValidatorRegistrationContext = async () => { + if (!client.account) { + throw new Error("Account is required to resolve validator registration context."); + } + + const consensusMain = client.chain.consensusMainContract; + if (!consensusMain?.address || consensusMain.address === zeroAddress) { + throw new Error("Cannot resolve ValidatorWalletFactory without a consensus main contract."); + } + + const [addressManager, chainId] = await Promise.all([ + publicClient.readContract({ + address: consensusMain.address as ViemAddress, + abi: CONSENSUS_ADDRESS_MANAGER_ABI, + functionName: "getAddressManager", + }) as Promise
, + publicClient.getChainId(), + ]); + const registrar = await publicClient.readContract({ + address: addressManager as ViemAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddress", + args: [VALIDATOR_WALLET_FACTORY_KEY], + }) as Address; + + if (!registrar || registrar === zeroAddress) { + throw new Error( + `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, + ); + } + + return { + registrar, + owner: client.account.address as Address, + chainId: BigInt(chainId), + }; + }; + + /** + * Which Claim/Commit layout the deployed staking contract uses. + * + * CON-715 widened both structs without renaming anything, and static tuples + * decode positionally, so the wrong shape does not fail — it silently returns + * neighbouring words (commit.input picks up claim.commit, i.e. an index where + * an amount belongs). Both shapes are deployed in the wild, so the layout is + * resolved from the chain rather than assumed, then cached for the client: + * getStakeInfo loops over every pending entry and must not re-probe each time. + * + * The probe only works in one direction. Reading the OLD layout with the + * CURRENT shape throws, because the response is shorter than the decoder + * expects; reading the CURRENT layout with the OLD shape succeeds and lies. + * So the current shape is always attempted first, and a decode failure — not + * a success — is what identifies a legacy chain. + */ + let commitLayout: "current" | "legacy" | null = null; + + const readCommitView = async ( + functionName: "delegatorDeposit" | "delegatorWithdrawal" | "validatorDeposit" | "validatorWithdrawal", + args: readonly unknown[], + ): Promise => { + const read = (layout: "current" | "legacy") => + publicClient.readContract({ + address: getStakingAddress(), + abi: (layout === "current" ? STAKING_COMMIT_VIEWS_CURRENT_ABI : STAKING_ABI) as any, + functionName, + args: args as any, + }); + + if (commitLayout) { + return read(commitLayout); + } + + try { + const result = await read("current"); + commitLayout = "current"; + return result; + } catch (currentError) { + // Could be a legacy layout, or a genuine failure (bad index, RPC error). + // Only a successful legacy decode distinguishes them; otherwise surface + // the original error, which describes the current-shape attempt. + try { + const result = await read("legacy"); + commitLayout = "legacy"; + return result; + } catch { + throw currentError; + } + } + }; + + /** + * Rotation is verified by the wallet, not the factory, so the registrar is the + * wallet's own address. The owner is read from the wallet rather than assumed + * to be the caller: the proof is bound to whoever `owner()` returns, and a + * mismatch is far easier to diagnose here than as an onlyOwner revert. + */ + const getOperatorTransferContext = async (validator: Address): Promise => { + const [owner, chainId] = await Promise.all([ + publicClient.readContract({ + address: validator as ViemAddress, + abi: VALIDATOR_WALLET_ABI, + functionName: "owner", + }) as Promise
, + publicClient.getChainId(), + ]); + + return { + registrar: validator, + owner, + chainId: BigInt(chainId), + }; + }; + return { /** Joins as a validator with the specified stake amount. */ validatorJoin: async (options: ValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); const stakingAddress = getStakingAddress(); - - const data = options.operator - ? encodeFunctionData({ - abi: STAKING_ABI, - functionName: "validatorJoin", - args: [options.operator as ViemAddress], - }) - : encodeFunctionData({ - abi: STAKING_ABI, - functionName: "validatorJoin", - }); + const context = await getValidatorRegistrationContext(); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error("Operator registration proof does not match the owner, registrar, chain, or public key."); + } + const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); + const data = encodeFunctionData({ + abi: STAKING_ABI, + functionName: "validatorJoin", + args: [options.registration.operatorPubKey, options.registration.possessionProof], + }); const result = await executeWrite({to: stakingAddress, data, value: amount}); const receipt = await publicClient.getTransactionReceipt({hash: result.transactionHash}); @@ -283,11 +403,13 @@ export const stakingActions = ( blockNumber: receipt.blockNumber, gasUsed: receipt.gasUsed, validatorWallet: validatorWallet!, - operator: options.operator || (client.account!.address as Address), + operator, amount: formatStakingAmount(amount), amountRaw: amount, }; }, + /** Resolves the registrar, owner, and chain binding required to create an operator proof. */ + getValidatorRegistrationContext, /** * Adds additional self-stake to an active validator position. The @@ -344,7 +466,14 @@ export const stakingActions = ( return executeWrite({to: getStakingAddress(), data}); }, - /** Sets the operator address for a validator wallet. */ + /** + * Sets the operator address for a validator wallet in one call. + * + * Removed from consensus by CON-715 in favour of the two-step rotation + * below; against a deployment that dropped it this reverts with no reason, + * because the selector simply does not exist. Prefer + * initiateOperatorTransfer + completeOperatorTransfer. + */ setOperator: async (options: SetOperatorOptions): Promise => { const data = encodeFunctionData({ abi: VALIDATOR_WALLET_ABI, @@ -354,6 +483,69 @@ export const stakingActions = ( return executeWrite({to: options.validator as ViemAddress, data}); }, + getOperatorTransferContext, + + /** + * Starts the two-step operator rotation. The proof is checked against the + * wallet-bound context before submission so a registration built for the + * wrong registrar fails locally instead of as an opaque on-chain revert. + */ + initiateOperatorTransfer: async ( + options: InitiateOperatorTransferOptions, + ): Promise => { + const context = await getOperatorTransferContext(options.validator); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error( + "Operator registration proof does not match the wallet, owner, chain, or public key. " + + "Rotation proofs must use the validator wallet as their registrar.", + ); + } + const data = encodeFunctionData({ + abi: VALIDATOR_WALLET_ABI, + functionName: "initiateOperatorTransfer", + args: [options.registration.operatorPubKey, options.registration.possessionProof], + }); + return executeWrite({to: options.validator as ViemAddress, data}); + }, + + /** + * Completes a pending rotation. Callable by the wallet owner or the pending + * operator, and only once the factory's operatorTransferDelay has elapsed. + */ + completeOperatorTransfer: async ( + options: CompleteOperatorTransferOptions, + ): Promise => { + const data = encodeFunctionData({ + abi: VALIDATOR_WALLET_ABI, + functionName: "completeOperatorTransfer", + args: [], + }); + return executeWrite({to: options.validator as ViemAddress, data}); + }, + + /** Abandons a pending rotation, leaving the current operator in place. */ + cancelOperatorTransfer: async ( + options: CancelOperatorTransferOptions, + ): Promise => { + const data = encodeFunctionData({ + abi: VALIDATOR_WALLET_ABI, + functionName: "cancelOperatorTransfer", + args: [], + }); + return executeWrite({to: options.validator as ViemAddress, data}); + }, + + /** Reads the pending operator and when its transfer was initiated. */ + getPendingOperator: async (validator: Address): Promise => { + const [operator, initiatedAt] = await publicClient.readContract({ + address: validator as ViemAddress, + abi: VALIDATOR_WALLET_ABI, + functionName: "getPendingOperator", + }) as [Address, bigint]; + + return {operator, initiatedAt}; + }, + /** Sets validator identity information (name, website, social links). */ setIdentity: async (options: SetIdentityOptions): Promise => { let extraCidBytes: `0x${string}` = "0x"; @@ -482,7 +674,7 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [epoch, commit] = (await contract.read.validatorDeposit([validator as ViemAddress, i])) as [ + const [epoch, commit] = (await readCommitView("validatorDeposit", [validator as ViemAddress, i])) as [ bigint, {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, ]; @@ -499,7 +691,7 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [epoch, commit] = (await contract.read.validatorWithdrawal([validator as ViemAddress, i])) as [ + const [epoch, commit] = (await readCommitView("validatorWithdrawal", [validator as ViemAddress, i])) as [ bigint, {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, ]; @@ -575,7 +767,7 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [claim, commit] = (await contract.read.delegatorDeposit([ + const [claim, commit] = (await readCommitView("delegatorDeposit", [ delegator as ViemAddress, validator as ViemAddress, i, @@ -599,7 +791,7 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [claim, commit] = (await contract.read.delegatorWithdrawal([ + const [claim, commit] = (await readCommitView("delegatorWithdrawal", [ delegator as ViemAddress, validator as ViemAddress, i, diff --git a/src/types/staking.ts b/src/types/staking.ts index 9d9c657..0af016b 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -1,6 +1,7 @@ import {Address} from "./accounts"; import {GetContractReturnType, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress} from "viem"; import {STAKING_ABI} from "@/abi/staking"; +import type {OperatorRegistrationContext, OperatorRegistrationProof} from "./vesting"; type WalletClientWithAccount = Client; @@ -155,7 +156,7 @@ export interface DelegatorJoinResult extends StakingTransactionResult { export interface ValidatorJoinOptions { amount: bigint | string; - operator?: Address; + registration: OperatorRegistrationProof; } export interface ValidatorDepositOptions { @@ -181,6 +182,30 @@ export interface SetOperatorOptions { operator: Address; } +/** + * Starts the two-step operator rotation. `registration` must be built with the + * validator wallet as its registrar — the wallet verifies the possession proof + * itself, unlike validatorJoin where the factory does. + */ +export interface InitiateOperatorTransferOptions { + validator: Address; + registration: OperatorRegistrationProof; +} + +export interface CompleteOperatorTransferOptions { + validator: Address; +} + +export interface CancelOperatorTransferOptions { + validator: Address; +} + +/** Pending operator and the timestamp its transfer was initiated (0 when none). */ +export interface PendingOperatorInfo { + operator: Address; + initiatedAt: bigint; +} + export interface SetIdentityOptions { validator: Address; moniker: string; @@ -211,6 +236,12 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; + getValidatorRegistrationContext: () => Promise; + getOperatorTransferContext: (validator: Address) => Promise; + initiateOperatorTransfer: (options: InitiateOperatorTransferOptions) => Promise; + completeOperatorTransfer: (options: CompleteOperatorTransferOptions) => Promise; + cancelOperatorTransfer: (options: CancelOperatorTransferOptions) => Promise; + getPendingOperator: (validator: Address) => Promise; validatorDeposit: (options: ValidatorDepositOptions) => Promise; validatorExit: (options: ValidatorExitOptions) => Promise; validatorClaim: (options?: ValidatorClaimOptions) => Promise; diff --git a/src/types/vesting.ts b/src/types/vesting.ts index 1c3d3ae..5e32ee2 100644 --- a/src/types/vesting.ts +++ b/src/types/vesting.ts @@ -1,4 +1,4 @@ -import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, PublicClient, Transport} from "viem"; +import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, Hex, PublicClient, Transport} from "viem"; import {Address} from "./accounts"; import {VESTING_ABI, VESTING_FACTORY_ABI} from "@/abi/vesting"; @@ -14,6 +14,24 @@ export type VestingFactoryContract = GetContractReturnType Promise
; vestingFactory: (vesting: Address) => Promise
; vestingAddressManager: (vesting: Address) => Promise
; + getVestingValidatorRegistrationContext: (vesting: Address) => Promise; vestingTotalAmount: (vesting: Address) => Promise; vestingStartDate: (vesting: Address) => Promise; vestingCliffDuration: (vesting: Address) => Promise; diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts index 5e2cef1..86020f6 100644 --- a/src/vesting/actions.ts +++ b/src/vesting/actions.ts @@ -41,12 +41,17 @@ import { VestingWithdrawResult, } from "@/types/vesting"; import {formatStakingAmount, parseStakingAmount} from "@/staking/utils"; +import { + operatorAddressFromPublicKey, + verifyOperatorRegistration, +} from "./operatorRegistration"; type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; const VESTING_FACTORY_KEY = "VestingFactory"; +const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; const COMBINED_ERROR_ABI = [...VESTING_ABI, ...VESTING_FACTORY_ABI, ...ADDRESS_MANAGER_ABI, ...STAKING_ABI] as const; function extractRevertReason(err: unknown): string { @@ -268,6 +273,31 @@ export const vestingActions = ( return factory; }; + const getVestingValidatorRegistrationContext = async (vesting: Address) => { + const [addressManager, chainId] = await Promise.all([ + readVesting
(vesting, "addressManager"), + publicClient.getChainId(), + ]); + const registrar = await publicClient.readContract({ + address: addressManager as ViemAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddress", + args: [VALIDATOR_WALLET_FACTORY_KEY], + }) as Address; + + if (!registrar || registrar === zeroAddress) { + throw new Error( + `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, + ); + } + + return { + registrar, + owner: vesting, + chainId: BigInt(chainId), + }; + }; + const getVestingContract = (vesting: Address): VestingContract => { return getContract({ address: vesting as ViemAddress, @@ -329,17 +359,22 @@ export const vestingActions = ( /** Creates a validator wallet and self-stakes vesting-held tokens. Must be called by the vesting beneficiary. */ vestingValidatorJoin: async (options: VestingValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); + const context = await getVestingValidatorRegistrationContext(options.vesting); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error("Operator registration proof does not match the vesting, registrar, chain, or public key."); + } + const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); const data = encodeFunctionData({ abi: VESTING_ABI, functionName: "vestingValidatorJoin", - args: [options.operator as ViemAddress, amount], + args: [options.registration.operatorPubKey, options.registration.possessionProof, amount], }); const result = await executeWrite({to: options.vesting as ViemAddress, data}); return { ...result, vesting: options.vesting, - operator: options.operator, + operator, beneficiary: client.account!.address as Address, amount: formatStakingAmount(amount), amountRaw: amount, @@ -610,6 +645,7 @@ export const vestingActions = ( vestingRevoker: (vesting: Address): Promise
=> readVesting
(vesting, "revoker"), vestingFactory: (vesting: Address): Promise
=> readVesting
(vesting, "factory"), vestingAddressManager: (vesting: Address): Promise
=> readVesting
(vesting, "addressManager"), + getVestingValidatorRegistrationContext, vestingTotalAmount: (vesting: Address): Promise => readVesting(vesting, "totalAmount"), vestingStartDate: (vesting: Address): Promise => readVesting(vesting, "startDate"), vestingCliffDuration: (vesting: Address): Promise => readVesting(vesting, "cliffDuration"), diff --git a/src/vesting/operatorRegistration.ts b/src/vesting/operatorRegistration.ts new file mode 100644 index 0000000..d7b4442 --- /dev/null +++ b/src/vesting/operatorRegistration.ts @@ -0,0 +1,110 @@ +import { + concatHex, + encodeAbiParameters, + getAddress, + hexToBigInt, + keccak256, + recoverMessageAddress, + sliceHex, + stringToHex, + toHex, + type Address, + type Hex, +} from "viem"; +import {privateKeyToAccount, publicKeyToAddress} from "viem/accounts"; +import type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "@/types/vesting"; + +export type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "@/types/vesting"; + +export const OPERATOR_REGISTRATION_DOMAIN = keccak256( + stringToHex("GenLayer/operatorPubKey/proof-of-possession/v1"), +); + +export function operatorAddressFromPublicKey(operatorPubKey: OperatorPublicKey): Address { + const publicKey = concatHex([ + "0x04", + toHex(operatorPubKey[0], {size: 32}), + toHex(operatorPubKey[1], {size: 32}), + ]); + return getAddress(publicKeyToAddress(publicKey)); +} + +export function operatorPossessionMessage( + operatorPubKey: OperatorPublicKey, + context: OperatorRegistrationContext, +): Hex { + return keccak256( + encodeAbiParameters( + [ + {type: "bytes32"}, + {type: "uint256"}, + {type: "address"}, + {type: "address"}, + {type: "uint256"}, + {type: "uint256"}, + ], + [ + OPERATOR_REGISTRATION_DOMAIN, + context.chainId, + context.registrar, + context.owner, + operatorPubKey[0], + operatorPubKey[1], + ], + ), + ); +} + +/** + * Builds the proof package consumed by proof-bearing validator registration. + * The private key is used only in memory and is never included in the result. + */ +export async function createOperatorRegistration( + options: CreateOperatorRegistrationOptions, +): Promise { + const account = privateKeyToAccount(options.privateKey); + const operatorPubKey: OperatorPublicKey = [ + hexToBigInt(sliceHex(account.publicKey, 1, 33)), + hexToBigInt(sliceHex(account.publicKey, 33, 65)), + ]; + const operator = operatorAddressFromPublicKey(operatorPubKey); + + if (operator !== getAddress(account.address)) { + throw new Error("Operator private key and public key derive different identities."); + } + + const possessionProof = await account.signMessage({ + message: {raw: operatorPossessionMessage(operatorPubKey, options)}, + }); + + return {operator, operatorPubKey, possessionProof}; +} + +/** Validates the key identity and the exact registrar/owner/chain-bound proof. */ +export async function verifyOperatorRegistration( + registration: OperatorRegistrationProof, + context: OperatorRegistrationContext, +): Promise { + try { + const operator = operatorAddressFromPublicKey(registration.operatorPubKey); + if (operator !== getAddress(registration.operator)) return false; + + const recovered = await recoverMessageAddress({ + message: {raw: operatorPossessionMessage(registration.operatorPubKey, context)}, + signature: registration.possessionProof, + }); + return getAddress(recovered) === operator; + } catch { + return false; + } +} diff --git a/src/vesting/validator.ts b/src/vesting/validator.ts index fa01a00..df40501 100644 --- a/src/vesting/validator.ts +++ b/src/vesting/validator.ts @@ -7,4 +7,16 @@ export type { VestingValidatorJoinResult, VestingValidatorSetIdentityOptions, VestingValidatorWalletOptions, + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, } from "@/types/vesting"; + +export { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorAddressFromPublicKey, + operatorPossessionMessage, + verifyOperatorRegistration, +} from "./operatorRegistration"; diff --git a/tests/operator-registration.test.ts b/tests/operator-registration.test.ts new file mode 100644 index 0000000..8d3818f --- /dev/null +++ b/tests/operator-registration.test.ts @@ -0,0 +1,106 @@ +import {describe, expect, it} from "vitest"; +import {getAddress} from "viem"; +import {privateKeyToAccount} from "viem/accounts"; +import { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorPossessionMessage, + verifyOperatorRegistration, + type OperatorRegistrationContext, +} from "../src/vesting/operatorRegistration"; + +const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; +const OTHER_OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000003"; +const CONTEXT: OperatorRegistrationContext = { + registrar: "0x1111111111111111111111111111111111111111", + owner: "0x2222222222222222222222222222222222222222", + chainId: 61999n, +}; + +describe("operator registration", () => { + it("matches the consensus proof-of-possession vector", async () => { + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + ...CONTEXT, + }); + + expect(OPERATOR_REGISTRATION_DOMAIN).toBe( + "0x56a1f863be2956668ca2fd6b4010d6fde7a54f2b5a02d6c624a2bad7e5fd5ada", + ); + expect(registration.operator).toBe(getAddress("0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF")); + expect(registration.operatorPubKey).toEqual([ + 89565891926547004231252920425935692360644145829622209833684329913297188986597n, + 12158399299693830322967808612713398636155367887041628176798871954788371653930n, + ]); + expect(operatorPossessionMessage(registration.operatorPubKey, CONTEXT)).toBe( + "0x7823e1bdaf3a8cea679a7bafaf8ddc39c379ac690f35696328650c3a712f36e0", + ); + expect(registration.possessionProof).toBe( + "0x30cedc70f8ab478fbc1a13a3f36e7f6a10eed631f59db4c451e38fe6d94dc640586d7a3202471043dbad68a3850655d39114aaca647df5734b171f8db7e88f161c", + ); + await expect(verifyOperatorRegistration(registration, CONTEXT)).resolves.toBe(true); + }); + + it("rejects wrong-key and cross-domain proofs", async () => { + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + ...CONTEXT, + }); + const wrongKey = privateKeyToAccount(OTHER_OPERATOR_KEY); + const wrongKeyProof = await wrongKey.signMessage({ + message: {raw: operatorPossessionMessage(registration.operatorPubKey, CONTEXT)}, + }); + + await expect( + verifyOperatorRegistration({...registration, possessionProof: wrongKeyProof}, CONTEXT), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, { + ...CONTEXT, + registrar: "0x3333333333333333333333333333333333333333", + }), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, { + ...CONTEXT, + owner: "0x4444444444444444444444444444444444444444", + }), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, {...CONTEXT, chainId: CONTEXT.chainId + 1n}), + ).resolves.toBe(false); + }); + + // The two proof-bearing flows differ only in who verifies, and therefore in + // the registrar the proof is bound to: validatorJoin is checked by the + // ValidatorWalletFactory, while initiateOperatorTransfer is checked by the + // wallet itself (PubKeyUtils.validateWithPossession(pubKey, address(this), + // owner(), proof)). Reusing a join proof to rotate is the easy mistake, so + // pin that it does not verify. + it("binds rotation proofs to the wallet, not the factory", async () => { + const factory = "0x1111111111111111111111111111111111111111"; + const wallet = "0x5555555555555555555555555555555555555555"; + const owner = "0x2222222222222222222222222222222222222222"; + const chainId = 61999n; + + const joinRegistration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + registrar: factory, + owner, + chainId, + }); + const rotationRegistration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + registrar: wallet, + owner, + chainId, + }); + + const rotationContext: OperatorRegistrationContext = {registrar: wallet, owner, chainId}; + + await expect(verifyOperatorRegistration(rotationRegistration, rotationContext)).resolves.toBe(true); + await expect(verifyOperatorRegistration(joinRegistration, rotationContext)).resolves.toBe(false); + expect(rotationRegistration.possessionProof).not.toBe(joinRegistration.possessionProof); + expect(rotationRegistration.operator).toBe(joinRegistration.operator); + }); +}); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index f58ce33..6465852 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -2,11 +2,16 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, encodeAbiParameters, encodeEventTopics, getAbiItem, parseEther} from "viem"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "../src/abi/staking"; import {stakingActions} from "../src/staking/actions"; +import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const STAKING_ADDRESS = "0x0000000000000000000000000000000000000044"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; -const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; +const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000066"; +const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000077"; +const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000088"; +const OPERATOR_PRIVATE_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; +const OPERATOR_ADDRESS = "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF"; const GAS_PRICE_HEX = "0x3b9aca00"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; @@ -35,8 +40,22 @@ const baseChain = { rpcUrls: {default: {http: ["http://127.0.0.1"]}}, isStudio: false, stakingContract: {address: STAKING_ADDRESS}, + consensusMainContract: {address: CONSENSUS_MAIN_ADDRESS}, }; +const makeRegistration = () => createOperatorRegistration({ + privateKey: OPERATOR_PRIVATE_KEY, + registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, + owner: ACCOUNT_ADDRESS, + chainId: BigInt(baseChain.id), +}); + +const readRegistrationContract = vi.fn().mockImplementation(async ({functionName}: any) => { + if (functionName === "getAddressManager") return ADDRESS_MANAGER_ADDRESS; + if (functionName === "getAddress") return VALIDATOR_WALLET_FACTORY_ADDRESS; + throw new Error(`Unexpected read: ${functionName}`); +}); + // Local-key harness (byte-for-byte regression anchor for the sign+sendRaw lane). const makeLocalHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -52,7 +71,8 @@ const makeLocalHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: vi.fn(), + readContract: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, signTransaction}; }; @@ -79,7 +99,8 @@ const makeProviderHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: vi.fn(), + readContract: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, request, signTransaction}; }; @@ -93,14 +114,15 @@ describe("stakingActions local lane", () => { it("validatorJoin encodes the call, decodes the ValidatorJoin event, and returns the full shape", async () => { const {actions, publicClient, signTransaction} = makeLocalHarness(); - const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + const registration = await makeRegistration(); + const result = await actions.validatorJoin({amount: "2gen", registration}); // Encoding routed to the staking contract with msg.value = stake amount. expect(publicClient.call.mock.calls[0][0].to).toBe(STAKING_ADDRESS); expect(publicClient.call.mock.calls[0][0].value).toBe(parseEther("2")); expect(decodeFunctionData({abi: STAKING_ABI, data: publicClient.call.mock.calls[0][0].data})).toEqual({ functionName: "validatorJoin", - args: [OPERATOR_ADDRESS], + args: [registration.operatorPubKey, registration.possessionProof], }); // Local sign+sendRaw path. @@ -117,6 +139,17 @@ describe("stakingActions local lane", () => { amountRaw: parseEther("2"), }); }); + + it("rejects a registration proof that is not bound to the joining owner and registrar", async () => { + const {actions, publicClient} = makeLocalHarness(); + const registration = await makeRegistration(); + + await expect(actions.validatorJoin({ + amount: "2gen", + registration: {...registration, possessionProof: "0x1234"}, + })).rejects.toThrow(/registration proof does not match/i); + expect(publicClient.call).not.toHaveBeenCalled(); + }); }); describe("stakingActions provider lane (Address-only)", () => { @@ -168,7 +201,7 @@ describe("stakingActions provider lane (Address-only)", () => { it("validatorJoin decodes the ValidatorJoin event off the provider-returned hash", async () => { const {actions, request, signTransaction} = makeProviderHarness(); - const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + const result = await actions.validatorJoin({amount: "2gen", registration: await makeRegistration()}); // Sent via the provider lane, not signed locally. expect(sentTxParams(request).to).toBe(STAKING_ADDRESS); diff --git a/tests/staking-commit-layout.test.ts b/tests/staking-commit-layout.test.ts new file mode 100644 index 0000000..0c2aede --- /dev/null +++ b/tests/staking-commit-layout.test.ts @@ -0,0 +1,64 @@ +/** + * The Claim/Commit layout probe rests on one asymmetry, so pin it. + * + * CON-715 widened both structs without renaming the functions. Decoding a + * post-CON-715 response with the older shape does NOT fail — it returns + * neighbouring words — so a successful decode cannot identify the layout. + * Only the reverse throws. That is why stakingActions tries the current shape + * first and treats a decode failure, rather than a success, as the signal. + * + * If this asymmetry ever stops holding, the probe silently starts reporting + * wrong balances again, which is exactly the bug it exists to prevent. + */ +import {describe, expect, it} from "vitest"; +import {decodeFunctionResult, encodeAbiParameters} from "viem"; +import {STAKING_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "../src/abi/staking"; + +const STAKE = 100000000000000000n; // 0.1 GEN +const CLAIM_COMMIT_INDEX = 2n; + +const outputsOf = (abi: readonly any[], name: string) => + abi.find((e: any) => e.type === "function" && e.name === name)!.outputs; + +const legacyOutputs = outputsOf(STAKING_ABI as any, "delegatorDeposit"); +const currentOutputs = outputsOf(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, "delegatorDeposit"); + +const legacyResponse = encodeAbiParameters(legacyOutputs, [ + {quantity: 7n, commit: CLAIM_COMMIT_INDEX}, + {input: STAKE, output: 5n, epoch: 3n, linkToNextCommit: 0n}, +] as any); + +const currentResponse = encodeAbiParameters(currentOutputs, [ + {quantity: 7n, offset: 0n, commit: CLAIM_COMMIT_INDEX}, + { + input: STAKE, + output: 5n, + outstanding: 9n, + epoch: 3n, + linkToNextCommit: 0n, + priced: true, + fragmented: false, + }, +] as any); + +const decodeWith = (abi: readonly any[], data: `0x${string}`) => + decodeFunctionResult({abi: abi as any, functionName: "delegatorDeposit", data}) as any; + +describe("staking Claim/Commit layout", () => { + it("reads the amount when the shape matches the response", () => { + expect(decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, currentResponse)[1].input).toBe(STAKE); + expect(decodeWith(STAKING_ABI as any, legacyResponse)[1].input).toBe(STAKE); + }); + + it("throws when the current shape meets a legacy response — this is the probe", () => { + expect(() => decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, legacyResponse)).toThrow(); + }); + + it("silently misreads when the legacy shape meets a current response", () => { + // Not a throw: claim.commit lands where commit.input is expected, which is + // how pending deposits came back as small indices instead of amounts. + const misread = decodeWith(STAKING_ABI as any, currentResponse)[1].input; + expect(misread).not.toBe(STAKE); + expect(misread).toBe(CLAIM_COMMIT_INDEX); + }); +}); diff --git a/tests/vesting-actions.test.ts b/tests/vesting-actions.test.ts index ec60a1f..05f9f9a 100644 --- a/tests/vesting-actions.test.ts +++ b/tests/vesting-actions.test.ts @@ -2,17 +2,19 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, parseEther, toHex, zeroAddress} from "viem"; import {VESTING_ABI} from "../src/abi/vesting"; import {vestingActions} from "../src/vesting/actions"; +import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const BENEFICIARY_ADDRESS = ACCOUNT_ADDRESS; const VESTING_ADDRESS = "0x0000000000000000000000000000000000000022"; const VALIDATOR_ADDRESS = "0x0000000000000000000000000000000000000033"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; -const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; const NEW_OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000bb"; const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000044"; const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000055"; const FACTORY_ADDRESS = "0x0000000000000000000000000000000000000066"; +const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000077"; +const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; const makeReceipt = () => ({ @@ -54,6 +56,7 @@ const makeHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async request => request), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; @@ -131,23 +134,39 @@ describe("vestingActions", () => { }); it("encodes vesting validator join and deposit without caller value", async () => { - const {actions, publicClient} = makeHarness(); + const {actions, client, publicClient} = makeHarness(); + client.chain.id = 999; + publicClient.readContract.mockImplementation(async ({address, functionName, args}: any) => { + if (address === VESTING_ADDRESS && functionName === "addressManager") return ADDRESS_MANAGER_ADDRESS; + if (address === ADDRESS_MANAGER_ADDRESS && functionName === "getAddress") { + expect(args).toEqual(["ValidatorWalletFactory"]); + return VALIDATOR_WALLET_FACTORY_ADDRESS; + } + throw new Error(`Unexpected read: ${functionName}`); + }); + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, + owner: VESTING_ADDRESS, + chainId: 1n, + }); const result = await actions.vestingValidatorJoin({ vesting: VESTING_ADDRESS, - operator: OPERATOR_ADDRESS, + registration, amount: "3gen", }); expect(publicClient.call.mock.calls[0][0].to).toBe(VESTING_ADDRESS); + expect(publicClient.getChainId).toHaveBeenCalledTimes(1); expect(publicClient.call.mock.calls[0][0].value).toBeUndefined(); expect(decodedWrite(publicClient)).toEqual({ functionName: "vestingValidatorJoin", - args: [OPERATOR_ADDRESS, parseEther("3")], + args: [registration.operatorPubKey, registration.possessionProof, parseEther("3")], }); expect(result).toMatchObject({ vesting: VESTING_ADDRESS, - operator: OPERATOR_ADDRESS, + operator: registration.operator, beneficiary: ACCOUNT_ADDRESS, amount: "3 GEN", amountRaw: parseEther("3"), @@ -377,6 +396,7 @@ const makeProviderHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; From d7d32627e036cf57600558a099bb589ccc5c8d2a Mon Sep 17 00:00:00 2001 From: KirilA Date: Tue, 1 Sep 2026 01:29:24 +0300 Subject: [PATCH 19/25] fix: align SDK consumers with the resolution-kernel train (#212) * fix: read the consensus surfaces the resolution-kernel train exposes Four entrypoints the SDK calls were removed or reshaped by the train. The first one is what fails in E2E today, on the dev-env stack, before any scenario completes: getTransactionData(txId, timestamp) -> not found on ABI That read answered with a projection evaluated at a caller-supplied clock. The train splits the two apart: the stored record is getStoredTransactionData(txId) and the projection lives behind getTransactionLifecycle. Chains upgrade independently, so the transaction read now picks whichever the chain's OWN abi offers rather than assuming -- the dev-env harness builds its chain from the live deployment, so its abi is the train's while a testnet's is not. The stored record renames three fields. The decoder already normalised two of them (txData/txCalldata, numOfInitialValidators/initialRotations); it now also accepts observedAt for currentTimestamp, which it reads unconditionally, so an unrecognised name was a crash rather than a missing field. activeValidators() is gone because committee capacity is 1,543 and an address[] that long overruns the return-size limit. The joined registry is paged instead: read validatorsJoinedCount() first so a registry that grows underneath the walk cannot spin the loop, then getValidatorsJoined 64 at a time -- the size the paged reads are written around, and the one genlayer-node uses for the same walk. activeValidatorsCount() went with it, which took all of getEpochInfo() down with a single read. validatorView lost its registry tree pointers (left, right, parent), so the struct is resynced to the nine fields the train returns; both call sites read it by name, so nothing else moves. validatorsRoot() is removed from the abi as well -- nothing here calls it, and leaving a declaration for a function that no longer exists only invites a revert. The status map is renumbered with them. ReadyToFinalize stopped being a stored status -- readiness is the resolution kernel's verdict now -- and removing it at ordinal 11 shifts the three above it down. Left alone, a ValidatorsTimeout transaction decoded as "READY_TO_FINALIZE": no error, just the wrong answer. The enum member stays, because the node still reports that state; what changed is that no chain value decodes to it, so the name->number map is now partial and says so in its type. The repo's own tests pinned the old numbering and are re-pointed to the train's. * fix: resync validatorViewPrimed and validatorViewPrePrimed The earlier commit resynced validatorView and missed its two siblings, which return the same struct and drifted the same way: the train dropped the registry tree pointers (left, right, parent), so a twelve-field decode runs off the end of a nine-field return. It surfaces as a bounds error rather than a wrong value -- "Position 319 is out of bounds (0 < position < 288)" from validatorViewPrimed -- because the reader walks past the encoded tail. * fix: align SDK consumers with train contracts * fix: route studio chains around the train lifecycle reads The train adaptation made the appeal and finalization actions read getTransactionLifecycle from ConsensusData unconditionally. Studio chains (localnet, studionet) run the studio-embedded consensus, which predates the train: that ConsensusData exposes no getTransactionLifecycle and no estimateLatestAppealCharge, so the read decodes a short tuple and viem raises "Position 63 is out of bounds (0 < position < 32)". This failed the studio suites of e2e run 33112369501, including 051_fee_lifecycle.feature ("submitAppeal records an appeal bond without expanding the primary fee budget"). Guard each affected action on client.chain.isStudio before any train read, mirroring transactionActions.getTransaction, and reinstate the pre-train call shape recovered from the v2-dev base: - appealTransaction encodes submitAppeal(bytes32) against the chain's own ConsensusMain ABI, with the caller's value defaulting to zero. The base resolved the value through FeeManager/RoundsStorage, both of which carry no address on studio, so it already degraded to zero there. - topUpAndSubmitAppeal encodes topUpAndSubmitAppeal(bytes32, FeesDistribution) without a decision id. - finalizeTransaction encodes finalizeTransaction(bytes32). - getAppealCharge and getMinAppealBond report the missing quote surface, which is the error the base already raised on studio. - resolveTransactions and finalizeDecisions are train-only additions with no base equivalent, so they report the gap and point at finalizeTransaction. canAppeal and the rounds-storage reads already returned their base error on studio: their missing-contract guards run before any train read, so they are left alone. finalizeIdlenessTxs keeps its migration error; studio's ConsensusMain ABI never carried that entrypoint, so the base did not support it there either. The train path is unchanged for non-studio chains. * feat(transactions)!: layer consumer and protocol lifecycles Keep the default transaction model on materialized state with a small discriminated lifecycle and stored-state wait helpers. Move projection, resolution source/action, and active decision identity behind the advanced namespace using one normalized schema on both Studio and contract networks.\n\nRemove ReadyToFinalize and duplicate canFinalize exposure. Finalize remains a protocol action.\n\nValidation: npm test -- --run; npm run lint; npm run build; npm run docs; git diff --check. * fix(staking): restore complete public action surface * fix(types): stop merging unrelated transaction results * fix(appeals): bind Studio calls to decisions * fix(appeals): use train ABI on Studio networks * fix(lifecycle): fall back to the Studio status when the RPC is absent The train surface routes Studio chains through the node RPC gen_getTransactionLifecycle: advanced.getTransactionLifecycle reads it directly, and _readLifecycleIdentity reads it for every appeal and finalization. The genlayer-studio backend deployed from studio main does not implement that method, so viem raises MethodNotFoundRpcError and the whole flow dies before it reaches anything the transaction can answer. Keep the node-RPC-first design and degrade only when the endpoint says it does not implement the method (viem's typed error, JSON-RPC -32601, or a message that reports the gap in text). The fallback synthesizes the lifecycle from the Studio consumer surface the SDK already uses, client.getTransaction, which proves the exact stored status and nothing else: the projection repeats the stored status, the resolution is NoOp/Unspecified, and the decision identity stays inactive. When the status cannot be read, the original RPC error is re-raised rather than reported as a lifecycle. _readLifecycleIdentity returns the same conservative identity, so a Studio chain without the RPC reports no decision instead of a fabricated one. A fabricated decision id would be signed into submitAppeal or finalizeTransaction as expectedDecisionId, and every consumer of that identity refuses to act on an inactive decision, which is the honest outcome for a decision the SDK cannot read. This unblocks the Studio flows that never touch a decision. It does not make the four appeal scenarios of 051_fee_lifecycle pass: those need a real active decision id on Studio, which only the backend can supply. * fix(studio): preserve the current consensus surface * fix(fees): fund default consensus rotations in estimates --------- Co-authored-by: Edgars Co-authored-by: kirilaa --- .github/workflows/smoke.yml | 4 + README.md | 36 +- docs/api-references/contracts.md | 303 +++++- docs/api-references/index.md | 263 +++++- docs/api-references/staking.md | 111 ++- docs/api-references/transactions.md | 67 +- .../types.Enumeration.TransactionStatus.md | 16 +- ...Variable.transactionsStatusNumberToName.md | 6 +- src/abi/consensusTrain.ts | 41 + src/abi/staking.ts | 213 +---- src/abi/studioConsensus.ts | 105 +++ src/chains/localnet.ts | 8 +- src/chains/studionet.ts | 8 +- src/contracts/actions.ts | 860 ++++++++++++------ src/staking/actions.ts | 146 ++- src/transactions/actions.ts | 381 +++++++- src/transactions/decoders.ts | 19 +- src/transactions/lifecycleFallback.ts | 131 +++ src/types/clients.ts | 36 +- src/types/staking.ts | 22 +- src/types/transactions.ts | 296 +++++- tests/chains-actions.test.ts | 69 ++ tests/client.test-d.ts | 60 ++ tests/contracts-actions.test.ts | 719 ++++++++++++--- tests/smoke.test.ts | 4 +- tests/staking-actions.test.ts | 129 +++ tests/staking-commit-layout.test.ts | 162 +++- tests/transactions.test.ts | 683 +++++++++++++- 28 files changed, 4023 insertions(+), 875 deletions(-) create mode 100644 src/abi/consensusTrain.ts create mode 100644 src/abi/studioConsensus.ts create mode 100644 src/transactions/lifecycleFallback.ts diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 6e681fd..6f8606f 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -9,6 +9,10 @@ on: jobs: smoke: name: Testnet Smoke Tests + # The v2 line intentionally targets the resolution-kernel train and does + # not support the currently deployed pre-train testnets. Its live coverage + # comes from the cross-repository E2E stack until those testnets upgrade. + if: github.event_name != 'pull_request' || github.base_ref != 'v2-dev' runs-on: ubuntu-latest timeout-minutes: 5 continue-on-error: true diff --git a/README.md b/README.md index fd9aa57..7cdf046 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,6 @@ values in wallet or app UI before signing. const estimate = await client.estimateTransactionFees({ leaderTimeunitsAllocation: 100n, validatorTimeunitsAllocation: 200n, - rotations: [0n], }); const txHash = await client.writeContract({ @@ -132,6 +131,10 @@ const txHash = await client.writeContract({ }); ``` +When `rotations` is omitted from an estimate, the SDK funds every round through +the chain's `defaultConsensusMaxRotations`. Pass `rotations` explicitly—including +`[0n]`—when the transaction should use a lower rotation budget. + 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 `estimateTransactionFees` for transactions @@ -267,11 +270,10 @@ transaction hash; on Studio/localnet it is the target GenLayer transaction id. A transaction can be finalized by consensus but still have a failed execution. Always check `txExecutionResult` before reading contract state: ```typescript -import { ExecutionResult, TransactionStatus } from "genlayer-js/types"; +import { ExecutionResult } from "genlayer-js/types"; -const receipt = await client.waitForTransactionReceipt({ +const receipt = await client.waitForFinalization({ hash: txHash, - status: TransactionStatus.FINALIZED, }); if (receipt.txExecutionResultName === ExecutionResult.FINISHED_WITH_RETURN) { @@ -297,6 +299,27 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); +// The default model is derived from the exact stored state. Processing entries +// have a phase; decided entries have an outcome. +console.log(tx.lifecycle); +// {state: "processing", phase: "revealing"} +// {state: "decided", outcome: "accepted"} + +// Advanced protocol consumers can explicitly request timestamp projection and +// the exact resolution action/source. Current Studio can prove only the stored +// status, so unsupported action/decision fields remain inactive there. +const protocolLifecycle = await client.advanced.getTransactionLifecycle({ hash: txHash }); +console.log(protocolLifecycle.storedStatus); +console.log(protocolLifecycle.projectedStatus); +console.log(protocolLifecycle.resolutionSource); +if (protocolLifecycle.resolutionAction === "Finalize") { + // Finalize is the protocol's current action/capability, not a transaction status. +} + +// The train retains the authoritative execution hash, not the old receipt +// bytes. `txReceipt` is therefore unavailable on train transactions. +console.log(tx.txExecutionHash); + // Messages emitted by the contract during execution console.log(tx.messages); // [{messageType, recipient, value, data, onAcceptance, saltNonce}, ...] @@ -428,9 +451,12 @@ const epochInfo = await client.getEpochInfo(); // totalClaimed: "500 GEN", // Total claimed rewards // } -// Get active validators +// Get validators currently eligible for consensus duties const validators = await client.getActiveValidators(); +// Inspect every identity in the append-only joined registry +const joinedValidators = await client.getJoinedValidators(); + // Check if address is a validator const isValidator = await client.isValidator("0x..."); diff --git a/docs/api-references/contracts.md b/docs/api-references/contracts.md index ddb4150..ecb3e31 100644 --- a/docs/api-references/contracts.md +++ b/docs/api-references/contracts.md @@ -4,7 +4,7 @@ Methods for deploying, reading, writing, and simulating GenLayer intelligent con ### getContractCode -Retrieves the source code of a deployed contract. Localnet only. +Retrieves the source code of a deployed contract. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -16,7 +16,7 @@ Retrieves the source code of a deployed contract. Localnet only. ### getContractSchema -Gets the schema (methods and constructor) of a deployed contract. Localnet only. +Gets the schema (methods and constructor) of a deployed contract. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -28,7 +28,7 @@ Gets the schema (methods and constructor) of a deployed contract. Localnet only. ### getContractSchemaForCode -Generates a schema for contract code without deploying it. Localnet only. +Generates a schema for contract code without deploying it. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -70,10 +70,15 @@ Simulates a state-modifying contract call without executing on-chain. | args | `CalldataEncodable[]` | no | | | kwargs | `Map \| {[key: string]: CalldataEncodable}` | no | | | rawReturn | `RawReturn` | no | | +| includeReceipt | `IncludeReceipt` | no | | +| value | `BigNumberish` | no | | | leaderOnly | `boolean` | no | | +| fees | `TransactionFeeOptions` | no | | | transactionHashVariant | `TransactionHashVariant` | no | | -**Returns:** `RawReturn extends true ? 0x${string} : CalldataEncodable` +**Returns:** `IncludeReceipt extends true + ? SimulateWriteContractResult + : RawReturn extends true ? 0x${string} : CalldataEncodable` --- @@ -88,9 +93,11 @@ Executes a state-modifying function on a contract through consensus. Returns the | functionName | `string` | yes | | | args | `CalldataEncodable[]` | no | | | kwargs | `Map \| {[key: string]: CalldataEncodable}` | no | | -| value | `bigint` | yes | | +| value | `bigint` | no | | | leaderOnly | `boolean` | no | | | consensusMaxRotations | `number` | no | | +| validUntil | `BigNumberish` | no | | +| fees | `TransactionFeeOptions` | no | | **Returns:** `0x${string}` @@ -108,12 +115,94 @@ Deploys a new intelligent contract to GenLayer. Returns the transaction hash. | kwargs | `Map \| {[key: string]: CalldataEncodable}` | no | | | leaderOnly | `boolean` | no | | | consensusMaxRotations | `number` | no | | +| validUntil | `BigNumberish` | no | | +| fees | `TransactionFeeOptions` | no | | + +--- + +### getCurrentFeePolicy + +Returns the active fee price policy used to build user-side caps. + +_No parameters._ + +**Returns:** `FeePolicyQuote` + +--- + +### estimateFeesDistribution + +Builds a fee distribution with caps derived from the active fee policy. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `FeeEstimateOptions` | no | | + +**Returns:** `FeesDistribution` + +--- + +### estimateTransactionFees + +Builds a complete transaction `fees` object, including feeValue. +Studio has no on-chain FeeManager in the chain definition, so this uses +the same deterministic round-fee math as Studio trusted mode there. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `FeeEstimateOptions` | no | | + +**Returns:** `TransactionFeeEstimate` + +--- + +### estimateTransactionFeesFromSimulation + +Builds a trusted fee preset from a representative Studio simulation. +This turns the returned fee accounting/report into execution and message +budgets while preserving mode-2 message allocations when the simulation +was run with them. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `SimulationFeeEstimateOptions` | yes | | + +**Returns:** `TransactionFeeEstimate` + +--- + +### estimateTransactionFeesForWrite + +Builds a trusted fee preset for a concrete write call in one step. +The method first gives the simulation a baseline fee budget, then uses +the returned Studio/GenVM fee accounting to derive the preset the dapp +should pass with the real transaction. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `WriteFeeEstimateOptions` | yes | | + +**Returns:** `TransactionFeeEstimate` + +--- + +### getAppealCharge + +Returns the full authoritative appeal charge (bond plus appeal funding) +on resolution-kernel contract networks. Current Studio has no +decision-bound quote surface. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +**Returns:** `bigint` --- ### getMinAppealBond -Calculates the minimum bond required to appeal a transaction. +@deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -123,15 +212,217 @@ Calculates the minimum bond required to appeal a transaction. --- +### getRoundNumber + +Returns the current consensus round number for a transaction. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +**Returns:** `bigint` + +--- + +### getRoundData + +Returns detailed data for a specific consensus round. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | +| round | `bigint` | yes | | + +--- + +### getLastRoundData + +Returns the current round number and its data for a transaction. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +--- + +### canAppeal + +Checks if a transaction can be appealed. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +**Returns:** `boolean` + +--- + +### getDeveloperNft + +Returns a developer's NFT reward record, or null when no NFT is registered. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| developer | `Address` | yes | | + +**Returns:** `DeveloperNft | null` + +--- + +### getClaimableRewardsFromFees + +Returns claimable developer-NFT rewards accrued from transaction fees. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| nftId | `BigNumberish` | yes | | + +**Returns:** `bigint` + +--- + +### getClaimableRewardsFromInflation + +Returns claimable developer-NFT rewards accrued from inflation. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| nftId | `BigNumberish` | yes | | +| numberOfEpochsToClaim | `BigNumberish` | yes | | + +**Returns:** `bigint` + +--- + +### claimNftRewards + +Claims all currently available rewards for a developer NFT. Returns the EVM transaction hash. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| nftId | `BigNumberish` | yes | | + +**Returns:** `0x${string}` + +--- + +### claimNftEpochs + +Claims a bounded number of reward epochs for a developer NFT. Returns the EVM transaction hash. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| nftId | `BigNumberish` | yes | | +| numberOfEpochsToClaim | `BigNumberish` | yes | | + +**Returns:** `0x${string}` + +--- + ### appealTransaction Appeals a consensus transaction to trigger a new round of validation. +Contract networks bind the call to the active decision and quote an +omitted value. Current Studio uses its native decision-free entrypoint; +its value defaults to zero when omitted. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txId | ``0x${string}`` | yes | | +| value | `bigint` | no | | + +--- + +### topUpFees + +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. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txId | ``0x${string}`` | yes | | +| distribution | `FeesDistributionInput` | yes | | +| value | `bigint` | yes | | + +**Returns:** `0x${string}` + +--- + +### topUpAndSubmitAppeal + +Deposits appeal fee budget and submits an appeal in the same consensus call. +Returns the existing GenLayer transaction id, matching appealTransaction. +Contract networks bind the call to the active decision and quote an +omitted value. Current Studio uses its native decision-free entrypoint; +its value defaults to zero when omitted. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | account | `Account` | no | | | txId | ``0x${string}`` | yes | | +| distribution | `FeesDistributionInput` | yes | | | value | `bigint` | no | | +**Returns:** `0x${string}` + +--- + +### finalizeTransaction + +Finalizes a single GenLayer transaction that is ready to be finalized. Returns the EVM transaction hash. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txId | ``0x${string}`` | yes | | + +**Returns:** `0x${string}` + +--- + +### finalizeIdlenessTxs + +@deprecated The train separates attempt-bound resolution from +decision-bound finalization. Use resolveTransactions or +finalizeDecisions after classifying the lifecycle action. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txIds | `readonly `0x${string}`[]` | yes | | + +**Returns:** `0x${string}` + +--- + +### resolveTransactions + +Resolves a batch of attempt-bound lifecycle actions. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txIds | `readonly `0x${string}`[]` | yes | | + +**Returns:** `0x${string}` + +--- + +### finalizeDecisions + +Finalizes a batch of active, decision-bound transactions. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txIds | `readonly `0x${string}`[]` | yes | | + +**Returns:** `0x${string}` + --- diff --git a/docs/api-references/index.md b/docs/api-references/index.md index c08ef14..bc6e3cc 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -103,16 +103,173 @@ const receipt = await client.waitForTransactionReceipt({ ``` +### 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. + +```typescript +const estimate = await client.estimateTransactionFees({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, +}); + +const txHash = await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: estimate.distribution, + feeValue: estimate.feeValue, + }, +}); +``` + +When `rotations` is omitted from an estimate, the SDK funds every round through +the chain's `defaultConsensusMaxRotations`. Pass `rotations` explicitly—including +`[0n]`—when the transaction should use a lower rotation budget. + +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 `estimateTransactionFees` for transactions +that can emit funded messages. + +Use the SDK call-key helpers when targeting a specific emitted message. Internal +messages are keyed by the GenVM method name; external EVM messages are keyed by +the first 4 bytes of the calldata selector. + +```typescript +import { + MessageType, + deriveExternalMessageCallKey, + deriveInternalMessageCallKey, + encodeExternalMessageFeeParams, + encodeInternalMessageFeeParams, +} from "genlayer-js"; + +const estimate = await client.estimateTransactionFees({ + messageAllocations: [ + { + messageType: MessageType.Internal, + recipient: childContractAddress, + callKey: deriveInternalMessageCallKey("settle_campaign"), + budget: 700_000n, + feeParams: encodeInternalMessageFeeParams({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + }), + }, + { + messageType: MessageType.External, + recipient: tokenAddress, + callKey: deriveExternalMessageCallKey("0xa9059cbb"), + budget: 210_000n, + feeParams: encodeExternalMessageFeeParams({ + gasLimit: 21_000n, + maxGasPrice: 10n, + }), + }, + ], +}); +``` + +You can also pass the same preset to Studio/localnet simulation. On Studio, +`includeReceipt` uses `sim_call` so the returned object includes the GenVM +receipt and fee accounting report: + +```typescript +const recommended = await client.estimateTransactionFeesForWrite({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], +}); + +await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: recommended.distribution, + messageAllocations: recommended.messageAllocations, + feeValue: recommended.feeValue, + }, +}); +``` + +For tests or tools that need to inspect the raw simulation, use the explicit +two-step flow: + +```typescript +const simulation = await client.simulateWriteContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: estimate.distribution, + feeValue: estimate.feeValue, + }, + includeReceipt: true, +}); + +console.log(simulation.feeAccounting); + +const recommended = await client.estimateTransactionFeesFromSimulation({ + simulation, +}); + +await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: recommended.distribution, + messageAllocations: recommended.messageAllocations, + feeValue: recommended.feeValue, + }, +}); +``` + +For transactions that are already submitted, use the fee-management helpers: + +```typescript +await client.topUpFees({ + txId, + value: 1_100n, + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + rotations: [0n], + }, +}); + +await client.topUpAndSubmitAppeal({ + txId, + value: 1_400n, + distribution: { + appealRounds: 1n, + rotations: [0n, 0n], + }, +}); +``` + +`topUpFees` 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 `txExecutionResult` before reading contract state: ```typescript -import { ExecutionResult, TransactionStatus } from "genlayer-js/types"; +import { ExecutionResult } from "genlayer-js/types"; -const receipt = await client.waitForTransactionReceipt({ +const receipt = await client.waitForFinalization({ hash: txHash, - status: TransactionStatus.FINALIZED, }); if (receipt.txExecutionResultName === ExecutionResult.FINISHED_WITH_RETURN) { @@ -138,6 +295,27 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); +// The default model is derived from the exact stored state. Processing entries +// have a phase; decided entries have an outcome. +console.log(tx.lifecycle); +// {state: "processing", phase: "revealing"} +// {state: "decided", outcome: "accepted"} + +// Advanced protocol consumers can explicitly request timestamp projection and +// the exact resolution action/source. Current Studio can prove only the stored +// status, so unsupported action/decision fields remain inactive there. +const protocolLifecycle = await client.advanced.getTransactionLifecycle({ hash: txHash }); +console.log(protocolLifecycle.storedStatus); +console.log(protocolLifecycle.projectedStatus); +console.log(protocolLifecycle.resolutionSource); +if (protocolLifecycle.resolutionAction === "Finalize") { + // Finalize is the protocol's current action/capability, not a transaction status. +} + +// The train retains the authoritative execution hash, not the old receipt +// bytes. `txReceipt` is therefore unavailable on train transactions. +console.log(tx.txExecutionHash); + // Messages emitted by the contract during execution console.log(tx.messages); // [{messageType, recipient, value, data, onAcceptance, saltNonce}, ...] @@ -164,6 +342,80 @@ console.log(trace.stderr); // standard error output console.log(trace.genvm_log); // detailed GenVM execution logs ``` +### Using with a wallet provider (MetaMask) + +When building a browser dApp, create two clients: one for reads (no wallet needed) and one for writes (signed by the wallet). This follows the standard viem pattern and keeps concerns separated. + +```typescript +import { createClient } from "genlayer-js"; +import { testnetBradbury } from "genlayer-js/chains"; +import { TransactionStatus } from "genlayer-js/types"; + +// Read client — talks directly to GenLayer RPC, no wallet needed +const readClient = createClient({ + chain: testnetBradbury, +}); + +// Write client — signs transactions through the wallet +const writeClient = createClient({ + chain: testnetBradbury, + account: address as `0x${string}`, // from wallet connection + provider: window.ethereum, // or from a wallet SDK +}); + +// Use readClient for all reads +const result = await readClient.readContract({ + address: contractAddress, + functionName: "get_storage", + args: [], +}); + +const tx = await readClient.getTransaction({ hash: txHash }); + +// Use writeClient for transactions (MetaMask popup) +const txHash = await writeClient.writeContract({ + address: contractAddress, + functionName: "update_storage", + args: ["new_value"], + value: BigInt(0), +}); + +// Either client can wait for receipts +const receipt = await readClient.waitForTransactionReceipt({ + hash: txHash, + status: TransactionStatus.ACCEPTED, +}); +``` + +### Switching the wallet to the correct network + +When using MetaMask or another browser wallet, the wallet may be connected to a different chain than what your client is configured for. Use `client.connect()` to switch the wallet to the correct GenLayer network before sending transactions: + +```typescript +import { createClient } from "genlayer-js"; +import { studionet } from "genlayer-js/chains"; + +const client = createClient({ + chain: studionet, + account: address as `0x${string}`, +}); + +// Switch MetaMask to the correct chain (adds the network if not present) +await client.connect("studionet"); + +// Now transactions will go to the right network +const txHash = await client.writeContract({ + address: contractAddress, + functionName: "create_profile", + args: ["alice", "Hello world"], + value: BigInt(0), +}); +``` + +Available networks: `"localnet"`, `"studionet"`, `"testnetAsimov"`, `"testnetBradbury"`. + +> **Note:** If the wallet is on the wrong chain when you call `writeContract`, the SDK will throw a clear error telling you which chain the wallet is on vs. which chain the client expects. Call `client.connect()` to resolve this. + ### Staking Operations The SDK provides staking functionality for validators and delegators on testnet-bradbury (and testnet-asimov). @@ -195,9 +447,12 @@ const epochInfo = await client.getEpochInfo(); // totalClaimed: "500 GEN", // Total claimed rewards // } -// Get active validators +// Get validators currently eligible for consensus duties const validators = await client.getActiveValidators(); +// Inspect every identity in the append-only joined registry +const joinedValidators = await client.getJoinedValidators(); + // Check if address is a validator const isValidator = await client.isValidator("0x..."); diff --git a/docs/api-references/staking.md b/docs/api-references/staking.md index da70e2c..6ceb872 100644 --- a/docs/api-references/staking.md +++ b/docs/api-references/staking.md @@ -17,11 +17,15 @@ Joins as a validator with the specified stake amount. ### validatorDeposit -Adds additional self-stake to an active validator position. +Adds additional self-stake to an active validator position. The +underlying Staking contract requires msg.sender == ValidatorWallet, +so the call is routed through the wallet's own validatorDeposit +forwarder (which re-enters Staking with the correct sender). | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | amount | `bigint \| string` | yes | | +| validator | `Address` | yes | | **Returns:** `StakingTransactionResult` @@ -29,11 +33,13 @@ Adds additional self-stake to an active validator position. ### validatorExit -Exits a validator position by burning the specified shares. +Exits a validator position by burning the specified shares. Same +msg.sender constraint as validatorDeposit — routed via the wallet. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | shares | `bigint \| string` | yes | | +| validator | `Address` | yes | | **Returns:** `StakingTransactionResult` @@ -65,7 +71,7 @@ Primes a validator for participation in the next epoch. ### setOperator -Sets the operator address for a validator wallet. +@deprecated Use initiateOperatorTransfer followed by completeOperatorTransfer. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -76,6 +82,57 @@ Sets the operator address for a validator wallet. --- +### initiateOperatorTransfer + +Starts the two-step operator rotation. The proof is checked against the +wallet-bound context before submission so a registration built for the +wrong registrar fails locally instead of as an opaque on-chain revert. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| options | `InitiateOperatorTransferOptions` | yes | | + +**Returns:** `StakingTransactionResult` + +--- + +### completeOperatorTransfer + +Completes a pending rotation. Callable by the wallet owner or the pending +operator, and only once the factory's operatorTransferDelay has elapsed. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| options | `CompleteOperatorTransferOptions` | yes | | + +**Returns:** `StakingTransactionResult` + +--- + +### cancelOperatorTransfer + +Abandons a pending rotation, leaving the current operator in place. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| options | `CancelOperatorTransferOptions` | yes | | + +**Returns:** `StakingTransactionResult` + +--- + +### getPendingOperator + +Reads the pending operator and when its transfer was initiated. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| validator | `Address` | yes | | + +**Returns:** `PendingOperatorInfo` + +--- + ### setIdentity Sets validator identity information (name, website, social links). @@ -138,7 +195,7 @@ Claims pending delegator withdrawals. ### isValidator -Checks if an address is an active validator. +Checks whether an address is a registered/joined validator wallet. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -160,6 +217,28 @@ Returns comprehensive information about a validator including stake, identity, a --- +### getCurrentEpoch + +Returns the current epoch number. + +_No parameters._ + +**Returns:** `bigint` + +--- + +### isValidatorBelowMin + +Checks whether a validator's self-stake is below the configured validator minimum. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| validator | `Address` | yes | | + +**Returns:** `boolean` + +--- + ### getStakeInfo Returns delegation stake information for a delegator-validator pair. @@ -197,7 +276,7 @@ Returns detailed data for a specific epoch. ### getActiveValidators -Returns addresses of all currently active validators. +Returns validators currently eligible for consensus duties. _No parameters._ @@ -207,7 +286,27 @@ _No parameters._ ### getActiveValidatorsCount -Returns the count of active validators. +Returns the count of validators currently eligible for consensus duties. + +_No parameters._ + +**Returns:** `bigint` + +--- + +### getJoinedValidators + +Returns every validator identity in the append-only joined registry. + +_No parameters._ + +**Returns:** `Address[]` + +--- + +### getJoinedValidatorsCount + +Returns the size of the append-only joined validator registry. _No parameters._ diff --git a/docs/api-references/transactions.md b/docs/api-references/transactions.md index 69a518d..09eb1d6 100644 --- a/docs/api-references/transactions.md +++ b/docs/api-references/transactions.md @@ -9,7 +9,8 @@ Polls until a transaction reaches the specified status. Returns the transaction | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | hash | `TransactionHash` | yes | | -| status | `TransactionStatus` | yes | | +| status | `TransactionStatus` | no | | +| waitUntil | `TransactionReceiptWaitUntil` | no | | | interval | `number` | no | | | retries | `number` | no | | | fullTransaction | `boolean` | no | | @@ -18,9 +19,59 @@ Polls until a transaction reaches the specified status. Returns the transaction --- +### waitForDecision + +Polls until the stored transaction state contains a materialized decision. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | +| interval | `number` | no | | +| retries | `number` | no | | +| fullTransaction | `boolean` | no | | + +**Returns:** `GenLayerTransaction` + +--- + +### waitForFinalization + +Polls until the stored transaction state is finalized. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | +| interval | `number` | no | | +| retries | `number` | no | | +| fullTransaction | `boolean` | no | | + +**Returns:** `GenLayerTransaction` + +--- + +### getTransactionLifecycle + +`advanced.getTransactionLifecycle` exposes stored/projected status, +resolution action/source, and active decision identity. Contract networks +use one fixed-block lifecycle read. `Finalize` is an action, not a status +or separate readiness field. +A Studio deployment that does not yet serve `gen_getTransactionLifecycle` +degrades to the stored status its consumer surface does prove, rather +than failing the whole read. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | +| timestamp | `number` | no | | + +**Returns:** `TransactionProtocolLifecycle` + +--- + ### getTransaction -Fetches transaction data including status, execution result, and consensus details. +Fetches a transaction with a simple stored lifecycle and split round data. +Use advanced.getTransactionLifecycle for protocol projection/action details. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -67,6 +118,18 @@ Cancels a pending transaction. Studio networks only. --- +### getTransactionQueuePosition + +Returns the queue slot position of a transaction in the pending queue. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | + +**Returns:** `number` + +--- + ### estimateTransactionGas Estimates gas required for a transaction. diff --git a/docs/api-references/types.Enumeration.TransactionStatus.md b/docs/api-references/types.Enumeration.TransactionStatus.md index a774a1e..2fdd594 100644 --- a/docs/api-references/types.Enumeration.TransactionStatus.md +++ b/docs/api-references/types.Enumeration.TransactionStatus.md @@ -52,6 +52,14 @@ Defined in: [types/transactions.ts:15](https://github.com/genlayerlabs/genlayer- *** +### LEADER\_REVEALING + +> **LEADER\_REVEALING**: `"LEADER_REVEALING"` + +Defined in: [types/transactions.ts:23](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/transactions.ts#L23) + +*** + ### LEADER\_TIMEOUT > **LEADER\_TIMEOUT**: `"LEADER_TIMEOUT"` @@ -76,14 +84,6 @@ Defined in: [types/transactions.ts:10](https://github.com/genlayerlabs/genlayer- *** -### READY\_TO\_FINALIZE - -> **READY\_TO\_FINALIZE**: `"READY_TO_FINALIZE"` - -Defined in: [types/transactions.ts:19](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/transactions.ts#L19) - -*** - ### REVEALING > **REVEALING**: `"REVEALING"` diff --git a/docs/api-references/types.Variable.transactionsStatusNumberToName.md b/docs/api-references/types.Variable.transactionsStatusNumberToName.md index aa81168..0ed1f98 100644 --- a/docs/api-references/types.Variable.transactionsStatusNumberToName.md +++ b/docs/api-references/types.Variable.transactionsStatusNumberToName.md @@ -20,15 +20,15 @@ Defined in: [types/transactions.ts:40](https://github.com/genlayerlabs/genlayer- ### 11 -> **11**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.READY_TO_FINALIZE` +> **11**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.VALIDATORS_TIMEOUT` ### 12 -> **12**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.VALIDATORS_TIMEOUT` +> **12**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.LEADER_TIMEOUT` ### 13 -> **13**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.LEADER_TIMEOUT` +> **13**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.LEADER_REVEALING` ### 2 diff --git a/src/abi/consensusTrain.ts b/src/abi/consensusTrain.ts new file mode 100644 index 0000000..38b6a95 --- /dev/null +++ b/src/abi/consensusTrain.ts @@ -0,0 +1,41 @@ +import {Abi, parseAbi} from "viem"; + +// Keep these ABIs runtime-validated without asking TypeScript to expand the very +// large lifecycle tuple into a recursive inferred type. +const parseTrainAbi = parseAbi as (signatures: readonly string[]) => Abi; + +/** Minimal train ABIs used by the high-level transaction reader. */ +export const CONSENSUS_DATA_TRAIN_ABI = parseTrainAbi([ + "function addressManager() view returns (address)", + "function getTransactionLifecycle(bytes32 _txId, uint256 _timestamp) view returns ((uint8 storedStatus, (bytes32 txId, uint8 storedStatus, uint8 projectedStatus, uint8 action, uint8 result, bytes32 resultHash, uint8 source, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint256 basisDecisionId, uint8 context, bytes32 attemptId, uint256 boundaryAt, uint256 evaluatedAt, uint256 snapshotBlock, uint256 decisionWindow, uint256 appealDeadline, bool materializesDecision, bool actionOutcomeDeterministic, bool nonCurrentEvaluation) resolution, (bool exists, uint256 decisionId, uint256 basisDecisionId, uint8 context, uint8 source, bytes32 sourceAttemptId, uint8 sourceStatus, uint8 status, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint8 result, bytes32 resultHash, uint256 effectiveAt, uint256 materializedAt, uint256 appealDeadline) latestDecision, bool decisionActive) lifecycle)", + "function estimateLatestAppealCharge(bytes32 _txId) view returns (uint256 decisionId, uint256 bond, uint256 funding, uint256 appealDeadline)", +]); + +export const ADDRESS_MANAGER_TRAIN_ABI = parseTrainAbi([ + "function getAddress(string _name) view returns (address)", +]); + +export const CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI = parseTrainAbi([ + "function getStoredTransactionDataLight(bytes32 _txId) view returns ((uint256 observedAt, address sender, address recipient, uint256 initialRotations, uint256 txSlot, uint256 createdTimestamp, uint256 lastVoteTimestamp, bytes32 randomSeed, uint8 result, bytes32 txExecutionHash, bytes txCalldata, bytes eqBlocksOutputs, (uint8 messageType, address recipient, uint256 value, bytes data, bool onAcceptance, uint256 saltNonce, bytes feeParams, uint256 declaredBudget, bytes allocationSubtree, bytes32 callKey, bool useBalance)[] messages, uint8 queueType, uint256 queuePosition, address activator, address lastLeader, uint8 status, bytes32 txId, (uint256 activationBlock, uint256 processingBlock, uint256 proposalBlock) readStateBlockRange, uint256 numOfRounds, (uint256 round, uint256 leaderIndex, uint256 votesCommitted, uint256 votesRevealed, uint256 appealBond, uint256 rotationsLeft, uint8 result, uint256 validatorsCount) lastRound, uint256 consumedValidatorsCount) transaction)", + "function getRoundValidatorsPaged(bytes32 _txId, uint256 _round, uint256 _offset, uint256 _limit) view returns (address[] page, uint256 total)", + "function getConsumedValidatorsPaged(bytes32 _txId, uint256 _offset, uint256 _limit) view returns (address[] page, uint256 total)", +]); + +export const ROUNDS_STORAGE_TRAIN_READ_ABI = parseTrainAbi([ + "function getRoundNumber(bytes32 txId) view returns (uint256)", + "function getLeaderIndex(bytes32 txId, uint256 round) view returns (uint256)", + "function getVotesCommitted(bytes32 txId, uint256 round) view returns (uint256)", + "function getVotesRevealed(bytes32 txId, uint256 round) view returns (uint256)", + "function getAppealBond(bytes32 txId, uint256 round) view returns (uint256)", + "function getRotationsLeft(bytes32 txId, uint256 round) view returns (uint256)", + "function getResult(bytes32 txId, uint256 round) view returns (uint8)", + "function getRoundValidatorsPage(bytes32 txId, uint256 round, uint256 offset, uint256 pageSize) view returns (address[] validators, uint256 total)", + "function getValidatorVotes(bytes32 txId, uint256 round) view returns (uint8[] votes)", + "function getValidatorVotesHash(bytes32 txId, uint256 round) view returns (bytes32[] hashes)", + "function getValidatorResultHash(bytes32 txId, uint256 round) view returns (bytes32[] hashes)", +]); + +export const TRANSACTION_MANAGER_TRAIN_READ_ABI = parseTrainAbi([ + "function getTxExecutionResult(bytes32 txId) view returns (uint8)", + "function getNumOfInitialValidators(bytes32 txId) view returns (uint256)", +]); diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 9f3ea14..56bc51f 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -48,18 +48,9 @@ export const VALIDATOR_WALLET_ABI = [ }, ], }, - { - name: "setOperator", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_operator", type: "address"}], - outputs: [], - }, - // Two-step operator rotation (CON-715). setOperator above is the single-call - // predecessor and is absent from newer consensus deployments, so callers pick - // whichever the deployed wallet exposes. Unlike validatorJoin, the possession - // proof here is verified by the wallet itself, so its registrar is the wallet - // address rather than the ValidatorWalletFactory. + // Two-step operator rotation (CON-715). The possession proof is verified by + // the wallet itself, so its registrar is the wallet address rather than the + // ValidatorWalletFactory. { name: "initiateOperatorTransfer", type: "function", @@ -142,6 +133,22 @@ export const VALIDATOR_WALLET_ABI = [ }, ] as const; +const STAKING_CLAIM_COMPONENTS = [ + {name: "quantity", type: "uint120"}, + {name: "offset", type: "uint120"}, + {name: "commit", type: "uint256"}, +] as const; + +const STAKING_COMMIT_COMPONENTS = [ + {name: "input", type: "uint256"}, + {name: "output", type: "uint256"}, + {name: "outstanding", type: "uint120"}, + {name: "epoch", type: "uint64"}, + {name: "linkToNextCommit", type: "uint56"}, + {name: "priced", type: "bool"}, + {name: "fragmented", type: "bool"}, +] as const; + export const STAKING_ABI = [ // Custom errors from IGenLayerStaking {name: "BurnTransferFailed", type: "error", inputs: []}, @@ -501,14 +508,14 @@ export const STAKING_ABI = [ // Functions { - name: "activeValidators", + name: "selectableValidators", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address[]"}], }, { - name: "activeValidatorsCount", + name: "selectableValidatorsCount", type: "function", stateMutability: "view", inputs: [], @@ -572,20 +579,12 @@ export const STAKING_ABI = [ { name: "claim_", type: "tuple", - components: [ - {name: "quantity", type: "uint256"}, - {name: "commit", type: "uint256"}, - ], + components: STAKING_CLAIM_COMPONENTS, }, { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -602,12 +601,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -651,20 +645,12 @@ export const STAKING_ABI = [ { name: "claim_", type: "tuple", - components: [ - {name: "quantity", type: "uint256"}, - {name: "commit", type: "uint256"}, - ], + components: STAKING_CLAIM_COMPONENTS, }, { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -681,12 +667,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1201,12 +1182,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1229,12 +1205,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1340,10 +1311,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "left", type: "address"}, - {name: "right", type: "address"}, - {name: "parent", type: "address"}, - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1366,10 +1334,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "left", type: "address"}, - {name: "right", type: "address"}, - {name: "parent", type: "address"}, - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1392,10 +1357,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "left", type: "address"}, - {name: "right", type: "address"}, - {name: "parent", type: "address"}, - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1421,12 +1383,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1442,12 +1399,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1486,13 +1438,6 @@ export const STAKING_ABI = [ inputs: [], outputs: [{name: "", type: "uint256"}], }, - { - name: "validatorsRoot", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{name: "", type: "address"}], - }, ] as const; // Slash contract ABI for slashing events @@ -1516,93 +1461,3 @@ export const SLASH_ABI = [ ], }, ] as const; - -/** - * The staking Claim/Commit views as consensus exposes them after CON-715. - * - * That change widened both structs — Claim gained `offset`, Commit gained - * `outstanding`/`priced`/`fragmented` and narrowed several members — while - * keeping the same function names and argument lists. Static tuples decode - * positionally, so reading a post-CON-715 chain with the older shape in - * STAKING_ABI silently returns the wrong words rather than failing: `commit.input` - * picks up `claim.commit`, which is why pending deposits read back as small - * indices instead of amounts. - * - * Both shapes are still in the wild, so neither can simply replace the other. - * stakingActions probes once per client and then reads with whichever matches. - * Decoding the OLD layout with this one throws (the response is too short), - * which is what makes the probe possible; the reverse direction is the silent - * one, so the current shape must always be tried first. - */ -const CURRENT_CLAIM_COMPONENTS = [ - {name: "quantity", type: "uint96"}, - {name: "offset", type: "uint96"}, - {name: "commit", type: "uint256"}, -] as const; - -const CURRENT_COMMIT_COMPONENTS = [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "outstanding", type: "uint120"}, - {name: "epoch", type: "uint64"}, - {name: "linkToNextCommit", type: "uint56"}, - {name: "priced", type: "bool"}, - {name: "fragmented", type: "bool"}, -] as const; - -export const STAKING_COMMIT_VIEWS_CURRENT_ABI = [ - { - name: "delegatorDeposit", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_delegator", type: "address"}, - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, - { - name: "delegatorWithdrawal", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_delegator", type: "address"}, - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, - { - name: "validatorDeposit", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "epoch_", type: "uint256"}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, - { - name: "validatorWithdrawal", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "epoch_", type: "uint256"}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, -] as const; diff --git a/src/abi/studioConsensus.ts b/src/abi/studioConsensus.ts new file mode 100644 index 0000000..d428dd4 --- /dev/null +++ b/src/abi/studioConsensus.ts @@ -0,0 +1,105 @@ +import type {Abi} from "viem"; + +const feesDistributionComponents = [ + {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"}, +] as const; + +const messageAllocationComponents = [ + {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"}, +] as const; + +const addTransactionParamsComponents = [ + {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: feesDistributionComponents}, + {name: "txCalldata", type: "bytes"}, + {name: "messageAllocations", type: "tuple[]", components: messageAllocationComponents}, +] as const; + +/** + * Current Studio transaction surface. Fee-aware admission and top-up are + * already live, while appeal and finalization remain decision-free. Studio's + * decision-bound parity work is qualified and released independently. + */ +export const studioConsensusMainAbi = [ + { + type: "function", + name: "addTransaction", + stateMutability: "payable", + inputs: [{name: "_params", type: "tuple", components: addTransactionParamsComponents}], + outputs: [], + }, + { + type: "function", + name: "deploySalted", + stateMutability: "payable", + inputs: [{name: "_params", type: "tuple", components: addTransactionParamsComponents}], + outputs: [], + }, + { + type: "function", + name: "topUpFees", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, + ], + outputs: [], + }, + { + type: "function", + name: "topUpAndSubmitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, + ], + outputs: [], + }, + { + type: "function", + name: "submitAppeal", + stateMutability: "payable", + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, + { + type: "function", + name: "finalizeTransaction", + stateMutability: "nonpayable", + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, +] as const satisfies Abi; + +const overriddenNames = new Set(studioConsensusMainAbi.map((entry) => entry.name)); + +export const withStudioConsensusMainAbi = (base: readonly unknown[]): Abi => { + const retained = base.filter((rawEntry) => { + const entry = rawEntry as {type?: string; name?: string}; + return entry.type !== "function" || + entry.name === undefined || + !overriddenNames.has(entry.name as typeof studioConsensusMainAbi[number]["name"]); + }); + return [...retained, ...studioConsensusMainAbi] as Abi; +}; diff --git a/src/chains/localnet.ts b/src/chains/localnet.ts index 733a8d3..b15f8d8 100644 --- a/src/chains/localnet.ts +++ b/src/chains/localnet.ts @@ -1,9 +1,10 @@ import {Address, defineChain} from "viem"; import {GenLayerChain} from "@/types"; +import {withStudioConsensusMainAbi} from "@/abi/studioConsensus"; // chains/localnet.ts const SIMULATOR_JSON_RPC_URL = "http://127.0.0.1:4000/api"; -const CONSENSUS_MAIN_CONTRACT = { +const CONSENSUS_MAIN_CONTRACT_BASE = { address: "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" as Address, abi: [ { @@ -1435,6 +1436,11 @@ const CONSENSUS_MAIN_CONTRACT = { bytecode: "", }; +const CONSENSUS_MAIN_CONTRACT = { + ...CONSENSUS_MAIN_CONTRACT_BASE, + abi: withStudioConsensusMainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), +}; + const CONSENSUS_DATA_CONTRACT = { address: "0x88B0F18613Db92Bf970FfE264E02496e20a74D16" as Address, abi: [ diff --git a/src/chains/studionet.ts b/src/chains/studionet.ts index 4d9de5f..8a8c7cb 100644 --- a/src/chains/studionet.ts +++ b/src/chains/studionet.ts @@ -1,10 +1,11 @@ import {Address, defineChain} from "viem"; import {GenLayerChain} from "@/types"; +import {withStudioConsensusMainAbi} from "@/abi/studioConsensus"; // chains/localnet.ts const SIMULATOR_JSON_RPC_URL = "https://studio.genlayer.com/api"; const EXPLORER_URL = "https://genlayer-explorer.vercel.app"; -const CONSENSUS_MAIN_CONTRACT = { +const CONSENSUS_MAIN_CONTRACT_BASE = { address: "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" as Address, abi: [ { @@ -1436,6 +1437,11 @@ const CONSENSUS_MAIN_CONTRACT = { bytecode: "", }; +const CONSENSUS_MAIN_CONTRACT = { + ...CONSENSUS_MAIN_CONTRACT_BASE, + abi: withStudioConsensusMainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), +}; + const CONSENSUS_DATA_CONTRACT = { address: "0x88B0F18613Db92Bf970FfE264E02496e20a74D16" as Address, abi: [ diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 93a8f35..d2de6c3 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -26,9 +26,10 @@ import { SimulateWriteContractResult, StudioExecutionFeeReport, StudioFeeAccounting, + ConsensusRoundData, + ConsensusLastRoundData, } from "@/types"; import {fromHex, toHex, zeroAddress, encodeFunctionData, PublicClient, parseEventLogs, type Abi} from "viem"; -import {TransactionHash} from "@/types/transactions"; import {toJsonSafeDeep, b64ToArray, arrayToB64} from "@/utils/jsonifier"; import { CALL_KEY_WILDCARD, @@ -38,6 +39,7 @@ import { normalizeTransactionFees, NormalizedTransactionFees, } from "@/transactions/fees"; +import {CONSENSUS_DATA_TRAIN_ABI, ROUNDS_STORAGE_TRAIN_READ_ABI} from "@/abi/consensusTrain"; const prefixHex = (hex: string): `0x${string}` => { return (hex.startsWith("0x") ? hex : `0x${hex}`) as `0x${string}`; @@ -454,10 +456,17 @@ export const contractActions = (client: GenLayerClient, publicCli getCurrentFeePolicy: async (): Promise => { return readCurrentFeePolicy(client, publicClient); }, - /** Builds a fee distribution with caps derived from the active fee policy. */ + /** + * Builds a fee distribution with caps derived from the active fee policy. + * Omitted rotations fund the chain's configured consensus maximum. + */ estimateFeesDistribution: async (args?: FeeEstimateOptions): Promise => { const policy = await readCurrentFeePolicy(client, publicClient); - return buildEstimatedFeesDistribution(args, policy); + return buildEstimatedFeesDistribution( + args, + policy, + client.chain.defaultConsensusMaxRotations, + ); }, /** * Builds a complete transaction `fees` object, including feeValue. @@ -466,7 +475,11 @@ export const contractActions = (client: GenLayerClient, publicCli */ estimateTransactionFees: async (args?: FeeEstimateOptions): Promise => { const policy = await readCurrentFeePolicy(client, publicClient); - const distribution = buildEstimatedFeesDistribution(args, policy); + const distribution = buildEstimatedFeesDistribution( + args, + policy, + client.chain.defaultConsensusMaxRotations, + ); return { distribution, @@ -487,7 +500,11 @@ export const contractActions = (client: GenLayerClient, publicCli const policy = await readCurrentFeePolicy(client, publicClient); const {estimateOptions, observed, messageAllocations} = buildEstimatedFeesOptionsFromSimulation(args, policy); - const distribution = buildEstimatedFeesDistribution(estimateOptions, policy); + const distribution = buildEstimatedFeesDistribution( + estimateOptions, + policy, + client.chain.defaultConsensusMaxRotations, + ); return { distribution, @@ -521,7 +538,11 @@ export const contractActions = (client: GenLayerClient, publicCli } = args; const policy = await readCurrentFeePolicy(client, publicClient); - const initialDistribution = buildEstimatedFeesDistribution(feeOptions, policy); + const initialDistribution = buildEstimatedFeesDistribution( + feeOptions, + policy, + client.chain.defaultConsensusMaxRotations, + ); const initialEstimate: TransactionFeeEstimate = { distribution: initialDistribution, messageAllocations: feeOptions.messageAllocations, @@ -589,7 +610,11 @@ export const contractActions = (client: GenLayerClient, publicCli }, policy, ); - const distribution = buildEstimatedFeesDistribution(estimateOptions, policy); + const distribution = buildEstimatedFeesDistribution( + estimateOptions, + policy, + client.chain.defaultConsensusMaxRotations, + ); return { distribution, @@ -599,32 +624,25 @@ export const contractActions = (client: GenLayerClient, publicCli observed, }; }, - /** Calculates the minimum bond required to appeal a transaction. */ + /** + * Returns the full authoritative appeal charge (bond plus appeal funding) + * on resolution-kernel contract networks. Current Studio has no + * decision-bound quote surface. + */ + getAppealCharge: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); + } + const context = await _readAppealContext({client, publicClient, txId: args.txId}); + return context.requiredValue; + }, + /** @deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { - const {txId} = args; - - if (!client.chain.feeManagerContract?.address || !client.chain.roundsStorageContract?.address) { - throw new Error("Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"); + if (client.chain.isStudio) { + throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); } - - const roundNumber = await publicClient.readContract({ - address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getRoundNumber", - args: [txId], - }) as bigint; - - const transaction = await client.getTransaction({hash: txId as TransactionHash}); - const txStatus = Number(transaction.status); - - const minBond = await publicClient.readContract({ - address: client.chain.feeManagerContract.address as `0x${string}`, - abi: client.chain.feeManagerContract.abi as Abi, - functionName: "calculateMinAppealBond", - args: [txId, roundNumber, txStatus], - }) as bigint; - - return minBond; + const context = await _readAppealContext({client, publicClient, txId: args.txId}); + return context.requiredValue; }, /** Returns the current consensus round number for a transaction. */ getRoundNumber: async (args: {txId: `0x${string}`}): Promise => { @@ -633,7 +651,7 @@ export const contractActions = (client: GenLayerClient, publicCli } return publicClient.readContract({ address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, functionName: "getRoundNumber", args: [args.txId], }) as Promise; @@ -643,11 +661,13 @@ export const contractActions = (client: GenLayerClient, publicCli if (!client.chain.roundsStorageContract?.address) { throw new Error("getRoundData not supported on this chain (missing roundsStorageContract)"); } - return publicClient.readContract({ + const snapshot = await publicClient.getBlock(); + return _readRoundDataSnapshot({ + publicClient, address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getRoundData", - args: [args.txId, args.round], + txId: args.txId, + round: args.round, + blockNumber: snapshot.number, }); }, /** Returns the current round number and its data for a transaction. */ @@ -655,23 +675,40 @@ export const contractActions = (client: GenLayerClient, publicCli if (!client.chain.roundsStorageContract?.address) { throw new Error("getLastRoundData not supported on this chain (missing roundsStorageContract)"); } - return publicClient.readContract({ - address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getLastRoundData", + const snapshot = await publicClient.getBlock(); + const address = client.chain.roundsStorageContract.address as `0x${string}`; + const round = await publicClient.readContract({ + address, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getRoundNumber", args: [args.txId], + blockNumber: snapshot.number, + }) as bigint; + const roundData = await _readRoundDataSnapshot({ + publicClient, + address, + txId: args.txId, + round, + blockNumber: snapshot.number, }); + return Object.assign( + [round, roundData] as [bigint, ConsensusRoundData], + {round, roundData}, + ) as ConsensusLastRoundData; }, /** Checks if a transaction can be appealed. */ canAppeal: async (args: {txId: `0x${string}`}): Promise => { if (!client.chain.appealsContract?.address) { throw new Error("canAppeal not supported on this chain (missing appealsContract)"); } + const context = await _readLifecycleIdentity({client, publicClient, txId: args.txId}); + if (!context.decisionActive) return false; return publicClient.readContract({ address: client.chain.appealsContract.address as `0x${string}`, - abi: client.chain.appealsContract.abi as Abi, + abi: APPEALS_TRAIN_ABI, functionName: "canAppeal", - args: [args.txId], + args: [args.txId, context.decisionId], + blockNumber: context.blockNumber, }) as Promise; }, /** Returns a developer's NFT reward record, or null when no NFT is registered. */ @@ -792,20 +829,47 @@ export const contractActions = (client: GenLayerClient, publicCli operationName: "Claim NFT epochs", }); }, - /** Appeals a consensus transaction to trigger a new round of validation. */ + /** + * Appeals a consensus transaction to trigger a new round of validation. + * Contract networks bind the call to the active decision and quote an + * omitted value. Current Studio uses its native decision-free entrypoint; + * its value defaults to zero when omitted. + */ appealTransaction: async (args: { account?: Account; txId: `0x${string}`; value?: bigint; }) => { const {account, txId} = args; - const value = await _resolveAppealValue({client, publicClient, txId, value: args.value}); - const senderAccount = account || client.account; - const encodedData = _encodeSubmitAppealData({client, txId}); + // Appeals don't go through _sendTransaction because submitAppeal emits // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. // The appeal operates on the same GenLayer txId, so we return it directly. + if (client.chain.isStudio) { + await _sendConsensusCall({ + client, + publicClient, + encodedData: _encodeStudioSubmitAppealData({txId}), + senderAccount, + value: args.value ?? 0n, + operationName: "Appeal", + }); + return txId; + } + + const context = await _readAppealContext({ + client, + publicClient, + txId, + includeQuote: args.value === undefined, + }); + const value = args.value ?? context.requiredValue; + + const encodedData = _encodeSubmitAppealData({ + txId, + expectedDecisionId: context.decisionId, + }); await _sendConsensusCall({ client, publicClient, @@ -842,6 +906,9 @@ export const contractActions = (client: GenLayerClient, publicCli /** * Deposits appeal fee budget and submits an appeal in the same consensus call. * Returns the existing GenLayer transaction id, matching appealTransaction. + * Contract networks bind the call to the active decision and quote an + * omitted value. Current Studio uses its native decision-free entrypoint; + * its value defaults to zero when omitted. */ topUpAndSubmitAppeal: async (args: { account?: Account; @@ -850,10 +917,33 @@ export const contractActions = (client: GenLayerClient, publicCli value?: bigint; }): Promise<`0x${string}`> => { const {account, txId, distribution} = args; - const value = await _resolveAppealValue({client, publicClient, txId, value: args.value}); - const senderAccount = account || client.account; - const encodedData = _encodeTopUpAndSubmitAppealData({txId, distribution}); + + if (client.chain.isStudio) { + await _sendConsensusCall({ + client, + publicClient, + encodedData: _encodeStudioTopUpAndSubmitAppealData({txId, distribution}), + senderAccount, + value: args.value ?? 0n, + operationName: "Top up and submit appeal", + }); + return txId; + } + + const context = await _readAppealContext({ + client, + publicClient, + txId, + includeQuote: args.value === undefined, + }); + const value = args.value ?? context.requiredValue; + + const encodedData = _encodeTopUpAndSubmitAppealData({ + txId, + expectedDecisionId: context.decisionId, + distribution, + }); await _sendConsensusCall({ client, publicClient, @@ -871,10 +961,34 @@ export const contractActions = (client: GenLayerClient, publicCli }): Promise<`0x${string}`> => { const {account, txId} = args; const senderAccount = account || client.account; + + if (client.chain.isStudio) { + return _sendConsensusCall({ + client, + publicClient, + encodedData: encodeFunctionData({ + abi: client.chain.consensusMainContract?.abi as any, + functionName: "finalizeTransaction", + args: [txId], + }), + senderAccount, + operationName: "Finalize", + }); + } + + const identity = await _readLifecycleIdentity({client, publicClient, txId}); + if (!identity.decisionActive) { + throw new Error(`Transaction ${txId} has no active decision to finalize`); + } + if (identity.resolutionAction !== 6) { + throw new Error( + `Transaction ${txId} is not ready to finalize (resolution action ${identity.resolutionAction})`, + ); + } const encodedData = encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, + abi: CONSENSUS_FINALIZATION_TRAIN_ABI, functionName: "finalizeTransaction", - args: [txId], + args: [txId, identity.decisionId], }); return _sendConsensusCall({ client, @@ -884,27 +998,103 @@ export const contractActions = (client: GenLayerClient, publicCli operationName: "Finalize", }); }, - /** Batch-finalizes idle GenLayer transactions (those stuck without progressing). Returns the EVM transaction hash. */ + /** + * @deprecated The train separates attempt-bound resolution from + * decision-bound finalization. Use resolveTransactions or + * finalizeDecisions after classifying the lifecycle action. + */ finalizeIdlenessTxs: async (args: { account?: Account; txIds: readonly `0x${string}`[]; }): Promise<`0x${string}`> => { - const {account, txIds} = args; - if (txIds.length === 0) { - throw new Error("finalizeIdlenessTxs requires at least one txId."); + throw new Error( + `finalizeIdlenessTxs(${args.txIds.length} transaction(s)) is unavailable on the train: ` + + "use resolveTransactions for attempt-bound lifecycle actions or finalizeDecisions for active decisions.", + ); + }, + /** Resolves a batch of attempt-bound lifecycle actions. */ + resolveTransactions: async (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }): Promise<`0x${string}`> => { + if (client.chain.isStudio) { + throw _studioTrainBatchError("resolveTransactions"); } - const senderAccount = account || client.account; + if (args.txIds.length === 0) { + throw new Error("resolveTransactions requires at least one txId."); + } + const snapshot = await publicClient.getBlock(); + const identities = await Promise.all(args.txIds.map(txId => + _readLifecycleIdentity({ + client, + publicClient, + txId, + blockNumber: snapshot.number, + blockTimestamp: snapshot.timestamp, + }), + )); const encodedData = encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "finalizeIdlenessTxs", - args: [txIds], + abi: CONSENSUS_FINALIZATION_TRAIN_ABI, + functionName: "resolveTransactions", + args: [args.txIds.map((txId, index) => ({ + txId, + expectedAttemptId: identities[index].attemptId, + }))], }); return _sendConsensusCall({ client, publicClient, encodedData, - senderAccount, - operationName: "Finalize idleness", + senderAccount: args.account || client.account, + operationName: "Resolve transactions", + }); + }, + /** Finalizes a batch of active, decision-bound transactions. */ + finalizeDecisions: async (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }): Promise<`0x${string}`> => { + if (client.chain.isStudio) { + throw _studioTrainBatchError("finalizeDecisions"); + } + if (args.txIds.length === 0) { + throw new Error("finalizeDecisions requires at least one txId."); + } + const snapshot = await publicClient.getBlock(); + const identities = await Promise.all(args.txIds.map(txId => + _readLifecycleIdentity({ + client, + publicClient, + txId, + blockNumber: snapshot.number, + blockTimestamp: snapshot.timestamp, + }), + )); + identities.forEach((identity, index) => { + if (!identity.decisionActive) { + throw new Error(`Transaction ${args.txIds[index]} has no active decision to finalize`); + } + if (identity.resolutionAction !== 6) { + throw new Error( + `Transaction ${args.txIds[index]} is not ready to finalize ` + + `(resolution action ${identity.resolutionAction})`, + ); + } + }); + const encodedData = encodeFunctionData({ + abi: CONSENSUS_FINALIZATION_TRAIN_ABI, + functionName: "finalizeDecisions", + args: [args.txIds.map((txId, index) => ({ + txId, + expectedDecisionId: identities[index].decisionId, + }))], + }); + return _sendConsensusCall({ + client, + publicClient, + encodedData, + senderAccount: args.account || client.account, + operationName: "Finalize decisions", }); }, }; @@ -931,39 +1121,6 @@ const CREATED_TRANSACTION_EVENT_ABI = [ }, ] as const; -const ADD_TRANSACTION_ABI_V5 = [ - { - type: "function", - name: "addTransaction", - stateMutability: "nonpayable", - inputs: [ - {name: "_sender", type: "address"}, - {name: "_recipient", type: "address"}, - {name: "_numOfInitialValidators", type: "uint256"}, - {name: "_maxRotations", type: "uint256"}, - {name: "_txData", type: "bytes"}, - ], - outputs: [], - }, -] as const; - -const ADD_TRANSACTION_ABI_V6 = [ - { - type: "function", - name: "addTransaction", - stateMutability: "payable", - inputs: [ - {name: "_sender", type: "address"}, - {name: "_recipient", type: "address"}, - {name: "_numOfInitialValidators", type: "uint256"}, - {name: "_maxRotations", type: "uint256"}, - {name: "_txData", type: "bytes"}, - {name: "_validUntil", type: "uint256"}, - ], - outputs: [], - }, -] as const; - const FEES_DISTRIBUTION_COMPONENTS = [ {name: "leaderTimeunitsAllocation", type: "uint256"}, {name: "validatorTimeunitsAllocation", type: "uint256"}, @@ -1023,6 +1180,21 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ ], outputs: [], }, + { + type: "function", + name: "topUpAndSubmitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + +/** Studio's current embedded consensus does not carry decision identities. */ +const CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI = [ { type: "function", name: "topUpAndSubmitAppeal", @@ -1035,6 +1207,73 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ }, ] as const; +const CONSENSUS_APPEAL_TRAIN_ABI = [ + { + type: "function", + name: "submitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, +] as const; + +const CONSENSUS_FINALIZATION_TRAIN_ABI = [ + { + type: "function", + name: "finalizeTransaction", + stateMutability: "nonpayable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, + { + type: "function", + name: "resolveTransactions", + stateMutability: "nonpayable", + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedAttemptId", type: "bytes32"}, + ], + }], + outputs: [], + }, + { + type: "function", + name: "finalizeDecisions", + stateMutability: "nonpayable", + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedDecisionId", type: "uint256"}, + ], + }], + outputs: [], + }, +] as const; + +const APPEALS_TRAIN_ABI = [ + { + type: "function", + name: "canAppeal", + stateMutability: "view", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [{name: "", type: "bool"}], + }, +] as const; + const FEE_MANAGER_CALCULATE_ROUND_FEES_ABI = [ { type: "function", @@ -1077,34 +1316,6 @@ const FEE_MANAGER_CALCULATE_ROUND_FEES_ABI = [ }, ] as const; -type AddTransactionAbiVersion = "fees" | "v6" | "v5"; - -const getAddTransactionAbiVersion = (abi: readonly unknown[] | undefined): AddTransactionAbiVersion => { - if (!abi || !Array.isArray(abi)) { - return "v5"; - } - - const addTransactionFunction = abi.find(item => { - if (!item || typeof item !== "object") { - return false; - } - - const candidate = item as {type?: string; name?: string}; - return candidate.type === "function" && candidate.name === "addTransaction"; - }) as {inputs?: readonly {type?: string; components?: readonly unknown[]}[]} | undefined; - - const inputs = addTransactionFunction?.inputs; - if (!Array.isArray(inputs)) { - return "v5"; - } - - if (inputs.length === 1 && inputs[0]?.type === "tuple") { - return "fees"; - } - - return inputs.length >= 6 ? "v6" : "v5"; -}; - type EncodedTransactionVariant = { encodedData: `0x${string}`; value: bigint; @@ -1408,6 +1619,7 @@ const defaultExecutionBudgetPerRound = (policy: FeePolicyQuote): bigint => { const buildEstimatedFeesDistribution = ( options: FeeEstimateOptions | undefined, policy: FeePolicyQuote, + defaultConsensusMaxRotations: number, ): FeesDistribution => { const headroomBps = toUInt( options?.priceCapHeadroomBps, @@ -1440,6 +1652,20 @@ const buildEstimatedFeesDistribution = ( const executionBudgetDefault = emitsMessages ? baseExecutionBudgetDefault + (policy.receiptGasPrice * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM) : baseExecutionBudgetDefault; + const appealRounds = toUInt(options?.appealRounds, "appealRounds", 0n); + const rotationsCount = Number(appealRounds + 1n); + if (!Number.isSafeInteger(rotationsCount)) { + throw new Error("rotations appealRounds is too large."); + } + let rotations = options?.rotations; + if (rotations === undefined) { + const defaultRotationBudget = toUInt( + defaultConsensusMaxRotations, + "defaultConsensusMaxRotations", + 0n, + ); + rotations = Array.from({length: rotationsCount}, () => defaultRotationBudget); + } return createFeesDistribution({ leaderTimeunitsAllocation: options?.leaderTimeunitsAllocation ?? ( @@ -1448,11 +1674,11 @@ const buildEstimatedFeesDistribution = ( validatorTimeunitsAllocation: options?.validatorTimeunitsAllocation ?? ( policy.enabled ? DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION : 0n ), - appealRounds: options?.appealRounds, + appealRounds, executionBudgetPerRound: options?.executionBudgetPerRound ?? executionBudgetDefault, executionConsumed: options?.executionConsumed, totalMessageFees, - rotations: options?.rotations, + rotations, maxPriceGenPerTimeUnit: options?.maxPriceGenPerTimeUnit ?? withCapHeadroom(policy.genPerTimeUnit, headroomBps), storageFeeMaxGasPrice: @@ -1860,129 +2086,272 @@ const _encodeAddTransactionData = ({ const txValidUntil = toUInt(validUntil, "validUntil", getDefaultValidUntil()); const feeValue = transactionFees.feeValue ?? 0n; - const addTransactionArgs: [ - Address, - `0x${string}`, - number, - number, - `0x${string}`, - ] = [ - validatedSenderAccount.address, - txRecipient, - client.chain.defaultNumberOfInitialValidators, - consensusMaxRotations, + const params = { + sender: validatedSenderAccount.address, + recipient: txRecipient, + numOfInitialValidators: BigInt(client.chain.defaultNumberOfInitialValidators), + maxRotations: BigInt(consensusMaxRotations), + validUntil: txValidUntil, + saltNonce: 0n, + userValue, + feesDistribution: transactionFees.distribution, txCalldata, - ]; - - const buildVariant = (abiVersion: AddTransactionAbiVersion): EncodedTransactionVariant => { - if (abiVersion === "fees") { - const params = { - sender: validatedSenderAccount.address, - recipient: txRecipient, - numOfInitialValidators: BigInt(client.chain.defaultNumberOfInitialValidators), - maxRotations: BigInt(consensusMaxRotations), - validUntil: txValidUntil, - saltNonce: 0n, - userValue, - feesDistribution: transactionFees.distribution, - txCalldata, - messageAllocations: transactionFees.messageAllocations, - }; + messageAllocations: transactionFees.messageAllocations, + }; - return { - encodedData: encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_WITH_FEES as any, - functionName: "addTransaction", - args: [params], - }), - value: userValue + feeValue, - }; - } + return [{ + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + functionName: "addTransaction", + args: [params], + }), + value: userValue + feeValue, + }]; +}; - if (abiVersion === "v6") { - return { - encodedData: encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V6 as any, - functionName: "addTransaction", - args: [...addTransactionArgs, txValidUntil], - }), - value: userValue, - }; - } +const _encodeSubmitAppealData = ({ + txId, + expectedDecisionId, +}: { + txId: `0x${string}`; + expectedDecisionId: bigint; +}): `0x${string}` => { + return encodeFunctionData({ + abi: CONSENSUS_APPEAL_TRAIN_ABI, + functionName: "submitAppeal", + args: [txId, expectedDecisionId], + }); +}; - return { - encodedData: encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V5 as any, - functionName: "addTransaction", - args: addTransactionArgs, - }), - value: userValue, - }; - }; +const STUDIO_APPEAL_QUOTE_UNSUPPORTED = + "Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"; + +const _encodeStudioSubmitAppealData = ({ + txId, +}: { + txId: `0x${string}`; +}): `0x${string}` => encodeFunctionData({ + abi: [{ + type: "function", + name: "submitAppeal", + stateMutability: "payable", + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }], + functionName: "submitAppeal", + args: [txId], +}); + +const _encodeStudioTopUpAndSubmitAppealData = ({ + txId, + distribution, +}: { + txId: `0x${string}`; + distribution: FeesDistributionInput; +}): `0x${string}` => encodeFunctionData({ + abi: CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI, + functionName: "topUpAndSubmitAppeal", + args: [txId, createFeesDistribution(distribution)], +}); + +const _studioTrainBatchError = (action: string): Error => + new Error( + `${action} is not exposed by Studio's embedded consensus: ` + + "use finalizeTransaction for an individual Studio transaction.", + ); + +const ROUND_PAGE_SIZE = 64n; + +const _unpackRoundValidatorPage = ( + value: any, +): {validators: readonly Address[]; total: bigint} => ({ + validators: value.validators ?? value[0], + total: BigInt(value.total ?? value[1]), +}); - if (transactionFees.requiresFeeAwareTransaction) { - return [buildVariant("fees")]; +/** Rebuilds the legacy RoundData shape without the aggregate four-array getter. */ +const _readRoundDataSnapshot = async ({ + publicClient, + address, + txId, + round, + blockNumber, +}: { + publicClient: PublicClient; + address: Address; + txId: `0x${string}`; + round: bigint; + blockNumber: bigint; +}): Promise => { + const read = (functionName: string, args: readonly unknown[]) => publicClient.readContract({ + address, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName, + args, + blockNumber, + } as any) as Promise; + + const [ + leaderIndex, + votesCommitted, + votesRevealed, + appealBond, + rotationsLeft, + result, + validatorVotes, + validatorVotesHash, + validatorResultHash, + firstPageRaw, + ] = await Promise.all([ + read("getLeaderIndex", [txId, round]), + read("getVotesCommitted", [txId, round]), + read("getVotesRevealed", [txId, round]), + read("getAppealBond", [txId, round]), + read("getRotationsLeft", [txId, round]), + read("getResult", [txId, round]), + read("getValidatorVotes", [txId, round]), + read("getValidatorVotesHash", [txId, round]), + read("getValidatorResultHash", [txId, round]), + read("getRoundValidatorsPage", [txId, round, 0n, ROUND_PAGE_SIZE]), + ]); + + const firstPage = _unpackRoundValidatorPage(firstPageRaw); + const offsets: bigint[] = []; + for (let offset = ROUND_PAGE_SIZE; offset < firstPage.total; offset += ROUND_PAGE_SIZE) { + offsets.push(offset); + } + const remainingPages = await Promise.all( + offsets.map(offset => read("getRoundValidatorsPage", [txId, round, offset, ROUND_PAGE_SIZE])), + ); + const pages = [firstPage, ...remainingPages.map(_unpackRoundValidatorPage)]; + if (pages.some(page => page.total !== firstPage.total)) { + throw new Error("Round validator page total changed within a fixed block snapshot"); + } + const roundValidators = pages.flatMap(page => [...page.validators]); + const expected = Number(firstPage.total); + if (roundValidators.length !== expected) { + throw new Error(`Incomplete round validator pages: expected ${expected}, received ${roundValidators.length}`); + } + for (const [name, values] of [ + ["validator votes", validatorVotes], + ["validator vote hashes", validatorVotesHash], + ["validator result hashes", validatorResultHash], + ] as const) { + if (values.length !== expected) { + throw new Error(`Incomplete ${name}: expected ${expected}, received ${values.length}`); + } } - const detectedVersion = getAddTransactionAbiVersion(client.chain.consensusMainContract?.abi); - const orderByDetectedVersion: Record = { - fees: ["fees", "v6", "v5"], - v6: ["v6", "v5", "fees"], - v5: ["v5", "v6", "fees"], + return { + round, + leaderIndex, + votesCommitted, + votesRevealed, + appealBond, + rotationsLeft, + result: Number(result), + roundValidators, + validatorVotes: [...validatorVotes].map(Number), + validatorVotesHash: [...validatorVotesHash], + validatorResultHash: [...validatorResultHash], }; +}; - return orderByDetectedVersion[detectedVersion].map(buildVariant); +type LifecycleIdentity = { + blockNumber: bigint; + blockTimestamp: bigint; + resolutionAction: number; + attemptId: `0x${string}`; + decisionActive: boolean; + decisionId: bigint; }; -const _encodeSubmitAppealData = ({ +const _readLifecycleIdentity = async ({ client, + publicClient, txId, + blockNumber, + blockTimestamp, }: { client: GenLayerClient; + publicClient: PublicClient; txId: `0x${string}`; -}): `0x${string}` => { - return encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "submitAppeal", - args: [txId], - }); + blockNumber?: bigint; + blockTimestamp?: bigint; +}): Promise => { + const consensusDataAddress = client.chain.consensusDataContract?.address as Address | undefined; + if (!consensusDataAddress || consensusDataAddress === zeroAddress) { + throw new Error("ConsensusData contract is not configured for this chain"); + } + + let snapshotNumber = blockNumber; + let snapshotTimestamp = blockTimestamp; + if (snapshotNumber === undefined || snapshotTimestamp === undefined) { + const snapshot = await publicClient.getBlock(); + snapshotNumber = snapshot.number; + snapshotTimestamp = snapshot.timestamp; + } + + const lifecycle = await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + args: [txId, snapshotTimestamp], + blockNumber: snapshotNumber, + }) as any; + const resolution = lifecycle.resolution ?? lifecycle[1]; + const latestDecision = lifecycle.latestDecision ?? lifecycle[2]; + const decisionActive = Boolean(lifecycle.decisionActive ?? lifecycle[3]); + + return { + blockNumber: snapshotNumber, + blockTimestamp: snapshotTimestamp, + resolutionAction: Number(resolution.action ?? resolution[3]), + attemptId: (resolution.attemptId ?? resolution[15]) as `0x${string}`, + decisionActive, + decisionId: decisionActive + ? BigInt(latestDecision.decisionId ?? latestDecision[1]) + : 0n, + }; }; -const _resolveAppealValue = async ({ +const _readAppealContext = async ({ client, publicClient, txId, - value, + includeQuote = true, }: { client: GenLayerClient; publicClient: PublicClient; txId: `0x${string}`; - value?: bigint; -}): Promise => { - if (value !== undefined) { - return value; + includeQuote?: boolean; +}): Promise => { + const identity = await _readLifecycleIdentity({client, publicClient, txId}); + if (!identity.decisionActive) { + throw new Error(`Transaction ${txId} has no active decision to appeal`); } - if (!client.chain.feeManagerContract?.address || !client.chain.roundsStorageContract?.address) { - return 0n; + if (!includeQuote) { + return {...identity, requiredValue: 0n}; } - const roundNumber = await publicClient.readContract({ - address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getRoundNumber", + const consensusDataAddress = client.chain.consensusDataContract!.address as Address; + const quote = await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "estimateLatestAppealCharge", args: [txId], - }) as bigint; - - const transaction = await client.getTransaction({hash: txId as TransactionHash}); - const txStatus = Number(transaction.status); - - return publicClient.readContract({ - address: client.chain.feeManagerContract.address as `0x${string}`, - abi: client.chain.feeManagerContract.abi as Abi, - functionName: "calculateMinAppealBond", - args: [txId, roundNumber, txStatus], - }) as Promise; + blockNumber: identity.blockNumber, + }) as any; + const quoteDecisionId = BigInt(quote.decisionId ?? quote[0]); + if (quoteDecisionId !== identity.decisionId) { + throw new Error( + `Appeal decision changed while reading ${txId}: expected ${identity.decisionId}, received ${quoteDecisionId}`, + ); + } + const bond = BigInt(quote.bond ?? quote[1]); + const funding = BigInt(quote.funding ?? quote[2]); + return {...identity, requiredValue: bond + funding}; }; const _encodeTopUpFeesData = ({ @@ -2001,15 +2370,17 @@ const _encodeTopUpFeesData = ({ const _encodeTopUpAndSubmitAppealData = ({ txId, + expectedDecisionId, distribution, }: { txId: `0x${string}`; + expectedDecisionId: bigint; distribution: FeesDistributionInput; }): `0x${string}` => { return encodeFunctionData({ abi: CONSENSUS_FEE_MANAGEMENT_ABI, functionName: "topUpAndSubmitAppeal", - args: [txId, createFeesDistribution(distribution)], + args: [txId, expectedDecisionId, createFeesDistribution(distribution)], }); }; @@ -2186,46 +2557,6 @@ const _sendConsensusCall = async ({ return evmHash; }; -const isAddTransactionAbiMismatchError = (error: unknown): boolean => { - const seen = new WeakSet(); - const serializedError = - typeof error === "object" && error !== null - ? JSON.stringify(error, (_key, value) => { - if (typeof value === "bigint") { - return value.toString(); - } - - if (typeof value === "object" && value !== null) { - if (seen.has(value as object)) { - return "[Circular]"; - } - seen.add(value as object); - } - - return value; - }) - : ""; - const errorObject = error as {shortMessage?: string; details?: string; message?: string}; - const errorMessage = [ - errorObject?.shortMessage, - errorObject?.details, - errorObject?.message, - serializedError, - String(error ?? ""), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - - return ( - errorMessage.includes("invalid pointer in tuple") || - errorMessage.includes("invalid pointer") || - errorMessage.includes("could not decode") || - errorMessage.includes("invalid arrayify value") || - errorMessage.includes("types/value length mismatch") - ); -}; - /** * Extracts the GenLayer txId from receipt logs by checking for either * NewTransaction (immediately activated) or CreatedTransaction (queued) events. @@ -2454,15 +2785,8 @@ const _sendTransaction = async ({ return externalTxId; }; - for (let i = 0; i < transactionVariants.length; i++) { - try { - return await sendWithEncodedData(transactionVariants[i]); - } catch (error) { - if (i === transactionVariants.length - 1 || !isAddTransactionAbiMismatchError(error)) { - throw error; - } - } + if (transactionVariants.length !== 1) { + throw new Error(`Train transaction encoding expected one variant, received ${transactionVariants.length}`); } - - throw new Error("Unable to send transaction."); + return sendWithEncodedData(transactionVariants[0]); }; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index cf46ab1..337cb82 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,8 +1,14 @@ import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; -import {STAKING_ABI, VALIDATOR_WALLET_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "@/abi/staking"; +import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; + +// The joined validator registry is only readable in slices: committee capacity +// is 1,543 and an address[] that long overruns the return-size limit. 64 is the +// size the paged reads are written around, and the one genlayer-node uses for +// the same walk. +const VALIDATORS_JOINED_PAGE_SIZE = 64n; import {operatorAddressFromPublicKey, verifyOperatorRegistration} from "@/vesting/operatorRegistration"; import { ValidatorInfo, @@ -282,58 +288,6 @@ export const stakingActions = ( }; }; - /** - * Which Claim/Commit layout the deployed staking contract uses. - * - * CON-715 widened both structs without renaming anything, and static tuples - * decode positionally, so the wrong shape does not fail — it silently returns - * neighbouring words (commit.input picks up claim.commit, i.e. an index where - * an amount belongs). Both shapes are deployed in the wild, so the layout is - * resolved from the chain rather than assumed, then cached for the client: - * getStakeInfo loops over every pending entry and must not re-probe each time. - * - * The probe only works in one direction. Reading the OLD layout with the - * CURRENT shape throws, because the response is shorter than the decoder - * expects; reading the CURRENT layout with the OLD shape succeeds and lies. - * So the current shape is always attempted first, and a decode failure — not - * a success — is what identifies a legacy chain. - */ - let commitLayout: "current" | "legacy" | null = null; - - const readCommitView = async ( - functionName: "delegatorDeposit" | "delegatorWithdrawal" | "validatorDeposit" | "validatorWithdrawal", - args: readonly unknown[], - ): Promise => { - const read = (layout: "current" | "legacy") => - publicClient.readContract({ - address: getStakingAddress(), - abi: (layout === "current" ? STAKING_COMMIT_VIEWS_CURRENT_ABI : STAKING_ABI) as any, - functionName, - args: args as any, - }); - - if (commitLayout) { - return read(commitLayout); - } - - try { - const result = await read("current"); - commitLayout = "current"; - return result; - } catch (currentError) { - // Could be a legacy layout, or a genuine failure (bad index, RPC error). - // Only a successful legacy decode distinguishes them; otherwise surface - // the original error, which describes the current-shape attempt. - try { - const result = await read("legacy"); - commitLayout = "legacy"; - return result; - } catch { - throw currentError; - } - } - }; - /** * Rotation is verified by the wallet, not the factory, so the registrar is the * wallet's own address. The owner is read from the wallet rather than assumed @@ -466,21 +420,12 @@ export const stakingActions = ( return executeWrite({to: getStakingAddress(), data}); }, - /** - * Sets the operator address for a validator wallet in one call. - * - * Removed from consensus by CON-715 in favour of the two-step rotation - * below; against a deployment that dropped it this reverts with no reason, - * because the selector simply does not exist. Prefer - * initiateOperatorTransfer + completeOperatorTransfer. - */ + /** @deprecated Use initiateOperatorTransfer followed by completeOperatorTransfer. */ setOperator: async (options: SetOperatorOptions): Promise => { - const data = encodeFunctionData({ - abi: VALIDATOR_WALLET_ABI, - functionName: "setOperator", - args: [options.operator as ViemAddress], - }); - return executeWrite({to: options.validator as ViemAddress, data}); + throw new Error( + `setOperator cannot rotate ${options.validator} to ${options.operator} on the train: ` + + "create an operator possession proof, then call initiateOperatorTransfer and completeOperatorTransfer.", + ); }, getOperatorTransferContext, @@ -618,7 +563,7 @@ export const stakingActions = ( return executeWrite({to: getStakingAddress(), data}); }, - /** Checks if an address is an active validator. */ + /** Checks whether an address is a registered/joined validator wallet. */ isValidator: async (address: Address): Promise => { const contract = getReadOnlyStakingContract(); return contract.read.isValidator([address as ViemAddress]) as Promise; @@ -641,13 +586,14 @@ export const stakingActions = ( }); // Fetch all data in parallel - const [view, owner, operator, identityRaw, currentEpoch, validatorMinStake] = await Promise.all([ + const [view, owner, operator, identityRaw, currentEpoch, validatorMinStake, banned] = await Promise.all([ contract.read.validatorView([validator as ViemAddress]) as Promise, walletContract.read.owner() as Promise
, walletContract.read.operator() as Promise
, walletContract.read.getIdentity().catch(() => null) as Promise, contract.read.epoch() as Promise, contract.read.validatorMinStake() as Promise, + contract.read.isValidatorBanned([validator as ViemAddress]) as Promise, ]); // Parse identity if available @@ -674,10 +620,7 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [epoch, commit] = (await readCommitView("validatorDeposit", [validator as ViemAddress, i])) as [ - bigint, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + const [epoch, commit] = await contract.read.validatorDeposit([validator as ViemAddress, i]); pendingDeposits.push({ epoch, stake: formatStakingAmount(commit.input), @@ -691,10 +634,7 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [epoch, commit] = (await readCommitView("validatorWithdrawal", [validator as ViemAddress, i])) as [ - bigint, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + const [epoch, commit] = await contract.read.validatorWithdrawal([validator as ViemAddress, i]); pendingWithdrawals.push({ epoch, shares: commit.input, @@ -719,8 +659,8 @@ export const stakingActions = ( vWithdrawalRaw: view.vWithdrawal, ePrimed: view.ePrimed, live: view.live, - banned: view.eBanned > 0n, - bannedEpoch: view.eBanned > 0n ? view.eBanned : undefined, + banned, + bannedEpoch: banned ? view.eBanned : undefined, needsPriming, currentEpoch, validatorMinStake: formatStakingAmount(validatorMinStake), @@ -767,14 +707,11 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [claim, commit] = (await readCommitView("delegatorDeposit", [ + const [claim, commit] = await contract.read.delegatorDeposit([ delegator as ViemAddress, validator as ViemAddress, i, - ])) as [ - {quantity: bigint; commit: bigint}, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + ]); pendingDeposits.push({ epoch: commit.epoch, stake: formatStakingAmount(commit.input), @@ -791,14 +728,11 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [claim, commit] = (await readCommitView("delegatorWithdrawal", [ + const [claim, commit] = await contract.read.delegatorWithdrawal([ delegator as ViemAddress, validator as ViemAddress, i, - ])) as [ - {quantity: bigint; commit: bigint}, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + ]); pendingWithdrawals.push({ epoch: commit.epoch, shares: claim.quantity, @@ -835,7 +769,7 @@ export const stakingActions = ( ] = await Promise.all([ contract.read.epoch() as Promise, contract.read.finalized() as Promise, - contract.read.activeValidatorsCount() as Promise, + contract.read.selectableValidatorsCount() as Promise, contract.read.epochMinDuration() as Promise, contract.read.epochZeroMinDuration() as Promise, contract.read.epochOdd() as Promise, @@ -873,6 +807,8 @@ export const stakingActions = ( currentEpoch: epoch, lastFinalizedEpoch: finalized, activeValidatorsCount: activeCount, + totalWeight: currentEpochData.weight, + inflationRaw: currentEpochData.inflation, epochMinDuration, nextEpochEstimate, validatorMinStake: formatStakingAmount(valMinStake), @@ -920,17 +856,37 @@ export const stakingActions = ( }; }, - /** Returns addresses of all currently active validators. */ + /** Returns validators currently eligible for consensus duties. */ getActiveValidators: async (): Promise => { const contract = getReadOnlyStakingContract(); - const validators = (await contract.read.activeValidators()) as Address[]; - return validators.filter(v => v !== "0x0000000000000000000000000000000000000000"); + return contract.read.selectableValidators() as Promise; }, - /** Returns the count of active validators. */ + /** Returns the count of validators currently eligible for consensus duties. */ getActiveValidatorsCount: async (): Promise => { const contract = getReadOnlyStakingContract(); - return contract.read.activeValidatorsCount() as Promise; + return contract.read.selectableValidatorsCount() as Promise; + }, + + /** Returns every validator identity in the append-only joined registry. */ + getJoinedValidators: async (): Promise => { + const contract = getReadOnlyStakingContract(); + const total = (await contract.read.validatorsJoinedCount()) as bigint; + const validators: Address[] = []; + + for (let start = 0n; start < total; start += VALIDATORS_JOINED_PAGE_SIZE) { + const page = (await contract.read.getValidatorsJoined([start, VALIDATORS_JOINED_PAGE_SIZE])) as Address[]; + if (page.length === 0) break; + validators.push(...page); + } + + return validators.filter(v => v !== "0x0000000000000000000000000000000000000000"); + }, + + /** Returns the size of the append-only joined validator registry. */ + getJoinedValidatorsCount: async (): Promise => { + const contract = getReadOnlyStakingContract(); + return contract.read.validatorsJoinedCount() as Promise; }, /** Returns addresses of validators currently in quarantine. */ diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 96b92ea..1b2c455 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -11,12 +11,135 @@ import { isDecidedState, DebugTraceResult, TransactionReceiptWaitUntil, + TransactionProtocolLifecycle, + transactionResolutionActionNumberToName, + transactionProtocolStatusNumberToName, + transactionResolutionSourceNumberToName, + transactionLifecycleFromStoredStatus, } from "../types/transactions"; import {transactionsConfig} from "../config/transactions"; import {sleep} from "../utils/async"; import {GenLayerChain} from "@/types"; -import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes} from "viem"; +import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes, zeroAddress} from "viem"; import {decodeLocalnetTransaction, decodeTransaction, simplifyTransactionReceipt} from "./decoders"; +import {isMethodNotFoundError, readStudioLifecycleFallback} from "./lifecycleFallback"; +import { + ADDRESS_MANAGER_TRAIN_ABI, + CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + CONSENSUS_DATA_TRAIN_ABI, + ROUNDS_STORAGE_TRAIN_READ_ABI, + TRANSACTION_MANAGER_TRAIN_READ_ABI, +} from "../abi/consensusTrain"; + +const TRANSACTION_PAGE_SIZE = 64n; + +type RawProtocolLifecycle = { + storedStatusCode: unknown; + projectedStatusCode: unknown; + resolutionActionCode: unknown; + resolutionSourceCode: unknown; + decisionId: unknown; + decisionActive: unknown; + evaluatedAt: unknown; +}; + +const protocolInteger = (value: unknown, label: string): number => { + if (value === null || value === undefined || value === "") { + throw new Error(`Missing protocol lifecycle ${label}`); + } + const numeric = Number(value); + if (!Number.isSafeInteger(numeric) || numeric < 0) { + throw new Error(`Invalid protocol lifecycle ${label}: ${String(value)}`); + } + return numeric; +}; + +const protocolName = ( + names: Record, + code: number, + label: string, +): T => { + const name = names[String(code)]; + if (!name) throw new Error(`Unknown protocol lifecycle ${label}: ${code}`); + return name; +}; + +const protocolDecisionId = (value: unknown, active: boolean): string | null => { + if (!active) return null; + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error(`Invalid protocol lifecycle decisionId: ${String(value)}`); + } + const decimal = String(value); + if (!/^\d+$/.test(decimal)) { + throw new Error(`Invalid protocol lifecycle decisionId: ${decimal}`); + } + return BigInt(decimal).toString(); +}; + +const normalizeProtocolLifecycle = (raw: RawProtocolLifecycle): TransactionProtocolLifecycle => { + const storedStatusCode = protocolInteger(raw.storedStatusCode, "storedStatusCode"); + const projectedStatusCode = protocolInteger(raw.projectedStatusCode, "projectedStatusCode"); + const resolutionActionCode = protocolInteger(raw.resolutionActionCode, "resolutionActionCode"); + const resolutionSourceCode = protocolInteger(raw.resolutionSourceCode, "resolutionSourceCode"); + if (typeof raw.decisionActive !== "boolean") { + throw new Error(`Invalid protocol lifecycle decisionActive: ${String(raw.decisionActive)}`); + } + + return { + storedStatus: protocolName(transactionProtocolStatusNumberToName, storedStatusCode, "storedStatusCode"), + storedStatusCode, + projectedStatus: protocolName( + transactionProtocolStatusNumberToName, + projectedStatusCode, + "projectedStatusCode", + ), + projectedStatusCode, + resolutionAction: protocolName( + transactionResolutionActionNumberToName, + resolutionActionCode, + "resolutionActionCode", + ), + resolutionActionCode, + resolutionSource: protocolName( + transactionResolutionSourceNumberToName, + resolutionSourceCode, + "resolutionSourceCode", + ), + resolutionSourceCode, + decisionId: protocolDecisionId(raw.decisionId, raw.decisionActive), + decisionActive: raw.decisionActive, + evaluatedAt: protocolInteger(raw.evaluatedAt, "evaluatedAt"), + }; +}; + +const resolvedAddress = (name: string, address: Address): Address => { + if (address === zeroAddress) { + throw new Error(`${name} is not registered in AddressManager`); + } + return address; +}; + +type AddressPage = readonly [readonly Address[], bigint] | {page: readonly Address[]; total: bigint}; + +const unpackAddressPage = (result: AddressPage): {page: readonly Address[]; total: bigint} => + "page" in result ? result : {page: result[0], total: result[1]}; + +const readAddressPages = async ( + total: bigint, + readPage: (offset: bigint) => Promise, +): Promise => { + const offsets: bigint[] = []; + for (let offset = 0n; offset < total; offset += TRANSACTION_PAGE_SIZE) offsets.push(offset); + const pages = (await Promise.all(offsets.map(readPage))).map(unpackAddressPage); + if (pages.some(page => page.total !== total)) { + throw new Error("Address page total changed within a fixed block snapshot"); + } + const items = pages.flatMap(({page}) => [...page]); + if (BigInt(items.length) !== total) { + throw new Error(`Incomplete address pages: expected ${total}, received ${items.length}`); + } + return items; +}; let didWarnWaitForTransactionReceiptStatus = false; @@ -52,9 +175,20 @@ const resolveWaitTarget = ( }; const hasReachedWaitTarget = ( - transactionStatusString: string, + transaction: GenLayerTransaction, target: ReturnType, ): boolean => { + const storedStatusName = transaction.statusName ?? ( + typeof transaction.status === "number" || /^\d+$/.test(String(transaction.status)) + ? transactionsStatusNumberToName[ + String(transaction.status) as keyof typeof transactionsStatusNumberToName + ] + : transaction.status as TransactionStatus | undefined + ); + const transactionStatusString = storedStatusName + ? transactionsStatusNameToNumber[storedStatusName] + : String(transaction.status); + if (target.waitUntil === "decided") { return isDecidedState(transactionStatusString); } @@ -112,7 +246,7 @@ export const receiptActions = (client: GenLayerClient, publicClie throw new Error(`Transaction not found: ${hash}`); } const transactionStatusString = String(transaction.status); - if (hasReachedWaitTarget(transactionStatusString, target)) { + if (hasReachedWaitTarget(transaction, target)) { let finalTransaction = transaction; if (client.chain.isStudio) { finalTransaction = decodeLocalnetTransaction(transaction as unknown as GenLayerTransaction); @@ -137,10 +271,111 @@ export const receiptActions = (client: GenLayerClient, publicClie fullTransaction, }); }, + /** Polls until the stored transaction state contains a materialized decision. */ + waitForDecision: async ({ + hash, + interval, + retries, + fullTransaction, + }: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }): Promise => receiptActions(client, publicClient).waitForTransactionReceipt({ + hash, + waitUntil: "decided", + interval, + retries, + fullTransaction, + }), + /** Polls until the stored transaction state is finalized. */ + waitForFinalization: async ({ + hash, + interval, + retries, + fullTransaction, + }: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }): Promise => receiptActions(client, publicClient).waitForTransactionReceipt({ + hash, + waitUntil: "finalized", + interval, + retries, + fullTransaction, + }), }); export const transactionActions = (client: GenLayerClient, publicClient: PublicClient) => ({ - /** Fetches transaction data including status, execution result, and consensus details. */ + advanced: { + /** + * `advanced.getTransactionLifecycle` exposes stored/projected status, + * resolution action/source, and active decision identity. Contract networks + * use one fixed-block lifecycle read. `Finalize` is an action, not a status + * or separate readiness field. + * + * A Studio deployment that does not yet serve `gen_getTransactionLifecycle` + * degrades to the stored status its consumer surface does prove, rather + * than failing the whole read. + */ + getTransactionLifecycle: async ({ + hash, + timestamp, + }: { + hash: TransactionHash; + timestamp?: number; + }): Promise => { + if (client.chain.isStudio) { + let raw: RawProtocolLifecycle; + try { + raw = await client.request({ + method: "gen_getTransactionLifecycle", + params: [{txId: hash, ...(timestamp === undefined ? {} : { + timestamp: protocolInteger(timestamp, "timestamp"), + })}], + }) as RawProtocolLifecycle; + } catch (error) { + if (!isMethodNotFoundError(error)) throw error; + raw = await readStudioLifecycleFallback({client, hash, timestamp, cause: error}); + } + return normalizeProtocolLifecycle(raw); + } + const consensusDataAddress = client.chain.consensusDataContract?.address as Address; + if (!consensusDataAddress || consensusDataAddress === zeroAddress) { + throw new Error("ConsensusData contract is not configured for this chain"); + } + + const snapshot = await publicClient.getBlock(); + const blockNumber = snapshot.number; + const evaluatedAt = timestamp === undefined + ? protocolInteger(snapshot.timestamp, "block timestamp") + : protocolInteger(timestamp, "timestamp"); + const lifecycle = await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + args: [hash, BigInt(evaluatedAt)], + blockNumber, + }) as any; + + return normalizeProtocolLifecycle({ + storedStatusCode: lifecycle.storedStatus, + projectedStatusCode: lifecycle.resolution.projectedStatus, + resolutionActionCode: lifecycle.resolution.action, + resolutionSourceCode: lifecycle.resolution.source, + decisionId: lifecycle.latestDecision.decisionId, + decisionActive: lifecycle.decisionActive, + evaluatedAt: lifecycle.resolution.evaluatedAt, + }); + }, + }, + /** + * Fetches a transaction with a simple stored lifecycle and split round data. + * Use advanced.getTransactionLifecycle for protocol projection/action details. + */ getTransaction: async ({hash}: {hash: TransactionHash}): Promise => { if (client.chain.isStudio) { const transaction = await client.getTransaction({hash}); @@ -149,33 +384,133 @@ export const transactionActions = (client: GenLayerClient, public transaction.status = Number(transactionsStatusNameToNumber[localnetStatus as TransactionStatus]); transaction.statusName = localnetStatus as TransactionStatus; + transaction.lifecycle = transactionLifecycleFromStoredStatus( + localnetStatus as TransactionStatus, + transaction.result, + ); return decodeLocalnetTransaction(transaction as unknown as GenLayerTransaction); } - const contractAddress = client.chain.consensusDataContract?.address as Address; - const contractAbi = client.chain.consensusDataContract?.abi as Abi; + const consensusDataAddress = client.chain.consensusDataContract?.address as Address; + if (!consensusDataAddress || consensusDataAddress === zeroAddress) { + throw new Error("ConsensusData contract is not configured for this chain"); + } + + // Freeze every transaction and round component to one chain snapshot so + // the assembled public transaction never mixes different stored states. + const snapshot = await publicClient.getBlock(); + const blockNumber = snapshot.number; + const addressManagerAddress = resolvedAddress( + "AddressManager", + await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "addressManager", + blockNumber, + }) as Address, + ); - const [txDataRaw, allDataRaw] = await Promise.all([ + const [bigRoundsAddressRaw, roundsStorageAddressRaw, transactionManagerAddressRaw] = await Promise.all([ publicClient.readContract({ - address: contractAddress, - abi: contractAbi, - functionName: "getTransactionData", - args: [hash, Math.round(new Date().getTime() / 1000)], - }) as Promise, + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["ConsensusDataBigRounds"], + blockNumber, + }), publicClient.readContract({ - address: contractAddress, - abi: contractAbi, - functionName: "getTransactionAllData", - args: [hash], - }) as Promise<[any, any[]]>, - ]); - - const txData = txDataRaw as unknown as GenLayerRawTransaction; - const [txAllData, _roundsData] = allDataRaw as unknown as [any, any[]]; + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["RoundsStorage"], + blockNumber, + }), + publicClient.readContract({ + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["TransactionManager"], + blockNumber, + }), + ]) as [Address, Address, Address]; + const bigRoundsAddress = resolvedAddress("ConsensusDataBigRounds", bigRoundsAddressRaw); + const roundsStorageAddress = resolvedAddress("RoundsStorage", roundsStorageAddressRaw); + const transactionManagerAddress = resolvedAddress("TransactionManager", transactionManagerAddressRaw); + + const txData = await publicClient.readContract({ + address: bigRoundsAddress, + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getStoredTransactionDataLight", + args: [hash], + blockNumber, + }) as any; + + const round = txData.lastRound.round; + const [roundValidators, consumedValidators, validatorVotes, validatorVotesHash, validatorResultHash, txExecutionResult, numOfInitialValidators] = + await Promise.all([ + readAddressPages(txData.lastRound.validatorsCount, offset => publicClient.readContract({ + address: bigRoundsAddress, + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getRoundValidatorsPaged", + args: [hash, round, offset, TRANSACTION_PAGE_SIZE], + blockNumber, + }) as Promise), + readAddressPages(txData.consumedValidatorsCount, offset => publicClient.readContract({ + address: bigRoundsAddress, + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getConsumedValidatorsPaged", + args: [hash, offset, TRANSACTION_PAGE_SIZE], + blockNumber, + }) as Promise), + publicClient.readContract({ + address: roundsStorageAddress, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getValidatorVotes", + args: [hash, round], + blockNumber, + }), + publicClient.readContract({ + address: roundsStorageAddress, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getValidatorVotesHash", + args: [hash, round], + blockNumber, + }), + publicClient.readContract({ + address: roundsStorageAddress, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getValidatorResultHash", + args: [hash, round], + blockNumber, + }), + publicClient.readContract({ + address: transactionManagerAddress, + abi: TRANSACTION_MANAGER_TRAIN_READ_ABI, + functionName: "getTxExecutionResult", + args: [hash], + blockNumber, + }), + publicClient.readContract({ + address: transactionManagerAddress, + abi: TRANSACTION_MANAGER_TRAIN_READ_ABI, + functionName: "getNumOfInitialValidators", + args: [hash], + blockNumber, + }), + ]) as [Address[], Address[], readonly number[], readonly `0x${string}`[], readonly `0x${string}`[], number, bigint]; const transaction = { ...txData, - txExecutionResult: Number(txAllData.txExecutionResult), - } as GenLayerRawTransaction; + numOfInitialValidators, + txExecutionResult: Number(txExecutionResult), + consumedValidators, + lastRound: { + ...txData.lastRound, + roundValidators, + validatorVotes: [...validatorVotes].map(Number), + validatorVotesHash: [...validatorVotesHash], + validatorResultHash: [...validatorResultHash], + }, + } as unknown as GenLayerRawTransaction; return decodeTransaction(transaction); }, /** Returns transaction IDs of child transactions created from emitted messages. */ diff --git a/src/transactions/decoders.ts b/src/transactions/decoders.ts index d112a7c..33e799f 100644 --- a/src/transactions/decoders.ts +++ b/src/transactions/decoders.ts @@ -1,4 +1,4 @@ -import {GenLayerTransaction, GenLayerRawTransaction, DecodedCallData, DecodedDeployData,transactionsStatusNumberToName, transactionResultNumberToName, executionResultNumberToName, voteTypeNumberToName, VoteType} from "../types/transactions"; +import {GenLayerTransaction, GenLayerRawTransaction, DecodedCallData, DecodedDeployData,transactionsStatusNumberToName, transactionResultNumberToName, executionResultNumberToName, voteTypeNumberToName, VoteType, transactionLifecycleFromStoredStatus} from "../types/transactions"; import {b64ToArray, calldataToUserFriendlyJson, resultToUserFriendlyJson} from "../utils/jsonifier"; import {fromRlp, fromHex, Hex, Address} from "viem"; import * as calldataAbi from "../abi/calldata"; @@ -74,19 +74,17 @@ export const decodeInputData = ( }; export const decodeTransaction = (tx: GenLayerRawTransaction): GenLayerTransaction => { - // Normalize field names across chain ABIs (Bradbury uses different names) - const txData = tx.txData ?? (tx as any).txCalldata; - const numOfInitialValidators = tx.numOfInitialValidators ?? (tx as any).initialRotations; - - const txDataDecoded = decodeInputData(txData, tx.recipient); + const txDataDecoded = decodeInputData(tx.txCalldata, tx.recipient); const decodedTx = { ...tx, - txData: txData, + // Preserve the public SDK field names while decoding one canonical train + // wire layout. Deployments with the old tuple must use the old SDK. + txData: tx.txCalldata, txDataDecoded: txDataDecoded, - currentTimestamp: tx.currentTimestamp.toString(), - numOfInitialValidators: numOfInitialValidators?.toString() ?? "0", + currentTimestamp: tx.observedAt.toString(), + numOfInitialValidators: tx.numOfInitialValidators.toString(), txSlot: tx.txSlot.toString(), createdTimestamp: tx.createdTimestamp.toString(), lastVoteTimestamp: tx.lastVoteTimestamp.toString(), @@ -102,6 +100,7 @@ export const decodeTransaction = (tx: GenLayerRawTransaction): GenLayerTransacti statusName: transactionsStatusNumberToName[String(tx.status) as keyof typeof transactionsStatusNumberToName], + lifecycle: transactionLifecycleFromStoredStatus(tx.status, tx.result), resultName: transactionResultNumberToName[String(tx.result) as keyof typeof transactionResultNumberToName], @@ -284,4 +283,4 @@ export const decodeLocalnetTransaction = (tx: GenLayerTransaction): GenLayerTran console.error("Error in decodeLocalnetTransaction:", e); } return tx; -}; \ No newline at end of file +}; diff --git a/src/transactions/lifecycleFallback.ts b/src/transactions/lifecycleFallback.ts new file mode 100644 index 0000000..0f49183 --- /dev/null +++ b/src/transactions/lifecycleFallback.ts @@ -0,0 +1,131 @@ +import {MethodNotFoundRpcError} from "viem"; + +import {GenLayerClient} from "../types/clients"; +import {GenLayerChain} from "../types/chains"; +import { + GenLayerTransaction, + TransactionHash, + TransactionStatus, + transactionsStatusNameToNumber, + transactionsStatusNumberToName, +} from "../types/transactions"; + +/** JSON-RPC "method not found" as defined by the spec. */ +const METHOD_NOT_FOUND_CODE = -32601; + +/** + * Studio reports a missing method through several shapes: the raw JSON-RPC + * error object thrown by the SDK transport, viem's typed wrapper, and older + * gateways that answer with a generic code and only the text to go on. + */ +const METHOD_NOT_FOUND_MESSAGE = /method not found|does not exist|method not supported/i; + +/** Studio's pre-proposal state; the protocol equivalent is Pending. */ +const STUDIO_ACTIVATED_STATUS = "ACTIVATED"; + +/** + * Reports whether the RPC endpoint answered "I do not implement this method", + * as opposed to failing to answer a method it does implement. Only the former + * may degrade to a locally synthesized lifecycle. + */ +export const isMethodNotFoundError = (error: unknown): boolean => { + if (error instanceof MethodNotFoundRpcError) return true; + + const seen = new Set(); + let current: unknown = error; + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + const {code, message} = current as {code?: unknown; message?: unknown}; + if (Number(code) === METHOD_NOT_FOUND_CODE) return true; + if (typeof message === "string" && METHOD_NOT_FOUND_MESSAGE.test(message)) return true; + current = (current as {cause?: unknown}).cause; + } + return false; +}; + +/** + * Lifecycle synthesized from the Studio consumer surface. It carries the exact + * stored status and nothing the consumer surface cannot prove: no projection, + * no resolution action, and no decision identity. + */ +export type StudioLifecycleFallback = { + storedStatusCode: number; + projectedStatusCode: number; + resolutionActionCode: number; + resolutionSourceCode: number; + decisionId: null; + decisionActive: false; + evaluatedAt: number; +}; + +/** Resolution-action and resolution-source codes for "nothing to report". */ +const NO_OP_RESOLUTION_ACTION_CODE = 0; +const UNSPECIFIED_RESOLUTION_SOURCE_CODE = 0; + +/** + * Reads the exact stored status off a Studio transaction. `transactionActions` + * hands over viem's raw transaction, whose `status` is the Studio status name, + * while `contractActions` hands over the SDK transaction, whose `status` is + * already the numeric code and whose `statusName` is already normalized. + */ +const storedStatusName = (transaction: GenLayerTransaction): TransactionStatus | undefined => { + const raw: unknown = transaction.statusName ?? transaction.status; + const byCode = (code: string): TransactionStatus | undefined => + Object.prototype.hasOwnProperty.call(transactionsStatusNumberToName, code) + ? transactionsStatusNumberToName[code as keyof typeof transactionsStatusNumberToName] + : undefined; + + if (typeof raw === "number") return byCode(String(raw)); + if (typeof raw !== "string") return undefined; + if (raw === STUDIO_ACTIVATED_STATUS) return TransactionStatus.PENDING; + if (/^\d+$/.test(raw)) return byCode(raw); + return Object.prototype.hasOwnProperty.call(transactionsStatusNameToNumber, raw) + ? (raw as TransactionStatus) + : undefined; +}; + +/** + * Synthesizes the protocol lifecycle from the Studio consumer surface after the + * node lifecycle RPC reported that it does not implement the method. + * + * Studio's transaction object proves the stored status and nothing else, so the + * projection repeats the stored status, the resolution is `NoOp`/`Unspecified`, + * and the decision identity stays inactive. The SDK must never invent a + * decision id: a fabricated one would be signed into an appeal or finalization. + * + * @throws the original RPC error when the status cannot be read, so a genuine + * gap is never reported as a lifecycle. + */ +export const readStudioLifecycleFallback = async ({ + client, + hash, + timestamp, + cause, +}: { + client: GenLayerClient; + hash: TransactionHash; + timestamp?: number; + cause: unknown; +}): Promise => { + let transaction: GenLayerTransaction; + try { + transaction = await client.getTransaction({hash}); + } catch { + throw cause; + } + + const status = storedStatusName(transaction); + if (!status) throw cause; + const storedStatusCode = Number(transactionsStatusNameToNumber[status]); + + return { + storedStatusCode, + // The consumer surface cannot project a status forward in time. + projectedStatusCode: storedStatusCode, + resolutionActionCode: NO_OP_RESOLUTION_ACTION_CODE, + resolutionSourceCode: UNSPECIFIED_RESOLUTION_SOURCE_CODE, + decisionId: null, + decisionActive: false, + evaluatedAt: timestamp ?? Math.floor(Date.now() / 1000), + }; +}; diff --git a/src/types/clients.ts b/src/types/clients.ts index 449975c..8347d25 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -15,6 +15,10 @@ import { BigNumberish, FeesDistributionInput, SimulateWriteContractResult, + ConsensusRoundData, + ConsensusLastRoundData, + TransactionProtocolLifecycle, + TransactionProtocolLifecycleArgs, } from "./transactions"; import {GenLayerChain} from "./chains"; import {Address, Account} from "./accounts"; @@ -34,6 +38,8 @@ export type GenLayerMethod = | {method: "gen_getContractSchema"; params: [address: Address] | [{address: Address}] | [{code: string}]} | {method: "gen_getContractSchemaForCode"; params: [contractCode: string]} | {method: "gen_getContractCode"; params: [address: Address] | [{address: Address}]} + | {method: "gen_getTransactionLifecycle"; params: [{txId: TransactionHash; timestamp?: number}]} + | {method: "gen_estimateLatestAppealCharge"; params: [{txId: TransactionHash}]} | {method: "sim_getTransactionsForAddress"; params: [address: Address, filter?: "all" | "from" | "to"]} | {method: "eth_getTransactionCount"; params: [address: Address, block: string]} | {method: "eth_estimateGas"; params: [transactionParams: any]} @@ -113,6 +119,10 @@ export type GenLayerClient = Omit< fees?: TransactionFeeOptions; }) => Promise<`0x${string}`>; getTransaction: (args: {hash: TransactionHash}) => Promise; + advanced: { + /** Protocol lifecycle read with projection and resolution action details. */ + getTransactionLifecycle: (args: TransactionProtocolLifecycleArgs) => Promise; + }; getCurrentNonce: (args: {address: Address}) => Promise; transfer: (args: {to: Address; value: bigint}) => Promise; estimateTransactionGas: (transactionParams: { @@ -130,6 +140,18 @@ export type GenLayerClient = Omit< retries?: number; fullTransaction?: boolean; }) => Promise; + waitForDecision: (args: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }) => Promise; + waitForFinalization: (args: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }) => Promise; getContractSchema: (address: Address) => Promise; getContractSchemaForCode: (contractCode: string | Uint8Array) => Promise; getContractCode: (address: Address) => Promise; @@ -142,8 +164,8 @@ export type GenLayerClient = Omit< getTransactionQueuePosition: (args: {hash: TransactionHash}) => Promise; cancelTransaction: (args: {hash: TransactionHash}) => Promise<{transaction_hash: string; status: string}>; getRoundNumber: (args: {txId: `0x${string}`}) => Promise; - getRoundData: (args: {txId: `0x${string}`; round: bigint}) => Promise; - getLastRoundData: (args: {txId: `0x${string}`}) => Promise; + getRoundData: (args: {txId: `0x${string}`; round: bigint}) => Promise; + getLastRoundData: (args: {txId: `0x${string}`}) => Promise; canAppeal: (args: {txId: `0x${string}`}) => Promise; getDeveloperNft: (args: {developer: Address}) => Promise; getClaimableRewardsFromFees: (args: {nftId: BigNumberish}) => Promise; @@ -185,6 +207,16 @@ export type GenLayerClient = Omit< account?: Account; txIds: readonly `0x${string}`[]; }) => Promise<`0x${string}`>; + resolveTransactions: (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }) => Promise<`0x${string}`>; + finalizeDecisions: (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }) => Promise<`0x${string}`>; + getAppealCharge: (args: {txId: `0x${string}`}) => Promise; + /** @deprecated Use getAppealCharge. This alias returns bond plus appeal funding. */ getMinAppealBond: (args: {txId: `0x${string}`}) => Promise; getCurrentFeePolicy: () => Promise; estimateFeesDistribution: (args?: FeeEstimateOptions) => Promise; diff --git a/src/types/staking.ts b/src/types/staking.ts index 0af016b..0a32106 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -17,9 +17,6 @@ export type StakingContract = GetContractReturnType< >; export interface ValidatorView { - left: Address; - right: Address; - parent: Address; eBanned: bigint; ePrimed: bigint; vStake: bigint; @@ -74,8 +71,11 @@ export interface ValidatorInfo { export interface WithdrawalCommit { input: bigint; output: bigint; + outstanding: bigint; epoch: bigint; linkToNextCommit: bigint; + priced: boolean; + fragmented: boolean; } export interface PendingDeposit { @@ -126,6 +126,10 @@ export interface EpochInfo { currentEpoch: bigint; lastFinalizedEpoch: bigint; activeValidatorsCount: bigint; + /** Current epoch's aggregate validator weight. */ + totalWeight: bigint; + /** Current epoch's inflation amount in base units. */ + inflationRaw: bigint; epochMinDuration: bigint; nextEpochEstimate: Date | null; validatorMinStake: string; @@ -237,24 +241,36 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; getValidatorRegistrationContext: () => Promise; + validatorPrime: (options: ValidatorPrimeOptions) => Promise; + /** @deprecated Use initiateOperatorTransfer followed by completeOperatorTransfer. */ + setOperator: (options: SetOperatorOptions) => Promise; getOperatorTransferContext: (validator: Address) => Promise; initiateOperatorTransfer: (options: InitiateOperatorTransferOptions) => Promise; completeOperatorTransfer: (options: CompleteOperatorTransferOptions) => Promise; cancelOperatorTransfer: (options: CancelOperatorTransferOptions) => Promise; getPendingOperator: (validator: Address) => Promise; + setIdentity: (options: SetIdentityOptions) => Promise; validatorDeposit: (options: ValidatorDepositOptions) => Promise; validatorExit: (options: ValidatorExitOptions) => Promise; validatorClaim: (options?: ValidatorClaimOptions) => Promise; delegatorJoin: (options: DelegatorJoinOptions) => Promise; delegatorExit: (options: DelegatorExitOptions) => Promise; delegatorClaim: (options: DelegatorClaimOptions) => Promise; + /** Checks whether the address is a registered/joined validator wallet. */ isValidator: (address: Address) => Promise; + getCurrentEpoch: () => Promise; + isValidatorBelowMin: (validator: Address) => Promise; getValidatorInfo: (validator: Address) => Promise; getStakeInfo: (delegator: Address, validator: Address) => Promise; getEpochInfo: () => Promise; getEpochData: (epochNumber: bigint) => Promise; getActiveValidators: () => Promise; getActiveValidatorsCount: () => Promise; + getJoinedValidators: () => Promise; + getJoinedValidatorsCount: () => Promise; + getQuarantinedValidators: () => Promise; + getBannedValidators: (startIndex?: bigint, size?: bigint) => Promise; + getQuarantinedValidatorsDetailed: (startIndex?: bigint, size?: bigint) => Promise; getStakingContract: () => StakingContract; parseStakingAmount: (amount: string | bigint) => bigint; formatStakingAmount: (amount: bigint) => string; diff --git a/src/types/transactions.ts b/src/types/transactions.ts index f4ad76e..bcdbcfa 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -18,17 +18,97 @@ export enum TransactionStatus { CANCELED = "CANCELED", APPEAL_REVEALING = "APPEAL_REVEALING", APPEAL_COMMITTING = "APPEAL_COMMITTING", - READY_TO_FINALIZE = "READY_TO_FINALIZE", VALIDATORS_TIMEOUT = "VALIDATORS_TIMEOUT", LEADER_TIMEOUT = "LEADER_TIMEOUT", LEADER_REVEALING = "LEADER_REVEALING", } -export enum TransactionResult { - SUCCESS = "SUCCESS", - FAILURE = "FAILURE", +export enum TransactionResolutionAction { + NO_OP = "NoOp", + CANCEL = "Cancel", + REPLACE_ACTOR = "ReplaceActor", + ROTATE_LEADER = "RotateLeader", + RESOLVE_APPEAL = "ResolveAppeal", + MATERIALIZE_DECISION = "MaterializeDecision", + FINALIZE = "Finalize", +} + +export const transactionResolutionActionNumberToName = { + "0": TransactionResolutionAction.NO_OP, + "1": TransactionResolutionAction.CANCEL, + "2": TransactionResolutionAction.REPLACE_ACTOR, + "3": TransactionResolutionAction.ROTATE_LEADER, + "4": TransactionResolutionAction.RESOLVE_APPEAL, + "5": TransactionResolutionAction.MATERIALIZE_DECISION, + "6": TransactionResolutionAction.FINALIZE, +}; + +/** Exact protocol status names used by the advanced lifecycle RPC. */ +export enum TransactionProtocolStatus { + UNINITIALIZED = "Uninitialized", + PENDING = "Pending", + PROPOSING = "Proposing", + COMMITTING = "Committing", + REVEALING = "Revealing", + ACCEPTED = "Accepted", + UNDETERMINED = "Undetermined", + FINALIZED = "Finalized", + CANCELED = "Canceled", + APPEAL_REVEALING = "AppealRevealing", + APPEAL_COMMITTING = "AppealCommitting", + VALIDATORS_TIMEOUT = "ValidatorsTimeout", + LEADER_TIMEOUT = "LeaderTimeout", + LEADER_REVEALING = "LeaderRevealing", } +export const transactionProtocolStatusNumberToName = { + "0": TransactionProtocolStatus.UNINITIALIZED, + "1": TransactionProtocolStatus.PENDING, + "2": TransactionProtocolStatus.PROPOSING, + "3": TransactionProtocolStatus.COMMITTING, + "4": TransactionProtocolStatus.REVEALING, + "5": TransactionProtocolStatus.ACCEPTED, + "6": TransactionProtocolStatus.UNDETERMINED, + "7": TransactionProtocolStatus.FINALIZED, + "8": TransactionProtocolStatus.CANCELED, + "9": TransactionProtocolStatus.APPEAL_REVEALING, + "10": TransactionProtocolStatus.APPEAL_COMMITTING, + "11": TransactionProtocolStatus.VALIDATORS_TIMEOUT, + "12": TransactionProtocolStatus.LEADER_TIMEOUT, + "13": TransactionProtocolStatus.LEADER_REVEALING, +}; + +/** Exact resolution-source names used by the advanced lifecycle RPC. */ +export enum TransactionResolutionSource { + UNSPECIFIED = "Unspecified", + ACTIVATION_INSUFFICIENT_VALIDATORS = "ActivationInsufficientValidators", + PROPOSAL_HANGING = "ProposalHanging", + LEADER_RECEIPT_TIMEOUT = "LeaderReceiptTimeout", + COMMIT_HANGING = "CommitHanging", + LEADER_REVEAL_HANGING = "LeaderRevealHanging", + FULL_REVEAL = "FullReveal", + REVEAL_DEADLINE = "RevealDeadline", + APPEAL_COMMIT_HANGING = "AppealCommitHanging", + APPEAL_FULL_REVEAL = "AppealFullReveal", + APPEAL_REVEAL_DEADLINE = "AppealRevealDeadline", + SELECTION_DEPLETED = "SelectionDepleted", +} + +export const transactionResolutionSourceNumberToName = { + "0": TransactionResolutionSource.UNSPECIFIED, + "1": TransactionResolutionSource.ACTIVATION_INSUFFICIENT_VALIDATORS, + "2": TransactionResolutionSource.PROPOSAL_HANGING, + "3": TransactionResolutionSource.LEADER_RECEIPT_TIMEOUT, + "4": TransactionResolutionSource.COMMIT_HANGING, + "5": TransactionResolutionSource.LEADER_REVEAL_HANGING, + "6": TransactionResolutionSource.FULL_REVEAL, + "7": TransactionResolutionSource.REVEAL_DEADLINE, + "8": TransactionResolutionSource.APPEAL_COMMIT_HANGING, + "9": TransactionResolutionSource.APPEAL_FULL_REVEAL, + "10": TransactionResolutionSource.APPEAL_REVEAL_DEADLINE, + "11": TransactionResolutionSource.SELECTION_DEPLETED, +}; + export enum TransactionResult { IDLE = "IDLE", AGREE = "AGREE", @@ -41,6 +121,8 @@ export enum TransactionResult { MAJORITY_TIMEOUT = "MAJORITY_TIMEOUT", } +// ReadyToFinalize is no longer a transaction status. Advanced consumers derive +// the current finalization capability from resolutionAction === "Finalize". export const transactionsStatusNumberToName = { "0": TransactionStatus.UNINITIALIZED, "1": TransactionStatus.PENDING, @@ -53,13 +135,12 @@ export const transactionsStatusNumberToName = { "8": TransactionStatus.CANCELED, "9": TransactionStatus.APPEAL_REVEALING, "10": TransactionStatus.APPEAL_COMMITTING, - "11": TransactionStatus.READY_TO_FINALIZE, - "12": TransactionStatus.VALIDATORS_TIMEOUT, - "13": TransactionStatus.LEADER_TIMEOUT, - "14": TransactionStatus.LEADER_REVEALING, + "11": TransactionStatus.VALIDATORS_TIMEOUT, + "12": TransactionStatus.LEADER_TIMEOUT, + "13": TransactionStatus.LEADER_REVEALING, }; -export const transactionsStatusNameToNumber = { +export const transactionsStatusNameToNumber: Record = { [TransactionStatus.UNINITIALIZED]: "0", [TransactionStatus.PENDING]: "1", [TransactionStatus.PROPOSING]: "2", @@ -71,10 +152,9 @@ export const transactionsStatusNameToNumber = { [TransactionStatus.CANCELED]: "8", [TransactionStatus.APPEAL_REVEALING]: "9", [TransactionStatus.APPEAL_COMMITTING]: "10", - [TransactionStatus.READY_TO_FINALIZE]: "11", - [TransactionStatus.VALIDATORS_TIMEOUT]: "12", - [TransactionStatus.LEADER_TIMEOUT]: "13", - [TransactionStatus.LEADER_REVEALING]: "14", + [TransactionStatus.VALIDATORS_TIMEOUT]: "11", + [TransactionStatus.LEADER_TIMEOUT]: "12", + [TransactionStatus.LEADER_REVEALING]: "13", }; export const DECIDED_STATES = [ @@ -116,6 +196,7 @@ export enum ExecutionResult { FINISHED_WITH_ERROR = "FINISHED_WITH_ERROR", TIMEOUT = "TIMEOUT", NONDET_DISAGREE = "NONDET_DISAGREE", + DETERMINISTIC_VIOLATION = "DETERMINISTIC_VIOLATION", } export const executionResultNumberToName = { @@ -124,30 +205,34 @@ export const executionResultNumberToName = { "2": ExecutionResult.FINISHED_WITH_ERROR, "3": ExecutionResult.TIMEOUT, "4": ExecutionResult.NONDET_DISAGREE, + "5": ExecutionResult.DETERMINISTIC_VIOLATION, }; export enum VoteType { NOT_VOTED = "NOT_VOTED", - AGREE = "AGREE", - DISAGREE = "DISAGREE", + FINISHED_WITH_RETURN = "FINISHED_WITH_RETURN", + FINISHED_WITH_ERROR = "FINISHED_WITH_ERROR", TIMEOUT = "TIMEOUT", + NONDET_DISAGREE = "NONDET_DISAGREE", DETERMINISTIC_VIOLATION = "DETERMINISTIC_VIOLATION", } export const voteTypeNumberToName = { "0": VoteType.NOT_VOTED, - "1": VoteType.AGREE, - "2": VoteType.DISAGREE, + "1": VoteType.FINISHED_WITH_RETURN, + "2": VoteType.FINISHED_WITH_ERROR, "3": VoteType.TIMEOUT, - "4": VoteType.DETERMINISTIC_VIOLATION, + "4": VoteType.NONDET_DISAGREE, + "5": VoteType.DETERMINISTIC_VIOLATION, }; export const voteTypeNameToNumber = { [VoteType.NOT_VOTED]: "0", - [VoteType.AGREE]: "1", - [VoteType.DISAGREE]: "2", + [VoteType.FINISHED_WITH_RETURN]: "1", + [VoteType.FINISHED_WITH_ERROR]: "2", [VoteType.TIMEOUT]: "3", - [VoteType.DETERMINISTIC_VIOLATION]: "4", + [VoteType.NONDET_DISAGREE]: "4", + [VoteType.DETERMINISTIC_VIOLATION]: "5", }; export type TransactionType = "deploy" | "call"; @@ -159,6 +244,147 @@ export enum TransactionHashVariant { export type TransactionReceiptWaitUntil = "decided" | "finalized"; +export type TransactionProcessingPhase = + | "uninitialized" + | "pending" + | "proposing" + | "committing" + | "revealing" + | "appeal-revealing" + | "appeal-committing" + | "leader-revealing"; + +export type TransactionDecisionOutcome = + | "accepted" + | "undetermined" + | "validators-timeout" + | "leader-timeout"; + +/** Consumer-oriented lifecycle derived only from the persisted transaction state. */ +export type TransactionLifecycle = + | {state: "processing"; phase: TransactionProcessingPhase} + | {state: "decided"; outcome: TransactionDecisionOutcome} + /** Outcome is omitted when the retained result cannot prove the pre-finalized decision. */ + | {state: "finalized"; outcome?: TransactionDecisionOutcome} + | {state: "canceled"}; + +/** + * Arguments for the advanced protocol lifecycle read. `timestamp` is a Unix + * timestamp; omitting it evaluates the lifecycle at the node or block time. + */ +export type TransactionProtocolLifecycleArgs = { + hash: TransactionHash; + timestamp?: number; +}; + +/** + * Advanced protocol lifecycle normalized across Studio and contract networks. + * `resolutionAction === "Finalize"` is the protocol's finalization capability; + * finalization is not a transaction status or a separate readiness flag. + */ +export type TransactionProtocolLifecycle = { + storedStatus: TransactionProtocolStatus; + storedStatusCode: number; + projectedStatus: TransactionProtocolStatus; + projectedStatusCode: number; + resolutionAction: TransactionResolutionAction; + resolutionActionCode: number; + resolutionSource: TransactionResolutionSource; + resolutionSourceCode: number; + decisionId: string | null; + decisionActive: boolean; + evaluatedAt: number; +}; + +const finalizedOutcomeFromResult = ( + result: TransactionResult | number | undefined, +): TransactionDecisionOutcome | undefined => { + const resultName = typeof result === "number" + ? transactionResultNumberToName[String(result) as keyof typeof transactionResultNumberToName] + : result; + + switch (resultName) { + case TransactionResult.MAJORITY_AGREE: + return "accepted"; + case TransactionResult.MAJORITY_TIMEOUT: + return "validators-timeout"; + case TransactionResult.MAJORITY_DISAGREE: + case TransactionResult.DETERMINISTIC_VIOLATION: + case TransactionResult.NO_MAJORITY: + return "undetermined"; + default: + // IDLE does not distinguish a finalized leader timeout from every other + // result-less path, so the SDK must not invent an outcome for it. + return undefined; + } +}; + +/** Maps the exact stored protocol status to the non-projecting public lifecycle. */ +export const transactionLifecycleFromStoredStatus = ( + status: TransactionStatus | number, + result?: TransactionResult | number, +): TransactionLifecycle => { + const statusName = typeof status === "number" + ? transactionsStatusNumberToName[String(status) as keyof typeof transactionsStatusNumberToName] + : status; + + switch (statusName) { + case TransactionStatus.UNINITIALIZED: + return {state: "processing", phase: "uninitialized"}; + case TransactionStatus.PENDING: + return {state: "processing", phase: "pending"}; + case TransactionStatus.PROPOSING: + return {state: "processing", phase: "proposing"}; + case TransactionStatus.COMMITTING: + return {state: "processing", phase: "committing"}; + case TransactionStatus.REVEALING: + return {state: "processing", phase: "revealing"}; + case TransactionStatus.APPEAL_REVEALING: + return {state: "processing", phase: "appeal-revealing"}; + case TransactionStatus.APPEAL_COMMITTING: + return {state: "processing", phase: "appeal-committing"}; + case TransactionStatus.LEADER_REVEALING: + return {state: "processing", phase: "leader-revealing"}; + case TransactionStatus.ACCEPTED: + return {state: "decided", outcome: "accepted"}; + case TransactionStatus.UNDETERMINED: + return {state: "decided", outcome: "undetermined"}; + case TransactionStatus.VALIDATORS_TIMEOUT: + return {state: "decided", outcome: "validators-timeout"}; + case TransactionStatus.LEADER_TIMEOUT: + return {state: "decided", outcome: "leader-timeout"}; + case TransactionStatus.FINALIZED: { + const outcome = finalizedOutcomeFromResult(result); + return outcome ? {state: "finalized", outcome} : {state: "finalized"}; + } + case TransactionStatus.CANCELED: + return {state: "canceled"}; + default: + throw new Error(`Unknown stored transaction status: ${String(status)}`); + } +}; + +/** Full public round shape reconstructed from bounded train reads. */ +export interface ConsensusRoundData { + round: bigint; + leaderIndex: bigint; + votesCommitted: bigint; + votesRevealed: bigint; + appealBond: bigint; + rotationsLeft: bigint; + result: number; + roundValidators: Address[]; + validatorVotes: number[]; + validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; +} + +/** Legacy tuple shape returned by getLastRoundData, with named properties retained. */ +export type ConsensusLastRoundData = [round: bigint, roundData: ConsensusRoundData] & { + round: bigint; + roundData: ConsensusRoundData; +}; + export enum MessageType { External = 0, Internal = 1, @@ -481,6 +707,8 @@ export type GenLayerTransaction = { // numOfInitialValidators: testnet numOfInitialValidators?: string; + /** Train maximum rotations, distinct from the initial committee size. */ + initialRotations?: bigint; // txSlot: testnet txSlot?: string; @@ -506,11 +734,15 @@ export type GenLayerTransaction = { data?: Record; txData?: Hex; txDataDecoded?: DecodedDeployData | DecodedCallData; - // txReceipt: testnet - txReceipt?: Hash; + /** Authoritative execution hash retained by the train. */ + txExecutionHash?: Hash; + eqBlocksOutputs?: Hex; + /** Legacy receipt bytes; unavailable on the train. */ + txReceipt?: Hex; // messages: testnet messages?: unknown[]; + consumedValidators?: Address[]; // queueType: testnet queueType?: number; @@ -525,8 +757,12 @@ export type GenLayerTransaction = { lastLeader?: Address; // status: localnet: TransactionStatus // status: testnet: number + /** Exact lifecycle status persisted by the transaction manager. */ status?: TransactionStatus | number; + /** Named form of the exact persisted lifecycle status. */ statusName?: TransactionStatus; + /** Simple lifecycle derived from `status`; it never uses timestamp projection. */ + lifecycle: TransactionLifecycle; // hash: localnet // txId: testnet// hash: localnet // txId: testnet hash?: TransactionHash; @@ -553,6 +789,7 @@ export type GenLayerTransaction = { result: number; roundValidators: Address[]; validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; validatorVotes: number[]; validatorVotesName: VoteType[]; }; @@ -575,20 +812,22 @@ export type GenLayerTransaction = { }; export type GenLayerRawTransaction = { - currentTimestamp: bigint; + observedAt: bigint; sender: Address; recipient: Address; - numOfInitialValidators?: bigint; // undefined on Bradbury — use `initialRotations` instead - initialRotations?: bigint; // Bradbury equivalent of `numOfInitialValidators` + initialRotations: bigint; + numOfInitialValidators: bigint; txSlot: bigint; createdTimestamp: bigint; lastVoteTimestamp: bigint; randomSeed: Hash; result: number; txExecutionResult?: number; - txData: Hex | undefined | null; - txReceipt: Hash; + txExecutionHash: Hash; + txCalldata: Hex; + eqBlocksOutputs: Hex; messages: unknown[]; + consumedValidators: Address[]; queueType: number; queuePosition: bigint; activator: Address; @@ -611,6 +850,7 @@ export type GenLayerRawTransaction = { result: number; roundValidators: Address[]; validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; validatorVotes: number[]; }; }; diff --git a/tests/chains-actions.test.ts b/tests/chains-actions.test.ts index 2ba4399..e869fd3 100644 --- a/tests/chains-actions.test.ts +++ b/tests/chains-actions.test.ts @@ -1,5 +1,17 @@ import {describe, expect, it, vi} from "vitest"; import {chainActions} from "../src/chains/actions"; +import {localnet, studionet} from "../src/chains"; + +type TestAbiParameter = { + type: string; + components?: readonly TestAbiParameter[]; +}; + +type TestAbiEntry = { + type: string; + name?: string; + inputs?: readonly TestAbiParameter[]; +}; const makeClient = (chainId: number) => ({ @@ -37,3 +49,60 @@ describe("chainActions.initializeConsensusSmartContract", () => { fetchSpy.mockRestore(); }); }); + +describe.each([ + ["localnet", localnet], + ["studionet", studionet], +])("%s Studio consensus ABI", (_name, chain) => { + it("exports Studio's fee-aware, decision-free transaction signatures", () => { + const functions = new Map( + (chain.consensusMainContract!.abi as readonly TestAbiEntry[]) + .filter((entry) => entry.type === "function" && entry.name) + .map((entry) => [entry.name!, entry]), + ); + const inputTypes = (name: string) => + functions.get(name)!.inputs!.map((input) => input.type); + + expect(inputTypes("addTransaction")).toEqual(["tuple"]); + expect(inputTypes("deploySalted")).toEqual(["tuple"]); + expect(inputTypes("topUpFees")).toEqual(["bytes32", "tuple"]); + expect(inputTypes("topUpAndSubmitAppeal")).toEqual(["bytes32", "tuple"]); + expect(inputTypes("submitAppeal")).toEqual(["bytes32"]); + expect(inputTypes("finalizeTransaction")).toEqual(["bytes32"]); + + const addParams = functions.get("addTransaction")!.inputs![0].components!; + expect(addParams.map((component) => component.type)).toEqual([ + "address", + "address", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "tuple", + "bytes", + "tuple[]", + ]); + expect(addParams[7].components!.map((component) => component.type)).toEqual([ + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256[]", + "uint256", + "uint256", + "uint256", + ]); + expect(addParams[9].components!.map((component) => component.type)).toEqual([ + "uint8", + "bool", + "uint256", + "address", + "bytes32", + "uint256", + "bytes", + ]); + }); +}); diff --git a/tests/client.test-d.ts b/tests/client.test-d.ts index 7b375d4..acd1dfa 100644 --- a/tests/client.test-d.ts +++ b/tests/client.test-d.ts @@ -46,6 +46,17 @@ test("type checks", () => { void client.getContractSchemaForCode("class SomeContract..."); + // Existing consumer-facing active-validator methods remain available, and + // the append-only joined registry is an explicitly named separate read. + void client.getActiveValidators(); + void client.getActiveValidatorsCount(); + void client.getJoinedValidators(); + void client.getJoinedValidatorsCount(); + + // Finalization readiness is an action, not a transaction status. + // @ts-expect-error READY_TO_FINALIZE was removed from the train status enum + void TransactionStatus.READY_TO_FINALIZE; + void client.waitForTransactionReceipt({ hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, }); @@ -74,6 +85,55 @@ test("type checks", () => { retries: 10, }); + void client.waitForDecision({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + fullTransaction: true, + }); + + void client.waitForFinalization({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + retries: 10, + }); + + void client.advanced.getTransactionLifecycle({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + timestamp: 1_700_000_000, + }).then(lifecycle => { + void lifecycle.storedStatus; + void lifecycle.storedStatusCode; + void lifecycle.projectedStatus; + void lifecycle.projectedStatusCode; + void lifecycle.resolutionAction; + void lifecycle.resolutionActionCode; + void lifecycle.resolutionSource; + void lifecycle.resolutionSourceCode; + void lifecycle.decisionId; + void lifecycle.decisionActive; + void lifecycle.evaluatedAt; + // @ts-expect-error finalization is represented by resolutionAction === "Finalize" + void lifecycle.finalization; + // @ts-expect-error there is no separate readiness field + void lifecycle.canFinalize; + }); + + // @ts-expect-error raw lifecycle access is intentionally kept out of the primary client namespace + void client.getTransactionLifecycle; + + void client.getTransaction({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + }).then(transaction => { + if (transaction.lifecycle.state === "processing") { + void transaction.lifecycle.phase; + // @ts-expect-error processing transactions do not expose an outcome + void transaction.lifecycle.outcome; + } + // Protocol projection/action is intentionally absent from the primary model. + // @ts-expect-error use advanced.getTransactionLifecycle for raw resolution details + void transaction.resolutionAction; + // @ts-expect-error use advanced.getTransactionLifecycle for the resolution action + void transaction.canFinalize; + }); + // @ts-expect-error missing hash void client.waitForTransactionReceipt({ status: TransactionStatus.FINALIZED, diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 67791f2..f91f208 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1,5 +1,11 @@ import {describe, it, expect, vi} from "vitest"; -import {decodeAbiParameters, decodeFunctionData, encodeFunctionData, keccak256, toHex} from "viem"; +import { + decodeAbiParameters, + decodeFunctionData, + encodeFunctionData, + keccak256, + toHex, +} from "viem"; import {contractActions} from "../src/contracts/actions"; import {NFT_MINTER_ABI} from "../src/abi/nftMinter"; import { @@ -20,8 +26,11 @@ const SENDER_ADDRESS = "0x0000000000000000000000000000000000000002"; const RECIPIENT_ADDRESS = "0x0000000000000000000000000000000000000003"; const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000004"; const NFT_MINTER_ADDRESS = "0x0000000000000000000000000000000000000005"; +const CONSENSUS_DATA_ADDRESS = "0x0000000000000000000000000000000000000006"; const MOCK_GENLAYER_TX_ID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const MOCK_EVM_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; +const MOCK_DECISION_ID = 42n; +const MOCK_ATTEMPT_ID = `0x${"bb".repeat(32)}` as const; const NEW_TRANSACTION_EVENT_ABI = { type: "event" as const, @@ -181,18 +190,6 @@ const ADD_TRANSACTION_ABI_WITH_FEES = [ }, ] as const; -const selectorForV5 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V5 as any, - functionName: "addTransaction", - args: [SENDER_ADDRESS, RECIPIENT_ADDRESS, 5, 3, "0x"], -}).slice(0, 10); - -const selectorForV6 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V6 as any, - functionName: "addTransaction", - args: [SENDER_ADDRESS, RECIPIENT_ADDRESS, 5, 3, "0x", 0n], -}).slice(0, 10); - const selectorForFees = encodeFunctionData({ abi: ADD_TRANSACTION_ABI_WITH_FEES as any, functionName: "addTransaction", @@ -646,7 +643,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(deriveExternalMessageCallKey("0x123456")).toBe(CALL_KEY_UNNAMED); }); - it("encodes addTransaction with 5 args when ABI has 5 inputs", async () => { + it("uses the train tuple layout even when embedded chain metadata is stale v5", async () => { const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V5, }); @@ -660,10 +657,10 @@ describe("contractActions addTransaction ABI compatibility", () => { ).rejects.toThrow("stop_after_encoding"); const encodedData = estimateTransactionGas.mock.calls[0][0].data as `0x${string}`; - expect(encodedData.slice(0, 10)).toBe(selectorForV5); + expect(encodedData.slice(0, 10)).toBe(selectorForFees); }); - it("encodes addTransaction with 6 args when ABI has 6 inputs", async () => { + it("uses the train tuple layout even when embedded chain metadata is stale v6", async () => { const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V6, }); @@ -677,7 +674,7 @@ describe("contractActions addTransaction ABI compatibility", () => { ).rejects.toThrow("stop_after_encoding"); const encodedData = estimateTransactionGas.mock.calls[0][0].data as `0x${string}`; - expect(encodedData.slice(0, 10)).toBe(selectorForV6); + expect(encodedData.slice(0, 10)).toBe(selectorForFees); }); it("encodes addTransaction with v0.6 fee params when ABI has tuple input", async () => { @@ -896,6 +893,85 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(estimateTransactionGas.mock.calls[0][0].value).toBe(11_000n); }); + it("defaults direct fee estimates to the chain rotation budget for every appeal round", async () => { + const requestMock = vi.fn().mockResolvedValue({ + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const defaulted = await actions.estimateFeesDistribution({appealRounds: 2n}); + const explicit = await actions.estimateFeesDistribution({rotations: [0n]}); + + expect(defaulted.rotations).toEqual([3n, 3n, 3n]); + expect(explicit.rotations).toEqual([0n]); + }); + + it("defaults simulation fee estimates to the chain rotation budget", async () => { + const requestMock = vi.fn().mockResolvedValue({ + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesFromSimulation({ + appealRounds: 1n, + simulation: {}, + }); + + expect(fees.distribution.rotations).toEqual([3n, 3n]); + }); + + it("uses the chain rotation budget in write estimation requests and fallback results", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }; + } + if (method === "sim_estimateTransactionFees") return {}; + if (method === "gen_call") return {data: "0x"}; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesForWrite({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + }); + + const estimateRequest = requestMock.mock.calls.find( + ([call]) => call.method === "sim_estimateTransactionFees", + )?.[0]; + expect(estimateRequest.params[0].fees.distribution.rotations).toEqual(["3"]); + expect(fees.distribution.rotations).toEqual([3n]); + }); + it("estimates fee distribution caps and fee value from FeeManager prices", async () => { const publicClient = { readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { @@ -1081,7 +1157,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.storageFeeMaxGasPrice).toBe(20n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(30n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); - expect(fees.feeValue).toBe(3_000_011_000n); + expect(fees.feeValue).toBe(12_000_044_000n); }); it("prefers Studio's exposed message fee budget floor over local fallback math", async () => { @@ -1111,7 +1187,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.policy.executionBudgetFloor).toBe(700_000n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); - expect(fees.feeValue).toBe(3_000_011_000n); + expect(fees.feeValue).toBe(12_000_044_000n); }); it("builds a Studio trusted fee preset from a simulation fee report", async () => { @@ -1255,7 +1331,7 @@ describe("contractActions addTransaction ABI compatibility", () => { }); expect(fees.distribution.executionBudgetPerRound).toBe(expectedExecutionBudgetPerRound); expect(fees.distribution.totalMessageFees).toBe(6n); - expect(fees.feeValue).toBe(613_123n); + expect(fees.feeValue).toBe(2_452_474n); }); it("uses the execution budget floor for simulation recommendations when observed usage is lower", async () => { @@ -1392,7 +1468,7 @@ describe("contractActions addTransaction ABI compatibility", () => { const simCall = requestMock.mock.calls.find(([call]) => call.method === "sim_estimateTransactionFees")?.[0]; expect(simCall).toBeDefined(); expect(simCall.params[0].value).toBe("0x7"); - expect(simCall.params[0].fees.feeValue).toBe("3000311110"); + expect(simCall.params[0].fees.feeValue).toBe("12001244110"); expect(simCall.params[0].fees.distribution.totalMessageFees).toBe("110"); expect(simCall.params[0].fees.messageAllocations[0].budget).toBe("110"); expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(602_117n); @@ -1454,7 +1530,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.messageAllocations?.[0].budget).toBe(50n); expect(fees.messageAllocations?.[0].feeParams).toBe(feeParams); expect(fees.distribution.totalMessageFees).toBe(50n); - expect(fees.feeValue).toBe(3_000_311_050n); + expect(fees.feeValue).toBe(12_001_244_050n); }); it("keeps Studio fee estimation gasless when sim_getFeeConfig is disabled", async () => { @@ -1490,11 +1566,10 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.feeValue).toBe(0n); }); - it("retries with v6 signature when v5 signature fails with ABI mismatch", async () => { + it("does not retry an obsolete selector when the exact train call fails", async () => { const signTransaction = vi .fn() - .mockRejectedValueOnce(new Error("Invalid pointer in tuple at location 128 in payload")) - .mockRejectedValueOnce(new Error("stop_after_retry")); + .mockRejectedValueOnce(new Error("Invalid pointer in tuple at location 128 in payload")); const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V5, signTransactionMock: signTransaction, @@ -1506,68 +1581,12 @@ describe("contractActions addTransaction ABI compatibility", () => { functionName: "ping", value: 0n, }), - ).rejects.toThrow("stop_after_retry"); + ).rejects.toThrow("Invalid pointer in tuple"); - expect(signTransaction).toHaveBeenCalledTimes(2); + expect(signTransaction).toHaveBeenCalledTimes(1); const firstEncodedData = signTransaction.mock.calls[0][0].data as `0x${string}`; - const secondEncodedData = signTransaction.mock.calls[1][0].data as `0x${string}`; - expect(firstEncodedData.slice(0, 10)).toBe(selectorForV5); - expect(secondEncodedData.slice(0, 10)).toBe(selectorForV6); - expect(estimateTransactionGas).toHaveBeenCalledTimes(2); - }); - - it("retries when ABI mismatch details are on error.details (viem InternalRpcError shape)", async () => { - const signTransaction = vi - .fn() - .mockRejectedValueOnce({ - shortMessage: "An internal error was received.", - details: "Invalid pointer in tuple at location 128 in payload", - }) - .mockRejectedValueOnce(new Error("stop_after_retry")); - const {actions} = setupWriteContractHarness({ - initialAbi: ADD_TRANSACTION_ABI_V5, - signTransactionMock: signTransaction as any, - }); - - await expect( - actions.writeContract({ - address: RECIPIENT_ADDRESS, - functionName: "ping", - value: 0n, - }), - ).rejects.toThrow("stop_after_retry"); - - expect(signTransaction).toHaveBeenCalledTimes(2); - const firstEncodedData = signTransaction.mock.calls[0][0].data as `0x${string}`; - const secondEncodedData = signTransaction.mock.calls[1][0].data as `0x${string}`; - expect(firstEncodedData.slice(0, 10)).toBe(selectorForV5); - expect(secondEncodedData.slice(0, 10)).toBe(selectorForV6); - }); - - it("retries with v5 signature when v6 signature fails with ABI mismatch", async () => { - const signTransaction = vi - .fn() - .mockRejectedValueOnce(new Error("Invalid pointer in tuple at location 128 in payload")) - .mockRejectedValueOnce(new Error("stop_after_retry")); - const {actions, estimateTransactionGas} = setupWriteContractHarness({ - initialAbi: ADD_TRANSACTION_ABI_V6, - signTransactionMock: signTransaction, - }); - - await expect( - actions.writeContract({ - address: RECIPIENT_ADDRESS, - functionName: "ping", - value: 0n, - }), - ).rejects.toThrow("stop_after_retry"); - - expect(signTransaction).toHaveBeenCalledTimes(2); - const firstEncodedData = signTransaction.mock.calls[0][0].data as `0x${string}`; - const secondEncodedData = signTransaction.mock.calls[1][0].data as `0x${string}`; - expect(firstEncodedData.slice(0, 10)).toBe(selectorForV6); - expect(secondEncodedData.slice(0, 10)).toBe(selectorForV5); - expect(estimateTransactionGas).toHaveBeenCalledTimes(2); + expect(firstEncodedData.slice(0, 10)).toBe(selectorForFees); + expect(estimateTransactionGas).toHaveBeenCalledTimes(1); }); it("uses direct eth_sendTransaction for non-local accounts without prepareTransactionRequest", async () => { @@ -1635,7 +1654,7 @@ describe("contractActions addTransaction ABI compatibility", () => { }); }); - it("retries alternate ABI for injected-wallet errors with nested invalid pointer details", async () => { + it("does not retry an obsolete selector for injected-wallet errors", async () => { const sentPayloads: `0x${string}`[] = []; const request = vi.fn().mockImplementation(async ({method, params}: {method: string; params?: any[]}) => { if (method === "eth_gasPrice") { @@ -1646,19 +1665,7 @@ describe("contractActions addTransaction ABI compatibility", () => { const payload = params?.[0]; sentPayloads.push(payload?.data); - if (sentPayloads.length === 1) { - throw { - code: -32603, - message: "Internal JSON-RPC error.", - data: { - originalError: { - message: "Invalid pointer in tuple at location 128 in payload", - }, - }, - }; - } - - return MOCK_EVM_TX_HASH; + throw new Error("Internal JSON-RPC error: invalid tuple pointer"); } throw new Error(`Unexpected RPC method: ${method}`); @@ -1688,17 +1695,12 @@ describe("contractActions addTransaction ABI compatibility", () => { }; const actions = contractActions(client as any, mockPublicClient as any); - const txHash = await actions.writeContract({ - address: RECIPIENT_ADDRESS, - functionName: "ping", - value: 0n, - }); + await expect( + actions.writeContract({address: RECIPIENT_ADDRESS, functionName: "ping", value: 0n}), + ).rejects.toThrow("invalid tuple pointer"); - // Should return GenLayer txId, not the EVM hash - expect(txHash).toBe(MOCK_GENLAYER_TX_ID); - expect(sentPayloads).toHaveLength(2); - expect(sentPayloads[0].slice(0, 10)).toBe(selectorForV5); - expect(sentPayloads[1].slice(0, 10)).toBe(selectorForV6); + expect(sentPayloads).toHaveLength(1); + expect(sentPayloads[0].slice(0, 10)).toBe(selectorForFees); }); it("throws when external wallet transaction is reverted", async () => { @@ -1797,14 +1799,38 @@ const FINALIZE_TX_ABI = [ type: "function" as const, name: "finalizeTransaction", stateMutability: "nonpayable" as const, - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, + { + type: "function" as const, + name: "resolveTransactions", + stateMutability: "nonpayable" as const, + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedAttemptId", type: "bytes32"}, + ], + }], outputs: [], }, { type: "function" as const, - name: "finalizeIdlenessTxs", + name: "finalizeDecisions", stateMutability: "nonpayable" as const, - inputs: [{name: "_txIds", type: "bytes32[]"}], + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedDecisionId", type: "uint256"}, + ], + }], outputs: [], }, ]; @@ -1826,22 +1852,42 @@ const FEE_MANAGEMENT_ABI = [ stateMutability: "payable" as const, inputs: [ {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, ], outputs: [], }, ] as const; +const APPEAL_TRAIN_ABI = [ + { + type: "function" as const, + name: "submitAppeal", + stateMutability: "payable" as const, + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, +] as const; + const finalizeTransactionSelector = encodeFunctionData({ abi: FINALIZE_TX_ABI as any, functionName: "finalizeTransaction", - args: [MOCK_GENLAYER_TX_ID], + args: [MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID], +}).slice(0, 10); + +const resolveTransactionsSelector = encodeFunctionData({ + abi: FINALIZE_TX_ABI as any, + functionName: "resolveTransactions", + args: [[{txId: MOCK_GENLAYER_TX_ID, expectedAttemptId: MOCK_ATTEMPT_ID}]], }).slice(0, 10); -const finalizeIdlenessSelector = encodeFunctionData({ +const finalizeDecisionsSelector = encodeFunctionData({ abi: FINALIZE_TX_ABI as any, - functionName: "finalizeIdlenessTxs", - args: [[MOCK_GENLAYER_TX_ID]], + functionName: "finalizeDecisions", + args: [[{txId: MOCK_GENLAYER_TX_ID, expectedDecisionId: MOCK_DECISION_ID}]], }).slice(0, 10); const topUpFeesSelector = encodeFunctionData({ @@ -1869,6 +1915,7 @@ const topUpAndSubmitAppealSelector = encodeFunctionData({ functionName: "topUpAndSubmitAppeal", args: [ MOCK_GENLAYER_TX_ID, + MOCK_DECISION_ID, { leaderTimeunitsAllocation: 0n, validatorTimeunitsAllocation: 0n, @@ -1884,7 +1931,27 @@ const topUpAndSubmitAppealSelector = encodeFunctionData({ ], }).slice(0, 10); -const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: string} = {}) => { +const trainLifecycle = ({action = 6, decisionActive = true}: {action?: number; decisionActive?: boolean} = {}) => ({ + storedStatus: 5, + resolution: { + projectedStatus: 5, + action, + attemptId: MOCK_ATTEMPT_ID, + }, + latestDecision: { + exists: decisionActive, + decisionId: decisionActive ? MOCK_DECISION_ID : 0n, + }, + decisionActive, +}); + +const setupFinalizeHarness = ({ + receiptStatus = "success", + resolutionAction = 6, +}: { + receiptStatus?: string; + resolutionAction?: number; +} = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: receiptStatus, logs: []}); @@ -1898,6 +1965,11 @@ const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: stri abi: FINALIZE_TX_ABI, bytecode: "0x", }, + consensusDataContract: { + address: CONSENSUS_DATA_ADDRESS, + abi: [], + bytecode: "0x", + }, }, account: { address: SENDER_ADDRESS, @@ -1913,18 +1985,30 @@ const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: stri }), }; - const publicClient = {waitForTransactionReceipt}; + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "getTransactionLifecycle") { + return trainLifecycle({action: resolutionAction}); + } + throw new Error(`Unexpected contract read: ${functionName}`); + }); + const publicClient = { + waitForTransactionReceipt, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; const actions = contractActions(client as any, publicClient as any); - return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client}; + return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client, publicClient}; }; const setupFeeManagementHarness = ({ receiptStatus = "success", isStudio = false, + decisionActive = true, }: { receiptStatus?: string; isStudio?: boolean; + decisionActive?: boolean; } = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); @@ -1940,6 +2024,21 @@ const setupFeeManagementHarness = ({ abi: FEE_MANAGEMENT_ABI, bytecode: "0x", }, + consensusDataContract: { + address: CONSENSUS_DATA_ADDRESS, + abi: [], + bytecode: "0x", + }, + appealsContract: { + address: RECIPIENT_ADDRESS, + abi: [], + bytecode: "0x", + }, + feeManagerContract: { + address: ADDRESS_MANAGER_ADDRESS, + abi: [], + bytecode: "0x", + }, }, account: { address: SENDER_ADDRESS, @@ -1955,10 +2054,22 @@ const setupFeeManagementHarness = ({ }), }; - const publicClient = {waitForTransactionReceipt}; + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "getTransactionLifecycle") return trainLifecycle({decisionActive}); + if (functionName === "estimateLatestAppealCharge") { + return [MOCK_DECISION_ID, 1000n, 234n, 999n]; + } + if (functionName === "canAppeal") return true; + throw new Error(`Unexpected contract read: ${functionName}`); + }); + const publicClient = { + waitForTransactionReceipt, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; const actions = contractActions(client as any, publicClient as any); - return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client}; + return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client, publicClient}; }; describe("contractActions getContractCode", () => { @@ -2091,7 +2202,109 @@ describe("contractActions getContractSchemaForCode", () => { }); }); +describe("contractActions bounded round reads", () => { + const validators = Array.from( + {length: 65}, + (_, index) => `0x${(index + 10).toString(16).padStart(40, "0")}`, + ); + const voteHashes = validators.map((_, index) => + `0x${(index + 1).toString(16).padStart(64, "0")}`, + ); + const resultHashes = validators.map((_, index) => + `0x${(index + 101).toString(16).padStart(64, "0")}`, + ); + + const setup = () => { + const readContract = vi.fn().mockImplementation(async ({functionName, args}: any) => { + if (functionName === "getRoundNumber") return 3n; + if (functionName === "getLeaderIndex") return 2n; + if (functionName === "getVotesCommitted") return 65n; + if (functionName === "getVotesRevealed") return 64n; + if (functionName === "getAppealBond") return 99n; + if (functionName === "getRotationsLeft") return 4n; + if (functionName === "getResult") return 1; + if (functionName === "getValidatorVotes") return validators.map(() => 1); + if (functionName === "getValidatorVotesHash") return voteHashes; + if (functionName === "getValidatorResultHash") return resultHashes; + if (functionName === "getRoundValidatorsPage") { + const offset = Number(args[2]); + const limit = Number(args[3]); + return [validators.slice(offset, offset + limit), BigInt(validators.length)]; + } + throw new Error(`Unexpected read ${functionName}`); + }); + const publicClient = { + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; + const client = { + chain: { + roundsStorageContract: {address: ADDRESS_MANAGER_ADDRESS, abi: []}, + }, + }; + return {actions: contractActions(client as any, publicClient as any), readContract}; + }; + + it("reconstructs getRoundData through fixed-block pages and split arrays", async () => { + const {actions, readContract} = setup(); + + const roundData = await actions.getRoundData({txId: MOCK_GENLAYER_TX_ID, round: 3n}); + + expect(roundData.roundValidators).toEqual(validators); + expect(roundData.validatorVotes).toHaveLength(65); + expect(roundData.validatorVotesHash).toEqual(voteHashes); + expect(roundData.validatorResultHash).toEqual(resultHashes); + expect(readContract.mock.calls.filter(([call]) => call.functionName === "getRoundValidatorsPage")) + .toHaveLength(2); + expect(readContract.mock.calls.every(([call]) => call.blockNumber === 123n)).toBe(true); + expect(readContract.mock.calls.some(([call]) => call.functionName === "getRoundData")).toBe(false); + }); + + it("preserves the legacy getLastRoundData tuple shape without the aggregate getter", async () => { + const {actions, readContract} = setup(); + + const lastRound = await actions.getLastRoundData({txId: MOCK_GENLAYER_TX_ID}); + + expect(lastRound[0]).toBe(3n); + expect(lastRound[1].roundValidators).toEqual(validators); + expect(lastRound.round).toBe(3n); + expect(lastRound.roundData).toBe(lastRound[1]); + expect(readContract.mock.calls.some(([call]) => call.functionName === "getLastRoundData")) + .toBe(false); + }); +}); + describe("contractActions fee management", () => { + it("quotes the full authoritative appeal charge", async () => { + const {actions, client} = setupFeeManagementHarness(); + delete (client.chain as any).feeManagerContract; + + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + }); + + it("encodes submitAppeal with the active decision id and quoted funding", async () => { + const {actions, signTransaction} = setupFeeManagementHarness(); + + await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( + MOCK_GENLAYER_TX_ID, + ); + + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.value).toBe(1234n); + const decoded = decodeFunctionData({abi: APPEAL_TRAIN_ABI, data: txRequest.data}); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + }); + + it("returns false from canAppeal when no decision is active", async () => { + const {actions, publicClient} = setupFeeManagementHarness({decisionActive: false}); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(false); + expect(publicClient.readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "canAppeal"}), + ); + }); + it("encodes topUpFees(bytes32, FeesDistribution) and returns the EVM tx hash", async () => { const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); @@ -2135,7 +2348,7 @@ describe("contractActions fee management", () => { expect(distribution.receiptFeeMaxGasPrice).toBe(36n); }); - it("encodes topUpAndSubmitAppeal(bytes32, FeesDistribution) and returns the GenLayer tx id", async () => { + it("encodes topUpAndSubmitAppeal with the active decision id", async () => { const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); const txId = await actions.topUpAndSubmitAppeal({ @@ -2158,8 +2371,9 @@ describe("contractActions fee management", () => { abi: FEE_MANAGEMENT_ABI as any, data: txRequest.data, }); - const [decodedTxId, distribution] = decoded.args as any[]; + const [decodedTxId, expectedDecisionId, distribution] = decoded.args as any[]; expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(expectedDecisionId).toBe(MOCK_DECISION_ID); expect(distribution.appealRounds).toBe(1n); expect(distribution.rotations).toEqual([0n, 1n]); }); @@ -2231,7 +2445,7 @@ describe("contractActions fee management", () => { }); describe("contractActions finalizeTransaction", () => { - it("encodes finalizeTransaction(bytes32) and returns EVM tx hash", async () => { + it("encodes finalizeTransaction with the active decision id", async () => { const {actions, signTransaction, sendRawTransaction} = setupFinalizeHarness(); const evmHash = await actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID}); @@ -2241,6 +2455,8 @@ describe("contractActions finalizeTransaction", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); expect(txRequest.data.slice(0, 10)).toBe(finalizeTransactionSelector); + const decoded = decodeFunctionData({abi: FINALIZE_TX_ABI as any, data: txRequest.data}); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); expect(txRequest.value).toBe(0n); }); @@ -2250,25 +2466,260 @@ describe("contractActions finalizeTransaction", () => { actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID}), ).rejects.toThrow(/Finalize reverted/); }); + + it("does not broadcast when the lifecycle action is not Finalize", async () => { + const {actions, signTransaction} = setupFinalizeHarness({resolutionAction: 0}); + + await expect( + actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID}), + ).rejects.toThrow(/not ready to finalize.*resolution action 0/i); + expect(signTransaction).not.toHaveBeenCalled(); + }); }); describe("contractActions finalizeIdlenessTxs", () => { - it("encodes finalizeIdlenessTxs(bytes32[]) and returns EVM tx hash", async () => { - const {actions, signTransaction, sendRawTransaction} = setupFinalizeHarness(); + it("fails with actionable train replacements and never broadcasts", async () => { + const {actions, signTransaction} = setupFinalizeHarness(); + await expect( + actions.finalizeIdlenessTxs({txIds: [MOCK_GENLAYER_TX_ID]}), + ).rejects.toThrow(/resolveTransactions.*finalizeDecisions/); + expect(signTransaction).not.toHaveBeenCalled(); + }); +}); - const evmHash = await actions.finalizeIdlenessTxs({txIds: [MOCK_GENLAYER_TX_ID]}); +describe("contractActions train lifecycle batches", () => { + it("encodes resolveTransactions with fixed-block attempt identities", async () => { + const {actions, signTransaction} = setupFinalizeHarness(); + + await actions.resolveTransactions({txIds: [MOCK_GENLAYER_TX_ID]}); - expect(evmHash).toBe(MOCK_EVM_TX_HASH); - expect(sendRawTransaction).toHaveBeenCalledTimes(1); const txRequest = signTransaction.mock.calls[0][0]; - expect(txRequest.data.slice(0, 10)).toBe(finalizeIdlenessSelector); + expect(txRequest.data.slice(0, 10)).toBe(resolveTransactionsSelector); + const decoded = decodeFunctionData({abi: FINALIZE_TX_ABI as any, data: txRequest.data}); + expect(decoded.args?.[0]).toEqual([{ + txId: MOCK_GENLAYER_TX_ID, + expectedAttemptId: MOCK_ATTEMPT_ID, + }]); }); - it("rejects an empty batch upfront", async () => { + it("encodes finalizeDecisions with fixed-block decision identities", async () => { const {actions, signTransaction} = setupFinalizeHarness(); - await expect( - actions.finalizeIdlenessTxs({txIds: []}), - ).rejects.toThrow(/at least one txId/); + + await actions.finalizeDecisions({txIds: [MOCK_GENLAYER_TX_ID]}); + + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.data.slice(0, 10)).toBe(finalizeDecisionsSelector); + const decoded = decodeFunctionData({abi: FINALIZE_TX_ABI as any, data: txRequest.data}); + expect(decoded.args?.[0]).toEqual([{ + txId: MOCK_GENLAYER_TX_ID, + expectedDecisionId: MOCK_DECISION_ID, + }]); + }); +}); + +/** Current Studio entrypoints intentionally remain independent of the v0.6 train. */ +const CONSENSUS_MAIN_STUDIO_ABI = [ + { + type: "function" as const, + name: "submitAppeal", + stateMutability: "payable" as const, + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, + { + type: "function" as const, + name: "finalizeTransaction", + stateMutability: "nonpayable" as const, + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, +] as const; + +const FEE_MANAGEMENT_STUDIO_ABI = [ + { + type: "function" as const, + name: "topUpAndSubmitAppeal", + stateMutability: "payable" as const, + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + +/** + * Mirrors the localnet/studionet chain config: ConsensusMain and ConsensusData + * resolve, but the fee manager, rounds storage, and appeals contracts carry no + * address. Studio stays a consumer of this SDK without joining this train. + */ +const setupStudioLifecycleHarness = () => { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); + const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: "success", logs: []}); + + const client = { + chain: { + id: 61_999, + isStudio: true, + consensusMainContract: { + address: MAIN_CONTRACT_ADDRESS, + abi: CONSENSUS_MAIN_STUDIO_ABI, + bytecode: "0x", + }, + consensusDataContract: { + address: CONSENSUS_DATA_ADDRESS, + abi: [], + bytecode: "0x", + }, + appealsContract: {address: undefined, abi: [], bytecode: "0x"}, + feeManagerContract: {address: undefined, abi: [], bytecode: "0x"}, + roundsStorageContract: {address: undefined, abi: [], bytecode: "0x"}, + }, + account: { + address: SENDER_ADDRESS, + type: "local", + signTransaction, + }, + getCurrentNonce: vi.fn().mockResolvedValue(0n), + estimateTransactionGas: vi.fn().mockResolvedValue(21_000n), + sendRawTransaction, + request: vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + + // Any train lifecycle read here is a regression: current Studio is a consumer + // of this SDK, not a member of the resolution-kernel landing cut. + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + throw new Error(`Unexpected consensus read on Studio: ${functionName}`); + }); + const publicClient = { + waitForTransactionReceipt, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; + + return { + actions: contractActions(client as any, publicClient as any), + signTransaction, + sendRawTransaction, + waitForTransactionReceipt, + readContract, + client, + publicClient, + }; +}; + +describe("contractActions current Studio surface", () => { + it("encodes Studio's native submitAppeal(bytes32) without a train lifecycle read", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).resolves.toBe( + MOCK_GENLAYER_TX_ID, + ); + + expect(readContract).not.toHaveBeenCalled(); + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); + expect(txRequest.value).toBe(500n); + const decoded = decodeFunctionData({abi: CONSENSUS_MAIN_STUDIO_ABI, data: txRequest.data}); + expect(decoded.functionName).toBe("submitAppeal"); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + }); + + it("defaults the Studio appeal value to zero when the caller omits it", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); + + expect(readContract).not.toHaveBeenCalled(); + expect(signTransaction.mock.calls[0][0].value).toBe(0n); + }); + + it("encodes Studio's native topUpAndSubmitAppeal without a decision id", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + const txId = await actions.topUpAndSubmitAppeal({ + txId: MOCK_GENLAYER_TX_ID, + value: 1234n, + distribution: {appealRounds: 1n, rotations: [0n, 1n]}, + }); + + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(readContract).not.toHaveBeenCalled(); + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.value).toBe(1234n); + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); + const [decodedTxId, distribution] = decoded.args as any[]; + expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(distribution.appealRounds).toBe(1n); + expect(distribution.rotations).toEqual([0n, 1n]); + }); + + it("encodes Studio's native finalizeTransaction(bytes32)", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( + MOCK_EVM_TX_HASH, + ); + + expect(readContract).not.toHaveBeenCalled(); + const decoded = decodeFunctionData({ + abi: CONSENSUS_MAIN_STUDIO_ABI, + data: signTransaction.mock.calls[0][0].data, + }); + expect(decoded.functionName).toBe("finalizeTransaction"); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + }); + + it("reports that the current Studio has no authoritative appeal quote", async () => { + const {actions, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing feeManagerContract\/roundsStorageContract/, + ); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing feeManagerContract\/roundsStorageContract/, + ); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("reports the missing appeals contract from canAppeal", async () => { + const {actions, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing appealsContract/, + ); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("rejects the train-only batch actions with an actionable message and never broadcasts", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.resolveTransactions({txIds: [MOCK_GENLAYER_TX_ID]})).rejects.toThrow( + /resolveTransactions is not exposed by Studio's embedded consensus.*finalizeTransaction/s, + ); + await expect(actions.finalizeDecisions({txIds: [MOCK_GENLAYER_TX_ID]})).rejects.toThrow( + /finalizeDecisions is not exposed by Studio's embedded consensus.*finalizeTransaction/s, + ); + expect(readContract).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled(); }); + + it("still threads the decision id through the train path on non-Studio chains", async () => { + const {actions, signTransaction, publicClient} = setupFeeManagementHarness(); + + await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); + + expect(publicClient.readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionLifecycle"}), + ); + const decoded = decodeFunctionData({ + abi: APPEAL_TRAIN_ABI, + data: signTransaction.mock.calls[0][0].data, + }); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + }); }); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 843e3d7..0ed9207 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -126,12 +126,12 @@ describe(`Testnet ${name} - Staking (read-only)`, () => { it("getQuarantinedValidators returns array", async () => { // This calls getValidatorQuarantineList() — the v0.5 renamed function - const quarantined = await (client as any).getQuarantinedValidators(); + const quarantined = await client.getQuarantinedValidators(); expect(Array.isArray(quarantined)).toBe(true); }, TIMEOUT); it("getBannedValidators returns array", async () => { - const banned = await (client as any).getBannedValidators(); + const banned = await client.getBannedValidators(); expect(Array.isArray(banned)).toBe(true); for (const b of banned) { expect(b.validator).toMatch(/^0x[0-9a-fA-F]{40}$/); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index 6465852..0ad8136 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -56,6 +56,12 @@ const readRegistrationContract = vi.fn().mockImplementation(async ({functionName throw new Error(`Unexpected read: ${functionName}`); }); +const makeStakingReadHarness = (readContract: ReturnType) => { + const client = {chain: baseChain}; + const publicClient = {readContract}; + return {actions: stakingActions(client as any, publicClient as any), publicClient}; +}; + // Local-key harness (byte-for-byte regression anchor for the sign+sendRaw lane). const makeLocalHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -252,3 +258,126 @@ describe("stakingActions provider lane (Address-only)", () => { expect(sentTxParams(request).gasPrice).toBeUndefined(); }); }); + +describe("stakingActions validator reads", () => { + it("uses the authoritative ban predicate instead of a stale nonzero ban epoch", async () => { + const readContract = vi.fn().mockImplementation(async ({functionName}: any) => { + if (functionName === "isValidator") return true; + if (functionName === "validatorView") { + return { + eBanned: 5n, + ePrimed: 9n, + vStake: parseEther("2"), + vShares: 2n, + dStake: 0n, + dShares: 0n, + vDeposit: 0n, + vWithdrawal: 0n, + live: true, + }; + } + if (functionName === "owner") return ACCOUNT_ADDRESS; + if (functionName === "operator") return OPERATOR_ADDRESS; + if (functionName === "getIdentity") return null; + if (functionName === "epoch") return 10n; + if (functionName === "validatorMinStake") return parseEther("1"); + if (functionName === "isValidatorBanned") return false; + if (functionName === "validatorDepositLen") return 0n; + if (functionName === "validatorWithdrawalLen") return 0n; + throw new Error(`Unexpected read: ${functionName}`); + }); + const {actions} = makeStakingReadHarness(readContract); + + const info = await actions.getValidatorInfo(VALIDATOR_WALLET_ADDRESS); + + expect(info.banned).toBe(false); + expect(info.bannedEpoch).toBeUndefined(); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: "isValidatorBanned", + args: [VALIDATOR_WALLET_ADDRESS], + })); + }); +}); + +describe("stakingActions validator-set reads", () => { + it("keeps setOperator source-compatible with an actionable train migration error", async () => { + const readContract = vi.fn(); + const {actions} = makeStakingReadHarness(readContract); + + await expect(actions.setOperator({ + validator: VALIDATOR_WALLET_ADDRESS, + operator: ACCOUNT_ADDRESS, + })).rejects.toThrow("initiateOperatorTransfer and completeOperatorTransfer"); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("keeps active-validator APIs bound to the selectable duty set", async () => { + const selectable = [ACCOUNT_ADDRESS, VALIDATOR_WALLET_ADDRESS]; + const readContract = vi.fn().mockImplementation(async ({functionName}: any) => { + if (functionName === "selectableValidators") return selectable; + if (functionName === "selectableValidatorsCount") return 2n; + throw new Error(`Unexpected read: ${functionName}`); + }); + const {actions} = makeStakingReadHarness(readContract); + + await expect(actions.getActiveValidators()).resolves.toEqual(selectable); + await expect(actions.getActiveValidatorsCount()).resolves.toBe(2n); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "validatorsJoinedCount"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getValidatorsJoined"}), + ); + }); + + it("exposes the joined registry separately and reads it in bounded pages", async () => { + const joined = Array.from( + {length: 66}, + (_, index) => `0x${(index + 1).toString(16).padStart(40, "0")}`, + ); + const readContract = vi.fn().mockImplementation(async ({functionName, args}: any) => { + if (functionName === "validatorsJoinedCount") return 66n; + if (functionName === "getValidatorsJoined") { + const [start, size] = args as [bigint, bigint]; + return joined.slice(Number(start), Number(start + size)); + } + throw new Error(`Unexpected read: ${functionName}`); + }); + const {actions} = makeStakingReadHarness(readContract); + + await expect(actions.getJoinedValidatorsCount()).resolves.toBe(66n); + await expect(actions.getJoinedValidators()).resolves.toEqual(joined); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "getValidatorsJoined", args: [0n, 64n]}), + ); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "getValidatorsJoined", args: [64n, 64n]}), + ); + }); + + it("reports the selectable count as active in epoch information", async () => { + const epochData = [10n, 0n, 10n, 100n, 0n, 0n, 3n, 0n, 0n, 0n, 0n]; + const responses: Record = { + epoch: 4n, + finalized: 3n, + selectableValidatorsCount: 2n, + epochMinDuration: 60n, + epochZeroMinDuration: 120n, + epochOdd: epochData, + epochEven: epochData, + validatorMinStake: 1n, + delegatorMinStake: 1n, + }; + const readContract = vi.fn().mockImplementation(async ({functionName}: any) => responses[functionName]); + const {actions} = makeStakingReadHarness(readContract); + + const info = await actions.getEpochInfo(); + + expect(info.activeValidatorsCount).toBe(2n); + expect(info.totalWeight).toBe(100n); + expect(info.inflationRaw).toBe(10n); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "selectableValidatorsCount"}), + ); + }); +}); diff --git a/tests/staking-commit-layout.test.ts b/tests/staking-commit-layout.test.ts index 0c2aede..fc72fa5 100644 --- a/tests/staking-commit-layout.test.ts +++ b/tests/staking-commit-layout.test.ts @@ -1,64 +1,128 @@ -/** - * The Claim/Commit layout probe rests on one asymmetry, so pin it. - * - * CON-715 widened both structs without renaming the functions. Decoding a - * post-CON-715 response with the older shape does NOT fail — it returns - * neighbouring words — so a successful decode cannot identify the layout. - * Only the reverse throws. That is why stakingActions tries the current shape - * first and treats a decode failure, rather than a success, as the signal. - * - * If this asymmetry ever stops holding, the probe silently starts reporting - * wrong balances again, which is exactly the bug it exists to prevent. - */ import {describe, expect, it} from "vitest"; import {decodeFunctionResult, encodeAbiParameters} from "viem"; -import {STAKING_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "../src/abi/staking"; +import {STAKING_ABI} from "../src/abi/staking"; -const STAKE = 100000000000000000n; // 0.1 GEN -const CLAIM_COMMIT_INDEX = 2n; +const STAKE = 100000000000000000n; +const CLAIM_COMPONENTS = [ + {name: "quantity", type: "uint120"}, + {name: "offset", type: "uint120"}, + {name: "commit", type: "uint256"}, +]; +const COMMIT_COMPONENTS = [ + {name: "input", type: "uint256"}, + {name: "output", type: "uint256"}, + {name: "outstanding", type: "uint120"}, + {name: "epoch", type: "uint64"}, + {name: "linkToNextCommit", type: "uint56"}, + {name: "priced", type: "bool"}, + {name: "fragmented", type: "bool"}, +]; +const VALIDATOR_VIEW_COMPONENTS = [ + {name: "eBanned", type: "uint256"}, + {name: "ePrimed", type: "uint256"}, + {name: "vStake", type: "uint256"}, + {name: "vShares", type: "uint256"}, + {name: "dStake", type: "uint256"}, + {name: "dShares", type: "uint256"}, + {name: "vDeposit", type: "uint256"}, + {name: "vWithdrawal", type: "uint256"}, + {name: "live", type: "bool"}, +]; -const outputsOf = (abi: readonly any[], name: string) => - abi.find((e: any) => e.type === "function" && e.name === name)!.outputs; +const view = (name: string) => + (STAKING_ABI as readonly any[]).find( + entry => entry.type === "function" && entry.name === name && entry.stateMutability === "view", + )!; -const legacyOutputs = outputsOf(STAKING_ABI as any, "delegatorDeposit"); -const currentOutputs = outputsOf(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, "delegatorDeposit"); +describe("staking Claim/Commit train layout", () => { + it("does not ship duplicate function signatures", () => { + const signatures = (STAKING_ABI as readonly any[]) + .filter(entry => entry.type === "function") + .map(entry => `${entry.name}(${entry.inputs.map((input: any) => input.type).join(",")})`); + expect(new Set(signatures).size).toBe(signatures.length); + }); + + it("uses the canonical train tuple in every commit read", () => { + for (const name of [ + "delegatorDeposit", + "delegatorWithdrawal", + "delegatorDepositByEpoch", + "delegatorWithdrawalByEpoch", + "validatorDeposit", + "validatorWithdrawal", + "validatorDepositByEpoch", + "validatorWithdrawalByEpoch", + ]) { + const commit = view(name).outputs.at(-1); + expect(commit.components, name).toEqual(COMMIT_COMPONENTS); + } -const legacyResponse = encodeAbiParameters(legacyOutputs, [ - {quantity: 7n, commit: CLAIM_COMMIT_INDEX}, - {input: STAKE, output: 5n, epoch: 3n, linkToNextCommit: 0n}, -] as any); + expect(view("delegatorDeposit").outputs[0].components).toEqual(CLAIM_COMPONENTS); + expect(view("delegatorWithdrawal").outputs[0].components).toEqual(CLAIM_COMPONENTS); + }); -const currentResponse = encodeAbiParameters(currentOutputs, [ - {quantity: 7n, offset: 0n, commit: CLAIM_COMMIT_INDEX}, - { - input: STAKE, - output: 5n, - outstanding: 9n, - epoch: 3n, - linkToNextCommit: 0n, - priced: true, - fragmented: false, - }, -] as any); + it("decodes raw train delegator bytes without a layout probe", () => { + const outputs = view("delegatorDeposit").outputs; + const encoded = encodeAbiParameters(outputs, [ + {quantity: 7n, offset: 2n, commit: 3n}, + { + input: STAKE, + output: 5n, + outstanding: 9n, + epoch: 4n, + linkToNextCommit: 0n, + priced: true, + fragmented: false, + }, + ] as any); -const decodeWith = (abi: readonly any[], data: `0x${string}`) => - decodeFunctionResult({abi: abi as any, functionName: "delegatorDeposit", data}) as any; + const [claim, commit] = decodeFunctionResult({ + abi: STAKING_ABI, + functionName: "delegatorDeposit", + data: encoded, + }) as any; -describe("staking Claim/Commit layout", () => { - it("reads the amount when the shape matches the response", () => { - expect(decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, currentResponse)[1].input).toBe(STAKE); - expect(decodeWith(STAKING_ABI as any, legacyResponse)[1].input).toBe(STAKE); + expect(claim).toMatchObject({quantity: 7n, offset: 2n, commit: 3n}); + expect(commit).toMatchObject({input: STAKE, outstanding: 9n, epoch: 4n, priced: true}); }); +}); - it("throws when the current shape meets a legacy response — this is the probe", () => { - expect(() => decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, legacyResponse)).toThrow(); +describe("staking ValidatorView train layout", () => { + it("uses the canonical nine-field tuple in every validator view", () => { + for (const name of ["validatorView", "validatorViewPrimed", "validatorViewPrePrimed"]) { + expect(view(name).outputs[0].components, name).toEqual(VALIDATOR_VIEW_COMPONENTS); + } }); - it("silently misreads when the legacy shape meets a current response", () => { - // Not a throw: claim.commit lands where commit.input is expected, which is - // how pending deposits came back as small indices instead of amounts. - const misread = decodeWith(STAKING_ABI as any, currentResponse)[1].input; - expect(misread).not.toBe(STAKE); - expect(misread).toBe(CLAIM_COMMIT_INDEX); + it("decodes raw train validator bytes without removed tree links", () => { + const output = view("validatorView").outputs[0]; + const encoded = encodeAbiParameters([output], [{ + eBanned: 1n, + ePrimed: 2n, + vStake: 3n, + vShares: 4n, + dStake: 5n, + dShares: 6n, + vDeposit: 7n, + vWithdrawal: 8n, + live: true, + }] as any); + + const decoded = decodeFunctionResult({ + abi: STAKING_ABI, + functionName: "validatorView", + data: encoded, + }) as any; + + expect(decoded).toMatchObject({ + eBanned: 1n, + ePrimed: 2n, + vStake: 3n, + dStake: 5n, + live: true, + }); + expect(decoded).not.toHaveProperty("left"); + expect(decoded).not.toHaveProperty("right"); + expect(decoded).not.toHaveProperty("parent"); }); }); diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 6c343c0..175ba3c 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -6,14 +6,31 @@ import { DECIDED_STATES, isDecidedState, transactionsStatusNumberToName, + transactionsStatusNameToNumber, + TransactionResolutionAction, + transactionResolutionActionNumberToName, + TransactionProtocolStatus, + transactionProtocolStatusNumberToName, + TransactionResolutionSource, + transactionResolutionSourceNumberToName, transactionResultNumberToName, executionResultNumberToName, + VoteType, + voteTypeNumberToName, + transactionLifecycleFromStoredStatus, } from "../src/types/transactions"; import { receiptActions, transactionActions, isSuccessful } from "../src/transactions/actions"; import { decodeTransaction, simplifyTransactionReceipt } from "../src/transactions/decoders"; import { localnet } from "../src/chains/localnet"; import type { GenLayerRawTransaction } from "../src/types/transactions"; -import {keccak256, stringToBytes} from "viem"; +import { + decodeFunctionResult, + encodeFunctionResult, + keccak256, + MethodNotFoundRpcError, + stringToBytes, +} from "viem"; +import {CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, CONSENSUS_DATA_TRAIN_ABI} from "../src/abi/consensusTrain"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -35,7 +52,7 @@ describe("DECIDED_STATES constant", () => { describe("isDecidedState utility function", () => { it("should return true for all decided states", () => { - const decidedStatusNumbers = ["5", "6", "13", "12", "8", "7"]; // ACCEPTED, UNDETERMINED, LEADER_TIMEOUT, VALIDATORS_TIMEOUT, CANCELED, FINALIZED + const decidedStatusNumbers = ["5", "6", "12", "11", "8", "7"]; // ACCEPTED, UNDETERMINED, LEADER_TIMEOUT, VALIDATORS_TIMEOUT, CANCELED, FINALIZED decidedStatusNumbers.forEach(statusNum => { expect(isDecidedState(statusNum)).toBe(true); @@ -43,7 +60,7 @@ describe("isDecidedState utility function", () => { }); it("should return false for non-decided states", () => { - const nonDecidedStatusNumbers = ["0", "1", "2", "3", "4", "9", "10", "11", "14"]; // transient states + const nonDecidedStatusNumbers = ["0", "1", "2", "3", "4", "9", "10", "13"]; // transient states nonDecidedStatusNumbers.forEach(statusNum => { expect(isDecidedState(statusNum)).toBe(false); @@ -61,10 +78,69 @@ describe("isDecidedState utility function", () => { describe("transaction enum maps", () => { it("maps v0.6 transaction status, vote type, and result type values", () => { - expect(transactionsStatusNumberToName["14"]).toBe(TransactionStatus.LEADER_REVEALING); - expect(isDecidedState("14")).toBe(false); + // The train removed ReadyToFinalize at ordinal 11 and shifted the three + // above it down, so LeaderRevealing is 13 now and nothing occupies 14. + expect(transactionsStatusNumberToName["13"]).toBe(TransactionStatus.LEADER_REVEALING); + expect(transactionsStatusNumberToName["11"]).toBe(TransactionStatus.VALIDATORS_TIMEOUT); + expect(transactionsStatusNumberToName["12"]).toBe(TransactionStatus.LEADER_TIMEOUT); + expect("READY_TO_FINALIZE" in TransactionStatus).toBe(false); + expect(Object.keys(transactionsStatusNameToNumber)).not.toContain("READY_TO_FINALIZE"); + expect(transactionResolutionActionNumberToName).toEqual({ + "0": TransactionResolutionAction.NO_OP, + "1": TransactionResolutionAction.CANCEL, + "2": TransactionResolutionAction.REPLACE_ACTOR, + "3": TransactionResolutionAction.ROTATE_LEADER, + "4": TransactionResolutionAction.RESOLVE_APPEAL, + "5": TransactionResolutionAction.MATERIALIZE_DECISION, + "6": TransactionResolutionAction.FINALIZE, + }); + expect(transactionProtocolStatusNumberToName["5"]).toBe(TransactionProtocolStatus.ACCEPTED); + expect(transactionProtocolStatusNumberToName["13"]).toBe(TransactionProtocolStatus.LEADER_REVEALING); + expect(transactionResolutionSourceNumberToName).toEqual({ + "0": TransactionResolutionSource.UNSPECIFIED, + "1": TransactionResolutionSource.ACTIVATION_INSUFFICIENT_VALIDATORS, + "2": TransactionResolutionSource.PROPOSAL_HANGING, + "3": TransactionResolutionSource.LEADER_RECEIPT_TIMEOUT, + "4": TransactionResolutionSource.COMMIT_HANGING, + "5": TransactionResolutionSource.LEADER_REVEAL_HANGING, + "6": TransactionResolutionSource.FULL_REVEAL, + "7": TransactionResolutionSource.REVEAL_DEADLINE, + "8": TransactionResolutionSource.APPEAL_COMMIT_HANGING, + "9": TransactionResolutionSource.APPEAL_FULL_REVEAL, + "10": TransactionResolutionSource.APPEAL_REVEAL_DEADLINE, + "11": TransactionResolutionSource.SELECTION_DEPLETED, + }); + expect(CONSENSUS_DATA_TRAIN_ABI.some(item => item.type === "function" && item.name === "canFinalize")).toBe(false); + expect(isDecidedState("13")).toBe(false); expect(executionResultNumberToName["3"]).toBe(ExecutionResult.TIMEOUT); expect(executionResultNumberToName["4"]).toBe(ExecutionResult.NONDET_DISAGREE); + expect(executionResultNumberToName).toEqual({ + "0": ExecutionResult.NOT_VOTED, + "1": ExecutionResult.FINISHED_WITH_RETURN, + "2": ExecutionResult.FINISHED_WITH_ERROR, + "3": ExecutionResult.TIMEOUT, + "4": ExecutionResult.NONDET_DISAGREE, + "5": ExecutionResult.DETERMINISTIC_VIOLATION, + }); + expect(voteTypeNumberToName).toEqual({ + "0": VoteType.NOT_VOTED, + "1": VoteType.FINISHED_WITH_RETURN, + "2": VoteType.FINISHED_WITH_ERROR, + "3": VoteType.TIMEOUT, + "4": VoteType.NONDET_DISAGREE, + "5": VoteType.DETERMINISTIC_VIOLATION, + }); + expect(TransactionResult).toEqual({ + IDLE: "IDLE", + AGREE: "AGREE", + DISAGREE: "DISAGREE", + TIMEOUT: "TIMEOUT", + DETERMINISTIC_VIOLATION: "DETERMINISTIC_VIOLATION", + NO_MAJORITY: "NO_MAJORITY", + MAJORITY_AGREE: "MAJORITY_AGREE", + MAJORITY_DISAGREE: "MAJORITY_DISAGREE", + MAJORITY_TIMEOUT: "MAJORITY_TIMEOUT", + }); expect(transactionResultNumberToName).toEqual({ "0": TransactionResult.IDLE, "1": TransactionResult.MAJORITY_AGREE, @@ -74,6 +150,71 @@ describe("transaction enum maps", () => { "5": TransactionResult.NO_MAJORITY, }); }); + + it("decodes the train lifecycle ABI from raw return bytes", () => { + const bytes32 = `0x${"00".repeat(32)}` as const; + const resolution = [ + bytes32, 5, 6, 6, 1, bytes32, 0, 0n, 0n, bytes32, bytes32, 0n, 0n, + 0n, 0, bytes32, 100n, 101n, 102n, 10n, 110n, false, true, false, + ] as const; + const latestDecision = [ + true, 1n, 0n, 0, 0, bytes32, 5, 6, 0n, 0n, bytes32, bytes32, 0n, + 0n, 1, bytes32, 100n, 101n, 110n, + ] as const; + const encoded = encodeFunctionResult({ + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + result: [5, resolution, latestDecision, true] as any, + }); + + const decoded = decodeFunctionResult({ + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + data: encoded, + }) as any; + + expect(decoded.storedStatus).toBe(5); + expect(decoded.resolution.projectedStatus).toBe(6); + expect(decoded.resolution.action).toBe(6); + expect(decoded.decisionActive).toBe(true); + }); +}); + +describe("stored transaction lifecycle mapping", () => { + it.each([ + [0, {state: "processing", phase: "uninitialized"}], + [1, {state: "processing", phase: "pending"}], + [2, {state: "processing", phase: "proposing"}], + [3, {state: "processing", phase: "committing"}], + [4, {state: "processing", phase: "revealing"}], + [5, {state: "decided", outcome: "accepted"}], + [6, {state: "decided", outcome: "undetermined"}], + [8, {state: "canceled"}], + [9, {state: "processing", phase: "appeal-revealing"}], + [10, {state: "processing", phase: "appeal-committing"}], + [11, {state: "decided", outcome: "validators-timeout"}], + [12, {state: "decided", outcome: "leader-timeout"}], + [13, {state: "processing", phase: "leader-revealing"}], + ] as const)("maps stored status %i without projection", (status, expected) => { + expect(transactionLifecycleFromStoredStatus(status)).toEqual(expected); + }); + + it.each([ + [1, {state: "finalized", outcome: "accepted"}], + [2, {state: "finalized", outcome: "undetermined"}], + [3, {state: "finalized", outcome: "validators-timeout"}], + [4, {state: "finalized", outcome: "undetermined"}], + [5, {state: "finalized", outcome: "undetermined"}], + [0, {state: "finalized"}], + ] as const)("maps finalized result %i only when its outcome is unambiguous", (result, expected) => { + expect(transactionLifecycleFromStoredStatus(7, result)).toEqual(expected); + }); + + it("fails loudly for an unknown protocol status", () => { + expect(() => transactionLifecycleFromStoredStatus(14)).toThrow( + "Unknown stored transaction status: 14", + ); + }); }); describe("isSuccessful", () => { @@ -152,7 +293,7 @@ describe("waitForTransactionReceipt with DECIDED_STATES", () => { }); it("should accept all decided states when waiting for ACCEPTED", async () => { - const decidedStatusNumbers = ["5", "6", "13", "12", "8", "7"]; // All decided states + const decidedStatusNumbers = ["5", "6", "12", "11", "8", "7"]; // All decided states for (const statusNum of decidedStatusNumbers) { const mockTransaction = { @@ -238,6 +379,51 @@ describe("waitForTransactionReceipt with DECIDED_STATES", () => { expect(result).toEqual(mockTransaction); }); + + it("waitForFinalization ignores a projected final status", async () => { + const hash = "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6" as any; + const mockClient = { + chain: localnet, + getTransaction: vi.fn().mockResolvedValue({ + hash, + status: 5, + statusName: TransactionStatus.ACCEPTED, + lifecycle: {state: "decided", outcome: "accepted"}, + projectedStatus: 7, + }), + }; + + await expect(receiptActions(mockClient as any, {} as any).waitForFinalization({ + hash, + retries: 0, + })).rejects.toThrow('current status: 5'); + }); + + it("provides focused decision and finalization wait helpers", async () => { + const hash = "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6" as any; + const decided = { + hash, + status: 6, + statusName: TransactionStatus.UNDETERMINED, + lifecycle: {state: "decided", outcome: "undetermined"}, + }; + const finalized = { + hash, + status: 7, + statusName: TransactionStatus.FINALIZED, + lifecycle: {state: "finalized", outcome: "undetermined"}, + }; + const mockClient = { + chain: localnet, + getTransaction: vi.fn() + .mockResolvedValueOnce(decided) + .mockResolvedValueOnce(finalized), + }; + const actions = receiptActions(mockClient as any, {} as any); + + await expect(actions.waitForDecision({hash, fullTransaction: true})).resolves.toEqual(decided); + await expect(actions.waitForFinalization({hash, fullTransaction: true})).resolves.toEqual(finalized); + }); }); describe("cancelTransaction", () => { @@ -318,21 +504,24 @@ describe("cancelTransaction", () => { }); }); -// ─── decodeTransaction field normalization ────────────────────────────────── +// ─── decodeTransaction train layout ───────────────────────────────────────── const makeRawTx = (overrides: Record = {}): GenLayerRawTransaction => ({ - currentTimestamp: 1000n, + observedAt: 1000n, sender: "0x0000000000000000000000000000000000000001" as any, recipient: "0x0000000000000000000000000000000000000002" as any, - numOfInitialValidators: 3n, + initialRotations: 3n, + numOfInitialValidators: 5n, txSlot: 5n, createdTimestamp: 900n, lastVoteTimestamp: 950n, randomSeed: "0x" + "ab".repeat(32) as any, result: 1, - txData: "0x" as any, - txReceipt: "0x" + "00".repeat(32) as any, + txCalldata: "0x" as any, + txExecutionHash: "0x" + "00".repeat(32) as any, + eqBlocksOutputs: "0x", messages: [], + consumedValidators: [], queueType: 0, queuePosition: 0n, activator: "0x0000000000000000000000000000000000000003" as any, @@ -355,11 +544,141 @@ const makeRawTx = (overrides: Record = {}): GenLayerRawTransact result: 1, roundValidators: [], validatorVotesHash: [], + validatorResultHash: [], validatorVotes: [1, 1, 1], }, ...overrides, }); +const makeLightTx = (overrides: Record = {}) => { + const raw = makeRawTx(); + return { + observedAt: raw.observedAt, + sender: raw.sender, + recipient: raw.recipient, + initialRotations: raw.initialRotations, + txSlot: raw.txSlot, + createdTimestamp: raw.createdTimestamp, + lastVoteTimestamp: raw.lastVoteTimestamp, + randomSeed: raw.randomSeed, + result: raw.result, + txExecutionHash: raw.txExecutionHash, + txCalldata: raw.txCalldata, + eqBlocksOutputs: raw.eqBlocksOutputs, + messages: raw.messages, + queueType: raw.queueType, + queuePosition: raw.queuePosition, + activator: raw.activator, + lastLeader: raw.lastLeader, + status: raw.status, + txId: raw.txId, + readStateBlockRange: raw.readStateBlockRange, + numOfRounds: raw.numOfRounds, + lastRound: { + round: raw.lastRound.round, + leaderIndex: raw.lastRound.leaderIndex, + votesCommitted: raw.lastRound.votesCommitted, + votesRevealed: raw.lastRound.votesRevealed, + appealBond: raw.lastRound.appealBond, + rotationsLeft: raw.lastRound.rotationsLeft, + result: raw.lastRound.result, + validatorsCount: 0n, + }, + consumedValidatorsCount: 0n, + ...overrides, + }; +}; + +describe("train light transaction ABI", () => { + it("decodes the exact nested tuple from raw return bytes", () => { + const light = makeLightTx({ + messages: [{ + messageType: 1, + recipient: "0x0000000000000000000000000000000000000005", + value: 7n, + data: "0x1234", + onAcceptance: true, + saltNonce: 8n, + feeParams: "0xabcd", + declaredBudget: 9n, + allocationSubtree: "0x5678", + callKey: `0x${"11".repeat(32)}`, + useBalance: false, + }], + }); + const encoded = encodeFunctionResult({ + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getStoredTransactionDataLight", + result: light as any, + }); + const decoded = decodeFunctionResult({ + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getStoredTransactionDataLight", + data: encoded, + }) as any; + + expect(decoded.txId).toBe(light.txId); + expect(decoded.txExecutionHash).toBe(light.txExecutionHash); + expect(decoded.lastRound.validatorsCount).toBe(0n); + expect(decoded.messages[0]).toMatchObject({ + messageType: 1, + recipient: "0x0000000000000000000000000000000000000005", + declaredBudget: 9n, + useBalance: false, + }); + }); +}); + +const ADDRESS_MANAGER = "0x0000000000000000000000000000000000000011"; +const BIG_ROUNDS = "0x0000000000000000000000000000000000000012"; +const ROUNDS_STORAGE = "0x0000000000000000000000000000000000000013"; +const TRANSACTION_MANAGER = "0x0000000000000000000000000000000000000014"; + +const trainLifecycle = (overrides: Record = {}) => ({ + storedStatus: 5, + resolution: {projectedStatus: 5, action: 0, source: 0, evaluatedAt: 456n}, + latestDecision: {}, + decisionActive: false, + ...overrides, +}); + +const trainReadContract = ({ + light = makeLightTx(), + lifecycle = trainLifecycle(), + roundValidators = [] as string[], + consumedValidators = [] as string[], +}: { + light?: ReturnType; + lifecycle?: ReturnType; + roundValidators?: string[]; + consumedValidators?: string[]; +} = {}) => vi.fn().mockImplementation(async ({functionName, args}: any) => { + if (functionName === "addressManager") return ADDRESS_MANAGER; + if (functionName === "getAddress") { + if (args[0] === "ConsensusDataBigRounds") return BIG_ROUNDS; + if (args[0] === "RoundsStorage") return ROUNDS_STORAGE; + if (args[0] === "TransactionManager") return TRANSACTION_MANAGER; + } + if (functionName === "getStoredTransactionDataLight") return light; + if (functionName === "getTransactionLifecycle") return lifecycle; + if (functionName === "getRoundValidatorsPaged") { + const offset = Number(args[2]); + const size = Number(args[3]); + return [roundValidators.slice(offset, offset + size), BigInt(roundValidators.length)]; + } + if (functionName === "getConsumedValidatorsPaged") { + const offset = Number(args[1]); + const size = Number(args[2]); + return [consumedValidators.slice(offset, offset + size), BigInt(consumedValidators.length)]; + } + if (functionName === "getValidatorVotes") return roundValidators.map(() => 1); + if (functionName === "getValidatorVotesHash") return roundValidators.map(() => `0x${"01".repeat(32)}`); + if (functionName === "getValidatorResultHash") return roundValidators.map(() => `0x${"02".repeat(32)}`); + if (functionName === "getTxExecutionResult") return 1; + if (functionName === "getNumOfInitialValidators") return 5n; + throw new Error(`Unexpected read: ${functionName}`); +}); + describe("getTriggeredTransactionIds", () => { it("finds child transaction IDs in the parent decision receipt", async () => { const parentHash = ("0x" + "11".repeat(32)) as any; @@ -369,10 +688,11 @@ describe("getTriggeredTransactionIds", () => { const internalMessageTopic = keccak256( stringToBytes("InternalMessageProcessed(bytes32,address,address)"), ); - const readContract = vi - .fn() - .mockResolvedValueOnce(makeRawTx({readStateBlockRange: {proposalBlock: 100n}})) - .mockResolvedValueOnce([{txExecutionResult: 1n}, []]); + const readContract = trainReadContract({ + light: makeLightTx({ + readStateBlockRange: {activationBlock: 0n, processingBlock: 0n, proposalBlock: 100n}, + }), + }); const getLogs = vi.fn().mockResolvedValue([{transactionHash: decisionHash}]); const getTransactionReceipt = vi.fn().mockResolvedValue({ logs: [ @@ -384,6 +704,7 @@ describe("getTriggeredTransactionIds", () => { }); const publicClient = { readContract, + getBlock: vi.fn().mockResolvedValue({number: 150n, timestamp: 1000n}), getBlockNumber: vi.fn().mockResolvedValue(200n), getLogs, getTransactionReceipt, @@ -407,44 +728,310 @@ describe("getTriggeredTransactionIds", () => { }); }); -describe("decodeTransaction", () => { - it("should decode standard field names (localnet/asimov)", () => { - const tx = makeRawTx(); - const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("3"); - expect(decoded.txSlot).toBe("5"); - expect(decoded.statusName).toBe("ACCEPTED"); - expect(decoded.resultName).toBe("MAJORITY_AGREE"); +describe("getTransaction train lifecycle", () => { + it("returns only the stored state in the primary transaction model", async () => { + const validators = Array.from( + {length: 65}, + (_, index) => `0x${(index + 1).toString(16).padStart(40, "0")}`, + ); + const consumed = validators.slice(0, 2); + const light = makeLightTx({ + status: 5, + lastRound: {...makeLightTx().lastRound, validatorsCount: 65n}, + consumedValidatorsCount: 2n, + }); + const readContract = trainReadContract({ + light, + roundValidators: validators, + consumedValidators: consumed, + }); + const publicClient = { + readContract, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + } as any; + const client = { + chain: { + isStudio: false, + consensusDataContract: { + address: "0x0000000000000000000000000000000000000010", + abi: [], + }, + }, + } as any; + + const transaction = await transactionActions(client, publicClient).getTransaction({ + hash: light.txId as any, + }); + + expect(transaction.status).toBe(5); + expect(transaction.statusName).toBe(TransactionStatus.ACCEPTED); + expect(transaction.lifecycle).toEqual({state: "decided", outcome: "accepted"}); + expect(transaction).not.toHaveProperty("storedStatus"); + expect(transaction).not.toHaveProperty("resolutionAction"); + expect(transaction).not.toHaveProperty("canFinalize"); + expect(transaction.lastRound?.roundValidators).toEqual(validators); + expect(transaction.lastRound?.validatorResultHash).toHaveLength(validators.length); + expect(transaction.consumedValidators).toEqual(consumed); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionLifecycle"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "canFinalize"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionAllData"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getStoredTransactionData"}), + ); }); - it("should handle Bradbury field: initialRotations instead of numOfInitialValidators", () => { - const tx = makeRawTx({ numOfInitialValidators: undefined }); - (tx as any).initialRotations = 5n; - const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("5"); + it("normalizes the contract-backed advanced lifecycle from one fixed-block read", async () => { + const light = makeLightTx(); + const readContract = trainReadContract({ + light, + lifecycle: trainLifecycle({ + storedStatus: 5, + resolution: {projectedStatus: 6, action: 6, source: 11, evaluatedAt: 500n}, + latestDecision: {decisionId: 7n}, + decisionActive: true, + }), + }); + const publicClient = { + readContract, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + } as any; + const client = { + chain: { + isStudio: false, + consensusDataContract: { + address: "0x0000000000000000000000000000000000000010", + abi: [], + }, + }, + } as any; + + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ + hash: light.txId as any, + timestamp: 500, + }); + + expect(lifecycle).toEqual({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Undetermined", + projectedStatusCode: 6, + resolutionAction: "Finalize", + resolutionActionCode: 6, + resolutionSource: "SelectionDepleted", + resolutionSourceCode: 11, + decisionId: "7", + decisionActive: true, + evaluatedAt: 500, + }); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "getTransactionLifecycle", + args: [light.txId, 500n], + blockNumber: 123n, + }), + ); + expect(readContract).toHaveBeenCalledTimes(1); + expect(lifecycle).not.toHaveProperty("finalization"); + expect(lifecycle).not.toHaveProperty("canFinalize"); }); - it("should handle Bradbury field: txCalldata instead of txData", () => { - const tx = makeRawTx({ txData: undefined }); - (tx as any).txCalldata = "0xdeadbeef"; - const decoded = decodeTransaction(tx); - expect(decoded.txData).toBe("0xdeadbeef"); + it("uses the advanced node RPC on Studio and returns the same normalized schema", async () => { + const hash = makeLightTx().txId as any; + const request = vi.fn().mockResolvedValue({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Undetermined", + projectedStatusCode: 6, + resolutionAction: "Finalize", + resolutionActionCode: 6, + resolutionSource: "SelectionDepleted", + resolutionSourceCode: 11, + decisionId: "0007", + decisionActive: true, + evaluatedAt: 500, + }); + const publicClient = { + getBlock: vi.fn(), + readContract: vi.fn(), + } as any; + const client = {chain: {isStudio: true}, request} as any; + + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ + hash, + timestamp: 500, + }); + + expect(lifecycle).toEqual({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Undetermined", + projectedStatusCode: 6, + resolutionAction: "Finalize", + resolutionActionCode: 6, + resolutionSource: "SelectionDepleted", + resolutionSourceCode: 11, + decisionId: "7", + decisionActive: true, + evaluatedAt: 500, + }); + expect(request).toHaveBeenCalledWith({ + method: "gen_getTransactionLifecycle", + params: [{txId: hash, timestamp: 500}], + }); + expect(publicClient.getBlock).not.toHaveBeenCalled(); + expect(publicClient.readContract).not.toHaveBeenCalled(); + expect(lifecycle).not.toHaveProperty("finalization"); + expect(lifecycle).not.toHaveProperty("canFinalize"); }); - it("should handle both Bradbury fields missing (defaults gracefully)", () => { - const tx = makeRawTx({ numOfInitialValidators: undefined, txData: undefined }); - const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("0"); - expect(decoded.txData).toBeUndefined(); + it("synthesizes the lifecycle from the Studio transaction when the RPC is absent", async () => { + const hash = makeLightTx().txId as any; + const request = vi.fn().mockRejectedValue( + new MethodNotFoundRpcError(new Error("Method not found"), { + method: "gen_getTransactionLifecycle", + }), + ); + const getTransaction = vi.fn().mockResolvedValue({status: TransactionStatus.ACCEPTED}); + const publicClient = {getBlock: vi.fn(), readContract: vi.fn()} as any; + const client = {chain: {isStudio: true}, request, getTransaction} as any; + + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ + hash, + timestamp: 500, + }); + + // Studio's consumer surface proves the stored status and nothing else: no + // projection, no resolution action, and no decision identity to report. + expect(lifecycle).toEqual({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Accepted", + projectedStatusCode: 5, + resolutionAction: "NoOp", + resolutionActionCode: 0, + resolutionSource: "Unspecified", + resolutionSourceCode: 0, + decisionId: null, + decisionActive: false, + evaluatedAt: 500, + }); + expect(getTransaction).toHaveBeenCalledWith({hash}); + expect(publicClient.readContract).not.toHaveBeenCalled(); }); - it("should prefer standard fields over Bradbury aliases when both present", () => { - const tx = makeRawTx({ numOfInitialValidators: 3n, txData: "0xaa" as any }); - (tx as any).initialRotations = 99n; - (tx as any).txCalldata = "0xbb"; + it("maps Studio's ACTIVATED state onto the protocol Pending status", async () => { + const hash = makeLightTx().txId as any; + const before = Math.floor(Date.now() / 1000); + const request = vi.fn().mockRejectedValue({code: -32601, message: "Method not found"}); + const client = { + chain: {isStudio: true}, + request, + getTransaction: vi.fn().mockResolvedValue({status: "ACTIVATED"}), + } as any; + + const lifecycle = await transactionActions(client, {} as any).advanced.getTransactionLifecycle({ + hash, + }); + + expect(lifecycle.storedStatus).toBe("Pending"); + expect(lifecycle.projectedStatus).toBe("Pending"); + expect(lifecycle.decisionActive).toBe(false); + expect(lifecycle.evaluatedAt).toBeGreaterThanOrEqual(before); + }); + + it("propagates a Studio lifecycle failure that is not a missing method", async () => { + const hash = makeLightTx().txId as any; + const getTransaction = vi.fn(); + const client = { + chain: {isStudio: true}, + request: vi.fn().mockRejectedValue({code: -32000, message: "execution reverted"}), + getTransaction, + } as any; + + await expect( + transactionActions(client, {} as any).advanced.getTransactionLifecycle({hash}), + ).rejects.toEqual({code: -32000, message: "execution reverted"}); + expect(getTransaction).not.toHaveBeenCalled(); + }); + + it("re-raises the original RPC error when the Studio status is unreadable", async () => { + const hash = makeLightTx().txId as any; + const cause = {code: -32601, message: "Method not found"}; + const client = { + chain: {isStudio: true}, + request: vi.fn().mockRejectedValue(cause), + getTransaction: vi.fn().mockResolvedValue({status: "NOT_A_STATUS"}), + } as any; + + await expect( + transactionActions(client, {} as any).advanced.getTransactionLifecycle({hash}), + ).rejects.toEqual(cause); + }); + + it("uses block time by default and nulls an inactive decision identity", async () => { + const light = makeLightTx(); + const readContract = trainReadContract({ + lifecycle: trainLifecycle({ + storedStatus: 1, + resolution: {projectedStatus: 1, action: 0, source: 0, evaluatedAt: 456n}, + latestDecision: {decisionId: 999n}, + decisionActive: false, + }), + }); + const publicClient = { + readContract, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + } as any; + const client = { + chain: { + isStudio: false, + consensusDataContract: {address: "0x0000000000000000000000000000000000000010", abi: []}, + }, + } as any; + + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ + hash: light.txId as any, + }); + + expect(lifecycle).toEqual({ + storedStatus: "Pending", + storedStatusCode: 1, + projectedStatus: "Pending", + projectedStatusCode: 1, + resolutionAction: "NoOp", + resolutionActionCode: 0, + resolutionSource: "Unspecified", + resolutionSourceCode: 0, + decisionId: null, + decisionActive: false, + evaluatedAt: 456, + }); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({args: [light.txId, 456n], blockNumber: 123n}), + ); + }); +}); + +describe("decodeTransaction", () => { + it("decodes the canonical train field layout into stable public names", () => { + const tx = makeRawTx(); const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("3"); - expect(decoded.txData).toBe("0xaa"); + expect(decoded.numOfInitialValidators).toBe("5"); + expect(decoded.initialRotations).toBe(3n); + expect(decoded.currentTimestamp).toBe("1000"); + expect(decoded.txData).toBe("0x"); + expect(decoded.txExecutionHash).toBe(tx.txExecutionHash); + expect(decoded.txReceipt).toBeUndefined(); + expect(decoded.txSlot).toBe("5"); + expect(decoded.statusName).toBe("ACCEPTED"); + expect(decoded.lifecycle).toEqual({state: "decided", outcome: "accepted"}); + expect(decoded.resultName).toBe("MAJORITY_AGREE"); }); it("should decode readStateBlockRange fields to strings", () => { @@ -463,8 +1050,12 @@ describe("decodeTransaction", () => { it("should map validator votes to vote type names", () => { const decoded = decodeTransaction(makeRawTx()); - const names = (decoded.lastRound as any)?.validatorVotesName; - expect(names).toEqual(["AGREE", "AGREE", "AGREE"]); + const names = decoded.lastRound?.validatorVotesName; + expect(names).toEqual([ + "FINISHED_WITH_RETURN", + "FINISHED_WITH_RETURN", + "FINISHED_WITH_RETURN", + ]); }); }); From b79622d3ce79ffd6e579c1d1ec1f68fe13cc8ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 3 Sep 2026 00:24:31 +0100 Subject: [PATCH 20/25] fix(fees): add exact quote and decision parity (#214) * fix(fees): mirror consensus deposit quote * fix(studio): bind decision lifecycle actions Use Studio's lifecycle and appeal-charge RPCs to bind appeal, combined top-up/appeal, and finalization to the active decision. Keep Studio ABI exports aligned with the v0.6 consensus surface and cover quoting and guarded selectors. * fix(fees): encode internal message price caps * fix(studio): surface mined envelope reverts * fix(fees): encode schedule-free topups * fix(transactions): discover message payment events * test(appeals): lock configured contract routing Verify that canAppeal reads the active decision from the separately configured Appeals contract at the lifecycle snapshot. This catches local-chain interface drift before cross-stack E2E. * fix(appeals): admit unfunded rounds safely Route the public appeal action through topUpAndSubmitAppeal, whose consensus-derived schedule extension is valid for both pre-funded and unfunded rounds. Keep the direct submitAppeal selector out of the high-level path so a full bond-plus-funding quote cannot still fail AppealRoundNotPermitted.\n\nCover quoted and caller-provided values on deployed Consensus and Studio, and refresh generated API docs. --- docs/api-references/contracts.md | 20 +- src/abi/studioConsensus.ts | 16 +- src/contracts/actions.ts | 393 +++++++++++++++---------------- src/transactions/actions.ts | 12 +- src/transactions/fees.ts | 44 ++++ src/types/transactions.ts | 5 + tests/chains-actions.test.ts | 8 +- tests/contracts-actions.test.ts | 290 ++++++++++++++++++----- tests/transactions.test.ts | 3 +- 9 files changed, 499 insertions(+), 292 deletions(-) diff --git a/docs/api-references/contracts.md b/docs/api-references/contracts.md index ecb3e31..c588ea7 100644 --- a/docs/api-references/contracts.md +++ b/docs/api-references/contracts.md @@ -133,6 +133,7 @@ _No parameters._ ### estimateFeesDistribution Builds a fee distribution with caps derived from the active fee policy. +Omitted rotations fund the chain's configured consensus maximum. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -188,9 +189,7 @@ should pass with the real transaction. ### getAppealCharge -Returns the full authoritative appeal charge (bond plus appeal funding) -on resolution-kernel contract networks. Current Studio has no -decision-bound quote surface. +Returns the full authoritative appeal charge (bond plus appeal funding). | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -324,9 +323,10 @@ Claims a bounded number of reward epochs for a developer NFT. Returns the EVM tr ### appealTransaction Appeals a consensus transaction to trigger a new round of validation. -Contract networks bind the call to the active decision and quote an -omitted value. Current Studio uses its native decision-free entrypoint; -its value defaults to zero when omitted. +The call is bound to the active decision on both Studio and contract +networks. The schedule-extending entry point is safe for both pre-funded +and unfunded appeals, while submitAppeal rejects an unfunded next round. +When value is omitted, the authoritative appeal charge is used. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -339,8 +339,7 @@ its value defaults to zero when omitted. ### topUpFees 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. +Returns the signed EVM envelope hash on every backend. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -357,9 +356,8 @@ backends, or the target GenLayer tx id on Studio/localnet. Deposits appeal fee budget and submits an appeal in the same consensus call. Returns the existing GenLayer transaction id, matching appealTransaction. -Contract networks bind the call to the active decision and quote an -omitted value. Current Studio uses its native decision-free entrypoint; -its value defaults to zero when omitted. +The call is bound to the active decision on both Studio and contract +networks. When value is omitted, the authoritative appeal charge is used. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/src/abi/studioConsensus.ts b/src/abi/studioConsensus.ts index d428dd4..4e34a92 100644 --- a/src/abi/studioConsensus.ts +++ b/src/abi/studioConsensus.ts @@ -37,9 +37,8 @@ const addTransactionParamsComponents = [ ] as const; /** - * Current Studio transaction surface. Fee-aware admission and top-up are - * already live, while appeal and finalization remain decision-free. Studio's - * decision-bound parity work is qualified and released independently. + * Studio transaction surface for the v0.6 consensus train. Appeals and + * finalization bind to the active decision identity just like contract nodes. */ export const studioConsensusMainAbi = [ { @@ -72,6 +71,7 @@ export const studioConsensusMainAbi = [ stateMutability: "payable", inputs: [ {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, ], outputs: [], @@ -80,14 +80,20 @@ export const studioConsensusMainAbi = [ type: "function", name: "submitAppeal", stateMutability: "payable", - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, { type: "function", name: "finalizeTransaction", stateMutability: "nonpayable", - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, ] as const satisfies Abi; diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index d2de6c3..23af27e 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -10,6 +10,7 @@ import { GenLayerClient, CalldataEncodable, Address, + TransactionHash, TransactionHashVariant, TransactionFeeOptions, TransactionFeeEstimate, @@ -34,6 +35,7 @@ import {toJsonSafeDeep, b64ToArray, arrayToB64} from "@/utils/jsonifier"; import { CALL_KEY_WILDCARD, createFeesDistribution, + createTopUpFeesDistribution, MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, normalizeMessageFeeAllocations, normalizeTransactionFees, @@ -624,23 +626,13 @@ export const contractActions = (client: GenLayerClient, publicCli observed, }; }, - /** - * Returns the full authoritative appeal charge (bond plus appeal funding) - * on resolution-kernel contract networks. Current Studio has no - * decision-bound quote surface. - */ + /** Returns the full authoritative appeal charge (bond plus appeal funding). */ getAppealCharge: async (args: {txId: `0x${string}`}): Promise => { - if (client.chain.isStudio) { - throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); - } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, /** @deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { - if (client.chain.isStudio) { - throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); - } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, @@ -698,6 +690,20 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** Checks if a transaction can be appealed. */ canAppeal: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + const context = await _readLifecycleIdentity({client, publicClient, txId: args.txId}); + if (!context.decisionActive) return false; + try { + const quote = await client.request({ + method: "gen_estimateLatestAppealCharge", + params: [{txId: args.txId as TransactionHash}], + }) as {decisionId?: unknown}; + return BigInt(String(quote.decisionId)) === context.decisionId; + } catch (error) { + if (/CanNotAppeal/i.test(String(error))) return false; + throw error; + } + } if (!client.chain.appealsContract?.address) { throw new Error("canAppeal not supported on this chain (missing appealsContract)"); } @@ -831,9 +837,10 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** * Appeals a consensus transaction to trigger a new round of validation. - * Contract networks bind the call to the active decision and quote an - * omitted value. Current Studio uses its native decision-free entrypoint; - * its value defaults to zero when omitted. + * The call is bound to the active decision on both Studio and contract + * networks. The schedule-extending entry point is safe for both pre-funded + * and unfunded appeals, while submitAppeal rejects an unfunded next round. + * When value is omitted, the authoritative appeal charge is used. */ appealTransaction: async (args: { account?: Account; @@ -846,18 +853,6 @@ export const contractActions = (client: GenLayerClient, publicCli // Appeals don't go through _sendTransaction because submitAppeal emits // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. // The appeal operates on the same GenLayer txId, so we return it directly. - if (client.chain.isStudio) { - await _sendConsensusCall({ - client, - publicClient, - encodedData: _encodeStudioSubmitAppealData({txId}), - senderAccount, - value: args.value ?? 0n, - operationName: "Appeal", - }); - return txId; - } - const context = await _readAppealContext({ client, publicClient, @@ -866,9 +861,13 @@ export const contractActions = (client: GenLayerClient, publicCli }); const value = args.value ?? context.requiredValue; - const encodedData = _encodeSubmitAppealData({ + const encodedData = _encodeTopUpAndSubmitAppealData({ txId, expectedDecisionId: context.decisionId, + // Consensus derives the appeal shape from live state and retains this + // normalized zero schedule only for ABI compatibility. The same call + // is therefore valid against both pre-funded and unfunded transactions. + distribution: {}, }); await _sendConsensusCall({ client, @@ -882,8 +881,7 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** * 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. + * Returns the signed EVM envelope hash on every backend. */ topUpFees: async (args: { account?: Account; @@ -906,9 +904,8 @@ export const contractActions = (client: GenLayerClient, publicCli /** * Deposits appeal fee budget and submits an appeal in the same consensus call. * Returns the existing GenLayer transaction id, matching appealTransaction. - * Contract networks bind the call to the active decision and quote an - * omitted value. Current Studio uses its native decision-free entrypoint; - * its value defaults to zero when omitted. + * The call is bound to the active decision on both Studio and contract + * networks. When value is omitted, the authoritative appeal charge is used. */ topUpAndSubmitAppeal: async (args: { account?: Account; @@ -919,18 +916,6 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId, distribution} = args; const senderAccount = account || client.account; - if (client.chain.isStudio) { - await _sendConsensusCall({ - client, - publicClient, - encodedData: _encodeStudioTopUpAndSubmitAppealData({txId, distribution}), - senderAccount, - value: args.value ?? 0n, - operationName: "Top up and submit appeal", - }); - return txId; - } - const context = await _readAppealContext({ client, publicClient, @@ -962,20 +947,6 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId} = args; const senderAccount = account || client.account; - if (client.chain.isStudio) { - return _sendConsensusCall({ - client, - publicClient, - encodedData: encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "finalizeTransaction", - args: [txId], - }), - senderAccount, - operationName: "Finalize", - }); - } - const identity = await _readLifecycleIdentity({client, publicClient, txId}); if (!identity.decisionActive) { throw new Error(`Transaction ${txId} has no active decision to finalize`); @@ -1193,33 +1164,6 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ }, ] as const; -/** Studio's current embedded consensus does not carry decision identities. */ -const CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI = [ - { - type: "function", - name: "topUpAndSubmitAppeal", - stateMutability: "payable", - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, - ], - outputs: [], - }, -] as const; - -const CONSENSUS_APPEAL_TRAIN_ABI = [ - { - type: "function", - name: "submitAppeal", - stateMutability: "payable", - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_expectedDecisionId", type: "uint256"}, - ], - outputs: [], - }, -] as const; - const CONSENSUS_FINALIZATION_TRAIN_ABI = [ { type: "function", @@ -1493,6 +1437,10 @@ const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { const genPerTimeUnit = bigintFromUnknown(policyRecord.genPerTimeUnit, "policy.genPerTimeUnit"); const storageUnitPrice = bigintFromUnknown(policyRecord.storageUnitPrice, "policy.storageUnitPrice"); const receiptGasPrice = bigintFromUnknown(policyRecord.receiptGasPrice, "policy.receiptGasPrice"); + const timeUnitOverlayBps = bigintFromUnknown( + policyRecord.timeUnitOverlayBps, + "policy.timeUnitOverlayBps", + ); const intrinsicGas = bigintFromUnknown(policyRecord.intrinsicGas, "policy.intrinsicGas", DEFAULT_INTRINSIC_GAS); const bootloaderOverhead = bigintFromUnknown( policyRecord.bootloaderOverhead, @@ -1546,6 +1494,7 @@ const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { storageUnitPrice, receiptGasPrice, executionBudgetFloor, + timeUnitOverlayBps, }; }; @@ -1596,6 +1545,9 @@ const readCurrentFeePolicy = async ( storageUnitPrice, receiptGasPrice, executionBudgetFloor: maxBigint(executionBudgetFloor, localExecutionBudgetFloor), + // Live networks quote through FeeManager.calculateRoundFees; this field is + // only consumed by Studio's local mirror. + timeUnitOverlayBps: 0n, }; }; @@ -1939,6 +1891,14 @@ const calculateFeeForRound = ( leaderTimeunitsAllocation + (numOfValidators * validatorTimeunitsAllocation) ); +const validatorsPerRoundSafe = (round: number): bigint => ( + VALIDATORS_PER_ROUND[Math.min(Math.max(round, 0), VALIDATORS_PER_ROUND.length - 1)] +); + +const successfulAppealProfit = (appealBond: bigint): bigint => ( + appealBond + (appealBond / 2n) +); + const calculateLocalRoundFees = ( distribution: FeesDistribution, numOfInitialValidators: number, @@ -1967,11 +1927,8 @@ const calculateLocalRoundFees = ( } const startIndex = validatorIndex(numOfInitialValidators); - if (startIndex + Number(distribution.appealRounds * 2n) >= VALIDATORS_PER_ROUND.length) { - throw new Error("InvalidNumOfValidators"); - } - let total = calculateFeeForRound( + let taxableWork = calculateFeeForRound( VALIDATORS_PER_ROUND[startIndex], distribution.rotations[0] + 1n, distribution.leaderTimeunitsAllocation, @@ -1987,24 +1944,47 @@ const calculateLocalRoundFees = ( rotationsThisRound = 1n; } - total += calculateFeeForRound( - VALIDATORS_PER_ROUND[startIndex + offset], + // Consensus indexes appeal/next-normal committees by absolute round and + // saturates at the published ladder; only round zero uses the requested + // initial committee. + taxableWork += calculateFeeForRound( + validatorsPerRoundSafe(offset), rotationsThisRound, distribution.leaderTimeunitsAllocation, distribution.validatorTimeunitsAllocation, ); } - if (policy.genPerTimeUnit > 0n) { - total *= policy.genPerTimeUnit; + const priceCap = distribution.maxPriceGenPerTimeUnit; + if (priceCap > 0n) { + taxableWork *= priceCap; } + let appealProfitReserve = 0n; + for (let appealOrdinal = 0; appealOrdinal < Number(distribution.appealRounds); appealOrdinal++) { + const nextNormalBond = calculateFeeForRound( + validatorsPerRoundSafe((appealOrdinal + 1) * 2), + distribution.rotations[appealOrdinal + 1] + 1n, + distribution.leaderTimeunitsAllocation, + distribution.validatorTimeunitsAllocation, + ) * (priceCap > 0n ? priceCap : 1n); + appealProfitReserve += successfulAppealProfit(nextNormalBond); + } + + const overlayBps = policy.timeUnitOverlayBps ?? 0n; + if (overlayBps < 0n || overlayBps >= 10_000n) { + throw new Error("InvalidTimeUnitOverlayBps"); + } + const overlay = overlayBps === 0n + ? 0n + : (taxableWork * overlayBps) / (10_000n - overlayBps); + const leaderRounds = distribution.rotations.reduce( (sum, rotations) => sum + rotations + 1n, distribution.appealRounds, ); - total += distribution.executionBudgetPerRound * leaderRounds; - return total; + return taxableWork + appealProfitReserve + overlay + + (distribution.executionBudgetPerRound * leaderRounds); }; const _resolveTransactionFees = async ({ @@ -2109,51 +2089,6 @@ const _encodeAddTransactionData = ({ }]; }; -const _encodeSubmitAppealData = ({ - txId, - expectedDecisionId, -}: { - txId: `0x${string}`; - expectedDecisionId: bigint; -}): `0x${string}` => { - return encodeFunctionData({ - abi: CONSENSUS_APPEAL_TRAIN_ABI, - functionName: "submitAppeal", - args: [txId, expectedDecisionId], - }); -}; - -const STUDIO_APPEAL_QUOTE_UNSUPPORTED = - "Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"; - -const _encodeStudioSubmitAppealData = ({ - txId, -}: { - txId: `0x${string}`; -}): `0x${string}` => encodeFunctionData({ - abi: [{ - type: "function", - name: "submitAppeal", - stateMutability: "payable", - inputs: [{name: "_txId", type: "bytes32"}], - outputs: [], - }], - functionName: "submitAppeal", - args: [txId], -}); - -const _encodeStudioTopUpAndSubmitAppealData = ({ - txId, - distribution, -}: { - txId: `0x${string}`; - distribution: FeesDistributionInput; -}): `0x${string}` => encodeFunctionData({ - abi: CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI, - functionName: "topUpAndSubmitAppeal", - args: [txId, createFeesDistribution(distribution)], -}); - const _studioTrainBatchError = (action: string): Error => new Error( `${action} is not exposed by Studio's embedded consensus: ` + @@ -2279,6 +2214,33 @@ const _readLifecycleIdentity = async ({ blockNumber?: bigint; blockTimestamp?: bigint; }): Promise => { + if (client.chain.isStudio) { + const lifecycle = await client.request({ + method: "gen_getTransactionLifecycle", + params: [{txId: txId as TransactionHash}], + }) as { + resolutionActionCode?: unknown; + decisionId?: unknown; + decisionActive?: unknown; + evaluatedAt?: unknown; + }; + if (typeof lifecycle.decisionActive !== "boolean") { + throw new Error( + `Studio returned an invalid decisionActive for ${txId}: ${String(lifecycle.decisionActive)}`, + ); + } + const decisionId = lifecycle.decisionActive ? BigInt(String(lifecycle.decisionId)) : 0n; + const evaluatedAt = BigInt(String(lifecycle.evaluatedAt ?? 0)); + return { + blockNumber: 0n, + blockTimestamp: evaluatedAt, + resolutionAction: Number(lifecycle.resolutionActionCode), + attemptId: `0x${"00".repeat(32)}`, + decisionActive: lifecycle.decisionActive, + decisionId, + }; + } + const consensusDataAddress = client.chain.consensusDataContract?.address as Address | undefined; if (!consensusDataAddress || consensusDataAddress === zeroAddress) { throw new Error("ConsensusData contract is not configured for this chain"); @@ -2335,6 +2297,27 @@ const _readAppealContext = async ({ return {...identity, requiredValue: 0n}; } + if (client.chain.isStudio) { + const quote = await client.request({ + method: "gen_estimateLatestAppealCharge", + params: [{txId: txId as TransactionHash}], + }) as { + decisionId?: unknown; + bond?: unknown; + funding?: unknown; + }; + const quoteDecisionId = BigInt(String(quote.decisionId)); + if (quoteDecisionId !== identity.decisionId) { + throw new Error( + `Appeal decision changed while reading ${txId}: expected ${identity.decisionId}, received ${quoteDecisionId}`, + ); + } + return { + ...identity, + requiredValue: BigInt(String(quote.bond)) + BigInt(String(quote.funding)), + }; + } + const consensusDataAddress = client.chain.consensusDataContract!.address as Address; const quote = await publicClient.readContract({ address: consensusDataAddress, @@ -2364,7 +2347,7 @@ const _encodeTopUpFeesData = ({ return encodeFunctionData({ abi: CONSENSUS_FEE_MANAGEMENT_ABI, functionName: "topUpFees", - args: [txId, createFeesDistribution(distribution)], + args: [txId, createTopUpFeesDistribution(distribution)], }); }; @@ -2384,6 +2367,54 @@ const _encodeTopUpAndSubmitAppealData = ({ }); }; +const _waitForSentEnvelope = async ({ + client, + publicClient, + evmHash, + operationName, + revertDetails, +}: { + client: GenLayerClient; + publicClient: PublicClient; + evmHash: `0x${string}`; + operationName: string; + revertDetails?: string; +}) => { + const receipt = await publicClient.waitForTransactionReceipt({ + hash: evmHash, + ...(client.chain.isStudio ? { + // Studio returns the envelope hash before its EVM transaction index is + // necessarily visible. viem's default six retries cover only ~12s and + // Studio reports that transient as ResourceNotFoundRpcError. + retryCount: 120, + retryDelay: 500, + } : {}), + }); + if (receipt.status !== "reverted") return receipt; + + let studioReason: string | undefined; + if (client.chain.isStudio) { + try { + const rawReceipt = await client.request({ + method: "eth_getTransactionReceipt", + params: [evmHash], + } as any) as Record | null; + const reason = rawReceipt?.revertReason ?? rawReceipt?.error; + if (typeof reason === "string" && reason.trim() !== "") { + studioReason = reason; + } + } catch { + // The status-0 receipt is authoritative. Older Studio versions may not + // expose the additive revertReason field, so preserve the generic error. + } + } + + const details = studioReason ?? revertDetails; + throw new Error( + `${operationName} reverted: EVM tx ${evmHash}${details ? `. ${details}` : ""}`, + ); +}; + const _sendEvmContractCall = async ({ client, publicClient, @@ -2435,13 +2466,7 @@ const _sendEvmContractCall = async ({ }; const serializedTransaction = await validatedAccount.signTransaction(txRequest); const evmHash = await client.sendRawTransaction({serializedTransaction}); - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; } @@ -2457,13 +2482,7 @@ const _sendEvmContractCall = async ({ gasPrice: gasPriceHex as `0x${string}`, }], })) as `0x${string}`; - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; }; @@ -2527,13 +2546,7 @@ const _sendConsensusCall = async ({ }; const serializedTransaction = await validatedAccount.signTransaction(txRequest); const evmHash = await client.sendRawTransaction({serializedTransaction}); - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; } @@ -2547,13 +2560,7 @@ const _sendConsensusCall = async ({ gas: `0x${estimatedGas.toString(16)}` as `0x${string}`, }], })) as `0x${string}`; - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; }; @@ -2690,23 +2697,15 @@ const _sendTransaction = async ({ const serializedTransaction = await validatedSenderAccount.signTransaction(transactionRequest); const txHash = await client.sendRawTransaction({serializedTransaction: serializedTransaction}); + const receipt = await _waitForSentEnvelope({ + client, + publicClient, + evmHash: txHash, + operationName: "Transaction", + revertDetails: gasEstimationError ? `Gas estimation error: ${gasEstimationError}` : undefined, + }); - if (client.chain.isStudio) { - // Studio RPCs process eth_sendRawTransaction internally. The returned - // hash is already the GenLayer tx hash; there is no separate EVM - // receipt to wait for or consensus event to extract. - return txHash; - } - - const receipt = await publicClient.waitForTransactionReceipt({hash: txHash}); - - if (receipt.status === "reverted") { - throw new Error( - `Transaction reverted: EVM tx ${txHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.${ - gasEstimationError ? ` Gas estimation error: ${gasEstimationError}` : "" - }`, - ); - } + if (client.chain.isStudio) return txHash; const txId = extractTxIdFromLogs(client, receipt.logs); if (!txId) { @@ -2757,23 +2756,15 @@ const _sendTransaction = async ({ params: [formattedRequest as any], })) as `0x${string}`; - if (client.chain.isStudio) { - // Studio RPCs process eth_sendRawTransaction internally (MetaMask signs - // and forwards). The returned hash IS the GenLayer tx hash — no need to - // wait for an EVM receipt or extract txId from logs. - return evmTxHash; - } - - // On real testnets, extract GenLayer txId from the NewTransaction event. - const externalReceipt = await publicClient.waitForTransactionReceipt({hash: evmTxHash}); + const externalReceipt = await _waitForSentEnvelope({ + client, + publicClient, + evmHash: evmTxHash, + operationName: "Transaction", + revertDetails: gasEstimationError ? `Gas estimation error: ${gasEstimationError}` : undefined, + }); - if (externalReceipt.status === "reverted") { - throw new Error( - `Transaction reverted: EVM tx ${evmTxHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.${ - gasEstimationError ? ` Gas estimation error: ${gasEstimationError}` : "" - }`, - ); - } + if (client.chain.isStudio) return evmTxHash; const externalTxId = extractTxIdFromLogs(client, externalReceipt.logs); if (!externalTxId) { diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 1b2c455..f978891 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -536,7 +536,9 @@ export const transactionActions = (client: GenLayerClient, public // InternalMessageProcessed indexes the child transaction ID, not its // parent. Find the EVM transactions that decided the parent first, then // inspect their receipts for the child-message events emitted alongside - // that decision. + // that decision. The event is emitted by MessagePayments, not + // ConsensusMain, so the decision receipt is the authoritative boundary + // and the event emitter address must not be restricted here. const decisionLogs = await publicClient.getLogs({ address: consensusAddress, event: undefined, @@ -553,17 +555,11 @@ export const transactionActions = (client: GenLayerClient, public publicClient.getTransactionReceipt({hash: transactionHash!}), ), ); - const normalizedConsensusAddress = consensusAddress.toLowerCase(); - return [ ...new Set( receipts.flatMap(receipt => receipt.logs - .filter( - log => - log.address.toLowerCase() === normalizedConsensusAddress && - log.topics[0] === internalMessageProcessedTopic, - ) + .filter(log => log.topics[0] === internalMessageProcessedTopic) .map(log => log.topics[1] as TransactionHash) .filter(Boolean), ), diff --git a/src/transactions/fees.ts b/src/transactions/fees.ts index 9aa35b9..b3b390a 100644 --- a/src/transactions/fees.ts +++ b/src/transactions/fees.ts @@ -143,6 +143,44 @@ export const createFeesDistribution = (input: FeesDistributionInput = {}): FeesD }; }; +/** + * Normalizes the delta accepted by ConsensusMainWithFees.topUpFees. + * + * Existing fee-aware transactions must not resubmit or extend their appeal + * schedule, so an omitted schedule is encoded as appealRounds=0/rotations=[]. + * An explicit non-empty schedule remains available for the consensus path + * that initializes fee state on a transaction whose distribution is empty. + */ +export const createTopUpFeesDistribution = ( + input: FeesDistributionInput = {}, +): FeesDistribution => { + const appealRounds = toUInt(input.appealRounds, "fees.distribution.appealRounds"); + let rotations: bigint[]; + if (input.rotations === undefined || input.rotations.length === 0) { + if (appealRounds !== 0n) { + throw new Error( + "fees.distribution.rotations must contain appealRounds + 1 entries when appealRounds is non-zero.", + ); + } + rotations = []; + } else { + rotations = normalizeRotations(input.rotations, appealRounds, "fees.distribution.rotations"); + } + + return { + leaderTimeunitsAllocation: toUInt(input.leaderTimeunitsAllocation, "fees.distribution.leaderTimeunitsAllocation"), + validatorTimeunitsAllocation: toUInt(input.validatorTimeunitsAllocation, "fees.distribution.validatorTimeunitsAllocation"), + appealRounds, + executionBudgetPerRound: toUInt(input.executionBudgetPerRound, "fees.distribution.executionBudgetPerRound"), + executionConsumed: toUInt(input.executionConsumed, "fees.distribution.executionConsumed"), + totalMessageFees: toUInt(input.totalMessageFees, "fees.distribution.totalMessageFees"), + rotations, + maxPriceGenPerTimeUnit: toUInt(input.maxPriceGenPerTimeUnit, "fees.distribution.maxPriceGenPerTimeUnit"), + storageFeeMaxGasPrice: toUInt(input.storageFeeMaxGasPrice, "fees.distribution.storageFeeMaxGasPrice"), + receiptFeeMaxGasPrice: toUInt(input.receiptFeeMaxGasPrice, "fees.distribution.receiptFeeMaxGasPrice"), + }; +}; + export const encodeInternalMessageFeeParams = (input: InternalMessageFeeParamsInput = {}) => { const appealRounds = toUInt(input.appealRounds, "internalMessageFeeParams.appealRounds"); return encodeAbiParameters( @@ -156,6 +194,9 @@ export const encodeInternalMessageFeeParams = (input: InternalMessageFeeParamsIn {name: "appealRounds", type: "uint256"}, {name: "executionBudgetPerRound", type: "uint256"}, {name: "rotations", type: "uint256[]"}, + {name: "maxPriceGenPerTimeUnit", type: "uint256"}, + {name: "storageFeeMaxGasPrice", type: "uint256"}, + {name: "receiptFeeMaxGasPrice", type: "uint256"}, ], }, ], @@ -166,6 +207,9 @@ export const encodeInternalMessageFeeParams = (input: InternalMessageFeeParamsIn appealRounds, executionBudgetPerRound: toUInt(input.executionBudgetPerRound, "internalMessageFeeParams.executionBudgetPerRound"), rotations: normalizeRotations(input.rotations, appealRounds, "internalMessageFeeParams.rotations"), + maxPriceGenPerTimeUnit: toUInt(input.maxPriceGenPerTimeUnit, "internalMessageFeeParams.maxPriceGenPerTimeUnit"), + storageFeeMaxGasPrice: toUInt(input.storageFeeMaxGasPrice, "internalMessageFeeParams.storageFeeMaxGasPrice"), + receiptFeeMaxGasPrice: toUInt(input.receiptFeeMaxGasPrice, "internalMessageFeeParams.receiptFeeMaxGasPrice"), }, ], ); diff --git a/src/types/transactions.ts b/src/types/transactions.ts index bcdbcfa..e33e32c 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -422,6 +422,9 @@ export type InternalMessageFeeParamsInput = { appealRounds?: BigNumberish; executionBudgetPerRound?: BigNumberish; rotations?: BigNumberish[]; + maxPriceGenPerTimeUnit?: BigNumberish; + storageFeeMaxGasPrice?: BigNumberish; + receiptFeeMaxGasPrice?: BigNumberish; }; export type ExternalMessageFeeParamsInput = { @@ -461,6 +464,8 @@ export type FeePolicyQuote = { storageUnitPrice: bigint; receiptGasPrice: bigint; executionBudgetFloor: bigint; + /** Combined developer/DAO share, grossed up over taxable time-unit work. */ + timeUnitOverlayBps?: bigint; }; export type FeeEstimateOptions = FeesDistributionInput & { diff --git a/tests/chains-actions.test.ts b/tests/chains-actions.test.ts index e869fd3..593f628 100644 --- a/tests/chains-actions.test.ts +++ b/tests/chains-actions.test.ts @@ -54,7 +54,7 @@ describe.each([ ["localnet", localnet], ["studionet", studionet], ])("%s Studio consensus ABI", (_name, chain) => { - it("exports Studio's fee-aware, decision-free transaction signatures", () => { + it("exports Studio's fee-aware, decision-bound transaction signatures", () => { const functions = new Map( (chain.consensusMainContract!.abi as readonly TestAbiEntry[]) .filter((entry) => entry.type === "function" && entry.name) @@ -66,9 +66,9 @@ describe.each([ expect(inputTypes("addTransaction")).toEqual(["tuple"]); expect(inputTypes("deploySalted")).toEqual(["tuple"]); expect(inputTypes("topUpFees")).toEqual(["bytes32", "tuple"]); - expect(inputTypes("topUpAndSubmitAppeal")).toEqual(["bytes32", "tuple"]); - expect(inputTypes("submitAppeal")).toEqual(["bytes32"]); - expect(inputTypes("finalizeTransaction")).toEqual(["bytes32"]); + expect(inputTypes("topUpAndSubmitAppeal")).toEqual(["bytes32", "uint256", "tuple"]); + expect(inputTypes("submitAppeal")).toEqual(["bytes32", "uint256"]); + expect(inputTypes("finalizeTransaction")).toEqual(["bytes32", "uint256"]); const addParams = functions.get("addTransaction")!.inputs![0].components!; expect(addParams.map((component) => component.type)).toEqual([ diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index f91f208..604f0f7 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -12,6 +12,7 @@ import { CALL_KEY_DEPLOY, CALL_KEY_UNNAMED, CALL_KEY_WILDCARD, + createTopUpFeesDistribution, DEPLOY_CALL_KEY, deployCallKey, deriveExternalMessageCallKey, @@ -148,6 +149,9 @@ const INTERNAL_MESSAGE_FEE_PARAMS_ABI = [ {name: "appealRounds", type: "uint256"}, {name: "executionBudgetPerRound", type: "uint256"}, {name: "rotations", type: "uint256[]"}, + {name: "maxPriceGenPerTimeUnit", type: "uint256"}, + {name: "storageFeeMaxGasPrice", type: "uint256"}, + {name: "receiptFeeMaxGasPrice", type: "uint256"}, ], }, ] as const; @@ -593,6 +597,9 @@ describe("contractActions addTransaction ABI compatibility", () => { appealRounds: 1n, executionBudgetPerRound: 20n, rotations: [2n, 3n], + maxPriceGenPerTimeUnit: 30n, + storageFeeMaxGasPrice: 40n, + receiptFeeMaxGasPrice: 50n, }); const [decoded] = decodeAbiParameters(INTERNAL_MESSAGE_FEE_PARAMS_ABI, encoded) as any; @@ -601,6 +608,9 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(decoded.appealRounds).toBe(1n); expect(decoded.executionBudgetPerRound).toBe(20n); expect(decoded.rotations).toEqual([2n, 3n]); + expect(decoded.maxPriceGenPerTimeUnit).toBe(30n); + expect(decoded.storageFeeMaxGasPrice).toBe(40n); + expect(decoded.receiptFeeMaxGasPrice).toBe(50n); }); it("encodes external message fee params as the consensus tuple", () => { @@ -893,6 +903,46 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(estimateTransactionGas.mock.calls[0][0].value).toBe(11_000n); }); + it("matches the consensus cap, overlay, appeal reserve, and round ladder locally on Studio", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "0", + receiptGasPrice: "0", + timeUnitOverlayBps: "1500", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFees({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + appealRounds: 1n, + rotations: [0n, 0n], + executionBudgetPerRound: 0n, + maxPriceGenPerTimeUnit: 12n, + storageFeeMaxGasPrice: 0n, + receiptFeeMaxGasPrice: 0n, + }); + + // Taxable work: (5-validator round 0 + absolute rounds 1 and 2) * cap + // = (1100 + 1500 + 2300) * 12 = 58800. + // Appeal profit reserve: 1.5 * (2300 * 12) = 41400. + // Overlay: floor(58800 * 1500 / 8500) = 10376. + expect(fees.feeValue).toBe(110_576n); + }); + it("defaults direct fee estimates to the chain rotation budget for every appeal round", async () => { const requestMock = vi.fn().mockResolvedValue({ enabled: false, @@ -1004,6 +1054,7 @@ describe("contractActions addTransaction ABI compatibility", () => { storageUnitPrice: 20n, receiptGasPrice: 30n, executionBudgetFloor: expectedLocalFloor, + timeUnitOverlayBps: 0n, }); expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(12n); expect(fees.distribution.storageFeeMaxGasPrice).toBe(24n); @@ -1739,6 +1790,45 @@ describe("contractActions addTransaction ABI compatibility", () => { ).rejects.toThrow("Transaction reverted"); }); + it("surfaces Studio's receipt-level reason for a rejected write", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + if (method === "eth_getTransactionReceipt") { + return {status: "0x0", revertReason: "InsufficientFees"}; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + const publicClient = makeMockPublicClient({status: "reverted", logs: []}); + const {actions, client} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + signTransactionMock: vi.fn().mockResolvedValue("0xsigned"), + publicClient, + isStudio: true, + requestMock, + }); + (client as any).sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); + + await expect( + actions.writeContract({address: RECIPIENT_ADDRESS, functionName: "ping", value: 0n}), + ).rejects.toThrow(/InsufficientFees/); + + expect(publicClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: MOCK_EVM_TX_HASH, + retryCount: 120, + retryDelay: 500, + }); + }); + it("decodes BudgetTooLow selector from gas estimation failures", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -1859,19 +1949,6 @@ const FEE_MANAGEMENT_ABI = [ }, ] as const; -const APPEAL_TRAIN_ABI = [ - { - type: "function" as const, - name: "submitAppeal", - stateMutability: "payable" as const, - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_expectedDecisionId", type: "uint256"}, - ], - outputs: [], - }, -] as const; - const finalizeTransactionSelector = encodeFunctionData({ abi: FINALIZE_TX_ABI as any, functionName: "finalizeTransaction", @@ -2005,10 +2082,12 @@ const setupFeeManagementHarness = ({ receiptStatus = "success", isStudio = false, decisionActive = true, + studioRevertReason, }: { receiptStatus?: string; isStudio?: boolean; decisionActive?: boolean; + studioRevertReason?: string; } = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); @@ -2050,6 +2129,9 @@ const setupFeeManagementHarness = ({ sendRawTransaction, request: vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; + if (method === "eth_getTransactionReceipt") { + return {status: "0x0", revertReason: studioRevertReason}; + } throw new Error(`Unexpected RPC method: ${method}`); }), }; @@ -2275,6 +2357,22 @@ describe("contractActions bounded round reads", () => { }); describe("contractActions fee management", () => { + it("normalizes ordinary top-ups without resubmitting the appeal schedule", () => { + expect(createTopUpFeesDistribution({executionBudgetPerRound: 123n})).toMatchObject({ + appealRounds: 0n, + rotations: [], + executionBudgetPerRound: 123n, + }); + }); + + it("requires an explicit complete schedule when initializing appeal rounds", () => { + expect(() => createTopUpFeesDistribution({appealRounds: 1n})).toThrow( + /rotations must contain appealRounds \+ 1 entries/, + ); + expect(createTopUpFeesDistribution({appealRounds: 1n, rotations: [0n, 2n]})) + .toMatchObject({appealRounds: 1n, rotations: [0n, 2n]}); + }); + it("quotes the full authoritative appeal charge", async () => { const {actions, client} = setupFeeManagementHarness(); delete (client.chain as any).feeManagerContract; @@ -2283,7 +2381,7 @@ describe("contractActions fee management", () => { await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); }); - it("encodes submitAppeal with the active decision id and quoted funding", async () => { + it("uses the schedule-extending appeal path with the active decision id and quoted funding", async () => { const {actions, signTransaction} = setupFeeManagementHarness(); await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( @@ -2292,8 +2390,13 @@ describe("contractActions fee management", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.value).toBe(1234n); - const decoded = decodeFunctionData({abi: APPEAL_TRAIN_ABI, data: txRequest.data}); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_ABI as any, data: txRequest.data}); + const [txId, decisionId, distribution] = decoded.args as any[]; + expect(decoded.functionName).toBe("topUpAndSubmitAppeal"); + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); + expect(distribution.appealRounds).toBe(0n); + expect(distribution.rotations).toEqual([0n]); }); it("returns false from canAppeal when no decision is active", async () => { @@ -2305,6 +2408,20 @@ describe("contractActions fee management", () => { ); }); + it("reads canAppeal from the configured Appeals contract for the active decision", async () => { + const {actions, publicClient} = setupFeeManagementHarness(); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(true); + expect(publicClient.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: RECIPIENT_ADDRESS, + functionName: "canAppeal", + args: [MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID], + blockNumber: 123n, + }), + ); + }); + it("encodes topUpFees(bytes32, FeesDistribution) and returns the EVM tx hash", async () => { const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); @@ -2312,12 +2429,8 @@ describe("contractActions fee management", () => { txId: MOCK_GENLAYER_TX_ID, value: 999n, distribution: { - leaderTimeunitsAllocation: 100n, - validatorTimeunitsAllocation: 200n, - appealRounds: 1n, executionBudgetPerRound: 500_000n, totalMessageFees: 30n, - rotations: [0n, 2n], maxPriceGenPerTimeUnit: 12n, storageFeeMaxGasPrice: 24n, receiptFeeMaxGasPrice: 36n, @@ -2337,12 +2450,12 @@ describe("contractActions fee management", () => { }); const [txId, distribution] = decoded.args as any[]; expect(txId).toBe(MOCK_GENLAYER_TX_ID); - expect(distribution.leaderTimeunitsAllocation).toBe(100n); - expect(distribution.validatorTimeunitsAllocation).toBe(200n); - expect(distribution.appealRounds).toBe(1n); + expect(distribution.leaderTimeunitsAllocation).toBe(0n); + expect(distribution.validatorTimeunitsAllocation).toBe(0n); + expect(distribution.appealRounds).toBe(0n); expect(distribution.executionBudgetPerRound).toBe(500_000n); expect(distribution.totalMessageFees).toBe(30n); - expect(distribution.rotations).toEqual([0n, 2n]); + expect(distribution.rotations).toEqual([]); expect(distribution.maxPriceGenPerTimeUnit).toBe(12n); expect(distribution.storageFeeMaxGasPrice).toBe(24n); expect(distribution.receiptFeeMaxGasPrice).toBe(36n); @@ -2390,7 +2503,7 @@ describe("contractActions fee management", () => { ).rejects.toThrow(/Top up fees reverted/); }); - it("returns the Studio RPC hash for fee management calls without waiting for an EVM receipt", async () => { + it("returns the Studio envelope hash after confirming its EVM receipt", async () => { const {actions, waitForTransactionReceipt} = setupFeeManagementHarness({isStudio: true}); const hash = await actions.topUpFees({ @@ -2400,16 +2513,34 @@ describe("contractActions fee management", () => { }); expect(hash).toBe(MOCK_EVM_TX_HASH); - expect(waitForTransactionReceipt).not.toHaveBeenCalled(); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: MOCK_EVM_TX_HASH, + retryCount: 120, + retryDelay: 500, + }); }); - it("returns the Studio RPC hash for external-wallet fee management calls without waiting for an EVM receipt", async () => { + it("surfaces Studio's receipt-level revert reason", async () => { + const {actions} = setupFeeManagementHarness({ + isStudio: true, + receiptStatus: "reverted", + studioRevertReason: "TopUpCannotExtendSchedule", + }); + + await expect(actions.topUpFees({ + txId: MOCK_GENLAYER_TX_ID, + value: 1n, + distribution: {}, + })).rejects.toThrow(/TopUpCannotExtendSchedule/); + }); + + it("returns the Studio envelope hash for external-wallet fee management calls after its receipt", async () => { const request = vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; if (method === "eth_sendTransaction") return MOCK_EVM_TX_HASH; throw new Error(`Unexpected RPC method: ${method}`); }); - const waitForTransactionReceipt = vi.fn(); + const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: "success", logs: []}); const client = { chain: { id: 61_127, @@ -2440,7 +2571,11 @@ describe("contractActions fee management", () => { expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "eth_sendTransaction", })); - expect(waitForTransactionReceipt).not.toHaveBeenCalled(); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: MOCK_EVM_TX_HASH, + retryCount: 120, + retryDelay: 500, + }); }); }); @@ -2517,20 +2652,26 @@ describe("contractActions train lifecycle batches", () => { }); }); -/** Current Studio entrypoints intentionally remain independent of the v0.6 train. */ +/** Studio mirrors the v0.6 decision-bound transaction entrypoints. */ const CONSENSUS_MAIN_STUDIO_ABI = [ { type: "function" as const, name: "submitAppeal", stateMutability: "payable" as const, - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, { type: "function" as const, name: "finalizeTransaction", stateMutability: "nonpayable" as const, - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, ] as const; @@ -2542,6 +2683,7 @@ const FEE_MANAGEMENT_STUDIO_ABI = [ stateMutability: "payable" as const, inputs: [ {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, ], outputs: [], @@ -2549,9 +2691,9 @@ const FEE_MANAGEMENT_STUDIO_ABI = [ ] as const; /** - * Mirrors the localnet/studionet chain config: ConsensusMain and ConsensusData - * resolve, but the fee manager, rounds storage, and appeals contracts carry no - * address. Studio stays a consumer of this SDK without joining this train. + * Mirrors the localnet/studionet chain config. Studio exposes lifecycle and + * appeal-quote RPCs while fee manager, rounds, and appeals contract addresses + * remain absent from its chain definition. */ const setupStudioLifecycleHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -2586,12 +2728,30 @@ const setupStudioLifecycleHarness = () => { sendRawTransaction, request: vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; + if (method === "gen_getTransactionLifecycle") { + return { + storedStatusCode: 5, + projectedStatusCode: 5, + resolutionActionCode: 6, + resolutionSourceCode: 6, + decisionId: MOCK_DECISION_ID.toString(), + decisionActive: true, + evaluatedAt: "456", + }; + } + if (method === "gen_estimateLatestAppealCharge") { + return { + decisionId: MOCK_DECISION_ID.toString(), + bond: "500", + funding: "734", + appealDeadline: "999", + }; + } throw new Error(`Unexpected RPC method: ${method}`); }), }; - // Any train lifecycle read here is a regression: current Studio is a consumer - // of this SDK, not a member of the resolution-kernel landing cut. + // Studio lifecycle identity comes from its protocol RPC, never an EVM read. const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { throw new Error(`Unexpected consensus read on Studio: ${functionName}`); }); @@ -2612,9 +2772,9 @@ const setupStudioLifecycleHarness = () => { }; }; -describe("contractActions current Studio surface", () => { - it("encodes Studio's native submitAppeal(bytes32) without a train lifecycle read", async () => { - const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); +describe("contractActions Studio decision-bound surface", () => { + it("uses Studio's schedule-extending appeal path with the active decision id", async () => { + const {actions, signTransaction, readContract, client} = setupStudioLifecycleHarness(); await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).resolves.toBe( MOCK_GENLAYER_TX_ID, @@ -2624,21 +2784,29 @@ describe("contractActions current Studio surface", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); expect(txRequest.value).toBe(500n); - const decoded = decodeFunctionData({abi: CONSENSUS_MAIN_STUDIO_ABI, data: txRequest.data}); - expect(decoded.functionName).toBe("submitAppeal"); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); + const [txId, decisionId, distribution] = decoded.args as any[]; + expect(decoded.functionName).toBe("topUpAndSubmitAppeal"); + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); + expect(distribution.appealRounds).toBe(0n); + expect(distribution.rotations).toEqual([0n]); + expect(client.request).toHaveBeenCalledWith({ + method: "gen_getTransactionLifecycle", + params: [{txId: MOCK_GENLAYER_TX_ID}], + }); }); - it("defaults the Studio appeal value to zero when the caller omits it", async () => { + it("uses Studio's authoritative appeal quote when value is omitted", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); expect(readContract).not.toHaveBeenCalled(); - expect(signTransaction.mock.calls[0][0].value).toBe(0n); + expect(signTransaction.mock.calls[0][0].value).toBe(1234n); }); - it("encodes Studio's native topUpAndSubmitAppeal without a decision id", async () => { + it("encodes Studio topUpAndSubmitAppeal with the active decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); const txId = await actions.topUpAndSubmitAppeal({ @@ -2652,13 +2820,14 @@ describe("contractActions current Studio surface", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.value).toBe(1234n); const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); - const [decodedTxId, distribution] = decoded.args as any[]; + const [decodedTxId, decisionId, distribution] = decoded.args as any[]; expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); expect(distribution.appealRounds).toBe(1n); expect(distribution.rotations).toEqual([0n, 1n]); }); - it("encodes Studio's native finalizeTransaction(bytes32)", async () => { + it("encodes Studio finalizeTransaction with the active decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( @@ -2671,27 +2840,21 @@ describe("contractActions current Studio surface", () => { data: signTransaction.mock.calls[0][0].data, }); expect(decoded.functionName).toBe("finalizeTransaction"); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); }); - it("reports that the current Studio has no authoritative appeal quote", async () => { + it("returns Studio's authoritative decision-bound appeal quote", async () => { const {actions, readContract} = setupStudioLifecycleHarness(); - await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /missing feeManagerContract\/roundsStorageContract/, - ); - await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /missing feeManagerContract\/roundsStorageContract/, - ); + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); expect(readContract).not.toHaveBeenCalled(); }); - it("reports the missing appeals contract from canAppeal", async () => { + it("uses Studio's appeal quote surface for canAppeal", async () => { const {actions, readContract} = setupStudioLifecycleHarness(); - await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /missing appealsContract/, - ); + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(true); expect(readContract).not.toHaveBeenCalled(); }); @@ -2717,9 +2880,12 @@ describe("contractActions current Studio surface", () => { expect.objectContaining({functionName: "getTransactionLifecycle"}), ); const decoded = decodeFunctionData({ - abi: APPEAL_TRAIN_ABI, + abi: FEE_MANAGEMENT_ABI as any, data: signTransaction.mock.calls[0][0].data, }); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + const [txId, decisionId] = decoded.args as any[]; + expect(decoded.functionName).toBe("topUpAndSubmitAppeal"); + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); }); }); diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 175ba3c..548c57e 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -685,6 +685,7 @@ describe("getTriggeredTransactionIds", () => { const childHash = ("0x" + "22".repeat(32)) as any; const decisionHash = ("0x" + "33".repeat(32)) as any; const consensusAddress = "0x0000000000000000000000000000000000000010"; + const messagePaymentsAddress = "0x0000000000000000000000000000000000000020"; const internalMessageTopic = keccak256( stringToBytes("InternalMessageProcessed(bytes32,address,address)"), ); @@ -697,7 +698,7 @@ describe("getTriggeredTransactionIds", () => { const getTransactionReceipt = vi.fn().mockResolvedValue({ logs: [ { - address: consensusAddress, + address: messagePaymentsAddress, topics: [internalMessageTopic, childHash], }, ], From facd9e9dc9a289d0110fe3b5b1a14a2938fe6e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 3 Sep 2026 01:01:09 +0100 Subject: [PATCH 21/25] feat(release): add Studio dev preview channel (#217) Add the dedicated Studio dev chain preset and make prerelease npm publication use a non-latest channel. --- .github/workflows/publish.yml | 29 +++++++--- README.md | 2 +- docs/api-references/index.md | 2 +- .../api-references/types.TypeAlias.Network.md | 2 +- scripts/resolve-release-channel.mjs | 53 +++++++++++++++++++ src/chains/index.ts | 1 + src/chains/studioDevnet.ts | 24 +++++++++ src/types/network.ts | 8 ++- src/wallet/connect.ts | 2 + tests/chains-config.test.ts | 32 +++++++++++ tests/release-channel.test.mjs | 36 +++++++++++++ 11 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 scripts/resolve-release-channel.mjs create mode 100644 src/chains/studioDevnet.ts create mode 100644 tests/chains-config.test.ts create mode 100644 tests/release-channel.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 340aa70..edb6d61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,21 +31,23 @@ jobs: - run: npm ci - - name: Verify tag matches package.json version + - name: Verify tag and resolve npm dist-tag + id: version run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - PKG_VERSION="$(node -p "require('./package.json').version")" - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Tag ($TAG_VERSION) and package.json ($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 + if [ "${GITHUB_REF_TYPE:-}" != "tag" ]; then + echo "Publish must run from a git tag. Current ref type: ${GITHUB_REF_TYPE:-unknown}" >&2 exit 1 fi + + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + node scripts/resolve-release-channel.mjs "$TAG_VERSION" "$PKG_VERSION" >> "$GITHUB_OUTPUT" echo "Tag $GITHUB_REF_NAME matches package.json $PKG_VERSION." - run: npm run build - name: Publish to npm - run: npm publish --provenance --access public + run: npm publish --provenance --access public --tag "${{ steps.version.outputs.npm_dist_tag }}" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -63,6 +65,17 @@ jobs: if [ -z "$NOTES" ]; then NOTES="Release $GITHUB_REF_NAME" fi + + NOTES="$NOTES + + npm install genlayer-js@${{ steps.version.outputs.npm_dist_tag }}" + + RELEASE_FLAGS=() + if [ "${{ steps.version.outputs.is_prerelease }}" = "true" ]; then + RELEASE_FLAGS+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ - --notes "$NOTES" + --notes "$NOTES" \ + "${RELEASE_FLAGS[@]}" diff --git a/README.md b/README.md index 7cdf046..79b2126 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,7 @@ const txHash = await client.writeContract({ }); ``` -Available networks: `"localnet"`, `"studionet"`, `"testnetAsimov"`, `"testnetBradbury"`. +Available networks: `"localnet"`, `"studionet"`, `"studioDevnet"`, `"testnetAsimov"`, `"testnetBradbury"`. > **Note:** If the wallet is on the wrong chain when you call `writeContract`, the SDK will throw a clear error telling you which chain the wallet is on vs. which chain the client expects. Call `client.connect()` to resolve this. diff --git a/docs/api-references/index.md b/docs/api-references/index.md index bc6e3cc..6b02dd3 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -412,7 +412,7 @@ const txHash = await client.writeContract({ }); ``` -Available networks: `"localnet"`, `"studionet"`, `"testnetAsimov"`, `"testnetBradbury"`. +Available networks: `"localnet"`, `"studionet"`, `"studioDevnet"`, `"testnetAsimov"`, `"testnetBradbury"`. > **Note:** If the wallet is on the wrong chain when you call `writeContract`, the SDK will throw a clear error telling you which chain the wallet is on vs. which chain the client expects. Call `client.connect()` to resolve this. diff --git a/docs/api-references/types.TypeAlias.Network.md b/docs/api-references/types.TypeAlias.Network.md index 675a5e2..2a862c4 100644 --- a/docs/api-references/types.TypeAlias.Network.md +++ b/docs/api-references/types.TypeAlias.Network.md @@ -1,5 +1,5 @@ # Type Alias: Network -> **Network** = `"localnet"` \| `"studionet"` \| `"testnetAsimov"` \| `"testnetBradbury"` \| `"mainnet"` +> **Network** = `"localnet"` \| `"studionet"` \| `"studioDevnet"` \| `"testnetAsimov"` \| `"testnetBradbury"` \| `"mainnet"` Defined in: [types/network.ts:1](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/network.ts#L1) diff --git a/scripts/resolve-release-channel.mjs b/scripts/resolve-release-channel.mjs new file mode 100644 index 0000000..da149b1 --- /dev/null +++ b/scripts/resolve-release-channel.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import {fileURLToPath} from "node:url"; +import {resolve} from "node:path"; + +const DIST_TAG_PATTERN = /^[a-z][a-z0-9._-]*$/i; + +export function resolveReleaseChannel(tagVersion, packageVersion) { + if (!tagVersion || !packageVersion) { + throw new Error("Usage: resolve-release-channel.mjs "); + } + if (tagVersion !== packageVersion) { + throw new Error( + `Tag (${tagVersion}) and package.json (${packageVersion}) disagree — refusing to publish.`, + ); + } + + const prerelease = tagVersion.includes("-"); + if (!prerelease) { + return {isPrerelease: false, npmDistTag: "latest"}; + } + + const suffix = tagVersion.slice(tagVersion.indexOf("-") + 1).split("+", 1)[0]; + const npmDistTag = suffix.split(".", 1)[0]; + if ( + !npmDistTag || + npmDistTag === "latest" || + !DIST_TAG_PATTERN.test(npmDistTag) || + /^v?[0-9]/i.test(npmDistTag) + ) { + throw new Error(`Invalid prerelease npm dist-tag: '${npmDistTag}'`); + } + + return {isPrerelease: true, npmDistTag}; +} + +function main() { + try { + const {isPrerelease, npmDistTag} = resolveReleaseChannel( + process.argv[2], + process.argv[3], + ); + process.stdout.write(`is_prerelease=${isPrerelease}\n`); + process.stdout.write(`npm_dist_tag=${npmDistTag}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/src/chains/index.ts b/src/chains/index.ts index 6979228..af4d8ad 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -1,5 +1,6 @@ // src/chains/index.ts export {localnet} from "./localnet"; export {studionet} from "./studionet"; +export {studioDevnet} from "./studioDevnet"; export {testnetAsimov} from "./testnetAsimov"; export {testnetBradbury} from "./testnetBradbury"; diff --git a/src/chains/studioDevnet.ts b/src/chains/studioDevnet.ts new file mode 100644 index 0000000..6568c9e --- /dev/null +++ b/src/chains/studioDevnet.ts @@ -0,0 +1,24 @@ +import {defineChain} from "viem"; +import {GenLayerChain} from "@/types"; +import {studionet} from "./studionet"; + +const STUDIO_DEV_JSON_RPC_URL = "https://studio-dev.genlayer.com/api"; + +/** + * Preview Studio deployment used to qualify the next Studio and SDK releases. + * + * Keep this separate from `studionet`: preview releases must not silently move + * the stable Studio endpoint or chain id for existing SDK consumers. + */ +export const studioDevnet: GenLayerChain = defineChain({ + ...studionet, + id: 61_997, + name: "GenLayer Studio Devnet", + rpcUrls: { + default: { + http: [STUDIO_DEV_JSON_RPC_URL], + }, + }, + // The stable Studio explorer does not index this preview deployment. + blockExplorers: undefined, +}); diff --git a/src/types/network.ts b/src/types/network.ts index 8eaee39..89ed21c 100644 --- a/src/types/network.ts +++ b/src/types/network.ts @@ -1 +1,7 @@ -export type Network = "localnet" | "studionet" | "testnetAsimov" | "testnetBradbury" | "mainnet"; +export type Network = + | "localnet" + | "studionet" + | "studioDevnet" + | "testnetAsimov" + | "testnetBradbury" + | "mainnet"; diff --git a/src/wallet/connect.ts b/src/wallet/connect.ts index 3985dd9..574153b 100644 --- a/src/wallet/connect.ts +++ b/src/wallet/connect.ts @@ -1,5 +1,6 @@ import {localnet} from "@/chains/localnet"; import {studionet} from "@/chains/studionet"; +import {studioDevnet} from "@/chains/studioDevnet"; import {testnetAsimov} from "@/chains/testnetAsimov"; import {testnetBradbury} from "@/chains/testnetBradbury"; import {GenLayerClient, GenLayerChain} from "@/types"; @@ -10,6 +11,7 @@ import {snapID} from "@/config/snapID"; const networks = { localnet, studionet, + studioDevnet, testnetAsimov, testnetBradbury, }; diff --git a/tests/chains-config.test.ts b/tests/chains-config.test.ts new file mode 100644 index 0000000..a8e8028 --- /dev/null +++ b/tests/chains-config.test.ts @@ -0,0 +1,32 @@ +import {describe, expect, it} from "vitest"; +import {studioDevnet, studionet} from "../src/chains"; + +describe("Studio chain presets", () => { + it("exports the dedicated Studio preview deployment", () => { + expect(studioDevnet).toMatchObject({ + id: 61_997, + isStudio: true, + name: "GenLayer Studio Devnet", + rpcUrls: { + default: { + http: ["https://studio-dev.genlayer.com/api"], + }, + }, + testnet: true, + }); + expect(studioDevnet.blockExplorers).toBeUndefined(); + expect(studioDevnet.consensusMainContract?.address).toBe( + studionet.consensusMainContract?.address, + ); + expect(studioDevnet.consensusDataContract?.address).toBe( + studionet.consensusDataContract?.address, + ); + }); + + it("does not move the stable Studio preset", () => { + expect(studionet.id).toBe(61_999); + expect(studionet.rpcUrls.default.http).toEqual([ + "https://studio.genlayer.com/api", + ]); + }); +}); diff --git a/tests/release-channel.test.mjs b/tests/release-channel.test.mjs new file mode 100644 index 0000000..98aed18 --- /dev/null +++ b/tests/release-channel.test.mjs @@ -0,0 +1,36 @@ +import {describe, expect, it} from "vitest"; +import {resolveReleaseChannel} from "../scripts/resolve-release-channel.mjs"; + +describe("release channel policy", () => { + it("publishes stable releases to latest", () => { + expect(resolveReleaseChannel("2.0.0", "2.0.0")).toEqual({ + isPrerelease: false, + npmDistTag: "latest", + }); + }); + + it("publishes RC releases to rc without moving latest", () => { + expect(resolveReleaseChannel("2.0.0-rc.1", "2.0.0-rc.1")).toEqual({ + isPrerelease: true, + npmDistTag: "rc", + }); + }); + + it("rejects mismatched tags", () => { + expect(() => resolveReleaseChannel("2.0.0-rc.1", "1.1.8")).toThrow( + "disagree", + ); + }); + + it("rejects prereleases that could target latest", () => { + expect(() => resolveReleaseChannel("2.0.0-latest.1", "2.0.0-latest.1")).toThrow( + "Invalid prerelease npm dist-tag", + ); + }); + + it("rejects version-like prerelease channels", () => { + expect(() => resolveReleaseChannel("2.0.0-2.1", "2.0.0-2.1")).toThrow( + "Invalid prerelease npm dist-tag", + ); + }); +}); From 672631bfff0a21a25d95591a523497f7c46fd01d Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 3 Sep 2026 01:09:32 +0100 Subject: [PATCH 22/25] Release v2.0.0-rc.1 [skip ci] --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8643e9..d8f3cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ +## [2.0.0-rc.1](https://github.com/genlayerlabs/genlayer-js/compare/v1.1.6...v2.0.0-rc.1) (2026-09-03) + + +### ⚠ BREAKING CHANGES + +* **calldata:** method-call calldata now uses the "" key instead of +"method". Encoded bytes for every write/read/view/schema call change. Peers +must run the v0.6 genvm-manager (or a node/studio build that expects the "" +key). +* **fees:** move wildcard callKey sentinel to keccak256(empty), deploy key = bytes32(0) (#190) +* **fees:** estimation correctness, wait-for-decided semantics, v0.6 enum completeness (#187) + +### Features + +* **calldata:** move method-call key from "method" to "" (empty string) ([#192](https://github.com/genlayerlabs/genlayer-js/issues/192)) ([50a936c](https://github.com/genlayerlabs/genlayer-js/commit/50a936c4f0e436739851c8a4c47badcdd8c588dd)) +* epoch-zero SDK helpers (getCurrentEpoch, isValidatorBelowMinStake) ([#194](https://github.com/genlayerlabs/genlayer-js/issues/194)) ([a338ad5](https://github.com/genlayerlabs/genlayer-js/commit/a338ad5646b4b53cb2df418a25dbef20a8cb5f11)) +* **fees:** estimation correctness, wait-for-decided semantics, v0.6 enum completeness ([#187](https://github.com/genlayerlabs/genlayer-js/issues/187)) ([2af6928](https://github.com/genlayerlabs/genlayer-js/commit/2af6928e2587091fb33f5f703160c8a160606048)) +* **fees:** integrate v0.6 tooling ([#180](https://github.com/genlayerlabs/genlayer-js/issues/180)) ([75f90e2](https://github.com/genlayerlabs/genlayer-js/commit/75f90e2c47297416b7273b0fa3967e207c4c3a6f)), closes [#168](https://github.com/genlayerlabs/genlayer-js/issues/168) [genlayer-testing-suite#78](https://github.com/genlayerlabs/genlayer-testing-suite/issues/78) [genlayer-cli#297](https://github.com/genlayerlabs/genlayer-cli/issues/297) [#170](https://github.com/genlayerlabs/genlayer-js/issues/170) [#168](https://github.com/genlayerlabs/genlayer-js/issues/168) [#172](https://github.com/genlayerlabs/genlayer-js/issues/172) [#173](https://github.com/genlayerlabs/genlayer-js/issues/173) [#174](https://github.com/genlayerlabs/genlayer-js/issues/174) [#175](https://github.com/genlayerlabs/genlayer-js/issues/175) [#176](https://github.com/genlayerlabs/genlayer-js/issues/176) [#177](https://github.com/genlayerlabs/genlayer-js/issues/177) +* proof-bearing validator joins (restores [#206](https://github.com/genlayerlabs/genlayer-js/issues/206), gated on its 4 companions) ([#208](https://github.com/genlayerlabs/genlayer-js/issues/208)) ([bae1eee](https://github.com/genlayerlabs/genlayer-js/commit/bae1eeeccedf0572047c59fc3d271794f83c4176)), closes [#207](https://github.com/genlayerlabs/genlayer-js/issues/207) [#1290](https://github.com/genlayerlabs/genlayer-js/issues/1290) +* **release:** add Studio dev preview channel ([#217](https://github.com/genlayerlabs/genlayer-js/issues/217)) ([facd9e9](https://github.com/genlayerlabs/genlayer-js/commit/facd9e9dc9a289d0110fe3b5b1a14a2938fe6e01)) +* **sdk:** developer-NFT read/claim actions + nonce coercion fix ([#195](https://github.com/genlayerlabs/genlayer-js/issues/195)) ([60e8ca3](https://github.com/genlayerlabs/genlayer-js/commit/60e8ca3c253baa66f62e570ee4b4fdbd50cfa447)) +* **staking,vesting,accounts:** provider-lane writes + native transfer ([#200](https://github.com/genlayerlabs/genlayer-js/issues/200)) ([bf42f13](https://github.com/genlayerlabs/genlayer-js/commit/bf42f13a66a2bb762e5ef1065eb89789b9c45d4a)) +* vesting delegator actions + ABI ([#193](https://github.com/genlayerlabs/genlayer-js/issues/193)) ([6f691ce](https://github.com/genlayerlabs/genlayer-js/commit/6f691ce704092992216dce83e54447dbdeb8c3b2)), closes [#1154](https://github.com/genlayerlabs/genlayer-js/issues/1154) + + +### Bug Fixes + +* align SDK consumers with the resolution-kernel train ([#212](https://github.com/genlayerlabs/genlayer-js/issues/212)) ([d7d3262](https://github.com/genlayerlabs/genlayer-js/commit/d7d32627e036cf57600558a099bb589ccc5c8d2a)) +* build package during git prepare ([#198](https://github.com/genlayerlabs/genlayer-js/issues/198)) ([5788928](https://github.com/genlayerlabs/genlayer-js/commit/57889281db1e34df49abcf6129ffe6f347e4de4d)) +* **fees:** add exact quote and decision parity ([#214](https://github.com/genlayerlabs/genlayer-js/issues/214)) ([b79622d](https://github.com/genlayerlabs/genlayer-js/commit/b79622d3ce79ffd6e579c1d1ec1f68fe13cc8ac1)) +* **fees:** move wildcard callKey sentinel to keccak256(empty), deploy key = bytes32(0) ([#190](https://github.com/genlayerlabs/genlayer-js/issues/190)) ([7679ea0](https://github.com/genlayerlabs/genlayer-js/commit/7679ea064f6a3e8348b485d5b76013791c6fbd6e)) +* include execution budget floor in fee defaults ([#186](https://github.com/genlayerlabs/genlayer-js/issues/186)) ([02eeabc](https://github.com/genlayerlabs/genlayer-js/commit/02eeabc9765de3768724a18f7c4fd9c1db0bb667)) +* make git install build script self contained ([#197](https://github.com/genlayerlabs/genlayer-js/issues/197)) ([289839d](https://github.com/genlayerlabs/genlayer-js/commit/289839d13183013eac050e052999d45d392e09e0)) +* resolve triggered transaction IDs from decision receipts ([#202](https://github.com/genlayerlabs/genlayer-js/issues/202)) ([0b7e3bd](https://github.com/genlayerlabs/genlayer-js/commit/0b7e3bd63aeab25beaf63c14956af6a458f56b06)) +* support proof-bearing validator joins ([#206](https://github.com/genlayerlabs/genlayer-js/issues/206)) ([6f12738](https://github.com/genlayerlabs/genlayer-js/commit/6f1273885567ff5cda77b7459edfd6666c5859d0)) + + +### Reverts + +* Revert "fix: support proof-bearing validator joins (#206)" (#207) ([7666f0b](https://github.com/genlayerlabs/genlayer-js/commit/7666f0b73987aa75b7fcd1d62c2759364ce36824)), closes [#206](https://github.com/genlayerlabs/genlayer-js/issues/206) [#207](https://github.com/genlayerlabs/genlayer-js/issues/207) + ## 1.1.8 (2026-05-06) diff --git a/package-lock.json b/package-lock.json index 42fbf8e..7c9d567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer-js", - "version": "1.1.8", + "version": "2.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer-js", - "version": "1.1.8", + "version": "2.0.0-rc.1", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/package.json b/package.json index b23d64d..2c938ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "genlayer-js", "type": "module", - "version": "1.1.8", + "version": "2.0.0-rc.1", "description": "GenLayer JavaScript SDK", "main": "dist/index.js", "types": "dist/index.d.ts", From 8c899cc53bdb17372782ac538a8d77e166be912a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 3 Sep 2026 09:45:05 +0100 Subject: [PATCH 23/25] fix(release): keep tag publish workflows active (#218) Remove the CI-suppression marker from release commits and directly test the real release configuration against GitHub skip directives. --- .release-it.cjs | 4 +++- tests/release-channel.test.mjs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.release-it.cjs b/.release-it.cjs index 55818e2..759237e 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -21,7 +21,9 @@ module.exports = { git: { - commitMessage: "Release v${version} [skip ci]", + // Keep release commits CI-active: GitHub also suppresses tag-triggered + // workflows when the tagged commit contains a skip directive. + commitMessage: "Release v${version}", tagName: "v${version}", requireCleanWorkingDir: false, requireUpstream: false, diff --git a/tests/release-channel.test.mjs b/tests/release-channel.test.mjs index 98aed18..0c6eddf 100644 --- a/tests/release-channel.test.mjs +++ b/tests/release-channel.test.mjs @@ -1,6 +1,10 @@ +import {createRequire} from "node:module"; import {describe, expect, it} from "vitest"; import {resolveReleaseChannel} from "../scripts/resolve-release-channel.mjs"; +const require = createRequire(import.meta.url); +const releaseConfig = require("../.release-it.cjs"); + describe("release channel policy", () => { it("publishes stable releases to latest", () => { expect(resolveReleaseChannel("2.0.0", "2.0.0")).toEqual({ @@ -33,4 +37,12 @@ describe("release channel policy", () => { "Invalid prerelease npm dist-tag", ); }); + + it("keeps release commits eligible for tag-triggered publishing", () => { + expect(releaseConfig.git.commitMessage).toBe("Release v${version}"); + expect(releaseConfig.git.commitMessage).not.toMatch( + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/i, + ); + expect(releaseConfig.git.commitMessage).not.toMatch(/skip-checks:\s*true/i); + }); }); From 6ee12f8607632192e3d857396b855d7590271fcd Mon Sep 17 00:00:00 2001 From: Claus Date: Wed, 23 Sep 2026 12:28:43 -0300 Subject: [PATCH 24/25] fix(consumers): align staking and lifecycle reads with the v0.6 producer (#220) * fix(staking): follow the CON-471 ValidatorStake.live -> hasUnclaimedRewards rename genlayer-consensus renames `ValidatorStake.live` to `hasUnclaimedRewards` (the flag marks unclaimed inflation rewards, not node liveness). Update the staking ABI tuple, the `ValidatorView` types and the view mapper, and the fixtures/smoke assertion that read the old key. Depends-On: genlayerlabs/genlayer-consensus#1403 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KVkzRWuHBntyhom3xqHEAG * fix(consumers): align staking and lifecycle reads with the migration producer Reconcile the staking ABI subset against consensus c4749a420, retain both reward-flag states independently of bans, and preserve uint256 claim amounts and execution generation. Add producer-derived fixtures and document pre-release package delivery and remaining integration requirements. * fix(transactions): discover canonical children across deferred delivery --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: kirilaa --- README.md | 4 + .../types.Interface.ValidatorInfo.md | 4 +- .../types.Interface.ValidatorView.md | 4 +- docs/consumer-migration-v06.md | 66 +++ src/abi/consensusTrain.ts | 2 +- src/abi/staking.ts | 237 ++------- src/staking/actions.ts | 2 +- src/transactions/actions.ts | 109 +++-- src/types/staking.ts | 4 +- src/types/transactions.ts | 2 + tests/consumer-producer-abi.test.ts | 47 ++ tests/fixtures/consensus-consumer-abi.json | 459 ++++++++++++++++++ tests/smoke.test.ts | 2 +- tests/staking-actions.test.ts | 6 +- tests/staking-commit-layout.test.ts | 15 +- tests/transactions.test.ts | 96 ++-- 16 files changed, 759 insertions(+), 300 deletions(-) create mode 100644 docs/consumer-migration-v06.md create mode 100644 tests/consumer-producer-abi.test.ts create mode 100644 tests/fixtures/consensus-consumer-abi.json diff --git a/README.md b/README.md index 79b2126..7bfb82e 100644 --- a/README.md +++ b/README.md @@ -500,3 +500,7 @@ We welcome contributions to GenLayerJS SDK! Whether it's new features, improved ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +### v0.6 consumer migration + +See [the v2 migration guide](docs/consumer-migration-v06.md) for the staking rewards rename, producer ABI changes, execution generation, and package selection. diff --git a/docs/api-references/types.Interface.ValidatorInfo.md b/docs/api-references/types.Interface.ValidatorInfo.md index 4182a13..672008b 100644 --- a/docs/api-references/types.Interface.ValidatorInfo.md +++ b/docs/api-references/types.Interface.ValidatorInfo.md @@ -68,9 +68,9 @@ Defined in: [types/staking.ts:64](https://github.com/genlayerlabs/genlayer-js/bl *** -### live +### hasUnclaimedRewards -> **live**: `boolean` +> **hasUnclaimedRewards**: `boolean` Defined in: [types/staking.ts:60](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L60) diff --git a/docs/api-references/types.Interface.ValidatorView.md b/docs/api-references/types.Interface.ValidatorView.md index ff2d379..fa96f6d 100644 --- a/docs/api-references/types.Interface.ValidatorView.md +++ b/docs/api-references/types.Interface.ValidatorView.md @@ -44,9 +44,9 @@ Defined in: [types/staking.ts:19](https://github.com/genlayerlabs/genlayer-js/bl *** -### live +### hasUnclaimedRewards -> **live**: `boolean` +> **hasUnclaimedRewards**: `boolean` Defined in: [types/staking.ts:30](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L30) diff --git a/docs/consumer-migration-v06.md b/docs/consumer-migration-v06.md new file mode 100644 index 0000000..a24986f --- /dev/null +++ b/docs/consumer-migration-v06.md @@ -0,0 +1,66 @@ +# v0.6 consumer migration + +This v2 SDK update targets the JM/Claudio/Kiril consensus train, audited at +`genlayerlabs/genlayer-consensus@c4749a42095bcfb9de69bef4f4fd5e9a6a2f86f2` +(PR #1634, above #1628). The fixture in +`tests/fixtures/consensus-consumer-abi.json` contains unmodified ABI entries +from that revision's `abis/IGenLayerStaking.json` `abis/ConsensusData.json`, and `abis/Messages.json`. +This is source compatibility evidence, not a deployment or full E2E result. + +## Staking + +Replace `ValidatorView.live` and `ValidatorInfo.live` with +`hasUnclaimedRewards`. There is no `live` alias. The flag reports rewards still +to be consumed at priming; it does not establish eligibility, readiness or ban +state. Continue to use `banned`, `needsPriming`, and selectable-validator APIs +for their independently defined purposes. Both boolean states are tested. + +Claim `quantity`/`offset` and Commit `outstanding` now decode as `uint256`. +Their JavaScript type remains `bigint`. The producer-fixture regression uses +values above the old `uint120` boundary. + +The shipped staking ABI subset also follows the producer's `delegatorClaim` +return, payable two-argument `inflationInit`, transaction-ID inputs on +idleness bans/quarantine, and `ValidatorBannedIdleness` event. Obsolete staking +selectors/events absent from this producer are removed. No administrative +wrapper or new governance journey is introduced. + +## Lifecycle + +`advanced.getTransactionLifecycle()` exposes optional `executionGeneration` +as an exact decimal string, independent of the retained decision's generation. +Older Studio RPCs without this field leave it absent; no generation is guessed. +A generation change invalidates cached attempt-specific receipts. Do not use +`decisionId` as a substitute for the execution generation. + +An accepted, undetermined or timeout decision can still be appealed or +finalized. `waitUntil: "decided"` is appropriate for an early result; use +`waitUntil: "finalized"` to wait for stored parent finalization. Neither parent +finalization nor `getTriggeredTransactionIds()` proves all internal delivery, +refunds or payouts complete. Child lookup reads parent-filtered +`InternalMessageEffectCommitted` events from the registered Messages contract, +including deferred delivery after the decision and earlier generations. It +returns historical canonical child IDs, not an active-generation inventory. +Queries scan all history in 10,000-block pages; supply `fromBlock` at deployment +to reduce RPC work. A later lower bound intentionally excludes older children. +RPC failures propagate instead of returning a silently incomplete list. This +requires the audited producer event and does not infer IDs for older deployments. + +## Package delivery and remaining qualification + +Use an exact commit of PR #220 for pre-release consumers and regenerate their +lockfiles; v0/v1 semver ranges do not select this v2 train. Studio frontend and +explorer must select the same audited SDK build. Publishing a tagged npm +release remains a separate release action after qualification. + +Select dev-env #145 (it includes #147 and merged #148), retaining Python #116 +and E2E #781 in the consumer closure. Later qualification must compose the +implemented Node stack #1953–#1956 and its Consensus #1634 dependency, verify +one source per repository, and record the actual resolved manifest. Do not +add a reverse SDK-to-Node dependency that creates a cycle. + +Outstanding integration work: full deployment/reset provenance, integrated +child-delivery journey validation, authoritative delivery-completion +API, generation-aware UI receipt invalidation, and deploy/write/read/appeal/ +recompute/governance journey coverage. Native ABI tests do not qualify those +journeys or the producer's native-GEN reserve allocation. diff --git a/src/abi/consensusTrain.ts b/src/abi/consensusTrain.ts index 38b6a95..b1fec81 100644 --- a/src/abi/consensusTrain.ts +++ b/src/abi/consensusTrain.ts @@ -7,7 +7,7 @@ const parseTrainAbi = parseAbi as (signatures: readonly string[]) => Abi; /** Minimal train ABIs used by the high-level transaction reader. */ export const CONSENSUS_DATA_TRAIN_ABI = parseTrainAbi([ "function addressManager() view returns (address)", - "function getTransactionLifecycle(bytes32 _txId, uint256 _timestamp) view returns ((uint8 storedStatus, (bytes32 txId, uint8 storedStatus, uint8 projectedStatus, uint8 action, uint8 result, bytes32 resultHash, uint8 source, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint256 basisDecisionId, uint8 context, bytes32 attemptId, uint256 boundaryAt, uint256 evaluatedAt, uint256 snapshotBlock, uint256 decisionWindow, uint256 appealDeadline, bool materializesDecision, bool actionOutcomeDeterministic, bool nonCurrentEvaluation) resolution, (bool exists, uint256 decisionId, uint256 basisDecisionId, uint8 context, uint8 source, bytes32 sourceAttemptId, uint8 sourceStatus, uint8 status, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint8 result, bytes32 resultHash, uint256 effectiveAt, uint256 materializedAt, uint256 appealDeadline) latestDecision, bool decisionActive) lifecycle)", + "function getTransactionLifecycle(bytes32 _txId, uint256 _timestamp) view returns ((uint8 storedStatus, (bytes32 txId, uint8 storedStatus, uint8 projectedStatus, uint8 action, uint8 result, bytes32 resultHash, uint8 source, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint256 basisDecisionId, uint8 context, bytes32 attemptId, uint256 boundaryAt, uint256 evaluatedAt, uint256 snapshotBlock, uint256 decisionWindow, uint256 appealDeadline, bool materializesDecision, bool actionOutcomeDeterministic, bool nonCurrentEvaluation) resolution, (bool exists, uint256 decisionId, uint256 basisDecisionId, uint8 context, uint8 source, bytes32 sourceAttemptId, uint8 sourceStatus, uint8 status, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint8 result, bytes32 resultHash, uint256 effectiveAt, uint256 materializedAt, uint256 appealDeadline) latestDecision, bool decisionActive, uint256 executionGeneration) lifecycle)", "function estimateLatestAppealCharge(bytes32 _txId) view returns (uint256 decisionId, uint256 bond, uint256 funding, uint256 appealDeadline)", ]); diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 56bc51f..92e7b09 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -133,16 +133,17 @@ export const VALIDATOR_WALLET_ABI = [ }, ] as const; +// Source: consensus c4749a42095bcfb9de69bef4f4fd5e9a6a2f86f2, abis/IGenLayerStaking.json. const STAKING_CLAIM_COMPONENTS = [ - {name: "quantity", type: "uint120"}, - {name: "offset", type: "uint120"}, + {name: "quantity", type: "uint256"}, + {name: "offset", type: "uint256"}, {name: "commit", type: "uint256"}, ] as const; const STAKING_COMMIT_COMPONENTS = [ {name: "input", type: "uint256"}, {name: "output", type: "uint256"}, - {name: "outstanding", type: "uint120"}, + {name: "outstanding", type: "uint256"}, {name: "epoch", type: "uint64"}, {name: "linkToNextCommit", type: "uint56"}, {name: "priced", type: "bool"}, @@ -205,19 +206,10 @@ export const STAKING_ABI = [ type: "event", inputs: [], }, - { - name: "BurnFailed", - type: "event", - inputs: [ - {name: "amount", type: "uint256", indexed: false}, - ], - }, { name: "BurnToL1", type: "event", - inputs: [ - {name: "amount", type: "uint256", indexed: false}, - ], + inputs: [{name: "amount", type: "uint256", indexed: false}], }, { name: "DelegatorClaim", @@ -248,30 +240,12 @@ export const STAKING_ABI = [ { name: "EpochAdvance", type: "event", - inputs: [ - {name: "epoch", type: "uint256", indexed: false}, - ], + inputs: [{name: "epoch", type: "uint256", indexed: false}], }, { name: "EpochFinalize", type: "event", - inputs: [ - {name: "epoch", type: "uint256", indexed: false}, - ], - }, - { - name: "EpochHasPendingTribunals", - type: "event", - inputs: [ - {name: "epoch", type: "uint256", indexed: false}, - ], - }, - { - name: "EpochZeroEnded", - type: "event", - inputs: [ - {name: "timestamp", type: "uint256", indexed: false}, - ], + inputs: [{name: "epoch", type: "uint256", indexed: false}], }, { name: "FeesReceived", @@ -284,9 +258,7 @@ export const STAKING_ABI = [ { name: "InflationInitiated", type: "event", - inputs: [ - {name: "timestamp", type: "uint256", indexed: false}, - ], + inputs: [{name: "timestamp", type: "uint256", indexed: false}], }, { name: "InflationReceived", @@ -308,72 +280,42 @@ export const STAKING_ABI = [ { name: "SetDeepthought", type: "event", - inputs: [ - {name: "deepthought", type: "address", indexed: false}, - ], + inputs: [{name: "deepthought", type: "address", indexed: false}], }, { name: "SetDelegatorMinimumStake", type: "event", - inputs: [ - {name: "delegatorMinStake", type: "uint256", indexed: false}, - ], + inputs: [{name: "delegatorMinStake", type: "uint256", indexed: false}], }, { name: "SetEpochMinDuration", type: "event", - inputs: [ - {name: "epochMinDuration", type: "uint256", indexed: false}, - ], + inputs: [{name: "epochMinDuration", type: "uint256", indexed: false}], }, { name: "SetEpochMinDurationThreshold", type: "event", - inputs: [ - {name: "epochMinDurationThreshold", type: "uint256", indexed: false}, - ], + inputs: [{name: "epochMinDurationThreshold", type: "uint256", indexed: false}], }, { name: "SetEpochZeroMinDuration", type: "event", - inputs: [ - {name: "epochZeroMinDuration", type: "uint256", indexed: false}, - ], - }, - { - name: "SetGen", - type: "event", - inputs: [ - {name: "gen", type: "address", indexed: false}, - ], + inputs: [{name: "epochZeroMinDuration", type: "uint256", indexed: false}], }, { name: "SetMaxValidators", type: "event", - inputs: [ - {name: "maxValidators", type: "uint256", indexed: false}, - ], + inputs: [{name: "maxValidators", type: "uint256", indexed: false}], }, { name: "SetReductionFactor", type: "event", - inputs: [ - {name: "reductionFactor", type: "uint256", indexed: false}, - ], - }, - { - name: "SetStakingInvariant", - type: "event", - inputs: [ - {name: "stakingInvariant", type: "address", indexed: false}, - ], + inputs: [{name: "reductionFactor", type: "uint256", indexed: false}], }, { name: "SetTransactionFeesManager", type: "event", - inputs: [ - {name: "transactionFeesManager", type: "address", indexed: false}, - ], + inputs: [{name: "transactionFeesManager", type: "address", indexed: false}], }, { name: "SetUnbondingPeriods", @@ -386,9 +328,7 @@ export const STAKING_ABI = [ { name: "SetValidatorMinimumStake", type: "event", - inputs: [ - {name: "validatorMinStake", type: "uint256", indexed: false}, - ], + inputs: [{name: "validatorMinStake", type: "uint256", indexed: false}], }, { name: "SetValidatorWeightParams", @@ -401,25 +341,18 @@ export const STAKING_ABI = [ { name: "ValidatorBanRemoved", type: "event", - inputs: [ - {name: "validator", type: "address", indexed: false}, - ], + inputs: [{name: "validator", type: "address", indexed: false}], }, { - name: "ValidatorBannedDeterministic", - type: "event", + anonymous: false, inputs: [ - {name: "validator", type: "address", indexed: false}, + {indexed: false, name: "validator", type: "address"}, + {indexed: false, name: "txId", type: "bytes32"}, + {indexed: false, name: "bannedAt", type: "uint256"}, + {indexed: false, name: "bannedUntil", type: "uint256"}, ], - }, - { name: "ValidatorBannedIdleness", type: "event", - inputs: [ - {name: "validator", type: "address", indexed: false}, - {name: "bannedAt", type: "uint256", indexed: false}, - {name: "bannedUntil", type: "uint256", indexed: false}, - ], }, { name: "ValidatorClaim", @@ -469,16 +402,7 @@ export const STAKING_ABI = [ { name: "ValidatorQuarantineRemoved", type: "event", - inputs: [ - {name: "validator", type: "address", indexed: false}, - ], - }, - { - name: "ValidatorQuarantineRepealed", - type: "event", - inputs: [ - {name: "validator", type: "address", indexed: false}, - ], + inputs: [{name: "validator", type: "address", indexed: false}], }, { name: "ValidatorQuarantined", @@ -498,13 +422,6 @@ export const STAKING_ABI = [ {name: "epoch", type: "uint256", indexed: false}, ], }, - { - name: "ValidatorsRegistered", - type: "event", - inputs: [ - {name: "count", type: "uint256", indexed: false}, - ], - }, // Functions { @@ -521,20 +438,6 @@ export const STAKING_ABI = [ inputs: [], outputs: [{name: "", type: "uint256"}], }, - { - name: "activeWeights", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{name: "", type: "uint256[]"}], - }, - { - name: "adminRegisterValidators", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "validatorAddresses", type: "address[]"}], - outputs: [], - }, { name: "burning", type: "function", @@ -557,14 +460,14 @@ export const STAKING_ABI = [ outputs: [{name: "", type: "uint256"}], }, { - name: "delegatorClaim", - type: "function", - stateMutability: "nonpayable", inputs: [ {name: "_delegator", type: "address"}, {name: "_validator", type: "address"}, ], - outputs: [], + name: "delegatorClaim", + outputs: [{name: "", type: "uint256"}], + stateMutability: "nonpayable", + type: "function", }, { name: "delegatorDeposit", @@ -944,26 +847,28 @@ export const STAKING_ABI = [ outputs: [{name: "", type: "address[]"}], }, { - name: "idlenessBan", - type: "function", - stateMutability: "nonpayable", inputs: [ {name: "_validator", type: "address"}, {name: "_at", type: "uint256"}, {name: "_until", type: "uint256"}, + {name: "_txId", type: "bytes32"}, ], + name: "idlenessBan", outputs: [], + stateMutability: "nonpayable", + type: "function", }, { - name: "idlenessBanBatch", - type: "function", - stateMutability: "nonpayable", inputs: [ {name: "_validators", type: "address[]"}, {name: "_quarantinedAt", type: "uint256"}, {name: "_quarantinedUntil", type: "uint256"}, + {name: "_txId", type: "bytes32"}, ], + name: "idlenessBanBatch", outputs: [], + stateMutability: "nonpayable", + type: "function", }, { name: "inflationEpoch", @@ -973,11 +878,14 @@ export const STAKING_ABI = [ outputs: [{name: "", type: "uint256"}], }, { + inputs: [ + {name: "_epochTo", type: "uint256"}, + {name: "_inauguralTimestamp", type: "uint256"}, + ], name: "inflationInit", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_inflationOnset", type: "uint256"}], outputs: [], + stateMutability: "payable", + type: "function", }, { name: "inflationReceive", @@ -1052,13 +960,6 @@ export const STAKING_ABI = [ inputs: [{name: "_finalizationPhase", type: "address"}], outputs: [], }, - { - name: "setGen", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_gen", type: "address"}], - outputs: [], - }, { name: "setIdlenessPhase", type: "function", @@ -1087,13 +988,6 @@ export const STAKING_ABI = [ inputs: [{name: "_revealingPhase", type: "address"}], outputs: [], }, - { - name: "setStakingInvariant", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_stakingInvariant", type: "address"}], - outputs: [], - }, { name: "setTransactionFeesManager", type: "function", @@ -1111,13 +1005,6 @@ export const STAKING_ABI = [ ], outputs: [], }, - { - name: "setValidatorMinimumStake", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_validatorMinStake", type: "uint256"}], - outputs: [], - }, { name: "setValidatorWeightParams", type: "function", @@ -1148,13 +1035,6 @@ export const STAKING_ABI = [ ], outputs: [{name: "", type: "uint256"}], }, - { - name: "validatorBanDeterministic", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_validator", type: "address"}], - outputs: [], - }, { name: "validatorClaim", type: "function", @@ -1241,14 +1121,15 @@ export const STAKING_ABI = [ outputs: [], }, { - name: "validatorQuarantine", - type: "function", - stateMutability: "nonpayable", inputs: [ {name: "_validator", type: "address"}, {name: "_at", type: "uint256"}, + {name: "_txId", type: "bytes32"}, ], + name: "validatorQuarantine", outputs: [], + stateMutability: "nonpayable", + type: "function", }, { name: "validatorQuarantineCount", @@ -1257,13 +1138,6 @@ export const STAKING_ABI = [ inputs: [], outputs: [{name: "", type: "uint256"}], }, - { - name: "validatorQuarantineRepeal", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_validator", type: "address"}], - outputs: [], - }, { name: "validatorSelection", type: "function", @@ -1311,7 +1185,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1319,7 +1193,7 @@ export const STAKING_ABI = [ {name: "dShares", type: "uint256"}, {name: "vDeposit", type: "uint256"}, {name: "vWithdrawal", type: "uint256"}, - {name: "live", type: "bool"}, + {name: "hasUnclaimedRewards", type: "bool"}, ], }, ], @@ -1334,7 +1208,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1342,7 +1216,7 @@ export const STAKING_ABI = [ {name: "dShares", type: "uint256"}, {name: "vDeposit", type: "uint256"}, {name: "vWithdrawal", type: "uint256"}, - {name: "live", type: "bool"}, + {name: "hasUnclaimedRewards", type: "bool"}, ], }, ], @@ -1357,7 +1231,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1365,7 +1239,7 @@ export const STAKING_ABI = [ {name: "dShares", type: "uint256"}, {name: "vDeposit", type: "uint256"}, {name: "vWithdrawal", type: "uint256"}, - {name: "live", type: "bool"}, + {name: "hasUnclaimedRewards", type: "bool"}, ], }, ], @@ -1424,13 +1298,6 @@ export const STAKING_ABI = [ inputs: [], outputs: [{name: "", type: "uint256"}], }, - { - name: "validatorsCount", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{name: "", type: "uint256"}], - }, { name: "validatorsJoinedCount", type: "function", diff --git a/src/staking/actions.ts b/src/staking/actions.ts index 337cb82..f525e17 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -658,7 +658,7 @@ export const stakingActions = ( vWithdrawal: formatStakingAmount(view.vWithdrawal), vWithdrawalRaw: view.vWithdrawal, ePrimed: view.ePrimed, - live: view.live, + hasUnclaimedRewards: view.hasUnclaimedRewards, banned, bannedEpoch: banned ? view.eBanned : undefined, needsPriming, diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index f978891..53291f5 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -20,7 +20,7 @@ import { import {transactionsConfig} from "../config/transactions"; import {sleep} from "../utils/async"; import {GenLayerChain} from "@/types"; -import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes, zeroAddress} from "viem"; +import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes, zeroAddress, parseAbiItem} from "viem"; import {decodeLocalnetTransaction, decodeTransaction, simplifyTransactionReceipt} from "./decoders"; import {isMethodNotFoundError, readStudioLifecycleFallback} from "./lifecycleFallback"; import { @@ -41,6 +41,7 @@ type RawProtocolLifecycle = { decisionId: unknown; decisionActive: unknown; evaluatedAt: unknown; + executionGeneration?: unknown; }; const protocolInteger = (value: unknown, label: string): number => { @@ -76,6 +77,14 @@ const protocolDecisionId = (value: unknown, active: boolean): string | null => { return BigInt(decimal).toString(); }; +const protocolGeneration = (value: unknown): string => { + if ((typeof value !== "bigint" && typeof value !== "string" && typeof value !== "number") || + (typeof value === "number" && !Number.isSafeInteger(value)) || !/^\d+$/.test(String(value))) { + throw new Error(`Invalid protocol lifecycle executionGeneration: ${String(value)}`); + } + return BigInt(value).toString(); +}; + const normalizeProtocolLifecycle = (raw: RawProtocolLifecycle): TransactionProtocolLifecycle => { const storedStatusCode = protocolInteger(raw.storedStatusCode, "storedStatusCode"); const projectedStatusCode = protocolInteger(raw.projectedStatusCode, "projectedStatusCode"); @@ -109,6 +118,9 @@ const normalizeProtocolLifecycle = (raw: RawProtocolLifecycle): TransactionProto decisionId: protocolDecisionId(raw.decisionId, raw.decisionActive), decisionActive: raw.decisionActive, evaluatedAt: protocolInteger(raw.evaluatedAt, "evaluatedAt"), + ...(raw.executionGeneration === undefined ? {} : { + executionGeneration: protocolGeneration(raw.executionGeneration), + }), }; }; @@ -369,6 +381,7 @@ export const transactionActions = (client: GenLayerClient, public decisionId: lifecycle.latestDecision.decisionId, decisionActive: lifecycle.decisionActive, evaluatedAt: lifecycle.resolution.evaluatedAt, + executionGeneration: lifecycle.executionGeneration, }); }, }, @@ -513,58 +526,56 @@ export const transactionActions = (client: GenLayerClient, public } as unknown as GenLayerRawTransaction; return decodeTransaction(transaction); }, - /** Returns transaction IDs of child transactions created from emitted messages. */ - getTriggeredTransactionIds: async ({hash}: {hash: TransactionHash}): Promise => { + /** + * Returns canonical child IDs committed for this parent, including deferred delivery. + * This is historical creation evidence, not child completion or current-generation status. + * Pass the deployment block as fromBlock to avoid scanning earlier chain history. + */ + getTriggeredTransactionIds: async ({hash, fromBlock = 0n}: { + hash: TransactionHash; + fromBlock?: bigint; + }): Promise => { if (client.chain.isStudio) { const tx = await client.getTransaction({hash}); return ((tx as any).triggered_transactions ?? []) as TransactionHash[]; } - - const tx = await transactionActions(client, publicClient).getTransaction({hash}); - const proposalBlock = BigInt(tx.readStateBlockRange?.proposalBlock ?? "0"); - if (proposalBlock === BigInt(0)) return []; - - const scanRange = BigInt(10_000); - const latestBlock = await publicClient.getBlockNumber(); - const toBlock = proposalBlock + scanRange < latestBlock ? proposalBlock + scanRange : latestBlock; - - const consensusAddress = client.chain.consensusMainContract?.address as Address; - const internalMessageProcessedTopic = keccak256(stringToBytes("InternalMessageProcessed(bytes32,address,address)")); - const transactionAcceptedTopic = keccak256(stringToBytes("TransactionAccepted(bytes32)")); - const transactionFinalizedTopic = keccak256(stringToBytes("TransactionFinalized(bytes32)")); - - // InternalMessageProcessed indexes the child transaction ID, not its - // parent. Find the EVM transactions that decided the parent first, then - // inspect their receipts for the child-message events emitted alongside - // that decision. The event is emitted by MessagePayments, not - // ConsensusMain, so the decision receipt is the authoritative boundary - // and the event emitter address must not be restricted here. - const decisionLogs = await publicClient.getLogs({ - address: consensusAddress, - event: undefined, - fromBlock: proposalBlock, - toBlock, - topics: [[transactionAcceptedTopic, transactionFinalizedTopic], hash], - } as any); - - const decisionTransactionHashes = [ - ...new Set(decisionLogs.map(log => log.transactionHash).filter(Boolean)), - ]; - const receipts = await Promise.all( - decisionTransactionHashes.map(transactionHash => - publicClient.getTransactionReceipt({hash: transactionHash!}), - ), - ); - return [ - ...new Set( - receipts.flatMap(receipt => - receipt.logs - .filter(log => log.topics[0] === internalMessageProcessedTopic) - .map(log => log.topics[1] as TransactionHash) - .filter(Boolean), - ), - ), - ]; + if (fromBlock < 0n) throw new Error("fromBlock must be nonnegative"); + const consensusDataAddress = client.chain.consensusDataContract?.address as Address; + if (!consensusDataAddress || consensusDataAddress === zeroAddress) { + throw new Error("ConsensusData contract is not configured for this chain"); + } + const blockNumber = await publicClient.getBlockNumber(); + const addressManager = resolvedAddress("AddressManager", await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "addressManager", + blockNumber, + }) as Address); + const messages = resolvedAddress("Messages", await publicClient.readContract({ + address: addressManager, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["Messages"], + blockNumber, + }) as Address); + const children = new Set(); + // Bound each RPC range, not the total history: terminal delivery may occur + // long after the parent's decision, and re-reveals can move proposalBlock. + for (let start = fromBlock; start <= blockNumber; start += 10_000n) { + const end = start + 9_999n; + const logs = await publicClient.getLogs({ + address: messages, + event: parseAbiItem("event InternalMessageEffectCommitted(bytes32 indexed txId, bytes32 indexed logicalOccurrence, bytes32 indexed generationEffectId, bytes32 childTxId)"), + args: {txId: hash}, + fromBlock: start, + toBlock: end < blockNumber ? end : blockNumber, + strict: true, + }); + for (const log of logs) { + if (log.args.childTxId) children.add(log.args.childTxId as TransactionHash); + } + } + return [...children]; }, /** Fetches the full execution trace including return data, stdout, stderr, and GenVM logs. */ debugTraceTransaction: async ({hash, round = 0}: {hash: TransactionHash; round?: number}): Promise => { diff --git a/src/types/staking.ts b/src/types/staking.ts index 0a32106..e358843 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -25,7 +25,7 @@ export interface ValidatorView { dShares: bigint; vDeposit: bigint; vWithdrawal: bigint; - live: boolean; + hasUnclaimedRewards: boolean; } export interface ValidatorIdentity { @@ -55,7 +55,7 @@ export interface ValidatorInfo { vWithdrawal: string; vWithdrawalRaw: bigint; ePrimed: bigint; - live: boolean; + hasUnclaimedRewards: boolean; banned: boolean; bannedEpoch?: bigint; needsPriming: boolean; diff --git a/src/types/transactions.ts b/src/types/transactions.ts index e33e32c..de4f7e1 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -293,6 +293,8 @@ export type TransactionProtocolLifecycle = { resolutionSourceCode: number; decisionId: string | null; decisionActive: boolean; + /** Current producer generation as an exact decimal string; absent on older Studio APIs. */ + executionGeneration?: string; evaluatedAt: number; }; diff --git a/tests/consumer-producer-abi.test.ts b/tests/consumer-producer-abi.test.ts new file mode 100644 index 0000000..86a3b16 --- /dev/null +++ b/tests/consumer-producer-abi.test.ts @@ -0,0 +1,47 @@ +import {describe, expect, it} from "vitest"; +import {decodeFunctionResult, encodeAbiParameters, type AbiParameter} from "viem"; +import producer from "./fixtures/consensus-consumer-abi.json"; +import {STAKING_ABI} from "../src/abi/staking"; +import {CONSENSUS_DATA_TRAIN_ABI} from "../src/abi/consensusTrain"; + +const layout = (parameters: readonly any[]): unknown => parameters.map(p => ({ + type: p.type, + ...(p.components ? {components: p.components.map((c: any) => ({name: c.name, layout: layout([c])}))} : {}), +})); +const stakingRead = (name: string) => (STAKING_ABI as readonly any[]).find( + item => item.name === name && item.stateMutability === "view", +)!; + +describe(`consumer ABI at consensus ${producer.revision}`, () => { + it("matches the producer validator views and all claim/commit readers", () => { + for (const name of ["validatorView", "validatorViewPrimed", "validatorViewPrePrimed"]) { + expect(layout(stakingRead(name).outputs)).toEqual(layout(producer.validatorView.outputs)); + } + const [claim, commit] = producer.delegatorDeposit.outputs; + for (const name of ["delegatorDeposit", "delegatorWithdrawal"]) { + expect(layout(stakingRead(name).outputs)).toEqual(layout([claim, commit])); + } + for (const name of ["validatorDeposit", "validatorWithdrawal", "delegatorDepositByEpoch", + "delegatorWithdrawalByEpoch", "validatorDepositByEpoch", "validatorWithdrawalByEpoch"]) { + expect(layout([stakingRead(name).outputs.at(-1)])).toEqual(layout([commit])); + } + }); + + it("decodes producer claim and commit amounts above uint120 without truncating", () => { + const amount = (1n << 200n) + 7n; + const encoded = encodeAbiParameters(producer.delegatorDeposit.outputs as AbiParameter[], [ + {quantity: amount, offset: amount - 1n, commit: 3n}, + {input: amount, output: amount, outstanding: amount, epoch: 4n, + linkToNextCommit: 0n, priced: true, fragmented: false}, + ]); + const [claim, commit] = decodeFunctionResult({abi: STAKING_ABI, functionName: "delegatorDeposit", data: encoded}); + expect(claim.quantity).toBe(amount); + expect(claim.offset).toBe(amount - 1n); + expect(commit.outstanding).toBe(amount); + }); + + it("matches the producer lifecycle including its independent execution generation", () => { + const fn = CONSENSUS_DATA_TRAIN_ABI.find(item => item.type === "function" && item.name === "getTransactionLifecycle") as any; + expect(layout(fn.outputs)).toEqual(layout(producer.getTransactionLifecycle.outputs)); + }); +}); diff --git a/tests/fixtures/consensus-consumer-abi.json b/tests/fixtures/consensus-consumer-abi.json new file mode 100644 index 0000000..775195e --- /dev/null +++ b/tests/fixtures/consensus-consumer-abi.json @@ -0,0 +1,459 @@ +{ + "repository": "genlayerlabs/genlayer-consensus", + "revision": "c4749a42095bcfb9de69bef4f4fd5e9a6a2f86f2", + "validatorView": { + "inputs": [ + { + "internalType": "address", + "name": "_validator", + "type": "address" + } + ], + "name": "validatorView", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "eBanned", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "ePrimed", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "vStake", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "vShares", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "dStake", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "dShares", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "vDeposit", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "vWithdrawal", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "hasUnclaimedRewards", + "type": "bool" + } + ], + "internalType": "struct IGenLayerStaking.ValidatorView", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + "delegatorDeposit": { + "inputs": [ + { + "internalType": "address", + "name": "_delegator", + "type": "address" + }, + { + "internalType": "address", + "name": "_validator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_index", + "type": "uint256" + } + ], + "name": "delegatorDeposit", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "quantity", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "offset", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "commit", + "type": "uint256" + } + ], + "internalType": "struct IGenLayerStaking.Claim", + "name": "claim_", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "input", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "output", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "outstanding", + "type": "uint256" + }, + { + "internalType": "uint64", + "name": "epoch", + "type": "uint64" + }, + { + "internalType": "uint56", + "name": "linkToNextCommit", + "type": "uint56" + }, + { + "internalType": "bool", + "name": "priced", + "type": "bool" + }, + { + "internalType": "bool", + "name": "fragmented", + "type": "bool" + } + ], + "internalType": "struct IGenLayerStaking.Commit", + "name": "commit_", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + "getTransactionLifecycle": { + "inputs": [ + { + "internalType": "bytes32", + "name": "_txId", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "_timestamp", + "type": "uint256" + } + ], + "name": "getTransactionLifecycle", + "outputs": [ + { + "components": [ + { + "internalType": "enum ITransactions.TransactionStatus", + "name": "storedStatus", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "txId", + "type": "bytes32" + }, + { + "internalType": "enum ITransactions.TransactionStatus", + "name": "storedStatus", + "type": "uint8" + }, + { + "internalType": "enum ITransactions.TransactionStatus", + "name": "projectedStatus", + "type": "uint8" + }, + { + "internalType": "enum IIdlenessPhase.ResolutionAction", + "name": "action", + "type": "uint8" + }, + { + "internalType": "enum ITransactions.ResultType", + "name": "result", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "resultHash", + "type": "bytes32" + }, + { + "internalType": "enum ITransactionManager.ResolutionSource", + "name": "source", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "sourceRound", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sourceGeneration", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "sourceRoundContextHash", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "roundPlanHash", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "resultRound", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "resultGeneration", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "basisDecisionId", + "type": "uint256" + }, + { + "internalType": "enum ITransactionManager.ResolutionContext", + "name": "context", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "attemptId", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "boundaryAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "evaluatedAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "snapshotBlock", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "decisionWindow", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "appealDeadline", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "materializesDecision", + "type": "bool" + }, + { + "internalType": "bool", + "name": "actionOutcomeDeterministic", + "type": "bool" + }, + { + "internalType": "bool", + "name": "nonCurrentEvaluation", + "type": "bool" + } + ], + "internalType": "struct IIdlenessPhase.ResolutionPlan", + "name": "resolution", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bool", + "name": "exists", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "decisionId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "basisDecisionId", + "type": "uint256" + }, + { + "internalType": "enum ITransactionManager.ResolutionContext", + "name": "context", + "type": "uint8" + }, + { + "internalType": "enum ITransactionManager.ResolutionSource", + "name": "source", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "sourceAttemptId", + "type": "bytes32" + }, + { + "internalType": "enum ITransactions.TransactionStatus", + "name": "sourceStatus", + "type": "uint8" + }, + { + "internalType": "enum ITransactions.TransactionStatus", + "name": "status", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "sourceRound", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sourceGeneration", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "sourceRoundContextHash", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "roundPlanHash", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "resultRound", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "resultGeneration", + "type": "uint256" + }, + { + "internalType": "enum ITransactions.ResultType", + "name": "result", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "resultHash", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "effectiveAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "materializedAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "appealDeadline", + "type": "uint256" + } + ], + "internalType": "struct ITransactionManager.DecisionRecord", + "name": "latestDecision", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "decisionActive", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "executionGeneration", + "type": "uint256" + } + ], + "internalType": "struct ConsensusData.TransactionLifecycle", + "name": "lifecycle", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + "internalMessageEffectCommitted": { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "txId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "logicalOccurrence", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "generationEffectId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "childTxId", + "type": "bytes32" + } + ], + "name": "InternalMessageEffectCommitted", + "type": "event" + } +} diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 0ed9207..f152ecc 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -102,7 +102,7 @@ describe(`Testnet ${name} - Staking (read-only)`, () => { expect(info.owner).toMatch(/^0x[0-9a-fA-F]{40}$/); expect(info.operator).toMatch(/^0x[0-9a-fA-F]{40}$/); expect(info.vStakeRaw).toBeTypeOf("bigint"); - expect(typeof info.live).toBe("boolean"); + expect(typeof info.hasUnclaimedRewards).toBe("boolean"); expect(typeof info.banned).toBe("boolean"); expect(typeof info.needsPriming).toBe("boolean"); expect(Array.isArray(info.pendingDeposits)).toBe(true); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index 0ad8136..02836c7 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -260,7 +260,7 @@ describe("stakingActions provider lane (Address-only)", () => { }); describe("stakingActions validator reads", () => { - it("uses the authoritative ban predicate instead of a stale nonzero ban epoch", async () => { + it.each([true, false])("keeps rewards=%s separate from the authoritative ban predicate", async hasUnclaimedRewards => { const readContract = vi.fn().mockImplementation(async ({functionName}: any) => { if (functionName === "isValidator") return true; if (functionName === "validatorView") { @@ -273,7 +273,7 @@ describe("stakingActions validator reads", () => { dShares: 0n, vDeposit: 0n, vWithdrawal: 0n, - live: true, + hasUnclaimedRewards, }; } if (functionName === "owner") return ACCOUNT_ADDRESS; @@ -290,6 +290,8 @@ describe("stakingActions validator reads", () => { const info = await actions.getValidatorInfo(VALIDATOR_WALLET_ADDRESS); + expect(info.hasUnclaimedRewards).toBe(hasUnclaimedRewards); + expect(info).not.toHaveProperty("live"); expect(info.banned).toBe(false); expect(info.bannedEpoch).toBeUndefined(); expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/tests/staking-commit-layout.test.ts b/tests/staking-commit-layout.test.ts index fc72fa5..71091ae 100644 --- a/tests/staking-commit-layout.test.ts +++ b/tests/staking-commit-layout.test.ts @@ -4,14 +4,14 @@ import {STAKING_ABI} from "../src/abi/staking"; const STAKE = 100000000000000000n; const CLAIM_COMPONENTS = [ - {name: "quantity", type: "uint120"}, - {name: "offset", type: "uint120"}, + {name: "quantity", type: "uint256"}, + {name: "offset", type: "uint256"}, {name: "commit", type: "uint256"}, ]; const COMMIT_COMPONENTS = [ {name: "input", type: "uint256"}, {name: "output", type: "uint256"}, - {name: "outstanding", type: "uint120"}, + {name: "outstanding", type: "uint256"}, {name: "epoch", type: "uint64"}, {name: "linkToNextCommit", type: "uint56"}, {name: "priced", type: "bool"}, @@ -26,7 +26,7 @@ const VALIDATOR_VIEW_COMPONENTS = [ {name: "dShares", type: "uint256"}, {name: "vDeposit", type: "uint256"}, {name: "vWithdrawal", type: "uint256"}, - {name: "live", type: "bool"}, + {name: "hasUnclaimedRewards", type: "bool"}, ]; const view = (name: string) => @@ -94,7 +94,7 @@ describe("staking ValidatorView train layout", () => { } }); - it("decodes raw train validator bytes without removed tree links", () => { + it.each([true, false])("decodes raw train validator bytes with rewards=%s", hasUnclaimedRewards => { const output = view("validatorView").outputs[0]; const encoded = encodeAbiParameters([output], [{ eBanned: 1n, @@ -105,7 +105,7 @@ describe("staking ValidatorView train layout", () => { dShares: 6n, vDeposit: 7n, vWithdrawal: 8n, - live: true, + hasUnclaimedRewards, }] as any); const decoded = decodeFunctionResult({ @@ -119,8 +119,9 @@ describe("staking ValidatorView train layout", () => { ePrimed: 2n, vStake: 3n, dStake: 5n, - live: true, + hasUnclaimedRewards, }); + expect(decoded).not.toHaveProperty("live"); expect(decoded).not.toHaveProperty("left"); expect(decoded).not.toHaveProperty("right"); expect(decoded).not.toHaveProperty("parent"); diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 548c57e..d8ce14f 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -22,16 +22,16 @@ import { import { receiptActions, transactionActions, isSuccessful } from "../src/transactions/actions"; import { decodeTransaction, simplifyTransactionReceipt } from "../src/transactions/decoders"; import { localnet } from "../src/chains/localnet"; -import type { GenLayerRawTransaction } from "../src/types/transactions"; +import type { GenLayerRawTransaction, TransactionHash } from "../src/types/transactions"; import { decodeFunctionResult, encodeFunctionResult, - keccak256, MethodNotFoundRpcError, - stringToBytes, } from "viem"; import {CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, CONSENSUS_DATA_TRAIN_ABI} from "../src/abi/consensusTrain"; +import producer from "./fixtures/consensus-consumer-abi.json"; + const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -164,7 +164,7 @@ describe("transaction enum maps", () => { const encoded = encodeFunctionResult({ abi: CONSENSUS_DATA_TRAIN_ABI, functionName: "getTransactionLifecycle", - result: [5, resolution, latestDecision, true] as any, + result: [5, resolution, latestDecision, true, 9n] as any, }); const decoded = decodeFunctionResult({ @@ -177,6 +177,7 @@ describe("transaction enum maps", () => { expect(decoded.resolution.projectedStatus).toBe(6); expect(decoded.resolution.action).toBe(6); expect(decoded.decisionActive).toBe(true); + expect(decoded.executionGeneration).toBe(9n); }); }); @@ -680,52 +681,49 @@ const trainReadContract = ({ }); describe("getTriggeredTransactionIds", () => { - it("finds child transaction IDs in the parent decision receipt", async () => { - const parentHash = ("0x" + "11".repeat(32)) as any; - const childHash = ("0x" + "22".repeat(32)) as any; - const decisionHash = ("0x" + "33".repeat(32)) as any; - const consensusAddress = "0x0000000000000000000000000000000000000010"; - const messagePaymentsAddress = "0x0000000000000000000000000000000000000020"; - const internalMessageTopic = keccak256( - stringToBytes("InternalMessageProcessed(bytes32,address,address)"), - ); - const readContract = trainReadContract({ - light: makeLightTx({ - readStateBlockRange: {activationBlock: 0n, processingBlock: 0n, proposalBlock: 100n}, - }), - }); - const getLogs = vi.fn().mockResolvedValue([{transactionHash: decisionHash}]); - const getTransactionReceipt = vi.fn().mockResolvedValue({ - logs: [ - { - address: messagePaymentsAddress, - topics: [internalMessageTopic, childHash], - }, - ], - }); - const publicClient = { - readContract, - getBlock: vi.fn().mockResolvedValue({number: 150n, timestamp: 1000n}), - getBlockNumber: vi.fn().mockResolvedValue(200n), - getLogs, - getTransactionReceipt, - } as any; - const client = { - chain: { - isStudio: false, - consensusDataContract: {address: consensusAddress, abi: []}, - consensusMainContract: {address: consensusAddress, abi: []}, - }, - } as any; + const parentHash = `0x${"11".repeat(32)}` as TransactionHash; + const childHash = `0x${"22".repeat(32)}` as const; + const laterChild = `0x${"33".repeat(32)}` as const; + const messages = "0x0000000000000000000000000000000000000020"; + const client = {chain: {isStudio: false, consensusDataContract: { + address: "0x0000000000000000000000000000000000000010", + }}} as any; + + it("finds canonical children across deferred delivery and re-reveals without truncating history", async () => { + const getLogs = vi.fn() + .mockResolvedValueOnce([{args: {childTxId: childHash}}]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{args: {childTxId: childHash}}, {args: {childTxId: laterChild}}]); + const readContract = vi.fn().mockResolvedValueOnce(client.chain.consensusDataContract.address) + .mockResolvedValueOnce(messages); + const publicClient = {readContract, getBlockNumber: vi.fn().mockResolvedValue(25_000n), getLogs} as any; + const result = await transactionActions(client, publicClient).getTriggeredTransactionIds({hash: parentHash}); + expect(result).toEqual([childHash, laterChild]); + expect(getLogs.mock.calls.map(([args]) => [args.fromBlock, args.toBlock])).toEqual([ + [0n, 9_999n], [10_000n, 19_999n], [20_000n, 25_000n], + ]); + for (const [args] of getLogs.mock.calls) { + expect(args).toMatchObject({address: messages, args: {txId: parentHash}, strict: true}); + expect(args.event.name).toBe(producer.internalMessageEffectCommitted.name); + const eventLayout = (inputs: readonly any[]) => inputs.map(({name, type, indexed}) => ({name, type, indexed: !!indexed})); + expect(eventLayout(args.event.inputs)).toEqual(eventLayout(producer.internalMessageEffectCommitted.inputs)); + } + expect(readContract.mock.calls[1][0]).toMatchObject({args: ["Messages"], blockNumber: 25_000n}); + }); - const result = await transactionActions(client, publicClient).getTriggeredTransactionIds({ - hash: parentHash, - }); + it("supports a deployment block lower bound and propagates RPC failures", async () => { + const getLogs = vi.fn().mockRejectedValue(new Error("RPC unavailable")); + const publicClient = {readContract: vi.fn().mockResolvedValue(messages), + getBlockNumber: vi.fn().mockResolvedValue(25_000n), getLogs} as any; + await expect(transactionActions(client, publicClient).getTriggeredTransactionIds({ + hash: parentHash, fromBlock: 24_000n, + })).rejects.toThrow("RPC unavailable"); + expect(getLogs.mock.calls[0][0]).toMatchObject({fromBlock: 24_000n, toBlock: 25_000n}); + }); - expect(result).toEqual([childHash]); - expect(getLogs.mock.calls[0][0].topics[1]).toBe(parentHash); - expect(Array.isArray(getLogs.mock.calls[0][0].topics[0])).toBe(true); - expect(getTransactionReceipt).toHaveBeenCalledWith({hash: decisionHash}); + it("preserves Studio's canonical triggered_transactions response", async () => { + const studio = {chain: {isStudio: true}, getTransaction: vi.fn().mockResolvedValue({triggered_transactions: [childHash]})} as any; + expect(await transactionActions(studio, {} as any).getTriggeredTransactionIds({hash: parentHash})).toEqual([childHash]); }); }); @@ -795,6 +793,7 @@ describe("getTransaction train lifecycle", () => { storedStatus: 5, resolution: {projectedStatus: 6, action: 6, source: 11, evaluatedAt: 500n}, latestDecision: {decisionId: 7n}, + executionGeneration: 9007199254740993n, decisionActive: true, }), }); @@ -827,6 +826,7 @@ describe("getTransaction train lifecycle", () => { resolutionSource: "SelectionDepleted", resolutionSourceCode: 11, decisionId: "7", + executionGeneration: "9007199254740993", decisionActive: true, evaluatedAt: 500, }); From 4dabdf296f5594a6dcce2c2af6e7d53168ab4397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Mon, 28 Sep 2026 17:22:12 +0100 Subject: [PATCH 25/25] fix(fees): price receipt floor with wrapper and terminal reserve (#223) Read FeeManager receiptWrapperBytes and quote propose-receipt gas with the effective gasPrice. Plain eth_call can expose tx.gasprice=0, which makes the Nitro rollup gas model revert. Avoid the zero-price messageFeeParamsBudgetFloor view and use the configured receipt wrapper plus Consensus #1692 terminal reserve. Recognize chain-derived pricing even when its plain-call quote is zero. Mirror the same floor in Studio when its RPC omits the authoritative value. Validation: 202 Vitest tests and typecheck, production build, changed-file ESLint, and diff whitespace check. --- src/contracts/actions.ts | 120 ++++++++++++++++++++++---------- tests/contracts-actions.test.ts | 117 +++++++++++++++++++++++++++---- 2 files changed, 187 insertions(+), 50 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 23af27e..624e6ae 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -30,7 +30,7 @@ import { ConsensusRoundData, ConsensusLastRoundData, } from "@/types"; -import {fromHex, toHex, zeroAddress, encodeFunctionData, PublicClient, parseEventLogs, type Abi} from "viem"; +import {fromHex, toHex, zeroAddress, encodeFunctionData, decodeFunctionResult, PublicClient, parseEventLogs, type Abi} from "viem"; import {toJsonSafeDeep, b64ToArray, arrayToB64} from "@/utils/jsonifier"; import { CALL_KEY_WILDCARD, @@ -1242,11 +1242,25 @@ const FEE_MANAGER_CALCULATE_ROUND_FEES_ABI = [ }, { type: "function", - name: "messageFeeParamsBudgetFloor", + name: "receiptWrapperBytes", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}], }, + { + type: "function", + name: "useChainDerivedReceiptPrice", + stateMutability: "view", + inputs: [], + outputs: [{name: "", type: "bool"}], + }, + { + type: "function", + name: "estimateProposeReceiptGas", + stateMutability: "view", + inputs: [{name: "_receiptBytes", type: "uint256"}], + outputs: [{name: "", type: "uint256"}], + }, { type: "function", name: "calculateRoundFees", @@ -1372,11 +1386,9 @@ const DEFAULT_BOOTLOADER_OVERHEAD = 60_000n; const DEFAULT_GAS_PER_CHANGED_SLOT = 1_000n; const DEFAULT_CALLDATA_GAS_PER_BYTE = 16n; const DEFAULT_FIXED_PROPOSE_RECEIPT_GAS = 210_000n; -const DEFAULT_FIXED_MESSAGE_REVEAL_GAS = 100_000n; -// ConsensusHelpers.MIN_RECEIPT_BYTES — smallest receipt payload the on-chain budget floor prices. -const DEFAULT_MIN_RECEIPT_BYTES = 512n; -const DEFAULT_MESSAGE_REVEAL_LENGTH_SLOTS = 32n; -const DEFAULT_NONDET_OUTPUT_LENGTH_BYTES = 32n; +const DEFAULT_RECEIPT_WRAPPER_BYTES = 1_024n; +// FeeProfileHelpers.MIN_TERMINAL_OUTPUT_BYTES: startup reserve for an empty failure envelope. +const MIN_TERMINAL_OUTPUT_BYTES = 64n; const TRANSACTION_GAS_HEADROOM_BPS = 20_000n; const DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM = 10_000n; const VALIDATORS_PER_ROUND = [ @@ -1418,6 +1430,30 @@ const bigintFromUnknown = (value: unknown, fieldName: string, fallback = 0n): bi throw new Error(`${fieldName} is not an integer value.`); }; +const proposeReceiptBudgetFloor = ({ + receiptGasPrice, + receiptWrapperBytes, + fixedProposeReceiptGas, + intrinsicGas, + bootloaderOverhead, + calldataGasPerByte, + gasPerChangedSlot, +}: { + receiptGasPrice: bigint; + receiptWrapperBytes: bigint; + fixedProposeReceiptGas: bigint; + intrinsicGas: bigint; + bootloaderOverhead: bigint; + calldataGasPerByte: bigint; + gasPerChangedSlot: bigint; +}): bigint => receiptGasPrice * ( + fixedProposeReceiptGas + + intrinsicGas + + bootloaderOverhead + + ((receiptWrapperBytes + MIN_TERMINAL_OUTPUT_BYTES) * calldataGasPerByte) + + (DEFAULT_RECEIPT_SLOTS_CHANGED * gasPerChangedSlot) +); + const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { const configRecord = config && typeof config === "object" && !Array.isArray(config) ? config as Record @@ -1462,23 +1498,21 @@ const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { "policy.fixedProposeReceiptGas", DEFAULT_FIXED_PROPOSE_RECEIPT_GAS, ); - const fixedMessageRevealGas = bigintFromUnknown( - policyRecord.fixedMessageRevealGas, - "policy.fixedMessageRevealGas", - DEFAULT_FIXED_MESSAGE_REVEAL_GAS, + const receiptWrapperBytes = bigintFromUnknown( + policyRecord.receiptWrapperBytes, + "policy.receiptWrapperBytes", + DEFAULT_RECEIPT_WRAPPER_BYTES, ); const executionBudgetFloor = policyRecord.messageFeeParamsBudgetFloor == null - ? receiptGasPrice * ( - fixedProposeReceiptGas + - intrinsicGas + - bootloaderOverhead + - (DEFAULT_RECEIPT_SLOTS_CHANGED * gasPerChangedSlot) + - fixedMessageRevealGas + - intrinsicGas + - bootloaderOverhead + - (DEFAULT_MESSAGE_REVEAL_LENGTH_SLOTS * gasPerChangedSlot) + - (DEFAULT_NONDET_OUTPUT_LENGTH_BYTES * calldataGasPerByte) - ) + ? proposeReceiptBudgetFloor({ + receiptGasPrice, + receiptWrapperBytes, + fixedProposeReceiptGas, + intrinsicGas, + bootloaderOverhead, + calldataGasPerByte, + gasPerChangedSlot, + }) : bigintFromUnknown( policyRecord.messageFeeParamsBudgetFloor, "policy.messageFeeParamsBudgetFloor", @@ -1513,38 +1547,48 @@ const readCurrentFeePolicy = async ( const address = client.chain.feeManagerContract.address as `0x${string}`; const abi = FEE_MANAGER_CALCULATE_ROUND_FEES_ABI as any; - const [genPerTimeUnit, storageUnitPrice, quotedReceiptGasPrice, executionBudgetFloor] = await Promise.all([ + const [genPerTimeUnit, storageUnitPrice, quotedReceiptGasPrice, receiptWrapperBytes, useChainDerivedReceiptPrice] = await Promise.all([ publicClient.readContract({address, abi, functionName: "GENPerTimeUnit", args: []}) as Promise, publicClient.readContract({address, abi, functionName: "storageUnitPrice", args: []}) as Promise, publicClient.readContract({address, abi, functionName: "quoteGasPrice", args: []}) as Promise, - publicClient.readContract({address, abi, functionName: "messageFeeParamsBudgetFloor", args: []}) as Promise, + publicClient.readContract({address, abi, functionName: "receiptWrapperBytes", args: []}) as Promise, + publicClient.readContract({address, abi, functionName: "useChainDerivedReceiptPrice", args: []}) as Promise, ]); - const enabled = genPerTimeUnit > 0n || storageUnitPrice > 0n || quotedReceiptGasPrice > 0n; + const enabled = useChainDerivedReceiptPrice || genPerTimeUnit > 0n || storageUnitPrice > 0n || quotedReceiptGasPrice > 0n; const networkReceiptGasPrice = enabled ? await publicClient.getGasPrice() : 0n; const receiptGasPrice = maxBigint(quotedReceiptGasPrice, networkReceiptGasPrice); if (enabled && receiptGasPrice === 0n) { throw new Error("receipt gas price quoted as zero; refusing to build a zero price cap"); } - // messageFeeParamsBudgetFloor() multiplies by quoteGasPrice() on-chain, which reads - // tx.gasprice ~ 0 under a plain eth_call — so the view can report a zero floor on - // chain-derived networks while the real submission-time floor is non-zero. Recompute - // the floor locally at the effective receipt price (FeeManager.estimateProposeReceiptGas - // at ConsensusHelpers.MIN_RECEIPT_BYTES) and take the max. - const localExecutionBudgetFloor = receiptGasPrice * ( - DEFAULT_FIXED_PROPOSE_RECEIPT_GAS + - DEFAULT_INTRINSIC_GAS + - DEFAULT_BOOTLOADER_OVERHEAD + - (DEFAULT_MIN_RECEIPT_BYTES * DEFAULT_CALLDATA_GAS_PER_BYTE) + - (DEFAULT_RECEIPT_SLOTS_CHANGED * DEFAULT_GAS_PER_CHANGED_SLOT) - ); + // A plain eth_call may have tx.gasprice == 0. On Nitro the fee model rejects + // that value, so quote gas with the effective price in the call context. + // The contract then applies its live governance and Nitro gas parameters. + let executionBudgetFloor = 0n; + if (receiptGasPrice > 0n) { + const data = encodeFunctionData({ + abi, + functionName: "estimateProposeReceiptGas", + args: [receiptWrapperBytes + MIN_TERMINAL_OUTPUT_BYTES], + }); + const result = await publicClient.call({to: address, data, gasPrice: receiptGasPrice, batch: false}); + if (!result.data) { + throw new Error("FeeManager.estimateProposeReceiptGas returned no data."); + } + const proposeReceiptGas = decodeFunctionResult({ + abi, + functionName: "estimateProposeReceiptGas", + data: result.data, + }) as bigint; + executionBudgetFloor = receiptGasPrice * proposeReceiptGas; + } return { enabled, genPerTimeUnit, storageUnitPrice, receiptGasPrice, - executionBudgetFloor: maxBigint(executionBudgetFloor, localExecutionBudgetFloor), + executionBudgetFloor, // Live networks quote through FeeManager.calculateRoundFees; this field is // only consumed by Studio's local mirror. timeUnitOverlayBps: 0n, diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 604f0f7..55607aa 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -279,6 +279,13 @@ const setupWriteContractHarness = ({ return {actions, estimateTransactionGas, client, signTransaction, publicClient}; }; +const mockNitroReceiptGasCall = (estimatedGas: bigint) => vi.fn().mockImplementation( + async ({gasPrice}: {gasPrice?: bigint}) => { + if (!gasPrice) throw new Error("InvalidRollupGasPrice"); + return {data: toHex(estimatedGas, {size: 32})}; + }, +); + const setupDeveloperNftHarness = ({ readContractMock, signTransactionMock, @@ -1028,11 +1035,13 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 20n; if (functionName === "quoteGasPrice") return 30n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return false; if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(1n), + call: mockNitroReceiptGasCall(315_408n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -1042,12 +1051,10 @@ describe("contractActions addTransaction ABI compatibility", () => { const fees = await actions.estimateTransactionFees({totalMessageFees: 5n}); - // Effective floor = max(on-chain view (1,234 — reads ~0-priced quoteGasPrice under - // eth_call), local recompute at the effective receipt price). Local formula pins - // FeeManager.estimateProposeReceiptGas(MIN_RECEIPT_BYTES=512): - // 210,000 + 21,000 + 60,000 + 512*16 + 7*1,000 = 306,192 gas. - const expectedLocalFloor = 30n * (210_000n + 21_000n + 60_000n + 512n * 16n + 7n * 1_000n); - expect(expectedLocalFloor).toBe(30n * 306_192n); + // Consensus prices the live 1,024-byte wrapper plus 64 terminal-output bytes: + // 210,000 + 21,000 + 60,000 + 1,088*16 + 7*1,000 = 315,408 gas. + const expectedLocalFloor = 30n * (210_000n + 21_000n + 60_000n + 1_088n * 16n + 7n * 1_000n); + expect(expectedLocalFloor).toBe(30n * 315_408n); expect(fees.policy).toEqual({ enabled: true, genPerTimeUnit: 10n, @@ -1061,19 +1068,33 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.receiptFeeMaxGasPrice).toBe(36n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_300_000n); expect(fees.feeValue).toBe(82n); + expect(publicClient.readContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: "receiptWrapperBytes", + })); + expect(publicClient.call).toHaveBeenCalledWith(expect.objectContaining({ + to: "0x00000000000000000000000000000000000000fe", + gasPrice: 30n, + batch: false, + })); + expect(publicClient.call.mock.calls[0][0].data.slice(-64)).toBe(toHex(1_088n, {size: 32}).slice(2)); + expect(publicClient.readContract).not.toHaveBeenCalledWith(expect.objectContaining({ + functionName: "messageFeeParamsBudgetFloor", + })); }); - it("uses the network gas price when FeeManager quotes a lower receipt gas price", async () => { + it("uses network gas price and FeeManager's configured receipt gas model", async () => { const publicClient = { readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 20n; if (functionName === "quoteGasPrice") return 0n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 2_048n; + if (functionName === "useChainDerivedReceiptPrice") return true; if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(25n), + call: mockNitroReceiptGasCall(400_000n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -1085,7 +1106,47 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(publicClient.getGasPrice).toHaveBeenCalledOnce(); expect(fees.policy.receiptGasPrice).toBe(25n); + expect(fees.policy.executionBudgetFloor).toBe(25n * 400_000n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(25n); + expect(publicClient.call).toHaveBeenCalledWith(expect.objectContaining({ + gasPrice: 25n, + batch: false, + })); + expect(publicClient.call.mock.calls[0][0].data.slice(-64)).toBe(toHex(2_112n, {size: 32}).slice(2)); + }); + + it("prices receipts when chain-derived quote is zero under eth_call", async () => { + const publicClient = { + readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "GENPerTimeUnit") return 0n; + if (functionName === "storageUnitPrice") return 0n; + if (functionName === "quoteGasPrice") return 0n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return true; + if (functionName === "calculateRoundFees") return 77n; + throw new Error(`unexpected readContract ${functionName}`); + }), + getGasPrice: vi.fn().mockResolvedValue(25n), + call: mockNitroReceiptGasCall(315_408n), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + const fees = await actions.estimateTransactionFees(); + + expect(fees.policy.enabled).toBe(true); + expect(fees.policy.receiptGasPrice).toBe(25n); + expect(fees.policy.executionBudgetFloor).toBe(25n * 315_408n); + expect(fees.feeValue).toBe(77n); + expect(publicClient.getGasPrice).toHaveBeenCalledOnce(); + expect(publicClient.call).toHaveBeenCalledWith(expect.objectContaining({gasPrice: 25n})); + const roundFeeCall = publicClient.readContract.mock.calls.find(([call]) => + call.functionName === "calculateRoundFees", + )?.[0]; + expect(roundFeeCall.args[0].executionBudgetPerRound).toBeGreaterThan(0n); }); it("throws instead of building a zero receipt gas price cap when policy is enabled", async () => { @@ -1094,7 +1155,8 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 0n; if (functionName === "quoteGasPrice") return 0n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return true; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(0n), @@ -1116,7 +1178,8 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 0n; if (functionName === "storageUnitPrice") return 0n; if (functionName === "quoteGasPrice") return 0n; - if (functionName === "messageFeeParamsBudgetFloor") return 0n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return false; if (functionName === "calculateRoundFees") return 0n; throw new Error(`unexpected readContract ${functionName}`); }), @@ -1141,11 +1204,13 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 20n; if (functionName === "quoteGasPrice") return 30n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return true; if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(1n), + call: mockNitroReceiptGasCall(315_408n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -1207,10 +1272,38 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(10n); expect(fees.distribution.storageFeeMaxGasPrice).toBe(20n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(30n); + expect(fees.policy.executionBudgetFloor).toBe(30n * 315_408n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); expect(fees.feeValue).toBe(12_000_044_000n); }); + it("uses Studio's configured wrapper when its fee floor is absent", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + receiptWrapperBytes: "2048", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFees(); + + expect(fees.policy.executionBudgetFloor).toBe(30n * 331_792n); + }); + it("prefers Studio's exposed message fee budget floor over local fallback math", async () => { const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "sim_getFeeConfig") {