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/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/chains-drift.yml b/.github/workflows/chains-drift.yml index a97289e..c01af3f 100644 --- a/.github/workflows/chains-drift.yml +++ b/.github/workflows/chains-drift.yml @@ -3,10 +3,14 @@ name: Chains Drift Check on: pull_request: branches: - - main + - v1 + - v2-dev + - v2 push: branches: - - main + - 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/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/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/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/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 < **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/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..b23d64d 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", @@ -11,14 +11,16 @@ "LICENSE" ], "scripts": { - "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", + "prepare": "npm run build", + "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", "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 < 0) { diff --git a/src/abi/index.ts b/src/abi/index.ts index 890c1ec..dc26821 100644 --- a/src/abi/index.ts +++ b/src/abi/index.ts @@ -4,3 +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, CONSENSUS_ADDRESS_MANAGER_ABI, VESTING_ABI, VESTING_FACTORY_ABI} from "./vesting"; +export {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/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 new file mode 100644 index 0000000..f874813 --- /dev/null +++ b/src/abi/vesting.ts @@ -0,0 +1,247 @@ +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: "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: []}, + {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/accounts/actions.ts b/src/accounts/actions.ts index 25ba5fa..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) { @@ -36,10 +37,68 @@ 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); + }, + /** + * 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/chains/testnetAsimov.ts b/src/chains/testnetAsimov.ts index fcdd1bc..b0b089a 100644 --- a/src/chains/testnetAsimov.ts +++ b/src/chains/testnetAsimov.ts @@ -411,6 +411,31 @@ const CONSENSUS_MAIN_CONTRACT = { "name": "NewTransaction", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "txId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "firstAnnouncedAt", + "type": "uint256" + } + ], + "name": "NewTransactionAlreadyAnnounced", + "type": "event" + }, { "anonymous": false, "inputs": [ diff --git a/src/chains/testnetBradbury.ts b/src/chains/testnetBradbury.ts index 68ec7cc..f6b0e00 100644 --- a/src/chains/testnetBradbury.ts +++ b/src/chains/testnetBradbury.ts @@ -411,6 +411,31 @@ const CONSENSUS_MAIN_CONTRACT = { "name": "NewTransaction", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "txId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "firstAnnouncedAt", + "type": "uint256" + } + ], + "name": "NewTransactionAlreadyAnnounced", + "type": "event" + }, { "anonymous": false, "inputs": [ diff --git a/src/client/client.ts b/src/client/client.ts index 757a662..5e46852 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"; @@ -147,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, @@ -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/contracts/actions.ts b/src/contracts/actions.ts index 13caeea..93a8f35 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -1,39 +1,165 @@ 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, Address, TransactionHashVariant, + TransactionFeeOptions, + TransactionFeeEstimate, + FeeEstimateOptions, + SimulationFeeEstimateOptions, + SimulationFeeUsage, + WriteFeeEstimateOptions, + FeePolicyQuote, + BigNumberish, + FeesDistribution, + FeesDistributionInput, + MessageFeeAllocationInput, + MessageType, + SimulateWriteContractResult, + StudioExecutionFeeReport, + StudioFeeAccounting, } from "@/types"; import {fromHex, toHex, zeroAddress, encodeFunctionData, PublicClient, parseEventLogs, type Abi} from "viem"; import {TransactionHash} from "@/types/transactions"; import {toJsonSafeDeep, b64ToArray, arrayToB64} from "@/utils/jsonifier"; +import { + CALL_KEY_WILDCARD, + createFeesDistribution, + MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, + normalizeMessageFeeAllocations, + normalizeTransactionFees, + NormalizedTransactionFees, +} from "@/transactions/fees"; + +const prefixHex = (hex: string): `0x${string}` => { + 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); + } + 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; } - throw new Error(`Unexpected gen_call response: ${JSON.stringify(result)}`); + 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 +265,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 +300,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 +353,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 +368,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 +406,8 @@ export const contractActions = (client: GenLayerClient, publicCli kwargs?: Map | {[key: string]: CalldataEncodable}; leaderOnly?: boolean; consensusMaxRotations?: number; + validUntil?: BigNumberish; + fees?: TransactionFeeOptions; }) => { const { account, @@ -240,6 +416,8 @@ export const contractActions = (client: GenLayerClient, publicCli kwargs, leaderOnly = false, consensusMaxRotations = client.chain.defaultConsensusMaxRotations, + validUntil, + fees, } = args; const data = [ @@ -249,21 +427,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; @@ -339,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; @@ -346,30 +799,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 +816,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 +951,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 +964,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,72 +1091,850 @@ 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 Array.isArray(addTransactionFunction?.inputs) ? addTransactionFunction.inputs.length : 0; + return inputs.length >= 6 ? "v6" : "v5"; }; -const _encodeAddTransactionData = ({ +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 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, - senderAccount, - recipient, - data, - consensusMaxRotations = client.chain.defaultConsensusMaxRotations, + publicClient, }: { client: GenLayerClient; - senderAccount?: Account; - recipient?: `0x${string}`; - data?: `0x${string}`; - consensusMaxRotations?: number; -}): {primaryEncodedData: `0x${string}`; fallbackEncodedData: `0x${string}`} => { - const validatedSenderAccount = validateAccount(senderAccount); - - const addTransactionArgs: [ - Address, - `0x${string}` | undefined, - number, - number | undefined, - `0x${string}` | undefined, - ] = [ - validatedSenderAccount.address, - recipient, - client.chain.defaultNumberOfInitialValidators, - consensusMaxRotations, - data, - ]; + 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 encodedDataV5 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V5 as any, - functionName: "addTransaction", - args: addTransactionArgs, - }); + const functionName = hasAbiFunction(consensusMainContract.abi, "getAddressManager") + ? "getAddressManager" + : hasAbiFunction(consensusMainContract.abi, "addressManager") + ? "addressManager" + : undefined; - // `_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); + if (!functionName) { + throw new Error("NFTMinter address resolution not supported on this chain (missing AddressManager getter)."); + } - const encodedDataV6 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V6 as any, - functionName: "addTransaction", - args: [...addTransactionArgs, validUntil], - }); + const addressManagerAddress = await publicClient.readContract({ + address: consensusMainContract.address as `0x${string}`, + abi: consensusMainContract.abi as Abi, + functionName, + args: [], + }) as Address; - if (getAddTransactionInputCount(client.chain.consensusMainContract?.abi) >= 6) { - return { - primaryEncodedData: encodedDataV6, - fallbackEncodedData: encodedDataV5, - }; + if (addressManagerAddress.toLowerCase() === zeroAddress) { + throw new Error("NFTMinter address resolution failed: AddressManager is zero."); } - return { - primaryEncodedData: encodedDataV5, - fallbackEncodedData: encodedDataV6, - }; + return addressManagerAddress as `0x${string}`; }; -const _encodeSubmitAppealData = ({ +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 => ( + 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; +// 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; +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; +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; +}; + +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, 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, + storageUnitPrice, + receiptGasPrice, + executionBudgetFloor: maxBigint(executionBudgetFloor, localExecutionBudgetFloor), + }; +}; + +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, + policy.receiptGasPrice * DEFAULT_TRANSACTION_EXECUTION_GAS, + ); +}; + +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( + policy.executionBudgetFloor, + 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 = ({ + client, + senderAccount, + recipient, + data, + consensusMaxRotations = client.chain.defaultConsensusMaxRotations, + validUntil, + userValue = 0n, + transactionFees, +}: { + client: GenLayerClient; + senderAccount?: Account; + recipient?: `0x${string}`; + data?: `0x${string}`; + consensusMaxRotations?: number; + 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}`, + number, + number, + `0x${string}`, + ] = [ + validatedSenderAccount.address, + txRecipient, + client.chain.defaultNumberOfInitialValidators, + consensusMaxRotations, + txCalldata, + ]; + + const buildVariant = (abiVersion: AddTransactionAbiVersion): EncodedTransactionVariant => { + if (abiVersion === "fees") { + const params = { + sender: validatedSenderAccount.address, + recipient: txRecipient, + numOfInitialValidators: BigInt(client.chain.defaultNumberOfInitialValidators), + maxRotations: BigInt(consensusMaxRotations), + validUntil: txValidUntil, + saltNonce: 0n, + userValue, + feesDistribution: transactionFees.distribution, + txCalldata, + messageAllocations: transactionFees.messageAllocations, + }; + + return { + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + functionName: "addTransaction", + args: [params], + }), + value: userValue + feeValue, + }; + } + + if (abiVersion === "v6") { + return { + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_V6 as any, + functionName: "addTransaction", + args: [...addTransactionArgs, txValidUntil], + }), + value: userValue, + }; + } + + return { + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_V5 as any, + functionName: "addTransaction", + args: addTransactionArgs, + }), + value: userValue, + }; + }; + + if (transactionFees.requiresFeeAwareTransaction) { + return [buildVariant("fees")]; + } + + 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 = ({ client, txId, }: { @@ -577,12 +1948,161 @@ 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)], + }); +}; + +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. * 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 +2156,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 +2176,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 +2260,76 @@ 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", + "0x305e533c": "BudgetTooLow", + "0xa70732ee": "RollupBudgetBelowFloor", + "0x632be5a1": "FeeValueMustBeNonZero", + }; + + 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 +2348,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 +2359,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 +2412,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 +2437,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 +2454,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..7e0b13d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,21 @@ export { } from "./transactions/decoders"; 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 { + 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 649db10..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]; @@ -131,25 +139,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") { @@ -208,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}); @@ -255,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 @@ -316,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, @@ -326,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"; @@ -421,12 +641,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 @@ -453,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}, ]; @@ -470,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}, ]; @@ -501,12 +722,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(); @@ -526,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, @@ -550,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/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..96b92ea 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, @@ -117,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 transactionAcceptedTopic = keccak256(stringToBytes("TransactionAccepted(bytes32)")); + const transactionFinalizedTopic = keccak256(stringToBytes("TransactionFinalized(bytes32)")); - const logs = await publicClient.getLogs({ + // 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/src/transactions/fees.ts b/src/transactions/fees.ts new file mode 100644 index 0000000..9aa35b9 --- /dev/null +++ b/src/transactions/fees.ts @@ -0,0 +1,233 @@ +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; +// 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; + +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..449975c 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -1,13 +1,30 @@ -import {Transport, Client, PublicActions, WalletActions} from "viem"; -import {GenLayerTransaction, TransactionHash, TransactionStatus, TransactionHashVariant, DebugTraceResult} from "./transactions"; +import {Transport, Client, PublicActions, WalletActions, TransactionReceipt} from "viem"; +import { + GenLayerTransaction, + TransactionHash, + TransactionStatus, + TransactionReceiptWaitUntil, + 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"; -import {ContractSchema} from "./contracts"; +import {ContractSchema, DeveloperNft} from "./contracts"; 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]} @@ -21,7 +38,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 +78,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,9 +109,12 @@ 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; + transfer: (args: {to: Address; value: bigint}) => Promise; estimateTransactionGas: (transactionParams: { from?: Address; to: Address; @@ -90,9 +123,12 @@ 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; }) => Promise; getContractSchema: (address: Address) => Promise; getContractSchemaForCode: (contractCode: string | Uint8Array) => Promise; @@ -109,11 +145,38 @@ 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}`; 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 +186,9 @@ export type GenLayerClient = Omit< txIds: readonly `0x${string}`[]; }) => Promise<`0x${string}`>; getMinAppealBond: (args: {txId: `0x${string}`}) => Promise; - } & StakingActions; + getCurrentFeePolicy: () => Promise; + estimateFeesDistribution: (args?: FeeEstimateOptions) => Promise; + estimateTransactionFees: (args?: FeeEstimateOptions) => Promise; + estimateTransactionFeesFromSimulation: (args: SimulationFeeEstimateOptions) => Promise; + estimateTransactionFeesForWrite: (args: WriteFeeEstimateOptions) => Promise; + } & StakingActions & VestingActions; 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/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/staking.ts b/src/types/staking.ts index e9ebc79..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; @@ -61,6 +62,10 @@ export interface ValidatorInfo { banned: boolean; bannedEpoch?: bigint; needsPriming: boolean; + currentEpoch: bigint; + validatorMinStake: string; + validatorMinStakeRaw: bigint; + belowMin: boolean; identity?: ValidatorIdentity; pendingDeposits: PendingDeposit[]; pendingWithdrawals: PendingWithdrawal[]; @@ -151,7 +156,7 @@ export interface DelegatorJoinResult extends StakingTransactionResult { export interface ValidatorJoinOptions { amount: bigint | string; - operator?: Address; + registration: OperatorRegistrationProof; } export interface ValidatorDepositOptions { @@ -177,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; @@ -207,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/transactions.ts b/src/types/transactions.ts index db3cb4a..f4ad76e 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", @@ -19,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 { @@ -35,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 = { @@ -52,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 = { @@ -69,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 = [ @@ -88,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 { @@ -151,6 +157,273 @@ export enum TransactionHashVariant { LATEST_NONFINAL = "latest-nonfinal", } +export type TransactionReceiptWaitUntil = "decided" | "finalized"; + +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/src/types/vesting.ts b/src/types/vesting.ts new file mode 100644 index 0000000..5e32ee2 --- /dev/null +++ b/src/types/vesting.ts @@ -0,0 +1,250 @@ +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"; + +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 type OperatorPublicKey = readonly [bigint, bigint]; + +export interface OperatorRegistrationContext { + registrar: Address; + owner: Address; + chainId: bigint; +} + +export interface OperatorRegistrationProof { + operator: Address; + operatorPubKey: OperatorPublicKey; + possessionProof: Hex; +} + +export interface CreateOperatorRegistrationOptions extends OperatorRegistrationContext { + privateKey: Hex; +} + +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; + registration: OperatorRegistrationProof; + 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
; + getVestingValidatorRegistrationContext: (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..86020f6 --- /dev/null +++ b/src/vesting/actions.ts @@ -0,0 +1,676 @@ +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"; +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 { + 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; + } + } + + 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 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 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 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, + 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 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], + }); + const result = await executeWrite({to: options.vesting as ViemAddress, data}); + + return { + ...result, + vesting: options.vesting, + 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"), + getVestingValidatorRegistrationContext, + 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/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 new file mode 100644 index 0000000..df40501 --- /dev/null +++ b/src/vesting/validator.ts @@ -0,0 +1,22 @@ +export type { + VestingValidatorClaimOptions, + VestingValidatorDepositOptions, + VestingValidatorExitOptions, + VestingValidatorInitiateOperatorTransferOptions, + VestingValidatorJoinOptions, + VestingValidatorJoinResult, + VestingValidatorSetIdentityOptions, + VestingValidatorWalletOptions, + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "@/types/vesting"; + +export { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorAddressFromPublicKey, + operatorPossessionMessage, + verifyOperatorRegistration, +} from "./operatorRegistration"; diff --git a/support/ci/ACTIVE_DEV_BRANCH b/support/ci/ACTIVE_DEV_BRANCH new file mode 100644 index 0000000..4e9fd46 --- /dev/null +++ b/support/ci/ACTIVE_DEV_BRANCH @@ -0,0 +1 @@ +v2-dev 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/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); + }); +}); diff --git a/tests/client.test-d.ts b/tests/client.test-d.ts index 72661b5..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..."); @@ -42,6 +50,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 876a20a..67791f2 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1,10 +1,25 @@ 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 {NFT_MINTER_ABI} from "../src/abi/nftMinter"; +import { + CALL_KEY_DEPLOY, + CALL_KEY_UNNAMED, + CALL_KEY_WILDCARD, + DEPLOY_CALL_KEY, + deployCallKey, + deriveExternalMessageCallKey, + deriveInternalMessageCallKey, + encodeExternalMessageFeeParams, + encodeInternalMessageFeeParams, + MessageType, +} from "../src/transactions/fees"; 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"; @@ -22,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: [ @@ -81,6 +106,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 +193,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 +242,7 @@ const setupWriteContractHarness = ({ const client = { chain: { id: 61_127, + isStudio, defaultNumberOfInitialValidators: 5, defaultConsensusMaxRotations: 3, consensusMainContract: { @@ -113,6 +250,12 @@ const setupWriteContractHarness = ({ abi: [...initialAbi], bytecode: "0x", }, + feeManagerContract: feeManagerAddress + ? { + address: feeManagerAddress, + abi: [], + } + : null, }, account: { address: SENDER_ADDRESS, @@ -122,20 +265,387 @@ const setupWriteContractHarness = ({ initializeConsensusSmartContract: vi.fn().mockResolvedValue(undefined), getCurrentNonce: vi.fn().mockResolvedValue(0n), estimateTransactionGas, + request: requestMock ?? vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "eth_gasPrice") { + return "0x1"; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + + const actions = contractActions(client as any, publicClient as any); + + 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, {} as any); + const actions = contractActions(client as any, publicClient as any); - return {actions, estimateTransactionGas, client, signTransaction}; + 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({ + 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")}`, + ); + + // 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_UNNAMED); + }); + it("encodes addTransaction with 5 args when ABI has 5 inputs", async () => { const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V5, @@ -170,6 +680,816 @@ 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}`); + }), + getGasPrice: vi.fn().mockResolvedValue(1n), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + 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: expectedLocalFloor, + }); + expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(12n); + expect(fees.distribution.storageFeeMaxGasPrice).toBe(24n); + expect(fees.distribution.receiptFeeMaxGasPrice).toBe(36n); + expect(fees.distribution.executionBudgetPerRound).toBe(3_000_300_000n); + 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}) => { + 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}`); + }), + getGasPrice: vi.fn().mockResolvedValue(1n), + }; + 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(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 () => { + 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(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 () => { + 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, + }); + + 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: expectedExecutionBudgetPerRound, + genvmMessageFeeConsumed: 5n, + messageFeeBudget: 10n, + messageFeeConsumed: 5n, + messageFeeRefunded: 0n, + internalDeclaredBudget: 5n, + externalMessageReserved: 0n, + externalMessageReimbursed: 0n, + externalMessageRemainder: 0n, + recommendedTotalMessageFees: 6n, + }); + expect(fees.distribution.executionBudgetPerRound).toBe(expectedExecutionBudgetPerRound); + expect(fees.distribution.totalMessageFees).toBe(6n); + 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 () => { + 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: "3000000000", + 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: "3000011110", + }, + }; + } + 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("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?.messageFeeBudget).toBe(110n); + expect(fees.observed?.messageFeeConsumed).toBe(50n); + 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(3_000_011_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(3_000_311_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 +1627,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", @@ -417,6 +1737,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"; @@ -471,6 +1809,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 +1844,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 +1919,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 +2091,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(); 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 new file mode 100644 index 0000000..6465852 --- /dev/null +++ b/tests/staking-actions.test.ts @@ -0,0 +1,254 @@ +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 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}, + 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"); + 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: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), + }; + 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: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), + }; + 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 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: [registration.operatorPubKey, registration.possessionProof], + }); + + // 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"), + }); + }); + + 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)", () => { + 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", registration: await makeRegistration()}); + + // 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/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/transactions.test.ts b/tests/transactions.test.ts index fd1e82b..6c343c0 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -1,9 +1,19 @@ 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"; +import {keccak256, stringToBytes} from "viem"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -33,7 +43,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 +59,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 @@ -263,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(); @@ -270,7 +414,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", () => { diff --git a/tests/vesting-actions.test.ts b/tests/vesting-actions.test.ts new file mode 100644 index 0000000..05f9f9a --- /dev/null +++ b/tests/vesting-actions.test.ts @@ -0,0 +1,492 @@ +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 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 = () => ({ + 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()), + getChainId: vi.fn().mockResolvedValue(1), + 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, 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, + 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: [registration.operatorPubKey, registration.possessionProof, parseEther("3")], + }); + expect(result).toMatchObject({ + vesting: VESTING_ADDRESS, + operator: registration.operator, + 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"), + }); + }); +}); + +// 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()), + getChainId: vi.fn().mockResolvedValue(1), + 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(); + }); +}); 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