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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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(), };