diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16015f4d..290de30f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,9 @@ jobs: - name: P-256 verification baseline run: go test ./internal/api -run '^$' -bench '^BenchmarkLabP256TransactionVerification$' -benchtime=1s -count=1 + - name: V2 finalized batch scaling sample + run: go test ./internal/v2/node -run '^$' -bench '^BenchmarkV2FinalizedBatch32$' -benchtime=1x -count=1 -timeout=120s + wallet: name: Wallet build runs-on: ubuntu-latest diff --git a/.github/workflows/v2-dependency-lock.yml b/.github/workflows/v2-dependency-lock.yml new file mode 100644 index 00000000..dbe20d36 --- /dev/null +++ b/.github/workflows/v2-dependency-lock.yml @@ -0,0 +1,46 @@ +name: V2 Dependency Lock + +on: + pull_request: + paths: + - go.mod + - internal/v2/** + - .github/workflows/v2-dependency-lock.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + lock: + name: Resolve production v2 dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + + - name: Resolve pinned dependencies + run: | + go get go.starlark.net@5395d018f003e2a08bfbca6dcb2562acee700f62 + go get github.com/tetratelabs/wazero@v1.11.0 + go get github.com/klauspost/reedsolomon@latest + go get github.com/libp2p/go-libp2p@latest + go get github.com/libp2p/go-libp2p-pubsub@latest + echo '--- go.mod ---' + cat go.mod + echo '--- go.sum ---' + cat go.sum + + - name: Upload dependency lock + uses: actions/upload-artifact@v4 + with: + name: v2-go-dependency-lock + path: | + go.mod + go.sum diff --git a/.github/workflows/v2-format-write.yml b/.github/workflows/v2-format-write.yml new file mode 100644 index 00000000..4cc4aa55 --- /dev/null +++ b/.github/workflows/v2-format-write.yml @@ -0,0 +1,62 @@ +name: V2 Exact Format + +on: + push: + branches: + - chatgpt/protocol-v2-foundation + paths: + - internal/v2/compute/messages.go + - internal/v2/provider/service.go + - internal/v2/provider/service_test.go + - internal/v2/tx/transaction.go + - internal/v2/node/runtime.go + - internal/v2/node/commit_preflight.go + - internal/v2/node/commit_preflight_test.go + - internal/v2/node/recovery_state.go + - internal/v2/node/recovery_state_test.go + - internal/v2/node/global_commit_journal.go + - internal/v2/node/global_commit_journal_test.go + - internal/v2/node/global_commit_recovery.go + - internal/v2/node/global_commit_recovery_test.go + - internal/v2/economics/finalized_collector_checkpoint.go + - internal/v2/economics/finalized_collector_checkpoint_test.go + - internal/v2/economics/shadow_epoch_engine_checkpoint.go + - internal/v2/economics/shadow_epoch_engine_checkpoint_test.go + - internal/v2/economics/epoch_parse.go + - internal/v2/economics/shadow_epoch_pending_checkpoint.go + - internal/v2/economics/shadow_epoch_pending_checkpoint_test.go + - internal/v2/compute/work.go + - internal/v2/compute/work_test.go + - .github/workflows/v2-format-write.yml + +permissions: + contents: write + +jobs: + format: + if: github.repository == 'the-code-learner/Zephyr-Chain' && github.ref == 'refs/heads/chatgpt/protocol-v2-foundation' + runs-on: ubuntu-latest + steps: + - name: Checkout v2 branch + uses: actions/checkout@v6 + with: + ref: chatgpt/protocol-v2-foundation + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - name: Format exact files + run: gofmt -w internal/v2/compute/messages.go internal/v2/provider/service.go internal/v2/provider/service_test.go internal/v2/tx/transaction.go internal/v2/node/runtime.go internal/v2/node/commit_preflight.go internal/v2/node/commit_preflight_test.go internal/v2/node/recovery_state.go internal/v2/node/recovery_state_test.go internal/v2/node/global_commit_journal.go internal/v2/node/global_commit_journal_test.go internal/v2/node/global_commit_recovery.go internal/v2/node/global_commit_recovery_test.go internal/v2/economics/finalized_collector_checkpoint.go internal/v2/economics/finalized_collector_checkpoint_test.go internal/v2/economics/shadow_epoch_engine_checkpoint.go internal/v2/economics/shadow_epoch_engine_checkpoint_test.go internal/v2/economics/epoch_parse.go internal/v2/economics/shadow_epoch_pending_checkpoint.go internal/v2/economics/shadow_epoch_pending_checkpoint_test.go internal/v2/compute/work.go internal/v2/compute/work_test.go + - name: Commit formatting if needed + run: | + if git diff --quiet; then + echo 'Formatting already exact.' + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add internal/v2/compute/messages.go internal/v2/provider/service.go internal/v2/provider/service_test.go internal/v2/tx/transaction.go internal/v2/node/runtime.go internal/v2/node/commit_preflight.go internal/v2/node/commit_preflight_test.go internal/v2/node/recovery_state.go internal/v2/node/recovery_state_test.go internal/v2/node/global_commit_journal.go internal/v2/node/global_commit_journal_test.go internal/v2/node/global_commit_recovery.go internal/v2/node/global_commit_recovery_test.go internal/v2/economics/finalized_collector_checkpoint.go internal/v2/economics/finalized_collector_checkpoint_test.go internal/v2/economics/shadow_epoch_engine_checkpoint.go internal/v2/economics/shadow_epoch_engine_checkpoint_test.go internal/v2/economics/epoch_parse.go internal/v2/economics/shadow_epoch_pending_checkpoint.go internal/v2/economics/shadow_epoch_pending_checkpoint_test.go internal/v2/compute/work.go internal/v2/compute/work_test.go + git commit -m 'gofmt Zephyr v2 recovery and journal tranche' + git push origin HEAD:chatgpt/protocol-v2-foundation diff --git a/.github/workflows/v2-lab.yml b/.github/workflows/v2-lab.yml new file mode 100644 index 00000000..13dc41e5 --- /dev/null +++ b/.github/workflows/v2-lab.yml @@ -0,0 +1,37 @@ +name: V2 Lab + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: v2-lab-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + v2-lab: + name: Protocol v2 conformance + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + + - name: V2 seven-validator conformance + run: go test ./internal/v2/lab ./internal/v2/node ./internal/v2/consensus ./internal/v2/state ./internal/v2/worldstate -count=1 -timeout=120s + + - name: V2 partition stress + run: go test ./internal/v2/lab -run '^TestV2Lab(FourThreePartitionStallsThenHeals|FiveTwoPartitionFinalizesQuorumSideAndMinorityCatchesUp)$' -count=10 -timeout=120s + + - name: V2 finalized batch scaling + run: go test ./internal/v2/node -run '^$' -bench '^BenchmarkV2FinalizedBatch32$' -benchtime=1x -count=1 -timeout=120s diff --git a/.github/workflows/v2-lock-write.yml b/.github/workflows/v2-lock-write.yml new file mode 100644 index 00000000..e1372028 --- /dev/null +++ b/.github/workflows/v2-lock-write.yml @@ -0,0 +1,47 @@ +name: V2 Exact Dependency Lock + +on: + push: + branches: + - chatgpt/protocol-v2-foundation + paths: + - go.mod + - internal/v2/contracts/** + - internal/v2/da/** + - internal/v2/network/** + - .github/workflows/v2-lock-write.yml + +permissions: + contents: write + +jobs: + lock: + if: github.repository == 'the-code-learner/Zephyr-Chain' && github.ref == 'refs/heads/chatgpt/protocol-v2-foundation' + runs-on: ubuntu-latest + steps: + - name: Checkout v2 branch + uses: actions/checkout@v6 + with: + ref: chatgpt/protocol-v2-foundation + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + + - name: Generate exact module lock + run: go mod tidy + + - name: Commit generated lock if needed + run: | + if git diff --quiet -- go.mod go.sum; then + echo 'Dependency lock already exact.' + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add go.mod go.sum + git commit -m 'lock exact Zephyr v2 Go dependency graph' + git push origin HEAD:chatgpt/protocol-v2-foundation diff --git a/LICENSE b/LICENSE index 9b1ec2fa..261eeb9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2025 the-code-learner - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..04dfdd12 --- /dev/null +++ b/NOTICE @@ -0,0 +1,9 @@ +Zephyr Chain + +Copyright 2026 the-code-learner + +Original project by the-code-learner. + +This attribution notice must be preserved in redistributed +copies and derivative works in accordance with the +Apache License 2.0. diff --git a/README.md b/README.md index 48122c8d..472b77b3 100644 --- a/README.md +++ b/README.md @@ -1,424 +1,277 @@ # Zephyr Chain -Zephyr Chain is an early-stage blockchain node and wallet stack focused on a production path toward validator-driven consensus, deterministic WASM execution, and a confidential compute marketplace. - -The long-term product vision lives in [Zaphyr-chain_manifesto.md](./Zaphyr-chain_manifesto.md). Application and use-case framing lives in [docs/applications.md](./docs/applications.md). Academic paper materials are maintained locally under `paper/` and kept private via `.gitignore`. This `README` stays practical: what works now, what changed in the latest iteration, and what comes next. - -## Current Status - -Implemented today: - -- Go HTTP node entrypoint in `cmd/node` -- DPoS election primitives and tests in `internal/dpos` -- transaction envelope validation in `internal/tx` -- durable accounts, mempool, committed blocks, atomic restart-safe state persistence, and snapshot restore in `internal/ledger` -- durable validator-set snapshots with versioning, proposer scheduling, and quorum summaries in `internal/ledger` -- durable consensus round state with restart-safe height, round, and round-start tracking in `internal/ledger` -- durable signed consensus proposals, validator votes, and quorum certificates in `internal/ledger` -- self-contained consensus proposals that carry the full candidate transaction body plus deterministic template fields -- automatic single-node block production plus manual dev block production -- optional proposer-schedule enforcement for block production when a validator set and local validator address are configured -- optional certificate-gated local block commit and remote block import when consensus enforcement is enabled -- certificate-gated local commit can replay a stored certified proposal body even when the local mempool no longer has that candidate -- optional consensus automation for scheduled self-proposal, validator auto-vote, timeout-driven round advance, proposer rotation, and certified proposer auto-commit on the current devnet path -- transport-backed peer replication in `internal/api` with the current implementation running over HTTP -- signed validator transport-identity proofs in status responses and peer verification views when a validator private key is configured -- optional strict peer-admission enforcement and peer-to-validator binding on the current HTTP transport -- peer status tracking, peer admission state, per-peer sync telemetry, block fetch by height, block import, snapshot-based catch-up, and consensus artifact replication for admitted peers -- consensus visibility endpoints for status, validator snapshots, active round inspection, proposer schedule inspection, latest consensus artifacts, and next-block template preview -- operator-facing observability endpoints for readiness, alerts, SLO summaries, alert-rule exports, recording-rule exports, dashboard bundles, Grafana dashboard export, JSON metrics, Prometheus metrics, and structured logs -- Vue wallet in `apps/wallet` -- wallet account generation, encrypted import/export, passphrase-protected local key storage, local signing, account inspection, faucet funding, and transaction broadcast - -Implemented in this iteration: - -- failed outgoing proposal, vote, and block dissemination now lands in durable `replication_blocked` peer incidents with artifact-specific `reason` labels and transport-oriented error-code rollups -- `GET /v1/alerts` now separates general peer-sync degradation from targeted `peer_import_blocked`, `peer_admission_blocked`, and `peer_replication_blocked` warnings built from durable peer incident rollups -- `GET /v1/alert-rules` and `GET /v1/alert-rules/prometheus` now export matching peer-import, peer-admission, and peer-replication diagnostic rules for scrape-based monitoring stacks -- `GET /metrics` now exports retained peer incident counts and latest observation timestamps per peer with the latest state, reason, and error-code labels attached for scrape-based drill-down -- `GET /v1/recording-rules` and `GET /v1/recording-rules/prometheus` now export a canonical per-peer incident-pressure rollup so downstream dashboards can reuse that peer view without rewriting PromQL -- `GET /v1/dashboards` and `GET /v1/dashboards/grafana` now expose peer incident reason panels plus a per-peer incident pressure panel built on that recording rule alongside state and error-code rollups so dissemination failures are visible in the peer-sync bundle -- `GET /v1/alerts` now also derives a targeted aggregate `peer_snapshot_restored` warning plus repair-path-specific `peer_snapshot_restore_divergence`, `peer_snapshot_restore_import_repair`, and `peer_snapshot_restore_fetch_fallback` warnings from retained `snapshot_restored` incidents so snapshot-based peer repair shows up as a first-class operator signal without hiding the exact repair path -- `GET /v1/alert-rules` and `GET /v1/alert-rules/prometheus` now additionally export `ZephyrPeerSnapshotRestore` so the snapshot-repair signal can be promoted into Prometheus-based alerting without custom rule authoring -- `GET /v1/recording-rules` and `GET /v1/recording-rules/prometheus` now additionally export the canonical peer-sync rollup `zephyr:peer_sync:snapshot_restore_pressure` so dashboards can track retained snapshot-repair pressure without re-deriving it from raw incident metrics -- `GET /v1/dashboards` and `GET /v1/dashboards/grafana` now add a `Peer snapshot restore pressure` stat to the peer-sync bundle so divergence, import-repair, and fetch-fallback recovery remain visible next to state, reason, error-code, and per-peer incident pressure -- `GET /v1/alert-rules` and `GET /v1/alert-rules/prometheus` now additionally export repair-path-specific `ZephyrPeerSnapshotRestoreDivergence`, `ZephyrPeerSnapshotRestoreImportRepair`, and `ZephyrPeerSnapshotRestoreFetchFallback` rules so downstream alerting can split divergence repair from import-repair and fetch-fallback paths without custom PromQL -- `GET /v1/recording-rules` and `GET /v1/recording-rules/prometheus` now additionally export the canonical peer-sync rollup `zephyr:peer_sync:snapshot_restore_pressure_by_reason` so dashboards and alert managers can reuse the filtered divergence, import-repair, and fetch-fallback series directly -- `GET /v1/dashboards` and `GET /v1/dashboards/grafana` now add a `Peer snapshot restore reasons` panel to the peer-sync bundle so repair-path pressure is graphed explicitly instead of only being inferred from the wider incident-reason panel -- `GET /v1/recording-rules`, `GET /v1/recording-rules/prometheus`, and `GET /v1/dashboards` now preserve the compatibility aggregate `peer_snapshot_restored` code alongside the split `peer_snapshot_restore_*` codes in related-alert metadata so downstream rollups and dashboard bundles can pivot incrementally without losing the older aggregate signal -- `GET /v1/metrics` now carries horizon-aware `peerSyncSummary.horizons` views for `5m`, `15m`, `1h`, `6h`, and `24h`, `GET /metrics` mirrors them through `zephyr_peer_sync_horizon_*` gauges, and `GET /v1/health` now includes recent peer incident occurrence and affected-peer horizon detail so operators can tell whether retained peer pressure is fresh or lingering -- `GET /v1/recording-rules` and `GET /v1/recording-rules/prometheus` now additionally export `zephyr:peer_sync:incident_pressure_by_horizon`, and `GET /v1/dashboards` plus `GET /v1/dashboards/grafana` now add a `Peer incident pressure horizons` panel so the peer-sync bundle can compare recent retained pressure across short and long windows without rebuilding PromQL -- `GET /metrics` now also exports per-peer snapshot-repair metadata through `zephyr_peer_snapshot_restore_last_height`, `zephyr_peer_snapshot_restore_last_observed_at_seconds`, and `zephyr_peer_snapshot_restore_age_seconds`, while `GET /v1/recording-rules` plus `GET /v1/recording-rules/prometheus` now add `zephyr:peer_sync:snapshot_restore_pressure_by_peer` and `zephyr:peer_sync:snapshot_restore_age_by_peer` for canonical per-peer repair pressure and repair age drill-down -- `GET /v1/dashboards` and `GET /v1/dashboards/grafana` now add `Peer snapshot restore pressure by peer`, `Peer snapshot restore heights`, and `Peer snapshot restore age` so operators can correlate repair pressure, retained repair reason, the latest restored height, and whether a restore is fresh before drilling into `/v1/peers` for block-hash and `recentIncidents` detail -- `GET /v1/metrics` now includes `chainThroughput` totals plus rolling `1m`, `5m`, and `15m` windows for committed blocks, committed transactions, average transactions per block, and recent TPS baselining, along with a `settlementThroughput` view carrying raw queue-drain lag, latest commit age, warn or fail thresholds, active alert metadata, normalized warn or fail utilization ratios, recent 1m, 5m, and 15m backlog-drain estimates, per-estimate warn utilization ratios, and an explicit `peakDrainEstimate` summary for the current worst-case backlog projection -- `GET /metrics` now also exports committed-block, committed-transaction, latest-block-interval, and rolling throughput gauges plus settlement queue-drain gauges such as `zephyr_settlement_queue_drain_lag_seconds`, `zephyr_settlement_queue_drain_threshold_seconds`, `zephyr_settlement_queue_drain_utilization_ratio`, `zephyr_settlement_estimated_queue_drain_warn_utilization_ratio`, `zephyr_settlement_estimated_queue_drain_warn_utilization_ratio_max`, `zephyr_settlement_estimated_queue_drain_seconds`, and `zephyr_settlement_estimated_queue_drain_seconds_max` so Prometheus-style monitoring can track both recent TPS and settlement pressure without re-deriving chain history -- `GET /v1/recording-rules` and `GET /v1/recording-rules/prometheus` now additionally export canonical `zephyr:chain:transactions_per_second_1m`, `zephyr:chain:transactions_per_second_5m`, and `zephyr:chain:transactions_per_second_15m` rollups for dashboard reuse -- `GET /v1/dashboards` and `GET /v1/dashboards/grafana` now add a `Recent transaction throughput` overview panel built on those rollups so operators can baseline recent TPS alongside readiness and peer health -- `GET /v1/health` now includes a `settlement_throughput` check that watches queued transaction drain against the configured automatic block interval when block production is enabled and carries the current worst-case drain forecast in its detail -- `GET /v1/alerts` and `GET /v1/slo` now derive `settlement_throughput_reduced`, `settlement_throughput_stalled`, and the `settlement_throughput` objective so slow or stalled queue drain becomes first-class operator evidence, with detail strings now carrying the current worst-case drain forecast when recent throughput baselines exist -- `GET /v1/alert-rules` and `GET /v1/alert-rules/prometheus` now export `ZephyrSettlementThroughputAtRisk` and `ZephyrSettlementThroughputStalled` so the same queue-drain signal can be promoted into Prometheus-based alerting -- `GET /v1/recording-rules` and `GET /v1/recording-rules/prometheus` now additionally export canonical `zephyr:settlement_throughput:at_risk` and `zephyr:settlement_throughput:breached` rollups plus normalized `zephyr:settlement_queue_drain:warn_utilization` and `zephyr:settlement_queue_drain:fail_utilization` series plus canonical `zephyr:settlement_queue_drain:estimate_seconds_1m`, `zephyr:settlement_queue_drain:estimate_seconds_5m`, `zephyr:settlement_queue_drain:estimate_seconds_15m`, and `zephyr:settlement_queue_drain:estimate_seconds_max` rollups and canonical `zephyr:settlement_queue_drain:estimate_warn_utilization_1m`, `zephyr:settlement_queue_drain:estimate_warn_utilization_5m`, `zephyr:settlement_queue_drain:estimate_warn_utilization_15m`, and `zephyr:settlement_queue_drain:estimate_warn_utilization_max` rollups for queue-drain dashboards and fleet summaries -- `GET /v1/dashboards` and `GET /v1/dashboards/grafana` now add a `Settlement throughput state` overview panel, a raw `Settlement queue-drain lag` panel, a normalized `Settlement queue-drain utilization` panel, an `Estimated queue-drain pressure` panel built on canonical warn-normalized drain-estimate recording rules, a `Worst-case estimated queue-drain pressure` stat built on the canonical max projected-pressure rollup, an `Estimated queue-drain time` panel built on canonical drain-estimate recording rules, and a `Worst-case estimated queue-drain time` stat built on the canonical max drain-estimate rollup so operators can see both absolute lag, threshold pressure, and recent backlog-drain expectations next to recent TPS baselines; on passive nodes these settlement-specific panels now stay visible in JSON with `disabledReason`, while Grafana export keeps only the enabled subset -- `GET /v1/peers` now backfills the latest import, snapshot-repair, and replication-failure telemetry from durable peer incidents so operator context survives restart before the next live sync pass -- successful local certified commits now record a durable `block_commit` consensus action so recovery history and action metrics cover the full proposer path from proposal and vote through commit -- focused tests now cover peer import, admission, replication, and snapshot-restore alerts, per-peer Prometheus incident metrics, restart-safe per-peer telemetry reconstruction, JSON and Prometheus throughput metrics including settlement alert metadata, normalized utilization ratios, raw settlement-lag gauges, recent backlog-drain estimates, the explicit peak drain-estimate summary, warn-normalized drain-estimate ratios, explicit max drain gauges, settlement health or alert or SLO detail enrichment with worst-case drain forecasts, the canonical max projected-pressure and max drain-estimate recording rules, throughput health or alert or SLO projections, alert-rule export, dashboard export, and durable `block_commit` history across the operator surfaces - -Planned but not implemented yet: - -- authenticated peer discovery and replay-safe transport over libp2p on top of the new HTTP admission and binding policy -- broader consensus recovery coverage plus richer dashboard packages, longer-horizon aggregation, and export adapters beyond the current local proposal, vote, block-commit, peer-import, snapshot-recovery, JSON metrics, Prometheus text export, alert-rule bundles, recording-rule bundles, dashboard bundles, Grafana dashboard export, derived readiness, alerts, SLO summaries, structured event logs, durable peer-sync history, and derived peer-sync summary surfaces -- on-chain staking and governance-driven validator updates instead of ad hoc election API writes -- deterministic WASM smart-contract runtime with native fee metering -- confidential compute marketplace for encrypted off-chain jobs paid in native tokens, with partitioned worker-lane scaling ahead of any full consensus-sharding step -- production observability, recovery tooling, and public testnet operations - -## Repository Layout - -- `cmd/node`: node process entrypoint and environment-based runtime configuration -- `internal/api`: HTTP handlers, peer replication, consensus surface, transport abstraction, sync loops, automation loop, and status endpoints -- `internal/consensus`: signed proposal and vote message primitives -- `internal/dpos`: candidate, vote, validator, and election service logic -- `internal/ledger`: persisted accounts, mempool entries, committed blocks, validator snapshots, round state, consensus artifacts, snapshots, and commit/import logic -- `internal/tx`: transaction envelope validation, address derivation, and signature verification -- `apps/wallet`: reference light wallet built with Vue 3, Vite, and Tailwind CSS -- `docs/`: architecture, API, usage, roadmap, and applications guides -- `paper/`: private local academic paper workspace, draft manuscript materials, and evaluation planning notes kept out of git via `.gitignore` -- `var/`: default local runtime state directory for the node, ignored by git - -## Prerequisites - -- Go 1.22 or newer -- Node.js -- npm - -PowerShell note: if your shell blocks `npm`, use `npm.cmd` instead. - -## Quick Start - -### 1. Run one node - -From the repository root, enable development-only endpoints only when you need the local faucet or manual block tools: - -```powershell -$env:ZEPHYR_ENABLE_DEV_ENDPOINTS="true" -go run ./cmd/node -``` +Zephyr Chain is an open-source blockchain project exploring a clean-break Protocol v2 designed around two equal engineering goals: -For a normal node process, leave `ZEPHYR_ENABLE_DEV_ENDPOINTS` unset or set it to `false`. The node process: - -- listens on `127.0.0.1:8080` by default; set `ZEPHYR_HTTP_ADDR` explicitly to bind another interface -- stores durable state in `var/node` -- produces blocks every `15s` when transactions are queued -- runs the consensus automation ticker every `1s`, but automation stays off until `ZEPHYR_ENABLE_CONSENSUS_AUTOMATION=true` -- uses a `5s` consensus round timeout once automation is enabled -- runs peer sync only if `ZEPHYR_PEERS` is configured -- exposes consensus status even before a validator set has been elected -- keeps `/v1/dev/*` unavailable unless `ZEPHYR_ENABLE_DEV_ENDPOINTS=true` is explicitly configured - -Useful probes: - -```powershell -Invoke-RestMethod http://localhost:8080/health -curl.exe -i http://localhost:8080/v1/health -Invoke-RestMethod http://localhost:8080/v1/alerts -Invoke-RestMethod http://localhost:8080/v1/slo -Invoke-RestMethod http://localhost:8080/v1/alert-rules -Invoke-RestMethod http://localhost:8080/v1/recording-rules -Invoke-RestMethod http://localhost:8080/v1/dashboards -Invoke-RestMethod http://localhost:8080/v1/status -curl.exe http://localhost:8080/metrics -curl.exe http://localhost:8080/v1/alert-rules/prometheus -curl.exe http://localhost:8080/v1/recording-rules/prometheus -curl.exe http://localhost:8080/v1/dashboards/grafana -``` +1. **scale up** toward extremely high transaction throughput when hardware is abundant; +2. **scale down** so verification, payments, relay, data-availability participation and useful network activity remain practical on commodity hardware and eventually inside the Zephyr smartphone wallet. -### 2. Run the wallet +Throughput may degrade when hardware is scarce. **Safety must not.** -In a second terminal: +The long-term mission is described in [Zaphyr-chain_manifesto.md](./Zaphyr-chain_manifesto.md). The executable v2 design is specified in [docs/protocol-v2.md](./docs/protocol-v2.md), and the exact implementation/non-claim boundary is tracked in [docs/protocol-v2-implementation-status.md](./docs/protocol-v2-implementation-status.md). -```powershell -cd apps/wallet -npm install -npm run dev -``` +> Zephyr's aspirational performance target is **1,000,000 transactions per second finalized through consensus**. It is a benchmark target, not a claim about current capacity. -If PowerShell execution policy blocks `npm`, run: +## Protocol v2 direction -```powershell -cd apps/wallet -npm.cmd install -npm.cmd run dev -``` +Zephyr v2 is being built as a clean break rather than preserving experimental wire/state compatibility. -Vite serves the wallet on `http://localhost:5173` by default and proxies `/health`, `/v1`, and `/metrics` to `http://127.0.0.1:8080`, so local development stays same-origin in the browser. Set `ZEPHYR_WALLET_DEV_NODE` to change the dev proxy target, or `VITE_ZEPHYR_API_BASE` when intentionally targeting a different API origin. +The architecture combines: -### 3. Run a two-node local devnet +- genesis-derived network identity and canonical bounded binary consensus encoding; +- separate account, node and validator identities; +- proof-oriented object state backed by an incremental Sparse Merkle Tree; +- proof-carrying transactions and deterministic parallel execution; +- weighted validator consensus, quorum certificates and global finalized headers; +- shard-ready state/execution with finalized asynchronous cross-shard receipts; +- Citizen/light verification intended for resource-constrained and mobile participants; +- deterministic smart-contract execution with a production WASM/Rust path; +- native custom-token creation, fixed/capped/mintable supply policies, mint/burn and transferability rules; +- a native distributed-compute market for workloads such as scientific computing, AI, rendering and other provider-executed jobs; +- authenticated data-availability foundations and libp2p/QUIC networking foundations; +- an oracle-free economic measurement layer for ZPH, compute demand/supply and adaptive monetary-policy experiments. -Node A, producer: +The implementation rule is: -```powershell -$env:ZEPHYR_NODE_ID="node-a" -$env:ZEPHYR_HTTP_ADDR=":8080" -$env:ZEPHYR_DATA_DIR="var/devnet-a" -$env:ZEPHYR_PEERS="http://localhost:8081" -$env:ZEPHYR_ENABLE_BLOCK_PRODUCTION="true" -$env:ZEPHYR_ENABLE_PEER_SYNC="true" -go run ./cmd/node +```text +measure first -> simulate second -> activate last ``` -Node B, replica: - -```powershell -$env:ZEPHYR_NODE_ID="node-b" -$env:ZEPHYR_HTTP_ADDR=":8081" -$env:ZEPHYR_DATA_DIR="var/devnet-b" -$env:ZEPHYR_PEERS="http://localhost:8080" -$env:ZEPHYR_ENABLE_BLOCK_PRODUCTION="false" -$env:ZEPHYR_ENABLE_PEER_SYNC="true" -go run ./cmd/node +## What is implemented on the v2 branch + +The current v2 development branch contains executable foundations and tests for: + +- canonical v2 codec and genesis/network identity; +- P-256 proof-carrying transactions with low-S canonical signatures; +- object/coin state and Sparse-Merkle inclusion/absence proofs; +- durable WAL/checkpoint state persistence and non-mutating state simulation; +- deterministic parallel batch execution; +- v2 proposal/vote/QC consensus and validator-root transitions; +- cross-shard ZPH receipts with proof verification and durable anti-replay state; +- Citizen/light proof verification and wallet-side quorum/state-proof verification; +- custom tokens with fixed, capped and mintable policies plus native mint/burn; +- transferability policy enforcement without serializing independent token transfers on one hot policy object; +- deterministic metered reference smart-contract execution and contract deploy/call paths; +- compute offers, escrow, collateral, assignment, provider results, replicated-majority settlement and objective slashing foundations; +- standardized compute `WorkVector`/`WorkSpec` representation; +- finalized compute settlement receipts that can be reconstructed and verified from chain state; +- ZCPI compute-price measurement from successfully verified standardized work; +- ZCSI compute-scarcity measurement with independent capacity-reliability gates; +- age-weighted native-money velocity and canonical per-shard economic epoch metrics; +- ZAMP shadow monetary-policy evaluation centered near a long-run ~2% net-supply-growth target; +- finalized-block economic collection, cross-epoch compute backlog accounting and chained Merkle-authenticated shadow monetary state; +- automatic **shadow** economic epoch closure and insertion of the pending `MonetaryEpochState` into the next normal consensus candidate; +- Reed-Solomon data-availability reconstruction foundations; +- libp2p/QUIC transport foundations; +- v1 and v2 consensus/performance CI labs. + +These are development/protocol foundations. They are not equivalent to a production public network. + +## Citizen Node / mobile goal + +A core Zephyr goal is to make the wallet a real network participant rather than a thin client that must trust a large RPC provider. + +The intended Citizen Node can progressively perform: + +```text +header + quorum verification + -> account/object proof verification + -> transaction relay + -> data-availability sampling/cache + -> opportunistic recent execution ``` -Use the wallet against Node A. Node B will follow through transaction, block, snapshot, and consensus-artifact sync from admitted peers. - -### 4. Enable certificate-gated commit/import +Participation should adapt to battery, connectivity and device resources. Smartphones are not assumed to be always-on validators. Consensus liveness must remain possible on inexpensive commodity hardware even when mobile operating systems suspend background work. -For production-style consensus enforcement on the current devnet flow, run a node with: +Two benchmark axes are therefore first-class: -```powershell -$env:ZEPHYR_NODE_ID="node-a" -$env:ZEPHYR_VALIDATOR_ADDRESS="zph_validator_a" -$env:ZEPHYR_VALIDATOR_PRIVATE_KEY="" -$env:ZEPHYR_ENFORCE_PROPOSER_SCHEDULE="true" -$env:ZEPHYR_REQUIRE_CONSENSUS_CERTIFICATES="true" -go run ./cmd/node +```text +How fast can Zephyr go? +How small can Zephyr run? ``` -Then use: +## Sharding and parallel execution -```powershell -Invoke-RestMethod http://localhost:8080/v1/status -Invoke-RestMethod http://localhost:8080/v1/dev/block-template -Invoke-RestMethod http://localhost:8080/v1/consensus -``` +Zephyr v2 is shard-aware but does not assume that more shards are automatically better. -`GET /v1/status` exposes the node's signed transport identity when `ZEPHYR_VALIDATOR_PRIVATE_KEY` is configured. The template response gives you the exact `height`, `previousHash`, `producedAt`, full `transactions`, ordered `transactionIds`, and `blockHash` that a signed proposal must certify. Once a matching quorum certificate exists, `POST /v1/dev/produce-block` can commit that exact block candidate from the stored proposal body. - -### 5. Enable autonomous certified consensus on a devnet - -Initial round-0 proposer: - -```powershell -$env:ZEPHYR_NODE_ID="node-a" -$env:ZEPHYR_HTTP_ADDR=":8080" -$env:ZEPHYR_DATA_DIR="var/devnet-a" -$env:ZEPHYR_PEERS="http://localhost:8081" -$env:ZEPHYR_VALIDATOR_PRIVATE_KEY="" -$env:ZEPHYR_ENABLE_BLOCK_PRODUCTION="true" -$env:ZEPHYR_ENABLE_PEER_SYNC="true" -$env:ZEPHYR_ENABLE_CONSENSUS_AUTOMATION="true" -$env:ZEPHYR_CONSENSUS_INTERVAL="250ms" -$env:ZEPHYR_CONSENSUS_ROUND_TIMEOUT="2s" -$env:ZEPHYR_ENFORCE_PROPOSER_SCHEDULE="true" -$env:ZEPHYR_REQUIRE_CONSENSUS_CERTIFICATES="true" -go run ./cmd/node -``` +The safe activation value remains a single shard until controlled 4/16-shard tests demonstrate safety, recovery and useful scaling. -Second validator: - -```powershell -$env:ZEPHYR_NODE_ID="node-b" -$env:ZEPHYR_HTTP_ADDR=":8081" -$env:ZEPHYR_DATA_DIR="var/devnet-b" -$env:ZEPHYR_PEERS="http://localhost:8080" -$env:ZEPHYR_VALIDATOR_PRIVATE_KEY="" -$env:ZEPHYR_ENABLE_BLOCK_PRODUCTION="true" -$env:ZEPHYR_ENABLE_PEER_SYNC="true" -$env:ZEPHYR_ENABLE_CONSENSUS_AUTOMATION="true" -$env:ZEPHYR_CONSENSUS_INTERVAL="250ms" -$env:ZEPHYR_CONSENSUS_ROUND_TIMEOUT="2s" -$env:ZEPHYR_REQUIRE_PEER_IDENTITY="true" -$env:ZEPHYR_PEER_VALIDATORS="http://localhost:8080=zph_validator_a" -$env:ZEPHYR_REQUIRE_CONSENSUS_CERTIFICATES="true" -go run ./cmd/node -``` +The intended model is: -With an active validator set and queued transactions, the scheduled proposer self-builds and signs the next proposal, active validators auto-vote, and the scheduled proposer auto-commits as soon as a matching quorum certificate exists. If the scheduled proposer stalls past the round timeout, the node advances the round, rotates the proposer, and the new proposer can reuse the latest stored candidate body for that same height. - -## Runtime Configuration - -### Node - -- `ZEPHYR_HTTP_ADDR`: HTTP bind address for the Go node -- `ZEPHYR_NODE_ID`: human-readable node identifier used in peer replication headers and status output -- `ZEPHYR_VALIDATOR_ADDRESS`: chain-level validator address used for proposer-schedule enforcement and status reporting -- `ZEPHYR_VALIDATOR_PRIVATE_KEY`: base64-encoded PKCS#8 P-256 private key used to derive and sign the node's validator transport identity plus automated proposal and vote messages -- `ZEPHYR_DATA_DIR`: local directory used for durable node state -- `ZEPHYR_PEERS`: comma-separated peer base URLs such as `http://localhost:8081,http://localhost:8082` -- `ZEPHYR_BLOCK_INTERVAL`: automatic block-production interval such as `15s` -- `ZEPHYR_CONSENSUS_INTERVAL`: automation ticker interval such as `250ms` or `1s` -- `ZEPHYR_CONSENSUS_ROUND_TIMEOUT`: timeout window for the active round before automation advances to the next round -- `ZEPHYR_SYNC_INTERVAL`: peer poll/sync interval such as `5s` -- `ZEPHYR_MAX_TXS_PER_BLOCK`: maximum committed transactions per produced block -- `ZEPHYR_ENABLE_BLOCK_PRODUCTION`: `true` or `false` -- `ZEPHYR_ENABLE_CONSENSUS_AUTOMATION`: `true` or `false`; when enabled, active validators automatically propose, vote, and advance rounds on the current devnet path -- `ZEPHYR_ENABLE_PEER_SYNC`: `true` or `false` -- `ZEPHYR_ENABLE_STRUCTURED_LOGS`: `true` or `false`; when enabled, the node emits newline-delimited JSON event logs for diagnostics, peer incidents, and snapshot recovery -- `ZEPHYR_REQUIRE_PEER_IDENTITY`: `true` or `false`; when enabled, replicated peer POST requests must include a valid signed transport identity -- `ZEPHYR_PEER_VALIDATORS`: comma-separated `=` bindings used to pin configured peers to expected validators -- `ZEPHYR_ENFORCE_PROPOSER_SCHEDULE`: `true` or `false`; when enabled and a validator set exists, only the scheduled proposer for the active round may produce the next block locally -- `ZEPHYR_REQUIRE_CONSENSUS_CERTIFICATES`: `true` or `false`; when enabled and a validator set exists, local block commit and remote block import require a matching proposal and quorum certificate - -Default values: - -- `ZEPHYR_HTTP_ADDR`: `:8080` -- `ZEPHYR_NODE_ID`: `node-local` -- `ZEPHYR_VALIDATOR_ADDRESS`: empty -- `ZEPHYR_VALIDATOR_PRIVATE_KEY`: empty -- `ZEPHYR_DATA_DIR`: `var/node` -- `ZEPHYR_PEERS`: empty -- `ZEPHYR_BLOCK_INTERVAL`: `15s` -- `ZEPHYR_CONSENSUS_INTERVAL`: `1s` -- `ZEPHYR_CONSENSUS_ROUND_TIMEOUT`: `5s` -- `ZEPHYR_SYNC_INTERVAL`: `5s` -- `ZEPHYR_MAX_TXS_PER_BLOCK`: `100` -- `ZEPHYR_ENABLE_BLOCK_PRODUCTION`: `true` -- `ZEPHYR_ENABLE_CONSENSUS_AUTOMATION`: `false` -- `ZEPHYR_ENABLE_PEER_SYNC`: `true` -- `ZEPHYR_ENABLE_STRUCTURED_LOGS`: `false` -- `ZEPHYR_REQUIRE_PEER_IDENTITY`: `false` -- `ZEPHYR_PEER_VALIDATORS`: empty -- `ZEPHYR_ENFORCE_PROPOSER_SCHEDULE`: `false` -- `ZEPHYR_REQUIRE_CONSENSUS_CERTIFICATES`: `false` - -### Wallet - -- `VITE_ZEPHYR_API_BASE`: base URL used by the wallet for node API calls -- default: `http://localhost:8080` - -Example `.env.local` inside `apps/wallet`: - -```env -VITE_ZEPHYR_API_BASE=http://localhost:8080 +```text +shard-local parallel execution + -> shard state/data/receipt commitments + -> global finalized commitment/QC ``` -## How The MVP Works - -1. The wallet generates an ECDSA P-256 keypair in the browser using Web Crypto. -2. It derives a Zephyr-style address from the SHA-256 hash of the exported public key. -3. The wallet stores the private key, public key, and address in browser `localStorage`. -4. The wallet can inspect node-side account state and use a dev faucet for local funding. -5. The wallet signs a canonical transaction payload locally and sends the signed envelope to the node. -6. The node validates the payload, address, signature, nonce, and available balance before persisting the transaction in the durable mempool. -7. A block-producing node can build a deterministic next-block template from the current mempool and latest chain tip. -8. DPoS elections persist a durable validator snapshot with versioning, voting-power totals, and next-proposer scheduling metadata. -9. Consensus state now persists the active height, round, and round start time separately from blocks and validator snapshots. -10. Operators can still submit signed proposals for the active round's concrete block template, including the exact `previousHash`, `producedAt`, full `transactions`, ordered `transactionIds`, and derived `blockHash`. -11. Validators can verify those proposal transactions directly from the proposal body instead of depending on local mempool convergence alone. -12. If consensus automation is enabled, the scheduled proposer for the active round can build that same template, sign a proposal, persist it, and disseminate it without an operator POST. -13. Active validators with automation enabled can sign and replicate a vote for the current known proposal. -14. If the active round times out, the node advances the round, rotates the scheduled proposer, and can accept higher-round messages from peers even if its own timer had not fired yet. -15. A new higher-round proposer can reuse the latest stored candidate body for that height instead of depending only on local mempool state. -16. Once vote power crosses quorum, the node stores a durable commit certificate artifact for that height and round. -17. If proposer-schedule enforcement is enabled, a node can refuse to produce a block unless its configured validator address matches the scheduled proposer for the active round. -18. If consensus-certificate enforcement is enabled, local block commit and remote block import both require a proposal and quorum certificate for the exact block template being committed. -19. A scheduled proposer with automation enabled and certificate enforcement turned on can auto-commit immediately from the stored certified proposal body. -20. A consensus-gated local commit can replay the stored certified proposal body even when the local mempool does not contain that candidate anymore. -21. If a validator private key is configured, the node derives a signed transport identity for its validator address and exposes that proof in runtime status. -22. Configured peer nodes receive transactions, self-contained consensus proposals, votes, and blocks over the current transport implementation, verify peer identity proofs when available, enforce admission and validator binding when configured, import blocks when possible, and fall back to snapshot restore when they need catch-up. - -## Current Limitations - -- the current multi-node layer is still HTTP-based under the new transport abstraction, not libp2p networking -- peer admission and validator pinning can now be enforced over the current HTTP transport, but peer discovery is still static configuration rather than libp2p -- the round engine now supports timeout-driven proposer rotation, latest-artifact rebroadcast after link recovery, richer `roundEvidence`, per-height `roundHistory`, `blockReadiness`, import-aware `recovery`, durable peer-sync incident history, bounded rejection diagnostics, machine-readable `GET /v1/metrics`, Prometheus-style `GET /metrics`, derived `GET /v1/health`, derived `GET /v1/alerts` including peer import, admission, replication, and snapshot-restore warnings, derived `GET /v1/slo`, recommended `GET /v1/alert-rules`, exported `GET /v1/alert-rules/prometheus`, recommended `GET /v1/recording-rules`, exported `GET /v1/recording-rules/prometheus`, recommended `GET /v1/dashboards`, exported `GET /v1/dashboards/grafana`, and local consensus-action history across restart for proposal, vote, round-advance, block-commit, import, and snapshot-repair events, but broader recovery tooling is still missing -- crash recovery now persists active round metadata plus a bounded local consensus-action WAL, and peer snapshot restore preserves local recovery, diagnostics, and peer-sync incident history, but replay coverage is still centered on local proposal, vote, certified block-commit, and import-repair paths rather than the full consensus lifecycle -- DPoS elections still happen through an API call, not an on-chain staking/governance flow -- snapshot restore is a state catch-up mechanism, not a trust-minimized proof-based sync protocol -- WASM smart-contract execution is planned, but not implemented yet -- confidential compute jobs, worker attestation, escrow, and settlement are planned, but not implemented yet -- wallet private keys are stored unencrypted in browser `localStorage` - -Because of these limitations, the current MVP should still be treated as a development prototype, not a production blockchain network. - -## Roadmap - -The production roadmap now lives in [docs/roadmap.md](./docs/roadmap.md). - -Short version: - -1. Move the new enforced HTTP peer-admission and validator-binding policy toward authenticated libp2p discovery plus replay-safe transport behavior. -2. Extend the new `blockReadiness`, `roundHistory`, `roundEvidence`, `recovery`, `diagnostics`, `peerSyncHistory`, `peerSyncSummary`, per-peer `recentIncidents`, `GET /v1/metrics`, `GET /metrics`, `GET /v1/health`, `GET /v1/alerts`, `GET /v1/slo`, `GET /v1/alert-rules`, `GET /v1/alert-rules/prometheus`, `GET /v1/recording-rules`, `GET /v1/recording-rules/prometheus`, `GET /v1/dashboards`, `GET /v1/dashboards/grafana`, and structured event logs into deeper recovery, longer-horizon incident retention, richer exported metrics, broader dashboard coverage, and production incident tooling. -3. Move validator lifecycle changes behind staking, delegation, slashing, and governance state transitions. -4. Add deterministic WASM execution, native fee metering, and the confidential compute lane. -5. Add production observability, recovery tooling, and public testnet operations. - -## Documentation - -- [docs/architecture.md](./docs/architecture.md) -- [docs/api.md](./docs/api.md) -- [docs/usage.md](./docs/usage.md) -- [docs/roadmap.md](./docs/roadmap.md) -- [docs/applications.md](./docs/applications.md) -- [paper/README.md](./paper/README.md) -- [Zaphyr-chain_manifesto.md](./Zaphyr-chain_manifesto.md) - -## License - -Zephyr Chain is licensed under the MIT License. See [LICENSE](./LICENSE). - - - - +Cross-shard native payments use finalized one-time receipts. General synchronous atomic cross-shard smart-contract semantics are deliberately not a v0 requirement. +## Smart contracts +The repository includes a bounded deterministic reference runtime and contract deploy/call execution path. The production direction is deterministic metered **WASM**, with Rust-first tooling/SDK work planned around a stable ABI and cross-machine conformance tests. +Validators must never depend on nondeterministic clock, network, filesystem or host randomness during consensus-critical execution. +## Native distributed compute +Heavy compute is intentionally separated from deterministic validator execution. +Examples include: +- scientific/HPC workloads; +- AI inference or training; +- video/3D rendering; +- simulation and optimization; +- other provider-executed workloads whose result can be verified through an approved evidence policy. +The chain manages economic and verification state such as: +```text +job -> escrow -> assignment -> provider result -> verification -> settlement +``` +Validators verify bounded settlement evidence rather than re-running arbitrarily expensive workloads. +Verification modes have explicit protocol boundaries for deterministic replication, replicated majority, challenge systems, ZK evidence, TEE evidence, client approval and future hybrids. Production ZK/TEE integrations are not yet claimed. +## Compute economics: ZCR, ZCPI and ZCSI +There is no honest single universal scalar that makes CPU, GPU tensor work, FP64 scientific work, memory, storage and network usage equivalent. +Zephyr therefore models compute as a normalized resource/work vector and only prices standardized workload classes whose definitions are committed by `WorkSpec`. +`ZCPI` measures the ZPH actually paid for finalized, verification-satisfied standardized work. It excludes provider-advertised offer prices and theoretical peak FLOPS. +`ZCSI` combines signals such as: +- escrow-backed standardized demand; +- verified standardized supply; +- opening/closing backlog; +- fulfilled work; +- compute utilization; +- reliable ZCPI trend. +Long jobs are accounted as stock-flow across epochs: +```text +opening backlog + new demand += +fulfilled + expired + closing backlog +``` +A numeric capacity value cannot make ZCSI trustworthy by itself. Compute supply has a separate reliability flag and remains monetarily inert until capacity can be derived from a consensus-reproducible benchmark/collateral/availability mechanism. +See [docs/compute-economics-v2.md](./docs/compute-economics-v2.md) and [docs/economic-runtime-v2.md](./docs/economic-runtime-v2.md). +## ZPH tokenomics — shadow mode +The v2 economic design does **not** assume a fixed maximum supply. +The research direction is an oracle-free burn/mint controller centered near approximately 2% long-run effective net supply growth, with bounded/rate-limited adjustments from on-chain signals such as: +- fee burn; +- staking/locked supply; +- protocol reserve; +- chain resource utilization; +- finalized operations; +- age-weighted money velocity; +- potentially reliable compute scarcity. +Compute feedback currently has three simulation modes: +```text +A — observe only +B — change suggested compute-reward routing only +C — B plus a narrow bounded shadow inflation correction +``` +Mode B is the preferred first activation candidate if long-run devnet evidence supports it. **No current mode mints live ZPH.** Suggested issuance is stored only as shadow economic state for replay and analysis. +See [docs/tokenomics-v2.md](./docs/tokenomics-v2.md) and [docs/economic-state-v2.md](./docs/economic-state-v2.md). +## Consensus & Performance Lab +Zephyr defines throughput as transactions **finalized through validator consensus**, not HTTP ingress or mempool acceptance. +CI includes multi-validator conformance, partition/recovery stress, finalized-throughput sampling, signature verification baselines and v2 batch-scaling tests. +Shared GitHub runner numbers are development signals only and must not be presented as production capacity claims. +See [docs/performance-lab.md](./docs/performance-lab.md). +## Current non-claims +The repository must not currently be presented as having production-ready: +- live adaptive ZPH issuance or final monetary parameters; +- active validator/compute/reserve reward distribution; +- production gas/resource pricing; +- authenticated governance-controlled economic parameters; +- production benchmarked/collateralized compute-capacity registry; +- production ZK/TEE confidential-compute integrations; +- audited deterministic WASM engine and stable Rust SDK; +- mobile OS lifecycle/background integration with measured device budgets; +- production peer discovery/NAT/mobile relay/shard-aware gossip; +- dynamic resharding or public 4/16-shard activation; +- public-mainnet operational/security maturity. +The authoritative detailed list is [docs/protocol-v2-implementation-status.md](./docs/protocol-v2-implementation-status.md). +## Repository layout +```text +apps/wallet/ Vue reference wallet / Citizen verification work +cmd/node/ legacy/current node entrypoint +cmd/compute-provider/ compute-provider tooling foundation +cmd/zephyr-econ-sim/ deterministic economic replay simulator +internal/v2/ clean-break Protocol v2 implementation +internal/v2/economics/ ZCPI, ZCSI, ZAMP, fee and epoch accounting +internal/v2/compute/ native distributed-compute market/state +internal/v2/node/ v2 candidate/finality runtime +internal/v2/worldstate/ proof-oriented in-memory/durable state +internal/v2/network/ v2 networking foundations +mobile/ Citizen/mobile policy foundations +docs/ protocol, economics, performance and roadmap docs +``` +The pre-v2 packages remain in the repository because the existing Consensus & Performance Lab and hardening work are still valuable regression gates while v2 is developed. +## Development checks +From the repository root: +```bash +gofmt -w ./internal/v2 ./mobile ./cmd/zephyr-econ-sim +go vet ./... +go test ./... +``` +Wallet: +```bash +cd apps/wallet +npm ci +npm run build +``` +The GitHub workflows are the authoritative shared gate for the v1 Lab, V2 Lab, wallet build and dependency lock. +## Documentation +Start with: +- [Protocol v2 architecture](./docs/protocol-v2.md) +- [Protocol v2 implementation status](./docs/protocol-v2-implementation-status.md) +- [Validator trust model](./docs/protocol-v2-validator-trust.md) +- [Tokenomics v2](./docs/tokenomics-v2.md) +- [Compute economics v2](./docs/compute-economics-v2.md) +- [Economic state v2](./docs/economic-state-v2.md) +- [Finalized economic runtime](./docs/economic-runtime-v2.md) +- [Consensus & Performance Lab](./docs/performance-lab.md) +- [Roadmap](./docs/roadmap.md) +- [Project manifesto](./Zaphyr-chain_manifesto.md) +## License +Zephyr Chain is licensed under the **Apache License, Version 2.0**. See [LICENSE](./LICENSE). +The redistribution attribution notice is provided in [NOTICE](./NOTICE) and must be preserved as required by the Apache License 2.0. diff --git a/apps/wallet/src/components/CitizenPanel.vue b/apps/wallet/src/components/CitizenPanel.vue new file mode 100644 index 00000000..c73db541 --- /dev/null +++ b/apps/wallet/src/components/CitizenPanel.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/apps/wallet/src/lib/v2Citizen.ts b/apps/wallet/src/lib/v2Citizen.ts new file mode 100644 index 00000000..cb27a2ed --- /dev/null +++ b/apps/wallet/src/lib/v2Citizen.ts @@ -0,0 +1,550 @@ +export interface CitizenValidatorDTO { + id: string + publicKey: string + power: string +} + +export interface CitizenObjectBundle { + network: string + height: number + shardId: number + header: string + certificate: string + commitment: string + commitmentProof: string + objectId: string + objectPresent: boolean + object?: string + stateProof: string + validators: CitizenValidatorDTO[] +} + +export interface VerifiedCitizenObject { + network: string + height: bigint + shardId: number + objectId: string + objectPresent: boolean + objectBytes?: Uint8Array + stateRoot: string +} + +export interface CitizenPowerState { + batteryPercent: number + charging: boolean + wifi: boolean + lowPower: boolean + appActive: boolean +} + +export interface CitizenMode { + verifyHeaders: boolean + relay: boolean + sampleDA: boolean + executeRecent: boolean + serveCache: boolean +} + +const P256_ORDER = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551') +const P256_HALF_ORDER = P256_ORDER >> 1n +const textEncoder = new TextEncoder() + +class BinaryReader { + private offset = 0 + + constructor(private readonly data: Uint8Array) {} + + u8(): number { + return this.take(1)[0] + } + + u16(): number { + const value = this.take(2) + return (value[0] << 8) | value[1] + } + + u32(): number { + const value = this.take(4) + return ((value[0] * 0x1000000) + (value[1] << 16) + (value[2] << 8) + value[3]) >>> 0 + } + + u64(): bigint { + const value = this.take(8) + let out = 0n + for (const byte of value) out = (out << 8n) | BigInt(byte) + return out + } + + fixed(length: number): Uint8Array { + return this.take(length) + } + + bytes(max: number): Uint8Array { + const length = this.u32() + if (length > max) throw new Error('Citizen proof field exceeds limit') + return this.take(length) + } + + done(): void { + if (this.offset !== this.data.length) throw new Error('Citizen proof has trailing data') + } + + private take(length: number): Uint8Array { + if (length < 0 || this.offset + length > this.data.length) throw new Error('Citizen proof is truncated') + const out = this.data.slice(this.offset, this.offset + length) + this.offset += length + return out + } +} + +class BinaryWriter { + private readonly chunks: Uint8Array[] = [] + + u32(value: number): void { + this.chunks.push(new Uint8Array([(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff])) + } + + u64(value: bigint): void { + const out = new Uint8Array(8) + let remaining = value + for (let i = 7; i >= 0; i--) { + out[i] = Number(remaining & 0xffn) + remaining >>= 8n + } + this.chunks.push(out) + } + + fixed(value: Uint8Array): void { + this.chunks.push(value) + } + + bytes(value: Uint8Array): void { + this.u32(value.length) + this.fixed(value) + } + + result(): Uint8Array { + return concatBytes(...this.chunks) + } +} + +interface ParsedHeader { + raw: Uint8Array + network: Uint8Array + height: bigint + shardCommitmentRoot: Uint8Array + certificateHash: Uint8Array +} + +interface ParsedCommitment { + raw: Uint8Array + shardId: number + stateRoot: Uint8Array + receiptRoot: Uint8Array + dataRoot: Uint8Array +} + +interface MerkleProof { + index: number + leafCount: number + siblings: Uint8Array[] +} + +interface StateProof { + exists: boolean + bitmap: Uint8Array + siblings: Uint8Array[] +} + +interface ParsedVote { + network: Uint8Array + height: bigint + round: bigint + headerHash: Uint8Array + voter: Uint8Array + publicKey: Uint8Array + signature: Uint8Array +} + +interface ParsedCertificate { + network: Uint8Array + height: bigint + round: bigint + headerHash: Uint8Array + votes: ParsedVote[] +} + +export function selectCitizenMode(power: CitizenPowerState): CitizenMode { + const mode: CitizenMode = { verifyHeaders: true, relay: false, sampleDA: false, executeRecent: false, serveCache: false } + if (power.lowPower || power.batteryPercent < 15) return mode + if (power.appActive) mode.relay = true + if (power.wifi && power.batteryPercent >= 30) { + mode.sampleDA = true + mode.serveCache = power.appActive + } + if (power.wifi && power.charging && power.batteryPercent >= 50) { + mode.executeRecent = true + mode.serveCache = true + } + return mode +} + +export async function fetchAndVerifyCitizenObject(baseURL: string, shardId: number, objectId: string): Promise { + const endpoint = new URL('/v2/light/object', normalizeBaseURL(baseURL)) + endpoint.searchParams.set('shard', String(shardId)) + endpoint.searchParams.set('id', objectId) + const response = await fetch(endpoint) + if (!response.ok) throw new Error(`Citizen proof request failed (${response.status})`) + return verifyCitizenObjectBundle(await response.json() as CitizenObjectBundle) +} + +export async function verifyCitizenObjectBundle(bundle: CitizenObjectBundle): Promise { + const header = parseHeader(base64ToBytes(bundle.header)) + const commitment = parseCommitment(base64ToBytes(bundle.commitment)) + const commitmentProof = parseMerkleProof(base64ToBytes(bundle.commitmentProof)) + const certificate = parseCertificate(base64ToBytes(bundle.certificate)) + + if (bundle.network.toLowerCase() !== bytesToHex(header.network) || bundle.shardId !== commitment.shardId) { + throw new Error('Citizen bundle network or shard mismatch') + } + await verifyFinality(header, certificate, bundle.validators) + + const commitmentLeaf = await merkleLeaf('shard-commitment', commitment.raw) + if (!await verifyMerkle(header.shardCommitmentRoot, commitmentLeaf, commitmentProof)) { + throw new Error('Shard commitment is not included in finalized header') + } + + const objectId = hexToBytes(bundle.objectId) + if (objectId.length !== 32) throw new Error('Invalid Citizen object ID') + const stateProof = parseStateProof(base64ToBytes(bundle.stateProof)) + if (stateProof.exists !== bundle.objectPresent) throw new Error('Object presence does not match state proof') + + let objectBytes: Uint8Array | undefined + let value: Uint8Array | undefined + if (bundle.objectPresent) { + if (!bundle.object) throw new Error('Citizen bundle omitted present object') + objectBytes = base64ToBytes(bundle.object) + if (objectBytes.length < 32 || !equalBytes(objectBytes.slice(0, 32), objectId)) throw new Error('Citizen object identity mismatch') + value = await domainHash('zephyr/object/v2', objectBytes) + } + if (!await verifySparseMerkle(commitment.stateRoot, objectId, value, stateProof)) { + throw new Error('Object Sparse-Merkle proof is invalid') + } + + return { + network: bytesToHex(header.network), height: header.height, shardId: commitment.shardId, + objectId: bytesToHex(objectId), objectPresent: bundle.objectPresent, objectBytes, + stateRoot: bytesToHex(commitment.stateRoot) + } +} + +async function verifyFinality(header: ParsedHeader, certificate: ParsedCertificate, validators: CitizenValidatorDTO[]): Promise { + const headerConsensusBytes = header.raw.slice() + headerConsensusBytes.fill(0, headerConsensusBytes.length - 32) + const headerHash = await domainHash('zephyr/global-header-consensus/v2', headerConsensusBytes) + if (!equalBytes(certificate.network, header.network) || certificate.height !== header.height || !equalBytes(certificate.headerHash, headerHash)) { + throw new Error('Citizen certificate does not target header') + } + + const validatorMap = new Map() + let totalPower = 0n + for (const validator of validators) { + const id = validator.id.toLowerCase() + if (validatorMap.has(id)) throw new Error('Duplicate validator in Citizen bundle') + const publicKey = base64ToBytes(validator.publicKey) + const derived = await domainHash('zephyr/validator-id/v2', publicKey) + if (bytesToHex(derived) !== id) throw new Error('Validator identity does not match public key') + const power = BigInt(validator.power) + if (power <= 0n) throw new Error('Invalid validator voting power') + totalPower += power + validatorMap.set(id, { publicKey, power }) + } + if (totalPower <= 0n) throw new Error('Citizen bundle has no validator power') + + let signedPower = 0n + const seen = new Set() + for (const vote of certificate.votes) { + if (!equalBytes(vote.network, certificate.network) || vote.height !== certificate.height || vote.round !== certificate.round || !equalBytes(vote.headerHash, certificate.headerHash)) { + throw new Error('Certificate contains vote for another target') + } + const voter = bytesToHex(vote.voter) + if (seen.has(voter)) throw new Error('Certificate contains duplicate validator vote') + seen.add(voter) + const validator = validatorMap.get(voter) + if (!validator || !equalBytes(validator.publicKey, vote.publicKey)) throw new Error('Certificate vote is not from active validator set') + if (!isCanonicalLowS(vote.signature) || !await verifyVoteSignature(vote)) throw new Error('Certificate contains invalid vote signature') + signedPower += validator.power + } + const quorum = (totalPower * 2n) / 3n + 1n + if (signedPower < quorum) throw new Error('Certificate is below 2/3+ quorum') + + const certificateHash = await hashCertificate(certificate) + if (!equalBytes(certificateHash, header.certificateHash)) throw new Error('Header certificate hash mismatch') +} + +async function verifyVoteSignature(vote: ParsedVote): Promise { + const body = new BinaryWriter() + body.fixed(vote.network) + body.u64(vote.height) + body.u64(vote.round) + body.fixed(vote.headerHash) + body.fixed(vote.voter) + const signingPayload = domainFrame('zephyr/consensus/vote/v2', body.result()) + try { + const key = await crypto.subtle.importKey('raw', toArrayBuffer(vote.publicKey), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']) + return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, toArrayBuffer(vote.signature), toArrayBuffer(signingPayload)) + } catch { + return false + } +} + +async function hashCertificate(certificate: ParsedCertificate): Promise { + const votes = [...certificate.votes].sort((a, b) => compareBytes(a.voter, b.voter)) + const writer = new BinaryWriter() + writer.fixed(certificate.network) + writer.u64(certificate.height) + writer.u64(certificate.round) + writer.fixed(certificate.headerHash) + writer.u32(votes.length) + for (const vote of votes) { + writer.fixed(vote.voter) + writer.bytes(vote.publicKey) + writer.bytes(vote.signature) + } + return domainHash('zephyr/quorum-certificate/v2', writer.result()) +} + +function parseHeader(raw: Uint8Array): ParsedHeader { + const reader = new BinaryReader(raw) + if (reader.u16() !== 2) throw new Error('Unsupported Zephyr header version') + const network = reader.fixed(32) + const height = reader.u64() + reader.fixed(32) + const shardCommitmentRoot = reader.fixed(32) + reader.fixed(32) + reader.fixed(32) + const certificateHash = reader.fixed(32) + reader.done() + if (height === 0n) throw new Error('Invalid finalized height') + return { raw, network, height, shardCommitmentRoot, certificateHash } +} + +function parseCommitment(raw: Uint8Array): ParsedCommitment { + const reader = new BinaryReader(raw) + const shardId = reader.u32() + const stateRoot = reader.fixed(32) + const receiptRoot = reader.fixed(32) + const dataRoot = reader.fixed(32) + reader.done() + return { raw, shardId, stateRoot, receiptRoot, dataRoot } +} + +function parseMerkleProof(raw: Uint8Array): MerkleProof { + const reader = new BinaryReader(raw) + const index = reader.u32() + const leafCount = reader.u32() + const count = reader.u32() + if (leafCount === 0 || index >= leafCount || count > 32) throw new Error('Invalid Merkle proof') + const siblings: Uint8Array[] = [] + for (let i = 0; i < count; i++) siblings.push(reader.fixed(32)) + reader.done() + return { index, leafCount, siblings } +} + +function parseStateProof(raw: Uint8Array): StateProof { + const reader = new BinaryReader(raw) + const existsRaw = reader.u8() + if (existsRaw !== 0 && existsRaw !== 1) throw new Error('Invalid state proof existence bit') + const bitmap = reader.fixed(32) + const count = reader.u16() + if (count > 256) throw new Error('Invalid state proof sibling count') + const siblings: Uint8Array[] = [] + for (let i = 0; i < count; i++) siblings.push(reader.fixed(32)) + reader.done() + let bits = 0 + for (let i = 0; i < 256; i++) if (bitmapBit(bitmap, i)) bits++ + if (bits !== siblings.length) throw new Error('State proof bitmap does not match siblings') + return { exists: existsRaw === 1, bitmap, siblings } +} + +function parseCertificate(raw: Uint8Array): ParsedCertificate { + const reader = new BinaryReader(raw) + const network = reader.fixed(32) + const height = reader.u64() + const round = reader.u64() + const headerHash = reader.fixed(32) + const count = reader.u32() + if (height === 0n || count === 0 || count > 4096) throw new Error('Invalid quorum certificate') + const votes: ParsedVote[] = [] + for (let i = 0; i < count; i++) votes.push(parseVote(reader.bytes(512))) + reader.done() + return { network, height, round, headerHash, votes } +} + +function parseVote(raw: Uint8Array): ParsedVote { + const reader = new BinaryReader(raw) + const network = reader.fixed(32) + const height = reader.u64() + const round = reader.u64() + const headerHash = reader.fixed(32) + const voter = reader.fixed(32) + const publicKey = reader.bytes(65) + const signature = reader.bytes(64) + reader.done() + if (height === 0n || publicKey.length !== 65 || signature.length !== 64) throw new Error('Invalid quorum vote') + return { network, height, round, headerHash, voter, publicKey, signature } +} + +async function verifyMerkle(root: Uint8Array, leaf: Uint8Array, proof: MerkleProof): Promise { + let target = 1 + while (target < proof.leafCount) target <<= 1 + let requiredDepth = 0 + for (let n = target; n > 1; n >>= 1) requiredDepth++ + if (proof.siblings.length !== requiredDepth) return false + let current = leaf + let position = proof.index + for (const sibling of proof.siblings) { + current = position % 2 === 0 ? await merkleBranch(current, sibling) : await merkleBranch(sibling, current) + position = Math.floor(position / 2) + } + return equalBytes(current, root) +} + +let defaultsPromise: Promise | undefined + +async function sparseDefaults(): Promise { + if (!defaultsPromise) { + defaultsPromise = (async () => { + const defaults = new Array(257) + defaults[256] = await domainHash('zephyr/smt/empty-leaf/v2', new Uint8Array()) + for (let depth = 255; depth >= 0; depth--) defaults[depth] = await smtBranch(defaults[depth + 1], defaults[depth + 1]) + return defaults + })() + } + return defaultsPromise +} + +async function verifySparseMerkle(root: Uint8Array, key: Uint8Array, value: Uint8Array | undefined, proof: StateProof): Promise { + if (proof.exists !== (value !== undefined) || key.length !== 32) return false + const defaults = await sparseDefaults() + let current = proof.exists && value ? await smtLeaf(key, value) : defaults[256] + let siblingIndex = 0 + for (let i = 0; i < 256; i++) { + const depth = 256 - i + let sibling = defaults[depth] + if (bitmapBit(proof.bitmap, i)) { + if (siblingIndex >= proof.siblings.length) return false + sibling = proof.siblings[siblingIndex++] + } + const bitIndex = depth - 1 + const byteIndex = Math.floor(bitIndex / 8) + const shift = 7 - (bitIndex % 8) + const bit = (key[byteIndex] >> shift) & 1 + current = bit === 0 ? await smtBranch(current, sibling) : await smtBranch(sibling, current) + } + return siblingIndex === proof.siblings.length && equalBytes(current, root) +} + +async function merkleLeaf(domain: string, payload: Uint8Array): Promise { + return domainHash(`zephyr/merkle/leaf/v2/${domain}`, payload) +} + +async function merkleBranch(left: Uint8Array, right: Uint8Array): Promise { + return domainHash('zephyr/merkle/branch/v2', concatBytes(left, right)) +} + +async function smtLeaf(key: Uint8Array, value: Uint8Array): Promise { + const writer = new BinaryWriter() + writer.fixed(key) + writer.bytes(value) + return domainHash('zephyr/smt/leaf/v2', writer.result()) +} + +async function smtBranch(left: Uint8Array, right: Uint8Array): Promise { + return domainHash('zephyr/smt/branch/v2', concatBytes(left, right)) +} + +async function domainHash(domain: string, payload: Uint8Array): Promise { + return sha256(domainFrame(domain, payload)) +} + +function domainFrame(domain: string, payload: Uint8Array): Uint8Array { + const writer = new BinaryWriter() + writer.bytes(textEncoder.encode(domain)) + writer.bytes(payload) + return writer.result() +} + +async function sha256(value: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest('SHA-256', toArrayBuffer(value))) +} + +function bitmapBit(bitmap: Uint8Array, index: number): boolean { + return (bitmap[Math.floor(index / 8)] & (1 << (index % 8))) !== 0 +} + +function isCanonicalLowS(signature: Uint8Array): boolean { + if (signature.length !== 64) return false + const r = bytesToBigInt(signature.slice(0, 32)) + const s = bytesToBigInt(signature.slice(32)) + return r > 0n && s > 0n && r < P256_ORDER && s <= P256_HALF_ORDER +} + +function bytesToBigInt(value: Uint8Array): bigint { + let out = 0n + for (const byte of value) out = (out << 8n) | BigInt(byte) + return out +} + +function normalizeBaseURL(value: string): string { + const url = new URL(value, window.location.origin) + if (!url.pathname.endsWith('/')) url.pathname += '/' + return url.toString() +} + +function base64ToBytes(value: string): Uint8Array { + const raw = atob(value) + const out = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) + return out +} + +function hexToBytes(value: string): Uint8Array { + if (!/^[0-9a-fA-F]*$/.test(value) || value.length % 2 !== 0) throw new Error('Invalid hex') + const out = new Uint8Array(value.length / 2) + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16) + return out +} + +function bytesToHex(value: Uint8Array): string { + return Array.from(value, byte => byte.toString(16).padStart(2, '0')).join('') +} + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i] + return diff === 0 +} + +function compareBytes(a: Uint8Array, b: Uint8Array): number { + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return a.length - b.length +} + +function concatBytes(...values: Uint8Array[]): Uint8Array { + const length = values.reduce((total, value) => total + value.length, 0) + const out = new Uint8Array(length) + let offset = 0 + for (const value of values) { + out.set(value, offset) + offset += value.length + } + return out +} + +function toArrayBuffer(value: Uint8Array): ArrayBuffer { + return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer +} diff --git a/apps/wallet/src/lib/v2CitizenTrust.ts b/apps/wallet/src/lib/v2CitizenTrust.ts new file mode 100644 index 00000000..efd18ade --- /dev/null +++ b/apps/wallet/src/lib/v2CitizenTrust.ts @@ -0,0 +1,191 @@ +import { + type CitizenObjectBundle, + type CitizenValidatorDTO, + type VerifiedCitizenObject, + verifyCitizenObjectBundle +} from './v2Citizen' + +export interface CitizenTrustAnchor { + // Genesis-derived Zephyr NetworkID in lowercase hex. + network: string + // Validator-set Merkle root trusted from genesis or a previously verified checkpoint. + validatorRoot: string +} + +// This is the wallet-facing entry point. It rejects a self-consistent but +// self-signed light bundle unless its validator set is anchored to the trusted +// genesis/checkpoint root before delegating the remaining QC/shard/state proof +// verification to v2Citizen.ts. +export async function fetchAndVerifyTrustedCitizenObject( + baseURL: string, + shardId: number, + objectId: string, + anchor: CitizenTrustAnchor +): Promise { + const endpoint = new URL('/v2/light/object', normalizeBaseURL(baseURL)) + endpoint.searchParams.set('shard', String(shardId)) + endpoint.searchParams.set('id', objectId) + const response = await fetch(endpoint) + if (!response.ok) throw new Error(`Citizen proof request failed (${response.status})`) + return verifyTrustedCitizenObjectBundle(await response.json() as CitizenObjectBundle, anchor) +} + +export async function verifyTrustedCitizenObjectBundle( + bundle: CitizenObjectBundle, + anchor: CitizenTrustAnchor +): Promise { + const expectedNetwork = normalizeHex32(anchor.network, 'Citizen trust-anchor network') + const expectedValidatorRoot = normalizeHex32(anchor.validatorRoot, 'Citizen trust-anchor validator root') + const header = base64ToBytes(bundle.header) + if (header.length !== 202) throw new Error('Invalid canonical Zephyr GlobalHeader length') + if (readU16(header, 0) !== 2) throw new Error('Unsupported Zephyr GlobalHeader version') + + const headerNetwork = bytesToHex(header.slice(2, 34)) + const headerValidatorRoot = bytesToHex(header.slice(106, 138)) + if (bundle.network.toLowerCase() !== expectedNetwork || headerNetwork !== expectedNetwork) { + throw new Error('Citizen bundle does not belong to the trusted Zephyr network') + } + if (headerValidatorRoot !== expectedValidatorRoot) { + throw new Error('Citizen header validator root is not trusted by this wallet checkpoint') + } + + const suppliedValidatorRoot = bytesToHex(await validatorSetRoot(bundle.validators)) + if (suppliedValidatorRoot !== expectedValidatorRoot) { + throw new Error('Citizen validator set does not match the committed trusted validator root') + } + + return verifyCitizenObjectBundle(bundle) +} + +async function validatorSetRoot(validators: CitizenValidatorDTO[]): Promise { + if (validators.length === 0) throw new Error('Citizen validator set is empty') + const canonical: Array<{ id: Uint8Array, publicKey: Uint8Array, power: bigint }> = [] + const seen = new Set() + for (const validator of validators) { + const id = hexToBytes(normalizeHex32(validator.id, 'validator ID')) + const idHex = bytesToHex(id) + if (seen.has(idHex)) throw new Error('Duplicate Citizen validator') + seen.add(idHex) + const publicKey = base64ToBytes(validator.publicKey) + if (publicKey.length !== 65) throw new Error('Invalid Citizen validator public key') + const derivedID = await domainHash('zephyr/validator-id/v2', publicKey) + if (!equalBytes(derivedID, id)) throw new Error('Citizen validator ID does not match public key') + const power = BigInt(validator.power) + if (power <= 0n || power > 0xffffffffffffffffn) throw new Error('Invalid Citizen validator voting power') + canonical.push({ id, publicKey, power }) + } + canonical.sort((a, b) => compareBytes(a.id, b.id)) + const leaves: Uint8Array[] = [] + for (const validator of canonical) { + const payload = concatBytes( + validator.id, + u32(validator.publicKey.length), + validator.publicKey, + u64(validator.power) + ) + leaves.push(await domainHash('zephyr/merkle/leaf/v2/validator', payload)) + } + return merkleRoot(leaves) +} + +async function merkleRoot(leaves: Uint8Array[]): Promise { + const empty = await domainHash('zephyr/merkle/empty/v2', new Uint8Array()) + if (leaves.length === 0) return empty + const level: Uint8Array[] = leaves.map(leaf => leaf.slice()) + let target = 1 + while (target < level.length) target <<= 1 + while (level.length < target) level.push(empty.slice()) + let current: Uint8Array[] = level + while (current.length > 1) { + const next: Uint8Array[] = [] + for (let i = 0; i < current.length; i += 2) { + next.push(await domainHash('zephyr/merkle/branch/v2', concatBytes(current[i], current[i + 1]))) + } + current = next + } + return current[0] +} + +async function domainHash(domain: string, payload: Uint8Array): Promise { + const encodedDomain = new TextEncoder().encode(domain) + const framed = concatBytes(u32(encodedDomain.length), encodedDomain, u32(payload.length), payload) + return new Uint8Array(await crypto.subtle.digest('SHA-256', toArrayBuffer(framed))) +} + +function normalizeHex32(value: string, label: string): string { + const normalized = value.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error(`${label} must be a 32-byte hex value`) + return normalized +} + +function readU16(value: Uint8Array, offset: number): number { + return (value[offset] << 8) | value[offset + 1] +} + +function u32(value: number): Uint8Array { + if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw new Error('u32 overflow') + return new Uint8Array([(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]) +} + +function u64(value: bigint): Uint8Array { + if (value < 0n || value > 0xffffffffffffffffn) throw new Error('u64 overflow') + const out = new Uint8Array(8) + let remaining = value + for (let i = 7; i >= 0; i--) { + out[i] = Number(remaining & 0xffn) + remaining >>= 8n + } + return out +} + +function base64ToBytes(value: string): Uint8Array { + const raw = atob(value) + const out = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) + return out +} + +function hexToBytes(value: string): Uint8Array { + const out = new Uint8Array(value.length / 2) + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16) + return out +} + +function bytesToHex(value: Uint8Array): string { + return Array.from(value, byte => byte.toString(16).padStart(2, '0')).join('') +} + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i] + return diff === 0 +} + +function compareBytes(a: Uint8Array, b: Uint8Array): number { + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return a.length - b.length +} + +function concatBytes(...values: Uint8Array[]): Uint8Array { + const length = values.reduce((total, value) => total + value.length, 0) + const out = new Uint8Array(length) + let offset = 0 + for (const value of values) { + out.set(value, offset) + offset += value.length + } + return out +} + +function toArrayBuffer(value: Uint8Array): ArrayBuffer { + return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer +} + +function normalizeBaseURL(value: string): string { + const url = new URL(value, window.location.origin) + if (!url.pathname.endsWith('/')) url.pathname += '/' + return url.toString() +} diff --git a/apps/wallet/src/lib/v2CitizenTrusted.ts b/apps/wallet/src/lib/v2CitizenTrusted.ts new file mode 100644 index 00000000..00bc091f --- /dev/null +++ b/apps/wallet/src/lib/v2CitizenTrusted.ts @@ -0,0 +1,310 @@ +export interface TrustedValidator { + id: string + publicKey: string + power: string +} + +export interface TrustedCitizenBundle { + network: string + height: number + shardId: number + header: string + certificate: string + commitment: string + commitmentProof: string + objectId: string + objectPresent: boolean + object?: string + stateProof: string + validators: TrustedValidator[] +} + +export interface CitizenTrustAnchor { + network: string + validatorRoot: string +} + +export interface TrustedCitizenObject { + network: string + height: bigint + shardId: number + objectId: string + objectPresent: boolean + objectBytes?: Uint8Array + stateRoot: string + nextTrustAnchor: CitizenTrustAnchor +} + +const ORDER = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551') +const HALF_ORDER = ORDER >> 1n +const encoder = new TextEncoder() + +class Reader { + private offset = 0 + constructor(private readonly data: Uint8Array) {} + u8(): number { return this.take(1)[0] } + u16(): number { const b = this.take(2); return (b[0] << 8) | b[1] } + u32(): number { const b = this.take(4); return ((b[0] * 0x1000000) + (b[1] << 16) + (b[2] << 8) + b[3]) >>> 0 } + u64(): bigint { let n = 0n; for (const b of this.take(8)) n = (n << 8n) | BigInt(b); return n } + fixed(n: number): Uint8Array { return this.take(n) } + bytes(max: number): Uint8Array { const n = this.u32(); if (n > max) throw new Error('Citizen field exceeds protocol limit'); return this.take(n) } + done(): void { if (this.offset !== this.data.length) throw new Error('Citizen payload has trailing data') } + private take(n: number): Uint8Array { if (n < 0 || this.offset + n > this.data.length) throw new Error('Citizen payload is truncated'); const out = this.data.slice(this.offset, this.offset + n); this.offset += n; return out } +} + +class Writer { + private chunks: Uint8Array[] = [] + u32(n: number): void { this.chunks.push(new Uint8Array([(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255])) } + u64(n: bigint): void { const out = new Uint8Array(8); for (let i = 7; i >= 0; i--) { out[i] = Number(n & 255n); n >>= 8n }; this.chunks.push(out) } + fixed(v: Uint8Array): void { this.chunks.push(v) } + bytes(v: Uint8Array): void { this.u32(v.length); this.fixed(v) } + result(): Uint8Array { return concat(...this.chunks) } +} + +type Header = { + raw: Uint8Array + network: Uint8Array + height: bigint + shardRoot: Uint8Array + validatorRoot: Uint8Array + nextValidatorRoot: Uint8Array + certificateHash: Uint8Array +} +type Commitment = { raw: Uint8Array, shard: number, stateRoot: Uint8Array } +type MerkleProof = { index: number, leafCount: number, siblings: Uint8Array[] } +type StateProof = { exists: boolean, bitmap: Uint8Array, siblings: Uint8Array[] } +type Vote = { network: Uint8Array, height: bigint, round: bigint, headerHash: Uint8Array, voter: Uint8Array, publicKey: Uint8Array, signature: Uint8Array } +type Certificate = { network: Uint8Array, height: bigint, round: bigint, headerHash: Uint8Array, votes: Vote[] } + +export async function fetchTrustedCitizenObject(baseURL: string, shardId: number, objectId: string, anchor: CitizenTrustAnchor): Promise { + const url = new URL('/v2/light/object', new URL(baseURL, window.location.origin)) + url.searchParams.set('shard', String(shardId)) + url.searchParams.set('id', objectId) + const response = await fetch(url) + if (!response.ok) throw new Error(`Citizen proof request failed (${response.status})`) + return verifyTrustedCitizenBundle(await response.json() as TrustedCitizenBundle, anchor) +} + +export async function verifyTrustedCitizenBundle(bundle: TrustedCitizenBundle, anchor: CitizenTrustAnchor): Promise { + const trustedNetwork = hex32(anchor.network, 'network trust anchor') + const trustedValidatorRoot = hex32(anchor.validatorRoot, 'validator trust anchor') + const header = parseHeader(b64(bundle.header)) + if (!eq(header.network, trustedNetwork) || bundle.network.toLowerCase() !== toHex(trustedNetwork)) throw new Error('Citizen bundle is from an untrusted network') + if (!eq(header.validatorRoot, trustedValidatorRoot)) throw new Error('Citizen header uses an untrusted validator set') + + const calculatedValidatorRoot = await validatorRoot(bundle.validators) + if (!eq(calculatedValidatorRoot, header.validatorRoot)) throw new Error('Citizen validator set does not match header commitment') + const certificate = parseCertificate(b64(bundle.certificate)) + await verifyCertificate(header, certificate, bundle.validators) + + const commitment = parseCommitment(b64(bundle.commitment)) + if (commitment.shard !== bundle.shardId) throw new Error('Citizen shard commitment mismatch') + if (!await verifyMerkle(header.shardRoot, await leaf('shard-commitment', commitment.raw), parseMerkleProof(b64(bundle.commitmentProof)))) throw new Error('Shard commitment is not finalized') + + const id = hex32(bundle.objectId, 'object ID') + const proof = parseStateProof(b64(bundle.stateProof)) + if (proof.exists !== bundle.objectPresent) throw new Error('Citizen state-proof presence mismatch') + let objectBytes: Uint8Array | undefined + let value: Uint8Array | undefined + if (bundle.objectPresent) { + if (!bundle.object) throw new Error('Citizen object bytes are missing') + objectBytes = b64(bundle.object) + if (objectBytes.length < 32 || !eq(objectBytes.slice(0, 32), id)) throw new Error('Citizen object ID mismatch') + value = await dh('zephyr/object/v2', objectBytes) + } + if (!await verifySMT(commitment.stateRoot, id, value, proof)) throw new Error('Citizen object state proof is invalid') + + const nextRoot = isZero(header.nextValidatorRoot) ? header.validatorRoot : header.nextValidatorRoot + return { + network: toHex(header.network), height: header.height, shardId: commitment.shard, + objectId: toHex(id), objectPresent: bundle.objectPresent, objectBytes, + stateRoot: toHex(commitment.stateRoot), + nextTrustAnchor: { network: toHex(header.network), validatorRoot: toHex(nextRoot) } + } +} + +function parseHeader(raw: Uint8Array): Header { + if (raw.length !== 234) throw new Error('Invalid Zephyr v2 GlobalHeader size') + const r = new Reader(raw) + if (r.u16() !== 2) throw new Error('Unsupported Zephyr header version') + const network = r.fixed(32) + const height = r.u64() + r.fixed(32) + const shardRoot = r.fixed(32) + const validatorRoot = r.fixed(32) + const nextValidatorRoot = r.fixed(32) + r.fixed(32) + const certificateHash = r.fixed(32) + r.done() + if (height === 0n || isZero(network) || isZero(shardRoot) || isZero(validatorRoot)) throw new Error('Invalid Zephyr v2 GlobalHeader') + return { raw, network, height, shardRoot, validatorRoot, nextValidatorRoot, certificateHash } +} + +function parseCommitment(raw: Uint8Array): Commitment { + const r = new Reader(raw) + const shard = r.u32() + const stateRoot = r.fixed(32) + r.fixed(32) + r.fixed(32) + r.done() + if (isZero(stateRoot)) throw new Error('Invalid shard commitment') + return { raw, shard, stateRoot } +} + +function parseMerkleProof(raw: Uint8Array): MerkleProof { + const r = new Reader(raw) + const index = r.u32(), leafCount = r.u32(), count = r.u32() + if (leafCount === 0 || index >= leafCount || count > 32) throw new Error('Invalid Merkle proof') + const siblings: Uint8Array[] = [] + for (let i = 0; i < count; i++) siblings.push(r.fixed(32)) + r.done() + return { index, leafCount, siblings } +} + +function parseStateProof(raw: Uint8Array): StateProof { + const r = new Reader(raw) + const exists = r.u8() + if (exists > 1) throw new Error('Invalid state proof') + const bitmap = r.fixed(32), count = r.u16() + if (count > 256) throw new Error('Invalid state proof') + const siblings: Uint8Array[] = [] + for (let i = 0; i < count; i++) siblings.push(r.fixed(32)) + r.done() + let bits = 0 + for (let i = 0; i < 256; i++) if (bitmapBit(bitmap, i)) bits++ + if (bits !== siblings.length) throw new Error('Invalid state-proof bitmap') + return { exists: exists === 1, bitmap, siblings } +} + +function parseVote(raw: Uint8Array): Vote { + const r = new Reader(raw) + const network = r.fixed(32), height = r.u64(), round = r.u64(), headerHash = r.fixed(32), voter = r.fixed(32) + const publicKey = r.bytes(65), signature = r.bytes(64) + r.done() + if (height === 0n || publicKey.length !== 65 || signature.length !== 64) throw new Error('Invalid validator vote') + return { network, height, round, headerHash, voter, publicKey, signature } +} + +function parseCertificate(raw: Uint8Array): Certificate { + const r = new Reader(raw) + const network = r.fixed(32), height = r.u64(), round = r.u64(), headerHash = r.fixed(32), count = r.u32() + if (height === 0n || count === 0 || count > 4096) throw new Error('Invalid quorum certificate') + const votes: Vote[] = [] + for (let i = 0; i < count; i++) votes.push(parseVote(r.bytes(512))) + r.done() + return { network, height, round, headerHash, votes } +} + +async function verifyCertificate(header: Header, certificate: Certificate, validators: TrustedValidator[]): Promise { + const unsigned = header.raw.slice() + unsigned.fill(0, unsigned.length - 32) + const headerHash = await dh('zephyr/global-header-consensus/v2', unsigned) + if (!eq(certificate.network, header.network) || certificate.height !== header.height || !eq(certificate.headerHash, headerHash)) throw new Error('QC does not target finalized header') + + const set = new Map() + let total = 0n + for (const validator of validators) { + const id = hex32(validator.id, 'validator ID'), key = b64(validator.publicKey), power = BigInt(validator.power) + if (key.length !== 65 || power <= 0n || power > 0xffffffffffffffffn || !eq(await dh('zephyr/validator-id/v2', key), id)) throw new Error('Invalid validator identity') + const idHex = toHex(id) + if (set.has(idHex)) throw new Error('Duplicate validator') + set.set(idHex, { key, power }); total += power + } + let signed = 0n + const seen = new Set() + for (const vote of certificate.votes) { + if (!eq(vote.network, certificate.network) || vote.height !== certificate.height || vote.round !== certificate.round || !eq(vote.headerHash, certificate.headerHash)) throw new Error('QC contains vote for another target') + const id = toHex(vote.voter), validator = set.get(id) + if (!validator || seen.has(id) || !eq(validator.key, vote.publicKey)) throw new Error('QC contains unauthorized or duplicate vote') + seen.add(id) + if (!lowS(vote.signature) || !await verifyVote(vote)) throw new Error('QC contains invalid signature') + signed += validator.power + } + if (signed < (total * 2n) / 3n + 1n) throw new Error('QC is below 2/3+ voting power') + if (!eq(await certificateHash(certificate), header.certificateHash)) throw new Error('QC hash does not match header') +} + +async function verifyVote(vote: Vote): Promise { + const w = new Writer(); w.fixed(vote.network); w.u64(vote.height); w.u64(vote.round); w.fixed(vote.headerHash); w.fixed(vote.voter) + const framed = frame('zephyr/consensus/vote/v2', w.result()) + try { + const key = await crypto.subtle.importKey('raw', ab(vote.publicKey), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']) + return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, ab(vote.signature), ab(framed)) + } catch { return false } +} + +async function certificateHash(c: Certificate): Promise { + const votes = [...c.votes].sort((a, b) => cmp(a.voter, b.voter)) + const w = new Writer(); w.fixed(c.network); w.u64(c.height); w.u64(c.round); w.fixed(c.headerHash); w.u32(votes.length) + for (const vote of votes) { w.fixed(vote.voter); w.bytes(vote.publicKey); w.bytes(vote.signature) } + return dh('zephyr/quorum-certificate/v2', w.result()) +} + +async function validatorRoot(validators: TrustedValidator[]): Promise { + const items: Array<{ id: Uint8Array, key: Uint8Array, power: bigint }> = [] + for (const validator of validators) items.push({ id: hex32(validator.id, 'validator ID'), key: b64(validator.publicKey), power: BigInt(validator.power) }) + items.sort((a, b) => cmp(a.id, b.id)) + const leaves: Uint8Array[] = [] + for (const item of items) { + const w = new Writer(); w.fixed(item.id); w.bytes(item.key); w.u64(item.power) + leaves.push(await leaf('validator', w.result())) + } + return merkleRoot(leaves) +} + +async function verifyMerkle(root: Uint8Array, leafHash: Uint8Array, proof: MerkleProof): Promise { + let target = 1; while (target < proof.leafCount) target <<= 1 + let depth = 0; for (let n = target; n > 1; n >>= 1) depth++ + if (proof.siblings.length !== depth) return false + let current = leafHash, position = proof.index + for (const sibling of proof.siblings) { current = position % 2 === 0 ? await branch(current, sibling) : await branch(sibling, current); position = Math.floor(position / 2) } + return eq(current, root) +} + +async function merkleRoot(leaves: Uint8Array[]): Promise { + const empty = await dh('zephyr/merkle/empty/v2', new Uint8Array()) + if (leaves.length === 0) return empty + let current: Uint8Array[] = leaves.map(x => x.slice()) + let target = 1; while (target < current.length) target <<= 1 + while (current.length < target) current.push(empty.slice()) + while (current.length > 1) { const next: Uint8Array[] = []; for (let i = 0; i < current.length; i += 2) next.push(await branch(current[i], current[i + 1])); current = next } + return current[0] +} + +let smtDefaultsPromise: Promise | undefined +async function smtDefaults(): Promise { + if (!smtDefaultsPromise) smtDefaultsPromise = (async () => { const d: Uint8Array[] = new Array(257); d[256] = await dh('zephyr/smt/empty-leaf/v2', new Uint8Array()); for (let i = 255; i >= 0; i--) d[i] = await smtBranch(d[i + 1], d[i + 1]); return d })() + return smtDefaultsPromise +} + +async function verifySMT(root: Uint8Array, key: Uint8Array, value: Uint8Array | undefined, proof: StateProof): Promise { + if (proof.exists !== (value !== undefined)) return false + const defaults = await smtDefaults() + let current = value ? await smtLeaf(key, value) : defaults[256], siblingIndex = 0 + for (let i = 0; i < 256; i++) { + const depth = 256 - i + let sibling = defaults[depth] + if (bitmapBit(proof.bitmap, i)) { if (siblingIndex >= proof.siblings.length) return false; sibling = proof.siblings[siblingIndex++] } + const bitIndex = depth - 1, bit = (key[Math.floor(bitIndex / 8)] >> (7 - (bitIndex % 8))) & 1 + current = bit === 0 ? await smtBranch(current, sibling) : await smtBranch(sibling, current) + } + return siblingIndex === proof.siblings.length && eq(current, root) +} + +async function leaf(domain: string, payload: Uint8Array): Promise { return dh(`zephyr/merkle/leaf/v2/${domain}`, payload) } +async function branch(a: Uint8Array, b: Uint8Array): Promise { return dh('zephyr/merkle/branch/v2', concat(a, b)) } +async function smtBranch(a: Uint8Array, b: Uint8Array): Promise { return dh('zephyr/smt/branch/v2', concat(a, b)) } +async function smtLeaf(key: Uint8Array, value: Uint8Array): Promise { const w = new Writer(); w.fixed(key); w.bytes(value); return dh('zephyr/smt/leaf/v2', w.result()) } +async function dh(domain: string, payload: Uint8Array): Promise { return new Uint8Array(await crypto.subtle.digest('SHA-256', ab(frame(domain, payload)))) } +function frame(domain: string, payload: Uint8Array): Uint8Array { const w = new Writer(); w.bytes(encoder.encode(domain)); w.bytes(payload); return w.result() } +function bitmapBit(bitmap: Uint8Array, index: number): boolean { return (bitmap[Math.floor(index / 8)] & (1 << (index % 8))) !== 0 } +function lowS(sig: Uint8Array): boolean { if (sig.length !== 64) return false; const r = bigint(sig.slice(0, 32)), s = bigint(sig.slice(32)); return r > 0n && s > 0n && r < ORDER && s <= HALF_ORDER } +function bigint(v: Uint8Array): bigint { let n = 0n; for (const b of v) n = (n << 8n) | BigInt(b); return n } +function b64(v: string): Uint8Array { const raw = atob(v), out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out } +function hex32(v: string, label: string): Uint8Array { const s = v.trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(s)) throw new Error(`${label} must be 32-byte hex`); const out = new Uint8Array(32); for (let i = 0; i < 32; i++) out[i] = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16); return out } +function toHex(v: Uint8Array): string { return Array.from(v, b => b.toString(16).padStart(2, '0')).join('') } +function isZero(v: Uint8Array): boolean { let x = 0; for (const b of v) x |= b; return x === 0 } +function eq(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let x = 0; for (let i = 0; i < a.length; i++) x |= a[i] ^ b[i]; return x === 0 } +function cmp(a: Uint8Array, b: Uint8Array): number { for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i]; return a.length - b.length } +function concat(...values: Uint8Array[]): Uint8Array { const out = new Uint8Array(values.reduce((n, v) => n + v.length, 0)); let at = 0; for (const v of values) { out.set(v, at); at += v.length }; return out } +function ab(v: Uint8Array): ArrayBuffer { return v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength) as ArrayBuffer } diff --git a/apps/wallet/src/main.ts b/apps/wallet/src/main.ts index cdc0ba5a..be4450da 100644 --- a/apps/wallet/src/main.ts +++ b/apps/wallet/src/main.ts @@ -1,6 +1,11 @@ import { createApp } from 'vue' import App from './App.vue' +import CitizenPanel from './components/CitizenPanel.vue' import './style.css' createApp(App).mount('#app') +const citizenHost = document.createElement('div') +citizenHost.id = 'zephyr-citizen-node' +document.body.appendChild(citizenHost) +createApp(CitizenPanel, { apiBase: import.meta.env.VITE_ZEPHYR_API_BASE ?? '' }).mount(citizenHost) diff --git a/cmd/compute-provider/main.go b/cmd/compute-provider/main.go new file mode 100644 index 00000000..0fac0559 --- /dev/null +++ b/cmd/compute-provider/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/libp2p/go-libp2p/core/peer" + + p2p "github.com/zephyr-chain/zephyr-chain/internal/v2/network/p2p" + "github.com/zephyr-chain/zephyr-chain/internal/v2/provider" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +type startupInfo struct { + PeerID string `json:"peerId"` + Addresses []string `json:"addresses"` + Capabilities []string `json:"capabilities"` + Storage string `json:"storage"` +} + +func main() { + networkID, err := parseNetworkID(os.Getenv("ZEPHYR_NETWORK_ID")) + if err != nil { + log.Fatal("ZEPHYR_NETWORK_ID must be a 32-byte hex v2 NetworkID") + } + storage := strings.TrimSpace(os.Getenv("ZEPHYR_PROVIDER_STORAGE")) + if storage == "" { + storage = ".zephyr/compute-provider" + } + listen := strings.TrimSpace(os.Getenv("ZEPHYR_PROVIDER_LISTEN")) + if listen == "" { + listen = "/ip4/0.0.0.0/udp/9901/quic-v1" + } + service, err := provider.New(provider.DiskStore{Dir: storage}, provider.HashExecutor{}, provider.IdentityExecutor{}) + if err != nil { + log.Fatal(err) + } + node, err := p2p.New(p2p.Config{Network: networkID, ListenAddrs: []string{listen}}) + if err != nil { + log.Fatal(err) + } + defer node.Close() + node.SetComputeHandler(func(ctx context.Context, remote peer.ID, payload []byte) ([]byte, error) { + _ = remote // transport authenticates peer identity; job authorization is checked by on-chain state. + return service.Handle(ctx, payload) + }) + addresses := make([]string, 0, len(node.Addrs())) + for _, address := range node.Addrs() { + addresses = append(addresses, fmt.Sprintf("%s/p2p/%s", address.String(), node.ID().String())) + } + info := startupInfo{PeerID: node.ID().String(), Addresses: addresses, Capabilities: []string{"sha256", "identity"}, Storage: storage} + encoded, _ := json.Marshal(info) + fmt.Println(string(encoded)) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + <-ctx.Done() +} + +func parseNetworkID(value string) (types.NetworkID, error) { + var network types.NetworkID + raw, err := hex.DecodeString(strings.TrimSpace(value)) + if err != nil || len(raw) != len(network) { + return network, fmt.Errorf("invalid network ID") + } + copy(network[:], raw) + if types.IsZero32([32]byte(network)) { + return types.NetworkID{}, fmt.Errorf("invalid network ID") + } + return network, nil +} diff --git a/cmd/zephyr-econ-sim/main.go b/cmd/zephyr-econ-sim/main.go new file mode 100644 index 00000000..866d8533 --- /dev/null +++ b/cmd/zephyr-econ-sim/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" +) + +type simulationInput struct { + Epoch uint64 `json:"epoch,omitempty"` + PriorTargetBps uint32 `json:"priorTargetBps"` + Metrics economics.MonetaryMetrics `json:"metrics"` + Policy *economics.MonetaryPolicy `json:"policy,omitempty"` + ComputeMarket *economics.ComputeMarketMetrics `json:"computeMarket,omitempty"` + ScarcityConfig *economics.ComputeScarcityConfig `json:"scarcityConfig,omitempty"` + FeedbackPolicy *economics.ComputeFeedbackPolicy `json:"feedbackPolicy,omitempty"` +} + +type simulationOutput struct { + Monetary economics.MonetaryDecision `json:"monetary"` + Scarcity *economics.ComputeScarcitySnapshot `json:"scarcity,omitempty"` + Feedback *economics.ComputeFeedbackDecision `json:"feedback,omitempty"` +} + +func main() { + inputPath := flag.String("input", "-", "JSON input path, or - for stdin") + flag.Parse() + reader := io.Reader(os.Stdin) + if *inputPath != "-" { + file, err := os.Open(*inputPath) + if err != nil { + fatal(err) + } + defer file.Close() + reader = file + } + var input simulationInput + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + fatal(err) + } + policy := economics.DefaultShadowPolicy() + if input.Policy != nil { + policy = *input.Policy + } + decision, err := economics.EvaluateShadow(input.PriorTargetBps, input.Metrics, policy) + if err != nil { + fatal(err) + } + output := simulationOutput{Monetary: decision} + if input.ComputeMarket != nil { + if input.Epoch == 0 { + fatal(fmt.Errorf("epoch is required when computeMarket is provided")) + } + scarcityConfig := economics.DefaultComputeScarcityConfig() + if input.ScarcityConfig != nil { + scarcityConfig = *input.ScarcityConfig + } + scarcity, err := economics.BuildComputeScarcity(input.Epoch, *input.ComputeMarket, scarcityConfig) + if err != nil { + fatal(err) + } + output.Scarcity = &scarcity + feedbackPolicy := economics.DefaultComputeFeedbackPolicy(economics.ComputeFeedbackObserveOnly) + if input.FeedbackPolicy != nil { + feedbackPolicy = *input.FeedbackPolicy + } + feedback, err := economics.EvaluateComputeFeedback(decision, input.Metrics, policy, scarcity, feedbackPolicy) + if err != nil { + fatal(err) + } + output.Feedback = &feedback + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(output); err != nil { + fatal(err) + } +} + +func fatal(err error) { + _, _ = fmt.Fprintf(os.Stderr, "zephyr-econ-sim: %v\n", err) + os.Exit(1) +} diff --git a/docs/compute-economics-v2.md b/docs/compute-economics-v2.md new file mode 100644 index 00000000..3daee875 --- /dev/null +++ b/docs/compute-economics-v2.md @@ -0,0 +1,414 @@ +# Zephyr v2 Compute Economics — ZCR, ZCPI and ZCSI + +Status: **design contract + executable shadow-mode instrumentation**. + +This document specifies how Zephyr measures compute work and how compute-market conditions may later influence protocol incentives without relying on external oracles. + +The core rule is: + +```text +measure first -> simulate second -> activate last +``` + +Nothing in this document authorizes live monetary minting. The current implementation is shadow-only. + +## 1. Why one universal compute unit is not enough + +CPU, GPU FP32, GPU FP64, tensor/AI, memory-heavy, rendering, storage and network workloads are not honestly comparable with one theoretical FLOP number. + +Zephyr therefore models compute as a **normalized resource vector** plus a workload-specific logical unit definition. + +The v2 resource vector currently includes: + +```text +CPUUnits +GPUFP32Units +GPUFP64Units +TensorUnits +MemoryByteSeconds +VRAMByteSeconds +StorageBytes +NetworkBytes +``` + +A standardized workload specification binds: + +```text +WorkloadHash +BenchmarkHash +WorkClass +normalized Units +WorkVector +version +``` + +The `BenchmarkHash` anchors the meaning of the unit. Provider self-reported peak performance is not sufficient. + +## 2. Eligible compute observations + +A compute observation may enter economic telemetry only when all of the following are true: + +1. the workload is registered against a versioned benchmark/specification; +2. the job was funded with real on-chain escrow; +3. the job reached a verification-satisfied settled state; +4. the result commitment is finalized; +5. the provider payment is the amount actually settled on-chain. + +Zephyr does **not** use advertised provider prices, unfilled offers or arbitrary self-reported capacity as a price observation. + +## 3. ZCPI — Zephyr Compute Price Index + +ZCPI answers: + +> how many atomic ZPH units were actually paid for standardized, verified compute work? + +For each eligible workload class: + +```text +class price = settled ZPH / normalized verified work units +``` + +The reference implementation uses: + +- integer/fixed-point Q9 prices; +- per-class medians; +- configurable basket weights; +- EWMA smoothing; +- minimum samples per class; +- basket coverage; +- an explicit `Reliable` flag. + +If market coverage is too thin, ZCPI is marked unreliable and its price-trend signal is excluded from ZCSI. + +ZCPI is an internal Zephyr compute-market index. It is not CPI and does not claim to measure fiat purchasing power or real-world inflation. + +## 4. Why ZCPI alone must not control inflation + +A higher ZPH price for compute can have multiple causes: + +```text +compute supply became scarce +compute demand increased +ZPH purchasing power against compute changed +workload mix changed +``` + +Therefore `ZCPI up -> mint more ZPH` is not a safe rule. + +Zephyr combines price information with observable demand/supply conditions in a separate **Zephyr Compute Scarcity Index (ZCSI)**. + +## 5. ZCSI — Zephyr Compute Scarcity Index + +ZCSI is a bounded signed score derived only from on-chain/consensus-reproducible compute-market metrics. + +The reference inputs are: + +```text +EscrowBackedDemandUnits +VerifiedSupplyUnits +BacklogUnits +FulfilledUnits +UtilizationBps +ComputePriceTrendBps +ComputeIndexReliable +``` + +Interpretation: + +- positive ZCSI: standardized compute is relatively scarce; +- near-zero ZCSI: demand and verified capacity are broadly balanced; +- negative ZCSI: verified capacity is abundant relative to demand. + +The score is bounded in basis points and uses integer arithmetic only. + +## 6. What counts as demand + +`EscrowBackedDemandUnits` must represent standardized work for jobs with real locked budget/escrow. + +The following do **not** count as monetary demand: + +- free API requests; +- un-funded job drafts; +- provider-created fake offers; +- arbitrary mempool messages; +- unregistered workload units. + +This makes demand manipulation economically costly rather than free. + +## 7. What counts as supply + +`VerifiedSupplyUnits` must eventually be derived from capacity that is: + +- benchmarked against an approved workload specification; +- bound to a provider identity; +- backed by collateral where required; +- recently demonstrated/available rather than permanently self-declared; +- normalized to the same workload units used by demand. + +The current ZCSI implementation consumes this metric but does not yet define the final consensus transition that produces the capacity registry. Until that registry is authenticated, ZCSI remains shadow-only. + +## 8. ZCSI components + +The reference score combines five signals. + +### Demand/supply pressure + +Conceptually: + +```text +(Demand - Supply) / Supply +``` + +bounded to a finite interval. + +### ZCPI price trend + +The smoothed ZCPI trend enters only if the compute index is reliable. If reliability is false, its weight is removed rather than treated as zero-price information. + +### Backlog pressure + +```text +Backlog / EscrowBackedDemand +``` + +A growing funded backlog indicates work waiting for capacity. + +### Utilization pressure + +```text +VerifiedUtilization - TargetUtilization +``` + +High persistent utilization supports a scarcity interpretation; low utilization pushes in the opposite direction. + +### Fulfillment pressure + +```text +1 - FulfilledWork / EscrowBackedDemand +``` + +A low fulfillment ratio indicates that funded standardized demand is not being satisfied. + +## 9. Reliability gates + +ZCSI has its own reliability gate in addition to ZCPI reliability. + +The reference configuration requires minimum standardized demand and verified supply volumes. If these are not met: + +```text +ZCSI.Reliable = false +``` + +An unreliable ZCSI is prohibited from moving either compute incentive routing or the monetary target in the shadow feedback evaluator. + +## 10. Three feedback modes + +The repository implements three **simulation-only** modes. + +### Mode A — Observe only + +```text +ZCSI -> telemetry +compute reward share -> unchanged +inflation target -> unchanged +``` + +This is the default and safest mode. + +### Mode B — Reward routing + +```text +ZCSI -> compute incentive share +inflation target -> unchanged +``` + +When verified compute is scarce, a larger fraction of the epoch's **net issuance budget** may be suggested for verified compute incentives. When capacity is abundant, that share may fall. + +This changes distribution, not the total monetary target. + +This is the leading candidate for first activation after sufficient devnet evidence. + +### Mode C — Reward routing + narrow monetary band + +```text +ZCSI -> compute incentive share +ZCSI -> small bounded inflation correction +``` + +Mode C may additionally suggest a very small correction around the ZAMP target. The correction is: + +- bounded; +- integer-only; +- subject to ZAMP's overall min/max band; +- disabled when ZCSI is unreliable; +- shadow-only until long simulations show a clear stability benefit. + +The compute feedback sensitivity is intentionally much smaller for total inflation than for reward routing. + +## 11. Reference default parameters are test values + +The checked-in defaults exist to make simulations reproducible. They are not mainnet constants. + +The current reference configuration uses a 10,000-bps bounded ZCSI score and a weighted mix of demand/supply, price trend, backlog, utilization and fulfillment. + +The current feedback reference starts with a compute reward share and permits a larger adjustment to **distribution** than to the total inflation target. + +No parameter should become public-network policy without replay, sensitivity and adversarial simulation. + +## 12. Anti-manipulation model + +The main attacks to test are: + +### Wash compute + +An attacker controls both client and provider and creates fake jobs. + +Mitigations to measure: + +- real escrow/settlement cost; +- standardized verified work requirement; +- provider collateral/slashing; +- fee burn; +- median rather than mean pricing; +- EWMA smoothing; +- per-identity/provider concentration limits if required. + +### Fake demand spam + +Unfunded jobs do not count. Only escrow-backed standardized demand enters ZCSI. + +### Fake supply + +Advertised GPU/CPU capacity does not count merely because a provider claims it. Production supply accounting must be benchmark-backed and availability-aware. + +### Price self-trading + +ZCPI uses completed verified settlements, but a client/provider pair can still trade with itself at a cost. Median, minimum sample/coverage requirements, collateral, fees and future concentration metrics reduce leverage. Devnet simulation must explicitly measure the cost of moving the index. + +### Workload-mix attack + +Per-class ZCPI values are preserved. Basket weights and registry activation are versioned so one newly created workload class cannot silently redefine the whole compute economy. + +## 13. Monetary relationship + +ZCSI should influence **incentive routing before total issuance**. + +The intended priority is: + +```text +1. measure compute scarcity +2. adjust suggested compute incentive share +3. observe whether new capacity enters +4. only after evidence, test a small total-inflation correction +``` + +The protocol should not solve a GPU shortage by blindly printing currency. The goal is to direct incentives toward the scarce resource while preserving a stable monetary constitution. + +## 14. Economics simulator + +`cmd/zephyr-econ-sim` supports the base ZAMP shadow decision and optional compute-market inputs. + +When `computeMarket` is present, `epoch` is required. The simulator returns: + +```text +monetary +scarcity (ZCSI) +feedback (A/B/C shadow decision) +``` + +A representative input shape is: + +```json +{ + "epoch": 42, + "priorTargetBps": 200, + "metrics": { + "Supply": 1000000000, + "CirculatingSupply": 900000000, + "StakedSupply": 450000000, + "ProtocolReserve": 100000000, + "BurnedThisEpoch": 12000, + "FinalizedOperations": 1000000, + "ResourceUtilizationBps": 5000, + "AgeWeightedVelocityBps": 5000, + "ComputeIndexQ9": 7500000000, + "ComputePriceTrendBps": 800, + "ComputeIndexReliable": true + }, + "computeMarket": { + "EscrowBackedDemandUnits": 200000, + "VerifiedSupplyUnits": 150000, + "BacklogUnits": 30000, + "FulfilledUnits": 170000, + "UtilizationBps": 8500, + "ComputePriceTrendBps": 800, + "ComputeIndexReliable": true + }, + "feedbackPolicy": { + "Mode": 1, + "BaseComputeRewardShareBps": 1000, + "MinComputeRewardShareBps": 0, + "MaxComputeRewardShareBps": 3000, + "RewardSensitivityBps": 2000, + "MonetarySensitivityBps": 25, + "MaxInflationCorrectionBps": 25 + } +} +``` + +Mode values are currently: + +```text +0 = observe only +1 = reward routing +2 = reward routing + narrow monetary band +``` + +These numeric values are experimental protocol-tooling values, not a public RPC stability promise. + +## 15. Required simulation matrix + +Before any non-zero compute feedback becomes live, replay at least: + +- balanced demand/supply; +- AI demand boom; +- sudden provider loss; +- rapid GPU capacity entry; +- persistent overcapacity; +- ZCPI rising with stable demand/supply; +- ZCPI falling while backlog rises; +- thin/unreliable market coverage; +- wash compute between related accounts; +- provider concentration/cartel scenarios; +- job spam with and without escrow; +- compute-price shocks during low/high fee burn; +- validator/partition events while compute metrics are incomplete; +- recovery after prolonged scarcity; +- alternating scarcity/abundance designed to induce controller oscillation. + +Compare all three modes on exactly the same epoch sequence. + +## 16. Activation gates + +Compute feedback remains shadow-only until: + +1. workload registry changes are authenticated and delayed; +2. verified supply accounting is consensus-reproducible; +3. demand includes only escrow-backed standardized work; +4. ZCPI and ZCSI can be reproduced by Citizen Nodes from finalized state; +5. index manipulation cost is quantified; +6. feedback remains stable under adversarial oscillating workloads; +7. Mode B demonstrates that reward routing increases useful capacity without destabilizing ZPH; +8. Mode C, if ever considered, demonstrates a material benefit over Mode B; +9. governance changes to weights/sensitivities are bounded and delayed; +10. unreliable/missing compute metrics fail closed to zero feedback. + +## 17. Current recommendation + +Run Mode A on early devnet to collect data. + +Then replay the same history under A/B/C. + +If evidence supports activation, prefer **Mode B first**: keep the ~2% ZAMP monetary target structurally stable while allowing compute scarcity to redirect part of net issuance toward verified capacity. + +Mode C should remain a later option, not the default assumption. diff --git a/docs/economic-runtime-v2.md b/docs/economic-runtime-v2.md new file mode 100644 index 00000000..219c4c95 --- /dev/null +++ b/docs/economic-runtime-v2.md @@ -0,0 +1,324 @@ +# Zephyr v2 Finalized Economic Runtime + +Status: **shadow/experimental implementation**. + +This document describes the executable path that turns finalized Zephyr v2 activity into reproducible economic telemetry for ZCPI, ZCSI and ZAMP without activating live minting. + +Companion documents: + +- `docs/tokenomics-v2.md` +- `docs/compute-economics-v2.md` +- `docs/economic-state-v2.md` +- `docs/protocol-v2-implementation-status.md` + +The governing rule remains: + +```text +measure first -> simulate second -> activate last +``` + +## 1. Finality boundary + +Economic telemetry is not allowed to advance from mempool admission, proposal construction, unfinalized votes, provider advertisements or RPC summaries. + +The runtime path is now executable as: + +```text +proof-carrying transactions + -> deterministic execution + -> proposal / votes / QC + -> finalized global block + -> FinalizedShardObservation + -> EpochCollector + -> ShardEpochMetrics + -> EpochAggregate + -> ZCPI + -> ZCSI + -> ZAMP shadow decision + -> pending MonetaryEpochState + -> first candidate of next epoch + -> Merkle state root + -> proposal / votes / QC + -> finalized MonetaryEpochState +``` + +`EpochCollector` has an explicit preview boundary so a node can evaluate telemetry before durable state apply and promote the preview only when the corresponding finalized transition succeeds. + +The node owns independent clones of the collector and shadow epoch engine. External configuration objects cannot mutate economic history behind the runtime lock. + +## 2. Native-supply attribution across shards + +A cross-shard ZPH transfer must not make global supply temporarily disappear while its receipt is in flight. + +The collector therefore treats per-shard native supply as an attribution ledger: + +```text +source finalizes outbound receipt + -> global circulating supply unchanged + -> source attribution remains until import + +destination finalizes receipt import + -> source attribution -= amount + -> destination attribution += amount + -> global circulating supply unchanged +``` + +Fee burn remains a real reduction in circulating ZPH under the current compatibility fee policy. + +This attribution rule is economic telemetry. Consensus double-spend protection remains the cross-shard receipt/anti-replay protocol. + +## 3. Age-weighted velocity from finalized coins + +Only consumed native coin witnesses that actually appear in the finalized execution result enter the velocity accumulator. + +`CreatedHeight` is consensus-stamped by execution. Wallet timestamps are not trusted. + +The existing minimum-age/full-weight policy therefore continues to make rapid fresh-coin cycling contribute little or zero weight. + +## 4. Compute demand is stock-flow, not same-epoch volume + +Long AI/scientific/rendering jobs may be posted in one epoch and settle in another. Zephyr therefore does not require `fulfilled <= new demand` inside a single epoch. + +The v2 economic wire uses the exact conservation rule: + +```text +OpeningComputeBacklog ++ EscrowBackedComputeDemand += +ComputeFulfilled ++ ComputeExpired ++ ComputeBacklog +``` + +where `ComputeBacklog` is the closing backlog carried into the next epoch. + +This avoids declaring legitimate long-running jobs invalid merely because their creation and settlement occur in different economic epochs. + +## 5. Standardized compute only + +A compute job affects standardized ZCSI demand/backlog only when its `WorkloadHash` resolves to an active `WorkSpec` in the workload registry. + +Unregistered workloads may still execute in the compute market, but they do not silently acquire a made-up normalization factor and do not enter standardized monetary telemetry. + +The standardized job unit comes from the committed `WorkSpec`/`WorkVector`, not from provider-advertised peak FLOPS. + +## 6. ZCPI from finalized settlement evidence + +The compute execution path commits a canonical `SettlementReceipt` containing: + +```text +JobID +ResultRoot +provider payments +refund +slashed collateral +slash reward +expiry flag +``` + +The economic replay path parses that receipt and recomputes the deterministic settlement from the pre-finalization `OnChainJob` witness. + +A receipt is accepted for `VerifiedWork` only when the reconstructed settlement bytes match exactly. + +For replicated-majority jobs this includes: + +- strict-majority result selection; +- payment only to providers on the accepted result root; +- refund accounting; +- deterministic slashing of dissenting collateral; +- slash-reward accounting. + +ZCPI then uses the ZPH actually paid for successfully verified standardized work. Offer prices, refunds and collateral movement are excluded from the compute price observation. + +## 7. Authenticated compute supply is a separate reliability gate + +A numeric capacity value is not enough to make ZCSI trustworthy. + +Each shard metric carries: + +```text +VerifiedComputeSupply +ComputeSupplyReliable +``` + +The numeric value can still be recorded for experiments, but `ComputeSupplyReliable=false` prevents ZCSI from becoming reliable regardless of how large the claimed capacity is. + +The production path must eventually derive reliable supply from a consensus-reproducible benchmark/collateral/availability registry. + +Until that exists, the safe runtime behavior is: + +```text +capacity telemetry may exist +ZCSI reliability = false +ZCSI monetary influence = zero +``` + +## 8. ZCSI inputs + +For an epoch, ZCSI can combine: + +```text +active standardized demand +verified standardized supply +closing backlog +fulfilled work +compute utilization +reliable ZCPI trend +``` + +Active demand is: + +```text +OpeningComputeBacklog + EscrowBackedComputeDemand +``` + +The ZCPI price component is ignored automatically when ZCPI coverage/reliability is insufficient. + +The whole ZCSI signal is not marked reliable unless the compute-supply reliability gate and minimum demand/supply thresholds pass. + +## 9. Shadow epoch engine + +`ShadowEpochEngine` composes one closed economic epoch as: + +```text +ShardEpochMetrics + -> EpochAggregate + -> BuildComputeIndex (ZCPI) + -> ComputePriceTrendBps + -> BuildComputeScarcity (ZCSI) + -> BuildShadowMonetaryEpochState (ZAMP + compute feedback) + -> ShadowMonetaryTransition +``` + +It follows a preview/accept model. + +`PreviewCloseEpoch` produces: + +- canonical aggregate; +- ZCPI snapshot; +- bounded price trend; +- ZCSI snapshot; +- ZAMP decision; +- compute-feedback decision A/B/C; +- pending `MonetaryEpochState`; +- consumed/created object delta. + +It does **not** advance controller history. + +`Accept` advances the controller only after the caller has finalized the exact monetary object through normal consensus/state finality. + +## 10. Automatic epoch scheduling and consensus inclusion + +When automatic shadow epochs are enabled, the runtime requires an epoch length of at least two blocks. + +At a configured epoch-boundary height: + +1. normal v2 consensus finalizes the boundary block; +2. the finalized collector preview closes the epoch; +3. ZCPI, ZCSI and ZAMP are evaluated; +4. the collector advances to the next economic epoch; +5. the resulting `MonetaryEpochState` becomes **pending**, not finalized. + +The next `BuildCandidate` automatically adds the pending monetary system-object delta to shard 0 before state-root simulation. + +Therefore the next proposal commits the exact monetary-state bytes through the shard `StateRoot` and global commitment root. The shadow epoch engine is advanced only if that candidate receives a valid QC and the state apply succeeds. + +If the certificate is rejected, neither the economic collector nor the monetary-history engine advances. + +The scheduler does not mint ZPH. `ShadowGrossMintTarget` and compute-incentive amounts remain recorded simulation outputs only. + +## 11. Monetary state chaining + +Each accepted shadow monetary object commits the prior accepted state through: + +```text +PreviousStateHash +``` + +The engine rejects: + +- skipped epochs; +- another network; +- a mismatched aggregate hash; +- a mismatched ZCPI snapshot; +- a broken prior-state hash. + +This is designed so a Citizen Node can eventually verify the complete economic-controller history from Merkle proofs rather than trusting a dashboard or validator RPC. + +## 12. Feedback modes remain shadow + +The three compute-feedback modes remain: + +```text +A — observe only +B — change suggested compute reward routing only +C — B plus a narrow bounded suggested inflation correction +``` + +No mode mints live ZPH in the current implementation. + +A reliable ZCSI is mandatory before modes B/C can alter even the simulated reward routing/target. + +Mode B remains the preferred first activation candidate if long-run testing eventually supports it. + +## 13. Runtime resource utilization + +The finalized collector includes a deterministic shadow resource-unit counter so chain utilization can be replayed without wall-clock measurements. + +The current reference units account for bounded protocol work such as: + +- finalized base transaction; +- transaction intent bytes; +- inputs/consumed/created objects; +- cross-shard outputs/imports; +- data-availability bytes. + +This is **not** the final production gas schedule. It exists so simulations can test controller stability using a deterministic load signal while the final resource-pricing schedule remains an activation decision. + +## 14. What is implemented now + +Executable foundations now include: + +- finalized-block economic collector; +- atomic collector preview/apply semantics; +- runtime-owned collector/engine clones; +- successful `Runtime.Commit` wiring into finalized economic telemetry; +- rejected-QC protection: failed commits cannot advance economic history; +- native supply attribution across shard receipt imports; +- compatibility full-fee-burn accounting; +- age-weighted finalized-spend velocity; +- compute backlog carry-over across epochs; +- compute expiry accounting; +- verified settlement-receipt reconstruction; +- finalized `VerifiedWork` extraction; +- independent authenticated-supply reliability bit; +- canonical v2 shard/aggregate economic metrics; +- previewable ZCPI/ZCSI/ZAMP shadow epoch engine; +- automatic configured epoch-boundary closure; +- pending Merkle monetary-object delta generation; +- automatic pending-state insertion into the first candidate of the next epoch; +- QC/state-finality acceptance of the monetary object; +- explicit proof that shadow issuance suggestions do not mutate live supply; +- tests for atomic rejection, cross-shard supply conservation, multi-epoch compute backlog, verified settlement pricing, shadow-state chaining and three-block epoch scheduling. + +## 15. What is still required + +Before monetary activation, Zephyr still needs: + +1. authenticated total-supply, staking and protocol-reserve system objects rather than genesis/operator-supplied shadow balance inputs; +2. production benchmarked/collateralized compute-capacity registry; +3. governance-delayed workload-registry and economic-parameter changes; +4. final gas/resource pricing and active burn/validator/reserve distribution; +5. replay/oscillation/manipulation datasets over long devnet runs; +6. Citizen wallet economic-state decoder/history UI; +7. durable recovery/replay of scheduler controller metadata across full node restarts; +8. transactional global multi-shard state-commit coordination so a backend error cannot leave an earlier shard applied while a later shard fails; +9. explicit protocol version/height gates before any live issuance or reward redistribution. + +The current invariant is therefore: + +```text +finalized data -> reproducible shadow decision +shadow decision -> consensus-finalized telemetry object +shadow decision != permission to mint +``` diff --git a/docs/economic-state-v2.md b/docs/economic-state-v2.md new file mode 100644 index 00000000..672cf1e4 --- /dev/null +++ b/docs/economic-state-v2.md @@ -0,0 +1,308 @@ +# Zephyr v2 Economic State and Epoch Accounting + +Status: **shadow/experimental protocol foundation**. + +This document defines how Zephyr can make ZAMP, ZCPI and ZCSI inputs reproducible from finalized chain state without creating a global per-transaction bottleneck. + +It complements: + +- `docs/tokenomics-v2.md` +- `docs/compute-economics-v2.md` +- `docs/protocol-v2.md` + +The current implementation does **not** activate live ZPH minting. + +## 1. No global monetary hot object per transaction + +A single monetary object consumed by every transaction would serialize execution and work against Zephyr's parallel/object/sharded architecture. + +Zephyr therefore separates: + +```text +per-transaction execution + -> per-shard block/epoch telemetry + -> canonical ShardEpochMetrics + -> epoch aggregation + -> shadow MonetaryEpochState +``` + +Only the epoch boundary needs a global monetary-state transition. + +## 2. Consensus-stamped coin age + +The v2 `Coin` object includes: + +```text +Token +Amount +CreatedHeight +``` + +`CreatedHeight` is not trusted from the wallet. During execution, every newly created coin output is rewritten with the candidate block height by the deterministic executor. + +This applies to: + +- native transfers; +- native fee/change outputs; +- token creation; +- custom-token mint; +- custom-token burn change; +- cross-shard outbound coin outputs. + +A wallet may place any height in its proposed output bytes, but the executed object uses the consensus execution height. + +Height `0` is reserved for genesis/unknown-age data and is excluded from age-weighted velocity until a policy explicitly defines otherwise. + +## 3. Age-weighted velocity + +Naive on-chain transfer volume is easy to manipulate by cycling the same funds repeatedly. + +The reference velocity accumulator instead weights a finalized spend by the age of the consumed coin: + +```text +age = spendHeight - CreatedHeight +``` + +with configurable: + +```text +MinAgeBlocks +FullWeightAgeBlocks +MaxVelocityBps +``` + +For age below `MinAgeBlocks`, the contribution is zero. + +Between minimum age and full-weight age, contribution increases with age. + +Above full-weight age, the contribution saturates. + +Conceptually: + +```text +contribution = amount * boundedAgeWeight +``` + +The epoch value is normalized by circulating native supply. + +The accumulator uses arbitrary-precision intermediate arithmetic and only converts to bounded consensus values at finalization. + +### Rapid self-cycling + +If a coin is spent and immediately recreated, the new output receives the current consensus height. Repeated rapid cycling therefore keeps resetting coin age and cannot repeatedly receive full velocity weight. + +This does not make manipulation impossible; it makes the attacker sacrifice time/capital lockup rather than obtaining free volume by fast self-transfers. + +## 4. Per-shard economic metrics + +Each shard can produce a canonical `ShardEpochMetrics` record containing: + +```text +Version +Epoch +ShardID +ChargedFees +BurnedFees +ValidatorFees +ReserveFees +FinalizedOperations +ResourceUsed +ResourceCapacity +CirculatingNativeSupply +AgeWeightedVelocityBps +EscrowBackedComputeDemand +VerifiedComputeSupply +ComputeBacklog +ComputeFulfilled +``` + +Validation requires exact fee conservation: + +```text +ChargedFees = BurnedFees + ValidatorFees + ReserveFees +``` + +and rejects impossible compute accounting such as fulfilled work above verified capacity or backlog/fulfilled units that overlap beyond funded demand. + +Each record has canonical binary bytes and a domain-separated hash. + +## 5. Epoch aggregation + +`AggregateEpochMetrics` combines unique shard records for the same epoch. + +The aggregation is deterministic and overflow-safe. + +It calculates two distinct utilization metrics: + +### Chain resource utilization + +```text +sum(ResourceUsed) / sum(ResourceCapacity) +``` + +This is a ZAMP/network signal. + +### Compute utilization + +```text +sum(ComputeFulfilled) / sum(VerifiedComputeSupply) +``` + +This is a ZCSI/compute-market signal. + +They are deliberately separate. High blockchain congestion is not evidence of GPU scarcity, and idle blockspace is not evidence of excess compute capacity. + +## 6. Multi-shard velocity weighting + +Shard velocity is not averaged equally across shards. + +The global epoch velocity is weighted by the native circulating supply represented by each shard: + +```text +GlobalVelocity = + sum(ShardCirculatingSupply * ShardVelocity) + / sum(ShardCirculatingSupply) +``` + +A tiny shard therefore cannot move global monetary telemetry as much as a shard holding a large fraction of circulating ZPH merely by reporting an extreme velocity value. + +## 7. Canonical epoch commitment + +The aggregated economic record has canonical bytes and a domain-separated `EpochAggregate.Hash()`. + +This hash is the bridge between high-throughput per-shard accounting and the global epoch monetary state. + +The intended future finality path is: + +```text +finalized shard metrics + -> epoch aggregate hash + -> MonetaryEpochState + -> state root / global finality +``` + +The exact placement of the economics commitment in the final public `GlobalHeader` remains an activation decision and must not be changed without wallet/light-client conformance vectors. + +## 8. Shadow MonetaryEpochState + +The reference implementation defines a deterministic `MonetaryEpochState` system object. + +It records: + +```text +Network +Epoch +Shadow = true +TotalSupply +CirculatingSupply +StakedSupply +ProtocolReserve +ZAMPBaseTargetBps +SuggestedTargetBps +BaseFee +AggregateHash +ComputeIndexQ9 +ComputeIndexReliable +ComputeScarcity score + reliability +FeedbackMode +ShadowGrossMintTarget +ShadowComputeIncentiveMint +InflationCorrection +PreviousStateHash +``` + +The system-object ID is deterministic for the network and the object version equals the epoch. + +## 9. Shadow means no issuance side effect + +`BuildShadowMonetaryEpochState` evaluates ZAMP and optional ZCSI feedback but does not mutate supply. + +In Mode C it may record, for example: + +```text +SuggestedTargetBps = 213 +ShadowGrossMintTarget = X +``` + +while still recording: + +```text +TotalSupply = observed pre-transition supply +Shadow = true +``` + +No transaction/executor path mints those suggested ZPH. + +A future activation must introduce a separate, explicit consensus transition and activation height/version. Shadow records are not authorization to mint. + +## 10. Epoch state chain + +Every state after the first can commit: + +```text +PreviousStateHash +``` + +The builder rejects a previous state from another network or a skipped/non-consecutive epoch. + +This enables a Citizen Node or audit tool to replay the economic-controller history rather than trusting a current RPC's summary. + +## 11. ZCPI/ZCSI feedback modes + +The economic state can record the same three simulation modes defined in `docs/compute-economics-v2.md`: + +```text +A: observe only +B: adjust compute reward routing only +C: adjust reward routing + narrow shadow inflation band +``` + +Mode B remains the preferred first candidate if devnet evidence eventually supports activation. + +Mode C remains experimental. + +## 12. Still required before live economics + +This foundation does not yet complete: + +- authenticated production derivation of verified compute capacity; +- final fee split and resource price constants/controller activation; +- live validator reward distribution; +- live protocol reserve credit/debit transitions; +- live ZPH mint/burn monetary transition; +- governance bounds and delayed parameter activation; +- economics commitment in the final light-client/global-header contract; +- long-run epoch replay datasets; +- adversarial money-velocity calibration; +- Citizen wallet UI for economic-state verification; +- economic recovery rules when compute telemetry is incomplete. + +Until those gates pass: + +```text +ZAMP = shadow +ZCSI feedback = shadow +suggested mint != minted supply +``` + +## 13. Testing strategy + +The repository tests should continuously assert: + +- wallet-provided coin age cannot survive execution unchanged; +- rapid fresh-coin cycling has little/zero velocity weight under the configured minimum age; +- old circulating coins receive higher bounded weight; +- per-shard fee accounting conserves every atomic ZPH unit; +- chain utilization and compute utilization are not conflated; +- shard velocity aggregation is supply-weighted; +- duplicate shard metrics are rejected; +- monetary epoch state round-trips canonically; +- epoch state binds the prior epoch hash; +- skipped epochs are rejected; +- Mode C can produce a suggestion without mutating `TotalSupply`. + +The engineering rule remains: + +```text +measure first -> simulate second -> activate last +``` diff --git a/docs/examples/zephyr-econ-sim-compute.json b/docs/examples/zephyr-econ-sim-compute.json new file mode 100644 index 00000000..f01b3bda --- /dev/null +++ b/docs/examples/zephyr-econ-sim-compute.json @@ -0,0 +1,35 @@ +{ + "epoch": 42, + "priorTargetBps": 200, + "metrics": { + "Supply": 1000000000, + "CirculatingSupply": 900000000, + "StakedSupply": 450000000, + "ProtocolReserve": 100000000, + "BurnedThisEpoch": 12000, + "FinalizedOperations": 1000000, + "ResourceUtilizationBps": 5000, + "AgeWeightedVelocityBps": 5000, + "ComputeIndexQ9": 7500000000, + "ComputePriceTrendBps": 800, + "ComputeIndexReliable": true + }, + "computeMarket": { + "EscrowBackedDemandUnits": 200000, + "VerifiedSupplyUnits": 150000, + "BacklogUnits": 30000, + "FulfilledUnits": 170000, + "UtilizationBps": 8500, + "ComputePriceTrendBps": 800, + "ComputeIndexReliable": true + }, + "feedbackPolicy": { + "Mode": 1, + "BaseComputeRewardShareBps": 1000, + "MinComputeRewardShareBps": 0, + "MaxComputeRewardShareBps": 3000, + "RewardSensitivityBps": 2000, + "MonetarySensitivityBps": 25, + "MaxInflationCorrectionBps": 25 + } +} diff --git a/docs/protocol-v2-current-status.md b/docs/protocol-v2-current-status.md new file mode 100644 index 00000000..9913b065 --- /dev/null +++ b/docs/protocol-v2-current-status.md @@ -0,0 +1,114 @@ +# Zephyr Protocol v2 — Current Executable Status + +This snapshot complements the authoritative `docs/protocol-v2.md`. It exists so implementation progress cannot be confused with future design goals. + +## Executable now on the v2 branch + +### Consensus and trust + +- canonical binary proposal, vote, quorum-certificate and `GlobalHeader` objects; +- low-S P-256 validator signatures; +- deterministic weighted proposer selection; +- exact `2/3+` integer voting-power quorum; +- duplicate-voter rejection; +- validator-set Merkle root over validator ID/public key/power; +- proposal rejection when the local authorized validator root differs from the header; +- pre-vote rejection when independently executed local state produces another candidate header; +- `GlobalHeader.ValidatorRoot` plus `GlobalHeader.NextValidatorRoot`; +- QC-backed validator-set transition: current committee authorizes the next committee for the following height; +- genesis-derived Citizen trust anchor (`NetworkID`, `ValidatorRoot`); +- dedicated 7-validator v2 Lab with happy path, 4/3 no-quorum partition/heal, 5/2 quorum/minority catch-up and conflicting-proposal rejection. + +### State, proofs and persistence + +- object/coin state model; +- 256-bit Sparse Merkle state; +- compressed inclusion/absence proofs; +- proof-carrying P-256 transactions; +- incremental state updates; +- copy-on-write non-mutating root preview before QC; +- preview-vs-real-apply equivalence tests; +- append-only network-bound WAL with CRC32C, sequence numbers and fsync; +- atomic checkpoint and crash-tail recovery; +- wrong-network persisted-state rejection; +- streamed canonical domain hashing and fixed-size branch hashing to reduce hot-path allocations. + +### Execution, assets and sharding + +- native ZPH/object transfers; +- protocol-native custom token creation; +- deterministic parallel execution of independent transactions; +- conflict rejection for shared inputs, duplicate transactions and mismatched pre-state roots; +- permanent shard placement encoded into object IDs; +- remote outputs become source-shard receipts rather than illegal local writes; +- receipt root committed in the source shard; +- destination import verifies source QC + authorized historical validator root + shard proof + receipt proof; +- destination import creates the deterministic destination object and a Merkle-state anti-replay marker; +- cross-shard receipt is spendable only from a later block because current-block transactions remain anchored to pre-state; +- tested two-shard transfer and durable replay rejection. + +### Citizen wallet + +- light proof API for finalized status and object proofs; +- Go Citizen verifier; +- browser cryptographic verifier using WebCrypto/BigInt; +- strict rotating-trust verifier in `apps/wallet/src/lib/v2CitizenTrusted.ts`; +- independent validator-root reconstruction, low-S P-256 QC verification, exact quorum, shard proof and Sparse-Merkle proof validation; +- returned `nextTrustAnchor` advances only after the current trusted committee finalizes the new root; +- battery/network-aware modes for verify-only, relay, bounded DA/cache and opportunistic execution. + +### Smart-contract foundation + +- WASM deployment/runtime abstraction; +- consensus-side metering guard; +- fuel limit; +- bounded args/result/events/access count; +- declared object read/write access; +- rejection of undeclared writes. + +### Native compute-market foundation + +- CPU/GPU/RAM/VRAM/storage/bandwidth/capability offers; +- collateral and pricing data; +- compute jobs with escrow/deadline; +- provider matching/assignment; +- replicated provider assignment; +- result submission, settlement, refund and expiry; +- deterministic, replicated, challenge, ZK-signal, TEE-attestation-signal, client-approval and hybrid verification policies. + +Heavy jobs are provider-executed. Validator consensus settles compact evidence and never requires every validator to replay AI training, scientific computation, rendering or similar workloads. + +### CI and measurement + +- legacy v1 Consensus & Performance Lab remains a regression gate; +- dedicated `V2 Lab` workflow runs v2 conformance and repeated partition tests; +- finalized v2 32-transfer benchmark uses seven validators and 1/4/8/16 execution workers; +- client key generation/signing remains outside the timed consensus path; +- the pre-allocation-optimization shared-runner sample peaked around 8 workers and showed allocation/state-proof work mattered more than simply increasing worker count, motivating the current hash/preview optimizations. + +## Deliberately not called production-complete yet + +The following are still engineering gates rather than shipped claims: + +- live process-facing v2 node/devnet wiring and durable runtime-height/round metadata; +- staking/governance policy for validator-set updates; +- full v2 fault transport with restart, proposer death, delay, duplicate/reorder and Byzantine cases; +- production libp2p/QUIC/mobile peer discovery and shard-aware gossip; +- 4/16-shard recovery and controlled-hardware scaling proof; +- reshard/split/merge policy; +- concrete deterministic production WASM engine, audited fuel schedule and Rust SDK; +- compute provider daemon, consensus-object escrow/slashing/disputes and concrete TEE/ZK integrations; +- production erasure coding/reconstruction and data-withholding fault tests; +- native Android/iOS lifecycle, resumable Citizen cache/relay and real-device battery/RAM/bandwidth measurements; +- controlled-hardware performance regression budgets; +- public v2 genesis/checkpoint/operator upgrade procedures. + +## Activation rule + +No subsystem is activated on a public Zephyr v2 network because it exists in code. It is activated only when its safety/liveness tests, restart/recovery tests and relevant performance/resource measurements pass. + +```text +more hardware -> more throughput +less hardware -> less throughput +less hardware -/-> weaker correctness +``` diff --git a/docs/protocol-v2-implementation-status.md b/docs/protocol-v2-implementation-status.md new file mode 100644 index 00000000..83e6f8a0 --- /dev/null +++ b/docs/protocol-v2-implementation-status.md @@ -0,0 +1,352 @@ +# Zephyr Protocol v2 — Implementation Status + +This document tracks what the clean-break v2 branch **actually implements**, what exists only as a reference/experimental boundary, and what still requires production engineering. + +Authoritative companion documents: + +- `docs/protocol-v2.md` — architecture and clean-break contract; +- `docs/tokenomics-v2.md` — oracle-free ZPH monetary direction; +- `docs/compute-economics-v2.md` — ZCR/ZCPI/ZCSI compute economics; +- `docs/economic-state-v2.md` — coin age, velocity, epoch aggregation and shadow monetary state; +- `docs/protocol-v2-validator-trust.md` — validator trust/rotation. + +Status legend: + +- **Implemented** — executable code and tests exist on the v2 branch. +- **Integrated foundation** — correctness boundary exists, but production integration is incomplete. +- **Shadow/experimental** — executable measurement/simulation/state exists but cannot change live monetary policy. +- **Not production-complete** — must not be presented as a shipped public-network capability. + +## Core identity, trust and wire protocol + +**Implemented** + +- canonical bounded binary codec for consensus-critical v2 data; +- typed network/account/node/validator/object/token/contract/job identities; +- genesis-derived `NetworkID`; +- independent account, node and validator identities; +- canonical low-S P-256 signing for proof-carrying transactions and validator consensus messages; +- binary proposal, vote and quorum-certificate wire formats; +- binary global-header, shard-commitment, cross-shard-receipt and Merkle-proof decoders; +- canonical Merkle commitment over validator ID, P-256 public key and integer voting power; +- proposal/runtime/import validation binds the exact validator root authorized by consensus; +- v2 genesis derives a `TrustAnchor { NetworkID, ValidatorRoot }` for Citizen wallets; +- QC-authorized `NextValidatorRoot` transitions and checkpoint-history foundations. + +## Object state and persistence + +**Implemented** + +- proof-oriented object model; +- coin objects carrying `Token`, `Amount` and consensus-stamped `CreatedHeight`; +- 256-bit Sparse Merkle Tree with incremental updates; +- compressed inclusion/absence proofs; +- in-memory world-state backend; +- non-mutating copy-on-write state preview; +- durable v2 backend with append-only WAL, CRC32C records, monotonic sequence numbers, network binding, fsync, atomic checkpointing and replay; +- safe truncation of torn WAL tails; +- rejection of persisted state from another network. + +`CreatedHeight` is overwritten by deterministic execution for newly created coin objects. Wallet-provided age metadata is therefore not trusted. + +**Not production-complete** + +- long-duration database growth/compaction benchmarks; +- large-state migration/repair tooling; +- production structured-KV/LSM backend comparison; +- archive/history indexing; +- further proof/state allocation reduction under large batches. + +## Proof-carrying transactions, native assets and execution + +**Implemented** + +- P-256 signed proof-carrying transaction format; +- state-root-bound object witnesses; +- witness verification without requiring full-state trust; +- native ZPH/object transfers; +- custom token creation; +- explicit custom-token supply policies: fixed, capped and mintable; +- native custom-token mint operation with authority/cap enforcement; +- native custom-token burn operation with authenticated `CurrentSupply` update; +- `Transferable` enforcement from a token-definition witness; +- read-only token-definition sharing so independent transfers of one token can remain parallel; +- ZPH cannot be minted through user custom-token authority; +- deterministic conservation and fee checks; +- deterministic parallel batch executor; +- rejection of batches with shared writes, duplicate transactions or different pre-state roots; +- atomic merge of independent execution results; +- state-root simulation before consensus finality; +- permanent shard placement encoded into object IDs; +- contract deploy/call execution path; +- compute-market operations represented in consensus object execution. + +**Activation guardrail** + +Custom-token cross-shard transfers/mints remain rejected until Zephyr has a globally verifiable token-policy proof/registry path. ZPH cross-shard receipts remain supported. + +**Not production-complete** + +- authenticated cross-shard custom-token policy distribution; +- active fee distribution objects/accounting for burn/validator/reserve shares; +- finalized production resource-unit gas schedule. + +The key invariant remains: candidate execution may calculate a future state root, but committed state is not mutated before a valid quorum certificate exists. + +## Consensus and global finality + +**Implemented** + +- integer voting power and deterministic weighted proposer selection; +- domain-separated proposal/vote signatures; +- locally reconstructed `2/3+` quorum; +- duplicate-voter rejection; +- canonical quorum-certificate hash; +- validator-set root as proposal validity invariant; +- `GlobalHeader` consensus hash without certificate/hash circularity; +- validator rotation commitment via `NextValidatorRoot`; +- dedicated v2 seven-validator conformance and partition-stress gate. + +Runtime path: + +```text +proof-carrying transactions + -> parallel execution + -> state-root simulation + -> shard commitments + -> GlobalHeader + -> proposal + -> votes + -> quorum certificate + -> state commit +``` + +**Not production-complete** + +- broader restart/proposer-death/Byzantine/wrong-chain matrix over production transport; +- governance-controlled validator-set activation policy; +- long-horizon checkpoint pruning/recovery rules. + +## Sharding + +**Implemented foundation** + +- deterministic account routing; +- permanent shard placement in object IDs; +- per-shard state/data/receipt commitments; +- global shard-commitment root; +- remote ZPH outputs become finalized cross-shard receipts; +- receipt Merkle batches/inclusion proofs; +- destination import verifies source QC, validator root, shard proof and receipt proof; +- durable Merkle-state anti-replay marker; +- two-shard end-to-end finalization/import/replay-rejection test; +- hostile self-signed validator-set receipts rejected. + +**Not production-complete** + +- shard-aware gossip/recovery under sustained faults; +- reshard/split/merge and object migration; +- receipt-marker pruning/history retention; +- custom-token global policy proof; +- 4/16-shard controlled-hardware conformance/recovery/throughput evidence. + +`shardCount = 1` remains the safe public activation value until those gates pass. + +## Citizen Node and smartphone wallet + +**Implemented** + +- Go Citizen verifier for headers/state/shard/data proofs; +- battery/network-aware participation policy; +- self-verifiable light API; +- strict wallet verification of canonical headers, low-S P-256 votes, exact quorum, validator roots, shard commitments and Sparse-Merkle proofs; +- genesis/checkpoint trust anchor and validator-root advancement; +- exact `uint64` voting power through decimal JSON + JavaScript `BigInt`; +- wallet resource modes for header-only, relay, DA sampling/cache and opportunistic recent execution. + +Because monetary state is a normal Merkle-authenticated system object, its bytes can already be proved through the same state-root path. A dedicated wallet monetary-state decoder/UI is not yet integrated. + +**Not production-complete** + +- Citizen status/control UI; +- iOS/Android lifecycle/background adapters; +- dedicated ZAMP/ZCSI state decoder/history view; +- multi-peer proof comparison/resumable cache/full relay integration; +- real-device RAM/battery/bandwidth measurements. + +## Smart contracts + +**Implemented foundation** + +- versioned contract deployment/runtime boundary; +- deterministic metered Zephyr Script reference runtime; +- bounded module/request/output/event limits; +- execution-step/fuel limits; +- declared read/write object sets and no undeclared writes; +- no clock/random/filesystem/network/import nondeterminism in the reference runtime; +- contract deploy/call executor integration and receipts. + +**Not production-complete** + +- production deterministic WASM engine and audited fuel schedule; +- Rust SDK/ABI tooling; +- cross-machine WASM conformance corpus and long-running contract fuzzing. + +## Native distributed compute market + +**Implemented** + +- provider offers and resource/capability requirements; +- collateral requirements; +- job posting with escrow/deadline; +- deterministic offer/job IDs; +- matching/assignment and multi-provider replicated verification; +- provider result submission; +- settlement, unused-escrow refund and expiry; +- objective replicated-majority slashing path; +- compute market object serialization and consensus transitions; +- verification-policy boundaries for deterministic, replicated, challenge, ZK, TEE, client-approved and hybrid evidence. + +Heavy compute is provider-executed; validators verify compact settlement evidence rather than replaying expensive workloads. + +### Compute economics — shadow/experimental + +**Implemented** + +- `WorkVector` resource representation instead of one fake universal FLOP scalar; +- workload classes and versioned `WorkSpec` bound to `WorkloadHash` + `BenchmarkHash`; +- conflicting registry definitions rejected; +- only finalized verification-satisfied settlements become `VerifiedWork` observations; +- offer prices and self-reported theoretical capacity excluded from ZCPI price observations; +- per-class medians, Q9 fixed-point pricing, EWMA, basket coverage and reliability; +- bounded compute-price trend; +- ZCSI combines escrow-backed standardized demand, verified supply, backlog, fulfillment, compute utilization and reliable ZCPI trend; +- unreliable ZCPI removes the price component rather than supplying fake information; +- ZCSI has independent minimum-demand/supply reliability gates; +- three simulator feedback modes: + - A — observe only; + - B — change suggested compute reward routing, not total inflation; + - C — reward routing plus a narrow bounded shadow inflation correction; +- unreliable ZCSI cannot move either reward routing or monetary target. + +**Not production-complete** + +- authenticated/governance-delayed workload registry; +- consensus-reproducible benchmarked/collateralized compute-capacity registry; +- provider daemon/scheduler and input/output distribution protocol; +- concrete production ZK verifier integrations; +- concrete production TEE attestation integrations; +- confidential-data key exchange; +- reputation/concentration/anti-collusion policy; +- empirical ZCPI basket weights and thresholds. + +## ZPH tokenomics and adaptive monetary policy + +See `docs/tokenomics-v2.md`, `docs/compute-economics-v2.md` and `docs/economic-state-v2.md`. + +### Shadow/experimental — implemented + +- no fixed max-supply assumption in the v2 economic design; +- long-run net supply-growth center near 2% annually; +- bounded/rate-limited adaptive ZAMP target using only on-chain signals; +- deterministic burn-offset accounting; +- resource fee quotation and burn/validator/reserve split reference engine; +- compatibility fee policy preserves current full-fee burn until authenticated distribution is activated; +- age-weighted velocity accumulator; +- consensus-stamped coin creation height used as the age anchor; +- rapid fresh-coin cycling can be assigned zero contribution below `MinAgeBlocks`; +- age weight saturates at `FullWeightAgeBlocks` and the velocity metric is bounded; +- per-shard canonical `ShardEpochMetrics` for fees, operations, chain resources, circulating native supply, velocity and compute market telemetry; +- exact fee conservation checks in shard epoch metrics; +- deterministic multi-shard epoch aggregation; +- global velocity is weighted by per-shard circulating ZPH rather than equal-weighted by shard; +- blockchain resource utilization and compute utilization are kept separate; +- canonical epoch aggregate hash; +- deterministic network-scoped `MonetaryEpochState` system-object ID; +- canonical shadow monetary-state serialization/hash; +- `PreviousStateHash` chains consecutive economic epochs; +- QC-safe object delta builder returns a state transition without mutating the store itself; +- shadow monetary object can be included in a Merkle state root via normal consume/recreate semantics; +- Mode C records suggested issuance without mutating `TotalSupply`; +- `cmd/zephyr-econ-sim` supports ZAMP plus optional ZCSI A/B/C replay. + +### Not production-complete / not active + +- ZAMP does not mint live ZPH; +- no public economic parameter set is final; +- active validator/compute/reserve reward distribution is not enabled; +- resource fee prices/base-fee controller are not consensus-active; +- runtime derivation of per-shard epoch metrics from every finalized block still needs completion; +- verified compute supply still needs an authenticated availability/benchmark source; +- the epoch monetary transition is not yet scheduled automatically by the node runtime; +- governance bounds/delays and emergency fallback rules remain open; +- Citizen monetary-state decoding/history UI remains open; +- Mode B/C feedback remains shadow-only until long-run/manipulation testing. + +## Data availability + +**Implemented foundation** + +- data roots and authenticated chunk/sample proofs; +- Reed-Solomon erasure-coded reconstruction; +- corrupted chunks rejected before reconstruction; +- bounded Citizen sampling mode. + +**Not production-complete** + +- final sampling confidence parameters; +- withholding-attack fault matrix; +- shard-aware data dissemination/repair; +- mobile bandwidth/storage measurements. + +## Transport + +**Implemented foundation** + +- separate consensus, transaction-relay and light-proof protocol capabilities; +- libp2p node identity separated from account/validator keys; +- QUIC path with network-scoped protocol IDs, frame limits/deadlines and loopback tests. + +**Not production-complete** + +- discovery/bootstrap/NAT/mobile relay policy; +- shard-aware gossip topology; +- full fault-transport equivalence matrix over libp2p/QUIC. + +## Performance gates + +**Implemented** + +- existing finalized-through-consensus v1 Lab remains mandatory; +- dedicated v2 seven-validator conformance/partition gate; +- v2 state/proof microbenchmarks; +- finalized v2 batch benchmark with 32 proof-carrying transfers, 7 validators and 1/4/8/16 execution workers; +- timed path includes witness/signature verification, execution/state-root simulation, proposal/votes, QC and committed transition. + +Shared CI numbers are development signals only, never production-capacity claims. + +## Activation gates + +Before a public v2 devnet: + +1. expand v2 fault coverage for restarts, proposer death, Byzantine/conflicting evidence and wrong-chain data; +2. complete long-running durable-state crash/recovery stress; +3. run Citizen verification against live nodes on real Android/iOS reference devices; +4. characterize one-shard performance on controlled hardware; +5. keep multi-shard public mode disabled until shard-aware recovery and 4/16-shard gates pass; +6. make production WASM deterministic/metered across machines; +7. complete production compute provider/evidence/dispute plumbing; +8. derive economic epoch metrics from finalized execution rather than external declarations; +9. authenticate verified compute capacity and registry activation; +10. keep ZAMP and ZCSI feedback shadow-only through replay, manipulation and oscillation tests; +11. activate any mint/reward/fee split only behind an explicit protocol version/height and governance bounds; +12. make genesis/checkpoint/operator upgrade and validator-rotation procedures explicit. + +The engineering rules remain: + +```text +more hardware -> more throughput +less hardware -> less throughput +less hardware -/-> weaker correctness +measure first -> simulate second -> activate last +``` diff --git a/docs/protocol-v2-validator-trust.md b/docs/protocol-v2-validator-trust.md new file mode 100644 index 00000000..1abe492d --- /dev/null +++ b/docs/protocol-v2-validator-trust.md @@ -0,0 +1,121 @@ +# Zephyr Protocol v2 — Validator Trust Chain + +This document specifies how validators, full nodes, cross-shard receipt importers and Citizen wallets identify the validator committee authorized to finalize a Zephyr v2 header. + +## Why a quorum certificate is not enough by itself + +A set of arbitrary keys can always create a self-consistent certificate for data they signed. Therefore a verifier must establish two facts independently: + +1. the votes reach Zephyr's normal `2/3+` voting-power quorum for a validator set; +2. that exact validator set is itself authorized by the already-trusted Zephyr chain. + +Zephyr v2 commits validator identity, P-256 public key and integer voting power into a canonical Merkle root. A header is only valid against a validator set whose calculated root equals `GlobalHeader.ValidatorRoot`. + +## Genesis trust anchor + +The canonical v2 genesis derives: + +```text +NetworkID = H(canonical genesis) +ValidatorRoot = Merkle(initial validator set) +``` + +`genesis.Config.TrustAnchor()` exposes these two values. A Citizen wallet can embed a known genesis or import an explicitly trusted checkpoint and needs no trusted RPC server to invent the committee for it. + +## Header transition rule + +Each canonical `GlobalHeader` contains: + +```text +ValidatorRoot current committee +NextValidatorRoot committee authorized for the next height +``` + +If `NextValidatorRoot` is zero, it means "unchanged" and the effective next root is `ValidatorRoot`. + +A committee transition is therefore: + +```text +trusted committee N + | + | signs GlobalHeader H + v +ValidatorRoot = root(N) +NextValidatorRoot = root(N+1) + | + | 2/3+ QC from N + v +committee N+1 becomes trusted for H+1 +``` + +The next committee does not authorize the block that installs itself. The currently trusted committee must finalize the transition first. + +## Validator behavior + +Before voting, a validator verifies all of the following: + +- proposal signature and scheduled proposer; +- proposal network and height; +- local validator-set root equals `Header.ValidatorRoot`; +- local deterministic execution produces the same `GlobalHeader` consensus hash; +- normal transaction/state/shard commitment rules. + +Only then may it sign a vote. + +## Runtime activation + +`node.ScheduleValidatorTransition` places the next set root in the candidate before proposal signing. + +`node.CommitWithValidatorTransition` first performs the normal QC-backed state commit using the current set. Only after successful finalization does the runtime advance its local `ValidatorRoot` to the header's effective next root. + +The following block therefore requires the new validator set. + +Governance/staking will eventually decide *which* transition is allowed to be proposed; the cryptographic transition rule described here remains the consensus trust boundary. + +## Citizen wallet behavior + +The strict wallet verifier in `apps/wallet/src/lib/v2CitizenTrusted.ts` starts from a `CitizenTrustAnchor` containing a trusted `NetworkID` and `ValidatorRoot`. + +For every proof bundle it independently checks: + +- header network equals the trusted network; +- supplied validator set hashes to the trusted/current `ValidatorRoot`; +- validator IDs match their P-256 public keys; +- every vote signature is canonical low-S P-256 and targets the same header; +- distinct signed voting power reaches `2/3+` using exact `BigInt` arithmetic; +- certificate hash matches the finalized header; +- shard commitment belongs to the finalized global commitment root; +- object inclusion/absence proof matches the shard state root. + +After all checks pass, the wallet may advance to: + +```text +nextTrustAnchor = { + network: current NetworkID, + validatorRoot: effective NextValidatorRoot +} +``` + +A malicious RPC can transport headers, validator sets and proofs, but it cannot choose a new trusted validator committee without a QC from the currently trusted committee. + +## Cross-shard receipts + +Cross-shard imports apply the same rule. A destination shard does not accept a source receipt merely because the supplied source validator set signed it. It verifies that the supplied set hashes to the source header's `ValidatorRoot`, verifies the QC, then verifies shard and receipt Merkle proofs before materializing destination state. + +## Recovery and checkpoints + +Future checkpoint/snapshot formats must carry enough finalized header history to bridge any validator-set transitions between the checkpoint already trusted by the verifier and the target state. Snapshot authentication never replaces this trust chain. + +## Security invariant + +```text +valid signatures + != +authorized validators + +valid signatures ++ committed validator root ++ previously trusted transition + = +authorized finality evidence +``` diff --git a/docs/protocol-v2.md b/docs/protocol-v2.md new file mode 100644 index 00000000..abf67f02 --- /dev/null +++ b/docs/protocol-v2.md @@ -0,0 +1,389 @@ +# Zephyr Protocol v2 — Clean-Break Architecture + +Status: **authoritative design target and implementation contract for the Zephyr v2 clean break**. + +Protocol v1 remains the prototype/conformance reference. Protocol v2 intentionally does not preserve v1 transaction wire format, persisted-state layout, node-role coupling or account-state representation. We keep the security, consensus, recovery and benchmarking lessons while replacing boundaries that would otherwise become permanent scaling debt. + +For the executable status of each subsystem, see `docs/protocol-v2-implementation-status.md`. For committee trust/rotation, see `docs/protocol-v2-validator-trust.md`. + +## Mission + +Zephyr v2 has two equal scaling goals: + +1. **Scale up when hardware is abundant.** The long-term target remains extremely high throughput, measured only as transactions finalized by validator consensus. +2. **Scale down without dying.** Payments, self-custody, verification and useful network participation must remain practical on commodity hardware, including a Citizen Node inside the Zephyr smartphone wallet. + +Large datacenters may add capacity, but must not be structurally required for Zephyr to remain alive. The network may degrade in **throughput** when resources fall; it must never degrade in **safety**. + +## Non-negotiable invariants + +- Headline TPS means finalized transactions per second, never API ingress or mempool acceptance. +- No performance change is accepted if the Consensus & Performance Lab finds a safety or liveness regression. +- A Citizen Node must verify its own balances/payments, finalized headers, shard commitments, state proofs and sampled data without holding the full global state/history. +- Wallet-side work only helps network throughput when it produces evidence another participant can independently verify. +- Heavy compute (AI training, scientific workloads, rendering, etc.) is never replayed by every validator. +- Validators do not require GPUs or datacenter-class machines. +- Account, node and validator identities are separate. +- Consensus-critical encoding is canonical binary; JSON remains for RPC, diagnostics and human-facing configuration. +- Consensus-critical economics use integers/fixed point, not floating point. +- Sharding is protocol-native but activation is benchmark-driven. v2 starts safely with `shardCount = 1` unless evidence supports more. +- Adding shards must not linearly increase the minimum hardware requirement of a Citizen Node. +- Smart-contract execution is deterministic and metered. +- Heavy/private compute is provider-executed and blockchain-settled through commitments/proofs/attestations/replication/challenges as appropriate. +- A quorum certificate is valid only for the validator set committed by the already-trusted header chain. +- Cross-shard imports are asynchronous and cannot bypass source finality or durable anti-replay state. + +## Architecture + +```text + ZEPHYR v2 + | + +----------+----------+ + | GLOBAL FINALITY | + +----------+----------+ + | + shard/object commitments + | + +--------------------+--------------------+ + | | | + Shard 0 Shard 1 Shard N + | | | + parallel exec parallel exec parallel exec + | | | + +--------------------+--------------------+ + | + proofs / receipts + | + +-----------------------+------------------------+ + | | | + Validators Full Nodes Citizen Nodes + | | smartphones + consensus full state | + finality proof serving headers/proofs + shard work history tx relay / DA + bounded cache + optional exec + + +----------------------+ + | Native Compute Market| + +----------------------+ + off-chain CPU / GPU + on-chain settlement +``` + +Zephyr remains one network with one global notion of finality. Shards are execution/state partitions, not independent chains. + +## 1. Genesis-derived network identity + +A canonical v2 genesis contains protocol version, chain name, genesis time, initial/max shard count, native token, initial validator identities/voting power and initial allocations. + +```text +networkId = H("zephyr/genesis/v2" || canonicalGenesis) +``` + +Genesis becomes the network identity and initial validator-set trust anchor. Nodes with different genesis data cannot silently claim the same network. + +The initial Citizen trust anchor is: + +```text +TrustAnchor { + NetworkID + ValidatorRoot = Merkle(initial validator set) +} +``` + +## 2. Validator trust chain + +Every validator set is committed by a canonical Merkle root over validator ID, P-256 public key and integer voting power. + +Each `GlobalHeader` contains both: + +```text +ValidatorRoot // committee authorizing this header +NextValidatorRoot // committee authorized for the next height +``` + +If `NextValidatorRoot` is zero, the committee remains unchanged. A committee cannot install itself: the current committee must first finalize the header committing the next root with the normal `2/3+` quorum. + +Citizen wallets advance their local trust anchor only after verifying that QC. Cross-shard receipt import applies the same rule to historical source committees. + +## 3. Canonical binary protocol + +Consensus-critical objects use a bounded binary codec with explicit widths, length prefixes and versioned hash/signing domains. JSON is an RPC representation only. + +Canonical binary objects include: + +- transactions and witnesses; +- objects and asset definitions; +- shard commitments and receipts; +- GlobalHeader; +- proposals, votes and quorum certificates; +- Merkle/Sparse-Merkle proofs; +- genesis and validator commitments. + +Protocol decoders reject trailing bytes, oversized fields and invalid versions. + +## 4. Proof-oriented object state + +Zephyr v2 replaces the v1 global account map as the consensus state primitive with versioned objects. + +Core categories include: + +```text +CoinObject +TokenDefinition +ContractObject +ComputeOffer / ComputeJob / ComputeResult state +SystemObject +``` + +User UX remains balance-oriented; wallets aggregate owned coin objects behind the scenes. + +Objects have deterministic IDs. For multi-shard state, the object ID permanently encodes its assigned shard so changing the active shard count cannot silently relocate an existing object. + +## 5. Sparse-Merkle state and proof-carrying transactions + +Each shard maintains an incremental 256-bit Sparse Merkle state root. Inclusion and absence proofs are compressed by omitting default siblings. + +A proof-carrying transaction identifies its pre-state root and carries authenticated object witnesses. A validator therefore verifies the same state evidence that the originating wallet could verify, rather than trusting wallet pre-validation. + +```text +wallet + -> verifies finalized state proof + -> declares inputs / outputs / access set + -> signs proof-carrying transaction + -> network verifies witness + signature + -> deterministic execution +``` + +Wallet-side work is useful because the evidence is independently reusable. Consensus remains responsible for uniqueness, ordering and finality. + +## 6. Deterministic parallel execution + +Transactions expose enough object dependencies to reject conflicting batches before concurrent execution. Independent transactions may execute in parallel; outputs merge in deterministic transaction order. + +The execution gate rejects: + +- duplicate transactions; +- duplicate/shared consumed objects in a parallel batch; +- mismatched pre-state roots; +- wrong-shard inputs; +- token conservation or fee violations; +- invalid witnesses/signatures. + +Candidate execution computes a future root through a non-mutating state preview. Committed state changes only after a valid quorum certificate. + +Performance work is profile-driven. The v2 benchmark measures 1/4/8/16 execution workers and reports finalized throughput/allocation behavior; adding goroutines is not considered scaling if proof/state allocation remains the bottleneck. + +## 7. Sharding and cross-shard receipts + +Sharding is native to the protocol but optional at activation. One shard is a fully valid Zephyr network. + +Account routing determines where newly created account-owned outputs live. If an output targets another shard, the source shard does **not** write that object locally. Instead it creates a cross-shard receipt committed by the source shard's `ReceiptRoot`. + +A destination import verifies: + +1. source `GlobalHeader` and its authorized validator-set QC; +2. source shard commitment inclusion in the global root; +3. receipt inclusion in the source `ReceiptRoot`; +4. destination routing; +5. absence of the durable receipt marker. + +The destination block then materializes both the destination object and a consensus-state anti-replay marker. Because normal transactions in that block are anchored to the destination pre-state root, an imported output becomes spendable only from a later block. + +Sharding is enabled beyond one shard only after multi-shard conformance/recovery and throughput evidence demonstrate a net benefit. + +## 8. GlobalHeader and finality + +A v2 block-height finality commitment is a compact `GlobalHeader` over: + +```text +version +network +height +parentHash +shardCommitmentRoot +validatorRoot +nextValidatorRoot +dataRoot +certificateHash +``` + +The consensus hash zeroes `certificateHash` to avoid circular signing. Proposal/vote signatures target this consensus hash. Once the quorum certificate forms, its hash is attached to the finalized header. + +The header therefore ties together execution state, receipt availability, committee trust and global finality. + +## 9. Citizen Node + +The Zephyr Wallet includes a Citizen verifier rather than acting as a blind RPC client. + +A proof bundle can carry: + +```text +GlobalHeader +QuorumCertificate +ValidatorSet +ShardCommitment + proof +Object + SparseMerkle proof +``` + +The strict wallet verifier starts from a genesis/checkpoint trust anchor, independently reconstructs validator IDs/root/voting power, verifies low-S P-256 votes and `2/3+` quorum using exact integer arithmetic, then verifies shard/object proofs. + +After a valid header, it may advance its local validator trust root to the QC-authorized `NextValidatorRoot`. + +Participation remains resource-aware: + +```text +battery low -> headers/proofs only +wallet active -> verify + relay +Wi-Fi -> + DA sampling / bounded cache +Wi-Fi + charging -> + opportunistic recent execution / serving +``` + +Mobile OS background availability is treated as opportunistic capacity, never as a consensus-liveness assumption. + +## 10. Native tokens + +Token definitions and coin objects are protocol-native so ordinary token transfers do not require executing general smart-contract bytecode. + +Native asset state supports supply limits, mint authority, burn/transfer policy and deterministic token IDs. Custom smart contracts remain available when an asset needs logic beyond the native model. + +## 11. Smart contracts + +The contract protocol targets deterministic metered WASM through a versioned ABI. Rust is the first-class SDK target, not a protocol requirement; other modern languages may target the same deterministic WASM subset. + +The runtime boundary already requires: + +- bounded module/request/output sizes; +- deterministic imports/opcodes; +- fuel limits; +- memory/stack policy; +- declared object read/write access; +- no undeclared writes; +- bounded events. + +The concrete production WASM engine and audited fuel schedule are selected only after cross-machine deterministic conformance and performance measurement. + +## 12. Native distributed compute market + +Heavy workloads are a native Zephyr market but run outside validator consensus execution. + +Provider offers describe CPU, GPU, RAM, VRAM, storage, bandwidth, capabilities, verification modes, pricing and collateral. Jobs describe content-addressed inputs/workload, resource requirements, budget/escrow, deadline and verification policy. + +Supported verification-policy primitives include: + +- deterministic replay for suitable bounded jobs; +- replicated providers and matching result commitments; +- challenge evidence; +- ZK validity proof integration; +- TEE attestation integration; +- client approval; +- hybrid policies. + +Validators settle compact evidence and payment state. They do not replay AI training, scientific simulations, rendering or other expensive compute merely to finalize payment. + +Confidential workloads keep private datasets/results off-chain where appropriate; Zephyr stores commitments, encrypted references and settlement evidence. + +## 13. Data availability + +Shard/global commitments include data roots. Citizen Nodes can verify bounded samples rather than downloading global block data. + +The checked-in foundation defines chunk/sample commitment verification; production erasure coding, reconstruction, confidence parameters and withholding fault tests remain activation gates. + +The invariant is that increasing shard/data capacity must not linearly increase the minimum data requirement of every Citizen Node. + +## 14. Transport boundaries + +Consensus, transaction relay and light-proof retrieval are distinct capabilities. HTTP remains the reference/test transport. Production peer networking can move to libp2p/QUIC/WebTransport behind those interfaces without redefining consensus objects. + +Shard-aware gossip, mobile relay/NAT traversal and the production transport implementation must pass the same conformance suite as the reference transport before public activation. + +## 15. Durable state + +The v2 durable backend uses an append-only network-bound WAL with checksums/sequence numbers/fsync plus atomic checkpoints and crash-tail recovery. This removes the v1 requirement to serialize the entire node state for every mutation. + +It remains a reference durable backend while large-state benchmark evidence determines whether the final production backend should be a structured KV/LSM implementation. + +## 16. Performance has two axes + +### Scale up — how fast can Zephyr finalize? + +Measure finalized tx/s, p50/p95/p99 finality, validators, shards, batch size, CPU, allocations, memory, state-write bytes, network bytes/finalized tx, witness bytes, DA bytes and persisted state. + +### Scale down — how small can a useful node be? + +Measure Citizen Node resident memory, cache size, sync bandwidth, proof size/verification time, header verification, DA sample bytes/time, mobile CPU/battery duty cycle and startup/resume latency. + +No phone or hardware budget becomes a production claim before measurement on real reference devices. + +## 17. Security and consensus continuity + +The existing Consensus & Performance Lab remains a regression gate and v2 has its own seven-validator conformance suite. + +V2 conformance covers certified happy path, 4/3 no-quorum partition, heal/recovery, 5/2 quorum/minority catch-up and conflicting proposal rejection. More restart/transport/Byzantine cases remain required before public devnet. + +The architecture changes **what consensus commits to**, not how much evidence is required for finality. + +## 18. Clean-break compatibility policy + +V2 intentionally breaks v1 compatibility for network identity, transaction wire/signing domain, object/state format, persistent backend, state-root calculation, node-role model, shard commitments, validator trust chain and contract ABI. + +It carries forward security invariants, consensus/fault lessons, performance methodology, recovery requirements, wallet self-custody and P-256 usability unless later benchmark/security evidence justifies changing them. + +No public v2 network silently accepts v1 protocol objects or pre-transition experimental v2 wire objects. + +## 19. Implementation sequence + +### Executable foundation + +Implemented on the v2 branch: + +- canonical binary codec and typed identities; +- genesis-derived network/trust identity; +- validator-set roots and QC-backed committee transitions; +- Sparse-Merkle reference state and compressed proofs; +- proof-oriented object/coin model and native tokens; +- signed proof-carrying transaction wire format; +- deterministic parallel executor and non-mutating state preview; +- durable WAL/checkpoint state backend; +- v2 proposal/vote/QC and GlobalHeader finality path; +- permanent object shard placement; +- finalized cross-shard receipt import with durable anti-replay marker; +- Citizen light API and strict wallet cryptographic verifier; +- deterministic WASM metering/runtime boundary; +- native compute-market state-machine foundation; +- data-availability sample boundary; +- separate consensus/transaction/light transport interfaces; +- dedicated v2 seven-validator partition/conformance CI gate; +- finalized v2 batch benchmark. + +### Next integration gates + +- mount a live v2 runtime/genesis/light provider in the process-facing node path; +- persist runtime height/header/validator-transition metadata alongside shard state; +- expand v2 fault injection to restart, proposer death, Byzantine payloads, wrong-chain data and transport delay/reorder; +- profile/reduce proof/state allocation and compare controlled-hardware v1/v2 throughput; +- add shard-aware recovery/gossip and 4/16-shard conformance; +- implement trusted checkpoint history across long validator rotations; +- integrate a production deterministic WASM engine and Rust SDK; +- move compute-market transitions into consensus object execution and build provider daemon/escrow/dispute flows; +- select production erasure coding and implement DA reconstruction/withholding tests; +- connect libp2p/QUIC/mobile peer transport; +- embed Citizen lifecycle/cache/relay controls in the wallet UI/native shell; +- benchmark real Android/iOS devices and commodity validators. + +### Public devnet gate + +No public v2 devnet until safety/liveness conformance, durable state/runtime recovery and Citizen trust-chain correctness pass; one-shard performance is characterized; multi-shard activation rules are defined; contract execution is deterministic/metered; and genesis/checkpoint/operator/wallet upgrade procedures are explicit. + +## Architectural north star + +When simplicity, throughput and decentralization conflict, prefer designs that preserve independent verification and graceful hardware degradation, then recover throughput through parallelism, sharding and optional high-end providers. + +```text +more hardware -> more throughput +less hardware -> less throughput +less hardware -/-> weaker correctness +``` + +Zephyr v2 is neither a "mobile blockchain" nor a "datacenter blockchain". It is intended to remain independently verifiable on small devices while scaling execution and data capacity when the network has more resources. diff --git a/docs/tokenomics-v2.md b/docs/tokenomics-v2.md new file mode 100644 index 00000000..06fa72ab --- /dev/null +++ b/docs/tokenomics-v2.md @@ -0,0 +1,457 @@ +# Zephyr v2 Tokenomics — Adaptive Monetary Policy + +Status: **design contract + shadow-mode implementation target**. + +This document defines the economic direction for ZPH in Protocol v2. It deliberately separates mechanisms that are already executable in the branch from mechanisms that must remain in shadow/simulation mode until devnet evidence demonstrates stability and manipulation resistance. + +Detailed companion specifications: + +- `docs/compute-economics-v2.md` — normalized compute work, ZCPI, ZCSI and A/B/C feedback experiments; +- `docs/economic-state-v2.md` — consensus-stamped coin age, velocity, per-shard epoch accounting and authenticated shadow monetary state. + +## Goals + +Zephyr does not use a fixed maximum ZPH supply as its long-term monetary rule. The target is a deterministic, oracle-free adaptive policy with a long-run **net supply growth center near 2% per year**. + +The policy must: + +- be computable from consensus state only; +- be independently reproducible by validators, full nodes and Citizen Nodes; +- never depend on USD prices, CPI, exchanges, energy prices or other external oracles; +- distinguish fast fee-market control from slow monetary-policy control; +- use integer/fixed-point arithmetic only; +- rate-limit monetary changes; +- resist wash activity, fake offers and short-lived transaction spam; +- remain observable in shadow mode before it is allowed to mint protocol supply. + +A 2% target refers to **ZPH monetary supply growth**, not real-world purchasing-power inflation. Without an external price oracle Zephyr cannot claim to track CPI or any fiat purchasing-power index. + +## 1. Two independent control loops + +### Fast loop — every block + +The fast loop prices scarce blockchain resources and can burn a base-fee component. + +Conceptually: + +```text +resource use + -> block/shard utilization + -> dynamic base fee + -> fee burn + validator/reward + reserve components +``` + +The reference fee engine can price: + +- transaction base work; +- signature verification; +- witness/proof bytes; +- state reads/writes; +- contract fuel; +- data-availability bytes; +- cross-shard receipts. + +It also supports deterministic burn/validator/reserve splitting with exact value conservation. The current compatibility policy remains full fee burn until authenticated reward/reserve distribution is activated. + +The fast loop may react to congestion quickly. It does **not** directly change the long-term monetary target. + +### Slow loop — every monetary epoch + +The slow loop is the **Zephyr Adaptive Monetary Policy (ZAMP)**. + +It observes smoothed on-chain economic/security metrics and calculates the gross mint that would be required to hit the epoch net-issuance target after burn. + +The branch implements this controller in **shadow mode only**. + +```text +supply +burn +stake ratio +protocol reserve ratio +blockchain resource utilization +age-weighted velocity +finalized operations +compute-market telemetry + | + v +slow bounded controller + | + v +gross mint target + | + - burn already observed + v +net supply target near 2% annualized +``` + +Shadow mode computes and can authenticate the decision but does not mutate live ZPH supply. + +## 2. Net inflation target + +For an epoch, the base target is approximately: + +```text +NetIssuanceTarget = Supply * TargetInflation / EpochsPerYear +``` + +where the default center is 200 basis points (2%). + +If `B` ZPH were burned during the epoch and `N` is the desired positive net issuance, the gross mint target is: + +```text +GrossMintTarget = N + B +``` + +therefore: + +```text +GrossMintTarget - Burn = N +``` + +Burn and mint are separate accounting flows. A high burn rate does not automatically make the currency permanently deflationary if the monetary constitution targets a positive net supply rate. + +## 3. Adaptive band and rate limit + +The target is not intended to jump with short-term activity. The current shadow reference policy uses a center, a bounded range and a maximum movement per epoch. + +The checked-in defaults are simulation parameters, **not public-mainnet constants**: + +```text +center: 2.00% +shadow minimum: 1.50% +shadow maximum: 2.50% +max change: 1 basis point / epoch +``` + +These numbers exist so the controller can be tested. They require economic simulation before activation. + +## 4. Oracle-free monetary signals + +ZAMP can use only values committed by Zephyr consensus. + +### Supply and burn + +Directly knowable from protocol state/accounting: + +- total ZPH supply; +- circulating supply; +- ZPH burned by the fee mechanism; +- protocol-minted ZPH after future activation; +- protocol reserve. + +### Security + +Directly known: + +- staked/bonded ZPH; +- validator voting power; +- collateral/slashing state. + +### Network utilization + +Do not use raw HTTP requests or mempool ingress. Use finalized consensus resource consumption. + +Blockchain resource utilization and compute-market utilization are separate signals and must not be conflated. + +### Finalized operations + +Operation counts are secondary signals only. They are not sufficient alone because an attacker can generate economically meaningless activity. + +### Age-weighted monetary velocity + +Simple transfer volume is wash-tradeable. Zephyr v2 coin objects now carry a consensus-stamped `CreatedHeight`. + +New coin outputs are rewritten by deterministic execution with the candidate block height, so the wallet cannot choose an old timestamp to create fake monetary age. + +The reference velocity accumulator uses: + +```text +age = spendHeight - CreatedHeight +``` + +with configurable minimum age, full-weight age and maximum velocity bounds. + +Rapidly recreating and cycling fresh coin objects therefore resets their age and can contribute zero below `MinAgeBlocks`. + +Unknown/genesis age (`CreatedHeight = 0`) is tracked separately and excluded by the current reference policy. + +## 5. Zephyr normalized compute work + +There is no honest universal scalar that makes every CPU, GPU, AI training job, renderer and scientific workload directly equivalent. + +Zephyr therefore uses a **resource vector**, not a fake universal FLOP count. + +The current model includes: + +```text +CPUUnits +GPUFP32Units +GPUFP64Units +TensorUnits +MemoryByteSeconds +VRAMByteSeconds +StorageBytes +NetworkBytes +``` + +A standardized workload definition carries protocol version, workload class, normalized units, workload hash, benchmark/specification hash and resource vector. + +The benchmark hash anchors the meaning of the units. A provider cannot make its hardware appear more valuable merely by self-reporting a larger number. + +## 6. Compute workload registry + +Only standardized work specifications are eligible for monetary telemetry. + +A registry entry binds: + +```text +WorkloadHash + -> WorkClass + -> normalized Units + -> WorkVector + -> BenchmarkHash +``` + +Conflicting definitions for the same workload hash are rejected. + +The current registry is a reference implementation. Before monetary activation it must become authenticated/governance-controlled state with delayed versioned activation. + +## 7. ZCPI — Zephyr Compute Price Index + +ZCPI is an internal Zephyr compute-market price index. It is **not** a CPI and is not a claim about real-world inflation. + +It answers: + +> how many atomic ZPH units were actually paid for standardized, verified compute work on Zephyr? + +ZCPI excludes: + +- advertised provider prices; +- unfilled offers; +- self-reported theoretical FLOPS; +- failed/unverified jobs; +- arbitrary unregistered workload units. + +Eligible observations derive from: + +```text +registered workload spec ++ finalized compute job ++ verification-satisfied result ++ actual on-chain provider payments +``` + +The reference implementation uses fixed-point Q9 arithmetic, per-class medians, EWMA smoothing, basket coverage and an explicit reliability flag. + +## 8. ZCSI — Zephyr Compute Scarcity Index + +ZCPI alone cannot safely drive inflation. A price increase can reflect scarcity, demand growth, ZPH purchasing-power movement or workload-mix changes. + +Zephyr therefore separately computes **ZCSI**, a bounded scarcity score based on: + +```text +escrow-backed standardized demand +verified standardized supply +funded backlog +fulfilled work +compute utilization +reliable ZCPI price trend +``` + +Only real escrow-backed standardized work counts as demand. Provider-advertised capacity does not become verified supply merely because it is claimed. + +If ZCPI is unreliable, the price component is removed. If demand/supply coverage is too thin, ZCSI itself becomes unreliable. + +An unreliable ZCSI is prohibited from changing either compute reward routing or the monetary target in the shadow evaluator. + +## 9. Compute feedback experiments A/B/C + +The branch implements three **shadow-only** modes. + +### A — observe only + +```text +ZCSI -> telemetry only +compute reward share -> unchanged +inflation target -> unchanged +``` + +### B — reward routing + +```text +ZCSI -> suggested compute reward share +inflation target -> unchanged +``` + +This is the preferred first candidate if devnet evidence eventually justifies activation. Scarce verified compute can receive a larger share of an already-defined issuance budget without changing total issuance. + +### C — reward routing + narrow monetary band + +```text +ZCSI -> suggested compute reward share +ZCSI -> small bounded shadow inflation correction +``` + +The total-inflation sensitivity is deliberately much smaller than the reward-routing sensitivity. + +Mode C must demonstrate a material stability/capacity benefit over Mode B before it is considered for activation. + +The current active economic boundary remains equivalent to Mode A: **no compute signal changes live supply**. + +## 10. Native ZPH fee accounting + +V2 execution requires native ZPH inputs to cover outputs plus the signed fee. + +The reference fee engine now supports: + +```text +resource charge + | + +-- burn + +-- validator/reward pool + +-- protocol reserve +``` + +with integer basis-point splits and deterministic rounding that conserves every atomic unit. + +Until state-backed distribution is activated, the compatibility policy preserves the current effective 100% fee burn. + +## 11. Smart-contract gas + +Contract execution reports deterministic `FuelUsed` and enforces `FuelLimit`. + +The reference resource fee model can include: + +```text +base transaction charge ++ signature work ++ witness bytes ++ state reads/writes ++ FuelUsed * FuelPrice ++ DA bytes ++ cross-shard receipts +``` + +Final production prices and the active base-fee controller remain simulation/benchmark decisions. + +## 12. Compute payment is not blockchain gas + +Heavy compute has two independent prices: + +```text +provider payment ++ blockchain settlement fee +``` + +A 100 ZPH AI job does not imply 100 ZPH of gas. Validators settle commitments/proofs/results; they do not replay the expensive workload. + +## 13. Native custom-token policy + +Protocol-native custom assets now have explicit supply policies: + +```text +FIXED +CAPPED +MINTABLE +``` + +The v2 executor implements: + +- custom-token creation; +- `MintToken` with mint-authority and cap enforcement; +- `BurnToken` with burn-permission enforcement; +- authenticated `TokenDefinition.CurrentSupply` updates; +- `Transferable` enforcement; +- read-only token-definition policy witnesses for parallel normal transfers. + +ZPH itself is excluded from user-authority mint/burn paths. Future ZPH issuance can occur only through an explicitly activated protocol monetary transition. + +Custom-token cross-shard transfer/mint is deliberately gated until a globally verifiable token-policy proof/registry is available. + +## 14. Economic epoch state + +Zephyr must not create one global monetary object touched by every transaction; that would serialize execution. + +The current foundation therefore uses: + +```text +finalized execution + -> per-shard epoch metrics + -> deterministic epoch aggregate + -> shadow MonetaryEpochState +``` + +Per-shard metrics cover: + +- charged/burned/validator/reserve fees; +- finalized operations; +- chain resource used/capacity; +- shard circulating ZPH; +- age-weighted velocity; +- escrow-backed compute demand; +- verified compute supply; +- compute backlog/fulfillment. + +Exact fee conservation is validated. + +Global velocity is weighted by per-shard circulating ZPH rather than giving every shard equal influence. + +Chain resource utilization and compute utilization are calculated separately. + +The canonical aggregate hash is committed by a deterministic shadow `MonetaryEpochState` system object. Consecutive states bind `PreviousStateHash`. + +The object can enter the normal Sparse-Merkle state root through consume/recreate semantics, so a future epoch-boundary runtime transition does not require another special consensus root. + +The current transition builder returns a state delta for pre-QC simulation; it does not commit a store itself. + +## 15. Testing and replay + +The branch includes deterministic tests for: + +- normalized work-spec serialization and registry conflicts; +- verified settlement observations; +- ZCPI medians, coverage, reliability and trend bounds; +- ZCSI demand/supply scarcity and reliability fail-closed behavior; +- A/B/C feedback separation; +- burn offset and rate-limited ZAMP target; +- fee split/resource quotation conservation; +- consensus override of wallet-provided coin creation height; +- old versus fresh age-weighted velocity; +- rapid self-cycling suppression; +- per-shard economic accounting and multi-shard aggregation; +- separation of chain and compute utilization; +- canonical shadow monetary state and previous-epoch binding; +- suggested Mode-C mint remaining shadow while `TotalSupply` is unchanged. + +`cmd/zephyr-econ-sim` supports base ZAMP replay and optional compute-market/ZCSI A/B/C inputs. See `docs/examples/zephyr-econ-sim-compute.json`. + +## 16. Activation gates + +ZAMP remains shadow-only until all of the following are true: + +1. per-shard economic metrics are derived automatically from finalized runtime execution rather than caller-provided summaries; +2. fee distribution is state-backed and conserves supply exactly; +3. verified compute supply comes from authenticated benchmark/availability state; +4. velocity is stress-tested against long-horizon self-cycling and capital-lock attacks; +5. all monetary metrics are reproducible from finalized state; +6. long simulations cover usage shocks, partitions, validator churn, spam, compute booms/busts and oscillating adversarial inputs; +7. parameter sensitivity does not create runaway or oscillatory issuance; +8. governance can change parameters only through bounded delayed transitions; +9. Citizen Nodes can decode and independently verify monetary state/history; +10. ZCPI/ZCSI have sufficient real-market coverage before Mode B or C is considered; +11. an emergency deterministic fallback can zero adaptive corrections when required metrics are unavailable/invalid; +12. live mint/reward/fee distribution has an explicit protocol-version/activation-height transition. + +## 17. Current policy boundary + +The branch implements **measurement, authenticated shadow state and shadow decisions**, not live monetary issuance. + +The design principle remains: + +```text +measure first +simulate second +activate last +``` + +This lets Zephyr develop an adaptive oracle-free economy without turning monetary policy into an untested consensus experiment. diff --git a/go.mod b/go.mod index 5a56f28b..411d2da0 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,99 @@ module github.com/zephyr-chain/zephyr-chain -go 1.22 +go 1.26.0 +require ( + github.com/klauspost/reedsolomon v1.14.1 + github.com/libp2p/go-libp2p v0.49.0 + github.com/libp2p/go-libp2p-pubsub v0.17.0 + github.com/multiformats/go-multiaddr v0.16.1 + go.starlark.net v0.0.0-20260708150628-5395d018f003 +) + +require ( + filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect + filippo.io/keygen v1.0.0 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/filecoin-project/go-clock v0.1.0 // indirect + github.com/flynn/noise v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/ipfs/go-cid v0.6.2 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/koron/go-ssdp v0.9.1 // indirect + github.com/libp2p/go-buffer-pool v0.1.0 // indirect + github.com/libp2p/go-flow-metrics v0.3.0 // indirect + github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect + github.com/libp2p/go-msgio v0.3.0 // indirect + github.com/libp2p/go-netroute v0.4.0 // indirect + github.com/libp2p/go-reuseport v0.4.0 // indirect + github.com/libp2p/go-yamux/v5 v5.1.0 // indirect + github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect + github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect + github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multiaddr-dns v0.6.0 // indirect + github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect + github.com/multiformats/go-multicodec v0.10.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-multistream v0.6.1 // indirect + github.com/multiformats/go-varint v0.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/ice/v4 v4.0.10 // indirect + github.com/pion/interceptor v0.1.40 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.8.19 // indirect + github.com/pion/sctp v1.8.39 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.6 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.0.2 // indirect + github.com/pion/webrtc/v4 v4.1.2 // indirect + github.com/prometheus/client_golang v1.24.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect + github.com/quic-go/webtransport-go v0.11.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/fx v1.24.0 // indirect + go.uber.org/mock v0.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/telemetry v0.0.0-20260717140457-bdb89881bb75 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + lukechampine.com/blake3 v1.4.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..802f63ed --- /dev/null +++ b/go.sum @@ -0,0 +1,242 @@ +filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 h1:JA0fFr+kxpqTdxR9LOBiTWpGNchqmkcsgmdeJZRclZ0= +filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI= +filippo.io/keygen v1.0.0 h1:u0/Fhxlgz3uPv+XxhfgTq3BJt5VesIPM5ue/OuG7qjQ= +filippo.io/keygen v1.0.0/go.mod h1:9nnw1SlYHYuPSo/3wjQzNjSbeHlq2NsKo5iEtfJPWP0= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 h1:oe6fCvaEpkhyW3qAicT0TnGtyht/UrgvOwMcEgLb7Aw= +github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3/go.mod h1:qdP0gaj0QtgX2RUZhnlVrceJ+Qln8aSlDyJwelLLFeM= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= +github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/ipfs/go-cid v0.6.2 h1:VuGwJd+KJTaMJ4S4d5EEf9SXc17YUblS5axCbocn9YE= +github.com/ipfs/go-cid v0.6.2/go.mod h1:Xhwg8NzHeK9xPCEZkCw4idzPiuNMpX3fARuI5Iwj1Lo= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= +github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/klauspost/reedsolomon v1.14.1 h1:swE9kzyWXD/wVG+l5Pe8bWnQ0giIY7D1GjCBKk3kG2U= +github.com/klauspost/reedsolomon v1.14.1/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ= +github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA= +github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= +github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= +github.com/libp2p/go-flow-metrics v0.3.0 h1:q31zcHUvHnwDO0SHaukewPYgwOBSxtt830uJtUx6784= +github.com/libp2p/go-flow-metrics v0.3.0/go.mod h1:nuhlreIwEguM1IvHAew3ij7A8BMlyHQJ279ao24eZZo= +github.com/libp2p/go-libp2p v0.49.0 h1:ibXuYPIHmMIPShob1BktQvSuFQkq/MemhQOLKfGujjw= +github.com/libp2p/go-libp2p v0.49.0/go.mod h1:lzjVcOBk5fCn1QD2XbSOKLZesB6gEsry8SLjCsAAGT4= +github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= +github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= +github.com/libp2p/go-libp2p-pubsub v0.17.0 h1:SNdvB6V0eYMXLRR95n+4vpxJKbFsbHhgjPdDiTpGoo0= +github.com/libp2p/go-libp2p-pubsub v0.17.0/go.mod h1:F0oKCGLFJNy9b0TyRi04b+LchEzq0t2eZyJuxwAIyDE= +github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= +github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= +github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= +github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= +github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= +github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= +github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= +github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= +github.com/libp2p/go-yamux/v5 v5.1.0 h1:8Qlxj4E9JGJAQVW6+uj2o7mqkqsIVlSUGmTWhlXzoHE= +github.com/libp2p/go-yamux/v5 v5.1.0/go.mod h1:tgIQ07ObtRR/I0IWsFOyQIL9/dR5UXgc2s8xKmNZv1o= +github.com/marcopolo/simnet v0.0.7 h1:DpH8BMGsF9+1w13L8rvCaAhb6nYJdY+dIXncDrssvUs= +github.com/marcopolo/simnet v0.0.7/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s= +github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= +github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= +github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= +github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= +github.com/multiformats/go-multiaddr-dns v0.6.0 h1:yKIW08WJHSPJ8bDAT2O/5fypCaUu9Bjl8r/1eJ4XAW8= +github.com/multiformats/go-multiaddr-dns v0.6.0/go.mod h1:dwIQwdORZfnNQCeS7xLXyn+7626oRmMsVP30Uronhf0= +github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= +github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= +github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= +github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= +github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-multistream v0.6.1 h1:4aoX5v6T+yWmc2raBHsTvzmFhOI8WVOer28DeBBEYdQ= +github.com/multiformats/go-multistream v0.6.1/go.mod h1:ksQf6kqHAb6zIsyw7Zm+gAuVo57Qbq84E27YlYqavqw= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c= +github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= +github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= +github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= +github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= +github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA= +github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +go.starlark.net v0.0.0-20260708150628-5395d018f003 h1:cAxcqHgW8fnmT0cEBU3TzvVYHIFt8IIGDMWUF6rImk4= +go.starlark.net v0.0.0-20260708150628-5395d018f003/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A= +golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260717140457-bdb89881bb75 h1:I9ygRooEYoVHV0SRNOSr/KVjTf5EeJ52BuNkVjsP2GU= +golang.org/x/telemetry v0.0.0-20260717140457-bdb89881bb75/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/internal/v2/README.md b/internal/v2/README.md new file mode 100644 index 00000000..8f1e9511 --- /dev/null +++ b/internal/v2/README.md @@ -0,0 +1,28 @@ +# Zephyr Protocol v2 reference foundation + +This directory contains the clean-break Protocol v2 reference types and proof-oriented execution primitives. + +It is intentionally isolated from the current v1 runtime while the Consensus & Performance Lab is used to compare behavior and performance. The goal is to migrate only after the v2 path proves safety, recovery and measurable performance/resource advantages. + +Packages: + +- `codec`: bounded canonical binary encoding primitives. +- `types`: typed network/account/node/validator/object/token/contract/job identifiers. +- `genesis`: canonical genesis and genesis-derived network identity. +- `state`: incremental Sparse Merkle Tree with compressed inclusion/absence proofs. +- `worldstate`: proof-native object state backend contract and reference in-memory backend. +- `object`: object and coin model. +- `tx`: signed proof-carrying v2 transactions and canonical wire format. +- `execution`: native transfer and token-creation reference executor. +- `assets`: protocol-native token definition model. +- `merkle`: ordered Merkle commitment/proof utility. +- `sharding`: shard routing, global/shard commitments and cross-shard receipt primitives. +- `da`: data-availability chunk commitments and sample verification. +- `citizen`: mobile/light verifier and resource-aware participation policy. +- `contracts`: deterministic WASM deployment/runtime boundary. +- `compute`: native distributed-compute market objects and verification modes. +- `transport`: separated consensus, transaction and light-proof transport capabilities. + +The authoritative architecture and implementation sequence is documented in `docs/protocol-v2.md`. + +Do not interpret the presence of an interface or protocol type as a production-complete feature. In particular, production WASM execution, erasure coding, dynamic sharding, durable v2 storage, mobile OS integration and compute-provider execution/settlement are explicit later integration milestones. diff --git a/internal/v2/assets/token.go b/internal/v2/assets/token.go new file mode 100644 index 00000000..12b2db7d --- /dev/null +++ b/internal/v2/assets/token.go @@ -0,0 +1,325 @@ +package assets + +import ( + "errors" + "math" + "strings" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrInvalidTokenDefinition = errors.New("invalid token definition") + +type SupplyPolicy uint8 + +const ( + // SupplyCapped is zero intentionally so existing v2 capped-token builders + // remain valid during the clean break when MaxSupply is already provided. + SupplyCapped SupplyPolicy = iota + SupplyFixed + SupplyMintable + SupplyPolicyCount +) + +type Definition struct { + TokenID types.TokenID + Name string + Symbol string + Decimals uint8 + SupplyPolicy SupplyPolicy + MaxSupply uint64 + CurrentSupply uint64 + MintAuthority types.AccountID + Burnable bool + Transferable bool +} + +type CreateToken struct { + Name string + Symbol string + Decimals uint8 + SupplyPolicy SupplyPolicy + MaxSupply uint64 + InitialSupply uint64 + MintAuthority types.AccountID + Burnable bool + Transferable bool +} + +type MintToken struct { + DefinitionObject types.ObjectID + Recipient types.AccountID + Amount uint64 +} + +type BurnToken struct { + DefinitionObject types.ObjectID + Amount uint64 +} + +func (c CreateToken) Validate() error { + name := strings.TrimSpace(c.Name) + symbol := strings.TrimSpace(c.Symbol) + if name == "" || len(name) > 64 || symbol == "" || len(symbol) > 16 || c.Decimals > 18 || c.InitialSupply == 0 || + types.IsZero32([32]byte(c.MintAuthority)) { + return ErrInvalidTokenDefinition + } + switch c.SupplyPolicy { + case SupplyCapped: + if c.MaxSupply == 0 || c.InitialSupply > c.MaxSupply { + return ErrInvalidTokenDefinition + } + case SupplyFixed: + if c.MaxSupply == 0 || c.InitialSupply != c.MaxSupply { + return ErrInvalidTokenDefinition + } + case SupplyMintable: + if c.MaxSupply != 0 { + return ErrInvalidTokenDefinition + } + default: + return ErrInvalidTokenDefinition + } + return nil +} + +func (c CreateToken) MarshalBinary() ([]byte, error) { + if err := c.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.String(strings.TrimSpace(c.Name)) + w.String(strings.TrimSpace(c.Symbol)) + w.U8(c.Decimals) + w.U8(uint8(c.SupplyPolicy)) + w.U64(c.MaxSupply) + w.U64(c.InitialSupply) + w.Fixed(c.MintAuthority[:]) + w.Bool(c.Burnable) + w.Bool(c.Transferable) + return w.BytesCopy(), nil +} + +func ParseCreateToken(data []byte) (CreateToken, error) { + r := codec.NewReader(data) + name, err := r.String(64) + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + symbol, err := r.String(16) + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + decimals, err := r.U8() + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + policy, err := r.U8() + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + maxSupply, err := r.U64() + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + initialSupply, err := r.U64() + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + authBytes, err := r.Fixed(32) + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + burnable, err := r.Bool() + if err != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + transferable, err := r.Bool() + if err != nil || r.Done() != nil { + return CreateToken{}, ErrInvalidTokenDefinition + } + var authority types.AccountID + copy(authority[:], authBytes) + out := CreateToken{ + Name: name, Symbol: symbol, Decimals: decimals, SupplyPolicy: SupplyPolicy(policy), + MaxSupply: maxSupply, InitialSupply: initialSupply, MintAuthority: authority, Burnable: burnable, Transferable: transferable, + } + if err := out.Validate(); err != nil { + return CreateToken{}, err + } + return out, nil +} + +func (d Definition) Validate() error { + if types.IsZero32([32]byte(d.TokenID)) || strings.TrimSpace(d.Name) == "" || strings.TrimSpace(d.Symbol) == "" || + d.Decimals > 18 || types.IsZero32([32]byte(d.MintAuthority)) { + return ErrInvalidTokenDefinition + } + switch d.SupplyPolicy { + case SupplyCapped, SupplyFixed: + if d.MaxSupply == 0 || d.CurrentSupply > d.MaxSupply { + return ErrInvalidTokenDefinition + } + case SupplyMintable: + if d.MaxSupply != 0 { + return ErrInvalidTokenDefinition + } + default: + return ErrInvalidTokenDefinition + } + return nil +} + +func (d Definition) MarshalBinary() ([]byte, error) { + if err := d.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.Fixed(d.TokenID[:]) + w.String(strings.TrimSpace(d.Name)) + w.String(strings.TrimSpace(d.Symbol)) + w.U8(d.Decimals) + w.U8(uint8(d.SupplyPolicy)) + w.U64(d.MaxSupply) + w.U64(d.CurrentSupply) + w.Fixed(d.MintAuthority[:]) + w.Bool(d.Burnable) + w.Bool(d.Transferable) + return w.BytesCopy(), nil +} + +func ParseDefinition(data []byte) (Definition, error) { + r := codec.NewReader(data) + tokenRaw, err := r.Fixed(32) + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + name, err := r.String(64) + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + symbol, err := r.String(16) + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + decimals, err := r.U8() + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + policy, err := r.U8() + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + maxSupply, err := r.U64() + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + currentSupply, err := r.U64() + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + authorityRaw, err := r.Fixed(32) + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + burnable, err := r.Bool() + if err != nil { + return Definition{}, ErrInvalidTokenDefinition + } + transferable, err := r.Bool() + if err != nil || r.Done() != nil { + return Definition{}, ErrInvalidTokenDefinition + } + var token types.TokenID + var authority types.AccountID + copy(token[:], tokenRaw) + copy(authority[:], authorityRaw) + out := Definition{ + TokenID: token, Name: name, Symbol: symbol, Decimals: decimals, SupplyPolicy: SupplyPolicy(policy), + MaxSupply: maxSupply, CurrentSupply: currentSupply, MintAuthority: authority, Burnable: burnable, Transferable: transferable, + } + if err := out.Validate(); err != nil { + return Definition{}, err + } + return out, nil +} + +func (d Definition) Mint(amount uint64) (Definition, error) { + if err := d.Validate(); err != nil || amount == 0 || d.SupplyPolicy == SupplyFixed || math.MaxUint64-d.CurrentSupply < amount { + return Definition{}, ErrInvalidTokenDefinition + } + next := d + next.CurrentSupply += amount + if d.SupplyPolicy == SupplyCapped && next.CurrentSupply > d.MaxSupply { + return Definition{}, ErrInvalidTokenDefinition + } + return next, next.Validate() +} + +func (d Definition) Burn(amount uint64) (Definition, error) { + if err := d.Validate(); err != nil || !d.Burnable || amount == 0 || amount > d.CurrentSupply { + return Definition{}, ErrInvalidTokenDefinition + } + next := d + next.CurrentSupply -= amount + return next, next.Validate() +} + +func (m MintToken) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(m.DefinitionObject)) || types.IsZero32([32]byte(m.Recipient)) || m.Amount == 0 { + return nil, ErrInvalidTokenDefinition + } + var w codec.Writer + w.Fixed(m.DefinitionObject[:]) + w.Fixed(m.Recipient[:]) + w.U64(m.Amount) + return w.BytesCopy(), nil +} + +func ParseMintToken(data []byte) (MintToken, error) { + if len(data) != 72 { + return MintToken{}, ErrInvalidTokenDefinition + } + var out MintToken + copy(out.DefinitionObject[:], data[:32]) + copy(out.Recipient[:], data[32:64]) + r := codec.NewReader(data[64:]) + amount, err := r.U64() + if err != nil || r.Done() != nil { + return MintToken{}, ErrInvalidTokenDefinition + } + out.Amount = amount + if _, err := out.MarshalBinary(); err != nil { + return MintToken{}, err + } + return out, nil +} + +func (b BurnToken) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(b.DefinitionObject)) || b.Amount == 0 { + return nil, ErrInvalidTokenDefinition + } + var w codec.Writer + w.Fixed(b.DefinitionObject[:]) + w.U64(b.Amount) + return w.BytesCopy(), nil +} + +func ParseBurnToken(data []byte) (BurnToken, error) { + if len(data) != 40 { + return BurnToken{}, ErrInvalidTokenDefinition + } + var out BurnToken + copy(out.DefinitionObject[:], data[:32]) + r := codec.NewReader(data[32:]) + amount, err := r.U64() + if err != nil || r.Done() != nil { + return BurnToken{}, ErrInvalidTokenDefinition + } + out.Amount = amount + if _, err := out.MarshalBinary(); err != nil { + return BurnToken{}, err + } + return out, nil +} diff --git a/internal/v2/assets/token_test.go b/internal/v2/assets/token_test.go new file mode 100644 index 00000000..99331a6a --- /dev/null +++ b/internal/v2/assets/token_test.go @@ -0,0 +1,88 @@ +package assets + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestTokenDefinitionSupplyPoliciesAndRoundTrip(t *testing.T) { + var token types.TokenID + var authority types.AccountID + token[0] = 1 + authority[0] = 2 + definition := Definition{ + TokenID: token, Name: "Capped", Symbol: "CAP", Decimals: 6, + SupplyPolicy: SupplyCapped, MaxSupply: 1_000, CurrentSupply: 400, + MintAuthority: authority, Burnable: true, Transferable: true, + } + raw, err := definition.MarshalBinary() + if err != nil { + t.Fatal(err) + } + parsed, err := ParseDefinition(raw) + if err != nil { + t.Fatal(err) + } + if parsed != definition { + t.Fatalf("definition round trip mismatch: %#v != %#v", parsed, definition) + } + minted, err := parsed.Mint(600) + if err != nil || minted.CurrentSupply != 1_000 { + t.Fatalf("unexpected capped mint result: %#v %v", minted, err) + } + if _, err := minted.Mint(1); err != ErrInvalidTokenDefinition { + t.Fatalf("expected cap rejection, got %v", err) + } + burned, err := minted.Burn(1_000) + if err != nil || burned.CurrentSupply != 0 { + t.Fatalf("full burn must leave a valid zero-supply definition: %#v %v", burned, err) + } +} + +func TestFixedAndUnlimitedMintPolicies(t *testing.T) { + var token types.TokenID + var authority types.AccountID + token[0] = 3 + authority[0] = 4 + fixed := Definition{ + TokenID: token, Name: "Fixed", Symbol: "FIX", SupplyPolicy: SupplyFixed, + MaxSupply: 100, CurrentSupply: 100, MintAuthority: authority, Transferable: true, + } + if _, err := fixed.Mint(1); err != ErrInvalidTokenDefinition { + t.Fatalf("fixed token unexpectedly minted: %v", err) + } + unlimited := fixed + unlimited.Name = "Mintable" + unlimited.Symbol = "MINT" + unlimited.SupplyPolicy = SupplyMintable + unlimited.MaxSupply = 0 + if next, err := unlimited.Mint(50); err != nil || next.CurrentSupply != 150 { + t.Fatalf("unlimited mint failed: %#v %v", next, err) + } +} + +func TestTokenMutationPayloadRoundTrip(t *testing.T) { + var definition types.ObjectID + var recipient types.AccountID + definition[0] = 1 + recipient[0] = 2 + mint := MintToken{DefinitionObject: definition, Recipient: recipient, Amount: 77} + raw, err := mint.MarshalBinary() + if err != nil { + t.Fatal(err) + } + parsedMint, err := ParseMintToken(raw) + if err != nil || parsedMint != mint { + t.Fatalf("mint payload mismatch: %#v %v", parsedMint, err) + } + burn := BurnToken{DefinitionObject: definition, Amount: 33} + raw, err = burn.MarshalBinary() + if err != nil { + t.Fatal(err) + } + parsedBurn, err := ParseBurnToken(raw) + if err != nil || parsedBurn != burn { + t.Fatalf("burn payload mismatch: %#v %v", parsedBurn, err) + } +} diff --git a/internal/v2/checkpoint/chain.go b/internal/v2/checkpoint/chain.go new file mode 100644 index 00000000..421ec515 --- /dev/null +++ b/internal/v2/checkpoint/chain.go @@ -0,0 +1,165 @@ +package checkpoint + +import ( + "errors" + "sync" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ( + ErrCheckpointConfig = errors.New("invalid checkpoint chain configuration") + ErrCheckpointSequence = errors.New("invalid checkpoint sequence") + ErrCheckpointValidators = errors.New("checkpoint validator set mismatch") + ErrCheckpointCert = errors.New("invalid checkpoint certificate") +) + +type Entry struct { + Header sharding.GlobalHeader + Certificate v2consensus.Certificate + Validators v2consensus.ValidatorSet +} + +type Chain struct { + mu sync.RWMutex + network types.NetworkID + currentRoot types.Hash + lastHeight uint64 + lastHash types.Hash + entries map[uint64]Entry + sets map[types.Hash]v2consensus.ValidatorSet +} + +func New(network types.NetworkID, genesis v2consensus.ValidatorSet) (*Chain, error) { + if types.IsZero32([32]byte(network)) || genesis.Network != network { + return nil, ErrCheckpointConfig + } + root, err := genesis.Root() + if err != nil || types.IsZero32([32]byte(root)) { + return nil, ErrCheckpointConfig + } + return &Chain{ + network: network, + currentRoot: root, + entries: make(map[uint64]Entry), + sets: map[types.Hash]v2consensus.ValidatorSet{root: cloneSet(genesis)}, + }, nil +} + +func (c *Chain) Network() types.NetworkID { + c.mu.RLock() + defer c.mu.RUnlock() + return c.network +} + +func (c *Chain) CurrentRoot() types.Hash { + c.mu.RLock() + defer c.mu.RUnlock() + return c.currentRoot +} + +func (c *Chain) Height() uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.lastHeight +} + +func (c *Chain) Append(header sharding.GlobalHeader, certificate v2consensus.Certificate, current v2consensus.ValidatorSet, next *v2consensus.ValidatorSet) error { + c.mu.Lock() + defer c.mu.Unlock() + + if header.Network != c.network || certificate.Network != c.network || current.Network != c.network { + return ErrCheckpointSequence + } + if header.Height != c.lastHeight+1 || (c.lastHeight > 0 && header.ParentHash != c.lastHash) { + return ErrCheckpointSequence + } + currentRoot, err := current.Root() + if err != nil || currentRoot != c.currentRoot || header.ValidatorRoot != currentRoot { + return ErrCheckpointValidators + } + if certificate.Height != header.Height || certificate.HeaderHash != v2consensus.HeaderConsensusHash(header) || header.CertificateHash != certificate.Hash() { + return ErrCheckpointCert + } + if err := current.VerifyCertificate(certificate); err != nil { + return ErrCheckpointCert + } + + nextRoot := header.EffectiveNextValidatorRoot() + if nextRoot != currentRoot { + if next == nil || next.Network != c.network { + return ErrCheckpointValidators + } + computed, err := next.Root() + if err != nil || computed != nextRoot { + return ErrCheckpointValidators + } + c.sets[nextRoot] = cloneSet(*next) + } else if next != nil { + computed, err := next.Root() + if err != nil || computed != nextRoot { + return ErrCheckpointValidators + } + c.sets[nextRoot] = cloneSet(*next) + } + + c.sets[currentRoot] = cloneSet(current) + c.entries[header.Height] = Entry{Header: header, Certificate: certificate, Validators: cloneSet(current)} + c.currentRoot = nextRoot + c.lastHeight = header.Height + c.lastHash = v2consensus.HeaderConsensusHash(header) + return nil +} + +func (c *Chain) Entry(height uint64) (Entry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + entry, ok := c.entries[height] + if !ok { + return Entry{}, false + } + entry.Validators = cloneSet(entry.Validators) + entry.Certificate.Votes = append([]v2consensus.Vote(nil), entry.Certificate.Votes...) + return entry, true +} + +func (c *Chain) ValidatorSet(root types.Hash) (v2consensus.ValidatorSet, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + set, ok := c.sets[root] + if !ok { + return v2consensus.ValidatorSet{}, false + } + return cloneSet(set), true +} + +func (c *Chain) VerifyEntry(height uint64) error { + c.mu.RLock() + entry, ok := c.entries[height] + c.mu.RUnlock() + if !ok { + return ErrCheckpointSequence + } + root, err := entry.Validators.Root() + if err != nil || root != entry.Header.ValidatorRoot { + return ErrCheckpointValidators + } + if entry.Certificate.HeaderHash != v2consensus.HeaderConsensusHash(entry.Header) || entry.Header.CertificateHash != entry.Certificate.Hash() { + return ErrCheckpointCert + } + if err := entry.Validators.VerifyCertificate(entry.Certificate); err != nil { + return ErrCheckpointCert + } + return nil +} + +func cloneSet(in v2consensus.ValidatorSet) v2consensus.ValidatorSet { + out := v2consensus.ValidatorSet{Network: in.Network, Validators: make([]v2consensus.Validator, len(in.Validators))} + for i, validator := range in.Validators { + out.Validators[i] = validator + out.Validators[i].PublicKey = append([]byte(nil), validator.PublicKey...) + } + return out +} diff --git a/internal/v2/checkpoint/chain_test.go b/internal/v2/checkpoint/chain_test.go new file mode 100644 index 00000000..f6fcbfef --- /dev/null +++ b/internal/v2/checkpoint/chain_test.go @@ -0,0 +1,116 @@ +package checkpoint + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestCheckpointChainTracksCertifiedValidatorRotation(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("checkpoint-chain"))) + currentKey, current := testValidatorSet(t, network, 10) + _, next := testValidatorSet(t, network, 20) + currentRoot, err := current.Root() + if err != nil { + t.Fatal(err) + } + nextRoot, err := next.Root() + if err != nil { + t.Fatal(err) + } + chain, err := New(network, current) + if err != nil { + t.Fatal(err) + } + + header := sharding.GlobalHeader{ + Version: 2, + Network: network, + Height: 1, + ShardCommitmentRoot: types.HashBytes("shards", []byte("one")), + ValidatorRoot: currentRoot, + NextValidatorRoot: nextRoot, + DataRoot: types.HashBytes("data", []byte("one")), + } + proposal, err := v2consensus.SignProposal(currentKey, header, 0) + if err != nil { + t.Fatal(err) + } + vote, err := v2consensus.SignVote(currentKey, network, 1, 0, v2consensus.HeaderConsensusHash(header)) + if err != nil { + t.Fatal(err) + } + certificate, err := current.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + header.CertificateHash = certificate.Hash() + if err := chain.Append(header, certificate, current, &next); err != nil { + t.Fatal(err) + } + if chain.Height() != 1 || chain.CurrentRoot() != nextRoot { + t.Fatal("checkpoint chain did not advance validator trust root") + } + if err := chain.VerifyEntry(1); err != nil { + t.Fatal(err) + } + if historical, ok := chain.ValidatorSet(currentRoot); !ok || historical.Network != network { + t.Fatal("historical validator set unavailable") + } + if future, ok := chain.ValidatorSet(nextRoot); !ok || future.Network != network { + t.Fatal("next validator set unavailable") + } +} + +func TestCheckpointChainRejectsUncertifiedRotation(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("checkpoint-reject"))) + key, current := testValidatorSet(t, network, 10) + _, attacker := testValidatorSet(t, network, 10) + currentRoot, _ := current.Root() + attackerRoot, _ := attacker.Root() + chain, err := New(network, current) + if err != nil { + t.Fatal(err) + } + header := sharding.GlobalHeader{ + Version: 2, + Network: network, + Height: 1, + ShardCommitmentRoot: types.HashBytes("shards", []byte("reject")), + ValidatorRoot: currentRoot, + NextValidatorRoot: attackerRoot, + DataRoot: types.HashBytes("data", []byte("reject")), + } + proposal, err := v2consensus.SignProposal(key, header, 0) + if err != nil { + t.Fatal(err) + } + vote, err := v2consensus.SignVote(key, network, 1, 0, v2consensus.HeaderConsensusHash(header)) + if err != nil { + t.Fatal(err) + } + certificate, err := current.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + header.CertificateHash = certificate.Hash() + if err := chain.Append(header, certificate, current, nil); err != ErrCheckpointValidators { + t.Fatalf("expected uncertified next-set rejection, got %v", err) + } +} + +func testValidatorSet(t *testing.T, network types.NetworkID, power uint64) (*ecdsa.PrivateKey, v2consensus.ValidatorSet) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + return key, v2consensus.ValidatorSet{Network: network, Validators: []v2consensus.Validator{{ID: id, PublicKey: pub, Power: power}}} +} diff --git a/internal/v2/citizen/trusted.go b/internal/v2/citizen/trusted.go new file mode 100644 index 00000000..14f182fe --- /dev/null +++ b/internal/v2/citizen/trusted.go @@ -0,0 +1,152 @@ +package citizen + +import ( + "encoding/hex" + "encoding/json" + "errors" + "strconv" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/state" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrTrustedBundle = errors.New("Citizen bundle is not anchored to trusted finalized state") + +type TrustAnchor struct { + Network types.NetworkID + ValidatorRoot types.Hash +} + +type ValidatorDTO struct { + ID string `json:"id"` + PublicKey []byte `json:"publicKey"` + Power string `json:"power"` +} + +type ObjectBundle struct { + Network string `json:"network"` + Height uint64 `json:"height"` + ShardID uint32 `json:"shardId"` + Header []byte `json:"header"` + Certificate []byte `json:"certificate"` + Commitment []byte `json:"commitment"` + CommitmentProof []byte `json:"commitmentProof"` + ObjectID string `json:"objectId"` + ObjectPresent bool `json:"objectPresent"` + Object []byte `json:"object,omitempty"` + StateProof []byte `json:"stateProof"` + Validators []ValidatorDTO `json:"validators"` +} + +type VerifiedObject struct { + Network types.NetworkID + Height uint64 + ShardID uint32 + ObjectID types.ObjectID + Present bool + Object object.Object + StateRoot types.Hash + NextAnchor TrustAnchor +} + +func VerifyObjectBundleJSON(data []byte, anchor TrustAnchor) (VerifiedObject, error) { + var bundle ObjectBundle + if len(data) == 0 || json.Unmarshal(data, &bundle) != nil { + return VerifiedObject{}, ErrTrustedBundle + } + return VerifyObjectBundle(bundle, anchor) +} + +func VerifyObjectBundle(bundle ObjectBundle, anchor TrustAnchor) (VerifiedObject, error) { + if types.IsZero32([32]byte(anchor.Network)) || types.IsZero32([32]byte(anchor.ValidatorRoot)) { + return VerifiedObject{}, ErrTrustedBundle + } + header, err := sharding.ParseGlobalHeader(bundle.Header) + if err != nil || header.Network != anchor.Network || header.ValidatorRoot != anchor.ValidatorRoot || header.Height != bundle.Height || bundle.Network != header.Network.String() { + return VerifiedObject{}, ErrTrustedBundle + } + validators, err := validatorSetFromDTO(header.Network, bundle.Validators) + if err != nil { + return VerifiedObject{}, ErrTrustedBundle + } + validatorRoot, err := validators.Root() + if err != nil || validatorRoot != header.ValidatorRoot { + return VerifiedObject{}, ErrTrustedBundle + } + certificate, err := v2consensus.ParseCertificate(bundle.Certificate) + if err != nil || certificate.Network != header.Network || certificate.Height != header.Height || certificate.HeaderHash != v2consensus.HeaderConsensusHash(header) || header.CertificateHash != certificate.Hash() { + return VerifiedObject{}, ErrTrustedBundle + } + if err := validators.VerifyCertificate(certificate); err != nil { + return VerifiedObject{}, ErrTrustedBundle + } + commitment, err := sharding.ParseCommitment(bundle.Commitment) + if err != nil || commitment.ShardID != bundle.ShardID { + return VerifiedObject{}, ErrTrustedBundle + } + commitmentProof, err := merkle.ParseProof(bundle.CommitmentProof) + if err != nil || !merkle.Verify(header.ShardCommitmentRoot, commitment.Hash(), commitmentProof) { + return VerifiedObject{}, ErrTrustedBundle + } + objectRaw, err := hex.DecodeString(bundle.ObjectID) + if err != nil || len(objectRaw) != 32 { + return VerifiedObject{}, ErrTrustedBundle + } + var objectID types.ObjectID + copy(objectID[:], objectRaw) + proof, err := state.ParseProof(bundle.StateProof) + if err != nil || proof.Exists != bundle.ObjectPresent { + return VerifiedObject{}, ErrTrustedBundle + } + verified := VerifiedObject{Network: header.Network, Height: header.Height, ShardID: bundle.ShardID, ObjectID: objectID, Present: bundle.ObjectPresent, StateRoot: commitment.StateRoot, NextAnchor: TrustAnchor{Network: header.Network, ValidatorRoot: header.EffectiveNextValidatorRoot()}} + if bundle.ObjectPresent { + obj, err := object.ParseObject(bundle.Object) + if err != nil || obj.ID != objectID { + return VerifiedObject{}, ErrTrustedBundle + } + hash := obj.Hash() + if !state.Verify(commitment.StateRoot, types.Hash(objectID), hash[:], proof) { + return VerifiedObject{}, ErrTrustedBundle + } + verified.Object = obj + } else if len(bundle.Object) != 0 || !state.Verify(commitment.StateRoot, types.Hash(objectID), nil, proof) { + return VerifiedObject{}, ErrTrustedBundle + } + return verified, nil +} + +func validatorSetFromDTO(network types.NetworkID, values []ValidatorDTO) (v2consensus.ValidatorSet, error) { + if len(values) == 0 || len(values) > v2consensus.MaxCertificateVotes { + return v2consensus.ValidatorSet{}, ErrTrustedBundle + } + set := v2consensus.ValidatorSet{Network: network, Validators: make([]v2consensus.Validator, len(values))} + seen := make(map[types.ValidatorID]struct{}, len(values)) + for i, value := range values { + idRaw, err := hex.DecodeString(value.ID) + if err != nil || len(idRaw) != 32 { + return v2consensus.ValidatorSet{}, ErrTrustedBundle + } + var id types.ValidatorID + copy(id[:], idRaw) + if types.ValidatorIDFromPublicKey(value.PublicKey) != id { + return v2consensus.ValidatorSet{}, ErrTrustedBundle + } + if _, duplicate := seen[id]; duplicate { + return v2consensus.ValidatorSet{}, ErrTrustedBundle + } + seen[id] = struct{}{} + power, err := strconv.ParseUint(value.Power, 10, 64) + if err != nil || power == 0 { + return v2consensus.ValidatorSet{}, ErrTrustedBundle + } + set.Validators[i] = v2consensus.Validator{ID: id, PublicKey: append([]byte(nil), value.PublicKey...), Power: power} + } + if err := set.Validate(); err != nil { + return v2consensus.ValidatorSet{}, ErrTrustedBundle + } + return set, nil +} diff --git a/internal/v2/citizen/verifier.go b/internal/v2/citizen/verifier.go new file mode 100644 index 00000000..270a1ffa --- /dev/null +++ b/internal/v2/citizen/verifier.go @@ -0,0 +1,91 @@ +package citizen + +import ( + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/da" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/state" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrFinality = errors.New("global header finality could not be verified") + +type FinalityVerifier interface { + VerifyFinalizedHeader(header sharding.GlobalHeader) error +} + +type Verifier struct { + Network types.NetworkID + Finality FinalityVerifier +} + +func (v Verifier) VerifyTransaction(t tx.Transaction) error { + if err := t.VerifyForNetwork(v.Network); err != nil { + return err + } + return t.VerifyWitnesses() +} + +func (v Verifier) VerifyObject(root types.Hash, obj object.Object, proof state.Proof) bool { + if err := obj.Validate(); err != nil { + return false + } + h := obj.Hash() + return state.Verify(root, types.Hash(obj.ID), h[:], proof) +} + +func (v Verifier) VerifyShard(header sharding.GlobalHeader, commitment sharding.Commitment, proof merkle.Proof) error { + if header.Network != v.Network { + return ErrFinality + } + if v.Finality == nil || v.Finality.VerifyFinalizedHeader(header) != nil { + return ErrFinality + } + if !merkle.Verify(header.ShardCommitmentRoot, commitment.Hash(), proof) { + return ErrFinality + } + return nil +} + +func (v Verifier) VerifyDASample(commitment da.Commitment, sample da.Sample, chunk []byte) bool { + return da.VerifySample(commitment, sample, chunk) +} + +type PowerState struct { + BatteryPercent uint8 + Charging bool + WiFi bool + LowPower bool + AppActive bool +} + +type Mode struct { + VerifyHeaders bool + Relay bool + SampleDA bool + ExecuteRecent bool + ServeCache bool +} + +func SelectMode(p PowerState) Mode { + mode := Mode{VerifyHeaders: true} + if p.LowPower || p.BatteryPercent < 15 { + return mode + } + if p.AppActive { + mode.Relay = true + } + if p.WiFi && p.BatteryPercent >= 30 { + mode.SampleDA = true + mode.ServeCache = p.AppActive + } + if p.WiFi && p.Charging && p.BatteryPercent >= 50 { + mode.ExecuteRecent = true + mode.ServeCache = true + } + return mode +} diff --git a/internal/v2/citizen/verifier_test.go b/internal/v2/citizen/verifier_test.go new file mode 100644 index 00000000..f91cd902 --- /dev/null +++ b/internal/v2/citizen/verifier_test.go @@ -0,0 +1,14 @@ +package citizen + +import "testing" + +func TestPowerAwareMode(t *testing.T) { + low := SelectMode(PowerState{BatteryPercent: 10, AppActive: true, WiFi: true}) + if !low.VerifyHeaders || low.Relay || low.SampleDA || low.ExecuteRecent { + t.Fatalf("unexpected low-power mode: %+v", low) + } + full := SelectMode(PowerState{BatteryPercent: 80, AppActive: true, WiFi: true, Charging: true}) + if !full.VerifyHeaders || !full.Relay || !full.SampleDA || !full.ExecuteRecent || !full.ServeCache { + t.Fatalf("unexpected full mode: %+v", full) + } +} diff --git a/internal/v2/codec/codec.go b/internal/v2/codec/codec.go new file mode 100644 index 00000000..e9272ecd --- /dev/null +++ b/internal/v2/codec/codec.go @@ -0,0 +1,183 @@ +package codec + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" +) + +var ( + ErrUnexpectedEOF = errors.New("unexpected end of canonical payload") + ErrTrailingData = errors.New("trailing data in canonical payload") + ErrLengthLimit = errors.New("canonical field exceeds configured length limit") +) + +type Writer struct { + buf bytes.Buffer +} + +func (w *Writer) U8(v uint8) { _ = w.buf.WriteByte(v) } + +func (w *Writer) U16(v uint16) { + var b [2]byte + binary.BigEndian.PutUint16(b[:], v) + _, _ = w.buf.Write(b[:]) +} + +func (w *Writer) U32(v uint32) { + var b [4]byte + binary.BigEndian.PutUint32(b[:], v) + _, _ = w.buf.Write(b[:]) +} + +func (w *Writer) U64(v uint64) { + var b [8]byte + binary.BigEndian.PutUint64(b[:], v) + _, _ = w.buf.Write(b[:]) +} + +func (w *Writer) Bool(v bool) { + if v { + w.U8(1) + return + } + w.U8(0) +} + +func (w *Writer) Fixed(v []byte) { + _, _ = w.buf.Write(v) +} + +func (w *Writer) Bytes(v []byte) { + w.U32(uint32(len(v))) + w.Fixed(v) +} + +func (w *Writer) String(v string) { w.Bytes([]byte(v)) } + +func (w *Writer) BytesCopy() []byte { + out := make([]byte, w.buf.Len()) + copy(out, w.buf.Bytes()) + return out +} + +type Reader struct { + data []byte + off int +} + +func NewReader(data []byte) *Reader { + return &Reader{data: data} +} + +func (r *Reader) take(n int) ([]byte, error) { + if n < 0 || r.off+n > len(r.data) { + return nil, ErrUnexpectedEOF + } + out := r.data[r.off : r.off+n] + r.off += n + return out, nil +} + +func (r *Reader) U8() (uint8, error) { + b, err := r.take(1) + if err != nil { + return 0, err + } + return b[0], nil +} + +func (r *Reader) U16() (uint16, error) { + b, err := r.take(2) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint16(b), nil +} + +func (r *Reader) U32() (uint32, error) { + b, err := r.take(4) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint32(b), nil +} + +func (r *Reader) U64() (uint64, error) { + b, err := r.take(8) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint64(b), nil +} + +func (r *Reader) Bool() (bool, error) { + v, err := r.U8() + if err != nil { + return false, err + } + switch v { + case 0: + return false, nil + case 1: + return true, nil + default: + return false, fmt.Errorf("invalid canonical bool %d", v) + } +} + +func (r *Reader) Fixed(n int) ([]byte, error) { return r.take(n) } + +func (r *Reader) Bytes(max uint32) ([]byte, error) { + n, err := r.U32() + if err != nil { + return nil, err + } + if n > max { + return nil, ErrLengthLimit + } + b, err := r.take(int(n)) + if err != nil { + return nil, err + } + out := make([]byte, len(b)) + copy(out, b) + return out, nil +} + +func (r *Reader) String(max uint32) (string, error) { + b, err := r.Bytes(max) + if err != nil { + return "", err + } + return string(b), nil +} + +func (r *Reader) Done() error { + if r.off != len(r.data) { + return ErrTrailingData + } + return nil +} + +// DomainHash preserves the exact canonical framing used by Writer.String + +// Writer.Bytes, but streams it directly into SHA-256 instead of allocating a +// temporary buffer and then copying that buffer before hashing. Consensus bytes +// and hash outputs therefore remain bit-for-bit compatible while hot Merkle +// paths avoid two transient allocations per hash. +func DomainHash(domain string, payload []byte) [32]byte { + h := sha256.New() + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(domain))) + _, _ = h.Write(length[:]) + _, _ = h.Write([]byte(domain)) + binary.BigEndian.PutUint32(length[:], uint32(len(payload))) + _, _ = h.Write(length[:]) + _, _ = h.Write(payload) + var out [32]byte + sum := h.Sum(out[:0]) + copy(out[:], sum) + return out +} diff --git a/internal/v2/codec/codec_test.go b/internal/v2/codec/codec_test.go new file mode 100644 index 00000000..2d55d5d3 --- /dev/null +++ b/internal/v2/codec/codec_test.go @@ -0,0 +1,72 @@ +package codec + +import ( + "bytes" + "crypto/sha256" + "testing" +) + +func TestCanonicalRoundTrip(t *testing.T) { + var w Writer + w.U8(7) + w.U16(0x1234) + w.U32(42) + w.U64(99) + w.Bool(true) + w.String("zephyr") + w.Bytes([]byte{1, 2, 3}) + + r := NewReader(w.BytesCopy()) + if v, _ := r.U8(); v != 7 { + t.Fatalf("u8=%d", v) + } + if v, _ := r.U16(); v != 0x1234 { + t.Fatalf("u16=%d", v) + } + if v, _ := r.U32(); v != 42 { + t.Fatalf("u32=%d", v) + } + if v, _ := r.U64(); v != 99 { + t.Fatalf("u64=%d", v) + } + if v, _ := r.Bool(); !v { + t.Fatal("bool=false") + } + if v, _ := r.String(64); v != "zephyr" { + t.Fatalf("string=%q", v) + } + if v, _ := r.Bytes(64); !bytes.Equal(v, []byte{1, 2, 3}) { + t.Fatalf("bytes=%v", v) + } + if err := r.Done(); err != nil { + t.Fatal(err) + } +} + +func TestDomainHashSeparatesDomains(t *testing.T) { + a := DomainHash("a", []byte("same")) + b := DomainHash("b", []byte("same")) + if a == b { + t.Fatal("domain-separated hashes collided") + } +} + +func TestDomainHashMatchesCanonicalWriterFraming(t *testing.T) { + cases := []struct { + domain string + payload []byte + }{ + {domain: "zephyr/smt/branch/v2", payload: bytes.Repeat([]byte{0x42}, 64)}, + {domain: "zephyr/transaction/v2", payload: []byte("proof-carrying")}, + {domain: "", payload: nil}, + } + for _, tc := range cases { + var legacy Writer + legacy.String(tc.domain) + legacy.Bytes(tc.payload) + expected := sha256.Sum256(legacy.BytesCopy()) + if got := DomainHash(tc.domain, tc.payload); got != expected { + t.Fatalf("domain hash framing changed for %q", tc.domain) + } + } +} diff --git a/internal/v2/compute/market.go b/internal/v2/compute/market.go new file mode 100644 index 00000000..7364cf22 --- /dev/null +++ b/internal/v2/compute/market.go @@ -0,0 +1,345 @@ +package compute + +import ( + "errors" + "math" + "sort" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ( + ErrMarketNotFound = errors.New("compute market record not found") + ErrMarketState = errors.New("invalid compute market state transition") + ErrMarketMatch = errors.New("compute offer does not satisfy job") + ErrMarketEscrow = errors.New("compute job escrow is insufficient") + ErrMarketVerification = errors.New("compute result verification requirements not met") + ErrMarketDuplicate = errors.New("duplicate compute market record") +) + +type JobStatus uint8 + +const ( + JobPending JobStatus = iota + 1 + JobAssigned + JobAwaitingVerification + JobSettled + JobExpired +) + +type OfferRecord struct { + ID types.Hash + Offer Offer +} + +type Assignment struct { + OfferID types.Hash + Provider types.AccountID + Price uint64 +} + +type JobRecord struct { + ID types.JobID + Job Job + Escrow uint64 + Status JobStatus + Assignments []Assignment + Results map[types.AccountID]Result +} + +type VerificationEvidence struct { + DeterministicReplay bool + ProofVerified bool + AttestationVerified bool + ChallengePassed bool + ClientApproved bool +} + +type Settlement struct { + JobID types.JobID + ResultRoot types.Hash + Payments map[types.AccountID]uint64 + Refund uint64 +} + +type Market struct { + mu sync.Mutex + offers map[types.Hash]OfferRecord + jobs map[types.JobID]*JobRecord +} + +func NewMarket() *Market { + return &Market{offers: make(map[types.Hash]OfferRecord), jobs: make(map[types.JobID]*JobRecord)} +} + +func OfferID(offer Offer) (types.Hash, error) { + raw, err := offer.MarshalBinary() + if err != nil { + return types.Hash{}, err + } + return types.Hash(codec.DomainHash("zephyr/compute-offer/v2", raw)), nil +} + +func ComputeJobID(job Job) (types.JobID, error) { + raw, err := job.MarshalBinary() + if err != nil { + return types.JobID{}, err + } + return types.JobID(codec.DomainHash("zephyr/compute-job/v2", raw)), nil +} + +func (m *Market) PublishOffer(offer Offer, height uint64) (types.Hash, error) { + if err := offer.Validate(); err != nil || height == 0 || offer.ValidUntilHeight < height { + return types.Hash{}, ErrInvalidOffer + } + id, _ := OfferID(offer) + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.offers[id]; exists { + return types.Hash{}, ErrMarketDuplicate + } + m.offers[id] = OfferRecord{ID: id, Offer: offer} + return id, nil +} + +func (m *Market) PostJob(job Job, escrow, height uint64) (types.JobID, error) { + if err := job.Validate(); err != nil || height == 0 || job.DeadlineHeight < height { + return types.JobID{}, ErrInvalidJob + } + if escrow < job.MaxPrice { + return types.JobID{}, ErrMarketEscrow + } + id, _ := ComputeJobID(job) + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.jobs[id]; exists { + return types.JobID{}, ErrMarketDuplicate + } + m.jobs[id] = &JobRecord{ID: id, Job: job, Escrow: escrow, Status: JobPending, Results: make(map[types.AccountID]Result)} + return id, nil +} + +func (m *Market) Assign(jobID types.JobID, offerID types.Hash, height uint64) (Assignment, error) { + m.mu.Lock() + defer m.mu.Unlock() + job, ok := m.jobs[jobID] + if !ok { + return Assignment{}, ErrMarketNotFound + } + offerRecord, ok := m.offers[offerID] + if !ok { + return Assignment{}, ErrMarketNotFound + } + if job.Status == JobSettled || job.Status == JobExpired || height == 0 || height > job.Job.DeadlineHeight || height > offerRecord.Offer.ValidUntilHeight { + return Assignment{}, ErrMarketState + } + if !offerMatchesJob(offerRecord.Offer, job.Job) { + return Assignment{}, ErrMarketMatch + } + for _, existing := range job.Assignments { + if existing.Provider == offerRecord.Offer.Provider { + return Assignment{}, ErrMarketDuplicate + } + } + target := requiredAssignments(job.Job) + if len(job.Assignments) >= target { + return Assignment{}, ErrMarketState + } + var committed uint64 + for _, existing := range job.Assignments { + if math.MaxUint64-committed < existing.Price { + return Assignment{}, ErrMarketEscrow + } + committed += existing.Price + } + price := offerRecord.Offer.PricePerUnit + if price > job.Job.MaxPrice || math.MaxUint64-committed < price || committed+price > job.Job.MaxPrice || committed+price > job.Escrow { + return Assignment{}, ErrMarketEscrow + } + assignment := Assignment{OfferID: offerID, Provider: offerRecord.Offer.Provider, Price: price} + job.Assignments = append(job.Assignments, assignment) + if len(job.Assignments) == target { + job.Status = JobAssigned + } + return assignment, nil +} + +func (m *Market) SubmitResult(result Result) error { + if err := result.Validate(); err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + job, ok := m.jobs[result.JobID] + if !ok { + return ErrMarketNotFound + } + if job.Status != JobAssigned && job.Status != JobAwaitingVerification { + return ErrMarketState + } + if result.CompletedHeight > job.Job.DeadlineHeight { + return ErrMarketState + } + assigned := false + for _, assignment := range job.Assignments { + if assignment.Provider == result.Provider { + assigned = true + break + } + } + if !assigned { + return ErrMarketMatch + } + if _, duplicate := job.Results[result.Provider]; duplicate { + return ErrMarketDuplicate + } + job.Results[result.Provider] = result + if len(job.Results) == requiredAssignments(job.Job) { + job.Status = JobAwaitingVerification + } + return nil +} + +func (m *Market) Finalize(jobID types.JobID, evidence VerificationEvidence) (Settlement, error) { + m.mu.Lock() + defer m.mu.Unlock() + job, ok := m.jobs[jobID] + if !ok { + return Settlement{}, ErrMarketNotFound + } + if job.Status != JobAwaitingVerification || len(job.Results) != requiredAssignments(job.Job) { + return Settlement{}, ErrMarketState + } + root, replicatedMatch := commonResultRoot(job) + if types.IsZero32([32]byte(root)) || !verificationSatisfied(job.Job.Verification, replicatedMatch, job.Results, evidence) { + return Settlement{}, ErrMarketVerification + } + payments := make(map[types.AccountID]uint64, len(job.Assignments)) + var paid uint64 + for _, assignment := range job.Assignments { + if math.MaxUint64-paid < assignment.Price { + return Settlement{}, ErrMarketEscrow + } + paid += assignment.Price + payments[assignment.Provider] += assignment.Price + } + if paid > job.Escrow { + return Settlement{}, ErrMarketEscrow + } + job.Status = JobSettled + return Settlement{JobID: jobID, ResultRoot: root, Payments: payments, Refund: job.Escrow - paid}, nil +} + +func (m *Market) Expire(height uint64) []types.JobID { + m.mu.Lock() + defer m.mu.Unlock() + var expired []types.JobID + for id, job := range m.jobs { + if job.Status != JobSettled && job.Status != JobExpired && height > job.Job.DeadlineHeight { + job.Status = JobExpired + expired = append(expired, id) + } + } + sort.Slice(expired, func(i, j int) bool { return expired[i].String() < expired[j].String() }) + return expired +} + +func requiredAssignments(job Job) int { + if job.Verification == VerificationReplicated { + return int(job.Replicas) + } + return 1 +} + +func offerMatchesJob(offer Offer, job Job) bool { + if offer.Resources.CPUCores < job.Resources.CPUCores || offer.Resources.MemoryMiB < job.Resources.MemoryMiB || + offer.Resources.GPUCount < job.Resources.GPUCount || offer.Resources.GPUMemoryMiB < job.Resources.GPUMemoryMiB || + offer.Resources.StorageMiB < job.Resources.StorageMiB || offer.Resources.BandwidthMbps < job.Resources.BandwidthMbps || + offer.Collateral < job.CollateralRequired || !supportsMode(offer.Verification, job.Verification) { + return false + } + available := make(map[string]struct{}, len(offer.Resources.Capabilities)) + for _, capability := range offer.Resources.Capabilities { + available[capability] = struct{}{} + } + for _, capability := range job.Resources.Capabilities { + if _, ok := available[capability]; !ok { + return false + } + } + return true +} + +func supportsMode(modes []VerificationMode, target VerificationMode) bool { + for _, mode := range modes { + if mode == target || mode == VerificationHybrid { + return true + } + } + return false +} + +func commonResultRoot(job *JobRecord) (types.Hash, bool) { + var root types.Hash + first := true + match := true + for _, result := range job.Results { + if first { + root = result.ResultRoot + first = false + continue + } + if result.ResultRoot != root { + match = false + } + } + return root, match && !first +} + +func verificationSatisfied(mode VerificationMode, replicatedMatch bool, results map[types.AccountID]Result, evidence VerificationEvidence) bool { + hasProof := true + hasAttestation := true + for _, result := range results { + hasProof = hasProof && !types.IsZero32([32]byte(result.ProofHash)) + hasAttestation = hasAttestation && !types.IsZero32([32]byte(result.AttestationHash)) + } + switch mode { + case VerificationDeterministic: + return evidence.DeterministicReplay + case VerificationReplicated: + return replicatedMatch + case VerificationChallenge: + return evidence.ChallengePassed + case VerificationZeroKnowledge: + return hasProof && evidence.ProofVerified + case VerificationTEE: + return hasAttestation && evidence.AttestationVerified + case VerificationClientApproved: + return evidence.ClientApproved + case VerificationHybrid: + score := 0 + if evidence.DeterministicReplay { + score++ + } + if replicatedMatch && len(results) > 1 { + score++ + } + if hasProof && evidence.ProofVerified { + score++ + } + if hasAttestation && evidence.AttestationVerified { + score++ + } + if evidence.ChallengePassed { + score++ + } + if evidence.ClientApproved { + score++ + } + return score >= 2 + default: + return false + } +} diff --git a/internal/v2/compute/market_test.go b/internal/v2/compute/market_test.go new file mode 100644 index 00000000..3ba61738 --- /dev/null +++ b/internal/v2/compute/market_test.go @@ -0,0 +1,84 @@ +package compute + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestReplicatedComputeMarketSettlement(t *testing.T) { + market := NewMarket() + providerA := types.AccountIDFromPublicKey([]byte("provider-a")) + providerB := types.AccountIDFromPublicKey([]byte("provider-b")) + owner := types.AccountIDFromPublicKey([]byte("job-owner")) + resources := Resources{CPUCores: 4, MemoryMiB: 4096, GPUCount: 1, GPUMemoryMiB: 8192, StorageMiB: 1024, BandwidthMbps: 100, Capabilities: []string{"cuda"}} + offerA := Offer{Provider: providerA, Resources: resources, PricePerUnit: 3, Collateral: 10, Verification: []VerificationMode{VerificationReplicated}, ValidUntilHeight: 100} + offerB := Offer{Provider: providerB, Resources: resources, PricePerUnit: 4, Collateral: 10, Verification: []VerificationMode{VerificationReplicated}, ValidUntilHeight: 100} + offerAID, err := market.PublishOffer(offerA, 1) + if err != nil { + t.Fatal(err) + } + offerBID, err := market.PublishOffer(offerB, 1) + if err != nil { + t.Fatal(err) + } + job := Job{ + Owner: owner, WorkloadHash: types.HashBytes("workload", []byte("render")), InputRoot: types.HashBytes("input", []byte("scene")), + Resources: resources, MaxPrice: 10, CollateralRequired: 5, Verification: VerificationReplicated, + DeadlineHeight: 50, Replicas: 2, + } + jobID, err := market.PostJob(job, 10, 2) + if err != nil { + t.Fatal(err) + } + if _, err := market.Assign(jobID, offerAID, 3); err != nil { + t.Fatal(err) + } + if _, err := market.Assign(jobID, offerBID, 3); err != nil { + t.Fatal(err) + } + root := types.HashBytes("result", []byte("same-output")) + if err := market.SubmitResult(Result{JobID: jobID, Provider: providerA, ResultRoot: root, CompletedHeight: 10}); err != nil { + t.Fatal(err) + } + if err := market.SubmitResult(Result{JobID: jobID, Provider: providerB, ResultRoot: root, CompletedHeight: 11}); err != nil { + t.Fatal(err) + } + settlement, err := market.Finalize(jobID, VerificationEvidence{}) + if err != nil { + t.Fatal(err) + } + if settlement.ResultRoot != root || settlement.Payments[providerA] != 3 || settlement.Payments[providerB] != 4 || settlement.Refund != 3 { + t.Fatalf("unexpected settlement: %+v", settlement) + } +} + +func TestComputeMarketRequiresVerificationEvidence(t *testing.T) { + market := NewMarket() + provider := types.AccountIDFromPublicKey([]byte("provider")) + owner := types.AccountIDFromPublicKey([]byte("owner")) + resources := Resources{CPUCores: 2, MemoryMiB: 2048} + offer := Offer{Provider: provider, Resources: resources, PricePerUnit: 2, Collateral: 5, Verification: []VerificationMode{VerificationZeroKnowledge}, ValidUntilHeight: 20} + offerID, err := market.PublishOffer(offer, 1) + if err != nil { + t.Fatal(err) + } + job := Job{Owner: owner, WorkloadHash: types.HashBytes("workload", []byte("zk")), InputRoot: types.HashBytes("input", []byte("x")), Resources: resources, MaxPrice: 3, CollateralRequired: 1, Verification: VerificationZeroKnowledge, DeadlineHeight: 15} + jobID, err := market.PostJob(job, 3, 2) + if err != nil { + t.Fatal(err) + } + if _, err := market.Assign(jobID, offerID, 3); err != nil { + t.Fatal(err) + } + result := Result{JobID: jobID, Provider: provider, ResultRoot: types.HashBytes("result", []byte("r")), ProofHash: types.HashBytes("proof", []byte("p")), CompletedHeight: 5} + if err := market.SubmitResult(result); err != nil { + t.Fatal(err) + } + if _, err := market.Finalize(jobID, VerificationEvidence{}); err != ErrMarketVerification { + t.Fatalf("expected proof verification gate, got %v", err) + } + if _, err := market.Finalize(jobID, VerificationEvidence{ProofVerified: true}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/v2/compute/messages.go b/internal/v2/compute/messages.go new file mode 100644 index 00000000..57d4d261 --- /dev/null +++ b/internal/v2/compute/messages.go @@ -0,0 +1,267 @@ +package compute + +import ( + "bytes" + "errors" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrComputeMessage = errors.New("invalid compute cross-shard message") + +type AssignmentMessage struct { + JobID types.JobID + JobOwner types.AccountID + JobShard uint32 + OfferID types.Hash + Offer Offer + Job Job +} + +func (m AssignmentMessage) Validate() error { + if types.IsZero32([32]byte(m.JobID)) || types.IsZero32([32]byte(m.JobOwner)) || types.IsZero32([32]byte(m.OfferID)) || m.Job.Owner != m.JobOwner { + return ErrComputeMessage + } + if err := m.Offer.Validate(); err != nil { + return ErrComputeMessage + } + if err := m.Job.Validate(); err != nil || !offerMatchesJob(m.Offer, m.Job) || m.Offer.Collateral < m.Job.CollateralRequired { + return ErrComputeMessage + } + return nil +} + +func (m AssignmentMessage) MarshalBinary() ([]byte, error) { + if err := m.Validate(); err != nil { + return nil, err + } + offer, _ := m.Offer.MarshalBinary() + job, _ := m.Job.MarshalBinary() + var w codec.Writer + w.Fixed(m.JobID[:]) + w.Fixed(m.JobOwner[:]) + w.U32(m.JobShard) + w.Fixed(m.OfferID[:]) + w.Bytes(offer) + w.Bytes(job) + return w.BytesCopy(), nil +} + +func ParseAssignmentMessage(data []byte) (AssignmentMessage, error) { + r := codec.NewReader(data) + jobIDRaw, err := r.Fixed(32) + if err != nil { + return AssignmentMessage{}, ErrComputeMessage + } + owner, err := readAccount(r) + if err != nil { + return AssignmentMessage{}, ErrComputeMessage + } + shard, err := r.U32() + if err != nil { + return AssignmentMessage{}, ErrComputeMessage + } + offerID, err := readHash(r) + if err != nil { + return AssignmentMessage{}, ErrComputeMessage + } + offerRaw, err := r.Bytes(1 << 20) + if err != nil { + return AssignmentMessage{}, ErrComputeMessage + } + offer, err := ParseOffer(offerRaw) + if err != nil { + return AssignmentMessage{}, ErrComputeMessage + } + jobRaw, err := r.Bytes(1 << 20) + if err != nil { + return AssignmentMessage{}, ErrComputeMessage + } + job, err := ParseJob(jobRaw) + if err != nil || r.Done() != nil { + return AssignmentMessage{}, ErrComputeMessage + } + var jobID types.JobID + copy(jobID[:], jobIDRaw) + message := AssignmentMessage{JobID: jobID, JobOwner: owner, JobShard: shard, OfferID: offerID, Offer: offer, Job: job} + if err := message.Validate(); err != nil { + return AssignmentMessage{}, err + } + return message, nil +} + +func (m AssignmentMessage) Output() (object.OutputSpec, error) { + raw, err := m.MarshalBinary() + if err != nil { + return object.OutputSpec{}, err + } + return object.OutputSpec{Owner: m.JobOwner, Kind: object.KindComputeAssignment, Data: raw}, nil +} + +func (m AssignmentMessage) ValidateForRecord(record OnChainJob) error { + if record.ID != m.JobID || record.Job.Owner != m.JobOwner { + return ErrComputeMessage + } + a, err := record.Job.MarshalBinary() + if err != nil { + return err + } + b, err := m.Job.MarshalBinary() + if err != nil || !bytes.Equal(a, b) { + return ErrComputeMessage + } + return m.Validate() +} + +type ResultMessage struct { + JobID types.JobID + JobOwner types.AccountID + JobShard uint32 + Result Result +} + +func (m ResultMessage) Validate() error { + if types.IsZero32([32]byte(m.JobID)) || types.IsZero32([32]byte(m.JobOwner)) || m.Result.JobID != m.JobID { + return ErrComputeMessage + } + return m.Result.Validate() +} + +func (m ResultMessage) MarshalBinary() ([]byte, error) { + if err := m.Validate(); err != nil { + return nil, err + } + result, _ := m.Result.MarshalBinary() + var w codec.Writer + w.Fixed(m.JobID[:]) + w.Fixed(m.JobOwner[:]) + w.U32(m.JobShard) + w.Bytes(result) + return w.BytesCopy(), nil +} + +func ParseResultMessage(data []byte) (ResultMessage, error) { + r := codec.NewReader(data) + jobRaw, err := r.Fixed(32) + if err != nil { + return ResultMessage{}, ErrComputeMessage + } + owner, err := readAccount(r) + if err != nil { + return ResultMessage{}, ErrComputeMessage + } + shard, err := r.U32() + if err != nil { + return ResultMessage{}, ErrComputeMessage + } + resultRaw, err := r.Bytes(2048) + if err != nil { + return ResultMessage{}, ErrComputeMessage + } + result, err := ParseResult(resultRaw) + if err != nil || r.Done() != nil { + return ResultMessage{}, ErrComputeMessage + } + var jobID types.JobID + copy(jobID[:], jobRaw) + message := ResultMessage{JobID: jobID, JobOwner: owner, JobShard: shard, Result: result} + if err := message.Validate(); err != nil { + return ResultMessage{}, err + } + return message, nil +} + +func (m ResultMessage) Output() (object.OutputSpec, error) { + raw, err := m.MarshalBinary() + if err != nil { + return object.OutputSpec{}, err + } + return object.OutputSpec{Owner: m.JobOwner, Kind: object.KindComputeResult, Data: raw}, nil +} + +type IngestRef struct { + JobObject types.ObjectID + MessageObject types.ObjectID +} + +func (r IngestRef) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(r.JobObject)) || types.IsZero32([32]byte(r.MessageObject)) || r.JobObject == r.MessageObject { + return nil, ErrComputeMessage + } + var w codec.Writer + w.Fixed(r.JobObject[:]) + w.Fixed(r.MessageObject[:]) + return w.BytesCopy(), nil +} + +func ParseIngestRef(data []byte) (IngestRef, error) { + if len(data) != 64 { + return IngestRef{}, ErrComputeMessage + } + var out IngestRef + copy(out.JobObject[:], data[:32]) + copy(out.MessageObject[:], data[32:]) + if _, err := out.MarshalBinary(); err != nil { + return IngestRef{}, err + } + return out, nil +} + +type JobRef struct{ JobObject types.ObjectID } + +func (r JobRef) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(r.JobObject)) { + return nil, ErrComputeMessage + } + return append([]byte(nil), r.JobObject[:]...), nil +} + +func ParseJobRef(data []byte) (JobRef, error) { + if len(data) != 32 { + return JobRef{}, ErrComputeMessage + } + var out JobRef + copy(out.JobObject[:], data) + if _, err := out.MarshalBinary(); err != nil { + return JobRef{}, err + } + return out, nil +} + +type SettlementReceipt struct { + JobID types.JobID + ResultRoot types.Hash + Payments map[types.AccountID]uint64 + Refund uint64 + Slashed map[types.AccountID]uint64 + SlashReward uint64 + Expired bool +} + +func (r SettlementReceipt) MarshalBinary() []byte { + var w codec.Writer + w.Fixed(r.JobID[:]) + w.Fixed(r.ResultRoot[:]) + writeAccountAmounts(&w, r.Payments) + w.U64(r.Refund) + writeAccountAmounts(&w, r.Slashed) + w.U64(r.SlashReward) + w.Bool(r.Expired) + return w.BytesCopy() +} + +func writeAccountAmounts(w *codec.Writer, values map[types.AccountID]uint64) { + ids := make([]types.AccountID, 0, len(values)) + for id := range values { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i].String() < ids[j].String() }) + w.U32(uint32(len(ids))) + for _, id := range ids { + w.Fixed(id[:]) + w.U64(values[id]) + } +} diff --git a/internal/v2/compute/model.go b/internal/v2/compute/model.go new file mode 100644 index 00000000..1a285002 --- /dev/null +++ b/internal/v2/compute/model.go @@ -0,0 +1,160 @@ +package compute + +import ( + "errors" + "strings" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +type VerificationMode uint8 + +const ( + VerificationUnknown VerificationMode = iota + VerificationDeterministic + VerificationReplicated + VerificationChallenge + VerificationZeroKnowledge + VerificationTEE + VerificationClientApproved + VerificationHybrid +) + +var ( + ErrInvalidOffer = errors.New("invalid compute offer") + ErrInvalidJob = errors.New("invalid compute job") + ErrInvalidResult = errors.New("invalid compute result") +) + +type Resources struct { + CPUCores uint16 + MemoryMiB uint32 + GPUCount uint16 + GPUMemoryMiB uint32 + StorageMiB uint64 + BandwidthMbps uint32 + Capabilities []string +} + +type Offer struct { + Provider types.AccountID + Resources Resources + PricePerUnit uint64 + Collateral uint64 + Verification []VerificationMode + ValidUntilHeight uint64 +} + +type Job struct { + Owner types.AccountID + WorkloadHash types.Hash + InputRoot types.Hash + Resources Resources + MaxPrice uint64 + CollateralRequired uint64 + Verification VerificationMode + DeadlineHeight uint64 + Replicas uint16 + Private bool +} + +type Result struct { + JobID types.JobID + Provider types.AccountID + ResultRoot types.Hash + ProofHash types.Hash + AttestationHash types.Hash + CompletedHeight uint64 +} + +func (r Resources) Validate() error { + if r.CPUCores == 0 && r.GPUCount == 0 { + return ErrInvalidOffer + } + if r.MemoryMiB == 0 || len(r.Capabilities) > 32 { + return ErrInvalidOffer + } + for _, c := range r.Capabilities { + if strings.TrimSpace(c) == "" || len(c) > 64 { + return ErrInvalidOffer + } + } + return nil +} + +func (o Offer) Validate() error { + if types.IsZero32([32]byte(o.Provider)) || o.PricePerUnit == 0 || o.ValidUntilHeight == 0 || + len(o.Verification) == 0 || len(o.Verification) > 7 { + return ErrInvalidOffer + } + return o.Resources.Validate() +} + +func (j Job) Validate() error { + if types.IsZero32([32]byte(j.Owner)) || types.IsZero32([32]byte(j.WorkloadHash)) || + types.IsZero32([32]byte(j.InputRoot)) || j.MaxPrice == 0 || j.DeadlineHeight == 0 || + j.Verification <= VerificationUnknown || j.Verification > VerificationHybrid { + return ErrInvalidJob + } + if j.Verification == VerificationReplicated && j.Replicas < 2 { + return ErrInvalidJob + } + return j.Resources.Validate() +} + +func (r Result) Validate() error { + if types.IsZero32([32]byte(r.JobID)) || types.IsZero32([32]byte(r.Provider)) || + types.IsZero32([32]byte(r.ResultRoot)) || r.CompletedHeight == 0 { + return ErrInvalidResult + } + return nil +} + +func (o Offer) MarshalBinary() ([]byte, error) { + if err := o.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.Fixed(o.Provider[:]) + writeResources(&w, o.Resources) + w.U64(o.PricePerUnit) + w.U64(o.Collateral) + w.U32(uint32(len(o.Verification))) + for _, mode := range o.Verification { + w.U8(uint8(mode)) + } + w.U64(o.ValidUntilHeight) + return w.BytesCopy(), nil +} + +func (j Job) MarshalBinary() ([]byte, error) { + if err := j.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.Fixed(j.Owner[:]) + w.Fixed(j.WorkloadHash[:]) + w.Fixed(j.InputRoot[:]) + writeResources(&w, j.Resources) + w.U64(j.MaxPrice) + w.U64(j.CollateralRequired) + w.U8(uint8(j.Verification)) + w.U64(j.DeadlineHeight) + w.U16(j.Replicas) + w.Bool(j.Private) + return w.BytesCopy(), nil +} + +func writeResources(w *codec.Writer, r Resources) { + w.U16(r.CPUCores) + w.U32(r.MemoryMiB) + w.U16(r.GPUCount) + w.U32(r.GPUMemoryMiB) + w.U64(r.StorageMiB) + w.U32(r.BandwidthMbps) + w.U32(uint32(len(r.Capabilities))) + for _, c := range r.Capabilities { + w.String(strings.TrimSpace(c)) + } +} diff --git a/internal/v2/compute/model_test.go b/internal/v2/compute/model_test.go new file mode 100644 index 00000000..d8e75f9f --- /dev/null +++ b/internal/v2/compute/model_test.go @@ -0,0 +1,25 @@ +package compute + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestReplicatedJobRequiresMultipleReplicas(t *testing.T) { + job := Job{ + Owner: types.AccountIDFromPublicKey([]byte("owner")), + WorkloadHash: types.HashBytes("workload", []byte("container")), + InputRoot: types.HashBytes("input", []byte("dataset")), + Resources: Resources{CPUCores: 4, MemoryMiB: 8192}, + MaxPrice: 100, Verification: VerificationReplicated, + DeadlineHeight: 100, Replicas: 1, + } + if err := job.Validate(); err == nil { + t.Fatal("replicated job accepted one replica") + } + job.Replicas = 3 + if err := job.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/v2/compute/onchain.go b/internal/v2/compute/onchain.go new file mode 100644 index 00000000..12d93d86 --- /dev/null +++ b/internal/v2/compute/onchain.go @@ -0,0 +1,212 @@ +package compute + +import ( + "math" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +type OnChainSettlement struct { + Settlement + CollateralReturns map[types.AccountID]uint64 + SlashedCollateral map[types.AccountID]uint64 + SlashReward uint64 +} + +func AssignOnChain(record OnChainJob, offerID types.Hash, offer Offer, height uint64) (OnChainJob, Assignment, uint64, error) { + if record.Status == JobSettled || record.Status == JobExpired || height == 0 || height > record.Job.DeadlineHeight || height > offer.ValidUntilHeight { + return OnChainJob{}, Assignment{}, 0, ErrMarketState + } + if !offerMatchesJob(offer, record.Job) || offer.Collateral < record.Job.CollateralRequired { + return OnChainJob{}, Assignment{}, 0, ErrMarketMatch + } + for _, existing := range record.Assignments { + if existing.Provider == offer.Provider { + return OnChainJob{}, Assignment{}, 0, ErrMarketDuplicate + } + } + target := requiredAssignments(record.Job) + if len(record.Assignments) >= target { + return OnChainJob{}, Assignment{}, 0, ErrMarketState + } + var committed uint64 + for _, existing := range record.Assignments { + if math.MaxUint64-committed < existing.Price { + return OnChainJob{}, Assignment{}, 0, ErrMarketEscrow + } + committed += existing.Price + } + if offer.PricePerUnit > record.Job.MaxPrice || math.MaxUint64-committed < offer.PricePerUnit || committed+offer.PricePerUnit > record.Job.MaxPrice || committed+offer.PricePerUnit > record.Escrow { + return OnChainJob{}, Assignment{}, 0, ErrMarketEscrow + } + assignment := Assignment{OfferID: offerID, Provider: offer.Provider, Price: offer.PricePerUnit} + updated := cloneOnChainJob(record) + updated.Assignments = append(updated.Assignments, assignment) + if len(updated.Assignments) == target { + updated.Status = JobAssigned + } + return updated, assignment, offer.Collateral - record.Job.CollateralRequired, nil +} + +func SubmitOnChainResult(record OnChainJob, result Result) (OnChainJob, error) { + if err := result.Validate(); err != nil || result.JobID != record.ID { + return OnChainJob{}, ErrInvalidResult + } + if record.Status != JobAssigned && record.Status != JobAwaitingVerification { + return OnChainJob{}, ErrMarketState + } + if result.CompletedHeight > record.Job.DeadlineHeight { + return OnChainJob{}, ErrMarketState + } + assigned := false + for _, assignment := range record.Assignments { + if assignment.Provider == result.Provider { + assigned = true + break + } + } + if !assigned { + return OnChainJob{}, ErrMarketMatch + } + for _, existing := range record.Results { + if existing.Provider == result.Provider { + return OnChainJob{}, ErrMarketDuplicate + } + } + updated := cloneOnChainJob(record) + updated.Results = append(updated.Results, result) + if len(updated.Results) == requiredAssignments(updated.Job) { + updated.Status = JobAwaitingVerification + } + return updated, nil +} + +func FinalizeOnChain(record OnChainJob, evidence VerificationEvidence) (OnChainJob, OnChainSettlement, error) { + if record.Status != JobAwaitingVerification || len(record.Results) != requiredAssignments(record.Job) { + return OnChainJob{}, OnChainSettlement{}, ErrMarketState + } + results := make(map[types.AccountID]Result, len(record.Results)) + for _, result := range record.Results { + results[result.Provider] = result + } + root, replicatedMatch := commonResultRootRecord(record.Results) + if types.IsZero32([32]byte(root)) || !verificationSatisfied(record.Job.Verification, replicatedMatch, results, evidence) { + return OnChainJob{}, OnChainSettlement{}, ErrMarketVerification + } + payments := make(map[types.AccountID]uint64, len(record.Assignments)) + collateral := make(map[types.AccountID]uint64, len(record.Assignments)) + var paid uint64 + for _, assignment := range record.Assignments { + if math.MaxUint64-paid < assignment.Price { + return OnChainJob{}, OnChainSettlement{}, ErrMarketEscrow + } + paid += assignment.Price + payments[assignment.Provider] += assignment.Price + collateral[assignment.Provider] += record.Job.CollateralRequired + } + if paid > record.Escrow { + return OnChainJob{}, OnChainSettlement{}, ErrMarketEscrow + } + updated := cloneOnChainJob(record) + updated.Status = JobSettled + return updated, OnChainSettlement{ + Settlement: Settlement{JobID: record.ID, ResultRoot: root, Payments: payments, Refund: record.Escrow - paid}, + CollateralReturns: collateral, + SlashedCollateral: make(map[types.AccountID]uint64), + }, nil +} + +// ResolveReplicatedMajority provides an objective dispute rule for replicated +// jobs. At least three replicas must have reported. Providers on the strict +// majority result root are paid and recover collateral. Minority collateral is +// slashed to the job owner, while unpaid compute budget is refunded. +func ResolveReplicatedMajority(record OnChainJob) (OnChainJob, OnChainSettlement, error) { + if record.Job.Verification != VerificationReplicated || len(record.Results) < 3 || len(record.Results) != len(record.Assignments) { + return OnChainJob{}, OnChainSettlement{}, ErrMarketVerification + } + counts := make(map[types.Hash]int) + for _, result := range record.Results { + counts[result.ResultRoot]++ + } + var majority types.Hash + majorityCount := 0 + for root, count := range counts { + if count > majorityCount { + majority, majorityCount = root, count + } + } + if majorityCount*2 <= len(record.Results) { + return OnChainJob{}, OnChainSettlement{}, ErrMarketVerification + } + resultByProvider := make(map[types.AccountID]types.Hash, len(record.Results)) + for _, result := range record.Results { + resultByProvider[result.Provider] = result.ResultRoot + } + payments := make(map[types.AccountID]uint64) + collateralReturns := make(map[types.AccountID]uint64) + slashed := make(map[types.AccountID]uint64) + var paid, slashReward uint64 + for _, assignment := range record.Assignments { + if resultByProvider[assignment.Provider] == majority { + if math.MaxUint64-paid < assignment.Price { + return OnChainJob{}, OnChainSettlement{}, ErrMarketEscrow + } + paid += assignment.Price + payments[assignment.Provider] += assignment.Price + collateralReturns[assignment.Provider] += record.Job.CollateralRequired + } else { + slashed[assignment.Provider] += record.Job.CollateralRequired + if math.MaxUint64-slashReward < record.Job.CollateralRequired { + return OnChainJob{}, OnChainSettlement{}, ErrMarketEscrow + } + slashReward += record.Job.CollateralRequired + } + } + if paid > record.Escrow { + return OnChainJob{}, OnChainSettlement{}, ErrMarketEscrow + } + updated := cloneOnChainJob(record) + updated.Status = JobSettled + return updated, OnChainSettlement{ + Settlement: Settlement{JobID: record.ID, ResultRoot: majority, Payments: payments, Refund: record.Escrow - paid}, + CollateralReturns: collateralReturns, + SlashedCollateral: slashed, + SlashReward: slashReward, + }, nil +} + +func ExpireOnChain(record OnChainJob, height uint64) (OnChainJob, uint64, map[types.AccountID]uint64, error) { + if record.Status == JobSettled || record.Status == JobExpired || height <= record.Job.DeadlineHeight { + return OnChainJob{}, 0, nil, ErrMarketState + } + updated := cloneOnChainJob(record) + updated.Status = JobExpired + collateral := make(map[types.AccountID]uint64, len(record.Assignments)) + for _, assignment := range record.Assignments { + collateral[assignment.Provider] += record.Job.CollateralRequired + } + return updated, record.Escrow, collateral, nil +} + +func cloneOnChainJob(record OnChainJob) OnChainJob { + out := record + out.Assignments = append([]Assignment(nil), record.Assignments...) + out.Results = append([]Result(nil), record.Results...) + return out +} + +func commonResultRootRecord(results []Result) (types.Hash, bool) { + if len(results) == 0 { + return types.Hash{}, false + } + ordered := append([]Result(nil), results...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].Provider.String() < ordered[j].Provider.String() }) + root := ordered[0].ResultRoot + for _, result := range ordered[1:] { + if result.ResultRoot != root { + return root, false + } + } + return root, true +} diff --git a/internal/v2/compute/onchain_test.go b/internal/v2/compute/onchain_test.go new file mode 100644 index 00000000..aedaabb4 --- /dev/null +++ b/internal/v2/compute/onchain_test.go @@ -0,0 +1,83 @@ +package compute + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestOnChainReplicatedLifecycleAndCollateral(t *testing.T) { + owner := types.AccountIDFromPublicKey([]byte("owner")) + providers := []types.AccountID{ + types.AccountIDFromPublicKey([]byte("p1")), + types.AccountIDFromPublicKey([]byte("p2")), + types.AccountIDFromPublicKey([]byte("p3")), + } + resources := Resources{CPUCores: 4, MemoryMiB: 4096} + job := Job{Owner: owner, WorkloadHash: types.HashBytes("work", []byte("render")), InputRoot: types.HashBytes("input", []byte("scene")), Resources: resources, MaxPrice: 30, CollateralRequired: 5, Verification: VerificationReplicated, DeadlineHeight: 100, Replicas: 3} + record := OnChainJob{ID: types.JobID(types.HashBytes("job", []byte("one"))), Job: job, Escrow: 30, Status: JobPending} + for i, provider := range providers { + offer := Offer{Provider: provider, Resources: resources, PricePerUnit: uint64(i + 2), Collateral: 9, Verification: []VerificationMode{VerificationReplicated}, ValidUntilHeight: 90} + var err error + var excess uint64 + record, _, excess, err = AssignOnChain(record, types.HashBytes("offer", []byte{byte(i)}), offer, 10) + if err != nil { + t.Fatal(err) + } + if excess != 4 { + t.Fatalf("unexpected collateral excess %d", excess) + } + } + if record.Status != JobAssigned { + t.Fatal("job not assigned after replica target") + } + root := types.HashBytes("result", []byte("same")) + for i, provider := range providers { + var err error + record, err = SubmitOnChainResult(record, Result{JobID: record.ID, Provider: provider, ResultRoot: root, CompletedHeight: uint64(20 + i)}) + if err != nil { + t.Fatal(err) + } + } + settled, settlement, err := FinalizeOnChain(record, VerificationEvidence{}) + if err != nil { + t.Fatal(err) + } + if settled.Status != JobSettled || settlement.ResultRoot != root || settlement.Refund != 21 { + t.Fatalf("unexpected settlement: %+v", settlement) + } + for _, provider := range providers { + if settlement.CollateralReturns[provider] != 5 { + t.Fatal("provider collateral was not returned") + } + } +} + +func TestReplicatedMajoritySlashesMinority(t *testing.T) { + owner := types.AccountIDFromPublicKey([]byte("owner-majority")) + providers := []types.AccountID{ + types.AccountIDFromPublicKey([]byte("mp1")), + types.AccountIDFromPublicKey([]byte("mp2")), + types.AccountIDFromPublicKey([]byte("mp3")), + } + resources := Resources{CPUCores: 2, MemoryMiB: 2048} + job := Job{Owner: owner, WorkloadHash: types.HashBytes("work", []byte("majority")), InputRoot: types.HashBytes("input", []byte("majority")), Resources: resources, MaxPrice: 9, CollateralRequired: 7, Verification: VerificationReplicated, DeadlineHeight: 100, Replicas: 3} + record := OnChainJob{ID: types.JobID(types.HashBytes("job", []byte("majority"))), Job: job, Escrow: 9, Status: JobAwaitingVerification} + for i, provider := range providers { + record.Assignments = append(record.Assignments, Assignment{OfferID: types.HashBytes("offer", []byte{byte(i)}), Provider: provider, Price: 2}) + } + good := types.HashBytes("result", []byte("good")) + bad := types.HashBytes("result", []byte("bad")) + record.Results = []Result{ + {JobID: record.ID, Provider: providers[0], ResultRoot: good, CompletedHeight: 10}, + {JobID: record.ID, Provider: providers[1], ResultRoot: good, CompletedHeight: 10}, + {JobID: record.ID, Provider: providers[2], ResultRoot: bad, CompletedHeight: 10}, + } + _, settlement, err := ResolveReplicatedMajority(record) + if err != nil { + t.Fatal(err) + } + if settlement.ResultRoot != good || settlement.SlashedCollateral[providers[2]] != 7 || settlement.SlashReward != 7 || settlement.Payments[providers[2]] != 0 { + t.Fatalf("unexpected majority settlement: %+v", settlement) + } +} diff --git a/internal/v2/compute/settled_work.go b/internal/v2/compute/settled_work.go new file mode 100644 index 00000000..36583da3 --- /dev/null +++ b/internal/v2/compute/settled_work.go @@ -0,0 +1,87 @@ +package compute + +import ( + "math" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +// ObserveSettledRecord reconstructs an index-eligible VerifiedWork observation +// from finalized compute state alone. No provider offer price or external RPC +// settlement summary is trusted. For replicated jobs, only providers on the +// strict-majority result root are counted as paid, matching the on-chain +// majority settlement rule. +func ObserveSettledRecord(record OnChainJob, registry *WorkRegistry) (VerifiedWork, error) { + if record.Status != JobSettled || registry == nil || len(record.Assignments) == 0 || len(record.Results) == 0 { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + spec, ok := registry.Resolve(record.Job.WorkloadHash) + if !ok || spec.Validate() != nil { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + + resultByProvider := make(map[types.AccountID]types.Hash, len(record.Results)) + rootCounts := make(map[types.Hash]int, len(record.Results)) + for _, result := range record.Results { + if err := result.Validate(); err != nil || result.JobID != record.ID { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + if _, duplicate := resultByProvider[result.Provider]; duplicate { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + resultByProvider[result.Provider] = result.ResultRoot + rootCounts[result.ResultRoot]++ + } + + var acceptedRoot types.Hash + acceptedCount := 0 + for root, count := range rootCounts { + if count > acceptedCount { + acceptedRoot = root + acceptedCount = count + } + } + if types.IsZero32([32]byte(acceptedRoot)) { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + if record.Job.Verification == VerificationReplicated { + if acceptedCount*2 <= len(record.Results) { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + } else if acceptedCount != len(record.Results) { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + + var paid uint64 + seenAssignments := make(map[types.AccountID]struct{}, len(record.Assignments)) + for _, assignment := range record.Assignments { + if _, duplicate := seenAssignments[assignment.Provider]; duplicate || assignment.Price == 0 { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + seenAssignments[assignment.Provider] = struct{}{} + root, hasResult := resultByProvider[assignment.Provider] + if !hasResult { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + if record.Job.Verification == VerificationReplicated && root != acceptedRoot { + continue + } + if math.MaxUint64-paid < assignment.Price { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + paid += assignment.Price + } + if paid == 0 || paid > record.Escrow { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + + return VerifiedWork{ + JobID: record.ID, + Class: spec.Class, + Units: spec.Units, + Vector: spec.Vector, + PaidZPH: paid, + Verification: record.Job.Verification, + ResultRoot: acceptedRoot, + }, nil +} diff --git a/internal/v2/compute/settled_work_test.go b/internal/v2/compute/settled_work_test.go new file mode 100644 index 00000000..23c4e498 --- /dev/null +++ b/internal/v2/compute/settled_work_test.go @@ -0,0 +1,81 @@ +package compute + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestObserveSettledRecordReplicatedMajority(t *testing.T) { + workload := types.Hash{1} + registry, err := NewWorkRegistry([]WorkSpec{{ + Version: WorkSpecVersion, + Class: WorkAITraining, + Units: 100, + WorkloadHash: workload, + BenchmarkHash: types.Hash{2}, + Vector: WorkVector{TensorUnits: 100}, + }}) + if err != nil { + t.Fatal(err) + } + + jobID := types.JobID{3} + providers := []types.AccountID{{4}, {5}, {6}} + majorityRoot := types.Hash{7} + record := OnChainJob{ + ID: jobID, + Job: Job{ + Owner: types.AccountID{8}, + WorkloadHash: workload, + InputRoot: types.Hash{9}, + Resources: Resources{CPUCores: 1, MemoryMiB: 1}, + MaxPrice: 60, + CollateralRequired: 1, + Verification: VerificationReplicated, + DeadlineHeight: 100, + Replicas: 3, + }, + Escrow: 60, + Status: JobSettled, + Assignments: []Assignment{ + {OfferID: types.Hash{10}, Provider: providers[0], Price: 10}, + {OfferID: types.Hash{11}, Provider: providers[1], Price: 20}, + {OfferID: types.Hash{12}, Provider: providers[2], Price: 30}, + }, + Results: []Result{ + {JobID: jobID, Provider: providers[0], ResultRoot: majorityRoot, CompletedHeight: 10}, + {JobID: jobID, Provider: providers[1], ResultRoot: majorityRoot, CompletedHeight: 10}, + {JobID: jobID, Provider: providers[2], ResultRoot: types.Hash{13}, CompletedHeight: 10}, + }, + } + + observed, err := ObserveSettledRecord(record, registry) + if err != nil { + t.Fatal(err) + } + if observed.PaidZPH != 30 { + t.Fatalf("paid = %d, want 30", observed.PaidZPH) + } + if observed.ResultRoot != majorityRoot || observed.Units != 100 || observed.Class != WorkAITraining { + t.Fatalf("unexpected verified work: %+v", observed) + } +} + +func TestObserveSettledRecordRejectsUnsettled(t *testing.T) { + registry, err := NewWorkRegistry([]WorkSpec{{ + Version: WorkSpecVersion, + Class: WorkCPUGeneral, + Units: 1, + WorkloadHash: types.Hash{1}, + BenchmarkHash: types.Hash{2}, + Vector: WorkVector{CPUUnits: 1}, + }}) + if err != nil { + t.Fatal(err) + } + _, err = ObserveSettledRecord(OnChainJob{Status: JobPending}, registry) + if err == nil { + t.Fatal("expected unsettled record rejection") + } +} diff --git a/internal/v2/compute/settlement_receipt.go b/internal/v2/compute/settlement_receipt.go new file mode 100644 index 00000000..f59e5d9b --- /dev/null +++ b/internal/v2/compute/settlement_receipt.go @@ -0,0 +1,129 @@ +package compute + +import ( + "bytes" + "math" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +// ParseSettlementReceipt decodes the canonical settlement evidence committed by +// compute finalization. The receipt is intentionally self-contained so light +// and economic replay code can verify settlement without trusting an RPC. +func ParseSettlementReceipt(data []byte) (SettlementReceipt, error) { + r := codec.NewReader(data) + jobRaw, err := r.Fixed(32) + if err != nil { + return SettlementReceipt{}, ErrMarketState + } + rootRaw, err := r.Fixed(32) + if err != nil { + return SettlementReceipt{}, ErrMarketState + } + payments, err := readAccountAmounts(r) + if err != nil { + return SettlementReceipt{}, err + } + refund, err := r.U64() + if err != nil { + return SettlementReceipt{}, ErrMarketState + } + slashed, err := readAccountAmounts(r) + if err != nil { + return SettlementReceipt{}, err + } + slashReward, err := r.U64() + if err != nil { + return SettlementReceipt{}, ErrMarketState + } + expired, err := r.Bool() + if err != nil || r.Done() != nil { + return SettlementReceipt{}, ErrMarketState + } + var jobID types.JobID + var resultRoot types.Hash + copy(jobID[:], jobRaw) + copy(resultRoot[:], rootRaw) + if types.IsZero32([32]byte(jobID)) { + return SettlementReceipt{}, ErrMarketState + } + out := SettlementReceipt{ + JobID: jobID, ResultRoot: resultRoot, Payments: payments, Refund: refund, + Slashed: slashed, SlashReward: slashReward, Expired: expired, + } + if expired { + if !types.IsZero32([32]byte(resultRoot)) || len(payments) != 0 || len(slashed) != 0 || slashReward != 0 { + return SettlementReceipt{}, ErrMarketState + } + } else if types.IsZero32([32]byte(resultRoot)) || len(payments) == 0 { + return SettlementReceipt{}, ErrMarketState + } + return out, nil +} + +func readAccountAmounts(r *codec.Reader) (map[types.AccountID]uint64, error) { + count, err := r.U32() + if err != nil || count > 1024 { + return nil, ErrMarketState + } + out := make(map[types.AccountID]uint64, int(count)) + for i := uint32(0); i < count; i++ { + raw, err := r.Fixed(32) + if err != nil { + return nil, ErrMarketState + } + amount, err := r.U64() + if err != nil || amount == 0 { + return nil, ErrMarketState + } + var account types.AccountID + copy(account[:], raw) + if types.IsZero32([32]byte(account)) { + return nil, ErrMarketState + } + if _, duplicate := out[account]; duplicate { + return nil, ErrMarketState + } + out[account] = amount + } + return out, nil +} + +// ObserveFinalizedSettlement verifies that a settlement receipt exactly matches +// the deterministic on-chain settlement for the supplied pre-finalization job +// record, then returns the ZCPI-eligible VerifiedWork observation. +func ObserveFinalizedSettlement(record OnChainJob, receipt SettlementReceipt, registry *WorkRegistry) (VerifiedWork, error) { + if registry == nil || receipt.Expired || receipt.JobID != record.ID || record.Status != JobAwaitingVerification { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + + updated, settlement, err := FinalizeOnChain(record, VerificationEvidence{}) + if err != nil { + updated, settlement, err = ResolveReplicatedMajority(record) + if err != nil { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + } + expected := SettlementReceipt{ + JobID: record.ID, ResultRoot: settlement.ResultRoot, Payments: settlement.Payments, + Refund: settlement.Refund, Slashed: settlement.SlashedCollateral, SlashReward: settlement.SlashReward, + } + if !bytes.Equal(receipt.MarshalBinary(), expected.MarshalBinary()) { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + return ObserveVerifiedWork(updated, settlement, registry) +} + +// SettlementPaid returns the amount actually paid to compute providers. Refunds +// and collateral movements are deliberately excluded from ZCPI pricing. +func SettlementPaid(receipt SettlementReceipt) (uint64, error) { + var paid uint64 + for _, amount := range receipt.Payments { + if math.MaxUint64-paid < amount { + return 0, ErrMarketEscrow + } + paid += amount + } + return paid, nil +} diff --git a/internal/v2/compute/settlement_receipt_test.go b/internal/v2/compute/settlement_receipt_test.go new file mode 100644 index 00000000..33080a95 --- /dev/null +++ b/internal/v2/compute/settlement_receipt_test.go @@ -0,0 +1,94 @@ +package compute + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestSettlementReceiptRoundTrip(t *testing.T) { + receipt := SettlementReceipt{ + JobID: types.JobID{1}, + ResultRoot: types.Hash{2}, + Payments: map[types.AccountID]uint64{{3}: 10, {4}: 20}, + Refund: 5, + Slashed: map[types.AccountID]uint64{{5}: 7}, + SlashReward: 7, + } + parsed, err := ParseSettlementReceipt(receipt.MarshalBinary()) + if err != nil { + t.Fatal(err) + } + if parsed.JobID != receipt.JobID || parsed.ResultRoot != receipt.ResultRoot || parsed.Refund != receipt.Refund || parsed.SlashReward != receipt.SlashReward { + t.Fatalf("round trip mismatch: %+v", parsed) + } + paid, err := SettlementPaid(parsed) + if err != nil || paid != 30 { + t.Fatalf("paid = %d err=%v, want 30", paid, err) + } +} + +func TestObserveFinalizedSettlementReplicatedMajority(t *testing.T) { + workload := types.Hash{11} + registry, err := NewWorkRegistry([]WorkSpec{{ + Version: WorkSpecVersion, + Class: WorkRendering, + Units: 50, + WorkloadHash: workload, + BenchmarkHash: types.Hash{12}, + Vector: WorkVector{GPUFP32Units: 50}, + }}) + if err != nil { + t.Fatal(err) + } + jobID := types.JobID{13} + providers := []types.AccountID{{14}, {15}, {16}} + root := types.Hash{17} + record := OnChainJob{ + ID: jobID, + Job: Job{ + Owner: types.AccountID{18}, + WorkloadHash: workload, + InputRoot: types.Hash{19}, + Resources: Resources{GPUCount: 1, MemoryMiB: 1}, + MaxPrice: 60, + CollateralRequired: 4, + Verification: VerificationReplicated, + DeadlineHeight: 100, + Replicas: 3, + }, + Escrow: 60, + Status: JobAwaitingVerification, + Assignments: []Assignment{ + {OfferID: types.Hash{20}, Provider: providers[0], Price: 10}, + {OfferID: types.Hash{21}, Provider: providers[1], Price: 20}, + {OfferID: types.Hash{22}, Provider: providers[2], Price: 30}, + }, + Results: []Result{ + {JobID: jobID, Provider: providers[0], ResultRoot: root, CompletedHeight: 10}, + {JobID: jobID, Provider: providers[1], ResultRoot: root, CompletedHeight: 10}, + {JobID: jobID, Provider: providers[2], ResultRoot: types.Hash{23}, CompletedHeight: 10}, + }, + } + receipt := SettlementReceipt{ + JobID: jobID, + ResultRoot: root, + Payments: map[types.AccountID]uint64{providers[0]: 10, providers[1]: 20}, + Refund: 30, + Slashed: map[types.AccountID]uint64{providers[2]: 4}, + SlashReward: 4, + } + observed, err := ObserveFinalizedSettlement(record, receipt, registry) + if err != nil { + t.Fatal(err) + } + if observed.PaidZPH != 30 || observed.Units != 50 || observed.ResultRoot != root { + t.Fatalf("unexpected observation: %+v", observed) + } + + tampered := receipt + tampered.Refund++ + if _, err := ObserveFinalizedSettlement(record, tampered, registry); err == nil { + t.Fatal("expected tampered receipt rejection") + } +} diff --git a/internal/v2/compute/state.go b/internal/v2/compute/state.go new file mode 100644 index 00000000..77a22fae --- /dev/null +++ b/internal/v2/compute/state.go @@ -0,0 +1,372 @@ +package compute + +import ( + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + computeOfferObjectIndex uint32 = 0x90000000 + computeJobObjectIndex uint32 = 0x90000001 +) + +type OnChainJob struct { + ID types.JobID + Job Job + Escrow uint64 + Status JobStatus + Assignments []Assignment + Results []Result +} + +func ParseOffer(data []byte) (Offer, error) { + r := codec.NewReader(data) + provider, err := readAccount(r) + if err != nil { + return Offer{}, ErrInvalidOffer + } + resources, err := readResources(r) + if err != nil { + return Offer{}, ErrInvalidOffer + } + price, err := r.U64() + if err != nil { + return Offer{}, ErrInvalidOffer + } + collateral, err := r.U64() + if err != nil { + return Offer{}, ErrInvalidOffer + } + count, err := r.U32() + if err != nil || count == 0 || count > 7 { + return Offer{}, ErrInvalidOffer + } + modes := make([]VerificationMode, int(count)) + for i := range modes { + mode, err := r.U8() + if err != nil { + return Offer{}, ErrInvalidOffer + } + modes[i] = VerificationMode(mode) + } + validUntil, err := r.U64() + if err != nil || r.Done() != nil { + return Offer{}, ErrInvalidOffer + } + offer := Offer{Provider: provider, Resources: resources, PricePerUnit: price, Collateral: collateral, Verification: modes, ValidUntilHeight: validUntil} + if err := offer.Validate(); err != nil { + return Offer{}, err + } + return offer, nil +} + +func ParseJob(data []byte) (Job, error) { + r := codec.NewReader(data) + owner, err := readAccount(r) + if err != nil { + return Job{}, ErrInvalidJob + } + workload, err := readHash(r) + if err != nil { + return Job{}, ErrInvalidJob + } + inputRoot, err := readHash(r) + if err != nil { + return Job{}, ErrInvalidJob + } + resources, err := readResources(r) + if err != nil { + return Job{}, ErrInvalidJob + } + maxPrice, err := r.U64() + if err != nil { + return Job{}, ErrInvalidJob + } + collateral, err := r.U64() + if err != nil { + return Job{}, ErrInvalidJob + } + mode, err := r.U8() + if err != nil { + return Job{}, ErrInvalidJob + } + deadline, err := r.U64() + if err != nil { + return Job{}, ErrInvalidJob + } + replicas, err := r.U16() + if err != nil { + return Job{}, ErrInvalidJob + } + private, err := r.Bool() + if err != nil || r.Done() != nil { + return Job{}, ErrInvalidJob + } + job := Job{Owner: owner, WorkloadHash: workload, InputRoot: inputRoot, Resources: resources, MaxPrice: maxPrice, CollateralRequired: collateral, Verification: VerificationMode(mode), DeadlineHeight: deadline, Replicas: replicas, Private: private} + if err := job.Validate(); err != nil { + return Job{}, err + } + return job, nil +} + +func (r Result) MarshalBinary() ([]byte, error) { + if err := r.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.Fixed(r.JobID[:]) + w.Fixed(r.Provider[:]) + w.Fixed(r.ResultRoot[:]) + w.Fixed(r.ProofHash[:]) + w.Fixed(r.AttestationHash[:]) + w.U64(r.CompletedHeight) + return w.BytesCopy(), nil +} + +func ParseResult(data []byte) (Result, error) { + r := codec.NewReader(data) + jobIDBytes, err := r.Fixed(32) + if err != nil { + return Result{}, ErrInvalidResult + } + provider, err := readAccount(r) + if err != nil { + return Result{}, ErrInvalidResult + } + resultRoot, err := readHash(r) + if err != nil { + return Result{}, ErrInvalidResult + } + proofHash, err := readHash(r) + if err != nil { + return Result{}, ErrInvalidResult + } + attestationHash, err := readHash(r) + if err != nil { + return Result{}, ErrInvalidResult + } + completedHeight, err := r.U64() + if err != nil || r.Done() != nil { + return Result{}, ErrInvalidResult + } + var jobID types.JobID + copy(jobID[:], jobIDBytes) + result := Result{JobID: jobID, Provider: provider, ResultRoot: resultRoot, ProofHash: proofHash, AttestationHash: attestationHash, CompletedHeight: completedHeight} + if err := result.Validate(); err != nil { + return Result{}, err + } + return result, nil +} + +func (a Assignment) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(a.OfferID)) || types.IsZero32([32]byte(a.Provider)) || a.Price == 0 { + return nil, ErrMarketState + } + var w codec.Writer + w.Fixed(a.OfferID[:]) + w.Fixed(a.Provider[:]) + w.U64(a.Price) + return w.BytesCopy(), nil +} + +func ParseAssignment(data []byte) (Assignment, error) { + r := codec.NewReader(data) + offerID, err := readHash(r) + if err != nil { + return Assignment{}, ErrMarketState + } + provider, err := readAccount(r) + if err != nil { + return Assignment{}, ErrMarketState + } + price, err := r.U64() + if err != nil || r.Done() != nil || price == 0 { + return Assignment{}, ErrMarketState + } + assignment := Assignment{OfferID: offerID, Provider: provider, Price: price} + if _, err := assignment.MarshalBinary(); err != nil { + return Assignment{}, err + } + return assignment, nil +} + +func (j OnChainJob) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(j.ID)) || j.Escrow < j.Job.MaxPrice || j.Status < JobPending || j.Status > JobExpired { + return nil, ErrMarketState + } + jobBytes, err := j.Job.MarshalBinary() + if err != nil { + return nil, err + } + assignments := append([]Assignment(nil), j.Assignments...) + sort.Slice(assignments, func(i, k int) bool { return assignments[i].Provider.String() < assignments[k].Provider.String() }) + results := append([]Result(nil), j.Results...) + sort.Slice(results, func(i, k int) bool { return results[i].Provider.String() < results[k].Provider.String() }) + + var w codec.Writer + w.Fixed(j.ID[:]) + w.Bytes(jobBytes) + w.U64(j.Escrow) + w.U8(uint8(j.Status)) + w.U32(uint32(len(assignments))) + for _, assignment := range assignments { + raw, err := assignment.MarshalBinary() + if err != nil { + return nil, err + } + w.Bytes(raw) + } + w.U32(uint32(len(results))) + for _, result := range results { + raw, err := result.MarshalBinary() + if err != nil { + return nil, err + } + w.Bytes(raw) + } + return w.BytesCopy(), nil +} + +func ParseOnChainJob(data []byte) (OnChainJob, error) { + r := codec.NewReader(data) + jobIDBytes, err := r.Fixed(32) + if err != nil { + return OnChainJob{}, ErrMarketState + } + jobBytes, err := r.Bytes(1 << 20) + if err != nil { + return OnChainJob{}, ErrMarketState + } + job, err := ParseJob(jobBytes) + if err != nil { + return OnChainJob{}, err + } + escrow, err := r.U64() + if err != nil { + return OnChainJob{}, ErrMarketState + } + status, err := r.U8() + if err != nil { + return OnChainJob{}, ErrMarketState + } + assignmentCount, err := r.U32() + if err != nil || assignmentCount > 1024 { + return OnChainJob{}, ErrMarketState + } + assignments := make([]Assignment, int(assignmentCount)) + for i := range assignments { + raw, err := r.Bytes(1024) + if err != nil { + return OnChainJob{}, ErrMarketState + } + assignments[i], err = ParseAssignment(raw) + if err != nil { + return OnChainJob{}, err + } + } + resultCount, err := r.U32() + if err != nil || resultCount > 1024 { + return OnChainJob{}, ErrMarketState + } + results := make([]Result, int(resultCount)) + for i := range results { + raw, err := r.Bytes(1024) + if err != nil { + return OnChainJob{}, ErrMarketState + } + results[i], err = ParseResult(raw) + if err != nil { + return OnChainJob{}, err + } + } + if r.Done() != nil { + return OnChainJob{}, ErrMarketState + } + var jobID types.JobID + copy(jobID[:], jobIDBytes) + record := OnChainJob{ID: jobID, Job: job, Escrow: escrow, Status: JobStatus(status), Assignments: assignments, Results: results} + if _, err := record.MarshalBinary(); err != nil { + return OnChainJob{}, err + } + return record, nil +} + +func NewOfferObject(txID types.Hash, shard uint32, offer Offer) (object.Object, error) { + raw, err := offer.MarshalBinary() + if err != nil { + return object.Object{}, err + } + return object.Object{ID: types.ObjectIDForShard(txID, computeOfferObjectIndex, shard), Version: 1, Owner: offer.Provider, Kind: object.KindComputeOffer, Data: raw}, nil +} + +func NewJobObject(txID types.Hash, shard uint32, job Job, escrow uint64) (object.Object, OnChainJob, error) { + jobID := types.JobIDFromTransaction(txID, 0) + record := OnChainJob{ID: jobID, Job: job, Escrow: escrow, Status: JobPending} + raw, err := record.MarshalBinary() + if err != nil { + return object.Object{}, OnChainJob{}, err + } + return object.Object{ID: types.ObjectIDForShard(txID, computeJobObjectIndex, shard), Version: 1, Owner: job.Owner, Kind: object.KindComputeJob, Data: raw}, record, nil +} + +func readResources(r *codec.Reader) (Resources, error) { + cpu, err := r.U16() + if err != nil { + return Resources{}, err + } + memory, err := r.U32() + if err != nil { + return Resources{}, err + } + gpu, err := r.U16() + if err != nil { + return Resources{}, err + } + gpuMemory, err := r.U32() + if err != nil { + return Resources{}, err + } + storage, err := r.U64() + if err != nil { + return Resources{}, err + } + bandwidth, err := r.U32() + if err != nil { + return Resources{}, err + } + count, err := r.U32() + if err != nil || count > 32 { + return Resources{}, ErrInvalidOffer + } + capabilities := make([]string, int(count)) + for i := range capabilities { + capabilities[i], err = r.String(64) + if err != nil { + return Resources{}, err + } + } + return Resources{CPUCores: cpu, MemoryMiB: memory, GPUCount: gpu, GPUMemoryMiB: gpuMemory, StorageMiB: storage, BandwidthMbps: bandwidth, Capabilities: capabilities}, nil +} + +func readAccount(r *codec.Reader) (types.AccountID, error) { + raw, err := r.Fixed(32) + if err != nil { + return types.AccountID{}, err + } + var out types.AccountID + copy(out[:], raw) + return out, nil +} + +func readHash(r *codec.Reader) (types.Hash, error) { + raw, err := r.Fixed(32) + if err != nil { + return types.Hash{}, err + } + var out types.Hash + copy(out[:], raw) + return out, nil +} diff --git a/internal/v2/compute/state_test.go b/internal/v2/compute/state_test.go new file mode 100644 index 00000000..7234870d --- /dev/null +++ b/internal/v2/compute/state_test.go @@ -0,0 +1,61 @@ +package compute + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestComputeProtocolObjectsRoundTrip(t *testing.T) { + owner := types.AccountIDFromPublicKey([]byte("compute-owner")) + provider := types.AccountIDFromPublicKey([]byte("compute-provider")) + offer := Offer{ + Provider: provider, + Resources: Resources{CPUCores: 8, MemoryMiB: 16384, GPUCount: 1, GPUMemoryMiB: 24576, StorageMiB: 102400, BandwidthMbps: 1000, Capabilities: []string{"cuda", "render"}}, + PricePerUnit: 25, Collateral: 100, Verification: []VerificationMode{VerificationReplicated, VerificationTEE}, ValidUntilHeight: 500, + } + rawOffer, err := offer.MarshalBinary() + if err != nil { + t.Fatal(err) + } + parsedOffer, err := ParseOffer(rawOffer) + if err != nil || parsedOffer.Provider != provider || parsedOffer.Resources.GPUCount != 1 { + t.Fatalf("offer round trip failed: %+v %v", parsedOffer, err) + } + + job := Job{ + Owner: owner, WorkloadHash: types.HashBytes("workload", []byte("render-scene")), InputRoot: types.HashBytes("input", []byte("scene")), + Resources: Resources{CPUCores: 4, MemoryMiB: 8192, GPUCount: 1, GPUMemoryMiB: 8192, StorageMiB: 2048, BandwidthMbps: 100, Capabilities: []string{"render"}}, + MaxPrice: 50, CollateralRequired: 50, Verification: VerificationReplicated, DeadlineHeight: 600, Replicas: 2, + } + rawJob, err := job.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if parsedJob, err := ParseJob(rawJob); err != nil || parsedJob.Owner != owner || parsedJob.Replicas != 2 { + t.Fatalf("job round trip failed: %+v %v", parsedJob, err) + } + + txID := types.HashBytes("tx", []byte("compute-job")) + jobObject, record, err := NewJobObject(txID, 0, job, 50) + if err != nil { + t.Fatal(err) + } + if jobObject.Kind != object.KindComputeJob || record.Status != JobPending { + t.Fatal("unexpected compute job object") + } + assignment := Assignment{OfferID: types.HashBytes("offer", []byte("1")), Provider: provider, Price: 25} + result := Result{JobID: record.ID, Provider: provider, ResultRoot: types.HashBytes("result", []byte("root")), CompletedHeight: 550} + record.Assignments = []Assignment{assignment} + record.Results = []Result{result} + record.Status = JobAwaitingVerification + rawRecord, err := record.MarshalBinary() + if err != nil { + t.Fatal(err) + } + parsed, err := ParseOnChainJob(rawRecord) + if err != nil || parsed.ID != record.ID || len(parsed.Assignments) != 1 || len(parsed.Results) != 1 { + t.Fatalf("on-chain record round trip failed: %+v %v", parsed, err) + } +} diff --git a/internal/v2/compute/work.go b/internal/v2/compute/work.go new file mode 100644 index 00000000..c2d90b20 --- /dev/null +++ b/internal/v2/compute/work.go @@ -0,0 +1,274 @@ +package compute + +import ( + "bytes" + "errors" + "math" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const WorkSpecVersion uint16 = 1 + +type WorkClass uint8 + +const ( + WorkUnknown WorkClass = iota + WorkCPUGeneral + WorkGPUFP32 + WorkGPUFP64 + WorkTensorAI + WorkMemory + WorkStorage + WorkNetwork + WorkRendering + WorkAIInference + WorkAITraining + WorkScientific + WorkClassCount +) + +var ( + ErrInvalidWorkSpec = errors.New("invalid normalized compute work specification") + ErrInvalidWorkRegistry = errors.New("invalid normalized compute work registry update") + ErrInvalidWorkSettlement = errors.New("invalid verified compute work settlement") +) + +// WorkVector is intentionally a vector rather than a single synthetic FLOP +// number. Units are protocol-defined normalized work units for CPU/GPU classes +// plus directly measurable byte-based resource dimensions. +type WorkVector struct { + CPUUnits uint64 + GPUFP32Units uint64 + GPUFP64Units uint64 + TensorUnits uint64 + MemoryByteSeconds uint64 + VRAMByteSeconds uint64 + StorageBytes uint64 + NetworkBytes uint64 +} + +func (v WorkVector) IsZero() bool { + return v.CPUUnits == 0 && v.GPUFP32Units == 0 && v.GPUFP64Units == 0 && v.TensorUnits == 0 && + v.MemoryByteSeconds == 0 && v.VRAMByteSeconds == 0 && v.StorageBytes == 0 && v.NetworkBytes == 0 +} + +// WorkSpec binds a normalized unit definition to a concrete workload and a +// benchmark/specification hash. Only registry-approved specs should be used by +// monetary telemetry; arbitrary provider declarations are never sufficient. +type WorkSpec struct { + Version uint16 + Class WorkClass + Units uint64 + WorkloadHash types.Hash + BenchmarkHash types.Hash + Vector WorkVector +} + +func (s WorkSpec) Validate() error { + if s.Version != WorkSpecVersion || s.Class <= WorkUnknown || s.Class >= WorkClassCount || s.Units == 0 || + types.IsZero32([32]byte(s.WorkloadHash)) || types.IsZero32([32]byte(s.BenchmarkHash)) || s.Vector.IsZero() { + return ErrInvalidWorkSpec + } + return nil +} + +func (s WorkSpec) MarshalBinary() ([]byte, error) { + if err := s.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.U16(s.Version) + w.U8(uint8(s.Class)) + w.U64(s.Units) + w.Fixed(s.WorkloadHash[:]) + w.Fixed(s.BenchmarkHash[:]) + w.U64(s.Vector.CPUUnits) + w.U64(s.Vector.GPUFP32Units) + w.U64(s.Vector.GPUFP64Units) + w.U64(s.Vector.TensorUnits) + w.U64(s.Vector.MemoryByteSeconds) + w.U64(s.Vector.VRAMByteSeconds) + w.U64(s.Vector.StorageBytes) + w.U64(s.Vector.NetworkBytes) + return w.BytesCopy(), nil +} + +func ParseWorkSpec(data []byte) (WorkSpec, error) { + r := codec.NewReader(data) + version, err := r.U16() + if err != nil { + return WorkSpec{}, ErrInvalidWorkSpec + } + class, err := r.U8() + if err != nil { + return WorkSpec{}, ErrInvalidWorkSpec + } + units, err := r.U64() + if err != nil { + return WorkSpec{}, ErrInvalidWorkSpec + } + workload, err := readHash(r) + if err != nil { + return WorkSpec{}, ErrInvalidWorkSpec + } + benchmark, err := readHash(r) + if err != nil { + return WorkSpec{}, ErrInvalidWorkSpec + } + values := make([]uint64, 8) + for i := range values { + values[i], err = r.U64() + if err != nil { + return WorkSpec{}, ErrInvalidWorkSpec + } + } + if r.Done() != nil { + return WorkSpec{}, ErrInvalidWorkSpec + } + out := WorkSpec{ + Version: version, + Class: WorkClass(class), + Units: units, + WorkloadHash: workload, + BenchmarkHash: benchmark, + Vector: WorkVector{ + CPUUnits: values[0], GPUFP32Units: values[1], GPUFP64Units: values[2], TensorUnits: values[3], + MemoryByteSeconds: values[4], VRAMByteSeconds: values[5], StorageBytes: values[6], NetworkBytes: values[7], + }, + } + if err := out.Validate(); err != nil { + return WorkSpec{}, err + } + return out, nil +} + +type WorkRegistry struct { + byWorkload map[types.Hash]WorkSpec +} + +func NewWorkRegistry(specs []WorkSpec) (*WorkRegistry, error) { + registry := &WorkRegistry{byWorkload: make(map[types.Hash]WorkSpec, len(specs))} + for _, spec := range specs { + if err := registry.Register(spec); err != nil { + return nil, err + } + } + return registry, nil +} + +func (r *WorkRegistry) Register(spec WorkSpec) error { + if r == nil || spec.Validate() != nil { + return ErrInvalidWorkRegistry + } + if r.byWorkload == nil { + r.byWorkload = make(map[types.Hash]WorkSpec) + } + if existing, ok := r.byWorkload[spec.WorkloadHash]; ok { + a, _ := existing.MarshalBinary() + b, _ := spec.MarshalBinary() + if !bytes.Equal(a, b) { + return ErrInvalidWorkRegistry + } + return nil + } + r.byWorkload[spec.WorkloadHash] = spec + return nil +} + +func (r *WorkRegistry) Resolve(workload types.Hash) (WorkSpec, bool) { + if r == nil { + return WorkSpec{}, false + } + spec, ok := r.byWorkload[workload] + return spec, ok +} + +// CanonicalSpecs returns an independent workload-hash-sorted registry view. +// Registry insertion order must never affect economic replay or checkpoint +// identity. +func (r *WorkRegistry) CanonicalSpecs() ([]WorkSpec, error) { + if r == nil { + return nil, ErrInvalidWorkRegistry + } + out := make([]WorkSpec, 0, len(r.byWorkload)) + for _, spec := range r.byWorkload { + if err := spec.Validate(); err != nil { + return nil, ErrInvalidWorkRegistry + } + out = append(out, spec) + } + sort.Slice(out, func(i, j int) bool { + return bytes.Compare(out[i].WorkloadHash[:], out[j].WorkloadHash[:]) < 0 + }) + return out, nil +} + +func (r *WorkRegistry) Clone() (*WorkRegistry, error) { + specs, err := r.CanonicalSpecs() + if err != nil { + return nil, err + } + return NewWorkRegistry(specs) +} + +// Hash commits the exact normalized workload definitions used by ZCPI/ZCSI. +// Checkpoints bind this hash instead of serializing an implicitly trusted +// registry snapshot; restore must be supplied the same registry explicitly. +func (r *WorkRegistry) Hash() (types.Hash, error) { + specs, err := r.CanonicalSpecs() + if err != nil { + return types.Hash{}, err + } + var w codec.Writer + w.U32(uint32(len(specs))) + for _, spec := range specs { + raw, err := spec.MarshalBinary() + if err != nil { + return types.Hash{}, ErrInvalidWorkRegistry + } + w.Bytes(raw) + } + return types.Hash(codec.DomainHash("zephyr/work-registry/v2", w.BytesCopy())), nil +} + +// VerifiedWork is an index-eligible observation. It can only be derived from a +// finalized/verified on-chain settlement plus a workload spec already approved +// by the protocol registry. Offer prices and provider self-reported capacity do +// not enter this record. +type VerifiedWork struct { + JobID types.JobID + Class WorkClass + Units uint64 + Vector WorkVector + PaidZPH uint64 + Verification VerificationMode + ResultRoot types.Hash +} + +func ObserveVerifiedWork(record OnChainJob, settlement OnChainSettlement, registry *WorkRegistry) (VerifiedWork, error) { + if record.Status != JobSettled || registry == nil || settlement.JobID != record.ID || + types.IsZero32([32]byte(settlement.ResultRoot)) { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + spec, ok := registry.Resolve(record.Job.WorkloadHash) + if !ok || spec.Validate() != nil { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + var paid uint64 + for _, amount := range settlement.Payments { + if math.MaxUint64-paid < amount { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + paid += amount + } + if paid == 0 { + return VerifiedWork{}, ErrInvalidWorkSettlement + } + return VerifiedWork{ + JobID: record.ID, Class: spec.Class, Units: spec.Units, Vector: spec.Vector, PaidZPH: paid, + Verification: record.Job.Verification, ResultRoot: settlement.ResultRoot, + }, nil +} diff --git a/internal/v2/compute/work_test.go b/internal/v2/compute/work_test.go new file mode 100644 index 00000000..447beb91 --- /dev/null +++ b/internal/v2/compute/work_test.go @@ -0,0 +1,129 @@ +package compute + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestWorkSpecRoundTripAndVerifiedSettlementObservation(t *testing.T) { + var workload, benchmark, resultRoot types.Hash + var jobID types.JobID + var provider types.AccountID + workload[0] = 1 + benchmark[0] = 2 + resultRoot[0] = 3 + jobID[0] = 4 + provider[0] = 5 + + spec := WorkSpec{ + Version: WorkSpecVersion, + Class: WorkTensorAI, + Units: 250, + WorkloadHash: workload, + BenchmarkHash: benchmark, + Vector: WorkVector{TensorUnits: 250, VRAMByteSeconds: 8 << 30}, + } + raw, err := spec.MarshalBinary() + if err != nil { + t.Fatal(err) + } + parsed, err := ParseWorkSpec(raw) + if err != nil { + t.Fatal(err) + } + if parsed != spec { + t.Fatalf("work spec round trip mismatch: %#v != %#v", parsed, spec) + } + registry, err := NewWorkRegistry([]WorkSpec{spec}) + if err != nil { + t.Fatal(err) + } + record := OnChainJob{ + ID: jobID, + Job: Job{WorkloadHash: workload, Verification: VerificationReplicated}, + Status: JobSettled, + } + settlement := OnChainSettlement{Settlement: Settlement{ + JobID: jobID, + ResultRoot: resultRoot, + Payments: map[types.AccountID]uint64{provider: 5000}, + }} + observation, err := ObserveVerifiedWork(record, settlement, registry) + if err != nil { + t.Fatal(err) + } + if observation.PaidZPH != 5000 || observation.Units != 250 || observation.Class != WorkTensorAI { + t.Fatalf("unexpected observation: %#v", observation) + } +} + +func TestWorkRegistryRejectsConflictingDefinition(t *testing.T) { + var workload, benchmarkA, benchmarkB types.Hash + workload[0] = 1 + benchmarkA[0] = 2 + benchmarkB[0] = 3 + base := WorkSpec{ + Version: WorkSpecVersion, + Class: WorkCPUGeneral, + Units: 1, + WorkloadHash: workload, + BenchmarkHash: benchmarkA, + Vector: WorkVector{CPUUnits: 1}, + } + registry, err := NewWorkRegistry([]WorkSpec{base}) + if err != nil { + t.Fatal(err) + } + conflicting := base + conflicting.BenchmarkHash = benchmarkB + if err := registry.Register(conflicting); err != ErrInvalidWorkRegistry { + t.Fatalf("expected conflicting registry definition rejection, got %v", err) + } +} + +func TestWorkRegistryHashIsInsertionOrderIndependent(t *testing.T) { + specA := WorkSpec{ + Version: WorkSpecVersion, Class: WorkCPUGeneral, Units: 10, + WorkloadHash: types.Hash{1}, BenchmarkHash: types.Hash{11}, + Vector: WorkVector{CPUUnits: 10}, + } + specB := WorkSpec{ + Version: WorkSpecVersion, Class: WorkRendering, Units: 20, + WorkloadHash: types.Hash{2}, BenchmarkHash: types.Hash{12}, + Vector: WorkVector{GPUFP32Units: 20}, + } + first, err := NewWorkRegistry([]WorkSpec{specA, specB}) + if err != nil { + t.Fatal(err) + } + second, err := NewWorkRegistry([]WorkSpec{specB, specA}) + if err != nil { + t.Fatal(err) + } + firstHash, err := first.Hash() + if err != nil { + t.Fatal(err) + } + secondHash, err := second.Hash() + if err != nil { + t.Fatal(err) + } + if firstHash != secondHash { + t.Fatalf("registry insertion order changed commitment: %x != %x", firstHash, secondHash) + } + + changed := specB + changed.Units++ + third, err := NewWorkRegistry([]WorkSpec{specA, changed}) + if err != nil { + t.Fatal(err) + } + thirdHash, err := third.Hash() + if err != nil { + t.Fatal(err) + } + if thirdHash == firstHash { + t.Fatal("changed workload definition did not change registry commitment") + } +} diff --git a/internal/v2/consensus/certificate_binary.go b/internal/v2/consensus/certificate_binary.go new file mode 100644 index 00000000..8f2294fd --- /dev/null +++ b/internal/v2/consensus/certificate_binary.go @@ -0,0 +1,7 @@ +package consensus + +// MarshalCertificate is the functional counterpart to ParseCertificate and +// preserves the existing Certificate.MarshalBinary canonical encoding. +func MarshalCertificate(c Certificate) ([]byte, error) { + return c.MarshalBinary() +} diff --git a/internal/v2/consensus/consensus.go b/internal/v2/consensus/consensus.go new file mode 100644 index 00000000..8c911cf5 --- /dev/null +++ b/internal/v2/consensus/consensus.go @@ -0,0 +1,316 @@ +package consensus + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "math/big" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ( + ErrValidatorSet = errors.New("invalid v2 validator set") + ErrProposal = errors.New("invalid v2 consensus proposal") + ErrVote = errors.New("invalid v2 consensus vote") + ErrCertificate = errors.New("invalid v2 quorum certificate") + ErrInsufficientPower = errors.New("insufficient v2 voting power") +) + +type Validator struct { + ID types.ValidatorID + PublicKey []byte + Power uint64 +} + +type ValidatorSet struct { + Network types.NetworkID + Validators []Validator +} + +type Proposal struct { + Header sharding.GlobalHeader + Round uint64 + Proposer types.ValidatorID + PublicKey []byte + Signature []byte +} + +type Vote struct { + Network types.NetworkID + Height uint64 + Round uint64 + HeaderHash types.Hash + Voter types.ValidatorID + PublicKey []byte + Signature []byte +} + +type Certificate struct { + Network types.NetworkID + Height uint64 + Round uint64 + HeaderHash types.Hash + Votes []Vote +} + +func (s ValidatorSet) Validate() error { + if types.IsZero32([32]byte(s.Network)) || len(s.Validators) == 0 { + return ErrValidatorSet + } + seen := make(map[types.ValidatorID]struct{}, len(s.Validators)) + var total uint64 + for _, validator := range s.Validators { + if validator.Power == 0 || len(validator.PublicKey) != 65 || types.ValidatorIDFromPublicKey(validator.PublicKey) != validator.ID { + return ErrValidatorSet + } + x, y := elliptic.Unmarshal(elliptic.P256(), validator.PublicKey) + if x == nil || y == nil { + return ErrValidatorSet + } + if _, duplicate := seen[validator.ID]; duplicate { + return ErrValidatorSet + } + seen[validator.ID] = struct{}{} + if ^uint64(0)-total < validator.Power { + return ErrValidatorSet + } + total += validator.Power + } + return nil +} + +func (s ValidatorSet) TotalPower() (uint64, error) { + if err := s.Validate(); err != nil { + return 0, err + } + var total uint64 + for _, validator := range s.Validators { + total += validator.Power + } + return total, nil +} + +func QuorumPower(total uint64) uint64 { + if total == 0 { + return 0 + } + q, r := total/3, total%3 + return q*2 + (r*2)/3 + 1 +} + +func (s ValidatorSet) Proposer(height, round uint64) (Validator, error) { + total, err := s.TotalPower() + if err != nil || height == 0 { + return Validator{}, ErrValidatorSet + } + validators := append([]Validator(nil), s.Validators...) + sort.Slice(validators, func(i, j int) bool { return bytes.Compare(validators[i].ID[:], validators[j].ID[:]) < 0 }) + slot := ((height-1)%total + (round % total)) % total + var cumulative uint64 + for _, validator := range validators { + cumulative += validator.Power + if slot < cumulative { + return validator, nil + } + } + return Validator{}, ErrValidatorSet +} + +func HeaderConsensusHash(header sharding.GlobalHeader) types.Hash { + copyHeader := header + copyHeader.CertificateHash = types.Hash{} + return types.Hash(codec.DomainHash("zephyr/global-header-consensus/v2", copyHeader.CanonicalBytes())) +} + +func (p Proposal) SigningDigest() types.Hash { + var w codec.Writer + headerHash := HeaderConsensusHash(p.Header) + w.Fixed(headerHash[:]) + w.U64(p.Round) + w.Fixed(p.Proposer[:]) + return types.Hash(codec.DomainHash("zephyr/consensus/proposal/v2", w.BytesCopy())) +} + +func (v Vote) SigningDigest() types.Hash { + var w codec.Writer + w.Fixed(v.Network[:]) + w.U64(v.Height) + w.U64(v.Round) + w.Fixed(v.HeaderHash[:]) + w.Fixed(v.Voter[:]) + return types.Hash(codec.DomainHash("zephyr/consensus/vote/v2", w.BytesCopy())) +} + +func SignProposal(privateKey *ecdsa.PrivateKey, header sharding.GlobalHeader, round uint64) (Proposal, error) { + publicKey, validatorID, err := signerIdentity(privateKey) + if err != nil { + return Proposal{}, err + } + proposal := Proposal{Header: header, Round: round, Proposer: validatorID, PublicKey: publicKey} + proposal.Signature, err = signDigest(privateKey, proposal.SigningDigest()) + return proposal, err +} + +func SignVote(privateKey *ecdsa.PrivateKey, network types.NetworkID, height, round uint64, headerHash types.Hash) (Vote, error) { + publicKey, validatorID, err := signerIdentity(privateKey) + if err != nil { + return Vote{}, err + } + vote := Vote{Network: network, Height: height, Round: round, HeaderHash: headerHash, Voter: validatorID, PublicKey: publicKey} + vote.Signature, err = signDigest(privateKey, vote.SigningDigest()) + return vote, err +} + +func (s ValidatorSet) VerifyProposal(proposal Proposal) error { + if err := s.Validate(); err != nil || proposal.Header.Network != s.Network || proposal.Header.Height == 0 || proposal.Header.CertificateHash != (types.Hash{}) { + return ErrProposal + } + validatorRoot, err := s.Root() + if err != nil || validatorRoot != proposal.Header.ValidatorRoot { + return ErrProposal + } + expected, err := s.Proposer(proposal.Header.Height, proposal.Round) + if err != nil || expected.ID != proposal.Proposer || !bytes.Equal(expected.PublicKey, proposal.PublicKey) { + return ErrProposal + } + if types.ValidatorIDFromPublicKey(proposal.PublicKey) != proposal.Proposer || verifyDigest(proposal.PublicKey, proposal.SigningDigest(), proposal.Signature) != nil { + return ErrProposal + } + return nil +} + +func (s ValidatorSet) VerifyVote(vote Vote) error { + if vote.Network != s.Network || vote.Height == 0 || types.IsZero32([32]byte(vote.HeaderHash)) || types.ValidatorIDFromPublicKey(vote.PublicKey) != vote.Voter { + return ErrVote + } + validator, ok := s.validator(vote.Voter) + if !ok || !bytes.Equal(validator.PublicKey, vote.PublicKey) || verifyDigest(vote.PublicKey, vote.SigningDigest(), vote.Signature) != nil { + return ErrVote + } + return nil +} + +func (s ValidatorSet) BuildCertificate(proposal Proposal, votes []Vote) (Certificate, error) { + if err := s.VerifyProposal(proposal); err != nil { + return Certificate{}, err + } + certificate := Certificate{Network: s.Network, Height: proposal.Header.Height, Round: proposal.Round, HeaderHash: HeaderConsensusHash(proposal.Header), Votes: append([]Vote(nil), votes...)} + if err := s.VerifyCertificate(certificate); err != nil { + return Certificate{}, err + } + return certificate, nil +} + +func (s ValidatorSet) VerifyCertificate(certificate Certificate) error { + if err := s.Validate(); err != nil || certificate.Network != s.Network || certificate.Height == 0 || types.IsZero32([32]byte(certificate.HeaderHash)) { + return ErrCertificate + } + total, _ := s.TotalPower() + quorum := QuorumPower(total) + seen := make(map[types.ValidatorID]struct{}, len(certificate.Votes)) + var power uint64 + for _, vote := range certificate.Votes { + if vote.Network != certificate.Network || vote.Height != certificate.Height || vote.Round != certificate.Round || vote.HeaderHash != certificate.HeaderHash { + return ErrCertificate + } + if _, duplicate := seen[vote.Voter]; duplicate { + return ErrCertificate + } + seen[vote.Voter] = struct{}{} + if err := s.VerifyVote(vote); err != nil { + return ErrCertificate + } + validator, _ := s.validator(vote.Voter) + power += validator.Power + } + if power < quorum { + return ErrInsufficientPower + } + return nil +} + +func (c Certificate) Hash() types.Hash { + votes := append([]Vote(nil), c.Votes...) + sort.Slice(votes, func(i, j int) bool { return bytes.Compare(votes[i].Voter[:], votes[j].Voter[:]) < 0 }) + var w codec.Writer + w.Fixed(c.Network[:]) + w.U64(c.Height) + w.U64(c.Round) + w.Fixed(c.HeaderHash[:]) + w.U32(uint32(len(votes))) + for _, vote := range votes { + w.Fixed(vote.Voter[:]) + w.Bytes(vote.PublicKey) + w.Bytes(vote.Signature) + } + return types.Hash(codec.DomainHash("zephyr/quorum-certificate/v2", w.BytesCopy())) +} + +func (s ValidatorSet) validator(id types.ValidatorID) (Validator, bool) { + for _, validator := range s.Validators { + if validator.ID == id { + return validator, true + } + } + return Validator{}, false +} + +func signerIdentity(privateKey *ecdsa.PrivateKey) ([]byte, types.ValidatorID, error) { + if privateKey == nil || privateKey.Curve != elliptic.P256() { + return nil, types.ValidatorID{}, ErrValidatorSet + } + publicKey := elliptic.Marshal(elliptic.P256(), privateKey.PublicKey.X, privateKey.PublicKey.Y) + return publicKey, types.ValidatorIDFromPublicKey(publicKey), nil +} + +func signDigest(privateKey *ecdsa.PrivateKey, digest types.Hash) ([]byte, error) { + r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:]) + if err != nil { + return nil, err + } + if s.Cmp(halfOrder()) > 0 { + s = new(big.Int).Sub(elliptic.P256().Params().N, s) + } + return append(pad32(r), pad32(s)...), nil +} + +func verifyDigest(publicKey []byte, digest types.Hash, signature []byte) error { + if len(publicKey) != 65 || len(signature) != 64 { + return ErrVote + } + x, y := elliptic.Unmarshal(elliptic.P256(), publicKey) + if x == nil || y == nil { + return ErrVote + } + r := new(big.Int).SetBytes(signature[:32]) + s := new(big.Int).SetBytes(signature[32:]) + order := elliptic.P256().Params().N + if r.Sign() <= 0 || s.Sign() <= 0 || r.Cmp(order) >= 0 || s.Cmp(order) >= 0 || s.Cmp(halfOrder()) > 0 { + return ErrVote + } + if !ecdsa.Verify(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, digest[:], r, s) { + return ErrVote + } + return nil +} + +func halfOrder() *big.Int { + return new(big.Int).Rsh(new(big.Int).Set(elliptic.P256().Params().N), 1) +} + +func pad32(value *big.Int) []byte { + raw := value.Bytes() + out := make([]byte, 32) + if len(raw) >= 32 { + copy(out, raw[len(raw)-32:]) + } else { + copy(out[32-len(raw):], raw) + } + return out +} diff --git a/internal/v2/consensus/consensus_test.go b/internal/v2/consensus/consensus_test.go new file mode 100644 index 00000000..a498a226 --- /dev/null +++ b/internal/v2/consensus/consensus_test.go @@ -0,0 +1,118 @@ +package consensus + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestV2QuorumCertificateRequiresTwoThirdsPlus(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("consensus-v2"))) + set := ValidatorSet{Network: network} + keys := make(map[types.ValidatorID]*ecdsa.PrivateKey) + for i := 0; i < 4; i++ { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + set.Validators = append(set.Validators, Validator{ID: id, PublicKey: pub, Power: 10}) + keys[id] = key + } + if QuorumPower(40) != 27 { + t.Fatalf("unexpected quorum: %d", QuorumPower(40)) + } + validatorRoot, err := set.Root() + if err != nil { + t.Fatal(err) + } + proposer, err := set.Proposer(1, 0) + if err != nil { + t.Fatal(err) + } + header := sharding.GlobalHeader{ + Version: 2, Network: network, Height: 1, + ShardCommitmentRoot: types.HashBytes("shards", []byte("root")), + ValidatorRoot: validatorRoot, + DataRoot: types.HashBytes("data", []byte("root")), + } + proposal, err := SignProposal(keys[proposer.ID], header, 0) + if err != nil { + t.Fatal(err) + } + if err := set.VerifyProposal(proposal); err != nil { + t.Fatal(err) + } + headerHash := HeaderConsensusHash(header) + votes := make([]Vote, 0, 3) + for i := 0; i < 3; i++ { + validator := set.Validators[i] + vote, err := SignVote(keys[validator.ID], network, 1, 0, headerHash) + if err != nil { + t.Fatal(err) + } + votes = append(votes, vote) + } + if _, err := set.BuildCertificate(proposal, votes[:2]); err != ErrInsufficientPower { + t.Fatalf("expected insufficient power, got %v", err) + } + certificate, err := set.BuildCertificate(proposal, votes) + if err != nil { + t.Fatal(err) + } + if types.IsZero32([32]byte(certificate.Hash())) { + t.Fatal("certificate hash is zero") + } + if err := set.VerifyCertificate(certificate); err != nil { + t.Fatal(err) + } +} + +func TestV2ProposalRejectsUncommittedValidatorRoot(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("validator-root"))) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + set := ValidatorSet{Network: network, Validators: []Validator{{ID: id, PublicKey: pub, Power: 1}}} + header := sharding.GlobalHeader{ + Version: 2, Network: network, Height: 1, + ShardCommitmentRoot: types.HashBytes("shards", []byte("root")), + ValidatorRoot: types.HashBytes("validators", []byte("wrong")), + DataRoot: types.HashBytes("data", []byte("root")), + } + proposal, err := SignProposal(key, header, 0) + if err != nil { + t.Fatal(err) + } + if err := set.VerifyProposal(proposal); err != ErrProposal { + t.Fatalf("expected validator-root mismatch rejection, got %v", err) + } +} + +func TestV2CertificateRejectsDuplicateVote(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("duplicate"))) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + set := ValidatorSet{Network: network, Validators: []Validator{{ID: id, PublicKey: pub, Power: 1}}} + hash := types.HashBytes("header", []byte("h")) + vote, err := SignVote(key, network, 1, 0, hash) + if err != nil { + t.Fatal(err) + } + certificate := Certificate{Network: network, Height: 1, HeaderHash: hash, Votes: []Vote{vote, vote}} + if err := set.VerifyCertificate(certificate); err != ErrCertificate { + t.Fatalf("expected duplicate vote rejection, got %v", err) + } +} diff --git a/internal/v2/consensus/transition.go b/internal/v2/consensus/transition.go new file mode 100644 index 00000000..a8901fe0 --- /dev/null +++ b/internal/v2/consensus/transition.go @@ -0,0 +1,26 @@ +package consensus + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" +) + +// VerifyCertifiedTransition proves that a currently trusted validator set +// finalized a header which authorizes next as the validator set for the next +// height. This is the trust-chain primitive used by Citizen checkpoints. +func VerifyCertifiedTransition(header sharding.GlobalHeader, certificate Certificate, current, next ValidatorSet) error { + currentRoot, err := current.Root() + if err != nil || current.Network != header.Network || currentRoot != header.ValidatorRoot { + return ErrValidatorSet + } + if certificate.Network != header.Network || certificate.Height != header.Height || certificate.HeaderHash != HeaderConsensusHash(header) || header.CertificateHash != certificate.Hash() { + return ErrCertificate + } + if err := current.VerifyCertificate(certificate); err != nil { + return err + } + nextRoot, err := next.Root() + if err != nil || next.Network != header.Network || nextRoot != header.EffectiveNextValidatorRoot() { + return ErrValidatorSet + } + return nil +} diff --git a/internal/v2/consensus/validator_root.go b/internal/v2/consensus/validator_root.go new file mode 100644 index 00000000..b5671eee --- /dev/null +++ b/internal/v2/consensus/validator_root.go @@ -0,0 +1,32 @@ +package consensus + +import ( + "bytes" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +// Root commits to the exact validator identities, public keys and integer +// voting powers that are authorized for a header. It is deliberately ordered +// by ValidatorID so peers and Citizen Nodes derive the same commitment. +func (s ValidatorSet) Root() (types.Hash, error) { + if err := s.Validate(); err != nil { + return types.Hash{}, err + } + validators := append([]Validator(nil), s.Validators...) + sort.Slice(validators, func(i, j int) bool { + return bytes.Compare(validators[i].ID[:], validators[j].ID[:]) < 0 + }) + leaves := make([]types.Hash, len(validators)) + for i, validator := range validators { + var w codec.Writer + w.Fixed(validator.ID[:]) + w.Bytes(validator.PublicKey) + w.U64(validator.Power) + leaves[i] = merkle.Leaf("validator", w.BytesCopy()) + } + return merkle.Root(leaves), nil +} diff --git a/internal/v2/consensus/validator_wire.go b/internal/v2/consensus/validator_wire.go new file mode 100644 index 00000000..48ab3c11 --- /dev/null +++ b/internal/v2/consensus/validator_wire.go @@ -0,0 +1,59 @@ +package consensus + +import ( + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrValidatorWire = errors.New("invalid canonical validator set") + +func (s ValidatorSet) MarshalBinary() ([]byte, error) { + if err := s.Validate(); err != nil || len(s.Validators) > MaxCertificateVotes { + return nil, ErrValidatorWire + } + var w codec.Writer + w.Fixed(s.Network[:]) + w.U32(uint32(len(s.Validators))) + for _, validator := range s.Validators { + w.Fixed(validator.ID[:]) + w.Bytes(validator.PublicKey) + w.U64(validator.Power) + } + return w.BytesCopy(), nil +} + +func ParseValidatorSet(data []byte) (ValidatorSet, error) { + r := codec.NewReader(data) + networkRaw, err := r.Fixed(32) + if err != nil { + return ValidatorSet{}, ErrValidatorWire + } + var network types.NetworkID + copy(network[:], networkRaw) + count, err := r.U32() + if err != nil || count == 0 || count > MaxCertificateVotes { + return ValidatorSet{}, ErrValidatorWire + } + set := ValidatorSet{Network: network, Validators: make([]Validator, int(count))} + for i := range set.Validators { + idRaw, err := r.Fixed(32) + if err != nil { + return ValidatorSet{}, ErrValidatorWire + } + copy(set.Validators[i].ID[:], idRaw) + set.Validators[i].PublicKey, err = r.Bytes(65) + if err != nil || len(set.Validators[i].PublicKey) != 65 { + return ValidatorSet{}, ErrValidatorWire + } + set.Validators[i].Power, err = r.U64() + if err != nil { + return ValidatorSet{}, ErrValidatorWire + } + } + if r.Done() != nil || set.Validate() != nil { + return ValidatorSet{}, ErrValidatorWire + } + return set, nil +} diff --git a/internal/v2/consensus/wire.go b/internal/v2/consensus/wire.go new file mode 100644 index 00000000..e8a701f2 --- /dev/null +++ b/internal/v2/consensus/wire.go @@ -0,0 +1,171 @@ +package consensus + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const MaxCertificateVotes = 4096 + +func (p Proposal) MarshalBinary() ([]byte, error) { + if len(p.PublicKey) != 65 || len(p.Signature) != 64 || types.IsZero32([32]byte(p.Proposer)) { + return nil, ErrProposal + } + var w codec.Writer + w.Bytes(p.Header.CanonicalBytes()) + w.U64(p.Round) + w.Fixed(p.Proposer[:]) + w.Bytes(p.PublicKey) + w.Bytes(p.Signature) + return w.BytesCopy(), nil +} + +func ParseProposal(data []byte) (Proposal, error) { + r := codec.NewReader(data) + headerBytes, err := r.Bytes(512) + if err != nil { + return Proposal{}, ErrProposal + } + header, err := sharding.ParseGlobalHeader(headerBytes) + if err != nil { + return Proposal{}, ErrProposal + } + round, err := r.U64() + if err != nil { + return Proposal{}, ErrProposal + } + proposerBytes, err := r.Fixed(32) + if err != nil { + return Proposal{}, ErrProposal + } + publicKey, err := r.Bytes(65) + if err != nil || len(publicKey) != 65 { + return Proposal{}, ErrProposal + } + signature, err := r.Bytes(64) + if err != nil || len(signature) != 64 || r.Done() != nil { + return Proposal{}, ErrProposal + } + var proposer types.ValidatorID + copy(proposer[:], proposerBytes) + return Proposal{Header: header, Round: round, Proposer: proposer, PublicKey: publicKey, Signature: signature}, nil +} + +func (v Vote) MarshalBinary() ([]byte, error) { + if len(v.PublicKey) != 65 || len(v.Signature) != 64 || v.Height == 0 || types.IsZero32([32]byte(v.Network)) || types.IsZero32([32]byte(v.HeaderHash)) || types.IsZero32([32]byte(v.Voter)) { + return nil, ErrVote + } + var w codec.Writer + w.Fixed(v.Network[:]) + w.U64(v.Height) + w.U64(v.Round) + w.Fixed(v.HeaderHash[:]) + w.Fixed(v.Voter[:]) + w.Bytes(v.PublicKey) + w.Bytes(v.Signature) + return w.BytesCopy(), nil +} + +func ParseVote(data []byte) (Vote, error) { + r := codec.NewReader(data) + networkBytes, err := r.Fixed(32) + if err != nil { + return Vote{}, ErrVote + } + height, err := r.U64() + if err != nil || height == 0 { + return Vote{}, ErrVote + } + round, err := r.U64() + if err != nil { + return Vote{}, ErrVote + } + headerBytes, err := r.Fixed(32) + if err != nil { + return Vote{}, ErrVote + } + voterBytes, err := r.Fixed(32) + if err != nil { + return Vote{}, ErrVote + } + publicKey, err := r.Bytes(65) + if err != nil || len(publicKey) != 65 { + return Vote{}, ErrVote + } + signature, err := r.Bytes(64) + if err != nil || len(signature) != 64 || r.Done() != nil { + return Vote{}, ErrVote + } + var network types.NetworkID + var headerHash types.Hash + var voter types.ValidatorID + copy(network[:], networkBytes) + copy(headerHash[:], headerBytes) + copy(voter[:], voterBytes) + if types.IsZero32([32]byte(network)) || types.IsZero32([32]byte(headerHash)) || types.IsZero32([32]byte(voter)) { + return Vote{}, ErrVote + } + return Vote{Network: network, Height: height, Round: round, HeaderHash: headerHash, Voter: voter, PublicKey: publicKey, Signature: signature}, nil +} + +func (c Certificate) MarshalBinary() ([]byte, error) { + if len(c.Votes) == 0 || len(c.Votes) > MaxCertificateVotes || c.Height == 0 || types.IsZero32([32]byte(c.Network)) || types.IsZero32([32]byte(c.HeaderHash)) { + return nil, ErrCertificate + } + var w codec.Writer + w.Fixed(c.Network[:]) + w.U64(c.Height) + w.U64(c.Round) + w.Fixed(c.HeaderHash[:]) + w.U32(uint32(len(c.Votes))) + for _, vote := range c.Votes { + raw, err := vote.MarshalBinary() + if err != nil { + return nil, err + } + w.Bytes(raw) + } + return w.BytesCopy(), nil +} + +func ParseCertificate(data []byte) (Certificate, error) { + r := codec.NewReader(data) + networkBytes, err := r.Fixed(32) + if err != nil { + return Certificate{}, ErrCertificate + } + height, err := r.U64() + if err != nil || height == 0 { + return Certificate{}, ErrCertificate + } + round, err := r.U64() + if err != nil { + return Certificate{}, ErrCertificate + } + headerBytes, err := r.Fixed(32) + if err != nil { + return Certificate{}, ErrCertificate + } + count, err := r.U32() + if err != nil || count == 0 || count > MaxCertificateVotes { + return Certificate{}, ErrCertificate + } + certificate := Certificate{Height: height, Round: round, Votes: make([]Vote, int(count))} + copy(certificate.Network[:], networkBytes) + copy(certificate.HeaderHash[:], headerBytes) + for i := range certificate.Votes { + voteBytes, err := r.Bytes(512) + if err != nil { + return Certificate{}, ErrCertificate + } + certificate.Votes[i], err = ParseVote(voteBytes) + if err != nil { + return Certificate{}, err + } + } + if r.Done() != nil || types.IsZero32([32]byte(certificate.Network)) || types.IsZero32([32]byte(certificate.HeaderHash)) { + return Certificate{}, ErrCertificate + } + return certificate, nil +} diff --git a/internal/v2/contracts/contracts.go b/internal/v2/contracts/contracts.go new file mode 100644 index 00000000..8c74d053 --- /dev/null +++ b/internal/v2/contracts/contracts.go @@ -0,0 +1,108 @@ +package contracts + +import ( + "bytes" + "errors" + "unicode/utf8" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + RuntimeWASMv1 = "wasm-v1" + RuntimeZephyrScriptV1 = "zephyr-script-v1" + MaxModuleBytes = 4 << 20 + MaxInitialStateBytes = 1 << 20 +) + +var ( + ErrInvalidModule = errors.New("invalid deterministic contract module") + ErrInvalidDeployment = errors.New("invalid contract deployment") + ErrFuelExhausted = errors.New("contract fuel exhausted") + ErrUndeclaredAccess = errors.New("contract attempted undeclared state access") +) + +type Deployment struct { + Runtime string + Code []byte + ABI uint16 + UpgradeAuthority types.AccountID + InitialState []byte + MaxMemoryPages uint32 +} + +func (d Deployment) Validate() error { + if d.ABI == 0 || len(d.Code) == 0 || len(d.Code) > MaxModuleBytes || + len(d.InitialState) > MaxInitialStateBytes || d.MaxMemoryPages == 0 || + types.IsZero32([32]byte(d.UpgradeAuthority)) { + return ErrInvalidDeployment + } + switch d.Runtime { + case RuntimeWASMv1: + if !ValidateWASMModule(d.Code) { + return ErrInvalidModule + } + case RuntimeZephyrScriptV1: + if !ValidateScriptModule(d.Code) { + return ErrInvalidModule + } + default: + return ErrInvalidDeployment + } + return nil +} + +func (d Deployment) MarshalBinary() ([]byte, error) { + if err := d.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.String(d.Runtime) + w.Bytes(d.Code) + w.U16(d.ABI) + w.Fixed(d.UpgradeAuthority[:]) + w.Bytes(d.InitialState) + w.U32(d.MaxMemoryPages) + return w.BytesCopy(), nil +} + +func ValidateWASMModule(code []byte) bool { + if len(code) < 8 || len(code) > MaxModuleBytes { + return false + } + return bytes.Equal(code[:4], []byte{0x00, 0x61, 0x73, 0x6d}) && + bytes.Equal(code[4:8], []byte{0x01, 0x00, 0x00, 0x00}) +} + +func ValidateScriptModule(code []byte) bool { + return len(code) > 0 && len(code) <= MaxModuleBytes && utf8.Valid(code) && !bytes.ContainsRune(code, 0) +} + +type Access struct { + ObjectID types.ObjectID + Write bool +} + +type Request struct { + ContractID types.ContractID + Runtime string + Code []byte + Entrypoint string + Arguments []byte + Accesses []Access + ReadValues map[types.ObjectID][]byte + FuelLimit uint64 +} + +type Result struct { + ReturnData []byte + FuelUsed uint64 + Writes map[types.ObjectID][]byte + Events [][]byte +} + +type Runtime interface { + ValidateModule(code []byte) error + Execute(request Request) (Result, error) +} diff --git a/internal/v2/contracts/contracts_test.go b/internal/v2/contracts/contracts_test.go new file mode 100644 index 00000000..bbdd12ba --- /dev/null +++ b/internal/v2/contracts/contracts_test.go @@ -0,0 +1,19 @@ +package contracts + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestDeploymentAcceptsWASMVersionOneEnvelope(t *testing.T) { + code := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + deployment := Deployment{ + Runtime: RuntimeWASMv1, Code: code, ABI: 1, + UpgradeAuthority: types.AccountIDFromPublicKey([]byte("owner")), + MaxMemoryPages: 64, + } + if err := deployment.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/v2/contracts/metered.go b/internal/v2/contracts/metered.go new file mode 100644 index 00000000..60c85c53 --- /dev/null +++ b/internal/v2/contracts/metered.go @@ -0,0 +1,99 @@ +package contracts + +import ( + "errors" + "strings" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + MaxArgumentsBytes = 1 << 20 + MaxReturnBytes = 1 << 20 + MaxEventBytes = 64 << 10 + MaxEvents = 1024 + MaxAccesses = 4096 +) + +var ( + ErrInvalidRequest = errors.New("invalid deterministic contract request") + ErrInvalidResult = errors.New("invalid deterministic contract result") +) + +// MeteredRuntime is the consensus guard around a concrete deterministic +// contract engine. The engine may change, but fuel, declared state access and +// bounded output remain consensus invariants outside the interpreter. +type MeteredRuntime struct { + Inner Runtime +} + +func (m MeteredRuntime) ValidateModule(code []byte) error { + if m.Inner == nil { + return ErrInvalidModule + } + return m.Inner.ValidateModule(code) +} + +func (m MeteredRuntime) Execute(request Request) (Result, error) { + if m.Inner == nil { + return Result{}, ErrInvalidRequest + } + allowed, err := validateRequest(request) + if err != nil { + return Result{}, err + } + // Code is optional at this generic guard boundary so deterministic runtimes + // backed by pre-registered/native modules can still use the same consensus + // invariants. Runtimes that require code (Zephyr Script/WASM) reject an empty + // module inside Execute/ValidateModule themselves. + if len(request.Code) > 0 { + if err := m.Inner.ValidateModule(request.Code); err != nil { + return Result{}, err + } + } + result, err := m.Inner.Execute(request) + if err != nil { + return Result{}, err + } + if result.FuelUsed > request.FuelLimit { + return Result{}, ErrFuelExhausted + } + if len(result.ReturnData) > MaxReturnBytes || len(result.Events) > MaxEvents { + return Result{}, ErrInvalidResult + } + for _, event := range result.Events { + if len(event) > MaxEventBytes { + return Result{}, ErrInvalidResult + } + } + for id := range result.Writes { + write, declared := allowed[id] + if !declared || !write { + return Result{}, ErrUndeclaredAccess + } + } + return result, nil +} + +func validateRequest(request Request) (map[types.ObjectID]bool, error) { + if types.IsZero32([32]byte(request.ContractID)) || strings.TrimSpace(request.Entrypoint) == "" || len(request.Entrypoint) > 128 || + len(request.Code) > MaxModuleBytes || len(request.Arguments) > MaxArgumentsBytes || request.FuelLimit == 0 || len(request.Accesses) > MaxAccesses { + return nil, ErrInvalidRequest + } + allowed := make(map[types.ObjectID]bool, len(request.Accesses)) + for _, access := range request.Accesses { + if types.IsZero32([32]byte(access.ObjectID)) { + return nil, ErrInvalidRequest + } + if _, duplicate := allowed[access.ObjectID]; duplicate { + return nil, ErrInvalidRequest + } + allowed[access.ObjectID] = access.Write + } + for id := range request.ReadValues { + if _, declared := allowed[id]; !declared { + return nil, ErrUndeclaredAccess + } + } + return allowed, nil +} diff --git a/internal/v2/contracts/metered_test.go b/internal/v2/contracts/metered_test.go new file mode 100644 index 00000000..c516d2e8 --- /dev/null +++ b/internal/v2/contracts/metered_test.go @@ -0,0 +1,36 @@ +package contracts + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +type fakeRuntime struct { + result Result +} + +func (f fakeRuntime) ValidateModule(code []byte) error { return nil } +func (f fakeRuntime) Execute(request Request) (Result, error) { return f.result, nil } + +func TestMeteredRuntimeEnforcesFuelAndAccess(t *testing.T) { + contract := types.ContractID(types.HashBytes("contract", []byte("c"))) + writable := types.ObjectID(types.HashBytes("object", []byte("write"))) + request := Request{ContractID: contract, Entrypoint: "transfer", FuelLimit: 100, Accesses: []Access{{ObjectID: writable, Write: true}}} + + valid := MeteredRuntime{Inner: fakeRuntime{result: Result{FuelUsed: 80, Writes: map[types.ObjectID][]byte{writable: []byte("new")}}}} + if _, err := valid.Execute(request); err != nil { + t.Fatal(err) + } + + overFuel := MeteredRuntime{Inner: fakeRuntime{result: Result{FuelUsed: 101}}} + if _, err := overFuel.Execute(request); err != ErrFuelExhausted { + t.Fatalf("expected fuel rejection, got %v", err) + } + + undeclared := types.ObjectID(types.HashBytes("object", []byte("other"))) + badAccess := MeteredRuntime{Inner: fakeRuntime{result: Result{FuelUsed: 1, Writes: map[types.ObjectID][]byte{undeclared: []byte("x")}}}} + if _, err := badAccess.Execute(request); err != ErrUndeclaredAccess { + t.Fatalf("expected undeclared access rejection, got %v", err) + } +} diff --git a/internal/v2/contracts/ops.go b/internal/v2/contracts/ops.go new file mode 100644 index 00000000..fff94a01 --- /dev/null +++ b/internal/v2/contracts/ops.go @@ -0,0 +1,178 @@ +package contracts + +import ( + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrContractWire = errors.New("invalid contract operation wire payload") + +type StoredContract struct { + ID types.ContractID + Deployment Deployment +} + +func ParseDeployment(data []byte) (Deployment, error) { + r := codec.NewReader(data) + runtimeName, err := r.String(64) + if err != nil { + return Deployment{}, ErrContractWire + } + code, err := r.Bytes(MaxModuleBytes) + if err != nil { + return Deployment{}, ErrContractWire + } + abi, err := r.U16() + if err != nil { + return Deployment{}, ErrContractWire + } + authorityRaw, err := r.Fixed(32) + if err != nil { + return Deployment{}, ErrContractWire + } + var authority types.AccountID + copy(authority[:], authorityRaw) + initial, err := r.Bytes(MaxInitialStateBytes) + if err != nil { + return Deployment{}, ErrContractWire + } + pages, err := r.U32() + if err != nil || r.Done() != nil { + return Deployment{}, ErrContractWire + } + deployment := Deployment{Runtime: runtimeName, Code: code, ABI: abi, UpgradeAuthority: authority, InitialState: initial, MaxMemoryPages: pages} + if err := deployment.Validate(); err != nil { + return Deployment{}, err + } + return deployment, nil +} + +func (s StoredContract) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(s.ID)) { + return nil, ErrContractWire + } + deployment, err := s.Deployment.MarshalBinary() + if err != nil { + return nil, err + } + var w codec.Writer + w.Fixed(s.ID[:]) + w.Bytes(deployment) + return w.BytesCopy(), nil +} + +func ParseStoredContract(data []byte) (StoredContract, error) { + r := codec.NewReader(data) + idRaw, err := r.Fixed(32) + if err != nil { + return StoredContract{}, ErrContractWire + } + var id types.ContractID + copy(id[:], idRaw) + deploymentRaw, err := r.Bytes(MaxModuleBytes + MaxInitialStateBytes + 256) + if err != nil || r.Done() != nil { + return StoredContract{}, ErrContractWire + } + deployment, err := ParseDeployment(deploymentRaw) + if err != nil || types.IsZero32([32]byte(id)) { + return StoredContract{}, ErrContractWire + } + return StoredContract{ID: id, Deployment: deployment}, nil +} + +type Call struct { + ContractObject types.ObjectID + Entrypoint string + Arguments []byte + FuelLimit uint64 + Accesses []Access +} + +func (c Call) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(c.ContractObject)) || c.Entrypoint == "" || len(c.Entrypoint) > 128 || len(c.Arguments) > MaxArgumentsBytes || c.FuelLimit == 0 || len(c.Accesses) > MaxAccesses { + return nil, ErrContractWire + } + var w codec.Writer + w.Fixed(c.ContractObject[:]) + w.String(c.Entrypoint) + w.Bytes(c.Arguments) + w.U64(c.FuelLimit) + w.U32(uint32(len(c.Accesses))) + seen := make(map[types.ObjectID]struct{}, len(c.Accesses)) + for _, access := range c.Accesses { + if types.IsZero32([32]byte(access.ObjectID)) { + return nil, ErrContractWire + } + if _, ok := seen[access.ObjectID]; ok { + return nil, ErrContractWire + } + seen[access.ObjectID] = struct{}{} + w.Fixed(access.ObjectID[:]) + w.Bool(access.Write) + } + return w.BytesCopy(), nil +} + +func ParseCall(data []byte) (Call, error) { + r := codec.NewReader(data) + contractRaw, err := r.Fixed(32) + if err != nil { + return Call{}, ErrContractWire + } + var contractObject types.ObjectID + copy(contractObject[:], contractRaw) + entry, err := r.String(128) + if err != nil { + return Call{}, ErrContractWire + } + args, err := r.Bytes(MaxArgumentsBytes) + if err != nil { + return Call{}, ErrContractWire + } + fuel, err := r.U64() + if err != nil { + return Call{}, ErrContractWire + } + count, err := r.U32() + if err != nil || count > MaxAccesses { + return Call{}, ErrContractWire + } + accesses := make([]Access, int(count)) + for i := range accesses { + raw, err := r.Fixed(32) + if err != nil { + return Call{}, ErrContractWire + } + copy(accesses[i].ObjectID[:], raw) + accesses[i].Write, err = r.Bool() + if err != nil { + return Call{}, ErrContractWire + } + } + if r.Done() != nil { + return Call{}, ErrContractWire + } + call := Call{ContractObject: contractObject, Entrypoint: entry, Arguments: args, FuelLimit: fuel, Accesses: accesses} + if _, err := call.MarshalBinary(); err != nil { + return Call{}, err + } + return call, nil +} + +type Receipt struct { + ContractID types.ContractID + FuelUsed uint64 + ReturnHash types.Hash + EventRoot types.Hash +} + +func (r Receipt) MarshalBinary() []byte { + var w codec.Writer + w.Fixed(r.ContractID[:]) + w.U64(r.FuelUsed) + w.Fixed(r.ReturnHash[:]) + w.Fixed(r.EventRoot[:]) + return w.BytesCopy() +} diff --git a/internal/v2/contracts/script_runtime.go b/internal/v2/contracts/script_runtime.go new file mode 100644 index 00000000..debc472e --- /dev/null +++ b/internal/v2/contracts/script_runtime.go @@ -0,0 +1,175 @@ +package contracts + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "go.starlark.net/starlark" +) + +var ErrScriptRuntime = errors.New("zephyr script execution failed") + +type ScriptRuntime struct{} + +func (ScriptRuntime) ValidateModule(code []byte) error { + if !ValidateScriptModule(code) { + return ErrInvalidModule + } + thread := &starlark.Thread{Name: "zephyr-validate", Load: disabledLoad} + thread.SetMaxExecutionSteps(1_000_000) + if _, err := starlark.ExecFile(thread, "contract.star", string(code), validationPredeclared()); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidModule, err) + } + return nil +} + +func (ScriptRuntime) Execute(request Request) (Result, error) { + if request.Runtime != RuntimeZephyrScriptV1 || !ValidateScriptModule(request.Code) { + return Result{}, ErrInvalidModule + } + allowed := make(map[string]bool, len(request.Accesses)) + for _, access := range request.Accesses { + allowed[access.ObjectID.String()] = access.Write + } + writes := make(map[types.ObjectID][]byte) + events := make([][]byte, 0) + predeclared := emptyPredeclared() + predeclared["state_read"] = starlark.NewBuiltin("state_read", func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var id string + if err := starlark.UnpackArgs("state_read", args, kwargs, "id", &id); err != nil { + return nil, err + } + normalized, rawID, err := parseObjectID(id) + if err != nil { + return nil, err + } + if _, ok := allowed[normalized]; !ok { + return nil, ErrUndeclaredAccess + } + value, ok := request.ReadValues[rawID] + if !ok { + return starlark.None, nil + } + return starlark.Bytes(string(value)), nil + }) + predeclared["state_write"] = starlark.NewBuiltin("state_write", func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var id string + var value starlark.Value + if err := starlark.UnpackArgs("state_write", args, kwargs, "id", &id, "value", &value); err != nil { + return nil, err + } + normalized, rawID, err := parseObjectID(id) + if err != nil { + return nil, err + } + if !allowed[normalized] { + return nil, ErrUndeclaredAccess + } + bytes, err := valueBytes(value) + if err != nil || len(bytes) > MaxReturnBytes { + return nil, ErrInvalidResult + } + writes[rawID] = append([]byte(nil), bytes...) + return starlark.None, nil + }) + predeclared["emit"] = starlark.NewBuiltin("emit", func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var value starlark.Value + if err := starlark.UnpackArgs("emit", args, kwargs, "value", &value); err != nil { + return nil, err + } + bytes, err := valueBytes(value) + if err != nil || len(bytes) > MaxEventBytes || len(events) >= MaxEvents { + return nil, ErrInvalidResult + } + events = append(events, append([]byte(nil), bytes...)) + return starlark.None, nil + }) + predeclared["sha256"] = starlark.NewBuiltin("sha256", func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var value starlark.Value + if err := starlark.UnpackArgs("sha256", args, kwargs, "value", &value); err != nil { + return nil, err + } + bytes, err := valueBytes(value) + if err != nil { + return nil, err + } + hash := sha256.Sum256(bytes) + return starlark.Bytes(string(hash[:])), nil + }) + thread := &starlark.Thread{Name: "zephyr-contract", Load: disabledLoad} + thread.SetMaxExecutionSteps(request.FuelLimit) + globals, err := starlark.ExecFile(thread, "contract.star", string(request.Code), predeclared) + if err != nil { + if thread.ExecutionSteps() >= request.FuelLimit { + return Result{}, ErrFuelExhausted + } + return Result{}, fmt.Errorf("%w: %w", ErrScriptRuntime, err) + } + entry, ok := globals[request.Entrypoint] + if !ok { + return Result{}, fmt.Errorf("%w: missing entrypoint", ErrScriptRuntime) + } + callable, ok := entry.(starlark.Callable) + if !ok { + return Result{}, fmt.Errorf("%w: entrypoint is not callable", ErrScriptRuntime) + } + value, err := starlark.Call(thread, callable, starlark.Tuple{starlark.String(string(request.Arguments))}, nil) + if err != nil { + if thread.ExecutionSteps() >= request.FuelLimit { + return Result{}, ErrFuelExhausted + } + return Result{}, fmt.Errorf("%w: %w", ErrScriptRuntime, err) + } + returned, err := valueBytes(value) + if value == starlark.None { + returned = nil + err = nil + } + if err != nil || len(returned) > MaxReturnBytes { + return Result{}, ErrInvalidResult + } + outWrites := make(map[types.ObjectID][]byte, len(writes)) + for id, value := range writes { + outWrites[id] = value + } + return Result{ReturnData: returned, FuelUsed: thread.ExecutionSteps(), Writes: outWrites, Events: events}, nil +} + +func emptyPredeclared() starlark.StringDict { return starlark.StringDict{} } + +func validationPredeclared() starlark.StringDict { + stub := func(_ *starlark.Thread, _ *starlark.Builtin, _ starlark.Tuple, _ []starlark.Tuple) (starlark.Value, error) { + return starlark.None, nil + } + return starlark.StringDict{"state_read": starlark.NewBuiltin("state_read", stub), "state_write": starlark.NewBuiltin("state_write", stub), "emit": starlark.NewBuiltin("emit", stub), "sha256": starlark.NewBuiltin("sha256", stub)} +} + +func disabledLoad(_ *starlark.Thread, module string) (starlark.StringDict, error) { + return nil, fmt.Errorf("module loading disabled: %s", module) +} + +func parseObjectID(value string) (string, types.ObjectID, error) { + var id types.ObjectID + normalized := strings.ToLower(strings.TrimSpace(value)) + raw, err := hex.DecodeString(normalized) + if err != nil || len(raw) != len(id) { + return "", id, ErrUndeclaredAccess + } + copy(id[:], raw) + return normalized, id, nil +} + +func valueBytes(value starlark.Value) ([]byte, error) { + switch v := value.(type) { + case starlark.Bytes: + return []byte(string(v)), nil + case starlark.String: + return []byte(string(v)), nil + default: + return nil, ErrInvalidResult + } +} diff --git a/internal/v2/contracts/script_runtime_test.go b/internal/v2/contracts/script_runtime_test.go new file mode 100644 index 00000000..ffbb83bd --- /dev/null +++ b/internal/v2/contracts/script_runtime_test.go @@ -0,0 +1,80 @@ +package contracts + +import ( + "errors" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestScriptRuntimeDeterministicStateAndEvents(t *testing.T) { + contract := types.ContractID(types.HashBytes("contract", []byte("script"))) + stateID := types.ObjectID(types.HashBytes("state", []byte("one"))) + code := []byte(` +def run(arg): + before = state_read("` + stateID.String() + `") + state_write("` + stateID.String() + `", arg) + emit(sha256(arg)) + return before +`) + request := Request{ + ContractID: contract, Runtime: RuntimeZephyrScriptV1, Code: code, Entrypoint: "run", Arguments: []byte("new"), + Accesses: []Access{{ObjectID: stateID, Write: true}}, ReadValues: map[types.ObjectID][]byte{stateID: []byte("old")}, FuelLimit: 100_000, + } + runtime := MeteredRuntime{Inner: ScriptRuntime{}} + first, err := runtime.Execute(request) + if err != nil { + t.Fatal(err) + } + second, err := runtime.Execute(request) + if err != nil { + t.Fatal(err) + } + if string(first.ReturnData) != "old" || string(first.Writes[stateID]) != "new" || len(first.Events) != 1 { + t.Fatalf("unexpected result: %+v", first) + } + if first.FuelUsed != second.FuelUsed || string(first.ReturnData) != string(second.ReturnData) || string(first.Events[0]) != string(second.Events[0]) { + t.Fatal("same contract request produced nondeterministic result") + } +} + +func TestScriptRuntimeRejectsUndeclaredWrite(t *testing.T) { + contract := types.ContractID(types.HashBytes("contract", []byte("script-write"))) + stateID := types.ObjectID(types.HashBytes("state", []byte("write"))) + code := []byte(` +def run(arg): + state_write("` + stateID.String() + `", arg) + return arg +`) + _, err := (MeteredRuntime{Inner: ScriptRuntime{}}).Execute(Request{ + ContractID: contract, Runtime: RuntimeZephyrScriptV1, Code: code, Entrypoint: "run", Arguments: []byte("x"), + Accesses: []Access{{ObjectID: stateID, Write: false}}, FuelLimit: 100_000, + }) + if !errors.Is(err, ErrUndeclaredAccess) { + t.Fatalf("expected undeclared access error, got %v", err) + } +} + +func TestScriptRuntimeFuelExhaustion(t *testing.T) { + contract := types.ContractID(types.HashBytes("contract", []byte("script-fuel"))) + code := []byte(` +def run(arg): + x = 0 + for i in range(1000000): + x += i + return arg +`) + _, err := (MeteredRuntime{Inner: ScriptRuntime{}}).Execute(Request{ + ContractID: contract, Runtime: RuntimeZephyrScriptV1, Code: code, Entrypoint: "run", Arguments: []byte("x"), FuelLimit: 100, + }) + if !errors.Is(err, ErrFuelExhausted) { + t.Fatalf("expected fuel exhaustion, got %v", err) + } +} + +func TestScriptRuntimeDisablesLoad(t *testing.T) { + code := []byte("load(\"remote.star\", \"x\")\ndef run(arg):\n return arg\n") + if err := (ScriptRuntime{}).ValidateModule(code); err == nil { + t.Fatal("expected module load to be rejected") + } +} diff --git a/internal/v2/da/da.go b/internal/v2/da/da.go new file mode 100644 index 00000000..08e7e37d --- /dev/null +++ b/internal/v2/da/da.go @@ -0,0 +1,73 @@ +package da + +import ( + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrInvalidSample = errors.New("invalid data-availability sample") + +type Commitment struct { + Root types.Hash + ChunkCount uint32 + DataShards uint16 + ParityShards uint16 + OriginalSize uint64 +} + +type Sample struct { + Index uint32 + ChunkHash types.Hash + Proof merkle.Proof +} + +type Encoder interface { + Encode(data []byte, dataShards, parityShards uint16) ([][]byte, error) +} + +func CommitChunks(chunks [][]byte, dataShards, parityShards uint16, originalSize uint64) (Commitment, []Sample, error) { + if len(chunks) == 0 || int(dataShards)+int(parityShards) != len(chunks) || dataShards == 0 { + return Commitment{}, nil, ErrInvalidSample + } + leaves := make([]types.Hash, len(chunks)) + hashes := make([]types.Hash, len(chunks)) + for i, chunk := range chunks { + hashes[i] = types.Hash(codec.DomainHash("zephyr/da/chunk/v2", chunk)) + var w codec.Writer + w.U32(uint32(i)) + w.Fixed(hashes[i][:]) + leaves[i] = merkle.Leaf("da-chunk", w.BytesCopy()) + } + root := merkle.Root(leaves) + samples := make([]Sample, len(chunks)) + for i := range chunks { + proof, err := merkle.BuildProof(leaves, i) + if err != nil { + return Commitment{}, nil, err + } + samples[i] = Sample{Index: uint32(i), ChunkHash: hashes[i], Proof: proof} + } + return Commitment{ + Root: root, ChunkCount: uint32(len(chunks)), DataShards: dataShards, + ParityShards: parityShards, OriginalSize: originalSize, + }, samples, nil +} + +func VerifySample(commitment Commitment, sample Sample, chunk []byte) bool { + if commitment.ChunkCount == 0 || sample.Index >= commitment.ChunkCount || + sample.Proof.Index != sample.Index || sample.Proof.LeafCount != commitment.ChunkCount { + return false + } + chunkHash := types.Hash(codec.DomainHash("zephyr/da/chunk/v2", chunk)) + if chunkHash != sample.ChunkHash { + return false + } + var w codec.Writer + w.U32(sample.Index) + w.Fixed(chunkHash[:]) + leaf := merkle.Leaf("da-chunk", w.BytesCopy()) + return merkle.Verify(commitment.Root, leaf, sample.Proof) +} diff --git a/internal/v2/da/da_test.go b/internal/v2/da/da_test.go new file mode 100644 index 00000000..02a57c51 --- /dev/null +++ b/internal/v2/da/da_test.go @@ -0,0 +1,17 @@ +package da + +import "testing" + +func TestSampleVerification(t *testing.T) { + chunks := [][]byte{[]byte("a"), []byte("b"), []byte("c"), []byte("d")} + commitment, samples, err := CommitChunks(chunks, 2, 2, 4) + if err != nil { + t.Fatal(err) + } + if !VerifySample(commitment, samples[2], chunks[2]) { + t.Fatal("sample failed") + } + if VerifySample(commitment, samples[2], []byte("tampered")) { + t.Fatal("tampered sample verified") + } +} diff --git a/internal/v2/da/erasure.go b/internal/v2/da/erasure.go new file mode 100644 index 00000000..a9815934 --- /dev/null +++ b/internal/v2/da/erasure.go @@ -0,0 +1,117 @@ +package da + +import ( + "bytes" + "errors" + + "github.com/klauspost/reedsolomon" +) + +const ( + MaxDataShards = 128 + MaxParityShards = 128 + MaxBlobBytes = 64 << 20 +) + +var ( + ErrInvalidErasureConfig = errors.New("invalid data-availability erasure configuration") + ErrReconstruction = errors.New("data-availability reconstruction failed") +) + +type ReedSolomonEncoder struct{} + +func (ReedSolomonEncoder) Encode(data []byte, dataShards, parityShards uint16) ([][]byte, error) { + if err := validateErasureConfig(len(data), dataShards, parityShards); err != nil { + return nil, err + } + encoder, err := reedsolomon.New(int(dataShards), int(parityShards)) + if err != nil { + return nil, ErrInvalidErasureConfig + } + shards, err := encoder.Split(data) + if err != nil { + return nil, ErrInvalidErasureConfig + } + if err := encoder.Encode(shards); err != nil { + return nil, ErrInvalidErasureConfig + } + return cloneChunks(shards), nil +} + +func EncodeBlob(data []byte, dataShards, parityShards uint16) (Commitment, [][]byte, []Sample, error) { + chunks, err := (ReedSolomonEncoder{}).Encode(data, dataShards, parityShards) + if err != nil { + return Commitment{}, nil, nil, err + } + commitment, samples, err := CommitChunks(chunks, dataShards, parityShards, uint64(len(data))) + if err != nil { + return Commitment{}, nil, nil, err + } + return commitment, chunks, samples, nil +} + +func ReconstructBlob(commitment Commitment, chunks [][]byte, samples []Sample) ([]byte, error) { + if err := validateCommitment(commitment); err != nil || len(chunks) != int(commitment.ChunkCount) || len(samples) != int(commitment.ChunkCount) { + return nil, ErrReconstruction + } + working := cloneChunks(chunks) + valid := 0 + for i := range working { + if working[i] == nil { + continue + } + if samples[i].Index != uint32(i) || !VerifySample(commitment, samples[i], working[i]) { + working[i] = nil + continue + } + valid++ + } + if valid < int(commitment.DataShards) { + return nil, ErrReconstruction + } + encoder, err := reedsolomon.New(int(commitment.DataShards), int(commitment.ParityShards)) + if err != nil { + return nil, ErrReconstruction + } + if err := encoder.Reconstruct(working); err != nil { + return nil, ErrReconstruction + } + for i := range working { + if !VerifySample(commitment, samples[i], working[i]) { + return nil, ErrReconstruction + } + } + var out bytes.Buffer + if err := encoder.Join(&out, working, int(commitment.OriginalSize)); err != nil { + return nil, ErrReconstruction + } + return out.Bytes(), nil +} + +func validateErasureConfig(size int, dataShards, parityShards uint16) error { + if size <= 0 || size > MaxBlobBytes || dataShards == 0 || parityShards == 0 || + dataShards > MaxDataShards || parityShards > MaxParityShards || int(dataShards)+int(parityShards) > 256 { + return ErrInvalidErasureConfig + } + return nil +} + +func validateCommitment(commitment Commitment) error { + if commitment.OriginalSize == 0 || commitment.OriginalSize > MaxBlobBytes || + commitment.DataShards == 0 || commitment.ParityShards == 0 || + commitment.DataShards > MaxDataShards || commitment.ParityShards > MaxParityShards || + uint32(commitment.DataShards)+uint32(commitment.ParityShards) != commitment.ChunkCount { + return ErrInvalidErasureConfig + } + return nil +} + +func cloneChunks(in [][]byte) [][]byte { + out := make([][]byte, len(in)) + for i, chunk := range in { + if chunk != nil { + out[i] = append([]byte(nil), chunk...) + } + } + return out +} diff --git a/internal/v2/da/erasure_test.go b/internal/v2/da/erasure_test.go new file mode 100644 index 00000000..73f86269 --- /dev/null +++ b/internal/v2/da/erasure_test.go @@ -0,0 +1,59 @@ +package da + +import ( + "bytes" + "testing" +) + +func TestErasureCodingReconstructsMissingAndCorruptChunks(t *testing.T) { + payload := bytes.Repeat([]byte("zephyr-data-availability/"), 1000) + commitment, chunks, samples, err := EncodeBlob(payload, 8, 4) + if err != nil { + t.Fatal(err) + } + if commitment.ChunkCount != 12 || commitment.OriginalSize != uint64(len(payload)) { + t.Fatalf("unexpected commitment: %+v", commitment) + } + working := cloneChunks(chunks) + working[1] = nil + working[5] = nil + working[10][0] ^= 0xff + recovered, err := ReconstructBlob(commitment, working, samples) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(recovered, payload) { + t.Fatal("reconstructed payload differs") + } +} + +func TestErasureCodingRejectsInsufficientAuthenticatedChunks(t *testing.T) { + payload := bytes.Repeat([]byte("z"), 8192) + commitment, chunks, samples, err := EncodeBlob(payload, 4, 2) + if err != nil { + t.Fatal(err) + } + working := cloneChunks(chunks) + working[0] = nil + working[1] = nil + working[2] = nil + if _, err := ReconstructBlob(commitment, working, samples); err != ErrReconstruction { + t.Fatalf("expected reconstruction failure, got %v", err) + } +} + +func TestCitizenSampleDetectsWithholdingAndTamper(t *testing.T) { + payload := []byte("citizen bounded sample") + commitment, chunks, samples, err := EncodeBlob(payload, 2, 2) + if err != nil { + t.Fatal(err) + } + if !VerifySample(commitment, samples[3], chunks[3]) { + t.Fatal("valid citizen sample rejected") + } + tampered := append([]byte(nil), chunks[3]...) + tampered[0] ^= 1 + if VerifySample(commitment, samples[3], tampered) { + t.Fatal("tampered citizen sample accepted") + } +} diff --git a/internal/v2/economics/compute_feedback.go b/internal/v2/economics/compute_feedback.go new file mode 100644 index 00000000..092bb4fb --- /dev/null +++ b/internal/v2/economics/compute_feedback.go @@ -0,0 +1,119 @@ +package economics + +import "errors" + +var ErrComputeFeedback = errors.New("invalid Zephyr compute feedback policy") + +type ComputeFeedbackMode uint8 + +const ( + ComputeFeedbackObserveOnly ComputeFeedbackMode = iota + ComputeFeedbackRewardRouting + ComputeFeedbackMonetaryBand +) + +type ComputeFeedbackPolicy struct { + Mode ComputeFeedbackMode + BaseComputeRewardShareBps uint32 + MinComputeRewardShareBps uint32 + MaxComputeRewardShareBps uint32 + RewardSensitivityBps uint32 + MonetarySensitivityBps uint32 + MaxInflationCorrectionBps uint32 +} + +type ComputeFeedbackDecision struct { + Shadow bool + Mode ComputeFeedbackMode + ScarcityScoreBps int32 + ScarcityReliable bool + ComputeRewardShareBps uint32 + SuggestedComputeIncentiveMint uint64 + InflationCorrectionBps int32 + BaseTargetInflationBps uint32 + SuggestedTargetInflationBps uint32 + SuggestedNetIssuance uint64 + SuggestedGrossMint uint64 +} + +func DefaultComputeFeedbackPolicy(mode ComputeFeedbackMode) ComputeFeedbackPolicy { + return ComputeFeedbackPolicy{ + Mode: mode, + BaseComputeRewardShareBps: 1_000, + MinComputeRewardShareBps: 0, + MaxComputeRewardShareBps: 3_000, + RewardSensitivityBps: 2_000, + MonetarySensitivityBps: 25, + MaxInflationCorrectionBps: 25, + } +} + +// EvaluateComputeFeedback simulates how ZCSI could affect incentive routing and, +// only in mode C, a narrow monetary band. It never mutates supply and all output +// remains shadow until governance/protocol activation gates are satisfied. +func EvaluateComputeFeedback(base MonetaryDecision, metrics MonetaryMetrics, monetary MonetaryPolicy, scarcity ComputeScarcitySnapshot, policy ComputeFeedbackPolicy) (ComputeFeedbackDecision, error) { + if !base.Shadow || policy.Mode > ComputeFeedbackMonetaryBand || policy.BaseComputeRewardShareBps > BasisPoints || + policy.MinComputeRewardShareBps > policy.BaseComputeRewardShareBps || policy.BaseComputeRewardShareBps > policy.MaxComputeRewardShareBps || + policy.MaxComputeRewardShareBps > BasisPoints || policy.RewardSensitivityBps > BasisPoints || + policy.MonetarySensitivityBps > BasisPoints || policy.MaxInflationCorrectionBps > BasisPoints { + return ComputeFeedbackDecision{}, ErrComputeFeedback + } + + decision := ComputeFeedbackDecision{ + Shadow: true, + Mode: policy.Mode, + ScarcityScoreBps: scarcity.ScoreBps, + ScarcityReliable: scarcity.Reliable, + ComputeRewardShareBps: policy.BaseComputeRewardShareBps, + BaseTargetInflationBps: base.TargetInflationBps, + SuggestedTargetInflationBps: base.TargetInflationBps, + SuggestedNetIssuance: base.NetIssuanceTarget, + SuggestedGrossMint: base.GrossMintTarget, + } + if !scarcity.Reliable || policy.Mode == ComputeFeedbackObserveOnly { + return decisionWithComputeBudget(decision), nil + } + + rewardDelta := int64(scarcity.ScoreBps) * int64(policy.RewardSensitivityBps) / int64(BasisPoints) + rewardShare := int64(policy.BaseComputeRewardShareBps) + rewardDelta + if rewardShare < int64(policy.MinComputeRewardShareBps) { + rewardShare = int64(policy.MinComputeRewardShareBps) + } + if rewardShare > int64(policy.MaxComputeRewardShareBps) { + rewardShare = int64(policy.MaxComputeRewardShareBps) + } + decision.ComputeRewardShareBps = uint32(rewardShare) + + if policy.Mode != ComputeFeedbackMonetaryBand { + return decisionWithComputeBudget(decision), nil + } + correction := int64(scarcity.ScoreBps) * int64(policy.MonetarySensitivityBps) / int64(BasisPoints) + maxCorrection := int64(policy.MaxInflationCorrectionBps) + if correction > maxCorrection { + correction = maxCorrection + } + if correction < -maxCorrection { + correction = -maxCorrection + } + decision.InflationCorrectionBps = int32(correction) + decision.SuggestedTargetInflationBps = clampTarget(int64(base.TargetInflationBps)+correction, monetary.MinInflationBps, monetary.MaxInflationBps) + net, err := epochIssuance(metrics.Supply, decision.SuggestedTargetInflationBps, monetary.EpochsPerYear) + if err != nil { + return ComputeFeedbackDecision{}, err + } + gross, err := addUint64(net, metrics.BurnedThisEpoch) + if err != nil { + return ComputeFeedbackDecision{}, err + } + decision.SuggestedNetIssuance = net + decision.SuggestedGrossMint = gross + return decisionWithComputeBudget(decision), nil +} + +func decisionWithComputeBudget(decision ComputeFeedbackDecision) ComputeFeedbackDecision { + budget, err := shareBps(decision.SuggestedNetIssuance, decision.ComputeRewardShareBps) + if err == nil { + decision.SuggestedComputeIncentiveMint = budget + } + return decision +} diff --git a/internal/v2/economics/compute_index.go b/internal/v2/economics/compute_index.go new file mode 100644 index 00000000..ae923675 --- /dev/null +++ b/internal/v2/economics/compute_index.go @@ -0,0 +1,153 @@ +package economics + +import ( + "errors" + "math/big" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" +) + +const ( + BasisPoints uint32 = 10_000 + PriceScaleQ9 uint64 = 1_000_000_000 +) + +var ErrComputeIndex = errors.New("invalid Zephyr compute price index input") + +type ComputeIndexConfig struct { + WeightsBps [compute.WorkClassCount]uint32 + MinSamplesPerClass uint32 + MinCoverageBps uint32 + EWMABps uint32 +} + +type ComputeIndexSnapshot struct { + Epoch uint64 + ClassPriceQ9 [compute.WorkClassCount]uint64 + ClassSamples [compute.WorkClassCount]uint64 + BasketPriceQ9 uint64 + CoverageBps uint32 + Reliable bool + TotalSamples uint64 +} + +func BuildComputeIndex(epoch uint64, observations []compute.VerifiedWork, prior ComputeIndexSnapshot, cfg ComputeIndexConfig) (ComputeIndexSnapshot, error) { + if epoch == 0 || cfg.MinSamplesPerClass == 0 || cfg.MinCoverageBps > BasisPoints || cfg.EWMABps > BasisPoints { + return ComputeIndexSnapshot{}, ErrComputeIndex + } + var totalWeight uint64 + for class := compute.WorkClass(1); class < compute.WorkClassCount; class++ { + totalWeight += uint64(cfg.WeightsBps[class]) + } + if totalWeight == 0 { + return ComputeIndexSnapshot{}, ErrComputeIndex + } + + prices := make([][]uint64, int(compute.WorkClassCount)) + for _, observation := range observations { + if observation.Class <= compute.WorkUnknown || observation.Class >= compute.WorkClassCount || observation.Units == 0 || observation.PaidZPH == 0 { + return ComputeIndexSnapshot{}, ErrComputeIndex + } + price, err := scaledRatio(observation.PaidZPH, PriceScaleQ9, observation.Units) + if err != nil { + return ComputeIndexSnapshot{}, err + } + prices[observation.Class] = append(prices[observation.Class], price) + } + + out := ComputeIndexSnapshot{Epoch: epoch} + basket := new(big.Int) + var activeWeight uint64 + for class := compute.WorkClass(1); class < compute.WorkClassCount; class++ { + classPrices := prices[class] + out.ClassSamples[class] = uint64(len(classPrices)) + out.TotalSamples += uint64(len(classPrices)) + if uint32(len(classPrices)) < cfg.MinSamplesPerClass || cfg.WeightsBps[class] == 0 { + continue + } + sort.Slice(classPrices, func(i, j int) bool { return classPrices[i] < classPrices[j] }) + median := medianUint64(classPrices) + current, err := ewma(prior.ClassPriceQ9[class], median, cfg.EWMABps) + if err != nil { + return ComputeIndexSnapshot{}, err + } + out.ClassPriceQ9[class] = current + weight := uint64(cfg.WeightsBps[class]) + activeWeight += weight + term := new(big.Int).Mul(new(big.Int).SetUint64(current), new(big.Int).SetUint64(weight)) + basket.Add(basket, term) + } + if activeWeight == 0 { + return out, nil + } + basket.Div(basket, new(big.Int).SetUint64(activeWeight)) + if !basket.IsUint64() { + return ComputeIndexSnapshot{}, ErrComputeIndex + } + out.BasketPriceQ9 = basket.Uint64() + coverage := activeWeight * uint64(BasisPoints) / totalWeight + if coverage > uint64(BasisPoints) { + coverage = uint64(BasisPoints) + } + out.CoverageBps = uint32(coverage) + out.Reliable = out.CoverageBps >= cfg.MinCoverageBps + return out, nil +} + +func ComputePriceTrendBps(current, prior uint64) int32 { + if current == 0 || prior == 0 || current == prior { + return 0 + } + delta := new(big.Int).Sub(new(big.Int).SetUint64(current), new(big.Int).SetUint64(prior)) + delta.Mul(delta, new(big.Int).SetUint64(uint64(BasisPoints))) + delta.Quo(delta, new(big.Int).SetUint64(prior)) + limit := big.NewInt(int64(BasisPoints)) + negativeLimit := new(big.Int).Neg(new(big.Int).Set(limit)) + if delta.Cmp(limit) > 0 { + return int32(BasisPoints) + } + if delta.Cmp(negativeLimit) < 0 { + return -int32(BasisPoints) + } + return int32(delta.Int64()) +} + +func scaledRatio(value, scale, divisor uint64) (uint64, error) { + if divisor == 0 { + return 0, ErrComputeIndex + } + numerator := new(big.Int).Mul(new(big.Int).SetUint64(value), new(big.Int).SetUint64(scale)) + numerator.Quo(numerator, new(big.Int).SetUint64(divisor)) + if !numerator.IsUint64() { + return 0, ErrComputeIndex + } + return numerator.Uint64(), nil +} + +func medianUint64(values []uint64) uint64 { + middle := len(values) / 2 + if len(values)%2 == 1 { + return values[middle] + } + low := values[middle-1] + high := values[middle] + return low + (high-low)/2 +} + +func ewma(prior, current uint64, alphaBps uint32) (uint64, error) { + if prior == 0 || alphaBps == BasisPoints { + return current, nil + } + if alphaBps == 0 { + return prior, nil + } + left := new(big.Int).Mul(new(big.Int).SetUint64(prior), new(big.Int).SetUint64(uint64(BasisPoints-alphaBps))) + right := new(big.Int).Mul(new(big.Int).SetUint64(current), new(big.Int).SetUint64(uint64(alphaBps))) + left.Add(left, right) + left.Quo(left, new(big.Int).SetUint64(uint64(BasisPoints))) + if !left.IsUint64() { + return 0, ErrComputeIndex + } + return left.Uint64(), nil +} diff --git a/internal/v2/economics/compute_index_test.go b/internal/v2/economics/compute_index_test.go new file mode 100644 index 00000000..370c2d78 --- /dev/null +++ b/internal/v2/economics/compute_index_test.go @@ -0,0 +1,75 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestComputeIndexUsesVerifiedSettlementsAndCoverage(t *testing.T) { + cfg := ComputeIndexConfig{MinSamplesPerClass: 2, MinCoverageBps: 10_000, EWMABps: 10_000} + cfg.WeightsBps[compute.WorkCPUGeneral] = 5_000 + cfg.WeightsBps[compute.WorkTensorAI] = 5_000 + observations := []compute.VerifiedWork{ + verifiedWork(compute.WorkCPUGeneral, 100, 200), + verifiedWork(compute.WorkCPUGeneral, 100, 400), + verifiedWork(compute.WorkTensorAI, 100, 600), + verifiedWork(compute.WorkTensorAI, 100, 1_000), + } + snapshot, err := BuildComputeIndex(1, observations, ComputeIndexSnapshot{}, cfg) + if err != nil { + t.Fatal(err) + } + if !snapshot.Reliable || snapshot.CoverageBps != 10_000 || snapshot.TotalSamples != 4 { + t.Fatalf("unexpected index coverage: %#v", snapshot) + } + // CPU median is 3 atomic ZPH/work unit; tensor median is 8. + // With equal basket weights the deterministic midpoint is 5.5. + if snapshot.ClassPriceQ9[compute.WorkCPUGeneral] != 3*PriceScaleQ9 || + snapshot.ClassPriceQ9[compute.WorkTensorAI] != 8*PriceScaleQ9 || + snapshot.BasketPriceQ9 != 11*PriceScaleQ9/2 { + t.Fatalf("unexpected compute prices: %#v", snapshot) + } +} + +func TestComputeIndexRequiresEnoughVerifiedClasses(t *testing.T) { + cfg := ComputeIndexConfig{MinSamplesPerClass: 2, MinCoverageBps: 7_500, EWMABps: 10_000} + cfg.WeightsBps[compute.WorkCPUGeneral] = 5_000 + cfg.WeightsBps[compute.WorkTensorAI] = 5_000 + observations := []compute.VerifiedWork{ + verifiedWork(compute.WorkCPUGeneral, 100, 200), + verifiedWork(compute.WorkCPUGeneral, 100, 300), + } + snapshot, err := BuildComputeIndex(1, observations, ComputeIndexSnapshot{}, cfg) + if err != nil { + t.Fatal(err) + } + if snapshot.Reliable || snapshot.CoverageBps != 5_000 { + t.Fatalf("under-covered index must remain shadow/unreliable: %#v", snapshot) + } +} + +func TestComputePriceTrendIsBounded(t *testing.T) { + if got := ComputePriceTrendBps(300, 100); got != 10_000 { + t.Fatalf("expected positive clamp, got %d", got) + } + if got := ComputePriceTrendBps(10, 100); got != -9_000 { + t.Fatalf("unexpected negative trend %d", got) + } +} + +func verifiedWork(class compute.WorkClass, units, paid uint64) compute.VerifiedWork { + var jobID types.JobID + var root types.Hash + jobID[0] = byte(class) + root[0] = byte(class) + return compute.VerifiedWork{ + JobID: jobID, + Class: class, + Units: units, + PaidZPH: paid, + Verification: compute.VerificationReplicated, + ResultRoot: root, + } +} diff --git a/internal/v2/economics/compute_scarcity.go b/internal/v2/economics/compute_scarcity.go new file mode 100644 index 00000000..970d40d0 --- /dev/null +++ b/internal/v2/economics/compute_scarcity.go @@ -0,0 +1,163 @@ +package economics + +import ( + "errors" + "math/big" +) + +var ErrComputeScarcity = errors.New("invalid Zephyr compute scarcity input") + +type ComputeScarcityConfig struct { + DemandSupplyWeightBps uint32 + PriceTrendWeightBps uint32 + BacklogWeightBps uint32 + UtilizationWeightBps uint32 + FulfillmentWeightBps uint32 + UtilizationTargetBps uint32 + MinDemandUnits uint64 + MinSupplyUnits uint64 + MaxAbsScoreBps uint32 +} + +type ComputeMarketMetrics struct { + EscrowBackedDemandUnits uint64 + VerifiedSupplyUnits uint64 + VerifiedSupplyReliable bool + BacklogUnits uint64 + FulfilledUnits uint64 + UtilizationBps uint32 + ComputePriceTrendBps int32 + ComputeIndexReliable bool +} + +type ComputeScarcitySnapshot struct { + Epoch uint64 + DemandSupplyPressureBps int32 + PriceTrendPressureBps int32 + BacklogPressureBps int32 + UtilizationPressureBps int32 + FulfillmentPressureBps int32 + ScoreBps int32 + Reliable bool +} + +func DefaultComputeScarcityConfig() ComputeScarcityConfig { + return ComputeScarcityConfig{ + DemandSupplyWeightBps: 3_000, + PriceTrendWeightBps: 2_000, + BacklogWeightBps: 2_000, + UtilizationWeightBps: 1_500, + FulfillmentWeightBps: 1_500, + UtilizationTargetBps: 7_000, + MinDemandUnits: 1_000, + MinSupplyUnits: 1_000, + MaxAbsScoreBps: 10_000, + } +} + +// BuildComputeScarcity calculates the Zephyr Compute Scarcity Index (ZCSI). +// Demand must represent standardized, escrow-backed work. Supply must represent +// standardized, benchmarked and collateralized capacity. Advertised prices or +// self-reported peak FLOPS are not valid inputs. An unauthenticated supply can +// still be observed as telemetry, but it can never make ZCSI reliable. +func BuildComputeScarcity(epoch uint64, metrics ComputeMarketMetrics, cfg ComputeScarcityConfig) (ComputeScarcitySnapshot, error) { + if epoch == 0 || cfg.UtilizationTargetBps > BasisPoints || cfg.MaxAbsScoreBps == 0 || cfg.MaxAbsScoreBps > BasisPoints || + metrics.UtilizationBps > BasisPoints || metrics.BacklogUnits > metrics.EscrowBackedDemandUnits || + metrics.FulfilledUnits > metrics.EscrowBackedDemandUnits { + return ComputeScarcitySnapshot{}, ErrComputeScarcity + } + weights := []uint32{ + cfg.DemandSupplyWeightBps, + cfg.PriceTrendWeightBps, + cfg.BacklogWeightBps, + cfg.UtilizationWeightBps, + cfg.FulfillmentWeightBps, + } + var totalWeight uint64 + for _, weight := range weights { + if weight > BasisPoints { + return ComputeScarcitySnapshot{}, ErrComputeScarcity + } + totalWeight += uint64(weight) + } + if totalWeight == 0 { + return ComputeScarcitySnapshot{}, ErrComputeScarcity + } + + out := ComputeScarcitySnapshot{Epoch: epoch} + out.DemandSupplyPressureBps = signedRatioPressure(metrics.EscrowBackedDemandUnits, metrics.VerifiedSupplyUnits) + out.BacklogPressureBps = unsignedRatioPressure(metrics.BacklogUnits, metrics.EscrowBackedDemandUnits) + out.UtilizationPressureBps = clampSignedBps(int64(metrics.UtilizationBps) - int64(cfg.UtilizationTargetBps)) + fulfilledBps := unsignedRatioPressure(metrics.FulfilledUnits, metrics.EscrowBackedDemandUnits) + out.FulfillmentPressureBps = int32(BasisPoints) - fulfilledBps + if metrics.EscrowBackedDemandUnits == 0 { + out.FulfillmentPressureBps = 0 + } + if metrics.ComputeIndexReliable { + out.PriceTrendPressureBps = clampSignedBps(int64(metrics.ComputePriceTrendBps)) + } + + weighted := int64(out.DemandSupplyPressureBps)*int64(cfg.DemandSupplyWeightBps) + + int64(out.BacklogPressureBps)*int64(cfg.BacklogWeightBps) + + int64(out.UtilizationPressureBps)*int64(cfg.UtilizationWeightBps) + + int64(out.FulfillmentPressureBps)*int64(cfg.FulfillmentWeightBps) + effectiveWeight := totalWeight + if metrics.ComputeIndexReliable { + weighted += int64(out.PriceTrendPressureBps) * int64(cfg.PriceTrendWeightBps) + } else { + effectiveWeight -= uint64(cfg.PriceTrendWeightBps) + } + if effectiveWeight == 0 { + return ComputeScarcitySnapshot{}, ErrComputeScarcity + } + out.ScoreBps = clampSigned(int64(weighted)/int64(effectiveWeight), int32(cfg.MaxAbsScoreBps)) + out.Reliable = metrics.VerifiedSupplyReliable && + metrics.EscrowBackedDemandUnits >= cfg.MinDemandUnits && + metrics.VerifiedSupplyUnits >= cfg.MinSupplyUnits + return out, nil +} + +func signedRatioPressure(demand, supply uint64) int32 { + if demand == 0 && supply == 0 { + return 0 + } + if supply == 0 { + return int32(BasisPoints) + } + delta := new(big.Int).Sub(new(big.Int).SetUint64(demand), new(big.Int).SetUint64(supply)) + delta.Mul(delta, new(big.Int).SetUint64(uint64(BasisPoints))) + delta.Quo(delta, new(big.Int).SetUint64(supply)) + if delta.Cmp(big.NewInt(int64(BasisPoints))) > 0 { + return int32(BasisPoints) + } + if delta.Cmp(big.NewInt(-int64(BasisPoints))) < 0 { + return -int32(BasisPoints) + } + return int32(delta.Int64()) +} + +func unsignedRatioPressure(value, total uint64) int32 { + if total == 0 { + return 0 + } + ratio := new(big.Int).Mul(new(big.Int).SetUint64(value), new(big.Int).SetUint64(uint64(BasisPoints))) + ratio.Quo(ratio, new(big.Int).SetUint64(total)) + if ratio.Cmp(new(big.Int).SetUint64(uint64(BasisPoints))) > 0 { + return int32(BasisPoints) + } + return int32(ratio.Int64()) +} + +func clampSignedBps(value int64) int32 { + return clampSigned(value, int32(BasisPoints)) +} + +func clampSigned(value int64, maximum int32) int32 { + if value > int64(maximum) { + return maximum + } + if value < -int64(maximum) { + return -maximum + } + return int32(value) +} diff --git a/internal/v2/economics/compute_scarcity_test.go b/internal/v2/economics/compute_scarcity_test.go new file mode 100644 index 00000000..7476328b --- /dev/null +++ b/internal/v2/economics/compute_scarcity_test.go @@ -0,0 +1,142 @@ +package economics + +import "testing" + +func TestComputeScarcityRisesWhenVerifiedDemandExceedsSupply(t *testing.T) { + metrics := ComputeMarketMetrics{ + EscrowBackedDemandUnits: 2_000, + VerifiedSupplyUnits: 1_000, + VerifiedSupplyReliable: true, + BacklogUnits: 500, + FulfilledUnits: 1_500, + UtilizationBps: 9_000, + ComputePriceTrendBps: 1_000, + ComputeIndexReliable: true, + } + snapshot, err := BuildComputeScarcity(1, metrics, DefaultComputeScarcityConfig()) + if err != nil { + t.Fatal(err) + } + if !snapshot.Reliable || snapshot.ScoreBps <= 0 { + t.Fatalf("expected reliable positive scarcity, got %#v", snapshot) + } + if snapshot.DemandSupplyPressureBps != int32(BasisPoints) { + t.Fatalf("expected demand/supply pressure clamp, got %d", snapshot.DemandSupplyPressureBps) + } +} + +func TestComputeScarcityIgnoresUnreliablePriceSignal(t *testing.T) { + cfg := DefaultComputeScarcityConfig() + metrics := ComputeMarketMetrics{ + EscrowBackedDemandUnits: 2_000, + VerifiedSupplyUnits: 2_000, + VerifiedSupplyReliable: true, + FulfilledUnits: 2_000, + UtilizationBps: cfg.UtilizationTargetBps, + ComputePriceTrendBps: 10_000, + ComputeIndexReliable: false, + } + first, err := BuildComputeScarcity(1, metrics, cfg) + if err != nil { + t.Fatal(err) + } + metrics.ComputePriceTrendBps = -10_000 + second, err := BuildComputeScarcity(2, metrics, cfg) + if err != nil { + t.Fatal(err) + } + if first.ScoreBps != second.ScoreBps || first.PriceTrendPressureBps != 0 || second.PriceTrendPressureBps != 0 { + t.Fatalf("unreliable ZCPI changed scarcity: %#v %#v", first, second) + } +} + +func TestComputeScarcityRejectsImpossibleSettlementMetrics(t *testing.T) { + metrics := ComputeMarketMetrics{EscrowBackedDemandUnits: 100, VerifiedSupplyUnits: 100, BacklogUnits: 101} + if _, err := BuildComputeScarcity(1, metrics, DefaultComputeScarcityConfig()); err != ErrComputeScarcity { + t.Fatalf("expected invalid backlog rejection, got %v", err) + } +} + +func TestUnauthenticatedSupplyCannotMakeZCSIReliable(t *testing.T) { + cfg := DefaultComputeScarcityConfig() + metrics := ComputeMarketMetrics{ + EscrowBackedDemandUnits: cfg.MinDemandUnits, + VerifiedSupplyUnits: cfg.MinSupplyUnits, + VerifiedSupplyReliable: false, + BacklogUnits: cfg.MinDemandUnits, + } + snapshot, err := BuildComputeScarcity(1, metrics, cfg) + if err != nil { + t.Fatal(err) + } + if snapshot.Reliable { + t.Fatal("unauthenticated compute supply made ZCSI reliable") + } +} + +func TestComputeFeedbackModesKeepActivationShadowed(t *testing.T) { + monetary := DefaultShadowPolicy() + metrics := MonetaryMetrics{ + Supply: 1_000_000_000, + CirculatingSupply: 900_000_000, + StakedSupply: 450_000_000, + ProtocolReserve: 100_000_000, + BurnedThisEpoch: 10_000, + FinalizedOperations: monetary.OperationsTarget, + ResourceUtilizationBps: monetary.UtilizationTargetBps, + AgeWeightedVelocityBps: monetary.VelocityTargetBps, + } + base, err := EvaluateShadow(monetary.TargetInflationBps, metrics, monetary) + if err != nil { + t.Fatal(err) + } + scarcity := ComputeScarcitySnapshot{Epoch: 1, ScoreBps: 8_000, Reliable: true} + + observe, err := EvaluateComputeFeedback(base, metrics, monetary, scarcity, DefaultComputeFeedbackPolicy(ComputeFeedbackObserveOnly)) + if err != nil { + t.Fatal(err) + } + if observe.SuggestedTargetInflationBps != base.TargetInflationBps || observe.ComputeRewardShareBps != 1_000 { + t.Fatalf("observe-only mode altered policy: %#v", observe) + } + + routing, err := EvaluateComputeFeedback(base, metrics, monetary, scarcity, DefaultComputeFeedbackPolicy(ComputeFeedbackRewardRouting)) + if err != nil { + t.Fatal(err) + } + if routing.ComputeRewardShareBps <= observe.ComputeRewardShareBps || routing.SuggestedTargetInflationBps != base.TargetInflationBps { + t.Fatalf("reward-routing mode did not isolate compute allocation: %#v", routing) + } + + band, err := EvaluateComputeFeedback(base, metrics, monetary, scarcity, DefaultComputeFeedbackPolicy(ComputeFeedbackMonetaryBand)) + if err != nil { + t.Fatal(err) + } + if !band.Shadow || band.InflationCorrectionBps <= 0 || band.SuggestedTargetInflationBps <= base.TargetInflationBps { + t.Fatalf("monetary-band mode did not produce bounded shadow correction: %#v", band) + } +} + +func TestUnreliableScarcityCannotMoveComputePolicy(t *testing.T) { + monetary := DefaultShadowPolicy() + metrics := MonetaryMetrics{ + Supply: 1_000_000_000, + CirculatingSupply: 900_000_000, + StakedSupply: 450_000_000, + ProtocolReserve: 100_000_000, + FinalizedOperations: monetary.OperationsTarget, + ResourceUtilizationBps: monetary.UtilizationTargetBps, + AgeWeightedVelocityBps: monetary.VelocityTargetBps, + } + base, err := EvaluateShadow(200, metrics, monetary) + if err != nil { + t.Fatal(err) + } + decision, err := EvaluateComputeFeedback(base, metrics, monetary, ComputeScarcitySnapshot{Epoch: 1, ScoreBps: 10_000, Reliable: false}, DefaultComputeFeedbackPolicy(ComputeFeedbackMonetaryBand)) + if err != nil { + t.Fatal(err) + } + if decision.InflationCorrectionBps != 0 || decision.SuggestedTargetInflationBps != base.TargetInflationBps || decision.ComputeRewardShareBps != 1_000 { + t.Fatalf("unreliable scarcity moved policy: %#v", decision) + } +} diff --git a/internal/v2/economics/epoch.go b/internal/v2/economics/epoch.go new file mode 100644 index 00000000..164aa168 --- /dev/null +++ b/internal/v2/economics/epoch.go @@ -0,0 +1,245 @@ +package economics + +import ( + "errors" + "math/big" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const EpochMetricsVersion uint16 = 2 + +var ErrEpochMetrics = errors.New("invalid Zephyr economic epoch metrics") + +type ShardEpochMetrics struct { + Version uint16 + Epoch uint64 + ShardID uint32 + ChargedFees uint64 + BurnedFees uint64 + ValidatorFees uint64 + ReserveFees uint64 + FinalizedOperations uint64 + ResourceUsed uint64 + ResourceCapacity uint64 + CirculatingNativeSupply uint64 + AgeWeightedVelocityBps uint32 + EscrowBackedComputeDemand uint64 + VerifiedComputeSupply uint64 + ComputeSupplyReliable bool + OpeningComputeBacklog uint64 + ComputeFulfilled uint64 + ComputeExpired uint64 + ComputeBacklog uint64 +} + +func (m ShardEpochMetrics) Validate() error { + if m.Version != EpochMetricsVersion || m.Epoch == 0 || m.ResourceCapacity == 0 || + m.ResourceUsed > m.ResourceCapacity || m.AgeWeightedVelocityBps > 10*BasisPoints || + (m.ComputeSupplyReliable && m.ComputeFulfilled > m.VerifiedComputeSupply) || !validComputeFlow( + m.OpeningComputeBacklog, + m.EscrowBackedComputeDemand, + m.ComputeFulfilled, + m.ComputeExpired, + m.ComputeBacklog, + ) { + return ErrEpochMetrics + } + feeTotal := new(big.Int).SetUint64(m.BurnedFees) + feeTotal.Add(feeTotal, new(big.Int).SetUint64(m.ValidatorFees)) + feeTotal.Add(feeTotal, new(big.Int).SetUint64(m.ReserveFees)) + if !feeTotal.IsUint64() || feeTotal.Uint64() != m.ChargedFees { + return ErrEpochMetrics + } + return nil +} + +func (m ShardEpochMetrics) CanonicalBytes() ([]byte, error) { + if err := m.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.U16(m.Version) + w.U64(m.Epoch) + w.U32(m.ShardID) + w.U64(m.ChargedFees) + w.U64(m.BurnedFees) + w.U64(m.ValidatorFees) + w.U64(m.ReserveFees) + w.U64(m.FinalizedOperations) + w.U64(m.ResourceUsed) + w.U64(m.ResourceCapacity) + w.U64(m.CirculatingNativeSupply) + w.U32(m.AgeWeightedVelocityBps) + w.U64(m.EscrowBackedComputeDemand) + w.U64(m.VerifiedComputeSupply) + w.Bool(m.ComputeSupplyReliable) + w.U64(m.OpeningComputeBacklog) + w.U64(m.ComputeFulfilled) + w.U64(m.ComputeExpired) + w.U64(m.ComputeBacklog) + return w.BytesCopy(), nil +} + +func (m ShardEpochMetrics) Hash() (types.Hash, error) { + raw, err := m.CanonicalBytes() + if err != nil { + return types.Hash{}, err + } + return types.Hash(codec.DomainHash("zephyr/shard-economics/v2", raw)), nil +} + +type EpochAggregate struct { + Epoch uint64 + ShardCount uint32 + ChargedFees uint64 + BurnedFees uint64 + ValidatorFees uint64 + ReserveFees uint64 + FinalizedOperations uint64 + ResourceUsed uint64 + ResourceCapacity uint64 + ResourceUtilizationBps uint32 + CirculatingNativeSupply uint64 + AgeWeightedVelocityBps uint32 + EscrowBackedComputeDemand uint64 + VerifiedComputeSupply uint64 + ComputeSupplyReliable bool + OpeningComputeBacklog uint64 + ComputeFulfilled uint64 + ComputeExpired uint64 + ComputeBacklog uint64 + ComputeUtilizationBps uint32 +} + +func AggregateEpochMetrics(metrics []ShardEpochMetrics) (EpochAggregate, error) { + if len(metrics) == 0 { + return EpochAggregate{}, ErrEpochMetrics + } + ordered := append([]ShardEpochMetrics(nil), metrics...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].ShardID < ordered[j].ShardID }) + epoch := ordered[0].Epoch + var charged, burned, validators, reserve, operations, used, capacity, circulating big.Int + var demand, supply, openingBacklog, fulfilled, expired, backlog, weightedVelocity big.Int + supplyReliable := true + for i, metric := range ordered { + if err := metric.Validate(); err != nil || metric.Epoch != epoch || (i > 0 && ordered[i-1].ShardID == metric.ShardID) { + return EpochAggregate{}, ErrEpochMetrics + } + addBig(&charged, metric.ChargedFees) + addBig(&burned, metric.BurnedFees) + addBig(&validators, metric.ValidatorFees) + addBig(&reserve, metric.ReserveFees) + addBig(&operations, metric.FinalizedOperations) + addBig(&used, metric.ResourceUsed) + addBig(&capacity, metric.ResourceCapacity) + addBig(&circulating, metric.CirculatingNativeSupply) + addBig(&demand, metric.EscrowBackedComputeDemand) + addBig(&supply, metric.VerifiedComputeSupply) + addBig(&openingBacklog, metric.OpeningComputeBacklog) + addBig(&fulfilled, metric.ComputeFulfilled) + addBig(&expired, metric.ComputeExpired) + addBig(&backlog, metric.ComputeBacklog) + supplyReliable = supplyReliable && metric.ComputeSupplyReliable + term := new(big.Int).Mul(new(big.Int).SetUint64(metric.CirculatingNativeSupply), new(big.Int).SetUint64(uint64(metric.AgeWeightedVelocityBps))) + weightedVelocity.Add(&weightedVelocity, term) + } + values := []*big.Int{ + &charged, &burned, &validators, &reserve, &operations, &used, &capacity, &circulating, + &demand, &supply, &openingBacklog, &fulfilled, &expired, &backlog, + } + for _, value := range values { + if !value.IsUint64() { + return EpochAggregate{}, ErrEpochMetrics + } + } + out := EpochAggregate{ + Epoch: epoch, + ShardCount: uint32(len(ordered)), + ChargedFees: charged.Uint64(), + BurnedFees: burned.Uint64(), + ValidatorFees: validators.Uint64(), + ReserveFees: reserve.Uint64(), + FinalizedOperations: operations.Uint64(), + ResourceUsed: used.Uint64(), + ResourceCapacity: capacity.Uint64(), + CirculatingNativeSupply: circulating.Uint64(), + EscrowBackedComputeDemand: demand.Uint64(), + VerifiedComputeSupply: supply.Uint64(), + ComputeSupplyReliable: supplyReliable, + OpeningComputeBacklog: openingBacklog.Uint64(), + ComputeFulfilled: fulfilled.Uint64(), + ComputeExpired: expired.Uint64(), + ComputeBacklog: backlog.Uint64(), + } + if out.ResourceCapacity == 0 || !validComputeFlow( + out.OpeningComputeBacklog, + out.EscrowBackedComputeDemand, + out.ComputeFulfilled, + out.ComputeExpired, + out.ComputeBacklog, + ) { + return EpochAggregate{}, ErrEpochMetrics + } + if out.ComputeSupplyReliable && out.ComputeFulfilled > out.VerifiedComputeSupply { + return EpochAggregate{}, ErrEpochMetrics + } + out.ResourceUtilizationBps = ratioBps(out.ResourceUsed, out.ResourceCapacity) + if out.VerifiedComputeSupply != 0 { + out.ComputeUtilizationBps = ratioBps(out.ComputeFulfilled, out.VerifiedComputeSupply) + } + if out.CirculatingNativeSupply != 0 { + weightedVelocity.Quo(&weightedVelocity, new(big.Int).SetUint64(out.CirculatingNativeSupply)) + if !weightedVelocity.IsUint64() || weightedVelocity.Uint64() > uint64(10*BasisPoints) { + return EpochAggregate{}, ErrEpochMetrics + } + out.AgeWeightedVelocityBps = uint32(weightedVelocity.Uint64()) + } + return out, nil +} + +func (a EpochAggregate) MonetaryMetrics(totalSupply, stakedSupply, protocolReserve, computeIndexQ9 uint64, computePriceTrendBps int32, computeIndexReliable bool) (MonetaryMetrics, error) { + if totalSupply == 0 || a.CirculatingNativeSupply == 0 || a.CirculatingNativeSupply > totalSupply || stakedSupply > a.CirculatingNativeSupply || protocolReserve > totalSupply { + return MonetaryMetrics{}, ErrEpochMetrics + } + return MonetaryMetrics{ + Supply: totalSupply, + CirculatingSupply: a.CirculatingNativeSupply, + StakedSupply: stakedSupply, + ProtocolReserve: protocolReserve, + BurnedThisEpoch: a.BurnedFees, + FinalizedOperations: a.FinalizedOperations, + ResourceUtilizationBps: a.ResourceUtilizationBps, + AgeWeightedVelocityBps: a.AgeWeightedVelocityBps, + ComputeIndexQ9: computeIndexQ9, + ComputePriceTrendBps: computePriceTrendBps, + ComputeIndexReliable: computeIndexReliable, + }, nil +} + +func (a EpochAggregate) ComputeMarketMetrics(computePriceTrendBps int32, computeIndexReliable bool) ComputeMarketMetrics { + return ComputeMarketMetrics{ + EscrowBackedDemandUnits: a.OpeningComputeBacklog + a.EscrowBackedComputeDemand, + VerifiedSupplyUnits: a.VerifiedComputeSupply, + VerifiedSupplyReliable: a.ComputeSupplyReliable, + BacklogUnits: a.ComputeBacklog, + FulfilledUnits: a.ComputeFulfilled, + UtilizationBps: a.ComputeUtilizationBps, + ComputePriceTrendBps: computePriceTrendBps, + ComputeIndexReliable: computeIndexReliable, + } +} + +func validComputeFlow(opening, demand, fulfilled, expired, closing uint64) bool { + available := new(big.Int).Add(new(big.Int).SetUint64(opening), new(big.Int).SetUint64(demand)) + resolved := new(big.Int).SetUint64(fulfilled) + resolved.Add(resolved, new(big.Int).SetUint64(expired)) + resolved.Add(resolved, new(big.Int).SetUint64(closing)) + return available.IsUint64() && resolved.IsUint64() && available.Cmp(resolved) == 0 +} + +func addBig(target *big.Int, value uint64) { + target.Add(target, new(big.Int).SetUint64(value)) +} diff --git a/internal/v2/economics/epoch_parse.go b/internal/v2/economics/epoch_parse.go new file mode 100644 index 00000000..10ff0249 --- /dev/null +++ b/internal/v2/economics/epoch_parse.go @@ -0,0 +1,103 @@ +package economics + +import "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + +func ParseEpochAggregate(data []byte) (EpochAggregate, error) { + r := codec.NewReader(data) + out := EpochAggregate{} + var err error + out.Epoch, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ShardCount, err = r.U32() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ChargedFees, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.BurnedFees, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ValidatorFees, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ReserveFees, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.FinalizedOperations, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ResourceUsed, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ResourceCapacity, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ResourceUtilizationBps, err = r.U32() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.CirculatingNativeSupply, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.AgeWeightedVelocityBps, err = r.U32() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.EscrowBackedComputeDemand, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.VerifiedComputeSupply, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ComputeSupplyReliable, err = r.Bool() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.OpeningComputeBacklog, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ComputeFulfilled, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ComputeExpired, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ComputeBacklog, err = r.U64() + if err != nil { + return EpochAggregate{}, ErrEpochMetrics + } + out.ComputeUtilizationBps, err = r.U32() + if err != nil || r.Done() != nil { + return EpochAggregate{}, ErrEpochMetrics + } + if _, err := out.CanonicalBytes(); err != nil { + return EpochAggregate{}, err + } + if out.ResourceUtilizationBps != ratioBps(out.ResourceUsed, out.ResourceCapacity) { + return EpochAggregate{}, ErrEpochMetrics + } + expectedComputeUtilization := uint32(0) + if out.VerifiedComputeSupply != 0 { + expectedComputeUtilization = ratioBps(out.ComputeFulfilled, out.VerifiedComputeSupply) + } + if out.ComputeUtilizationBps != expectedComputeUtilization { + return EpochAggregate{}, ErrEpochMetrics + } + return out, nil +} diff --git a/internal/v2/economics/epoch_test.go b/internal/v2/economics/epoch_test.go new file mode 100644 index 00000000..7080c14c --- /dev/null +++ b/internal/v2/economics/epoch_test.go @@ -0,0 +1,134 @@ +package economics + +import "testing" + +func TestAggregateEpochMetricsSeparatesChainAndComputeUtilization(t *testing.T) { + metrics := []ShardEpochMetrics{ + { + Version: EpochMetricsVersion, Epoch: 7, ShardID: 0, + ChargedFees: 100, BurnedFees: 40, ValidatorFees: 50, ReserveFees: 10, + FinalizedOperations: 1_000, ResourceUsed: 50, ResourceCapacity: 100, + CirculatingNativeSupply: 800, AgeWeightedVelocityBps: 2_000, + EscrowBackedComputeDemand: 100, VerifiedComputeSupply: 80, ComputeSupplyReliable: true, + ComputeFulfilled: 70, ComputeExpired: 10, ComputeBacklog: 20, + }, + { + Version: EpochMetricsVersion, Epoch: 7, ShardID: 1, + ChargedFees: 50, BurnedFees: 20, ValidatorFees: 25, ReserveFees: 5, + FinalizedOperations: 500, ResourceUsed: 10, ResourceCapacity: 100, + CirculatingNativeSupply: 200, AgeWeightedVelocityBps: 8_000, + EscrowBackedComputeDemand: 50, VerifiedComputeSupply: 40, ComputeSupplyReliable: true, + ComputeFulfilled: 30, ComputeExpired: 10, ComputeBacklog: 10, + }, + } + aggregate, err := AggregateEpochMetrics(metrics) + if err != nil { + t.Fatal(err) + } + if aggregate.ChargedFees != 150 || aggregate.BurnedFees != 60 || aggregate.FinalizedOperations != 1_500 { + t.Fatalf("unexpected totals: %#v", aggregate) + } + if aggregate.ResourceUtilizationBps != 3_000 { + t.Fatalf("chain utilization should be 3000 bps, got %d", aggregate.ResourceUtilizationBps) + } + if aggregate.ComputeUtilizationBps != 8_333 { + t.Fatalf("compute utilization should be based on fulfilled/capacity, got %d", aggregate.ComputeUtilizationBps) + } + if aggregate.AgeWeightedVelocityBps != 3_200 { + t.Fatalf("velocity must be supply-weighted across shards, got %d", aggregate.AgeWeightedVelocityBps) + } + if aggregate.ComputeExpired != 20 || !aggregate.ComputeSupplyReliable { + t.Fatalf("unexpected compute aggregate: %#v", aggregate) + } + market := aggregate.ComputeMarketMetrics(500, true) + if market.UtilizationBps != aggregate.ComputeUtilizationBps || market.EscrowBackedDemandUnits != 150 || market.VerifiedSupplyUnits != 120 || !market.VerifiedSupplyReliable { + t.Fatalf("unexpected compute market projection: %#v", market) + } +} + +func TestShardEpochMetricsCarriesBacklogAcrossEpochs(t *testing.T) { + metrics := ShardEpochMetrics{ + Version: EpochMetricsVersion, Epoch: 2, ResourceCapacity: 100, + OpeningComputeBacklog: 100, EscrowBackedComputeDemand: 20, + VerifiedComputeSupply: 100, ComputeSupplyReliable: true, + ComputeFulfilled: 80, ComputeExpired: 10, ComputeBacklog: 30, + } + if err := metrics.Validate(); err != nil { + t.Fatal(err) + } + market, err := AggregateEpochMetrics([]ShardEpochMetrics{metrics}) + if err != nil { + t.Fatal(err) + } + projected := market.ComputeMarketMetrics(0, false) + if projected.EscrowBackedDemandUnits != 120 || projected.BacklogUnits != 30 || projected.FulfilledUnits != 80 { + t.Fatalf("unexpected carried demand: %#v", projected) + } +} + +func TestUnauthenticatedComputeSupplyCannotBecomeReliable(t *testing.T) { + metrics := ShardEpochMetrics{ + Version: EpochMetricsVersion, Epoch: 1, ResourceCapacity: 100, + EscrowBackedComputeDemand: 100, VerifiedComputeSupply: 1_000, + ComputeFulfilled: 60, ComputeBacklog: 40, + } + aggregate, err := AggregateEpochMetrics([]ShardEpochMetrics{metrics}) + if err != nil { + t.Fatal(err) + } + market := aggregate.ComputeMarketMetrics(0, true) + if market.VerifiedSupplyReliable { + t.Fatal("unauthenticated compute supply became reliable") + } +} + +func TestShardEpochMetricsRejectsInconsistentAccounting(t *testing.T) { + base := ShardEpochMetrics{ + Version: EpochMetricsVersion, Epoch: 1, ResourceCapacity: 100, + ChargedFees: 10, BurnedFees: 4, ValidatorFees: 5, ReserveFees: 1, + EscrowBackedComputeDemand: 100, VerifiedComputeSupply: 100, ComputeSupplyReliable: true, + ComputeFulfilled: 60, ComputeBacklog: 40, + } + if err := base.Validate(); err != nil { + t.Fatal(err) + } + badFees := base + badFees.ReserveFees = 2 + if err := badFees.Validate(); err != ErrEpochMetrics { + t.Fatalf("fee accounting mismatch accepted: %v", err) + } + badDemand := base + badDemand.ComputeBacklog = 41 + if err := badDemand.Validate(); err != ErrEpochMetrics { + t.Fatalf("broken compute flow accepted: %v", err) + } + badSupply := base + badSupply.VerifiedComputeSupply = 59 + if err := badSupply.Validate(); err != ErrEpochMetrics { + t.Fatalf("fulfilled work above authenticated supply accepted: %v", err) + } +} + +func TestAggregateEpochMetricsRejectsDuplicateShard(t *testing.T) { + base := ShardEpochMetrics{ + Version: EpochMetricsVersion, Epoch: 1, ShardID: 0, + ResourceCapacity: 1, ChargedFees: 1, BurnedFees: 1, + } + if _, err := AggregateEpochMetrics([]ShardEpochMetrics{base, base}); err != ErrEpochMetrics { + t.Fatalf("duplicate shard accepted: %v", err) + } +} + +func TestEpochAggregateBuildsZAMPInputs(t *testing.T) { + aggregate := EpochAggregate{ + Epoch: 1, ShardCount: 1, BurnedFees: 123, FinalizedOperations: 100, + ResourceUtilizationBps: 4_000, CirculatingNativeSupply: 900, AgeWeightedVelocityBps: 3_000, + } + metrics, err := aggregate.MonetaryMetrics(1_000, 400, 50, 7_500_000_000, 100, true) + if err != nil { + t.Fatal(err) + } + if metrics.BurnedThisEpoch != 123 || metrics.StakedSupply != 400 || metrics.AgeWeightedVelocityBps != 3_000 || metrics.ComputeIndexQ9 != 7_500_000_000 { + t.Fatalf("unexpected monetary projection: %#v", metrics) + } +} diff --git a/internal/v2/economics/epoch_wire.go b/internal/v2/economics/epoch_wire.go new file mode 100644 index 00000000..8aa4905b --- /dev/null +++ b/internal/v2/economics/epoch_wire.go @@ -0,0 +1,54 @@ +package economics + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func (a EpochAggregate) CanonicalBytes() ([]byte, error) { + if a.Epoch == 0 || a.ShardCount == 0 || a.ResourceCapacity == 0 || a.ResourceUsed > a.ResourceCapacity || + a.ResourceUtilizationBps > BasisPoints || a.ComputeUtilizationBps > BasisPoints || a.AgeWeightedVelocityBps > 10*BasisPoints || + (a.ComputeSupplyReliable && a.ComputeFulfilled > a.VerifiedComputeSupply) || !validComputeFlow( + a.OpeningComputeBacklog, + a.EscrowBackedComputeDemand, + a.ComputeFulfilled, + a.ComputeExpired, + a.ComputeBacklog, + ) { + return nil, ErrEpochMetrics + } + if a.BurnedFees > a.ChargedFees || a.ValidatorFees > a.ChargedFees-a.BurnedFees || + a.ReserveFees != a.ChargedFees-a.BurnedFees-a.ValidatorFees { + return nil, ErrEpochMetrics + } + var w codec.Writer + w.U64(a.Epoch) + w.U32(a.ShardCount) + w.U64(a.ChargedFees) + w.U64(a.BurnedFees) + w.U64(a.ValidatorFees) + w.U64(a.ReserveFees) + w.U64(a.FinalizedOperations) + w.U64(a.ResourceUsed) + w.U64(a.ResourceCapacity) + w.U32(a.ResourceUtilizationBps) + w.U64(a.CirculatingNativeSupply) + w.U32(a.AgeWeightedVelocityBps) + w.U64(a.EscrowBackedComputeDemand) + w.U64(a.VerifiedComputeSupply) + w.Bool(a.ComputeSupplyReliable) + w.U64(a.OpeningComputeBacklog) + w.U64(a.ComputeFulfilled) + w.U64(a.ComputeExpired) + w.U64(a.ComputeBacklog) + w.U32(a.ComputeUtilizationBps) + return w.BytesCopy(), nil +} + +func (a EpochAggregate) Hash() (types.Hash, error) { + raw, err := a.CanonicalBytes() + if err != nil { + return types.Hash{}, err + } + return types.Hash(codec.DomainHash("zephyr/epoch-economics/v2", raw)), nil +} diff --git a/internal/v2/economics/fees.go b/internal/v2/economics/fees.go new file mode 100644 index 00000000..aff52201 --- /dev/null +++ b/internal/v2/economics/fees.go @@ -0,0 +1,164 @@ +package economics + +import ( + "errors" + "math/big" +) + +var ( + ErrFeePolicy = errors.New("invalid Zephyr fee policy") + ErrResourceFee = errors.New("invalid Zephyr resource fee input") +) + +type FeePolicy struct { + BurnBps uint32 + ValidatorBps uint32 + ReserveBps uint32 +} + +type FeeAllocation struct { + Total uint64 + Burn uint64 + Validators uint64 + Reserve uint64 +} + +// CompatibilityFeePolicy preserves the current v2 executor behavior while fee +// accounting is moved into authenticated monetary state: the full signed Fee is +// counted as burn. Alternative splits can be simulated without activating them. +func CompatibilityFeePolicy() FeePolicy { + return FeePolicy{BurnBps: BasisPoints} +} + +func (p FeePolicy) Validate() error { + total := uint64(p.BurnBps) + uint64(p.ValidatorBps) + uint64(p.ReserveBps) + if p.BurnBps > BasisPoints || p.ValidatorBps > BasisPoints || p.ReserveBps > BasisPoints || total != uint64(BasisPoints) { + return ErrFeePolicy + } + return nil +} + +// SplitFee uses floor division for validator and reserve shares. Any indivisible +// remainder is assigned to burn, making conservation exact and deterministic. +func SplitFee(fee uint64, policy FeePolicy) (FeeAllocation, error) { + if err := policy.Validate(); err != nil { + return FeeAllocation{}, err + } + validators, err := shareBps(fee, policy.ValidatorBps) + if err != nil { + return FeeAllocation{}, err + } + reserve, err := shareBps(fee, policy.ReserveBps) + if err != nil || validators > fee || reserve > fee-validators { + return FeeAllocation{}, ErrFeePolicy + } + burn := fee - validators - reserve + return FeeAllocation{Total: fee, Burn: burn, Validators: validators, Reserve: reserve}, nil +} + +type ResourceUsage struct { + BaseTransactions uint64 + SignatureOps uint64 + WitnessBytes uint64 + StateReads uint64 + StateWrites uint64 + ContractFuel uint64 + DataAvailabilityBytes uint64 + CrossShardReceipts uint64 +} + +type ResourcePrices struct { + BaseTransaction uint64 + SignatureOp uint64 + WitnessByte uint64 + StateRead uint64 + StateWrite uint64 + ContractFuel uint64 + DataAvailabilityByte uint64 + CrossShardReceipt uint64 +} + +func QuoteResourceFee(usage ResourceUsage, prices ResourcePrices) (uint64, error) { + pairs := [][2]uint64{ + {usage.BaseTransactions, prices.BaseTransaction}, + {usage.SignatureOps, prices.SignatureOp}, + {usage.WitnessBytes, prices.WitnessByte}, + {usage.StateReads, prices.StateRead}, + {usage.StateWrites, prices.StateWrite}, + {usage.ContractFuel, prices.ContractFuel}, + {usage.DataAvailabilityBytes, prices.DataAvailabilityByte}, + {usage.CrossShardReceipts, prices.CrossShardReceipt}, + } + total := new(big.Int) + for _, pair := range pairs { + term := new(big.Int).Mul(new(big.Int).SetUint64(pair[0]), new(big.Int).SetUint64(pair[1])) + total.Add(total, term) + } + if !total.IsUint64() { + return 0, ErrResourceFee + } + return total.Uint64(), nil +} + +type BaseFeePolicy struct { + TargetUsage uint64 + AdjustmentDenominator uint64 + MinBaseFee uint64 + MaxBaseFee uint64 +} + +func (p BaseFeePolicy) Validate() error { + if p.TargetUsage == 0 || p.AdjustmentDenominator == 0 || p.MinBaseFee == 0 || p.MaxBaseFee < p.MinBaseFee { + return ErrFeePolicy + } + return nil +} + +// NextBaseFee is a bounded integer-only congestion controller. It is provided +// for simulation/reference use and is not activated as the live v2 fee market. +func NextBaseFee(current, used uint64, policy BaseFeePolicy) (uint64, error) { + if err := policy.Validate(); err != nil || current < policy.MinBaseFee || current > policy.MaxBaseFee { + return 0, ErrFeePolicy + } + if used == policy.TargetUsage { + return current, nil + } + var distance uint64 + increase := used > policy.TargetUsage + if increase { + distance = used - policy.TargetUsage + } else { + distance = policy.TargetUsage - used + } + change := new(big.Int).Mul(new(big.Int).SetUint64(current), new(big.Int).SetUint64(distance)) + change.Quo(change, new(big.Int).SetUint64(policy.TargetUsage)) + change.Quo(change, new(big.Int).SetUint64(policy.AdjustmentDenominator)) + if change.Sign() == 0 { + change.SetUint64(1) + } + if !change.IsUint64() { + return 0, ErrResourceFee + } + delta := change.Uint64() + if increase { + next := new(big.Int).Add(new(big.Int).SetUint64(current), new(big.Int).SetUint64(delta)) + max := new(big.Int).SetUint64(policy.MaxBaseFee) + if next.Cmp(max) > 0 { + return policy.MaxBaseFee, nil + } + return next.Uint64(), nil + } + if delta >= current || current-delta < policy.MinBaseFee { + return policy.MinBaseFee, nil + } + return current - delta, nil +} + +func shareBps(value uint64, bps uint32) (uint64, error) { + share := new(big.Int).Mul(new(big.Int).SetUint64(value), new(big.Int).SetUint64(uint64(bps))) + share.Quo(share, new(big.Int).SetUint64(uint64(BasisPoints))) + if !share.IsUint64() { + return 0, ErrFeePolicy + } + return share.Uint64(), nil +} diff --git a/internal/v2/economics/fees_test.go b/internal/v2/economics/fees_test.go new file mode 100644 index 00000000..07c33438 --- /dev/null +++ b/internal/v2/economics/fees_test.go @@ -0,0 +1,88 @@ +package economics + +import ( + "math" + "testing" +) + +func TestSplitFeeConservesEveryAtomicUnit(t *testing.T) { + allocation, err := SplitFee(101, FeePolicy{BurnBps: 4_000, ValidatorBps: 5_000, ReserveBps: 1_000}) + if err != nil { + t.Fatal(err) + } + if allocation.Burn != 41 || allocation.Validators != 50 || allocation.Reserve != 10 { + t.Fatalf("unexpected deterministic split: %#v", allocation) + } + if allocation.Burn+allocation.Validators+allocation.Reserve != allocation.Total { + t.Fatal("fee split does not conserve value") + } +} + +func TestCompatibilityFeePolicyPreservesFullBurn(t *testing.T) { + allocation, err := SplitFee(12345, CompatibilityFeePolicy()) + if err != nil { + t.Fatal(err) + } + if allocation.Burn != 12345 || allocation.Validators != 0 || allocation.Reserve != 0 { + t.Fatalf("compatibility policy changed existing semantics: %#v", allocation) + } +} + +func TestQuoteResourceFee(t *testing.T) { + usage := ResourceUsage{ + BaseTransactions: 1, + SignatureOps: 2, + WitnessBytes: 100, + StateReads: 3, + StateWrites: 2, + ContractFuel: 50, + DataAvailabilityBytes: 20, + CrossShardReceipts: 1, + } + prices := ResourcePrices{ + BaseTransaction: 10, + SignatureOp: 2, + WitnessByte: 1, + StateRead: 3, + StateWrite: 5, + ContractFuel: 1, + DataAvailabilityByte: 2, + CrossShardReceipt: 7, + } + fee, err := QuoteResourceFee(usage, prices) + if err != nil { + t.Fatal(err) + } + if fee != 230 { + t.Fatalf("unexpected resource fee %d", fee) + } +} + +func TestQuoteResourceFeeRejectsOverflow(t *testing.T) { + _, err := QuoteResourceFee(ResourceUsage{BaseTransactions: math.MaxUint64}, ResourcePrices{BaseTransaction: 2}) + if err != ErrResourceFee { + t.Fatalf("expected overflow rejection, got %v", err) + } +} + +func TestNextBaseFeeMovesTowardCongestionAndBounds(t *testing.T) { + policy := BaseFeePolicy{TargetUsage: 100, AdjustmentDenominator: 8, MinBaseFee: 10, MaxBaseFee: 10_000} + high, err := NextBaseFee(1_000, 200, policy) + if err != nil { + t.Fatal(err) + } + if high != 1_125 { + t.Fatalf("unexpected congestion increase %d", high) + } + low, err := NextBaseFee(1_000, 0, policy) + if err != nil { + t.Fatal(err) + } + if low != 875 { + t.Fatalf("unexpected utilization decrease %d", low) + } + floor, err := NextBaseFee(10, 0, policy) + if err != nil || floor != 10 { + t.Fatalf("minimum fee must be preserved: %d %v", floor, err) + } +} diff --git a/internal/v2/economics/finalized_collector.go b/internal/v2/economics/finalized_collector.go new file mode 100644 index 00000000..8ae896f9 --- /dev/null +++ b/internal/v2/economics/finalized_collector.go @@ -0,0 +1,450 @@ +package economics + +import ( + "errors" + "math" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/execution" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrFinalizedEconomics = errors.New("invalid finalized Zephyr economic observation") + +// EpochCollectorConfig contains only deterministic protocol/shadow-policy +// inputs. Initial circulating supply and opening backlog are prior finalized +// state, not mutable operator telemetry. +type EpochCollectorConfig struct { + Epoch uint64 + ShardCount uint32 + NativeToken types.TokenID + InitialCirculatingSupply map[uint32]uint64 + OpeningComputeBacklog map[uint32]uint64 + ResourceCapacityPerBlock map[uint32]uint64 + VelocityPolicy VelocityPolicy + FeePolicy FeePolicy + WorkRegistry *compute.WorkRegistry +} + +type FinalizedShardObservation struct { + Transactions []tx.Transaction + Results []execution.Result + Imports []sharding.CrossShardReceipt + DataBytes uint64 + ComputeCapacityUnits uint64 + ComputeCapacityReliable bool +} + +type shardEpochAccumulator struct { + fees FeeAllocation + operations uint64 + resourceUsed uint64 + resourceCapacity uint64 + openingBacklog uint64 + newDemand uint64 + fulfilled uint64 + expired uint64 + closingBacklog uint64 + computeSupply uint64 + computeSupplyReliable bool + velocity *VelocityAccumulator +} + +type EpochCollector struct { + config EpochCollectorConfig + shards map[uint32]*shardEpochAccumulator + supply map[uint32]uint64 + verifiedWork []compute.VerifiedWork + lastHeight uint64 +} + +func NewEpochCollector(config EpochCollectorConfig) (*EpochCollector, error) { + if config.Epoch == 0 || config.ShardCount == 0 || types.IsZero32([32]byte(config.NativeToken)) || + config.FeePolicy != CompatibilityFeePolicy() { + return nil, ErrFinalizedEconomics + } + if _, err := NewVelocityAccumulator(config.VelocityPolicy); err != nil { + return nil, err + } + collector := &EpochCollector{ + config: config, + shards: make(map[uint32]*shardEpochAccumulator, config.ShardCount), + supply: make(map[uint32]uint64, config.ShardCount), + } + for shard := uint32(0); shard < config.ShardCount; shard++ { + capacity := config.ResourceCapacityPerBlock[shard] + if capacity == 0 { + return nil, ErrFinalizedEconomics + } + velocity, err := NewVelocityAccumulator(config.VelocityPolicy) + if err != nil { + return nil, err + } + opening := config.OpeningComputeBacklog[shard] + collector.shards[shard] = &shardEpochAccumulator{ + openingBacklog: opening, + closingBacklog: opening, + computeSupplyReliable: true, + velocity: velocity, + } + collector.supply[shard] = config.InitialCirculatingSupply[shard] + } + return collector, nil +} + +// ObserveFinalizedBlock applies a full global-block observation atomically. +// Telemetry changes only after normal consensus finality and never mutates +// world state or mints ZPH. +func (c *EpochCollector) ObserveFinalizedBlock(height uint64, observations map[uint32]FinalizedShardObservation) error { + if c == nil { + return ErrFinalizedEconomics + } + preview := c.clone() + if preview == nil { + return ErrFinalizedEconomics + } + if err := preview.observeFinalizedBlock(height, observations); err != nil { + return err + } + *c = *preview + return nil +} + +func (c *EpochCollector) observeFinalizedBlock(height uint64, observations map[uint32]FinalizedShardObservation) error { + if height == 0 || height <= c.lastHeight { + return ErrFinalizedEconomics + } + for shard := uint32(0); shard < c.config.ShardCount; shard++ { + observation := observations[shard] + if len(observation.Transactions) != len(observation.Results) { + return ErrFinalizedEconomics + } + acc := c.shards[shard] + if err := addTo(&acc.resourceCapacity, c.config.ResourceCapacityPerBlock[shard]); err != nil { + return err + } + if err := addTo(&acc.computeSupply, observation.ComputeCapacityUnits); err != nil { + return err + } + acc.computeSupplyReliable = acc.computeSupplyReliable && observation.ComputeCapacityReliable + + for i := range observation.Transactions { + transaction := observation.Transactions[i] + result := observation.Results[i] + if transaction.ShardID != shard || result.TxID != transaction.ID() { + return ErrFinalizedEconomics + } + if err := c.observeFinalizedTransaction(height, shard, transaction, result, acc); err != nil { + return err + } + } + for _, receipt := range observation.Imports { + if receipt.DestinationShard != shard || receipt.SourceShard >= c.config.ShardCount || receipt.SourceShard == receipt.DestinationShard { + return ErrFinalizedEconomics + } + if err := c.observeImportedReceipt(receipt); err != nil { + return err + } + if err := addTo(&acc.operations, 1); err != nil { + return err + } + if err := addTo(&acc.resourceUsed, 2); err != nil { + return err + } + } + if observation.DataBytes > 0 { + if observation.DataBytes > math.MaxUint64-1023 { + return ErrFinalizedEconomics + } + dataUnits := (observation.DataBytes + 1023) / 1024 + if err := addTo(&acc.resourceUsed, dataUnits); err != nil { + return err + } + } + if acc.resourceUsed > acc.resourceCapacity { + return ErrFinalizedEconomics + } + } + c.lastHeight = height + return nil +} + +func (c *EpochCollector) observeFinalizedTransaction(height uint64, shard uint32, transaction tx.Transaction, result execution.Result, acc *shardEpochAccumulator) error { + allocation, err := SplitFee(transaction.Fee, c.config.FeePolicy) + if err != nil || allocation.Validators != 0 || allocation.Reserve != 0 { + return ErrFinalizedEconomics + } + if err := addTo(&acc.fees.Total, allocation.Total); err != nil { + return err + } + if err := addTo(&acc.fees.Burn, allocation.Burn); err != nil { + return err + } + if c.supply[shard] < allocation.Burn { + return ErrFinalizedEconomics + } + c.supply[shard] -= allocation.Burn + if err := addTo(&acc.operations, uint64(len(transaction.Operations))); err != nil { + return err + } + resourceUnits, err := finalizedTransactionResourceUnits(transaction, result) + if err != nil { + return err + } + if err := addTo(&acc.resourceUsed, resourceUnits); err != nil { + return err + } + + consumed := make(map[types.ObjectID]struct{}, len(result.Consumed)) + for _, id := range result.Consumed { + consumed[id] = struct{}{} + } + for _, witness := range transaction.Witnesses { + if _, wasConsumed := consumed[witness.Object.ID]; !wasConsumed || witness.Object.Kind != object.KindCoin { + continue + } + coin, err := object.ParseCoin(witness.Object.Data) + if err != nil { + return err + } + if coin.Token == c.config.NativeToken { + if err := acc.velocity.ObserveCoin(coin, height); err != nil { + return err + } + } + } + return c.observeComputeLifecycle(transaction, result, acc) +} + +func finalizedTransactionResourceUnits(transaction tx.Transaction, result execution.Result) (uint64, error) { + intentBytes := uint64(len(transaction.IntentBytes())) + units := uint64(1) + (intentBytes+1023)/1024 + parts := []uint64{ + uint64(len(transaction.Inputs)), + uint64(len(result.Consumed)), + uint64(len(result.Created)), + uint64(len(result.Outbound)), + } + for _, value := range parts { + if math.MaxUint64-units < value { + return 0, ErrFinalizedEconomics + } + units += value + } + return units, nil +} + +func (c *EpochCollector) observeImportedReceipt(receipt sharding.CrossShardReceipt) error { + if receipt.Output.Kind != object.KindCoin { + return nil + } + coin, err := object.ParseCoin(receipt.Output.Data) + if err != nil { + return err + } + if coin.Token != c.config.NativeToken { + return nil + } + if c.supply[receipt.SourceShard] < coin.Amount || math.MaxUint64-c.supply[receipt.DestinationShard] < coin.Amount { + return ErrFinalizedEconomics + } + c.supply[receipt.SourceShard] -= coin.Amount + c.supply[receipt.DestinationShard] += coin.Amount + return nil +} + +func (c *EpochCollector) observeComputeLifecycle(transaction tx.Transaction, result execution.Result, acc *shardEpochAccumulator) error { + if c.config.WorkRegistry == nil || len(transaction.Operations) != 1 { + return nil + } + op := transaction.Operations[0] + switch op.Kind { + case tx.OpComputeJob: + job, err := compute.ParseJob(op.Payload) + if err != nil { + return err + } + spec, ok := c.config.WorkRegistry.Resolve(job.WorkloadHash) + if !ok { + return nil + } + if err := addTo(&acc.newDemand, spec.Units); err != nil { + return err + } + return addTo(&acc.closingBacklog, spec.Units) + case tx.OpComputeFinalize, tx.OpComputeResolveReplicated: + record, ok, err := computeJobWitness(transaction) + if err != nil || !ok { + return ErrFinalizedEconomics + } + spec, registered := c.config.WorkRegistry.Resolve(record.Job.WorkloadHash) + if !registered { + return nil + } + receipt, ok, err := settlementReceiptForJob(result.Created, record.ID) + if err != nil || !ok { + return ErrFinalizedEconomics + } + verified, err := compute.ObserveFinalizedSettlement(record, receipt, c.config.WorkRegistry) + if err != nil || verified.Units != spec.Units { + return ErrFinalizedEconomics + } + if acc.closingBacklog < spec.Units { + return ErrFinalizedEconomics + } + acc.closingBacklog -= spec.Units + if err := addTo(&acc.fulfilled, spec.Units); err != nil { + return err + } + c.verifiedWork = append(c.verifiedWork, verified) + case tx.OpComputeExpire: + record, ok, err := computeJobWitness(transaction) + if err != nil || !ok { + return ErrFinalizedEconomics + } + spec, registered := c.config.WorkRegistry.Resolve(record.Job.WorkloadHash) + if !registered { + return nil + } + if acc.closingBacklog < spec.Units { + return ErrFinalizedEconomics + } + acc.closingBacklog -= spec.Units + return addTo(&acc.expired, spec.Units) + } + return nil +} + +func computeJobWitness(transaction tx.Transaction) (compute.OnChainJob, bool, error) { + var record compute.OnChainJob + found := false + for _, witness := range transaction.Witnesses { + if witness.Object.Kind != object.KindComputeJob { + continue + } + if found { + return compute.OnChainJob{}, false, ErrFinalizedEconomics + } + parsed, err := compute.ParseOnChainJob(witness.Object.Data) + if err != nil { + return compute.OnChainJob{}, false, err + } + record, found = parsed, true + } + return record, found, nil +} + +func settlementReceiptForJob(created []object.Object, jobID types.JobID) (compute.SettlementReceipt, bool, error) { + for _, createdObject := range created { + if createdObject.Kind != object.KindSystem { + continue + } + receipt, err := compute.ParseSettlementReceipt(createdObject.Data) + if err != nil { + continue + } + if receipt.JobID == jobID { + return receipt, true, nil + } + } + return compute.SettlementReceipt{}, false, nil +} + +func (c *EpochCollector) FinalizeEpoch() ([]ShardEpochMetrics, []compute.VerifiedWork, error) { + if c == nil { + return nil, nil, ErrFinalizedEconomics + } + metrics := make([]ShardEpochMetrics, 0, c.config.ShardCount) + for shard := uint32(0); shard < c.config.ShardCount; shard++ { + acc := c.shards[shard] + velocity := VelocitySnapshot{} + if c.supply[shard] > 0 { + var err error + velocity, err = acc.velocity.Finalize(c.supply[shard]) + if err != nil { + return nil, nil, err + } + } + metric := ShardEpochMetrics{ + Version: EpochMetricsVersion, Epoch: c.config.Epoch, ShardID: shard, + ChargedFees: acc.fees.Total, BurnedFees: acc.fees.Burn, + ValidatorFees: acc.fees.Validators, ReserveFees: acc.fees.Reserve, + FinalizedOperations: acc.operations, ResourceUsed: acc.resourceUsed, ResourceCapacity: acc.resourceCapacity, + CirculatingNativeSupply: c.supply[shard], AgeWeightedVelocityBps: velocity.AgeWeightedVelocityBps, + EscrowBackedComputeDemand: acc.newDemand, VerifiedComputeSupply: acc.computeSupply, + ComputeSupplyReliable: acc.computeSupplyReliable, OpeningComputeBacklog: acc.openingBacklog, + ComputeFulfilled: acc.fulfilled, ComputeExpired: acc.expired, ComputeBacklog: acc.closingBacklog, + } + if err := metric.Validate(); err != nil { + return nil, nil, err + } + metrics = append(metrics, metric) + } + return metrics, append([]compute.VerifiedWork(nil), c.verifiedWork...), nil +} + +func (c *EpochCollector) AdvanceEpoch(next uint64) error { + if c == nil || next != c.config.Epoch+1 { + return ErrFinalizedEconomics + } + c.config.Epoch = next + c.verifiedWork = nil + for shard := uint32(0); shard < c.config.ShardCount; shard++ { + prior := c.shards[shard] + velocity, err := NewVelocityAccumulator(c.config.VelocityPolicy) + if err != nil { + return err + } + c.shards[shard] = &shardEpochAccumulator{ + openingBacklog: prior.closingBacklog, + closingBacklog: prior.closingBacklog, + computeSupplyReliable: true, + velocity: velocity, + } + } + return nil +} + +func (c *EpochCollector) CirculatingSupply(shard uint32) (uint64, bool) { + if c == nil || shard >= c.config.ShardCount { + return 0, false + } + return c.supply[shard], true +} + +func (c *EpochCollector) clone() *EpochCollector { + if c == nil { + return nil + } + out := &EpochCollector{ + config: c.config, + shards: make(map[uint32]*shardEpochAccumulator, len(c.shards)), + supply: make(map[uint32]uint64, len(c.supply)), + verifiedWork: append([]compute.VerifiedWork(nil), c.verifiedWork...), + lastHeight: c.lastHeight, + } + for shard, amount := range c.supply { + out.supply[shard] = amount + } + for shard, source := range c.shards { + copyAccumulator := *source + if source.velocity != nil { + velocityCopy := *source.velocity + velocityCopy.weightedValueBps.Set(&source.velocity.weightedValueBps) + copyAccumulator.velocity = &velocityCopy + } + out.shards[shard] = ©Accumulator + } + return out +} + +func addTo(target *uint64, value uint64) error { + if target == nil || math.MaxUint64-*target < value { + return ErrFinalizedEconomics + } + *target += value + return nil +} diff --git a/internal/v2/economics/finalized_collector_checkpoint.go b/internal/v2/economics/finalized_collector_checkpoint.go new file mode 100644 index 00000000..d28ab30e --- /dev/null +++ b/internal/v2/economics/finalized_collector_checkpoint.go @@ -0,0 +1,406 @@ +package economics + +import ( + "math/big" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + collectorCheckpointVersion uint16 = 1 + maxCheckpointVerifiedWork = 1_000_000 + maxVelocityAccumulatorBytes = 128 +) + +// CheckpointBytes serializes the exact mid-epoch collector state. The workload +// registry itself is not serialized; only its commitment is stored so restore +// must be supplied the intended registry explicitly. +func (c *EpochCollector) CheckpointBytes() ([]byte, error) { + if c == nil || c.config.Epoch == 0 || c.config.ShardCount == 0 || len(c.verifiedWork) > maxCheckpointVerifiedWork { + return nil, ErrFinalizedEconomics + } + registryHash, err := collectorRegistryHash(c.config.WorkRegistry) + if err != nil { + return nil, err + } + var w codec.Writer + w.U16(collectorCheckpointVersion) + w.U64(c.config.Epoch) + w.U32(c.config.ShardCount) + w.Fixed(c.config.NativeToken[:]) + w.Fixed(registryHash[:]) + w.U64(c.config.VelocityPolicy.MinAgeBlocks) + w.U64(c.config.VelocityPolicy.FullWeightAgeBlocks) + w.U32(c.config.VelocityPolicy.MaxVelocityBps) + w.U32(c.config.FeePolicy.BurnBps) + w.U32(c.config.FeePolicy.ValidatorBps) + w.U32(c.config.FeePolicy.ReserveBps) + w.U64(c.lastHeight) + + for shard := uint32(0); shard < c.config.ShardCount; shard++ { + acc := c.shards[shard] + if acc == nil || acc.velocity == nil || c.config.ResourceCapacityPerBlock[shard] == 0 { + return nil, ErrFinalizedEconomics + } + w.U64(c.config.ResourceCapacityPerBlock[shard]) + w.U64(c.supply[shard]) + w.U64(acc.fees.Total) + w.U64(acc.fees.Burn) + w.U64(acc.fees.Validators) + w.U64(acc.fees.Reserve) + w.U64(acc.operations) + w.U64(acc.resourceUsed) + w.U64(acc.resourceCapacity) + w.U64(acc.openingBacklog) + w.U64(acc.newDemand) + w.U64(acc.fulfilled) + w.U64(acc.expired) + w.U64(acc.closingBacklog) + w.U64(acc.computeSupply) + w.Bool(acc.computeSupplyReliable) + weighted := acc.velocity.weightedValueBps.Bytes() + if len(weighted) > maxVelocityAccumulatorBytes { + return nil, ErrFinalizedEconomics + } + w.Bytes(weighted) + w.U64(acc.velocity.observedSpends) + w.U64(acc.velocity.eligibleSpends) + w.U64(acc.velocity.unknownAgeSpends) + w.U64(acc.velocity.freshSpends) + } + + w.U32(uint32(len(c.verifiedWork))) + for _, observation := range c.verifiedWork { + if err := writeVerifiedWork(&w, observation); err != nil { + return nil, err + } + } + return w.BytesCopy(), nil +} + +func RestoreEpochCollector(data []byte, registry *compute.WorkRegistry) (*EpochCollector, error) { + r := codec.NewReader(data) + version, err := r.U16() + if err != nil || version != collectorCheckpointVersion { + return nil, ErrFinalizedEconomics + } + epoch, err := r.U64() + if err != nil || epoch == 0 { + return nil, ErrFinalizedEconomics + } + shardCount, err := r.U32() + if err != nil || shardCount == 0 || shardCount > 1_000_000 { + return nil, ErrFinalizedEconomics + } + nativeRaw, err := r.Fixed(32) + if err != nil { + return nil, ErrFinalizedEconomics + } + registryRaw, err := r.Fixed(32) + if err != nil { + return nil, ErrFinalizedEconomics + } + var native types.TokenID + var expectedRegistryHash types.Hash + copy(native[:], nativeRaw) + copy(expectedRegistryHash[:], registryRaw) + if types.IsZero32([32]byte(native)) { + return nil, ErrFinalizedEconomics + } + actualRegistryHash, err := collectorRegistryHash(registry) + if err != nil || actualRegistryHash != expectedRegistryHash { + return nil, ErrFinalizedEconomics + } + velocityPolicy := VelocityPolicy{} + velocityPolicy.MinAgeBlocks, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + velocityPolicy.FullWeightAgeBlocks, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + velocityPolicy.MaxVelocityBps, err = r.U32() + if err != nil { + return nil, ErrFinalizedEconomics + } + if _, err := NewVelocityAccumulator(velocityPolicy); err != nil { + return nil, err + } + feePolicy := FeePolicy{} + feePolicy.BurnBps, err = r.U32() + if err != nil { + return nil, ErrFinalizedEconomics + } + feePolicy.ValidatorBps, err = r.U32() + if err != nil { + return nil, ErrFinalizedEconomics + } + feePolicy.ReserveBps, err = r.U32() + if err != nil || feePolicy != CompatibilityFeePolicy() { + return nil, ErrFinalizedEconomics + } + lastHeight, err := r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + + capacity := make(map[uint32]uint64, shardCount) + supply := make(map[uint32]uint64, shardCount) + shards := make(map[uint32]*shardEpochAccumulator, shardCount) + for shard := uint32(0); shard < shardCount; shard++ { + perBlock, err := r.U64() + if err != nil || perBlock == 0 { + return nil, ErrFinalizedEconomics + } + capacity[shard] = perBlock + supply[shard], err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc := &shardEpochAccumulator{} + acc.fees.Total, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.fees.Burn, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.fees.Validators, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.fees.Reserve, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.operations, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.resourceUsed, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.resourceCapacity, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.openingBacklog, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.newDemand, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.fulfilled, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.expired, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.closingBacklog, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.computeSupply, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.computeSupplyReliable, err = r.Bool() + if err != nil { + return nil, ErrFinalizedEconomics + } + weighted, err := r.Bytes(maxVelocityAccumulatorBytes) + if err != nil { + return nil, ErrFinalizedEconomics + } + velocity, err := NewVelocityAccumulator(velocityPolicy) + if err != nil { + return nil, err + } + velocity.weightedValueBps.SetBytes(weighted) + velocity.observedSpends, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + velocity.eligibleSpends, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + velocity.unknownAgeSpends, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + velocity.freshSpends, err = r.U64() + if err != nil { + return nil, ErrFinalizedEconomics + } + acc.velocity = velocity + if err := validateRestoredAccumulator(acc); err != nil { + return nil, err + } + shards[shard] = acc + } + + verifiedCount, err := r.U32() + if err != nil || verifiedCount > maxCheckpointVerifiedWork { + return nil, ErrFinalizedEconomics + } + verified := make([]compute.VerifiedWork, int(verifiedCount)) + for i := range verified { + verified[i], err = readVerifiedWork(r) + if err != nil { + return nil, err + } + } + if r.Done() != nil { + return nil, ErrFinalizedEconomics + } + ownedRegistry, err := cloneCollectorRegistry(registry) + if err != nil { + return nil, err + } + return &EpochCollector{ + config: EpochCollectorConfig{ + Epoch: epoch, ShardCount: shardCount, NativeToken: native, + InitialCirculatingSupply: copyShardMap(supply), + OpeningComputeBacklog: make(map[uint32]uint64, shardCount), + ResourceCapacityPerBlock: capacity, + VelocityPolicy: velocityPolicy, + FeePolicy: feePolicy, + WorkRegistry: ownedRegistry, + }, + shards: shards, supply: supply, verifiedWork: verified, lastHeight: lastHeight, + }, nil +} + +func collectorRegistryHash(registry *compute.WorkRegistry) (types.Hash, error) { + if registry == nil { + return types.Hash{}, nil + } + return registry.Hash() +} + +func cloneCollectorRegistry(registry *compute.WorkRegistry) (*compute.WorkRegistry, error) { + if registry == nil { + return nil, nil + } + return registry.Clone() +} + +func validateRestoredAccumulator(acc *shardEpochAccumulator) error { + if acc == nil || acc.velocity == nil || acc.resourceUsed > acc.resourceCapacity || + acc.fees.Total != acc.fees.Burn+acc.fees.Validators+acc.fees.Reserve || + !validComputeFlow(acc.openingBacklog, acc.newDemand, acc.fulfilled, acc.expired, acc.closingBacklog) || + (acc.computeSupplyReliable && acc.fulfilled > acc.computeSupply) || + acc.velocity.eligibleSpends > acc.velocity.observedSpends || + acc.velocity.unknownAgeSpends > acc.velocity.observedSpends || + acc.velocity.freshSpends > acc.velocity.observedSpends { + return ErrFinalizedEconomics + } + return nil +} + +func writeVerifiedWork(w *codec.Writer, observation compute.VerifiedWork) error { + if w == nil || types.IsZero32([32]byte(observation.JobID)) || observation.Class <= compute.WorkUnknown || + observation.Class >= compute.WorkClassCount || observation.Units == 0 || observation.Vector.IsZero() || observation.PaidZPH == 0 || + observation.Verification <= compute.VerificationUnknown || observation.Verification > compute.VerificationHybrid || + types.IsZero32([32]byte(observation.ResultRoot)) { + return ErrFinalizedEconomics + } + w.Fixed(observation.JobID[:]) + w.U8(uint8(observation.Class)) + w.U64(observation.Units) + writeWorkVector(w, observation.Vector) + w.U64(observation.PaidZPH) + w.U8(uint8(observation.Verification)) + w.Fixed(observation.ResultRoot[:]) + return nil +} + +func readVerifiedWork(r *codec.Reader) (compute.VerifiedWork, error) { + jobRaw, err := r.Fixed(32) + if err != nil { + return compute.VerifiedWork{}, ErrFinalizedEconomics + } + class, err := r.U8() + if err != nil { + return compute.VerifiedWork{}, ErrFinalizedEconomics + } + units, err := r.U64() + if err != nil { + return compute.VerifiedWork{}, ErrFinalizedEconomics + } + vector, err := readWorkVector(r) + if err != nil { + return compute.VerifiedWork{}, err + } + paid, err := r.U64() + if err != nil { + return compute.VerifiedWork{}, ErrFinalizedEconomics + } + verification, err := r.U8() + if err != nil { + return compute.VerifiedWork{}, ErrFinalizedEconomics + } + rootRaw, err := r.Fixed(32) + if err != nil { + return compute.VerifiedWork{}, ErrFinalizedEconomics + } + var jobID types.JobID + var resultRoot types.Hash + copy(jobID[:], jobRaw) + copy(resultRoot[:], rootRaw) + out := compute.VerifiedWork{ + JobID: jobID, Class: compute.WorkClass(class), Units: units, Vector: vector, + PaidZPH: paid, Verification: compute.VerificationMode(verification), ResultRoot: resultRoot, + } + var sink codec.Writer + if err := writeVerifiedWork(&sink, out); err != nil { + return compute.VerifiedWork{}, err + } + return out, nil +} + +func writeWorkVector(w *codec.Writer, vector compute.WorkVector) { + w.U64(vector.CPUUnits) + w.U64(vector.GPUFP32Units) + w.U64(vector.GPUFP64Units) + w.U64(vector.TensorUnits) + w.U64(vector.MemoryByteSeconds) + w.U64(vector.VRAMByteSeconds) + w.U64(vector.StorageBytes) + w.U64(vector.NetworkBytes) +} + +func readWorkVector(r *codec.Reader) (compute.WorkVector, error) { + values := make([]uint64, 8) + var err error + for i := range values { + values[i], err = r.U64() + if err != nil { + return compute.WorkVector{}, ErrFinalizedEconomics + } + } + return compute.WorkVector{ + CPUUnits: values[0], GPUFP32Units: values[1], GPUFP64Units: values[2], TensorUnits: values[3], + MemoryByteSeconds: values[4], VRAMByteSeconds: values[5], StorageBytes: values[6], NetworkBytes: values[7], + }, nil +} + +func copyShardMap(source map[uint32]uint64) map[uint32]uint64 { + out := make(map[uint32]uint64, len(source)) + for shard, value := range source { + out[shard] = value + } + return out +} + +var _ = big.NewInt diff --git a/internal/v2/economics/finalized_collector_checkpoint_test.go b/internal/v2/economics/finalized_collector_checkpoint_test.go new file mode 100644 index 00000000..64371b55 --- /dev/null +++ b/internal/v2/economics/finalized_collector_checkpoint_test.go @@ -0,0 +1,152 @@ +package economics + +import ( + "bytes" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestEpochCollectorCheckpointRoundTrip(t *testing.T) { + registry, err := compute.NewWorkRegistry([]compute.WorkSpec{{ + Version: compute.WorkSpecVersion, Class: compute.WorkCPUGeneral, Units: 100, + WorkloadHash: types.Hash{1}, BenchmarkHash: types.Hash{2}, + Vector: compute.WorkVector{CPUUnits: 100}, + }}) + if err != nil { + t.Fatal(err) + } + collector, err := NewEpochCollector(EpochCollectorConfig{ + Epoch: 3, ShardCount: 2, NativeToken: types.TokenID{3}, + InitialCirculatingSupply: map[uint32]uint64{0: 900, 1: 100}, + OpeningComputeBacklog: map[uint32]uint64{0: 50}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 1_000, 1: 2_000}, + VelocityPolicy: VelocityPolicy{ + MinAgeBlocks: 2, FullWeightAgeBlocks: 20, MaxVelocityBps: 10_000, + }, + FeePolicy: CompatibilityFeePolicy(), + WorkRegistry: registry, + }) + if err != nil { + t.Fatal(err) + } + collector.lastHeight = 22 + first := collector.shards[0] + first.fees = FeeAllocation{Total: 7, Burn: 7} + first.operations = 9 + first.resourceUsed = 50 + first.resourceCapacity = 2_000 + first.newDemand = 100 + first.fulfilled = 80 + first.expired = 10 + first.closingBacklog = 60 + first.computeSupply = 100 + first.computeSupplyReliable = true + first.velocity.weightedValueBps.SetUint64(123_456) + first.velocity.observedSpends = 4 + first.velocity.eligibleSpends = 2 + first.velocity.unknownAgeSpends = 1 + first.velocity.freshSpends = 1 + second := collector.shards[1] + second.resourceCapacity = 4_000 + collector.verifiedWork = []compute.VerifiedWork{{ + JobID: types.JobID{4}, Class: compute.WorkCPUGeneral, Units: 100, + Vector: compute.WorkVector{CPUUnits: 100}, PaidZPH: 25, + Verification: compute.VerificationReplicated, ResultRoot: types.Hash{5}, + }} + + raw, err := collector.CheckpointBytes() + if err != nil { + t.Fatal(err) + } + restored, err := RestoreEpochCollector(raw, registry) + if err != nil { + t.Fatal(err) + } + rawAgain, err := restored.CheckpointBytes() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw, rawAgain) { + t.Fatal("collector checkpoint is not canonical across restore") + } + if restored.Epoch() != 3 || restored.lastHeight != 22 || restored.supply[0] != 900 || restored.supply[1] != 100 { + t.Fatalf("restored collector lost identity/state: %#v", restored) + } + if restored.shards[0].velocity.weightedValueBps.Uint64() != 123_456 || len(restored.verifiedWork) != 1 { + t.Fatalf("restored collector lost accumulated telemetry: %#v", restored.shards[0]) + } +} + +func TestEpochCollectorCheckpointRejectsDifferentWorkRegistry(t *testing.T) { + original, err := compute.NewWorkRegistry([]compute.WorkSpec{{ + Version: compute.WorkSpecVersion, Class: compute.WorkCPUGeneral, Units: 100, + WorkloadHash: types.Hash{1}, BenchmarkHash: types.Hash{2}, Vector: compute.WorkVector{CPUUnits: 100}, + }}) + if err != nil { + t.Fatal(err) + } + collector, err := NewEpochCollector(EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: types.TokenID{3}, + InitialCirculatingSupply: map[uint32]uint64{0: 1_000}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 100}, + VelocityPolicy: VelocityPolicy{MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000}, + FeePolicy: CompatibilityFeePolicy(), WorkRegistry: original, + }) + if err != nil { + t.Fatal(err) + } + raw, err := collector.CheckpointBytes() + if err != nil { + t.Fatal(err) + } + changed, err := compute.NewWorkRegistry([]compute.WorkSpec{{ + Version: compute.WorkSpecVersion, Class: compute.WorkCPUGeneral, Units: 101, + WorkloadHash: types.Hash{1}, BenchmarkHash: types.Hash{2}, Vector: compute.WorkVector{CPUUnits: 101}, + }}) + if err != nil { + t.Fatal(err) + } + if _, err := RestoreEpochCollector(raw, changed); err == nil { + t.Fatal("checkpoint restored with a different workload registry") + } +} + +func TestRuntimeCollectorCloneOwnsRegistryAndCapacityConfig(t *testing.T) { + registry, err := compute.NewWorkRegistry([]compute.WorkSpec{{ + Version: compute.WorkSpecVersion, Class: compute.WorkCPUGeneral, Units: 10, + WorkloadHash: types.Hash{1}, BenchmarkHash: types.Hash{2}, Vector: compute.WorkVector{CPUUnits: 10}, + }}) + if err != nil { + t.Fatal(err) + } + capacity := map[uint32]uint64{0: 100} + collector, err := NewEpochCollector(EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: types.TokenID{3}, + InitialCirculatingSupply: map[uint32]uint64{0: 1_000}, + ResourceCapacityPerBlock: capacity, + VelocityPolicy: VelocityPolicy{MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000}, + FeePolicy: CompatibilityFeePolicy(), WorkRegistry: registry, + }) + if err != nil { + t.Fatal(err) + } + owned := collector.Clone() + if owned == nil { + t.Fatal("collector clone failed") + } + capacity[0] = 999 + if err := registry.Register(compute.WorkSpec{ + Version: compute.WorkSpecVersion, Class: compute.WorkRendering, Units: 20, + WorkloadHash: types.Hash{9}, BenchmarkHash: types.Hash{10}, Vector: compute.WorkVector{GPUFP32Units: 20}, + }); err != nil { + t.Fatal(err) + } + if owned.config.ResourceCapacityPerBlock[0] != 100 { + t.Fatal("external capacity map mutation leaked into runtime-owned collector") + } + if _, ok := owned.config.WorkRegistry.Resolve(types.Hash{9}); ok { + t.Fatal("external registry mutation leaked into runtime-owned collector") + } +} diff --git a/internal/v2/economics/finalized_collector_clone.go b/internal/v2/economics/finalized_collector_clone.go new file mode 100644 index 00000000..43702ea5 --- /dev/null +++ b/internal/v2/economics/finalized_collector_clone.go @@ -0,0 +1,35 @@ +package economics + +// Clone returns an independent deep copy of the finalized economics collector. +// Runtime owners use this to prevent external callers from mutating economic +// telemetry or its protocol configuration outside the node synchronization +// boundary. +func (c *EpochCollector) Clone() *EpochCollector { + out := c.clone() + if out == nil { + return nil + } + out.config.InitialCirculatingSupply = copyShardMap(c.config.InitialCirculatingSupply) + out.config.OpeningComputeBacklog = copyShardMap(c.config.OpeningComputeBacklog) + out.config.ResourceCapacityPerBlock = copyShardMap(c.config.ResourceCapacityPerBlock) + registry, err := cloneCollectorRegistry(c.config.WorkRegistry) + if err != nil { + return nil + } + out.config.WorkRegistry = registry + return out +} + +func (c *EpochCollector) Epoch() uint64 { + if c == nil { + return 0 + } + return c.config.Epoch +} + +func (c *EpochCollector) LastHeight() uint64 { + if c == nil { + return 0 + } + return c.lastHeight +} diff --git a/internal/v2/economics/finalized_collector_preview.go b/internal/v2/economics/finalized_collector_preview.go new file mode 100644 index 00000000..3f106d8b --- /dev/null +++ b/internal/v2/economics/finalized_collector_preview.go @@ -0,0 +1,34 @@ +package economics + +import "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + +// PreviewFinalizedBlock evaluates a finalized block observation without +// advancing the receiver. A node can prepare this preview before durable state +// Apply and promote it only after the block transition succeeds. +func (c *EpochCollector) PreviewFinalizedBlock(height uint64, observations map[uint32]FinalizedShardObservation) (*EpochCollector, error) { + if c == nil { + return nil, ErrFinalizedEconomics + } + preview := c.clone() + if preview == nil { + return nil, ErrFinalizedEconomics + } + if err := preview.observeFinalizedBlock(height, observations); err != nil { + return nil, err + } + return preview, nil +} + +func (c *EpochCollector) ShardCount() uint32 { + if c == nil { + return 0 + } + return c.config.ShardCount +} + +func (c *EpochCollector) NativeTokenID() types.TokenID { + if c == nil { + return types.TokenID{} + } + return c.config.NativeToken +} diff --git a/internal/v2/economics/finalized_collector_test.go b/internal/v2/economics/finalized_collector_test.go new file mode 100644 index 00000000..4987af3d --- /dev/null +++ b/internal/v2/economics/finalized_collector_test.go @@ -0,0 +1,189 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/execution" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func testCollectorConfig(shards uint32, native types.TokenID) EpochCollectorConfig { + supply := make(map[uint32]uint64, shards) + capacity := make(map[uint32]uint64, shards) + for shard := uint32(0); shard < shards; shard++ { + capacity[shard] = 10_000 + } + return EpochCollectorConfig{ + Epoch: 1, ShardCount: shards, NativeToken: native, + InitialCirculatingSupply: supply, + OpeningComputeBacklog: make(map[uint32]uint64), + ResourceCapacityPerBlock: capacity, + VelocityPolicy: VelocityPolicy{ + MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000, + }, + FeePolicy: CompatibilityFeePolicy(), + } +} + +func TestEpochCollectorMovesCrossShardSupplyOnImport(t *testing.T) { + native := types.TokenID{1} + cfg := testCollectorConfig(2, native) + cfg.InitialCirculatingSupply[0] = 1_000 + collector, err := NewEpochCollector(cfg) + if err != nil { + t.Fatal(err) + } + output, err := object.NewCoinOutputAtHeight(types.AccountID{2}, native, 100, 1) + if err != nil { + t.Fatal(err) + } + receipt := sharding.CrossShardReceipt{SourceShard: 0, DestinationShard: 1, Output: output} + if err := collector.ObserveFinalizedBlock(1, map[uint32]FinalizedShardObservation{ + 1: {Imports: []sharding.CrossShardReceipt{receipt}}, + }); err != nil { + t.Fatal(err) + } + left, _ := collector.CirculatingSupply(0) + right, _ := collector.CirculatingSupply(1) + if left != 900 || right != 100 { + t.Fatalf("unexpected shard supply attribution: %d/%d", left, right) + } + metrics, _, err := collector.FinalizeEpoch() + if err != nil { + t.Fatal(err) + } + if metrics[0].CirculatingNativeSupply+metrics[1].CirculatingNativeSupply != 1_000 { + t.Fatal("cross-shard import changed global supply") + } +} + +func TestEpochCollectorRejectsBlockAtomically(t *testing.T) { + native := types.TokenID{1} + cfg := testCollectorConfig(2, native) + cfg.InitialCirculatingSupply[0] = 100 + collector, err := NewEpochCollector(cfg) + if err != nil { + t.Fatal(err) + } + output, err := object.NewCoinOutputAtHeight(types.AccountID{2}, native, 200, 1) + if err != nil { + t.Fatal(err) + } + err = collector.ObserveFinalizedBlock(1, map[uint32]FinalizedShardObservation{ + 1: {Imports: []sharding.CrossShardReceipt{{SourceShard: 0, DestinationShard: 1, Output: output}}}, + }) + if err == nil { + t.Fatal("expected impossible cross-shard supply rejection") + } + left, _ := collector.CirculatingSupply(0) + right, _ := collector.CirculatingSupply(1) + if left != 100 || right != 0 { + t.Fatalf("failed observation partially mutated collector: %d/%d", left, right) + } +} + +func TestEpochCollectorCarriesComputeBacklogAndBuildsVerifiedWork(t *testing.T) { + native := types.TokenID{1} + workload := types.Hash{3} + registry, err := compute.NewWorkRegistry([]compute.WorkSpec{{ + Version: compute.WorkSpecVersion, Class: compute.WorkAITraining, Units: 100, + WorkloadHash: workload, BenchmarkHash: types.Hash{4}, + Vector: compute.WorkVector{TensorUnits: 100}, + }}) + if err != nil { + t.Fatal(err) + } + cfg := testCollectorConfig(1, native) + cfg.InitialCirculatingSupply[0] = 1_000 + cfg.WorkRegistry = registry + collector, err := NewEpochCollector(cfg) + if err != nil { + t.Fatal(err) + } + + job := compute.Job{ + Owner: types.AccountID{5}, WorkloadHash: workload, InputRoot: types.Hash{6}, + Resources: compute.Resources{CPUCores: 1, MemoryMiB: 1}, MaxPrice: 30, + CollateralRequired: 2, Verification: compute.VerificationReplicated, + DeadlineHeight: 100, Replicas: 2, + } + jobRaw, err := job.MarshalBinary() + if err != nil { + t.Fatal(err) + } + post := tx.Transaction{ShardID: 0, Operations: []tx.Operation{{Kind: tx.OpComputeJob, Payload: jobRaw}}} + postResult := execution.Result{TxID: post.ID()} + if err := collector.ObserveFinalizedBlock(1, map[uint32]FinalizedShardObservation{ + 0: {Transactions: []tx.Transaction{post}, Results: []execution.Result{postResult}}, + }); err != nil { + t.Fatal(err) + } + first, _, err := collector.FinalizeEpoch() + if err != nil { + t.Fatal(err) + } + if first[0].EscrowBackedComputeDemand != 100 || first[0].ComputeBacklog != 100 { + t.Fatalf("unexpected first epoch compute flow: %#v", first[0]) + } + if err := collector.AdvanceEpoch(2); err != nil { + t.Fatal(err) + } + + jobID := types.JobID{7} + providers := []types.AccountID{{8}, {9}} + root := types.Hash{10} + record := compute.OnChainJob{ + ID: jobID, Job: job, Escrow: 30, Status: compute.JobAwaitingVerification, + Assignments: []compute.Assignment{ + {OfferID: types.Hash{11}, Provider: providers[0], Price: 10}, + {OfferID: types.Hash{12}, Provider: providers[1], Price: 20}, + }, + Results: []compute.Result{ + {JobID: jobID, Provider: providers[0], ResultRoot: root, CompletedHeight: 2}, + {JobID: jobID, Provider: providers[1], ResultRoot: root, CompletedHeight: 2}, + }, + } + recordRaw, err := record.MarshalBinary() + if err != nil { + t.Fatal(err) + } + receipt := compute.SettlementReceipt{ + JobID: jobID, ResultRoot: root, + Payments: map[types.AccountID]uint64{providers[0]: 10, providers[1]: 20}, + } + jobObject := object.Object{ID: types.ObjectID{13}, Version: 1, Owner: job.Owner, Kind: object.KindComputeJob, Data: recordRaw} + finalize := tx.Transaction{ + ShardID: 0, + Operations: []tx.Operation{{Kind: tx.OpComputeFinalize}}, + Witnesses: []tx.Witness{{Object: jobObject}}, + } + finalizeResult := execution.Result{ + TxID: finalize.ID(), + Created: []object.Object{{ID: types.ObjectID{14}, Version: 1, Kind: object.KindSystem, Data: receipt.MarshalBinary()}}, + } + if err := collector.ObserveFinalizedBlock(2, map[uint32]FinalizedShardObservation{ + 0: { + Transactions: []tx.Transaction{finalize}, Results: []execution.Result{finalizeResult}, + ComputeCapacityUnits: 100, ComputeCapacityReliable: true, + }, + }); err != nil { + t.Fatal(err) + } + second, verified, err := collector.FinalizeEpoch() + if err != nil { + t.Fatal(err) + } + if second[0].OpeningComputeBacklog != 100 || second[0].ComputeFulfilled != 100 || second[0].ComputeBacklog != 0 { + t.Fatalf("unexpected second epoch compute flow: %#v", second[0]) + } + if !second[0].ComputeSupplyReliable || second[0].VerifiedComputeSupply != 100 { + t.Fatalf("unexpected compute supply: %#v", second[0]) + } + if len(verified) != 1 || verified[0].PaidZPH != 30 || verified[0].Units != 100 { + t.Fatalf("unexpected verified work: %#v", verified) + } +} diff --git a/internal/v2/economics/monetary.go b/internal/v2/economics/monetary.go new file mode 100644 index 00000000..5feaa666 --- /dev/null +++ b/internal/v2/economics/monetary.go @@ -0,0 +1,215 @@ +package economics + +import ( + "errors" + "math/big" +) + +var ErrMonetaryPolicy = errors.New("invalid Zephyr adaptive monetary policy input") + +type MonetaryPolicy struct { + TargetInflationBps uint32 + MinInflationBps uint32 + MaxInflationBps uint32 + MaxEpochStepBps uint32 + EpochsPerYear uint64 + ReserveTargetBps uint32 + StakeTargetBps uint32 + UtilizationTargetBps uint32 + VelocityTargetBps uint32 + OperationsTarget uint64 + ReserveWeightBps uint32 + StakeWeightBps uint32 + UtilizationWeightBps uint32 + VelocityWeightBps uint32 + OperationsWeightBps uint32 +} + +type MonetaryMetrics struct { + Supply uint64 + CirculatingSupply uint64 + StakedSupply uint64 + ProtocolReserve uint64 + BurnedThisEpoch uint64 + FinalizedOperations uint64 + ResourceUtilizationBps uint32 + AgeWeightedVelocityBps uint32 + ComputeIndexQ9 uint64 + ComputePriceTrendBps int32 + ComputeIndexReliable bool +} + +type MonetaryDecision struct { + Shadow bool + TargetInflationBps uint32 + NetIssuanceTarget uint64 + BurnOffset uint64 + GrossMintTarget uint64 + ProjectedNetChange uint64 + ReserveRatioBps uint32 + StakeRatioBps uint32 + OperationsActivityBps uint32 + ComputeIndexQ9 uint64 + ComputePriceTrendBps int32 + ComputeIndexReliable bool +} + +func DefaultShadowPolicy() MonetaryPolicy { + return MonetaryPolicy{ + TargetInflationBps: 200, + MinInflationBps: 150, + MaxInflationBps: 250, + MaxEpochStepBps: 1, + EpochsPerYear: 365, + ReserveTargetBps: 1_000, + StakeTargetBps: 5_000, + UtilizationTargetBps: 5_000, + VelocityTargetBps: 5_000, + OperationsTarget: 1_000_000, + ReserveWeightBps: 500, + StakeWeightBps: 500, + UtilizationWeightBps: 250, + VelocityWeightBps: 250, + OperationsWeightBps: 100, + } +} + +// EvaluateShadow computes the monetary action Zephyr would take for an epoch, +// but deliberately does not mutate supply. This is the only supported v2 mode +// until devnet simulations establish stability and manipulation resistance. +func EvaluateShadow(priorTargetBps uint32, metrics MonetaryMetrics, policy MonetaryPolicy) (MonetaryDecision, error) { + if err := validateMonetary(metrics, policy); err != nil { + return MonetaryDecision{}, err + } + reserveRatio := ratioBps(metrics.ProtocolReserve, metrics.Supply) + stakeRatio := ratioBps(metrics.StakedSupply, metrics.CirculatingSupply) + operationsActivity := activityBps(metrics.FinalizedOperations, policy.OperationsTarget) + + correction := int64(0) + correction += weightedGap(policy.ReserveTargetBps, reserveRatio, policy.ReserveWeightBps) + correction += weightedGap(policy.StakeTargetBps, stakeRatio, policy.StakeWeightBps) + correction += weightedGap(policy.UtilizationTargetBps, metrics.ResourceUtilizationBps, policy.UtilizationWeightBps) + correction += weightedGap(policy.VelocityTargetBps, metrics.AgeWeightedVelocityBps, policy.VelocityWeightBps) + correction += weightedGap(BasisPoints, operationsActivity, policy.OperationsWeightBps) + + target := clampTarget(int64(policy.TargetInflationBps)+correction, policy.MinInflationBps, policy.MaxInflationBps) + if priorTargetBps != 0 { + target = rateLimitTarget(priorTargetBps, target, policy.MaxEpochStepBps) + } + netTarget, err := epochIssuance(metrics.Supply, target, policy.EpochsPerYear) + if err != nil { + return MonetaryDecision{}, err + } + gross, err := addUint64(netTarget, metrics.BurnedThisEpoch) + if err != nil { + return MonetaryDecision{}, err + } + return MonetaryDecision{ + Shadow: true, + TargetInflationBps: target, + NetIssuanceTarget: netTarget, + BurnOffset: metrics.BurnedThisEpoch, + GrossMintTarget: gross, + ProjectedNetChange: netTarget, + ReserveRatioBps: reserveRatio, + StakeRatioBps: stakeRatio, + OperationsActivityBps: operationsActivity, + ComputeIndexQ9: metrics.ComputeIndexQ9, + ComputePriceTrendBps: metrics.ComputePriceTrendBps, + ComputeIndexReliable: metrics.ComputeIndexReliable, + }, nil +} + +func validateMonetary(metrics MonetaryMetrics, policy MonetaryPolicy) error { + if metrics.Supply == 0 || metrics.CirculatingSupply == 0 || metrics.CirculatingSupply > metrics.Supply || + metrics.StakedSupply > metrics.CirculatingSupply || metrics.ProtocolReserve > metrics.Supply || + policy.EpochsPerYear == 0 || policy.OperationsTarget == 0 || policy.MinInflationBps > policy.TargetInflationBps || + policy.TargetInflationBps > policy.MaxInflationBps || policy.MaxInflationBps > BasisPoints || + policy.ReserveTargetBps > BasisPoints || policy.StakeTargetBps > BasisPoints || + policy.UtilizationTargetBps > BasisPoints || policy.VelocityTargetBps > BasisPoints || + metrics.ResourceUtilizationBps > 2*BasisPoints || metrics.AgeWeightedVelocityBps > 2*BasisPoints { + return ErrMonetaryPolicy + } + weights := []uint32{policy.ReserveWeightBps, policy.StakeWeightBps, policy.UtilizationWeightBps, policy.VelocityWeightBps, policy.OperationsWeightBps} + for _, weight := range weights { + if weight > BasisPoints { + return ErrMonetaryPolicy + } + } + return nil +} + +func weightedGap(target, actual, weight uint32) int64 { + gap := int64(target) - int64(actual) + return gap * int64(weight) / int64(BasisPoints) +} + +func clampTarget(target int64, minimum, maximum uint32) uint32 { + if target < int64(minimum) { + return minimum + } + if target > int64(maximum) { + return maximum + } + return uint32(target) +} + +func rateLimitTarget(prior, next, maximumStep uint32) uint32 { + if maximumStep == 0 || prior == next { + return prior + } + if next > prior { + if next-prior > maximumStep { + return prior + maximumStep + } + return next + } + if prior-next > maximumStep { + return prior - maximumStep + } + return next +} + +func ratioBps(value, total uint64) uint32 { + if total == 0 { + return 0 + } + ratio := new(big.Int).Mul(new(big.Int).SetUint64(value), new(big.Int).SetUint64(uint64(BasisPoints))) + ratio.Quo(ratio, new(big.Int).SetUint64(total)) + limit := new(big.Int).SetUint64(uint64(BasisPoints)) + if ratio.Cmp(limit) > 0 { + return BasisPoints + } + return uint32(ratio.Uint64()) +} + +func activityBps(value, target uint64) uint32 { + if target == 0 { + return 0 + } + ratio := new(big.Int).Mul(new(big.Int).SetUint64(value), new(big.Int).SetUint64(uint64(BasisPoints))) + ratio.Quo(ratio, new(big.Int).SetUint64(target)) + limit := new(big.Int).SetUint64(uint64(2 * BasisPoints)) + if ratio.Cmp(limit) > 0 { + return 2 * BasisPoints + } + return uint32(ratio.Uint64()) +} + +func epochIssuance(supply uint64, targetBps uint32, epochsPerYear uint64) (uint64, error) { + value := new(big.Int).Mul(new(big.Int).SetUint64(supply), new(big.Int).SetUint64(uint64(targetBps))) + value.Quo(value, new(big.Int).SetUint64(uint64(BasisPoints))) + value.Quo(value, new(big.Int).SetUint64(epochsPerYear)) + if !value.IsUint64() { + return 0, ErrMonetaryPolicy + } + return value.Uint64(), nil +} + +func addUint64(a, b uint64) (uint64, error) { + value := new(big.Int).Add(new(big.Int).SetUint64(a), new(big.Int).SetUint64(b)) + if !value.IsUint64() { + return 0, ErrMonetaryPolicy + } + return value.Uint64(), nil +} diff --git a/internal/v2/economics/monetary_state.go b/internal/v2/economics/monetary_state.go new file mode 100644 index 00000000..39b086a9 --- /dev/null +++ b/internal/v2/economics/monetary_state.go @@ -0,0 +1,300 @@ +package economics + +import ( + "encoding/binary" + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const MonetaryEpochStateVersion uint16 = 1 + +var ErrMonetaryState = errors.New("invalid Zephyr shadow monetary epoch state") + +type MonetaryEpochState struct { + Version uint16 + Network types.NetworkID + Epoch uint64 + Shadow bool + TotalSupply uint64 + CirculatingSupply uint64 + StakedSupply uint64 + ProtocolReserve uint64 + ZAMPBaseTargetBps uint32 + SuggestedTargetBps uint32 + BaseFee uint64 + AggregateHash types.Hash + ComputeIndexQ9 uint64 + ComputeIndexReliable bool + ComputeScarcityMagnitudeBps uint32 + ComputeScarcityNegative bool + ComputeScarcityReliable bool + FeedbackMode ComputeFeedbackMode + ShadowGrossMintTarget uint64 + ShadowComputeIncentiveMint uint64 + InflationCorrectionMagnitudeBps uint32 + InflationCorrectionNegative bool + PreviousStateHash types.Hash +} + +func (s MonetaryEpochState) Validate() error { + if s.Version != MonetaryEpochStateVersion || types.IsZero32([32]byte(s.Network)) || s.Epoch == 0 || !s.Shadow || + s.TotalSupply == 0 || s.CirculatingSupply == 0 || s.CirculatingSupply > s.TotalSupply || + s.StakedSupply > s.CirculatingSupply || s.ProtocolReserve > s.TotalSupply || + s.ZAMPBaseTargetBps > BasisPoints || s.SuggestedTargetBps > BasisPoints || + types.IsZero32([32]byte(s.AggregateHash)) || s.ComputeScarcityMagnitudeBps > BasisPoints || + s.FeedbackMode > ComputeFeedbackMonetaryBand || s.InflationCorrectionMagnitudeBps > BasisPoints { + return ErrMonetaryState + } + return nil +} + +func (s MonetaryEpochState) CanonicalBytes() ([]byte, error) { + if err := s.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.U16(s.Version) + w.Fixed(s.Network[:]) + w.U64(s.Epoch) + w.Bool(s.Shadow) + w.U64(s.TotalSupply) + w.U64(s.CirculatingSupply) + w.U64(s.StakedSupply) + w.U64(s.ProtocolReserve) + w.U32(s.ZAMPBaseTargetBps) + w.U32(s.SuggestedTargetBps) + w.U64(s.BaseFee) + w.Fixed(s.AggregateHash[:]) + w.U64(s.ComputeIndexQ9) + w.Bool(s.ComputeIndexReliable) + w.U32(s.ComputeScarcityMagnitudeBps) + w.Bool(s.ComputeScarcityNegative) + w.Bool(s.ComputeScarcityReliable) + w.U8(uint8(s.FeedbackMode)) + w.U64(s.ShadowGrossMintTarget) + w.U64(s.ShadowComputeIncentiveMint) + w.U32(s.InflationCorrectionMagnitudeBps) + w.Bool(s.InflationCorrectionNegative) + w.Fixed(s.PreviousStateHash[:]) + return w.BytesCopy(), nil +} + +func ParseMonetaryEpochState(data []byte) (MonetaryEpochState, error) { + r := codec.NewReader(data) + version, err := r.U16() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + networkRaw, err := r.Fixed(32) + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + epoch, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + shadow, err := r.Bool() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + totalSupply, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + circulating, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + staked, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + reserve, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + baseTarget, err := r.U32() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + suggestedTarget, err := r.U32() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + baseFee, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + aggregateRaw, err := r.Fixed(32) + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + computeIndex, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + computeIndexReliable, err := r.Bool() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + scarcityMagnitude, err := r.U32() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + scarcityNegative, err := r.Bool() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + scarcityReliable, err := r.Bool() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + mode, err := r.U8() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + grossMint, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + computeMint, err := r.U64() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + correctionMagnitude, err := r.U32() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + correctionNegative, err := r.Bool() + if err != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + previousRaw, err := r.Fixed(32) + if err != nil || r.Done() != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + var network types.NetworkID + var aggregate, previous types.Hash + copy(network[:], networkRaw) + copy(aggregate[:], aggregateRaw) + copy(previous[:], previousRaw) + out := MonetaryEpochState{ + Version: MonetaryEpochStateVersion, Network: network, Epoch: epoch, Shadow: shadow, + TotalSupply: totalSupply, CirculatingSupply: circulating, StakedSupply: staked, ProtocolReserve: reserve, + ZAMPBaseTargetBps: baseTarget, SuggestedTargetBps: suggestedTarget, BaseFee: baseFee, + AggregateHash: aggregate, ComputeIndexQ9: computeIndex, ComputeIndexReliable: computeIndexReliable, + ComputeScarcityMagnitudeBps: scarcityMagnitude, ComputeScarcityNegative: scarcityNegative, + ComputeScarcityReliable: scarcityReliable, FeedbackMode: ComputeFeedbackMode(mode), + ShadowGrossMintTarget: grossMint, ShadowComputeIncentiveMint: computeMint, + InflationCorrectionMagnitudeBps: correctionMagnitude, InflationCorrectionNegative: correctionNegative, + PreviousStateHash: previous, + } + if version != MonetaryEpochStateVersion || out.Validate() != nil { + return MonetaryEpochState{}, ErrMonetaryState + } + return out, nil +} + +func (s MonetaryEpochState) Hash() (types.Hash, error) { + raw, err := s.CanonicalBytes() + if err != nil { + return types.Hash{}, err + } + return types.Hash(codec.DomainHash("zephyr/monetary-epoch-state/v2", raw)), nil +} + +func MonetaryStateObjectID(network types.NetworkID) types.ObjectID { + var w codec.Writer + w.Fixed(network[:]) + w.String("monetary-epoch") + hash := codec.DomainHash("zephyr/system-object-id/v2", w.BytesCopy()) + binary.BigEndian.PutUint32(hash[:4], 0) + return types.ObjectID(hash) +} + +func (s MonetaryEpochState) Object() (object.Object, error) { + raw, err := s.CanonicalBytes() + if err != nil { + return object.Object{}, err + } + return object.Object{ + ID: MonetaryStateObjectID(s.Network), Version: s.Epoch, + Kind: object.KindSystem, Data: raw, + }, nil +} + +func BuildShadowMonetaryEpochState( + network types.NetworkID, + previous *MonetaryEpochState, + aggregate EpochAggregate, + totalSupply uint64, + stakedSupply uint64, + protocolReserve uint64, + computeIndexQ9 uint64, + computePriceTrendBps int32, + computeIndexReliable bool, + scarcity ComputeScarcitySnapshot, + monetaryPolicy MonetaryPolicy, + feedbackPolicy ComputeFeedbackPolicy, + baseFee uint64, +) (MonetaryEpochState, MonetaryDecision, ComputeFeedbackDecision, error) { + if types.IsZero32([32]byte(network)) || scarcity.Epoch != aggregate.Epoch { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, ErrMonetaryState + } + aggregateHash, err := aggregate.Hash() + if err != nil { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, err + } + metrics, err := aggregate.MonetaryMetrics(totalSupply, stakedSupply, protocolReserve, computeIndexQ9, computePriceTrendBps, computeIndexReliable) + if err != nil { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, err + } + priorTarget := monetaryPolicy.TargetInflationBps + var previousHash types.Hash + if previous != nil { + if previous.Network != network || previous.Epoch+1 != aggregate.Epoch || previous.Validate() != nil { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, ErrMonetaryState + } + priorTarget = previous.ZAMPBaseTargetBps + previousHash, err = previous.Hash() + if err != nil { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, err + } + } + monetaryDecision, err := EvaluateShadow(priorTarget, metrics, monetaryPolicy) + if err != nil { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, err + } + feedback, err := EvaluateComputeFeedback(monetaryDecision, metrics, monetaryPolicy, scarcity, feedbackPolicy) + if err != nil { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, err + } + scarcityMagnitude, scarcityNegative := signedMagnitude(scarcity.ScoreBps) + correctionMagnitude, correctionNegative := signedMagnitude(feedback.InflationCorrectionBps) + state := MonetaryEpochState{ + Version: MonetaryEpochStateVersion, Network: network, Epoch: aggregate.Epoch, Shadow: true, + TotalSupply: totalSupply, CirculatingSupply: aggregate.CirculatingNativeSupply, + StakedSupply: stakedSupply, ProtocolReserve: protocolReserve, + ZAMPBaseTargetBps: monetaryDecision.TargetInflationBps, SuggestedTargetBps: feedback.SuggestedTargetInflationBps, + BaseFee: baseFee, AggregateHash: aggregateHash, ComputeIndexQ9: computeIndexQ9, ComputeIndexReliable: computeIndexReliable, + ComputeScarcityMagnitudeBps: scarcityMagnitude, ComputeScarcityNegative: scarcityNegative, + ComputeScarcityReliable: scarcity.Reliable, FeedbackMode: feedbackPolicy.Mode, + ShadowGrossMintTarget: feedback.SuggestedGrossMint, ShadowComputeIncentiveMint: feedback.SuggestedComputeIncentiveMint, + InflationCorrectionMagnitudeBps: correctionMagnitude, InflationCorrectionNegative: correctionNegative, + PreviousStateHash: previousHash, + } + if err := state.Validate(); err != nil { + return MonetaryEpochState{}, MonetaryDecision{}, ComputeFeedbackDecision{}, err + } + return state, monetaryDecision, feedback, nil +} + +func signedMagnitude(value int32) (uint32, bool) { + if value < 0 { + return uint32(-int64(value)), true + } + return uint32(value), false +} diff --git a/internal/v2/economics/monetary_state_test.go b/internal/v2/economics/monetary_state_test.go new file mode 100644 index 00000000..4c35c3fc --- /dev/null +++ b/internal/v2/economics/monetary_state_test.go @@ -0,0 +1,105 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestShadowMonetaryEpochStateRoundTripAndNoLiveMint(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("economics-state"))) + aggregate := EpochAggregate{ + Epoch: 1, ShardCount: 1, + ChargedFees: 100, BurnedFees: 100, FinalizedOperations: 1_000, + ResourceUsed: 50, ResourceCapacity: 100, ResourceUtilizationBps: 5_000, + CirculatingNativeSupply: 900_000_000, AgeWeightedVelocityBps: 5_000, + EscrowBackedComputeDemand: 2_000, VerifiedComputeSupply: 1_000, + ComputeBacklog: 500, ComputeFulfilled: 1_000, ComputeExpired: 500, ComputeUtilizationBps: 10_000, + } + scarcity, err := BuildComputeScarcity(1, aggregate.ComputeMarketMetrics(1_000, true), DefaultComputeScarcityConfig()) + if err != nil { + t.Fatal(err) + } + monetary := DefaultShadowPolicy() + feedbackPolicy := DefaultComputeFeedbackPolicy(ComputeFeedbackMonetaryBand) + state, base, feedback, err := BuildShadowMonetaryEpochState( + network, nil, aggregate, 1_000_000_000, 450_000_000, 100_000_000, + 7_500_000_000, 1_000, true, scarcity, monetary, feedbackPolicy, 10, + ) + if err != nil { + t.Fatal(err) + } + if !state.Shadow || state.TotalSupply != 1_000_000_000 || state.ShadowGrossMintTarget == 0 { + t.Fatalf("unexpected shadow state: %#v", state) + } + if feedback.SuggestedGrossMint != state.ShadowGrossMintTarget || base.ProjectedNetChange == 0 { + t.Fatalf("shadow decisions were not committed correctly: %#v %#v", base, feedback) + } + raw, err := state.CanonicalBytes() + if err != nil { + t.Fatal(err) + } + parsed, err := ParseMonetaryEpochState(raw) + if err != nil { + t.Fatal(err) + } + if parsed != state { + t.Fatalf("monetary state round trip mismatch: %#v != %#v", parsed, state) + } + obj, err := state.Object() + if err != nil { + t.Fatal(err) + } + if obj.ID != MonetaryStateObjectID(network) || obj.Version != state.Epoch { + t.Fatalf("unexpected monetary system object: %#v", obj) + } +} + +func TestShadowMonetaryEpochStateChainsPreviousEpoch(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("economics-chain"))) + policy := DefaultShadowPolicy() + feedbackPolicy := DefaultComputeFeedbackPolicy(ComputeFeedbackObserveOnly) + makeAggregate := func(epoch uint64) EpochAggregate { + return EpochAggregate{ + Epoch: epoch, ShardCount: 1, ChargedFees: 10, BurnedFees: 10, + FinalizedOperations: 100, ResourceUsed: 50, ResourceCapacity: 100, ResourceUtilizationBps: 5_000, + CirculatingNativeSupply: 900_000_000, AgeWeightedVelocityBps: 5_000, + EscrowBackedComputeDemand: 1_000, VerifiedComputeSupply: 1_000, + ComputeFulfilled: 700, ComputeBacklog: 300, ComputeUtilizationBps: 7_000, + } + } + firstAggregate := makeAggregate(1) + firstScarcity, err := BuildComputeScarcity(1, firstAggregate.ComputeMarketMetrics(0, false), DefaultComputeScarcityConfig()) + if err != nil { + t.Fatal(err) + } + first, _, _, err := BuildShadowMonetaryEpochState(network, nil, firstAggregate, 1_000_000_000, 450_000_000, 100_000_000, 0, 0, false, firstScarcity, policy, feedbackPolicy, 10) + if err != nil { + t.Fatal(err) + } + secondAggregate := makeAggregate(2) + secondScarcity, err := BuildComputeScarcity(2, secondAggregate.ComputeMarketMetrics(0, false), DefaultComputeScarcityConfig()) + if err != nil { + t.Fatal(err) + } + second, _, _, err := BuildShadowMonetaryEpochState(network, &first, secondAggregate, 1_000_000_000, 450_000_000, 100_000_000, 0, 0, false, secondScarcity, policy, feedbackPolicy, 10) + if err != nil { + t.Fatal(err) + } + firstHash, err := first.Hash() + if err != nil { + t.Fatal(err) + } + if second.PreviousStateHash != firstHash { + t.Fatal("monetary epoch state did not bind previous state") + } + + thirdAggregate := makeAggregate(4) + thirdScarcity, err := BuildComputeScarcity(4, thirdAggregate.ComputeMarketMetrics(0, false), DefaultComputeScarcityConfig()) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := BuildShadowMonetaryEpochState(network, &second, thirdAggregate, 1_000_000_000, 450_000_000, 100_000_000, 0, 0, false, thirdScarcity, policy, feedbackPolicy, 10); err != ErrMonetaryState { + t.Fatalf("skipped epoch accepted: %v", err) + } +} diff --git a/internal/v2/economics/monetary_test.go b/internal/v2/economics/monetary_test.go new file mode 100644 index 00000000..ed539f48 --- /dev/null +++ b/internal/v2/economics/monetary_test.go @@ -0,0 +1,77 @@ +package economics + +import "testing" + +func TestShadowMonetaryControllerOffsetsBurnAndTargetsNetIssuance(t *testing.T) { + policy := DefaultShadowPolicy() + metrics := MonetaryMetrics{ + Supply: 1_000_000_000, + CirculatingSupply: 900_000_000, + StakedSupply: 450_000_000, + ProtocolReserve: 100_000_000, + BurnedThisEpoch: 12_345, + FinalizedOperations: policy.OperationsTarget, + ResourceUtilizationBps: policy.UtilizationTargetBps, + AgeWeightedVelocityBps: policy.VelocityTargetBps, + ComputeIndexQ9: 7_500_000_000, + ComputePriceTrendBps: 250, + ComputeIndexReliable: true, + } + decision, err := EvaluateShadow(policy.TargetInflationBps, metrics, policy) + if err != nil { + t.Fatal(err) + } + if !decision.Shadow || decision.TargetInflationBps != policy.TargetInflationBps { + t.Fatalf("unexpected target decision: %#v", decision) + } + if decision.GrossMintTarget != decision.NetIssuanceTarget+metrics.BurnedThisEpoch || decision.ProjectedNetChange != decision.NetIssuanceTarget { + t.Fatalf("burn must be offset before net inflation target: %#v", decision) + } +} + +func TestShadowMonetaryControllerRateLimitsAdaptiveTarget(t *testing.T) { + policy := DefaultShadowPolicy() + metrics := MonetaryMetrics{ + Supply: 1_000_000_000, + CirculatingSupply: 900_000_000, + StakedSupply: 100_000_000, + ProtocolReserve: 1_000_000, + FinalizedOperations: 1, + ResourceUtilizationBps: 100, + AgeWeightedVelocityBps: 100, + } + decision, err := EvaluateShadow(200, metrics, policy) + if err != nil { + t.Fatal(err) + } + if decision.TargetInflationBps != 201 { + t.Fatalf("expected one-basis-point upward rate limit, got %d", decision.TargetInflationBps) + } +} + +func TestComputeIndexIsTelemetryOnlyInShadowV0(t *testing.T) { + policy := DefaultShadowPolicy() + base := MonetaryMetrics{ + Supply: 1_000_000_000, + CirculatingSupply: 900_000_000, + StakedSupply: 450_000_000, + ProtocolReserve: 100_000_000, + FinalizedOperations: policy.OperationsTarget, + ResourceUtilizationBps: policy.UtilizationTargetBps, + AgeWeightedVelocityBps: policy.VelocityTargetBps, + } + first, err := EvaluateShadow(200, base, policy) + if err != nil { + t.Fatal(err) + } + base.ComputeIndexQ9 = 99_000_000_000 + base.ComputePriceTrendBps = 10_000 + base.ComputeIndexReliable = true + second, err := EvaluateShadow(200, base, policy) + if err != nil { + t.Fatal(err) + } + if first.TargetInflationBps != second.TargetInflationBps { + t.Fatalf("compute price must remain telemetry-only before activation study: %d != %d", first.TargetInflationBps, second.TargetInflationBps) + } +} diff --git a/internal/v2/economics/monetary_transition.go b/internal/v2/economics/monetary_transition.go new file mode 100644 index 00000000..7526c073 --- /dev/null +++ b/internal/v2/economics/monetary_transition.go @@ -0,0 +1,37 @@ +package economics + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +// ShadowMonetaryTransition returns the object delta for an epoch transition. +// It does not mutate a store. The caller may include this delta in candidate +// state simulation and apply it only after normal consensus finality. +func ShadowMonetaryTransition(previous *object.Object, next MonetaryEpochState) ([]types.ObjectID, []object.Object, error) { + if err := next.Validate(); err != nil { + return nil, nil, err + } + nextObject, err := next.Object() + if err != nil { + return nil, nil, err + } + if previous == nil { + if next.Epoch != 1 || !types.IsZero32([32]byte(next.PreviousStateHash)) { + return nil, nil, ErrMonetaryState + } + return nil, []object.Object{nextObject}, nil + } + if previous.ID != nextObject.ID || previous.Kind != object.KindSystem { + return nil, nil, ErrMonetaryState + } + priorState, err := ParseMonetaryEpochState(previous.Data) + if err != nil || priorState.Network != next.Network || priorState.Epoch+1 != next.Epoch || previous.Version != priorState.Epoch { + return nil, nil, ErrMonetaryState + } + priorHash, err := priorState.Hash() + if err != nil || next.PreviousStateHash != priorHash { + return nil, nil, ErrMonetaryState + } + return []types.ObjectID{previous.ID}, []object.Object{nextObject}, nil +} diff --git a/internal/v2/economics/monetary_transition_test.go b/internal/v2/economics/monetary_transition_test.go new file mode 100644 index 00000000..1ac68e12 --- /dev/null +++ b/internal/v2/economics/monetary_transition_test.go @@ -0,0 +1,71 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestShadowMonetaryTransitionCanBeFinalizedThroughStateRoot(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("monetary-transition"))) + policy := DefaultShadowPolicy() + feedback := DefaultComputeFeedbackPolicy(ComputeFeedbackObserveOnly) + makeEpoch := func(epoch uint64, previous *MonetaryEpochState) MonetaryEpochState { + aggregate := EpochAggregate{ + Epoch: epoch, ShardCount: 1, ChargedFees: 10, BurnedFees: 10, + FinalizedOperations: 100, ResourceUsed: 50, ResourceCapacity: 100, ResourceUtilizationBps: 5_000, + CirculatingNativeSupply: 900_000_000, AgeWeightedVelocityBps: 4_000, + EscrowBackedComputeDemand: 1_000, VerifiedComputeSupply: 1_000, + ComputeFulfilled: 700, ComputeBacklog: 300, ComputeUtilizationBps: 7_000, + } + scarcity, err := BuildComputeScarcity(epoch, aggregate.ComputeMarketMetrics(0, false), DefaultComputeScarcityConfig()) + if err != nil { + t.Fatal(err) + } + state, _, _, err := BuildShadowMonetaryEpochState(network, previous, aggregate, 1_000_000_000, 450_000_000, 100_000_000, 0, 0, false, scarcity, policy, feedback, 10) + if err != nil { + t.Fatal(err) + } + return state + } + + store := worldstate.NewMemory() + first := makeEpoch(1, nil) + consumed, created, err := ShadowMonetaryTransition(nil, first) + if err != nil { + t.Fatal(err) + } + root1, err := store.Apply(consumed, created) + if err != nil { + t.Fatal(err) + } + firstObject, proof, ok := store.Proof(MonetaryStateObjectID(network)) + if !ok || firstObject.Version != 1 { + t.Fatal("first monetary state not committed") + } + if len(proof.Siblings) == 0 && root1 == (types.Hash{}) { + t.Fatal("monetary state did not affect authenticated root") + } + + second := makeEpoch(2, &first) + consumed, created, err = ShadowMonetaryTransition(&firstObject, second) + if err != nil { + t.Fatal(err) + } + root2, err := store.Apply(consumed, created) + if err != nil { + t.Fatal(err) + } + if root2 == root1 { + t.Fatal("epoch transition did not change state root") + } + secondObject, _, ok := store.Proof(MonetaryStateObjectID(network)) + if !ok || secondObject.Version != 2 { + t.Fatal("second monetary state not committed") + } + parsed, err := ParseMonetaryEpochState(secondObject.Data) + if err != nil || parsed.PreviousStateHash != second.PreviousStateHash { + t.Fatalf("committed monetary state cannot be verified: %#v %v", parsed, err) + } +} diff --git a/internal/v2/economics/shadow_epoch_engine.go b/internal/v2/economics/shadow_epoch_engine.go new file mode 100644 index 00000000..54ccb451 --- /dev/null +++ b/internal/v2/economics/shadow_epoch_engine.go @@ -0,0 +1,168 @@ +package economics + +import ( + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrShadowEpochEngine = errors.New("invalid Zephyr shadow epoch transition") + +type ShadowEpochEngineConfig struct { + ComputeIndex ComputeIndexConfig + ComputeScarcity ComputeScarcityConfig + Monetary MonetaryPolicy + ComputeFeedback ComputeFeedbackPolicy +} + +type MonetaryBalanceSnapshot struct { + TotalSupply uint64 + StakedSupply uint64 + ProtocolReserve uint64 + BaseFee uint64 +} + +type ShadowEpochPreview struct { + Aggregate EpochAggregate + ComputeIndex ComputeIndexSnapshot + ComputePriceTrendBps int32 + ComputeScarcity ComputeScarcitySnapshot + MonetaryDecision MonetaryDecision + ComputeFeedback ComputeFeedbackDecision + State MonetaryEpochState + Consumed []types.ObjectID + Created []object.Object +} + +type ShadowEpochEngine struct { + Network types.NetworkID + config ShadowEpochEngineConfig + priorIndex ComputeIndexSnapshot + previous *MonetaryEpochState +} + +func NewShadowEpochEngine(network types.NetworkID, config ShadowEpochEngineConfig) (*ShadowEpochEngine, error) { + if types.IsZero32([32]byte(network)) || config.Monetary.EpochsPerYear == 0 || config.Monetary.OperationsTarget == 0 || + config.ComputeScarcity.MaxAbsScoreBps == 0 || config.ComputeFeedback.Mode > ComputeFeedbackMonetaryBand { + return nil, ErrShadowEpochEngine + } + return &ShadowEpochEngine{Network: network, config: config}, nil +} + +// PreviewCloseEpoch deterministically composes finalized shard telemetry into +// ZCPI, ZCSI, ZAMP and a Merkle-authenticated shadow MonetaryEpochState. It does +// not advance controller history. The returned object delta must be included in +// a normal consensus candidate and finalized before Accept is called. +func (e *ShadowEpochEngine) PreviewCloseEpoch(metrics []ShardEpochMetrics, verifiedWork []compute.VerifiedWork, balances MonetaryBalanceSnapshot) (ShadowEpochPreview, error) { + if e == nil { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + aggregate, err := AggregateEpochMetrics(metrics) + if err != nil { + return ShadowEpochPreview{}, err + } + if e.previous == nil { + if aggregate.Epoch != 1 { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + } else if aggregate.Epoch != e.previous.Epoch+1 { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + + index, err := BuildComputeIndex(aggregate.Epoch, verifiedWork, e.priorIndex, e.config.ComputeIndex) + if err != nil { + return ShadowEpochPreview{}, err + } + trend := ComputePriceTrendBps(index.BasketPriceQ9, e.priorIndex.BasketPriceQ9) + scarcity, err := BuildComputeScarcity(aggregate.Epoch, aggregate.ComputeMarketMetrics(trend, index.Reliable), e.config.ComputeScarcity) + if err != nil { + return ShadowEpochPreview{}, err + } + state, monetary, feedback, err := BuildShadowMonetaryEpochState( + e.Network, + e.previous, + aggregate, + balances.TotalSupply, + balances.StakedSupply, + balances.ProtocolReserve, + index.BasketPriceQ9, + trend, + index.Reliable, + scarcity, + e.config.Monetary, + e.config.ComputeFeedback, + balances.BaseFee, + ) + if err != nil { + return ShadowEpochPreview{}, err + } + + var previousObject *object.Object + if e.previous != nil { + prior, err := e.previous.Object() + if err != nil { + return ShadowEpochPreview{}, err + } + previousObject = &prior + } + consumed, created, err := ShadowMonetaryTransition(previousObject, state) + if err != nil { + return ShadowEpochPreview{}, err + } + return ShadowEpochPreview{ + Aggregate: aggregate, ComputeIndex: index, ComputePriceTrendBps: trend, + ComputeScarcity: scarcity, MonetaryDecision: monetary, ComputeFeedback: feedback, + State: state, Consumed: consumed, Created: created, + }, nil +} + +// Accept advances the shadow controller only after the caller has finalized the +// exact monetary object returned by PreviewCloseEpoch. +func (e *ShadowEpochEngine) Accept(preview ShadowEpochPreview) error { + if e == nil || preview.State.Network != e.Network || preview.State.Epoch != preview.Aggregate.Epoch || + preview.ComputeIndex.Epoch != preview.State.Epoch || preview.ComputeScarcity.Epoch != preview.State.Epoch || + preview.State.Validate() != nil { + return ErrShadowEpochEngine + } + if e.previous == nil { + if preview.State.Epoch != 1 || !types.IsZero32([32]byte(preview.State.PreviousStateHash)) { + return ErrShadowEpochEngine + } + } else { + if preview.State.Epoch != e.previous.Epoch+1 { + return ErrShadowEpochEngine + } + priorHash, err := e.previous.Hash() + if err != nil || preview.State.PreviousStateHash != priorHash { + return ErrShadowEpochEngine + } + } + aggregateHash, err := preview.Aggregate.Hash() + if err != nil || aggregateHash != preview.State.AggregateHash { + return ErrShadowEpochEngine + } + if preview.State.ComputeIndexQ9 != preview.ComputeIndex.BasketPriceQ9 || preview.State.ComputeIndexReliable != preview.ComputeIndex.Reliable { + return ErrShadowEpochEngine + } + + stateCopy := preview.State + e.previous = &stateCopy + e.priorIndex = preview.ComputeIndex + return nil +} + +func (e *ShadowEpochEngine) PreviousState() (MonetaryEpochState, bool) { + if e == nil || e.previous == nil { + return MonetaryEpochState{}, false + } + return *e.previous, true +} + +func (e *ShadowEpochEngine) PriorComputeIndex() ComputeIndexSnapshot { + if e == nil { + return ComputeIndexSnapshot{} + } + return e.priorIndex +} diff --git a/internal/v2/economics/shadow_epoch_engine_checkpoint.go b/internal/v2/economics/shadow_epoch_engine_checkpoint.go new file mode 100644 index 00000000..5fb26b34 --- /dev/null +++ b/internal/v2/economics/shadow_epoch_engine_checkpoint.go @@ -0,0 +1,191 @@ +package economics + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const shadowEpochEngineCheckpointVersion uint16 = 1 + +func ShadowEpochEngineConfigHash(config ShadowEpochEngineConfig) types.Hash { + var w codec.Writer + for class := compute.WorkClass(0); class < compute.WorkClassCount; class++ { + w.U32(config.ComputeIndex.WeightsBps[class]) + } + w.U32(config.ComputeIndex.MinSamplesPerClass) + w.U32(config.ComputeIndex.MinCoverageBps) + w.U32(config.ComputeIndex.EWMABps) + + w.U32(config.ComputeScarcity.DemandSupplyWeightBps) + w.U32(config.ComputeScarcity.PriceTrendWeightBps) + w.U32(config.ComputeScarcity.BacklogWeightBps) + w.U32(config.ComputeScarcity.UtilizationWeightBps) + w.U32(config.ComputeScarcity.FulfillmentWeightBps) + w.U32(config.ComputeScarcity.UtilizationTargetBps) + w.U64(config.ComputeScarcity.MinDemandUnits) + w.U64(config.ComputeScarcity.MinSupplyUnits) + w.U32(config.ComputeScarcity.MaxAbsScoreBps) + + w.U32(config.Monetary.TargetInflationBps) + w.U32(config.Monetary.MinInflationBps) + w.U32(config.Monetary.MaxInflationBps) + w.U32(config.Monetary.MaxEpochStepBps) + w.U64(config.Monetary.EpochsPerYear) + w.U32(config.Monetary.ReserveTargetBps) + w.U32(config.Monetary.StakeTargetBps) + w.U32(config.Monetary.UtilizationTargetBps) + w.U32(config.Monetary.VelocityTargetBps) + w.U64(config.Monetary.OperationsTarget) + w.U32(config.Monetary.ReserveWeightBps) + w.U32(config.Monetary.StakeWeightBps) + w.U32(config.Monetary.UtilizationWeightBps) + w.U32(config.Monetary.VelocityWeightBps) + w.U32(config.Monetary.OperationsWeightBps) + + w.U8(uint8(config.ComputeFeedback.Mode)) + w.U32(config.ComputeFeedback.BaseComputeRewardShareBps) + w.U32(config.ComputeFeedback.MinComputeRewardShareBps) + w.U32(config.ComputeFeedback.MaxComputeRewardShareBps) + w.U32(config.ComputeFeedback.RewardSensitivityBps) + w.U32(config.ComputeFeedback.MonetarySensitivityBps) + w.U32(config.ComputeFeedback.MaxInflationCorrectionBps) + return types.Hash(codec.DomainHash("zephyr/shadow-economics-config/v2", w.BytesCopy())) +} + +func (e *ShadowEpochEngine) CheckpointBytes() ([]byte, error) { + if e == nil || types.IsZero32([32]byte(e.Network)) { + return nil, ErrShadowEpochEngine + } + var w codec.Writer + w.U16(shadowEpochEngineCheckpointVersion) + w.Fixed(e.Network[:]) + configHash := ShadowEpochEngineConfigHash(e.config) + w.Fixed(configHash[:]) + writeComputeIndexSnapshot(&w, e.priorIndex) + w.Bool(e.previous != nil) + if e.previous != nil { + raw, err := e.previous.CanonicalBytes() + if err != nil { + return nil, err + } + w.Bytes(raw) + } + return w.BytesCopy(), nil +} + +func RestoreShadowEpochEngine(data []byte, expectedNetwork types.NetworkID, config ShadowEpochEngineConfig) (*ShadowEpochEngine, error) { + if types.IsZero32([32]byte(expectedNetwork)) { + return nil, ErrShadowEpochEngine + } + r := codec.NewReader(data) + version, err := r.U16() + if err != nil || version != shadowEpochEngineCheckpointVersion { + return nil, ErrShadowEpochEngine + } + networkRaw, err := r.Fixed(32) + if err != nil { + return nil, ErrShadowEpochEngine + } + var network types.NetworkID + copy(network[:], networkRaw) + if network != expectedNetwork { + return nil, ErrShadowEpochEngine + } + configRaw, err := r.Fixed(32) + if err != nil { + return nil, ErrShadowEpochEngine + } + var checkpointConfigHash types.Hash + copy(checkpointConfigHash[:], configRaw) + if checkpointConfigHash != ShadowEpochEngineConfigHash(config) { + return nil, ErrShadowEpochEngine + } + priorIndex, err := readComputeIndexSnapshot(r) + if err != nil { + return nil, err + } + hasPrevious, err := r.Bool() + if err != nil { + return nil, ErrShadowEpochEngine + } + var previous *MonetaryEpochState + if hasPrevious { + raw, err := r.Bytes(64 << 10) + if err != nil { + return nil, ErrShadowEpochEngine + } + parsed, err := ParseMonetaryEpochState(raw) + if err != nil || parsed.Network != expectedNetwork || parsed.Epoch != priorIndex.Epoch { + return nil, ErrShadowEpochEngine + } + previous = &parsed + } else if priorIndex != (ComputeIndexSnapshot{}) { + return nil, ErrShadowEpochEngine + } + if r.Done() != nil { + return nil, ErrShadowEpochEngine + } + engine, err := NewShadowEpochEngine(expectedNetwork, config) + if err != nil { + return nil, err + } + engine.priorIndex = priorIndex + engine.previous = previous + return engine, nil +} + +func writeComputeIndexSnapshot(w *codec.Writer, snapshot ComputeIndexSnapshot) { + w.U64(snapshot.Epoch) + for class := compute.WorkClass(0); class < compute.WorkClassCount; class++ { + w.U64(snapshot.ClassPriceQ9[class]) + } + for class := compute.WorkClass(0); class < compute.WorkClassCount; class++ { + w.U64(snapshot.ClassSamples[class]) + } + w.U64(snapshot.BasketPriceQ9) + w.U32(snapshot.CoverageBps) + w.Bool(snapshot.Reliable) + w.U64(snapshot.TotalSamples) +} + +func readComputeIndexSnapshot(r *codec.Reader) (ComputeIndexSnapshot, error) { + out := ComputeIndexSnapshot{} + var err error + out.Epoch, err = r.U64() + if err != nil { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + for class := compute.WorkClass(0); class < compute.WorkClassCount; class++ { + out.ClassPriceQ9[class], err = r.U64() + if err != nil { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + } + for class := compute.WorkClass(0); class < compute.WorkClassCount; class++ { + out.ClassSamples[class], err = r.U64() + if err != nil { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + } + out.BasketPriceQ9, err = r.U64() + if err != nil { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + out.CoverageBps, err = r.U32() + if err != nil || out.CoverageBps > BasisPoints { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + out.Reliable, err = r.Bool() + if err != nil { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + out.TotalSamples, err = r.U64() + if err != nil { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + if out.Epoch == 0 && out != (ComputeIndexSnapshot{}) { + return ComputeIndexSnapshot{}, ErrShadowEpochEngine + } + return out, nil +} diff --git a/internal/v2/economics/shadow_epoch_engine_checkpoint_test.go b/internal/v2/economics/shadow_epoch_engine_checkpoint_test.go new file mode 100644 index 00000000..dc81ce89 --- /dev/null +++ b/internal/v2/economics/shadow_epoch_engine_checkpoint_test.go @@ -0,0 +1,94 @@ +package economics + +import ( + "bytes" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestShadowEpochEngineCheckpointRoundTrip(t *testing.T) { + config := testShadowEpochConfig() + network := types.NetworkID{1} + engine, err := NewShadowEpochEngine(network, config) + if err != nil { + t.Fatal(err) + } + preview, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(1, 0, 1_000, 500, 500)}, + []compute.VerifiedWork{testVerifiedCPUWork(1, 200)}, + MonetaryBalanceSnapshot{TotalSupply: 1_000, StakedSupply: 400, ProtocolReserve: 100, BaseFee: 1}, + ) + if err != nil { + t.Fatal(err) + } + if err := engine.Accept(preview); err != nil { + t.Fatal(err) + } + raw, err := engine.CheckpointBytes() + if err != nil { + t.Fatal(err) + } + restored, err := RestoreShadowEpochEngine(raw, network, config) + if err != nil { + t.Fatal(err) + } + rawAgain, err := restored.CheckpointBytes() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw, rawAgain) { + t.Fatal("shadow epoch checkpoint is not canonical across restore") + } + state, ok := restored.PreviousState() + if !ok || state != preview.State { + t.Fatalf("restored engine lost prior monetary state: %#v", state) + } + if restored.PriorComputeIndex() != preview.ComputeIndex { + t.Fatal("restored engine lost prior compute index") + } +} + +func TestShadowEpochEngineCheckpointRejectsPolicyChange(t *testing.T) { + config := testShadowEpochConfig() + network := types.NetworkID{1} + engine, err := NewShadowEpochEngine(network, config) + if err != nil { + t.Fatal(err) + } + raw, err := engine.CheckpointBytes() + if err != nil { + t.Fatal(err) + } + changed := config + changed.Monetary.TargetInflationBps++ + if _, err := RestoreShadowEpochEngine(raw, network, changed); err == nil { + t.Fatal("checkpoint restored under a different monetary policy") + } + changed = config + changed.ComputeScarcity.MinSupplyUnits++ + if _, err := RestoreShadowEpochEngine(raw, network, changed); err == nil { + t.Fatal("checkpoint restored under a different ZCSI policy") + } + changed = config + changed.ComputeFeedback.Mode = ComputeFeedbackMonetaryBand + if _, err := RestoreShadowEpochEngine(raw, network, changed); err == nil { + t.Fatal("checkpoint restored under a different compute-feedback mode") + } +} + +func TestShadowEpochEngineCheckpointRejectsWrongNetwork(t *testing.T) { + config := testShadowEpochConfig() + engine, err := NewShadowEpochEngine(types.NetworkID{1}, config) + if err != nil { + t.Fatal(err) + } + raw, err := engine.CheckpointBytes() + if err != nil { + t.Fatal(err) + } + if _, err := RestoreShadowEpochEngine(raw, types.NetworkID{2}, config); err == nil { + t.Fatal("checkpoint restored on a different network") + } +} diff --git a/internal/v2/economics/shadow_epoch_engine_clone.go b/internal/v2/economics/shadow_epoch_engine_clone.go new file mode 100644 index 00000000..6d1cdf06 --- /dev/null +++ b/internal/v2/economics/shadow_epoch_engine_clone.go @@ -0,0 +1,15 @@ +package economics + +// Clone returns an independent copy of the shadow epoch engine, including the +// accepted monetary-state history anchor and prior ZCPI snapshot. +func (e *ShadowEpochEngine) Clone() *ShadowEpochEngine { + if e == nil { + return nil + } + out := *e + if e.previous != nil { + previous := *e.previous + out.previous = &previous + } + return &out +} diff --git a/internal/v2/economics/shadow_epoch_engine_test.go b/internal/v2/economics/shadow_epoch_engine_test.go new file mode 100644 index 00000000..9d0c3adf --- /dev/null +++ b/internal/v2/economics/shadow_epoch_engine_test.go @@ -0,0 +1,126 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func testShadowEpochConfig() ShadowEpochEngineConfig { + index := ComputeIndexConfig{MinSamplesPerClass: 1, MinCoverageBps: 10_000, EWMABps: 10_000} + index.WeightsBps[compute.WorkCPUGeneral] = 10_000 + return ShadowEpochEngineConfig{ + ComputeIndex: index, + ComputeScarcity: DefaultComputeScarcityConfig(), + Monetary: DefaultShadowPolicy(), + ComputeFeedback: DefaultComputeFeedbackPolicy(ComputeFeedbackRewardRouting), + } +} + +func testEpochMetric(epoch uint64, opening, demand, fulfilled, closing uint64) ShardEpochMetrics { + return ShardEpochMetrics{ + Version: EpochMetricsVersion, Epoch: epoch, ShardID: 0, + ChargedFees: 10, BurnedFees: 10, + FinalizedOperations: 10_000, ResourceUsed: 50, ResourceCapacity: 100, + CirculatingNativeSupply: 900, AgeWeightedVelocityBps: 5_000, + EscrowBackedComputeDemand: demand, VerifiedComputeSupply: 1_000, + ComputeSupplyReliable: true, OpeningComputeBacklog: opening, + ComputeFulfilled: fulfilled, ComputeBacklog: closing, + } +} + +func testVerifiedCPUWork(jobByte byte, paid uint64) compute.VerifiedWork { + return compute.VerifiedWork{ + JobID: types.JobID{jobByte}, Class: compute.WorkCPUGeneral, Units: 100, + PaidZPH: paid, Verification: compute.VerificationReplicated, ResultRoot: types.Hash{jobByte + 1}, + } +} + +func TestShadowEpochEnginePreviewDoesNotAdvanceUntilAccepted(t *testing.T) { + engine, err := NewShadowEpochEngine(types.NetworkID{1}, testShadowEpochConfig()) + if err != nil { + t.Fatal(err) + } + balances := MonetaryBalanceSnapshot{TotalSupply: 1_000, StakedSupply: 400, ProtocolReserve: 100, BaseFee: 1} + preview, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(1, 0, 1_000, 500, 500)}, + []compute.VerifiedWork{testVerifiedCPUWork(1, 200)}, + balances, + ) + if err != nil { + t.Fatal(err) + } + if _, ok := engine.PreviousState(); ok { + t.Fatal("preview advanced controller history") + } + if !preview.State.Shadow || preview.State.Epoch != 1 || len(preview.Consumed) != 0 || len(preview.Created) != 1 { + t.Fatalf("unexpected first preview: %#v", preview) + } + if !preview.ComputeIndex.Reliable || !preview.ComputeScarcity.Reliable { + t.Fatalf("expected reliable test economics: index=%#v scarcity=%#v", preview.ComputeIndex, preview.ComputeScarcity) + } + if err := engine.Accept(preview); err != nil { + t.Fatal(err) + } + state, ok := engine.PreviousState() + if !ok || state.Epoch != 1 { + t.Fatalf("accepted state missing: %#v", state) + } +} + +func TestShadowEpochEngineChainsSecondEpochObject(t *testing.T) { + engine, err := NewShadowEpochEngine(types.NetworkID{1}, testShadowEpochConfig()) + if err != nil { + t.Fatal(err) + } + balances := MonetaryBalanceSnapshot{TotalSupply: 1_000, StakedSupply: 400, ProtocolReserve: 100, BaseFee: 1} + first, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(1, 0, 1_000, 500, 500)}, + []compute.VerifiedWork{testVerifiedCPUWork(1, 200)}, + balances, + ) + if err != nil { + t.Fatal(err) + } + if err := engine.Accept(first); err != nil { + t.Fatal(err) + } + second, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(2, 500, 0, 500, 0)}, + []compute.VerifiedWork{testVerifiedCPUWork(3, 300)}, + balances, + ) + if err != nil { + t.Fatal(err) + } + if len(second.Consumed) != 1 || len(second.Created) != 1 || second.State.PreviousStateHash == (types.Hash{}) { + t.Fatalf("second epoch did not replace and chain monetary object: %#v", second) + } + if err := engine.Accept(second); err != nil { + t.Fatal(err) + } + state, _ := engine.PreviousState() + if state.Epoch != 2 { + t.Fatalf("epoch = %d, want 2", state.Epoch) + } +} + +func TestShadowEpochEngineRejectsTamperedPreview(t *testing.T) { + engine, err := NewShadowEpochEngine(types.NetworkID{1}, testShadowEpochConfig()) + if err != nil { + t.Fatal(err) + } + preview, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(1, 0, 1_000, 500, 500)}, + []compute.VerifiedWork{testVerifiedCPUWork(1, 200)}, + MonetaryBalanceSnapshot{TotalSupply: 1_000, StakedSupply: 400, ProtocolReserve: 100, BaseFee: 1}, + ) + if err != nil { + t.Fatal(err) + } + preview.State.AggregateHash[0] ^= 0xff + if err := engine.Accept(preview); err == nil { + t.Fatal("tampered aggregate commitment accepted") + } +} diff --git a/internal/v2/economics/shadow_epoch_pending_checkpoint.go b/internal/v2/economics/shadow_epoch_pending_checkpoint.go new file mode 100644 index 00000000..10ee4cd3 --- /dev/null +++ b/internal/v2/economics/shadow_epoch_pending_checkpoint.go @@ -0,0 +1,90 @@ +package economics + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const shadowEpochPendingCheckpointVersion uint16 = 1 + +func (p ShadowEpochPreview) PendingCheckpointBytes() ([]byte, error) { + aggregateRaw, err := p.Aggregate.CanonicalBytes() + if err != nil { + return nil, err + } + stateRaw, err := p.State.CanonicalBytes() + if err != nil { + return nil, err + } + if p.ComputeIndex.Epoch != p.State.Epoch || p.Aggregate.Epoch != p.State.Epoch { + return nil, ErrShadowEpochEngine + } + var w codec.Writer + w.U16(shadowEpochPendingCheckpointVersion) + w.Bytes(aggregateRaw) + writeComputeIndexSnapshot(&w, p.ComputeIndex) + w.Bytes(stateRaw) + return w.BytesCopy(), nil +} + +// RestoreShadowEpochPreview rebuilds the pending Merkle transition from the +// accepted engine history rather than trusting serialized consumed/created +// object lists. A cloned engine must be able to Accept the reconstructed +// preview before it is returned. +func RestoreShadowEpochPreview(data []byte, engine *ShadowEpochEngine) (ShadowEpochPreview, error) { + if engine == nil { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + r := codec.NewReader(data) + version, err := r.U16() + if err != nil || version != shadowEpochPendingCheckpointVersion { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + aggregateRaw, err := r.Bytes(64 << 10) + if err != nil { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + aggregate, err := ParseEpochAggregate(aggregateRaw) + if err != nil { + return ShadowEpochPreview{}, err + } + index, err := readComputeIndexSnapshot(r) + if err != nil { + return ShadowEpochPreview{}, err + } + stateRaw, err := r.Bytes(64 << 10) + if err != nil || r.Done() != nil { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + state, err := ParseMonetaryEpochState(stateRaw) + if err != nil || state.Network != engine.Network || state.Epoch != aggregate.Epoch || index.Epoch != state.Epoch { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + + var previousObject *object.Object + if previous, ok := engine.PreviousState(); ok { + obj, err := previous.Object() + if err != nil { + return ShadowEpochPreview{}, err + } + previousObject = &obj + } + consumed, created, err := ShadowMonetaryTransition(previousObject, state) + if err != nil { + return ShadowEpochPreview{}, err + } + preview := ShadowEpochPreview{ + Aggregate: aggregate, + ComputeIndex: index, + ComputeScarcity: ComputeScarcitySnapshot{Epoch: state.Epoch}, + State: state, + Consumed: append([]types.ObjectID(nil), consumed...), + Created: append([]object.Object(nil), created...), + } + validator := engine.Clone() + if validator == nil || validator.Accept(preview) != nil { + return ShadowEpochPreview{}, ErrShadowEpochEngine + } + return preview, nil +} diff --git a/internal/v2/economics/shadow_epoch_pending_checkpoint_test.go b/internal/v2/economics/shadow_epoch_pending_checkpoint_test.go new file mode 100644 index 00000000..ade1f5e7 --- /dev/null +++ b/internal/v2/economics/shadow_epoch_pending_checkpoint_test.go @@ -0,0 +1,85 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestPendingShadowEpochCheckpointRecovery(t *testing.T) { + config := testShadowEpochConfig() + network := types.NetworkID{1} + engine, err := NewShadowEpochEngine(network, config) + if err != nil { + t.Fatal(err) + } + preview, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(1, 0, 1_000, 500, 500)}, + []compute.VerifiedWork{testVerifiedCPUWork(1, 200)}, + MonetaryBalanceSnapshot{TotalSupply: 1_000, StakedSupply: 400, ProtocolReserve: 100, BaseFee: 1}, + ) + if err != nil { + t.Fatal(err) + } + raw, err := preview.PendingCheckpointBytes() + if err != nil { + t.Fatal(err) + } + restored, err := RestoreShadowEpochPreview(raw, engine) + if err != nil { + t.Fatal(err) + } + if restored.State != preview.State || restored.Aggregate != preview.Aggregate || restored.ComputeIndex != preview.ComputeIndex { + t.Fatal("pending checkpoint changed committed economics") + } + if len(restored.Consumed) != 0 || len(restored.Created) != 1 || restored.Created[0].ID != MonetaryStateObjectID(network) { + t.Fatal("pending checkpoint did not rebuild the first monetary object delta") + } +} + +func TestPendingShadowEpochCheckpointRequiresAcceptedHistory(t *testing.T) { + config := testShadowEpochConfig() + network := types.NetworkID{1} + engine, err := NewShadowEpochEngine(network, config) + if err != nil { + t.Fatal(err) + } + first, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(1, 0, 1_000, 500, 500)}, + []compute.VerifiedWork{testVerifiedCPUWork(1, 200)}, + MonetaryBalanceSnapshot{TotalSupply: 1_000, StakedSupply: 400, ProtocolReserve: 100, BaseFee: 1}, + ) + if err != nil { + t.Fatal(err) + } + if err := engine.Accept(first); err != nil { + t.Fatal(err) + } + second, err := engine.PreviewCloseEpoch( + []ShardEpochMetrics{testEpochMetric(2, 500, 0, 500, 0)}, + []compute.VerifiedWork{testVerifiedCPUWork(3, 300)}, + MonetaryBalanceSnapshot{TotalSupply: 1_000, StakedSupply: 400, ProtocolReserve: 100, BaseFee: 1}, + ) + if err != nil { + t.Fatal(err) + } + raw, err := second.PendingCheckpointBytes() + if err != nil { + t.Fatal(err) + } + fresh, err := NewShadowEpochEngine(network, config) + if err != nil { + t.Fatal(err) + } + if _, err := RestoreShadowEpochPreview(raw, fresh); err == nil { + t.Fatal("pending epoch restored without the accepted predecessor") + } + restored, err := RestoreShadowEpochPreview(raw, engine) + if err != nil { + t.Fatal(err) + } + if len(restored.Consumed) != 1 || len(restored.Created) != 1 { + t.Fatal("pending replacement delta was not rebuilt") + } +} diff --git a/internal/v2/economics/velocity.go b/internal/v2/economics/velocity.go new file mode 100644 index 00000000..c798dfa2 --- /dev/null +++ b/internal/v2/economics/velocity.go @@ -0,0 +1,90 @@ +package economics + +import ( + "errors" + "math/big" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" +) + +var ErrVelocity = errors.New("invalid Zephyr age-weighted velocity input") + +type VelocityPolicy struct { + MinAgeBlocks uint64 + FullWeightAgeBlocks uint64 + MaxVelocityBps uint32 +} + +type VelocitySnapshot struct { + AgeWeightedVelocityBps uint32 + ObservedSpends uint64 + EligibleSpends uint64 + UnknownAgeSpends uint64 + FreshSpends uint64 +} + +type VelocityAccumulator struct { + policy VelocityPolicy + weightedValueBps big.Int + observedSpends uint64 + eligibleSpends uint64 + unknownAgeSpends uint64 + freshSpends uint64 +} + +func NewVelocityAccumulator(policy VelocityPolicy) (*VelocityAccumulator, error) { + if policy.FullWeightAgeBlocks == 0 || policy.MinAgeBlocks > policy.FullWeightAgeBlocks || policy.MaxVelocityBps == 0 || policy.MaxVelocityBps > 10*BasisPoints { + return nil, ErrVelocity + } + return &VelocityAccumulator{policy: policy}, nil +} + +// ObserveCoin records a finalized spend. CreatedHeight must come from the +// consensus-stamped coin object, never from an untrusted RPC timestamp. +func (a *VelocityAccumulator) ObserveCoin(coin object.Coin, spendHeight uint64) error { + if a == nil || coin.Amount == 0 { + return ErrVelocity + } + a.observedSpends++ + if coin.CreatedHeight == 0 { + a.unknownAgeSpends++ + return nil + } + if spendHeight <= coin.CreatedHeight { + return ErrVelocity + } + age := spendHeight - coin.CreatedHeight + if age < a.policy.MinAgeBlocks { + a.freshSpends++ + return nil + } + if age > a.policy.FullWeightAgeBlocks { + age = a.policy.FullWeightAgeBlocks + } + weight := new(big.Int).Mul(new(big.Int).SetUint64(age), new(big.Int).SetUint64(uint64(BasisPoints))) + weight.Quo(weight, new(big.Int).SetUint64(a.policy.FullWeightAgeBlocks)) + contribution := new(big.Int).Mul(new(big.Int).SetUint64(coin.Amount), weight) + a.weightedValueBps.Add(&a.weightedValueBps, contribution) + a.eligibleSpends++ + return nil +} + +// Finalize normalizes the accumulated age-weighted moved value by circulating +// supply. A full-age spend of 50% of circulating supply contributes 5,000 bps. +func (a *VelocityAccumulator) Finalize(circulatingSupply uint64) (VelocitySnapshot, error) { + if a == nil || circulatingSupply == 0 { + return VelocitySnapshot{}, ErrVelocity + } + value := new(big.Int).Quo(new(big.Int).Set(&a.weightedValueBps), new(big.Int).SetUint64(circulatingSupply)) + maximum := new(big.Int).SetUint64(uint64(a.policy.MaxVelocityBps)) + if value.Cmp(maximum) > 0 { + value.Set(maximum) + } + return VelocitySnapshot{ + AgeWeightedVelocityBps: uint32(value.Uint64()), + ObservedSpends: a.observedSpends, + EligibleSpends: a.eligibleSpends, + UnknownAgeSpends: a.unknownAgeSpends, + FreshSpends: a.freshSpends, + }, nil +} diff --git a/internal/v2/economics/velocity_test.go b/internal/v2/economics/velocity_test.go new file mode 100644 index 00000000..6068c162 --- /dev/null +++ b/internal/v2/economics/velocity_test.go @@ -0,0 +1,96 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestAgeWeightedVelocityRewardsOlderCirculation(t *testing.T) { + policy := VelocityPolicy{MinAgeBlocks: 10, FullWeightAgeBlocks: 100, MaxVelocityBps: 20_000} + accumulator, err := NewVelocityAccumulator(policy) + if err != nil { + t.Fatal(err) + } + var token types.TokenID + token[0] = 1 + if err := accumulator.ObserveCoin(object.Coin{Token: token, Amount: 500, CreatedHeight: 100}, 200); err != nil { + t.Fatal(err) + } + snapshot, err := accumulator.Finalize(1_000) + if err != nil { + t.Fatal(err) + } + if snapshot.AgeWeightedVelocityBps != 5_000 || snapshot.EligibleSpends != 1 { + t.Fatalf("unexpected old-coin velocity: %#v", snapshot) + } +} + +func TestAgeWeightedVelocitySuppressesRapidSelfCycling(t *testing.T) { + policy := VelocityPolicy{MinAgeBlocks: 10, FullWeightAgeBlocks: 100, MaxVelocityBps: 20_000} + accumulator, err := NewVelocityAccumulator(policy) + if err != nil { + t.Fatal(err) + } + var token types.TokenID + token[0] = 1 + for height := uint64(101); height <= 109; height++ { + coin := object.Coin{Token: token, Amount: 1_000, CreatedHeight: height - 1} + if err := accumulator.ObserveCoin(coin, height); err != nil { + t.Fatal(err) + } + } + snapshot, err := accumulator.Finalize(1_000) + if err != nil { + t.Fatal(err) + } + if snapshot.AgeWeightedVelocityBps != 0 || snapshot.FreshSpends != 9 { + t.Fatalf("rapid cycling should have zero contribution under minimum age: %#v", snapshot) + } +} + +func TestAgeWeightedVelocityExcludesUnknownAgeAndRejectsImpossibleAge(t *testing.T) { + policy := VelocityPolicy{MinAgeBlocks: 1, FullWeightAgeBlocks: 100, MaxVelocityBps: 20_000} + accumulator, err := NewVelocityAccumulator(policy) + if err != nil { + t.Fatal(err) + } + var token types.TokenID + token[0] = 1 + if err := accumulator.ObserveCoin(object.Coin{Token: token, Amount: 100}, 10); err != nil { + t.Fatal(err) + } + if err := accumulator.ObserveCoin(object.Coin{Token: token, Amount: 100, CreatedHeight: 10}, 10); err != ErrVelocity { + t.Fatalf("expected impossible same-height spend rejection, got %v", err) + } + snapshot, err := accumulator.Finalize(1_000) + if err != nil { + t.Fatal(err) + } + if snapshot.UnknownAgeSpends != 1 { + t.Fatalf("unknown-age spend should be tracked but excluded: %#v", snapshot) + } +} + +func TestAgeWeightedVelocityIsBounded(t *testing.T) { + policy := VelocityPolicy{MinAgeBlocks: 1, FullWeightAgeBlocks: 1, MaxVelocityBps: 12_000} + accumulator, err := NewVelocityAccumulator(policy) + if err != nil { + t.Fatal(err) + } + var token types.TokenID + token[0] = 1 + for i := 0; i < 10; i++ { + if err := accumulator.ObserveCoin(object.Coin{Token: token, Amount: 1_000, CreatedHeight: 1}, 2); err != nil { + t.Fatal(err) + } + } + snapshot, err := accumulator.Finalize(1_000) + if err != nil { + t.Fatal(err) + } + if snapshot.AgeWeightedVelocityBps != 12_000 { + t.Fatalf("velocity clamp failed: %#v", snapshot) + } +} diff --git a/internal/v2/execution/coin_metadata.go b/internal/v2/execution/coin_metadata.go new file mode 100644 index 00000000..8afd055f --- /dev/null +++ b/internal/v2/execution/coin_metadata.go @@ -0,0 +1,17 @@ +package execution + +import "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + +func stampCoinOutput(spec object.OutputSpec, height uint64) (object.OutputSpec, error) { + if spec.Kind != object.KindCoin { + return spec, nil + } + coin, err := object.ParseCoin(spec.Data) + if err != nil { + return object.OutputSpec{}, err + } + coin.CreatedHeight = height + stamped := spec + stamped.Data = coin.MarshalBinary() + return stamped, nil +} diff --git a/internal/v2/execution/coin_metadata_test.go b/internal/v2/execution/coin_metadata_test.go new file mode 100644 index 00000000..c50e999c --- /dev/null +++ b/internal/v2/execution/coin_metadata_test.go @@ -0,0 +1,63 @@ +package execution + +import ( + "crypto/elliptic" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestExecutorOverridesWalletCoinCreationHeight(t *testing.T) { + key := makeKey(t) + owner := types.AccountIDFromPublicKey(elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y)) + network := types.NetworkID(types.HashBytes("network", []byte("coin-height"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + store := worldstate.NewMemory() + seed := types.HashBytes("coin-height", []byte("seed")) + inputID := types.ObjectIDForShard(seed, 0, 0) + inputSpec, err := object.NewCoinOutputAtHeight(owner, native, 100, 50) + if err != nil { + t.Fatal(err) + } + input := object.Object{ID: inputID, Version: 1, Owner: owner, Kind: object.KindCoin, Data: inputSpec.Data} + if _, err := store.Apply(nil, []object.Object{input}); err != nil { + t.Fatal(err) + } + witness, proof, ok := store.Proof(inputID) + if !ok { + t.Fatal("missing input proof") + } + maliciousOutput, err := object.NewCoinOutputAtHeight(owner, native, 99, 1) + if err != nil { + t.Fatal(err) + } + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: store.Root(), + Inputs: []tx.InputRef{{ObjectID: inputID, Version: 1, ObjectHash: witness.Hash()}}, + Outputs: []object.OutputSpec{maliciousOutput}, + Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Fee: 1, + Witnesses: []tx.Witness{{Object: witness, Proof: proof}}, + } + transaction.Salt[0] = 1 + if err := transaction.Sign(key); err != nil { + t.Fatal(err) + } + result, err := (Engine{Network: network, NativeToken: native, ShardCount: 1, Height: 100}).Execute(transaction) + if err != nil { + t.Fatal(err) + } + if len(result.Created) != 1 { + t.Fatalf("unexpected output count %d", len(result.Created)) + } + coin, err := object.ParseCoin(result.Created[0].Data) + if err != nil { + t.Fatal(err) + } + if coin.CreatedHeight != 100 { + t.Fatalf("wallet-controlled height survived execution: got %d want 100", coin.CreatedHeight) + } +} diff --git a/internal/v2/execution/compute_lifecycle_test.go b/internal/v2/execution/compute_lifecycle_test.go new file mode 100644 index 00000000..90a6286d --- /dev/null +++ b/internal/v2/execution/compute_lifecycle_test.go @@ -0,0 +1,245 @@ +package execution + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestCrossShardComputeEscrowAssignmentResultAndSettlement(t *testing.T) { + const shardCount = uint32(2) + networkID := types.NetworkID(types.HashBytes("network", []byte("compute-lifecycle"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + ownerKey, owner := computeAccountOnShard(t, 1, shardCount) + providerKey1, provider1 := computeAccountOnShard(t, 0, shardCount) + providerKey2, provider2 := computeAccountOnShard(t, 0, shardCount) + ownerStore := worldstate.NewMemory() + providerStores := []*worldstate.Memory{worldstate.NewMemory(), worldstate.NewMemory()} + + ownerCoin := seedComputeCoin(t, ownerStore, owner, native, 300, "owner") + providerCoins := []types.ObjectID{ + seedComputeCoin(t, providerStores[0], provider1, native, 200, "provider-1"), + seedComputeCoin(t, providerStores[1], provider2, native, 200, "provider-2"), + } + providers := []types.AccountID{provider1, provider2} + providerKeys := []*ecdsa.PrivateKey{providerKey1, providerKey2} + resources := compute.Resources{CPUCores: 8, MemoryMiB: 16384, GPUCount: 1, GPUMemoryMiB: 12288, StorageMiB: 1024, BandwidthMbps: 100, Capabilities: []string{"render"}} + job := compute.Job{Owner: owner, WorkloadHash: types.HashBytes("workload", []byte("scene")), InputRoot: types.HashBytes("input", []byte("scene-data")), Resources: resources, MaxPrice: 100, CollateralRequired: 20, Verification: compute.VerificationReplicated, DeadlineHeight: 100, Replicas: 2} + jobPayload, err := job.MarshalBinary() + if err != nil { + t.Fatal(err) + } + jobTx := computeSignedTx(t, ownerStore, ownerKey, networkID, native, 1, []types.ObjectID{ownerCoin}, 199, tx.OpComputeJob, jobPayload, 1) + jobResult, err := (Engine{Network: networkID, NativeToken: native, ShardCount: shardCount, Height: 1}).Execute(jobTx) + if err != nil { + t.Fatal(err) + } + if _, err := ownerStore.Apply(jobResult.Consumed, jobResult.Created); err != nil { + t.Fatal(err) + } + jobObject := objectOfKind(t, jobResult.Created, object.KindComputeJob) + ownerCoin = objectOfKind(t, jobResult.Created, object.KindCoin).ID + record, err := compute.ParseOnChainJob(jobObject.Data) + if err != nil { + t.Fatal(err) + } + + assignmentObjects := make([]object.Object, 0, 2) + for i := range providers { + offer := compute.Offer{Provider: providers[i], Resources: resources, PricePerUnit: 10, Collateral: 20, Verification: []compute.VerificationMode{compute.VerificationReplicated}, ValidUntilHeight: 80} + offerPayload, _ := offer.MarshalBinary() + offerTx := computeSignedTx(t, providerStores[i], providerKeys[i], networkID, native, 0, []types.ObjectID{providerCoins[i]}, 179, tx.OpComputeOffer, offerPayload, byte(10+i)) + offerResult, err := (Engine{Network: networkID, NativeToken: native, ShardCount: shardCount, Height: 2}).Execute(offerTx) + if err != nil { + t.Fatal(err) + } + if _, err := providerStores[i].Apply(offerResult.Consumed, offerResult.Created); err != nil { + t.Fatal(err) + } + offerObject := objectOfKind(t, offerResult.Created, object.KindComputeOffer) + providerCoins[i] = objectOfKind(t, offerResult.Created, object.KindCoin).ID + message := compute.AssignmentMessage{JobID: record.ID, JobOwner: owner, JobShard: 1, OfferID: types.Hash(offerObject.ID), Offer: offer, Job: job} + messageRaw, _ := message.MarshalBinary() + acceptTx := computeSignedTx(t, providerStores[i], providerKeys[i], networkID, native, 0, []types.ObjectID{offerObject.ID, providerCoins[i]}, 178, tx.OpComputeAccept, messageRaw, byte(20+i)) + acceptResult, err := (Engine{Network: networkID, NativeToken: native, ShardCount: shardCount, Height: 3}).Execute(acceptTx) + if err != nil { + t.Fatal(err) + } + if len(acceptResult.Outbound) != 1 || acceptResult.Outbound[0].DestinationShard != 1 || acceptResult.Outbound[0].Output.Kind != object.KindComputeAssignment { + t.Fatalf("assignment not routed cross-shard: %+v", acceptResult.Outbound) + } + if _, err := providerStores[i].Apply(acceptResult.Consumed, acceptResult.Created); err != nil { + t.Fatal(err) + } + providerCoins[i] = objectOfKind(t, acceptResult.Created, object.KindCoin).ID + outbound := acceptResult.Outbound[0] + assignmentObjects = append(assignmentObjects, object.Object{ID: types.ObjectIDForShard(acceptResult.TxID, outbound.OutputIndex, 1), Version: 1, Owner: outbound.Output.Owner, Kind: outbound.Output.Kind, Data: outbound.Output.Data}) + } + if _, err := ownerStore.Apply(nil, assignmentObjects); err != nil { + t.Fatal(err) + } + + for i, assignment := range assignmentObjects { + refRaw, _ := (compute.IngestRef{JobObject: jobObject.ID, MessageObject: assignment.ID}).MarshalBinary() + ingestTx := computeSignedTx(t, ownerStore, ownerKey, networkID, native, 1, []types.ObjectID{jobObject.ID, assignment.ID, ownerCoin}, uint64(198-i), tx.OpComputeIngestAssignment, refRaw, byte(30+i)) + ingestResult, err := (Engine{Network: networkID, NativeToken: native, ShardCount: shardCount, Height: uint64(4 + i)}).Execute(ingestTx) + if err != nil { + t.Fatal(err) + } + if _, err := ownerStore.Apply(ingestResult.Consumed, ingestResult.Created); err != nil { + t.Fatal(err) + } + jobObject = objectOfKind(t, ingestResult.Created, object.KindComputeJob) + ownerCoin = objectOfKind(t, ingestResult.Created, object.KindCoin).ID + } + record, err = compute.ParseOnChainJob(jobObject.Data) + if err != nil || record.Status != compute.JobAssigned || len(record.Assignments) != 2 { + t.Fatalf("job not fully assigned: %+v %v", record, err) + } + + resultRoot := types.HashBytes("result", []byte("rendered-scene")) + resultObjects := make([]object.Object, 0, 2) + for i := range providers { + message := compute.ResultMessage{JobID: record.ID, JobOwner: owner, JobShard: 1, Result: compute.Result{JobID: record.ID, Provider: providers[i], ResultRoot: resultRoot, CompletedHeight: uint64(10 + i)}} + messageRaw, _ := message.MarshalBinary() + resultTx := computeSignedTx(t, providerStores[i], providerKeys[i], networkID, native, 0, []types.ObjectID{providerCoins[i]}, 177, tx.OpComputeResult, messageRaw, byte(40+i)) + result, err := (Engine{Network: networkID, NativeToken: native, ShardCount: shardCount, Height: uint64(10 + i)}).Execute(resultTx) + if err != nil { + t.Fatal(err) + } + if len(result.Outbound) != 1 || result.Outbound[0].Output.Kind != object.KindComputeResult { + t.Fatal("compute result not routed to job shard") + } + if _, err := providerStores[i].Apply(result.Consumed, result.Created); err != nil { + t.Fatal(err) + } + providerCoins[i] = objectOfKind(t, result.Created, object.KindCoin).ID + outbound := result.Outbound[0] + resultObjects = append(resultObjects, object.Object{ID: types.ObjectIDForShard(result.TxID, outbound.OutputIndex, 1), Version: 1, Owner: outbound.Output.Owner, Kind: outbound.Output.Kind, Data: outbound.Output.Data}) + } + if _, err := ownerStore.Apply(nil, resultObjects); err != nil { + t.Fatal(err) + } + for i, resultObject := range resultObjects { + refRaw, _ := (compute.IngestRef{JobObject: jobObject.ID, MessageObject: resultObject.ID}).MarshalBinary() + ingestTx := computeSignedTx(t, ownerStore, ownerKey, networkID, native, 1, []types.ObjectID{jobObject.ID, resultObject.ID, ownerCoin}, uint64(196-i), tx.OpComputeIngestResult, refRaw, byte(50+i)) + ingestResult, err := (Engine{Network: networkID, NativeToken: native, ShardCount: shardCount, Height: uint64(20 + i)}).Execute(ingestTx) + if err != nil { + t.Fatal(err) + } + if _, err := ownerStore.Apply(ingestResult.Consumed, ingestResult.Created); err != nil { + t.Fatal(err) + } + jobObject = objectOfKind(t, ingestResult.Created, object.KindComputeJob) + ownerCoin = objectOfKind(t, ingestResult.Created, object.KindCoin).ID + } + record, err = compute.ParseOnChainJob(jobObject.Data) + if err != nil || record.Status != compute.JobAwaitingVerification || len(record.Results) != 2 { + t.Fatalf("results not ready for verification: %+v %v", record, err) + } + jobRef, _ := (compute.JobRef{JobObject: jobObject.ID}).MarshalBinary() + finalTx := computeSignedTx(t, ownerStore, ownerKey, networkID, native, 1, []types.ObjectID{jobObject.ID, ownerCoin}, 194, tx.OpComputeFinalize, jobRef, 60) + finalResult, err := (Engine{Network: networkID, NativeToken: native, ShardCount: shardCount, Height: 30}).Execute(finalTx) + if err != nil { + t.Fatal(err) + } + if len(finalResult.Outbound) != 2 { + t.Fatalf("expected two provider payouts, got %d", len(finalResult.Outbound)) + } + var ownerRefund uint64 + for _, item := range finalResult.Created { + if item.Kind == object.KindCoin && item.Owner == owner { + coin, _ := object.ParseCoin(item.Data) + ownerRefund += coin.Amount + } + } + // 194 fee-change + 80 unused escrow refund. + if ownerRefund != 274 { + t.Fatalf("unexpected owner value after settlement: %d", ownerRefund) + } + for _, outbound := range finalResult.Outbound { + coin, err := object.ParseCoin(outbound.Output.Data) + if err != nil || coin.Amount != 30 { + t.Fatalf("provider payment+collateral must equal 30: %+v %v", outbound, err) + } + } +} + +func computeAccountOnShard(t *testing.T, shard, count uint32) (*ecdsa.PrivateKey, types.AccountID) { + t.Helper() + for i := 0; i < 10000; i++ { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + account := types.AccountIDFromPublicKey(pub) + if types.AccountShard(account, count) == shard { + return key, account + } + } + t.Fatal("failed to find account on requested shard") + return nil, types.AccountID{} +} + +func seedComputeCoin(t *testing.T, store *worldstate.Memory, owner types.AccountID, token types.TokenID, amount uint64, label string) types.ObjectID { + t.Helper() + spec, err := object.NewCoinOutput(owner, token, amount) + if err != nil { + t.Fatal(err) + } + id := types.ObjectIDForShard(types.HashBytes("seed", []byte(label)), 0, types.AccountShard(owner, 2)) + if _, err := store.Apply(nil, []object.Object{{ID: id, Version: 1, Owner: owner, Kind: spec.Kind, Data: spec.Data}}); err != nil { + t.Fatal(err) + } + return id +} + +func computeSignedTx(t *testing.T, store *worldstate.Memory, key *ecdsa.PrivateKey, networkID types.NetworkID, native types.TokenID, shard uint32, inputIDs []types.ObjectID, change uint64, op uint16, payload []byte, salt byte) tx.Transaction { + t.Helper() + inputs := make([]tx.InputRef, 0, len(inputIDs)) + witnesses := make([]tx.Witness, 0, len(inputIDs)) + for _, id := range inputIDs { + item, proof, ok := store.Proof(id) + if !ok { + t.Fatalf("missing input %s", id) + } + h := item.Hash() + inputs = append(inputs, tx.InputRef{ObjectID: id, Version: item.Version, ObjectHash: h}) + witnesses = append(witnesses, tx.Witness{Object: item, Proof: proof}) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + owner := types.AccountIDFromPublicKey(pub) + outputs := []object.OutputSpec{} + if change > 0 { + spec, err := object.NewCoinOutput(owner, native, change) + if err != nil { + t.Fatal(err) + } + outputs = append(outputs, spec) + } + transaction := tx.Transaction{Version: tx.Version, Network: networkID, ShardID: shard, StateRoot: store.Root(), Inputs: inputs, Outputs: outputs, Operations: []tx.Operation{{Kind: op, Payload: payload}}, Fee: 1, ValidUntilHeight: 200, Witnesses: witnesses} + transaction.Salt[0] = salt + if err := transaction.Sign(key); err != nil { + t.Fatal(err) + } + return transaction +} + +func objectOfKind(t *testing.T, objects []object.Object, kind object.Kind) object.Object { + t.Helper() + for _, item := range objects { + if item.Kind == kind { + return item + } + } + t.Fatalf("object kind %d not found", kind) + return object.Object{} +} diff --git a/internal/v2/execution/contract_execution_test.go b/internal/v2/execution/contract_execution_test.go new file mode 100644 index 00000000..d00bf89e --- /dev/null +++ b/internal/v2/execution/contract_execution_test.go @@ -0,0 +1,118 @@ +package execution + +import ( + "crypto/elliptic" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/contracts" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestDeployAndCallZephyrScriptAreStateCommitted(t *testing.T) { + key := makeKey(t) + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + owner := types.AccountIDFromPublicKey(pub) + networkID := types.NetworkID(types.HashBytes("network", []byte("contract-execution"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + store := worldstate.NewMemory() + feeID := types.ObjectIDFromTransaction(types.HashBytes("seed", []byte("contract-fee")), 0) + feeSpec, _ := object.NewCoinOutput(owner, native, 10) + feeObject := object.Object{ID: feeID, Version: 1, Owner: owner, Kind: object.KindCoin, Data: feeSpec.Data} + root, err := store.Apply(nil, []object.Object{feeObject}) + if err != nil { + t.Fatal(err) + } + feeWitness, feeProof, _ := store.Proof(feeID) + feeHash := feeWitness.Hash() + change, _ := object.NewCoinOutput(owner, native, 9) + code := []byte("def run(id):\n before = state_read(id)\n state_write(id, \"new\")\n emit(sha256(\"event\"))\n return before\n") + deployment := contracts.Deployment{Runtime: contracts.RuntimeZephyrScriptV1, Code: code, ABI: 1, UpgradeAuthority: owner, InitialState: []byte("old"), MaxMemoryPages: 16} + payload, err := deployment.MarshalBinary() + if err != nil { + t.Fatal(err) + } + deploy := tx.Transaction{Version: tx.Version, Network: networkID, ShardID: 0, StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: feeID, Version: 1, ObjectHash: feeHash}}, Outputs: []object.OutputSpec{change}, + Operations: []tx.Operation{{Kind: tx.OpDeployContract, Payload: payload}}, Fee: 1, + Witnesses: []tx.Witness{{Object: feeWitness, Proof: feeProof}}, ValidUntilHeight: 20} + deploy.Salt[0] = 41 + if err := deploy.Sign(key); err != nil { + t.Fatal(err) + } + engine := Engine{Network: networkID, NativeToken: native, ShardCount: 1} + deployResult, err := engine.Execute(deploy) + if err != nil { + t.Fatal(err) + } + if len(deployResult.Created) != 3 { + t.Fatalf("expected change+contract+state, got %d", len(deployResult.Created)) + } + if _, err := store.Apply(deployResult.Consumed, deployResult.Created); err != nil { + t.Fatal(err) + } + var contractObject, stateObject, callFee object.Object + for _, item := range deployResult.Created { + switch item.Kind { + case object.KindContract: + contractObject = item + case object.KindContractState: + stateObject = item + case object.KindCoin: + callFee = item + } + } + if contractObject.ID == (types.ObjectID{}) || stateObject.ID == (types.ObjectID{}) { + t.Fatal("deployment objects missing") + } + root = store.Root() + objects := []object.Object{callFee, contractObject, stateObject} + inputs := make([]tx.InputRef, 0, len(objects)) + witnesses := make([]tx.Witness, 0, len(objects)) + for _, item := range objects { + proved, proof, ok := store.Proof(item.ID) + if !ok { + t.Fatal("missing call input proof") + } + h := proved.Hash() + inputs = append(inputs, tx.InputRef{ObjectID: item.ID, Version: item.Version, ObjectHash: h}) + witnesses = append(witnesses, tx.Witness{Object: proved, Proof: proof}) + } + callPayload, err := (contracts.Call{ContractObject: contractObject.ID, Entrypoint: "run", Arguments: []byte(stateObject.ID.String()), FuelLimit: 100_000, Accesses: []contracts.Access{{ObjectID: stateObject.ID, Write: true}}}).MarshalBinary() + if err != nil { + t.Fatal(err) + } + change2, _ := object.NewCoinOutput(owner, native, 8) + call := tx.Transaction{Version: tx.Version, Network: networkID, ShardID: 0, StateRoot: root, + Inputs: inputs, Outputs: []object.OutputSpec{change2}, Operations: []tx.Operation{{Kind: tx.OpContractCall, Payload: callPayload}}, Fee: 1, + Witnesses: witnesses, ValidUntilHeight: 30} + call.Salt[0] = 42 + if err := call.Sign(key); err != nil { + t.Fatal(err) + } + callResult, err := engine.Execute(call) + if err != nil { + t.Fatal(err) + } + if _, err := store.Apply(callResult.Consumed, callResult.Created); err != nil { + t.Fatal(err) + } + updated, _, ok := store.Proof(stateObject.ID) + if !ok || updated.Version != 2 || string(updated.Data) != "new" { + t.Fatalf("contract state not committed: %+v", updated) + } + if _, _, ok := store.Proof(contractObject.ID); !ok { + t.Fatal("read-only contract object was consumed") + } + foundReceipt := false + for _, item := range callResult.Created { + if item.Kind == object.KindSystem { + foundReceipt = true + } + } + if !foundReceipt { + t.Fatal("contract execution receipt not committed") + } +} diff --git a/internal/v2/execution/engine.go b/internal/v2/execution/engine.go new file mode 100644 index 00000000..0cdc63d0 --- /dev/null +++ b/internal/v2/execution/engine.go @@ -0,0 +1,275 @@ +package execution + +import ( + "errors" + "math" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/assets" + "github.com/zephyr-chain/zephyr-chain/internal/v2/contracts" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ( + ErrUnsupportedOperation = errors.New("unsupported v2 operation") + ErrOwnership = errors.New("transaction does not own an input object") + ErrConservation = errors.New("token conservation failed") + ErrOverflow = errors.New("token amount overflow") + ErrShard = errors.New("transaction routed to wrong shard") + ErrTokenPolicy = errors.New("native token policy rejected operation") +) + +type OutboundOutput struct { + DestinationShard uint32 + OutputIndex uint32 + Output object.OutputSpec +} + +type Result struct { + Consumed []types.ObjectID + Created []object.Object + Outbound []OutboundOutput + TxID types.Hash +} + +type Engine struct { + Network types.NetworkID + NativeToken types.TokenID + ShardCount uint32 + Height uint64 + ContractRuntimes map[string]contracts.MeteredRuntime +} + +func (e Engine) Execute(t tx.Transaction) (Result, error) { + if err := t.VerifyForNetwork(e.Network); err != nil { + return Result{}, err + } + if err := t.VerifyWitnesses(); err != nil { + return Result{}, err + } + if e.ShardCount == 0 { + e.ShardCount = 1 + } + router := sharding.Router{ShardCount: e.ShardCount} + senderShard, err := router.ShardForAccount(t.Sender) + if err != nil || t.ShardID != senderShard { + return Result{}, ErrShard + } + for _, in := range t.Inputs { + inputShard, err := router.ShardForObject(in.ObjectID) + if err != nil || inputShard != t.ShardID { + return Result{}, ErrShard + } + } + if len(t.Operations) != 1 { + return Result{}, ErrUnsupportedOperation + } + switch t.Operations[0].Kind { + case tx.OpTransfer: + return e.executeTransfer(t) + case tx.OpCreateToken: + return e.executeCreateToken(t, t.Operations[0].Payload) + default: + return e.executeExtended(t, t.Operations[0]) + } +} + +func (e Engine) executeTransfer(t tx.Transaction) (Result, error) { + inputTotals := map[types.TokenID]uint64{} + definitions := map[types.TokenID]assets.Definition{} + consumed := make([]types.ObjectID, 0, len(t.Inputs)) + for _, w := range t.Witnesses { + switch w.Object.Kind { + case object.KindCoin: + if w.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + coin, err := object.ParseCoin(w.Object.Data) + if err != nil { + return Result{}, err + } + if err := add(inputTotals, coin.Token, coin.Amount); err != nil { + return Result{}, err + } + consumed = append(consumed, w.Object.ID) + case object.KindTokenDefinition: + definition, err := assets.ParseDefinition(w.Object.Data) + if err != nil || definition.TokenID == e.NativeToken { + return Result{}, ErrTokenPolicy + } + if _, duplicate := definitions[definition.TokenID]; duplicate { + return Result{}, ErrTokenPolicy + } + definitions[definition.TokenID] = definition + default: + return Result{}, ErrOwnership + } + } + + outputTotals := map[types.TokenID]uint64{} + txID := t.ID() + created := make([]object.Object, 0, len(t.Outputs)) + outbound := make([]OutboundOutput, 0) + router := sharding.Router{ShardCount: e.ShardCount} + for i, spec := range t.Outputs { + if spec.Kind != object.KindCoin { + return Result{}, ErrConservation + } + coin, err := object.ParseCoin(spec.Data) + if err != nil { + return Result{}, err + } + if coin.Token != e.NativeToken { + definition, ok := definitions[coin.Token] + if !ok || !definition.Transferable { + return Result{}, ErrTokenPolicy + } + } + if err := add(outputTotals, coin.Token, coin.Amount); err != nil { + return Result{}, err + } + destination, err := router.ShardForAccount(spec.Owner) + if err != nil { + return Result{}, ErrShard + } + if coin.Token != e.NativeToken && destination != t.ShardID { + return Result{}, ErrTokenPolicy + } + stamped, err := stampCoinOutput(spec, e.Height) + if err != nil { + return Result{}, err + } + if destination == t.ShardID { + created = append(created, object.Object{ + ID: types.ObjectIDForShard(txID, uint32(i), destination), Version: 1, + Owner: stamped.Owner, Kind: stamped.Kind, Data: append([]byte(nil), stamped.Data...), + }) + } else { + outbound = append(outbound, OutboundOutput{DestinationShard: destination, OutputIndex: uint32(i), Output: stamped}) + } + } + + for token, inAmount := range inputTotals { + required := outputTotals[token] + if token == e.NativeToken { + if math.MaxUint64-required < t.Fee { + return Result{}, ErrOverflow + } + required += t.Fee + } else { + definition, ok := definitions[token] + if !ok || !definition.Transferable { + return Result{}, ErrTokenPolicy + } + } + if inAmount != required { + return Result{}, ErrConservation + } + delete(outputTotals, token) + } + if len(outputTotals) != 0 { + return Result{}, ErrConservation + } + + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: txID}, nil +} + +func (e Engine) executeCreateToken(t tx.Transaction, payload []byte) (Result, error) { + create, err := assets.ParseCreateToken(payload) + if err != nil { + return Result{}, err + } + if create.MintAuthority != t.Sender { + return Result{}, ErrOwnership + } + var nativeIn uint64 + for _, w := range t.Witnesses { + if w.Object.Owner != t.Sender || w.Object.Kind != object.KindCoin { + return Result{}, ErrOwnership + } + coin, err := object.ParseCoin(w.Object.Data) + if err != nil { + return Result{}, err + } + if coin.Token != e.NativeToken { + return Result{}, ErrConservation + } + if math.MaxUint64-nativeIn < coin.Amount { + return Result{}, ErrOverflow + } + nativeIn += coin.Amount + } + var nativeOut uint64 + txID := t.ID() + created := make([]object.Object, 0, len(t.Outputs)+2) + outbound := make([]OutboundOutput, 0) + router := sharding.Router{ShardCount: e.ShardCount} + for i, spec := range t.Outputs { + coin, err := object.ParseCoin(spec.Data) + if err != nil || spec.Kind != object.KindCoin || coin.Token != e.NativeToken { + return Result{}, ErrConservation + } + if math.MaxUint64-nativeOut < coin.Amount { + return Result{}, ErrOverflow + } + nativeOut += coin.Amount + destination, err := router.ShardForAccount(spec.Owner) + if err != nil { + return Result{}, ErrShard + } + stamped, err := stampCoinOutput(spec, e.Height) + if err != nil { + return Result{}, err + } + if destination == t.ShardID { + created = append(created, object.Object{ + ID: types.ObjectIDForShard(txID, uint32(i), destination), Version: 1, + Owner: stamped.Owner, Kind: stamped.Kind, Data: append([]byte(nil), stamped.Data...), + }) + } else { + outbound = append(outbound, OutboundOutput{DestinationShard: destination, OutputIndex: uint32(i), Output: stamped}) + } + } + if math.MaxUint64-nativeOut < t.Fee || nativeIn != nativeOut+t.Fee { + return Result{}, ErrConservation + } + + tokenID := types.TokenIDFromTransaction(txID, 0) + definition := assets.Definition{ + TokenID: tokenID, Name: create.Name, Symbol: create.Symbol, Decimals: create.Decimals, SupplyPolicy: create.SupplyPolicy, + MaxSupply: create.MaxSupply, CurrentSupply: create.InitialSupply, + MintAuthority: create.MintAuthority, Burnable: create.Burnable, Transferable: create.Transferable, + } + defData, err := definition.MarshalBinary() + if err != nil { + return Result{}, err + } + defID := types.ObjectIDForShard(txID, 0x80000000, t.ShardID) + created = append(created, object.Object{ + ID: defID, Version: 1, Owner: t.Sender, Kind: object.KindTokenDefinition, Data: defData, + }) + initialCoin, err := object.NewCoinOutputAtHeight(t.Sender, tokenID, create.InitialSupply, e.Height) + if err != nil { + return Result{}, err + } + created = append(created, object.Object{ + ID: types.ObjectIDForShard(txID, 0x80000001, t.ShardID), Version: 1, + Owner: initialCoin.Owner, Kind: initialCoin.Kind, Data: initialCoin.Data, + }) + consumed := make([]types.ObjectID, len(t.Inputs)) + for i, in := range t.Inputs { + consumed[i] = in.ObjectID + } + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: txID}, nil +} + +func add(totals map[types.TokenID]uint64, token types.TokenID, amount uint64) error { + current := totals[token] + if math.MaxUint64-current < amount { + return ErrOverflow + } + totals[token] = current + amount + return nil +} diff --git a/internal/v2/execution/engine_test.go b/internal/v2/execution/engine_test.go new file mode 100644 index 00000000..1acd4874 --- /dev/null +++ b/internal/v2/execution/engine_test.go @@ -0,0 +1,142 @@ +package execution + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/assets" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/state" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func makeKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return k +} + +func TestWalletProofToExecutionToNewRoot(t *testing.T) { + aliceKey := makeKey(t) + alicePub := elliptic.Marshal(elliptic.P256(), aliceKey.PublicKey.X, aliceKey.PublicKey.Y) + alice := types.AccountIDFromPublicKey(alicePub) + bob := types.AccountIDFromPublicKey([]byte("bob")) + network := types.NetworkID(types.HashBytes("network", []byte("v2"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + + store := worldstate.NewMemory() + genesisTx := types.HashBytes("genesis", []byte("coin")) + coinID := types.ObjectIDFromTransaction(genesisTx, 0) + initialOut, err := object.NewCoinOutput(alice, native, 100) + if err != nil { + t.Fatal(err) + } + initial := object.Object{ID: coinID, Version: 1, Owner: alice, Kind: initialOut.Kind, Data: initialOut.Data} + root, err := store.Apply(nil, []object.Object{initial}) + if err != nil { + t.Fatal(err) + } + + witnessObject, proof, ok := store.Proof(coinID) + if !ok { + t.Fatal("missing input") + } + inputHash := witnessObject.Hash() + toBob, _ := object.NewCoinOutput(bob, native, 25) + change, _ := object.NewCoinOutput(alice, native, 74) + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: coinID, Version: 1, ObjectHash: inputHash}}, + Outputs: []object.OutputSpec{toBob, change}, + Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Fee: 1, ValidUntilHeight: 10, + Witnesses: []tx.Witness{{Object: witnessObject, Proof: proof}}, + } + transaction.Salt[0] = 7 + if err := transaction.Sign(aliceKey); err != nil { + t.Fatal(err) + } + + engine := Engine{Network: network, NativeToken: native, ShardCount: 1} + result, err := engine.Execute(transaction) + if err != nil { + t.Fatal(err) + } + if len(result.Consumed) != 1 || len(result.Created) != 2 { + t.Fatalf("unexpected result: %+v", result) + } + newRoot, err := store.Apply(result.Consumed, result.Created) + if err != nil { + t.Fatal(err) + } + if newRoot == root { + t.Fatal("state root did not change") + } + + bobObject, bobProof, ok := store.Proof(result.Created[0].ID) + if !ok { + t.Fatal("bob output missing") + } + bobHash := bobObject.Hash() + if !state.Verify(newRoot, types.Hash(bobObject.ID), bobHash[:], bobProof) { + t.Fatal("recipient cannot verify resulting object") + } +} + +func TestNativeTokenCreation(t *testing.T) { + key := makeKey(t) + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + alice := types.AccountIDFromPublicKey(pub) + network := types.NetworkID(types.HashBytes("network", []byte("v2"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + store := worldstate.NewMemory() + id := types.ObjectIDFromTransaction(types.HashBytes("seed", []byte("fee")), 0) + feeCoinOut, _ := object.NewCoinOutput(alice, native, 10) + feeCoin := object.Object{ID: id, Version: 1, Owner: alice, Kind: feeCoinOut.Kind, Data: feeCoinOut.Data} + root, err := store.Apply(nil, []object.Object{feeCoin}) + if err != nil { + t.Fatal(err) + } + witness, proof, _ := store.Proof(id) + h := witness.Hash() + change, _ := object.NewCoinOutput(alice, native, 9) + + create := assets.CreateToken{ + Name: "Example Token", Symbol: "EXM", Decimals: 6, + MaxSupply: 1_000_000, InitialSupply: 500_000, MintAuthority: alice, + Burnable: true, Transferable: true, + } + payload, err := create.MarshalBinary() + if err != nil { + t.Fatal(err) + } + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: id, Version: 1, ObjectHash: h}}, + Outputs: []object.OutputSpec{change}, + Operations: []tx.Operation{{Kind: tx.OpCreateToken, Payload: payload}}, + Fee: 1, + Witnesses: []tx.Witness{{Object: witness, Proof: proof}}, + } + transaction.Salt[0] = 9 + if err := transaction.Sign(key); err != nil { + t.Fatal(err) + } + result, err := (Engine{Network: network, NativeToken: native, ShardCount: 1}).Execute(transaction) + if err != nil { + t.Fatal(err) + } + if len(result.Created) != 3 { + t.Fatalf("expected change + token definition + initial supply, got %d", len(result.Created)) + } + if result.Created[1].Kind != object.KindTokenDefinition || result.Created[2].Kind != object.KindCoin { + t.Fatalf("unexpected token creation objects: %v %v", result.Created[1].Kind, result.Created[2].Kind) + } +} diff --git a/internal/v2/execution/extended_compute.go b/internal/v2/execution/extended_compute.go new file mode 100644 index 00000000..61823407 --- /dev/null +++ b/internal/v2/execution/extended_compute.go @@ -0,0 +1,427 @@ +package execution + +import ( + "bytes" + "math" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const computeReceiptIndex uint32 = 0xC0000000 + +func (e Engine) executeCompute(t tx.Transaction, op tx.Operation) (Result, error) { + switch op.Kind { + case tx.OpComputeOffer: + return e.executeComputeOffer(t, op.Payload) + case tx.OpComputeJob: + return e.executeComputeJob(t, op.Payload) + case tx.OpComputeAccept: + return e.executeComputeAccept(t, op.Payload) + case tx.OpComputeResult: + return e.executeComputeResult(t, op.Payload) + case tx.OpComputeIngestAssignment: + return e.executeComputeIngestAssignment(t, op.Payload) + case tx.OpComputeIngestResult: + return e.executeComputeIngestResult(t, op.Payload) + case tx.OpComputeFinalize: + return e.executeComputeFinalize(t, op.Payload, false) + case tx.OpComputeResolveReplicated: + return e.executeComputeFinalize(t, op.Payload, true) + case tx.OpComputeExpire: + return e.executeComputeExpire(t, op.Payload) + default: + return Result{}, ErrUnsupportedOperation + } +} + +func (e Engine) executeComputeOffer(t tx.Transaction, payload []byte) (Result, error) { + offer, err := compute.ParseOffer(payload) + if err != nil || offer.Provider != t.Sender || (e.Height > 0 && offer.ValidUntilHeight < e.Height) { + return Result{}, ErrOwnership + } + created, outbound, consumed, err := e.accountNativeValue(t, nil, 0, offer.Collateral) + if err != nil { + return Result{}, err + } + offerObject, err := compute.NewOfferObject(t.ID(), t.ShardID, offer) + if err != nil { + return Result{}, err + } + created = append(created, offerObject) + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) executeComputeJob(t tx.Transaction, payload []byte) (Result, error) { + job, err := compute.ParseJob(payload) + if err != nil || job.Owner != t.Sender || (e.Height > 0 && job.DeadlineHeight <= e.Height) { + return Result{}, ErrOwnership + } + created, outbound, consumed, err := e.accountNativeValue(t, nil, 0, job.MaxPrice) + if err != nil { + return Result{}, err + } + jobObject, _, err := compute.NewJobObject(t.ID(), t.ShardID, job, job.MaxPrice) + if err != nil { + return Result{}, err + } + created = append(created, jobObject) + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) executeComputeAccept(t tx.Transaction, payload []byte) (Result, error) { + message, err := compute.ParseAssignmentMessage(payload) + if err != nil || message.Offer.Provider != t.Sender || message.JobShard >= e.ShardCount { + return Result{}, ErrOwnership + } + offerWitness, ok := witnessByKind(t, object.KindComputeOffer) + if !ok || offerWitness.Object.Owner != t.Sender || types.Hash(offerWitness.Object.ID) != message.OfferID || !bytes.Equal(offerWitness.Object.Data, mustOfferBytes(message.Offer)) { + return Result{}, ErrOwnership + } + locked := message.Job.CollateralRequired + excluded := map[types.ObjectID]bool{offerWitness.Object.ID: true} + created, outbound, consumed, err := e.accountNativeValue(t, excluded, message.Offer.Collateral, locked) + if err != nil { + return Result{}, err + } + consumed = append(consumed, offerWitness.Object.ID) + spec, err := message.Output() + if err != nil { + return Result{}, err + } + if message.JobShard == t.ShardID { + created = append(created, object.Object{ID: types.ObjectIDForShard(t.ID(), computeReceiptIndex, t.ShardID), Version: 1, Owner: spec.Owner, Kind: spec.Kind, Data: spec.Data}) + } else { + outbound = append(outbound, OutboundOutput{DestinationShard: message.JobShard, OutputIndex: computeReceiptIndex, Output: spec}) + } + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) executeComputeResult(t tx.Transaction, payload []byte) (Result, error) { + message, err := compute.ParseResultMessage(payload) + if err != nil || message.Result.Provider != t.Sender || message.JobShard >= e.ShardCount { + return Result{}, ErrOwnership + } + created, outbound, consumed, err := e.accountNativeValue(t, nil, 0, 0) + if err != nil { + return Result{}, err + } + spec, _ := message.Output() + if message.JobShard == t.ShardID { + created = append(created, object.Object{ID: types.ObjectIDForShard(t.ID(), computeReceiptIndex, t.ShardID), Version: 1, Owner: spec.Owner, Kind: spec.Kind, Data: spec.Data}) + } else { + outbound = append(outbound, OutboundOutput{DestinationShard: message.JobShard, OutputIndex: computeReceiptIndex, Output: spec}) + } + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) executeComputeIngestAssignment(t tx.Transaction, payload []byte) (Result, error) { + ref, err := compute.ParseIngestRef(payload) + if err != nil { + return Result{}, err + } + jobWitness, ok := witnessByID(t, ref.JobObject) + if !ok || jobWitness.Object.Kind != object.KindComputeJob || jobWitness.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + messageWitness, ok := witnessByID(t, ref.MessageObject) + if !ok || messageWitness.Object.Kind != object.KindComputeAssignment || messageWitness.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + record, err := compute.ParseOnChainJob(jobWitness.Object.Data) + if err != nil { + return Result{}, err + } + message, err := compute.ParseAssignmentMessage(messageWitness.Object.Data) + if err != nil || message.JobShard != t.ShardID || message.ValidateForRecord(record) != nil { + return Result{}, compute.ErrComputeMessage + } + updated, _, _, err := compute.AssignOnChain(record, message.OfferID, message.Offer, e.Height) + if err != nil { + return Result{}, err + } + updatedRaw, err := updated.MarshalBinary() + if err != nil { + return Result{}, err + } + excluded := map[types.ObjectID]bool{jobWitness.Object.ID: true, messageWitness.Object.ID: true} + created, outbound, consumed, err := e.accountNativeValue(t, excluded, record.Job.CollateralRequired, record.Job.CollateralRequired) + if err != nil { + return Result{}, err + } + consumed = append(consumed, jobWitness.Object.ID, messageWitness.Object.ID) + jobObject := jobWitness.Object + jobObject.Version++ + jobObject.Data = updatedRaw + created = append(created, jobObject) + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) executeComputeIngestResult(t tx.Transaction, payload []byte) (Result, error) { + ref, err := compute.ParseIngestRef(payload) + if err != nil { + return Result{}, err + } + jobWitness, ok := witnessByID(t, ref.JobObject) + if !ok || jobWitness.Object.Kind != object.KindComputeJob || jobWitness.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + messageWitness, ok := witnessByID(t, ref.MessageObject) + if !ok || messageWitness.Object.Kind != object.KindComputeResult || messageWitness.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + record, err := compute.ParseOnChainJob(jobWitness.Object.Data) + if err != nil { + return Result{}, err + } + message, err := compute.ParseResultMessage(messageWitness.Object.Data) + if err != nil || message.JobID != record.ID || message.JobOwner != record.Job.Owner || message.JobShard != t.ShardID { + return Result{}, compute.ErrComputeMessage + } + updated, err := compute.SubmitOnChainResult(record, message.Result) + if err != nil { + return Result{}, err + } + updatedRaw, _ := updated.MarshalBinary() + excluded := map[types.ObjectID]bool{jobWitness.Object.ID: true, messageWitness.Object.ID: true} + created, outbound, consumed, err := e.accountNativeValue(t, excluded, 0, 0) + if err != nil { + return Result{}, err + } + consumed = append(consumed, jobWitness.Object.ID, messageWitness.Object.ID) + jobObject := jobWitness.Object + jobObject.Version++ + jobObject.Data = updatedRaw + created = append(created, jobObject) + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) executeComputeFinalize(t tx.Transaction, payload []byte, majority bool) (Result, error) { + ref, err := compute.ParseJobRef(payload) + if err != nil { + return Result{}, err + } + jobWitness, ok := witnessByID(t, ref.JobObject) + if !ok || jobWitness.Object.Kind != object.KindComputeJob || jobWitness.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + record, err := compute.ParseOnChainJob(jobWitness.Object.Data) + if err != nil || record.Job.Owner != t.Sender { + return Result{}, ErrOwnership + } + var settlement compute.OnChainSettlement + if majority { + _, settlement, err = compute.ResolveReplicatedMajority(record) + } else { + if record.Job.Verification != compute.VerificationReplicated { + return Result{}, compute.ErrMarketVerification + } + _, settlement, err = compute.FinalizeOnChain(record, compute.VerificationEvidence{}) + } + if err != nil { + return Result{}, err + } + locked, err := jobLockedValue(record) + if err != nil { + return Result{}, err + } + generated, generatedOutbound, generatedAmount, err := e.settlementOutputs(t, record, settlement) + if err != nil || generatedAmount != locked { + return Result{}, compute.ErrMarketEscrow + } + excluded := map[types.ObjectID]bool{jobWitness.Object.ID: true} + created, outbound, consumed, err := e.accountNativeValue(t, excluded, locked, generatedAmount) + if err != nil { + return Result{}, err + } + created = append(created, generated...) + outbound = append(outbound, generatedOutbound...) + consumed = append(consumed, jobWitness.Object.ID) + receipt := compute.SettlementReceipt{JobID: record.ID, ResultRoot: settlement.ResultRoot, Payments: settlement.Payments, Refund: settlement.Refund, Slashed: settlement.SlashedCollateral, SlashReward: settlement.SlashReward} + created = append(created, object.Object{ID: types.ObjectIDForShard(t.ID(), computeReceiptIndex, t.ShardID), Version: 1, Kind: object.KindSystem, Data: receipt.MarshalBinary()}) + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) executeComputeExpire(t tx.Transaction, payload []byte) (Result, error) { + ref, err := compute.ParseJobRef(payload) + if err != nil { + return Result{}, err + } + jobWitness, ok := witnessByID(t, ref.JobObject) + if !ok || jobWitness.Object.Kind != object.KindComputeJob || jobWitness.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + record, err := compute.ParseOnChainJob(jobWitness.Object.Data) + if err != nil { + return Result{}, err + } + _, refund, collateral, err := compute.ExpireOnChain(record, e.Height) + if err != nil { + return Result{}, err + } + settlement := compute.OnChainSettlement{Settlement: compute.Settlement{JobID: record.ID, Payments: map[types.AccountID]uint64{}, Refund: refund}, CollateralReturns: collateral, SlashedCollateral: map[types.AccountID]uint64{}} + locked, err := jobLockedValue(record) + if err != nil { + return Result{}, err + } + generated, generatedOutbound, generatedAmount, err := e.settlementOutputs(t, record, settlement) + if err != nil || generatedAmount != locked { + return Result{}, compute.ErrMarketEscrow + } + excluded := map[types.ObjectID]bool{jobWitness.Object.ID: true} + created, outbound, consumed, err := e.accountNativeValue(t, excluded, locked, generatedAmount) + if err != nil { + return Result{}, err + } + created = append(created, generated...) + outbound = append(outbound, generatedOutbound...) + consumed = append(consumed, jobWitness.Object.ID) + receipt := compute.SettlementReceipt{JobID: record.ID, Refund: refund, Expired: true} + created = append(created, object.Object{ID: types.ObjectIDForShard(t.ID(), computeReceiptIndex, t.ShardID), Version: 1, Kind: object.KindSystem, Data: receipt.MarshalBinary()}) + return Result{Consumed: consumed, Created: created, Outbound: outbound, TxID: t.ID()}, nil +} + +func (e Engine) accountNativeValue(t tx.Transaction, excluded map[types.ObjectID]bool, internalIn, internalOut uint64) ([]object.Object, []OutboundOutput, []types.ObjectID, error) { + var available uint64 = internalIn + consumed := make([]types.ObjectID, 0) + for _, witness := range t.Witnesses { + if excluded[witness.Object.ID] { + continue + } + if witness.Object.Kind != object.KindCoin || witness.Object.Owner != t.Sender { + return nil, nil, nil, ErrOwnership + } + coin, err := object.ParseCoin(witness.Object.Data) + if err != nil || coin.Token != e.NativeToken || math.MaxUint64-available < coin.Amount { + return nil, nil, nil, ErrConservation + } + available += coin.Amount + consumed = append(consumed, witness.Object.ID) + } + var required uint64 = internalOut + created := make([]object.Object, 0, len(t.Outputs)) + outbound := make([]OutboundOutput, 0) + router := sharding.Router{ShardCount: e.ShardCount} + for i, spec := range t.Outputs { + if spec.Kind != object.KindCoin { + return nil, nil, nil, ErrConservation + } + coin, err := object.ParseCoin(spec.Data) + if err != nil || coin.Token != e.NativeToken || math.MaxUint64-required < coin.Amount { + return nil, nil, nil, ErrConservation + } + required += coin.Amount + destination, err := router.ShardForAccount(spec.Owner) + if err != nil { + return nil, nil, nil, ErrShard + } + if destination == t.ShardID { + created = append(created, object.Object{ID: types.ObjectIDForShard(t.ID(), uint32(i), t.ShardID), Version: 1, Owner: spec.Owner, Kind: spec.Kind, Data: append([]byte(nil), spec.Data...)}) + } else { + outbound = append(outbound, OutboundOutput{DestinationShard: destination, OutputIndex: uint32(i), Output: spec}) + } + } + if math.MaxUint64-required < t.Fee { + return nil, nil, nil, ErrOverflow + } + required += t.Fee + if available != required { + return nil, nil, nil, ErrConservation + } + return created, outbound, consumed, nil +} + +func (e Engine) settlementOutputs(t tx.Transaction, record compute.OnChainJob, settlement compute.OnChainSettlement) ([]object.Object, []OutboundOutput, uint64, error) { + amounts := make(map[types.AccountID]uint64) + for provider, value := range settlement.Payments { + amounts[provider] += value + } + for provider, value := range settlement.CollateralReturns { + if math.MaxUint64-amounts[provider] < value { + return nil, nil, 0, ErrOverflow + } + amounts[provider] += value + } + ownerAmount := settlement.Refund + if math.MaxUint64-ownerAmount < settlement.SlashReward { + return nil, nil, 0, ErrOverflow + } + ownerAmount += settlement.SlashReward + if ownerAmount > 0 { + amounts[record.Job.Owner] += ownerAmount + } + ids := make([]types.AccountID, 0, len(amounts)) + for id := range amounts { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i].String() < ids[j].String() }) + router := sharding.Router{ShardCount: e.ShardCount} + created := make([]object.Object, 0, len(ids)) + outbound := make([]OutboundOutput, 0) + var total uint64 + for i, id := range ids { + amount := amounts[id] + if math.MaxUint64-total < amount { + return nil, nil, 0, ErrOverflow + } + total += amount + spec, err := object.NewCoinOutput(id, e.NativeToken, amount) + if err != nil { + return nil, nil, 0, err + } + destination, err := router.ShardForAccount(id) + if err != nil { + return nil, nil, 0, ErrShard + } + index := computeReceiptIndex + 1 + uint32(i) + if destination == t.ShardID { + created = append(created, object.Object{ID: types.ObjectIDForShard(t.ID(), index, t.ShardID), Version: 1, Owner: spec.Owner, Kind: spec.Kind, Data: spec.Data}) + } else { + outbound = append(outbound, OutboundOutput{DestinationShard: destination, OutputIndex: index, Output: spec}) + } + } + return created, outbound, total, nil +} + +func jobLockedValue(record compute.OnChainJob) (uint64, error) { + locked := record.Escrow + for range record.Assignments { + if math.MaxUint64-locked < record.Job.CollateralRequired { + return 0, ErrOverflow + } + locked += record.Job.CollateralRequired + } + return locked, nil +} + +func witnessByKind(t tx.Transaction, kind object.Kind) (tx.Witness, bool) { + var found tx.Witness + ok := false + for _, witness := range t.Witnesses { + if witness.Object.Kind == kind { + if ok { + return tx.Witness{}, false + } + found, ok = witness, true + } + } + return found, ok +} + +func witnessByID(t tx.Transaction, id types.ObjectID) (tx.Witness, bool) { + for _, witness := range t.Witnesses { + if witness.Object.ID == id { + return witness, true + } + } + return tx.Witness{}, false +} + +func mustOfferBytes(offer compute.Offer) []byte { + raw, _ := offer.MarshalBinary() + return raw +} diff --git a/internal/v2/execution/extended_contract.go b/internal/v2/execution/extended_contract.go new file mode 100644 index 00000000..14ad304b --- /dev/null +++ b/internal/v2/execution/extended_contract.go @@ -0,0 +1,194 @@ +package execution + +import ( + "math" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/contracts" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + contractObjectIndex uint32 = 0xA0000000 + contractStateIndex uint32 = 0xA0000001 + contractReceiptIndex uint32 = 0xA0000010 +) + +func (e Engine) executeExtended(t tx.Transaction, op tx.Operation) (Result, error) { + switch op.Kind { + case tx.OpDeployContract: + return e.executeDeployContract(t, op.Payload) + case tx.OpContractCall: + return e.executeContractCall(t, op.Payload) + case tx.OpMintToken: + return e.executeMintToken(t, op.Payload) + case tx.OpBurnToken: + return e.executeBurnToken(t, op.Payload) + case tx.OpComputeOffer, tx.OpComputeJob, tx.OpComputeResult, tx.OpComputeAccept, tx.OpComputeIngestAssignment, tx.OpComputeIngestResult, tx.OpComputeFinalize, tx.OpComputeResolveReplicated, tx.OpComputeExpire: + return e.executeCompute(t, op) + default: + return Result{}, ErrUnsupportedOperation + } +} + +func (e Engine) contractRuntime(name string) (contracts.MeteredRuntime, bool) { + if e.ContractRuntimes != nil { + if runtime, ok := e.ContractRuntimes[name]; ok && runtime.Inner != nil { + return runtime, true + } + } + if name == contracts.RuntimeZephyrScriptV1 { + return contracts.MeteredRuntime{Inner: contracts.ScriptRuntime{}}, true + } + return contracts.MeteredRuntime{}, false +} + +func (e Engine) executeDeployContract(t tx.Transaction, payload []byte) (Result, error) { + deployment, err := contracts.ParseDeployment(payload) + if err != nil || deployment.UpgradeAuthority != t.Sender { + return Result{}, ErrOwnership + } + runtime, ok := e.contractRuntime(deployment.Runtime) + if !ok { + return Result{}, ErrUnsupportedOperation + } + if err := runtime.ValidateModule(deployment.Code); err != nil { + return Result{}, err + } + created, consumed, err := e.feeOnlyOutputs(t) + if err != nil { + return Result{}, err + } + txID := t.ID() + contractID := types.ContractIDFromTransaction(txID, 0) + storedRaw, err := (contracts.StoredContract{ID: contractID, Deployment: deployment}).MarshalBinary() + if err != nil { + return Result{}, err + } + created = append(created, object.Object{ID: types.ObjectIDForShard(txID, contractObjectIndex, t.ShardID), Version: 1, Owner: t.Sender, Kind: object.KindContract, Data: storedRaw}) + if len(deployment.InitialState) > 0 { + created = append(created, object.Object{ID: types.ObjectIDForShard(txID, contractStateIndex, t.ShardID), Version: 1, Owner: t.Sender, Kind: object.KindContractState, Data: append([]byte(nil), deployment.InitialState...)}) + } + return Result{Consumed: consumed, Created: created, TxID: txID}, nil +} + +func (e Engine) executeContractCall(t tx.Transaction, payload []byte) (Result, error) { + call, err := contracts.ParseCall(payload) + if err != nil { + return Result{}, err + } + witnesses := make(map[types.ObjectID]tx.Witness, len(t.Witnesses)) + for _, witness := range t.Witnesses { + witnesses[witness.Object.ID] = witness + } + contractWitness, ok := witnesses[call.ContractObject] + if !ok || contractWitness.Object.Kind != object.KindContract { + return Result{}, ErrOwnership + } + stored, err := contracts.ParseStoredContract(contractWitness.Object.Data) + if err != nil { + return Result{}, err + } + runtime, ok := e.contractRuntime(stored.Deployment.Runtime) + if !ok { + return Result{}, ErrUnsupportedOperation + } + readValues := make(map[types.ObjectID][]byte, len(call.Accesses)) + for _, access := range call.Accesses { + witness, ok := witnesses[access.ObjectID] + if !ok || witness.Object.Kind != object.KindContractState { + return Result{}, ErrOwnership + } + readValues[access.ObjectID] = append([]byte(nil), witness.Object.Data...) + } + result, err := runtime.Execute(contracts.Request{ContractID: stored.ID, Runtime: stored.Deployment.Runtime, Code: stored.Deployment.Code, Entrypoint: call.Entrypoint, Arguments: call.Arguments, Accesses: call.Accesses, ReadValues: readValues, FuelLimit: call.FuelLimit}) + if err != nil { + return Result{}, err + } + created, consumed, err := e.feeOnlyOutputsExcluding(t, call.ContractObject, call.Accesses) + if err != nil { + return Result{}, err + } + for id, value := range result.Writes { + witness := witnesses[id] + consumed = append(consumed, id) + updated := witness.Object + updated.Version++ + updated.Data = append([]byte(nil), value...) + created = append(created, updated) + } + eventLeaves := make([]types.Hash, len(result.Events)) + for i, event := range result.Events { + eventLeaves[i] = merkle.Leaf("contract-event", event) + } + receipt := contracts.Receipt{ContractID: stored.ID, FuelUsed: result.FuelUsed, ReturnHash: types.Hash(codec.DomainHash("zephyr/contract-return/v2", result.ReturnData)), EventRoot: merkle.Root(eventLeaves)} + txID := t.ID() + created = append(created, object.Object{ID: types.ObjectIDForShard(txID, contractReceiptIndex, t.ShardID), Version: 1, Owner: t.Sender, Kind: object.KindSystem, Data: receipt.MarshalBinary()}) + return Result{Consumed: consumed, Created: created, TxID: txID}, nil +} + +func (e Engine) feeOnlyOutputs(t tx.Transaction) ([]object.Object, []types.ObjectID, error) { + return e.feeOnlyOutputsExcluding(t, types.ObjectID{}, nil) +} + +func (e Engine) feeOnlyOutputsExcluding(t tx.Transaction, contractObject types.ObjectID, accesses []contracts.Access) ([]object.Object, []types.ObjectID, error) { + nonCoin := make(map[types.ObjectID]bool, len(accesses)+1) + if !types.IsZero32([32]byte(contractObject)) { + nonCoin[contractObject] = true + } + for _, access := range accesses { + nonCoin[access.ObjectID] = true + } + var nativeIn uint64 + consumed := make([]types.ObjectID, 0) + for _, witness := range t.Witnesses { + if nonCoin[witness.Object.ID] { + continue + } + if witness.Object.Kind != object.KindCoin || witness.Object.Owner != t.Sender { + return nil, nil, ErrOwnership + } + coin, err := object.ParseCoin(witness.Object.Data) + if err != nil || coin.Token != e.NativeToken { + return nil, nil, ErrConservation + } + if math.MaxUint64-nativeIn < coin.Amount { + return nil, nil, ErrOverflow + } + nativeIn += coin.Amount + consumed = append(consumed, witness.Object.ID) + } + var nativeOut uint64 + created := make([]object.Object, 0, len(t.Outputs)) + router := sharding.Router{ShardCount: e.ShardCount} + for i, spec := range t.Outputs { + if spec.Kind != object.KindCoin { + return nil, nil, ErrConservation + } + destination, err := router.ShardForAccount(spec.Owner) + if err != nil || destination != t.ShardID { + return nil, nil, ErrShard + } + coin, err := object.ParseCoin(spec.Data) + if err != nil || coin.Token != e.NativeToken { + return nil, nil, ErrConservation + } + if math.MaxUint64-nativeOut < coin.Amount { + return nil, nil, ErrOverflow + } + nativeOut += coin.Amount + stamped, err := stampCoinOutput(spec, e.Height) + if err != nil { + return nil, nil, err + } + created = append(created, object.Object{ID: types.ObjectIDForShard(t.ID(), uint32(i), t.ShardID), Version: 1, Owner: stamped.Owner, Kind: stamped.Kind, Data: append([]byte(nil), stamped.Data...)}) + } + if math.MaxUint64-nativeOut < t.Fee || nativeIn != nativeOut+t.Fee { + return nil, nil, ErrConservation + } + return created, consumed, nil +} diff --git a/internal/v2/execution/parallel.go b/internal/v2/execution/parallel.go new file mode 100644 index 00000000..80c99e3c --- /dev/null +++ b/internal/v2/execution/parallel.go @@ -0,0 +1,176 @@ +package execution + +import ( + "errors" + "runtime" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/contracts" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +var ( + ErrBatchConflict = errors.New("v2 batch contains conflicting transactions") + ErrBatchStateRoot = errors.New("v2 batch does not target the current state root") +) + +type accessMode uint8 + +const ( + accessRead accessMode = iota + 1 + accessWrite +) + +// BatchExecutor executes proof-carrying transactions concurrently when their +// state access sets are independent. Shared immutable/read-only objects are +// allowed; any read/write or write/write overlap is rejected before workers run. +type BatchExecutor struct { + Engine Engine + Workers int +} + +func (b BatchExecutor) ExecuteBatch(transactions []tx.Transaction) ([]Result, error) { + if len(transactions) == 0 { + return nil, nil + } + if err := validateIndependentBatch(transactions); err != nil { + return nil, err + } + workers := b.Workers + if workers <= 0 { + workers = runtime.GOMAXPROCS(0) + } + if workers > len(transactions) { + workers = len(transactions) + } + if workers < 1 { + workers = 1 + } + + results := make([]Result, len(transactions)) + errs := make([]error, len(transactions)) + jobs := make(chan int) + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + for index := range jobs { + results[index], errs[index] = b.Engine.Execute(transactions[index]) + } + }() + } + for i := range transactions { + jobs <- i + } + close(jobs) + wg.Wait() + for i := range errs { + if errs[i] != nil { + return nil, errs[i] + } + } + return results, nil +} + +func (b BatchExecutor) ApplyBatch(store worldstate.Backend, transactions []tx.Transaction) (types.Hash, []Result, error) { + if len(transactions) == 0 { + return store.Root(), nil, nil + } + if store.Root() != transactions[0].StateRoot { + return store.Root(), nil, ErrBatchStateRoot + } + results, err := b.ExecuteBatch(transactions) + if err != nil { + return store.Root(), nil, err + } + consumed := make([]types.ObjectID, 0) + created := make([]object.Object, 0) + for _, result := range results { + consumed = append(consumed, result.Consumed...) + created = append(created, result.Created...) + } + root, err := store.Apply(consumed, created) + if err != nil { + return store.Root(), nil, err + } + return root, results, nil +} + +func validateIndependentBatch(transactions []tx.Transaction) error { + root := transactions[0].StateRoot + seenTransactions := make(map[types.Hash]struct{}) + global := make(map[types.ObjectID]accessMode) + for _, transaction := range transactions { + if transaction.StateRoot != root { + return ErrBatchStateRoot + } + id := transaction.ID() + if _, duplicate := seenTransactions[id]; duplicate { + return ErrBatchConflict + } + seenTransactions[id] = struct{}{} + accesses, err := transactionAccesses(transaction) + if err != nil { + return err + } + for objectID, mode := range accesses { + if prior, exists := global[objectID]; exists { + if prior == accessWrite || mode == accessWrite { + return ErrBatchConflict + } + continue + } + global[objectID] = mode + } + } + return nil +} + +func transactionAccesses(transaction tx.Transaction) (map[types.ObjectID]accessMode, error) { + accesses := make(map[types.ObjectID]accessMode, len(transaction.Inputs)) + for _, input := range transaction.Inputs { + accesses[input.ObjectID] = accessWrite + } + if len(transaction.Operations) != 1 { + return accesses, nil + } + switch transaction.Operations[0].Kind { + case tx.OpTransfer: + for _, witness := range transaction.Witnesses { + if witness.Object.Kind != object.KindTokenDefinition { + continue + } + if _, present := accesses[witness.Object.ID]; !present { + return nil, ErrBatchConflict + } + accesses[witness.Object.ID] = accessRead + } + return accesses, nil + case tx.OpContractCall: + call, err := contracts.ParseCall(transaction.Operations[0].Payload) + if err != nil { + return nil, err + } + if _, present := accesses[call.ContractObject]; !present { + return nil, ErrBatchConflict + } + accesses[call.ContractObject] = accessRead + for _, access := range call.Accesses { + if _, present := accesses[access.ObjectID]; !present { + return nil, ErrBatchConflict + } + if access.Write { + accesses[access.ObjectID] = accessWrite + } else { + accesses[access.ObjectID] = accessRead + } + } + return accesses, nil + default: + return accesses, nil + } +} diff --git a/internal/v2/execution/parallel_access_test.go b/internal/v2/execution/parallel_access_test.go new file mode 100644 index 00000000..ab3ef444 --- /dev/null +++ b/internal/v2/execution/parallel_access_test.go @@ -0,0 +1,51 @@ +package execution + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/contracts" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestBatchAllowsSharedReadOnlyContractObject(t *testing.T) { + root := types.HashBytes("state", []byte("shared-read")) + contractID := types.ObjectID(types.HashBytes("contract-object", []byte("shared"))) + stateA := types.ObjectID(types.HashBytes("state-object", []byte("a"))) + stateB := types.ObjectID(types.HashBytes("state-object", []byte("b"))) + feeA := types.ObjectID(types.HashBytes("coin", []byte("a"))) + feeB := types.ObjectID(types.HashBytes("coin", []byte("b"))) + + makeTx := func(stateID, feeID types.ObjectID, salt byte) tx.Transaction { + payload, err := (contracts.Call{ContractObject: contractID, Entrypoint: "run", FuelLimit: 100, Accesses: []contracts.Access{{ObjectID: stateID, Write: true}}}).MarshalBinary() + if err != nil { + t.Fatal(err) + } + transaction := tx.Transaction{StateRoot: root, Inputs: []tx.InputRef{{ObjectID: contractID}, {ObjectID: stateID}, {ObjectID: feeID}}, Operations: []tx.Operation{{Kind: tx.OpContractCall, Payload: payload}}} + transaction.Salt[0] = salt + return transaction + } + if err := validateIndependentBatch([]tx.Transaction{makeTx(stateA, feeA, 1), makeTx(stateB, feeB, 2)}); err != nil { + t.Fatalf("shared read-only contract should be parallelizable: %v", err) + } +} + +func TestBatchRejectsReadWriteContractStateConflict(t *testing.T) { + root := types.HashBytes("state", []byte("rw-conflict")) + contractID := types.ObjectID(types.HashBytes("contract-object", []byte("conflict"))) + shared := types.ObjectID(types.HashBytes("state-object", []byte("shared"))) + feeA := types.ObjectID(types.HashBytes("coin", []byte("ca"))) + feeB := types.ObjectID(types.HashBytes("coin", []byte("cb"))) + makeTx := func(write bool, feeID types.ObjectID, salt byte) tx.Transaction { + payload, err := (contracts.Call{ContractObject: contractID, Entrypoint: "run", FuelLimit: 100, Accesses: []contracts.Access{{ObjectID: shared, Write: write}}}).MarshalBinary() + if err != nil { + t.Fatal(err) + } + transaction := tx.Transaction{StateRoot: root, Inputs: []tx.InputRef{{ObjectID: contractID}, {ObjectID: shared}, {ObjectID: feeID}}, Operations: []tx.Operation{{Kind: tx.OpContractCall, Payload: payload}}} + transaction.Salt[0] = salt + return transaction + } + if err := validateIndependentBatch([]tx.Transaction{makeTx(false, feeA, 1), makeTx(true, feeB, 2)}); err != ErrBatchConflict { + t.Fatalf("expected read/write conflict, got %v", err) + } +} diff --git a/internal/v2/execution/parallel_test.go b/internal/v2/execution/parallel_test.go new file mode 100644 index 00000000..f5fea551 --- /dev/null +++ b/internal/v2/execution/parallel_test.go @@ -0,0 +1,78 @@ +package execution + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestParallelBatchAppliesIndependentTransfers(t *testing.T) { + aliceKey := makeKey(t) + carolKey := makeKey(t) + alicePub := elliptic.Marshal(elliptic.P256(), aliceKey.PublicKey.X, aliceKey.PublicKey.Y) + carolPub := elliptic.Marshal(elliptic.P256(), carolKey.PublicKey.X, carolKey.PublicKey.Y) + alice := types.AccountIDFromPublicKey(alicePub) + carol := types.AccountIDFromPublicKey(carolPub) + bob := types.AccountIDFromPublicKey([]byte("bob-parallel")) + dave := types.AccountIDFromPublicKey([]byte("dave-parallel")) + network := types.NetworkID(types.HashBytes("network", []byte("parallel"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + + store := worldstate.NewMemory() + coinAID := types.ObjectIDFromTransaction(types.HashBytes("seed", []byte("a")), 0) + coinCID := types.ObjectIDFromTransaction(types.HashBytes("seed", []byte("c")), 0) + coinAOut, _ := object.NewCoinOutput(alice, native, 100) + coinCOut, _ := object.NewCoinOutput(carol, native, 200) + coinA := object.Object{ID: coinAID, Version: 1, Owner: alice, Kind: coinAOut.Kind, Data: coinAOut.Data} + coinC := object.Object{ID: coinCID, Version: 1, Owner: carol, Kind: coinCOut.Kind, Data: coinCOut.Data} + root, err := store.Apply(nil, []object.Object{coinA, coinC}) + if err != nil { + t.Fatal(err) + } + + makeTransfer := func(key *ecdsa.PrivateKey, input object.Object, recipient types.AccountID, amount, change uint64, salt byte) tx.Transaction { + proofObject, proof, ok := store.Proof(input.ID) + if !ok { + t.Fatal("missing input proof") + } + toRecipient, _ := object.NewCoinOutput(recipient, native, amount) + toChange, _ := object.NewCoinOutput(input.Owner, native, change) + h := proofObject.Hash() + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: input.ID, Version: input.Version, ObjectHash: h}}, + Outputs: []object.OutputSpec{toRecipient, toChange}, Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Fee: 1, Witnesses: []tx.Witness{{Object: proofObject, Proof: proof}}, + } + transaction.Salt[0] = salt + if err := transaction.Sign(key); err != nil { + t.Fatal(err) + } + return transaction + } + + txA := makeTransfer(aliceKey, coinA, bob, 25, 74, 1) + txC := makeTransfer(carolKey, coinC, dave, 50, 149, 2) + newRoot, results, err := (BatchExecutor{Engine: Engine{Network: network, NativeToken: native, ShardCount: 1}, Workers: 2}).ApplyBatch(store, []tx.Transaction{txA, txC}) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 || newRoot == root { + t.Fatalf("unexpected parallel result count/root") + } +} + +func TestParallelBatchRejectsSharedInput(t *testing.T) { + root := types.HashBytes("root", []byte("same")) + id := types.ObjectID(types.HashBytes("object", []byte("shared"))) + a := tx.Transaction{StateRoot: root, Inputs: []tx.InputRef{{ObjectID: id}}} + b := tx.Transaction{StateRoot: root, Inputs: []tx.InputRef{{ObjectID: id}}} + if err := validateIndependentBatch([]tx.Transaction{a, b}); err != ErrBatchConflict { + t.Fatalf("expected batch conflict, got %v", err) + } +} diff --git a/internal/v2/execution/token_mutation.go b/internal/v2/execution/token_mutation.go new file mode 100644 index 00000000..7e2c3aa7 --- /dev/null +++ b/internal/v2/execution/token_mutation.go @@ -0,0 +1,176 @@ +package execution + +import ( + "math" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/assets" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const tokenMintOutputIndex uint32 = 0x81000000 + +func (e Engine) executeMintToken(t tx.Transaction, payload []byte) (Result, error) { + request, err := assets.ParseMintToken(payload) + if err != nil { + return Result{}, err + } + definitionObject, definition, err := tokenDefinitionWitness(t, request.DefinitionObject) + if err != nil || definition.TokenID == e.NativeToken || definition.MintAuthority != t.Sender { + return Result{}, ErrTokenPolicy + } + next, err := definition.Mint(request.Amount) + if err != nil { + return Result{}, ErrTokenPolicy + } + + created, consumed, err := e.feeOnlyOutputsExcluding(t, request.DefinitionObject, nil) + if err != nil { + return Result{}, err + } + nextRaw, err := next.MarshalBinary() + if err != nil { + return Result{}, err + } + updatedDefinition := definitionObject + updatedDefinition.Version++ + updatedDefinition.Data = nextRaw + created = append(created, updatedDefinition) + consumed = append(consumed, definitionObject.ID) + + minted, err := object.NewCoinOutputAtHeight(request.Recipient, definition.TokenID, request.Amount, e.Height) + if err != nil { + return Result{}, err + } + router := sharding.Router{ShardCount: e.ShardCount} + destination, err := router.ShardForAccount(request.Recipient) + if err != nil || destination != t.ShardID { + return Result{}, ErrTokenPolicy + } + txID := t.ID() + created = append(created, object.Object{ + ID: types.ObjectIDForShard(txID, tokenMintOutputIndex, t.ShardID), Version: 1, + Owner: minted.Owner, Kind: minted.Kind, Data: minted.Data, + }) + return Result{Consumed: consumed, Created: created, TxID: txID}, nil +} + +func (e Engine) executeBurnToken(t tx.Transaction, payload []byte) (Result, error) { + request, err := assets.ParseBurnToken(payload) + if err != nil { + return Result{}, err + } + definitionObject, definition, err := tokenDefinitionWitness(t, request.DefinitionObject) + if err != nil || definition.TokenID == e.NativeToken || !definition.Burnable { + return Result{}, ErrTokenPolicy + } + next, err := definition.Burn(request.Amount) + if err != nil { + return Result{}, ErrTokenPolicy + } + + var nativeIn, tokenIn uint64 + consumed := make([]types.ObjectID, 0, len(t.Inputs)) + for _, witness := range t.Witnesses { + if witness.Object.ID == request.DefinitionObject { + continue + } + if witness.Object.Kind != object.KindCoin || witness.Object.Owner != t.Sender { + return Result{}, ErrOwnership + } + coin, err := object.ParseCoin(witness.Object.Data) + if err != nil { + return Result{}, err + } + switch coin.Token { + case e.NativeToken: + if math.MaxUint64-nativeIn < coin.Amount { + return Result{}, ErrOverflow + } + nativeIn += coin.Amount + case definition.TokenID: + if math.MaxUint64-tokenIn < coin.Amount { + return Result{}, ErrOverflow + } + tokenIn += coin.Amount + default: + return Result{}, ErrConservation + } + consumed = append(consumed, witness.Object.ID) + } + + var nativeOut, tokenOut uint64 + created := make([]object.Object, 0, len(t.Outputs)+1) + router := sharding.Router{ShardCount: e.ShardCount} + for i, spec := range t.Outputs { + if spec.Kind != object.KindCoin { + return Result{}, ErrConservation + } + destination, err := router.ShardForAccount(spec.Owner) + if err != nil || destination != t.ShardID { + return Result{}, ErrShard + } + coin, err := object.ParseCoin(spec.Data) + if err != nil { + return Result{}, err + } + switch coin.Token { + case e.NativeToken: + if math.MaxUint64-nativeOut < coin.Amount { + return Result{}, ErrOverflow + } + nativeOut += coin.Amount + case definition.TokenID: + if spec.Owner != t.Sender || math.MaxUint64-tokenOut < coin.Amount { + return Result{}, ErrTokenPolicy + } + tokenOut += coin.Amount + default: + return Result{}, ErrConservation + } + stamped, err := stampCoinOutput(spec, e.Height) + if err != nil { + return Result{}, err + } + created = append(created, object.Object{ + ID: types.ObjectIDForShard(t.ID(), uint32(i), t.ShardID), Version: 1, + Owner: stamped.Owner, Kind: stamped.Kind, Data: append([]byte(nil), stamped.Data...), + }) + } + if math.MaxUint64-nativeOut < t.Fee || nativeIn != nativeOut+t.Fee { + return Result{}, ErrConservation + } + if math.MaxUint64-tokenOut < request.Amount || tokenIn != tokenOut+request.Amount { + return Result{}, ErrConservation + } + + nextRaw, err := next.MarshalBinary() + if err != nil { + return Result{}, err + } + updatedDefinition := definitionObject + updatedDefinition.Version++ + updatedDefinition.Data = nextRaw + created = append(created, updatedDefinition) + consumed = append(consumed, definitionObject.ID) + return Result{Consumed: consumed, Created: created, TxID: t.ID()}, nil +} + +func tokenDefinitionWitness(t tx.Transaction, id types.ObjectID) (object.Object, assets.Definition, error) { + for _, witness := range t.Witnesses { + if witness.Object.ID != id { + continue + } + if witness.Object.Kind != object.KindTokenDefinition { + return object.Object{}, assets.Definition{}, ErrTokenPolicy + } + definition, err := assets.ParseDefinition(witness.Object.Data) + if err != nil { + return object.Object{}, assets.Definition{}, err + } + return witness.Object, definition, nil + } + return object.Object{}, assets.Definition{}, ErrTokenPolicy +} diff --git a/internal/v2/execution/token_mutation_test.go b/internal/v2/execution/token_mutation_test.go new file mode 100644 index 00000000..d4fd30d4 --- /dev/null +++ b/internal/v2/execution/token_mutation_test.go @@ -0,0 +1,134 @@ +package execution + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/assets" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestCustomTokenMintAndBurnLifecycle(t *testing.T) { + aliceKey := makeKey(t) + bobKey := makeKey(t) + alice := types.AccountIDFromPublicKey(elliptic.Marshal(elliptic.P256(), aliceKey.PublicKey.X, aliceKey.PublicKey.Y)) + bob := types.AccountIDFromPublicKey(elliptic.Marshal(elliptic.P256(), bobKey.PublicKey.X, bobKey.PublicKey.Y)) + network := types.NetworkID(types.HashBytes("network", []byte("token-mutation"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + custom := types.TokenID(types.HashBytes("token", []byte("CUSTOM"))) + + definition := assets.Definition{ + TokenID: custom, Name: "Custom", Symbol: "CUS", Decimals: 6, + SupplyPolicy: assets.SupplyCapped, MaxSupply: 1_000, CurrentSupply: 500, + MintAuthority: alice, Burnable: true, Transferable: true, + } + definitionRaw, err := definition.MarshalBinary() + if err != nil { + t.Fatal(err) + } + seed := types.HashBytes("token-mutation", []byte("objects")) + definitionID := types.ObjectIDForShard(seed, 1, 0) + aliceFeeID := types.ObjectIDForShard(seed, 2, 0) + bobFeeID := types.ObjectIDForShard(seed, 3, 0) + initialCoinID := types.ObjectIDForShard(seed, 4, 0) + aliceFee, _ := object.NewCoinOutput(alice, native, 10) + bobFee, _ := object.NewCoinOutput(bob, native, 10) + initialCoin, _ := object.NewCoinOutput(alice, custom, 500) + store := worldstate.NewMemory() + _, err = store.Apply(nil, []object.Object{ + {ID: definitionID, Version: 1, Owner: alice, Kind: object.KindTokenDefinition, Data: definitionRaw}, + {ID: aliceFeeID, Version: 1, Owner: alice, Kind: object.KindCoin, Data: aliceFee.Data}, + {ID: bobFeeID, Version: 1, Owner: bob, Kind: object.KindCoin, Data: bobFee.Data}, + {ID: initialCoinID, Version: 1, Owner: alice, Kind: object.KindCoin, Data: initialCoin.Data}, + }) + if err != nil { + t.Fatal(err) + } + + mintPayload, _ := (assets.MintToken{DefinitionObject: definitionID, Recipient: bob, Amount: 100}).MarshalBinary() + aliceChange, _ := object.NewCoinOutput(alice, native, 9) + mint := transactionWithProofs(t, store, aliceKey, network, []types.ObjectID{definitionID, aliceFeeID}, []object.OutputSpec{aliceChange}, tx.Operation{Kind: tx.OpMintToken, Payload: mintPayload}, 1) + engine := Engine{Network: network, NativeToken: native, ShardCount: 1} + mintResult, err := engine.Execute(mint) + if err != nil { + t.Fatal(err) + } + if _, err := store.Apply(mintResult.Consumed, mintResult.Created); err != nil { + t.Fatal(err) + } + updatedDefinition, ok := store.GetObject(definitionID) + if !ok { + t.Fatal("mint removed token definition") + } + mintedDefinition, err := assets.ParseDefinition(updatedDefinition.Data) + if err != nil || mintedDefinition.CurrentSupply != 600 || updatedDefinition.Version != 2 { + t.Fatalf("unexpected definition after mint: %#v %v", mintedDefinition, err) + } + var bobMinted object.Object + for _, created := range mintResult.Created { + if created.Kind != object.KindCoin || created.Owner != bob { + continue + } + coin, parseErr := object.ParseCoin(created.Data) + if parseErr == nil && coin.Token == custom && coin.Amount == 100 { + bobMinted = created + break + } + } + if types.IsZero32([32]byte(bobMinted.ID)) { + t.Fatal("minted recipient coin missing") + } + + burnPayload, _ := (assets.BurnToken{DefinitionObject: definitionID, Amount: 40}).MarshalBinary() + bobNativeChange, _ := object.NewCoinOutput(bob, native, 9) + bobTokenChange, _ := object.NewCoinOutput(bob, custom, 60) + burn := transactionWithProofs(t, store, bobKey, network, []types.ObjectID{definitionID, bobFeeID, bobMinted.ID}, []object.OutputSpec{bobNativeChange, bobTokenChange}, tx.Operation{Kind: tx.OpBurnToken, Payload: burnPayload}, 1) + burnResult, err := engine.Execute(burn) + if err != nil { + t.Fatal(err) + } + if _, err := store.Apply(burnResult.Consumed, burnResult.Created); err != nil { + t.Fatal(err) + } + updatedDefinition, ok = store.GetObject(definitionID) + if !ok { + t.Fatal("burn removed token definition") + } + burnedDefinition, err := assets.ParseDefinition(updatedDefinition.Data) + if err != nil || burnedDefinition.CurrentSupply != 560 || updatedDefinition.Version != 3 { + t.Fatalf("unexpected definition after burn: %#v %v", burnedDefinition, err) + } +} + +func TestFixedTokenCannotMint(t *testing.T) { + definition := assets.Definition{SupplyPolicy: assets.SupplyFixed, MaxSupply: 100, CurrentSupply: 100} + definition.TokenID[0] = 1 + definition.MintAuthority[0] = 2 + definition.Name = "Fixed" + definition.Symbol = "FIX" + if _, err := definition.Mint(1); err == nil { + t.Fatal("fixed token unexpectedly minted") + } +} + +func transactionWithProofs(t *testing.T, store worldstate.Backend, key *ecdsa.PrivateKey, network types.NetworkID, ids []types.ObjectID, outputs []object.OutputSpec, operation tx.Operation, fee uint64) tx.Transaction { + t.Helper() + transaction := tx.Transaction{Version: tx.Version, Network: network, ShardID: 0, StateRoot: store.Root(), Outputs: outputs, Operations: []tx.Operation{operation}, Fee: fee} + for _, id := range ids { + obj, proof, present := store.Proof(id) + if !present { + t.Fatalf("missing proof object %s", id.String()) + } + transaction.Inputs = append(transaction.Inputs, tx.InputRef{ObjectID: id, Version: obj.Version, ObjectHash: obj.Hash()}) + transaction.Witnesses = append(transaction.Witnesses, tx.Witness{Object: obj, Proof: proof}) + } + transaction.Salt[0] = byte(len(ids) + int(operation.Kind)) + if err := transaction.Sign(key); err != nil { + t.Fatal(err) + } + return transaction +} diff --git a/internal/v2/execution/token_policy_parallel_test.go b/internal/v2/execution/token_policy_parallel_test.go new file mode 100644 index 00000000..d132530d --- /dev/null +++ b/internal/v2/execution/token_policy_parallel_test.go @@ -0,0 +1,62 @@ +package execution + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestTokenDefinitionPolicyWitnessIsSharedReadForTransfers(t *testing.T) { + var root types.Hash + var definitionID, coinA, coinB types.ObjectID + root[0] = 1 + definitionID[0] = 2 + coinA[0] = 3 + coinB[0] = 4 + definition := object.Object{ID: definitionID, Version: 1, Kind: object.KindTokenDefinition} + first := tx.Transaction{ + StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: coinA}, {ObjectID: definitionID}}, + Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Witnesses: []tx.Witness{{Object: object.Object{ID: coinA, Kind: object.KindCoin}}, {Object: definition}}, + } + first.Salt[0] = 1 + second := tx.Transaction{ + StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: coinB}, {ObjectID: definitionID}}, + Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Witnesses: []tx.Witness{{Object: object.Object{ID: coinB, Kind: object.KindCoin}}, {Object: definition}}, + } + second.Salt[0] = 2 + if err := validateIndependentBatch([]tx.Transaction{first, second}); err != nil { + t.Fatalf("shared read-only token definition should not serialize transfers: %v", err) + } +} + +func TestTokenDefinitionWriteConflictsWithTransferRead(t *testing.T) { + var root types.Hash + var definitionID, coinA types.ObjectID + root[0] = 1 + definitionID[0] = 2 + coinA[0] = 3 + definition := object.Object{ID: definitionID, Version: 1, Kind: object.KindTokenDefinition} + transfer := tx.Transaction{ + StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: coinA}, {ObjectID: definitionID}}, + Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Witnesses: []tx.Witness{{Object: object.Object{ID: coinA, Kind: object.KindCoin}}, {Object: definition}}, + } + transfer.Salt[0] = 1 + mint := tx.Transaction{ + StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: definitionID}}, + Operations: []tx.Operation{{Kind: tx.OpMintToken}}, + Witnesses: []tx.Witness{{Object: definition}}, + } + mint.Salt[0] = 2 + if err := validateIndependentBatch([]tx.Transaction{transfer, mint}); err != ErrBatchConflict { + t.Fatalf("token definition write must conflict with transfer read, got %v", err) + } +} diff --git a/internal/v2/execution/token_transfer_policy_test.go b/internal/v2/execution/token_transfer_policy_test.go new file mode 100644 index 00000000..aef27043 --- /dev/null +++ b/internal/v2/execution/token_transfer_policy_test.go @@ -0,0 +1,130 @@ +package execution + +import ( + "crypto/elliptic" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/assets" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestCustomTokenTransferRequiresTransferableDefinition(t *testing.T) { + key := makeKey(t) + sender := types.AccountIDFromPublicKey(elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y)) + recipient := sender + recipient[31] ^= 1 + network := types.NetworkID(types.HashBytes("network", []byte("token-policy"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + custom := types.TokenID(types.HashBytes("token", []byte("POLICY"))) + + makeTransaction := func(transferable bool) (Engine, tx.Transaction) { + definition := assets.Definition{ + TokenID: custom, Name: "Policy", Symbol: "PLC", SupplyPolicy: assets.SupplyFixed, + MaxSupply: 100, CurrentSupply: 100, MintAuthority: sender, Burnable: true, Transferable: transferable, + } + definitionRaw, err := definition.MarshalBinary() + if err != nil { + t.Fatal(err) + } + seed := types.HashBytes("token-policy", []byte{byte(boolByte(transferable))}) + definitionID := types.ObjectIDForShard(seed, 1, 0) + customID := types.ObjectIDForShard(seed, 2, 0) + feeID := types.ObjectIDForShard(seed, 3, 0) + customOut, _ := object.NewCoinOutput(sender, custom, 100) + feeOut, _ := object.NewCoinOutput(sender, native, 10) + store := worldstate.NewMemory() + _, err = store.Apply(nil, []object.Object{ + {ID: definitionID, Version: 1, Owner: sender, Kind: object.KindTokenDefinition, Data: definitionRaw}, + {ID: customID, Version: 1, Owner: sender, Kind: object.KindCoin, Data: customOut.Data}, + {ID: feeID, Version: 1, Owner: sender, Kind: object.KindCoin, Data: feeOut.Data}, + }) + if err != nil { + t.Fatal(err) + } + toRecipient, _ := object.NewCoinOutput(recipient, custom, 100) + feeChange, _ := object.NewCoinOutput(sender, native, 9) + transaction := transactionWithProofs(t, store, key, network, []types.ObjectID{customID, definitionID, feeID}, []object.OutputSpec{toRecipient, feeChange}, tx.Operation{Kind: tx.OpTransfer}, 1) + return Engine{Network: network, NativeToken: native, ShardCount: 1}, transaction + } + + engine, blocked := makeTransaction(false) + if _, err := engine.Execute(blocked); err != ErrTokenPolicy { + t.Fatalf("non-transferable token should be rejected, got %v", err) + } + engine, allowed := makeTransaction(true) + result, err := engine.Execute(allowed) + if err != nil { + t.Fatal(err) + } + if len(result.Consumed) != 2 { + t.Fatalf("definition witness must remain read-only; consumed=%d", len(result.Consumed)) + } +} + +func TestCustomTokenCrossShardTransferRemainsActivationGated(t *testing.T) { + var keyAccount types.AccountID + key := makeKey(t) + for attempts := 0; attempts < 64; attempts++ { + keyAccount = types.AccountIDFromPublicKey(elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y)) + if types.AccountShard(keyAccount, 2) == 0 { + break + } + key = makeKey(t) + } + if types.AccountShard(keyAccount, 2) != 0 { + t.Fatal("failed to generate shard-0 sender") + } + + var recipient types.AccountID + for attempts := 0; attempts < 64; attempts++ { + recipientKey := makeKey(t) + recipient = types.AccountIDFromPublicKey(elliptic.Marshal(elliptic.P256(), recipientKey.PublicKey.X, recipientKey.PublicKey.Y)) + if types.AccountShard(recipient, 2) == 1 { + break + } + } + if types.AccountShard(recipient, 2) != 1 { + t.Fatal("failed to generate shard-1 recipient") + } + + network := types.NetworkID(types.HashBytes("network", []byte("token-cross-shard"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + custom := types.TokenID(types.HashBytes("token", []byte("CUSTOM-X"))) + definition := assets.Definition{ + TokenID: custom, Name: "Cross", Symbol: "CRS", SupplyPolicy: assets.SupplyFixed, + MaxSupply: 100, CurrentSupply: 100, MintAuthority: keyAccount, Transferable: true, + } + definitionRaw, _ := definition.MarshalBinary() + seed := types.HashBytes("token-cross-shard", []byte("objects")) + definitionID := types.ObjectIDForShard(seed, 1, 0) + customID := types.ObjectIDForShard(seed, 2, 0) + feeID := types.ObjectIDForShard(seed, 3, 0) + customOut, _ := object.NewCoinOutput(keyAccount, custom, 100) + feeOut, _ := object.NewCoinOutput(keyAccount, native, 10) + store := worldstate.NewMemory() + _, err := store.Apply(nil, []object.Object{ + {ID: definitionID, Version: 1, Owner: keyAccount, Kind: object.KindTokenDefinition, Data: definitionRaw}, + {ID: customID, Version: 1, Owner: keyAccount, Kind: object.KindCoin, Data: customOut.Data}, + {ID: feeID, Version: 1, Owner: keyAccount, Kind: object.KindCoin, Data: feeOut.Data}, + }) + if err != nil { + t.Fatal(err) + } + remote, _ := object.NewCoinOutput(recipient, custom, 100) + feeChange, _ := object.NewCoinOutput(keyAccount, native, 9) + transaction := transactionWithProofs(t, store, key, network, []types.ObjectID{customID, definitionID, feeID}, []object.OutputSpec{remote, feeChange}, tx.Operation{Kind: tx.OpTransfer}, 1) + _, err = (Engine{Network: network, NativeToken: native, ShardCount: 2}).Execute(transaction) + if err != ErrTokenPolicy { + t.Fatalf("custom token cross-shard transfer must remain gated, got %v", err) + } +} + +func boolByte(value bool) byte { + if value { + return 1 + } + return 0 +} diff --git a/internal/v2/genesis/genesis.go b/internal/v2/genesis/genesis.go new file mode 100644 index 00000000..dac64f2c --- /dev/null +++ b/internal/v2/genesis/genesis.go @@ -0,0 +1,149 @@ +package genesis + +import ( + "bytes" + "errors" + "math" + "sort" + "strings" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ProtocolVersion uint16 = 2 + +var ( + ErrProtocolVersion = errors.New("genesis protocol version must be 2") + ErrChainName = errors.New("genesis chain name is required") + ErrShardConfig = errors.New("invalid genesis shard configuration") + ErrValidator = errors.New("invalid genesis validator") + ErrAllocation = errors.New("invalid genesis allocation") + ErrVotingPower = errors.New("genesis voting power overflow") +) + +type Validator struct { + ID types.ValidatorID + ConsensusPublicKey []byte + VotingPower uint64 +} + +type Allocation struct { + Owner types.AccountID + Amount uint64 +} + +type Config struct { + Version uint16 + ChainName string + GenesisUnix uint64 + InitialShardCount uint32 + MaxShardCount uint32 + NativeSymbol string + Validators []Validator + Allocations []Allocation +} + +func (g Config) Validate() error { + if g.Version != ProtocolVersion { + return ErrProtocolVersion + } + if strings.TrimSpace(g.ChainName) == "" { + return ErrChainName + } + if g.InitialShardCount == 0 || g.MaxShardCount < g.InitialShardCount { + return ErrShardConfig + } + symbol := strings.TrimSpace(g.NativeSymbol) + if symbol == "" || len(symbol) > 16 { + return ErrAllocation + } + + seenValidators := map[types.ValidatorID]struct{}{} + var total uint64 + for _, v := range g.Validators { + if types.IsZero32([32]byte(v.ID)) || len(v.ConsensusPublicKey) == 0 || v.VotingPower == 0 { + return ErrValidator + } + if _, ok := seenValidators[v.ID]; ok { + return ErrValidator + } + seenValidators[v.ID] = struct{}{} + if math.MaxUint64-total < v.VotingPower { + return ErrVotingPower + } + total += v.VotingPower + } + if len(g.Validators) == 0 || total == 0 { + return ErrValidator + } + + seenAllocations := map[types.AccountID]struct{}{} + for _, a := range g.Allocations { + if types.IsZero32([32]byte(a.Owner)) || a.Amount == 0 { + return ErrAllocation + } + if _, ok := seenAllocations[a.Owner]; ok { + return ErrAllocation + } + seenAllocations[a.Owner] = struct{}{} + } + return nil +} + +func (g Config) CanonicalBytes() ([]byte, error) { + if err := g.Validate(); err != nil { + return nil, err + } + + validators := append([]Validator(nil), g.Validators...) + sort.Slice(validators, func(i, j int) bool { + return bytes.Compare(validators[i].ID[:], validators[j].ID[:]) < 0 + }) + allocations := append([]Allocation(nil), g.Allocations...) + sort.Slice(allocations, func(i, j int) bool { + return bytes.Compare(allocations[i].Owner[:], allocations[j].Owner[:]) < 0 + }) + + var w codec.Writer + w.U16(g.Version) + w.String(strings.TrimSpace(g.ChainName)) + w.U64(g.GenesisUnix) + w.U32(g.InitialShardCount) + w.U32(g.MaxShardCount) + w.String(strings.TrimSpace(g.NativeSymbol)) + w.U32(uint32(len(validators))) + for _, v := range validators { + w.Fixed(v.ID[:]) + w.Bytes(v.ConsensusPublicKey) + w.U64(v.VotingPower) + } + w.U32(uint32(len(allocations))) + for _, a := range allocations { + w.Fixed(a.Owner[:]) + w.U64(a.Amount) + } + return w.BytesCopy(), nil +} + +func (g Config) NetworkID() (types.NetworkID, error) { + payload, err := g.CanonicalBytes() + if err != nil { + return types.NetworkID{}, err + } + return types.NetworkID(codec.DomainHash("zephyr/genesis/v2", payload)), nil +} + +func (g Config) TotalVotingPower() (uint64, error) { + if err := g.Validate(); err != nil { + return 0, err + } + var total uint64 + for _, v := range g.Validators { + if math.MaxUint64-total < v.VotingPower { + return 0, ErrVotingPower + } + total += v.VotingPower + } + return total, nil +} diff --git a/internal/v2/genesis/genesis_test.go b/internal/v2/genesis/genesis_test.go new file mode 100644 index 00000000..748e6eb2 --- /dev/null +++ b/internal/v2/genesis/genesis_test.go @@ -0,0 +1,39 @@ +package genesis + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestNetworkIDIsCanonicalAcrossInputOrdering(t *testing.T) { + v1 := types.ValidatorIDFromPublicKey([]byte("validator-1")) + v2 := types.ValidatorIDFromPublicKey([]byte("validator-2")) + a1 := types.AccountIDFromPublicKey([]byte("account-1")) + a2 := types.AccountIDFromPublicKey([]byte("account-2")) + + base := Config{ + Version: ProtocolVersion, ChainName: "zephyr-test", GenesisUnix: 1, + InitialShardCount: 1, MaxShardCount: 16, NativeSymbol: "ZPH", + Validators: []Validator{ + {ID: v1, ConsensusPublicKey: []byte("validator-1"), VotingPower: 10}, + {ID: v2, ConsensusPublicKey: []byte("validator-2"), VotingPower: 20}, + }, + Allocations: []Allocation{{Owner: a1, Amount: 10}, {Owner: a2, Amount: 20}}, + } + reordered := base + reordered.Validators = []Validator{base.Validators[1], base.Validators[0]} + reordered.Allocations = []Allocation{base.Allocations[1], base.Allocations[0]} + + id1, err := base.NetworkID() + if err != nil { + t.Fatal(err) + } + id2, err := reordered.NetworkID() + if err != nil { + t.Fatal(err) + } + if id1 != id2 { + t.Fatalf("network id changed with ordering: %s != %s", id1, id2) + } +} diff --git a/internal/v2/genesis/json.go b/internal/v2/genesis/json.go new file mode 100644 index 00000000..2681afa4 --- /dev/null +++ b/internal/v2/genesis/json.go @@ -0,0 +1,105 @@ +package genesis + +import ( + "crypto/elliptic" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrGenesisJSON = errors.New("invalid Zephyr v2 genesis JSON") + +type jsonValidator struct { + ConsensusPublicKey string `json:"consensusPublicKey"` + VotingPower string `json:"votingPower"` +} + +type jsonAllocation struct { + Owner string `json:"owner"` + Amount string `json:"amount"` +} + +type jsonConfig struct { + Version uint16 `json:"version"` + ChainName string `json:"chainName"` + GenesisUnix uint64 `json:"genesisUnix"` + InitialShardCount uint32 `json:"initialShardCount"` + MaxShardCount uint32 `json:"maxShardCount"` + NativeSymbol string `json:"nativeSymbol"` + Validators []jsonValidator `json:"validators"` + Allocations []jsonAllocation `json:"allocations"` +} + +func LoadJSON(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + return ParseJSON(data) +} + +func ParseJSON(data []byte) (Config, error) { + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + var raw jsonConfig + if err := decoder.Decode(&raw); err != nil { + return Config{}, fmt.Errorf("%w: %v", ErrGenesisJSON, err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Config{}, ErrGenesisJSON + } + cfg := Config{ + Version: raw.Version, ChainName: raw.ChainName, GenesisUnix: raw.GenesisUnix, + InitialShardCount: raw.InitialShardCount, MaxShardCount: raw.MaxShardCount, NativeSymbol: raw.NativeSymbol, + Validators: make([]Validator, len(raw.Validators)), Allocations: make([]Allocation, len(raw.Allocations)), + } + for i, value := range raw.Validators { + pub, err := hex.DecodeString(strings.TrimSpace(value.ConsensusPublicKey)) + if err != nil || len(pub) != 65 { + return Config{}, ErrGenesisJSON + } + x, y := elliptic.Unmarshal(elliptic.P256(), pub) + if x == nil || y == nil { + return Config{}, ErrGenesisJSON + } + power, err := strconv.ParseUint(value.VotingPower, 10, 64) + if err != nil || power == 0 { + return Config{}, ErrGenesisJSON + } + cfg.Validators[i] = Validator{ID: types.ValidatorIDFromPublicKey(pub), ConsensusPublicKey: pub, VotingPower: power} + } + for i, value := range raw.Allocations { + ownerRaw, err := hex.DecodeString(strings.TrimSpace(value.Owner)) + if err != nil || len(ownerRaw) != 32 { + return Config{}, ErrGenesisJSON + } + var owner types.AccountID + copy(owner[:], ownerRaw) + amount, err := strconv.ParseUint(value.Amount, 10, 64) + if err != nil || amount == 0 { + return Config{}, ErrGenesisJSON + } + cfg.Allocations[i] = Allocation{Owner: owner, Amount: amount} + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func (g Config) NativeTokenID() (types.TokenID, error) { + network, err := g.NetworkID() + if err != nil { + return types.TokenID{}, err + } + payload := append(append([]byte(nil), network[:]...), []byte(strings.TrimSpace(g.NativeSymbol))...) + return types.TokenID(types.HashBytes("zephyr/native-token/v2", payload)), nil +} diff --git a/internal/v2/genesis/state.go b/internal/v2/genesis/state.go new file mode 100644 index 00000000..4b035286 --- /dev/null +++ b/internal/v2/genesis/state.go @@ -0,0 +1,81 @@ +package genesis + +import ( + "encoding/binary" + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +var ErrGenesisState = errors.New("invalid or partially initialized Zephyr v2 genesis state") + +func SeedStates(g Config, states map[uint32]worldstate.Backend) error { + if err := g.Validate(); err != nil || len(states) != int(g.InitialShardCount) { + return ErrGenesisState + } + network, err := g.NetworkID() + if err != nil { + return err + } + native, err := g.NativeTokenID() + if err != nil { + return err + } + canonical, err := g.CanonicalBytes() + if err != nil { + return err + } + genesisHash := types.Hash(codec.DomainHash("zephyr/genesis/state/v2", canonical)) + emptyRoot := worldstate.NewMemory().Root() + + for shard := uint32(0); shard < g.InitialShardCount; shard++ { + store, ok := states[shard] + if !ok || store == nil { + return ErrGenesisState + } + markerID := GenesisMarkerID(network, shard) + if marker, exists := store.GetObject(markerID); exists { + if marker.Kind != object.KindSystem || len(marker.Data) != 36 || string(marker.Data[:32]) != string(genesisHash[:]) || binary.BigEndian.Uint32(marker.Data[32:]) != shard { + return ErrGenesisState + } + continue + } + if store.Root() != emptyRoot { + return ErrGenesisState + } + created := make([]object.Object, 0) + for _, allocation := range g.Allocations { + if types.AccountShard(allocation.Owner, g.InitialShardCount) != shard { + continue + } + coin, err := object.NewCoinOutput(allocation.Owner, native, allocation.Amount) + if err != nil { + return err + } + created = append(created, object.Object{ID: GenesisAllocationID(network, allocation.Owner, shard), Version: 1, Owner: allocation.Owner, Kind: coin.Kind, Data: coin.Data}) + } + markerData := make([]byte, 36) + copy(markerData[:32], genesisHash[:]) + binary.BigEndian.PutUint32(markerData[32:], shard) + created = append(created, object.Object{ID: markerID, Version: 1, Kind: object.KindSystem, Data: markerData}) + if _, err := store.Apply(nil, created); err != nil { + return err + } + } + return nil +} + +func GenesisMarkerID(network types.NetworkID, shard uint32) types.ObjectID { + return types.ObjectIDForShard(types.Hash(network), 0xfffffff0, shard) +} + +func GenesisAllocationID(network types.NetworkID, owner types.AccountID, shard uint32) types.ObjectID { + var w codec.Writer + w.Fixed(network[:]) + w.Fixed(owner[:]) + seed := types.Hash(codec.DomainHash("zephyr/genesis/allocation/v2", w.BytesCopy())) + return types.ObjectIDForShard(seed, 0, shard) +} diff --git a/internal/v2/genesis/trust.go b/internal/v2/genesis/trust.go new file mode 100644 index 00000000..1e48b23f --- /dev/null +++ b/internal/v2/genesis/trust.go @@ -0,0 +1,44 @@ +package genesis + +import ( + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +type TrustAnchor struct { + Network types.NetworkID + ValidatorRoot types.Hash +} + +func (g Config) ValidatorSet() (v2consensus.ValidatorSet, error) { + network, err := g.NetworkID() + if err != nil { + return v2consensus.ValidatorSet{}, err + } + set := v2consensus.ValidatorSet{Network: network, Validators: make([]v2consensus.Validator, len(g.Validators))} + for i, validator := range g.Validators { + set.Validators[i] = v2consensus.Validator{ + ID: validator.ID, + PublicKey: append([]byte(nil), validator.ConsensusPublicKey...), + Power: validator.VotingPower, + } + } + if err := set.Validate(); err != nil { + return v2consensus.ValidatorSet{}, ErrValidator + } + return set, nil +} + +// TrustAnchor derives the two values a Citizen wallet must embed or obtain from +// a trusted checkpoint before it accepts self-verifiable light data. +func (g Config) TrustAnchor() (TrustAnchor, error) { + set, err := g.ValidatorSet() + if err != nil { + return TrustAnchor{}, err + } + root, err := set.Root() + if err != nil { + return TrustAnchor{}, ErrValidator + } + return TrustAnchor{Network: set.Network, ValidatorRoot: root}, nil +} diff --git a/internal/v2/genesis/trust_test.go b/internal/v2/genesis/trust_test.go new file mode 100644 index 00000000..0244082d --- /dev/null +++ b/internal/v2/genesis/trust_test.go @@ -0,0 +1,57 @@ +package genesis + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestTrustAnchorDerivesNetworkAndValidatorRootFromGenesis(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + validatorID := types.ValidatorIDFromPublicKey(pub) + owner := types.AccountIDFromPublicKey([]byte("genesis-owner")) + config := Config{ + Version: ProtocolVersion, ChainName: "zephyr-trust-test", GenesisUnix: 1, + InitialShardCount: 1, MaxShardCount: 16, NativeSymbol: "ZPH", + Validators: []Validator{{ID: validatorID, ConsensusPublicKey: pub, VotingPower: 10}}, + Allocations: []Allocation{{Owner: owner, Amount: 100}}, + } + anchor, err := config.TrustAnchor() + if err != nil { + t.Fatal(err) + } + network, err := config.NetworkID() + if err != nil { + t.Fatal(err) + } + set, err := config.ValidatorSet() + if err != nil { + t.Fatal(err) + } + root, err := set.Root() + if err != nil { + t.Fatal(err) + } + if anchor.Network != network || anchor.ValidatorRoot != root || types.IsZero32([32]byte(anchor.ValidatorRoot)) { + t.Fatalf("unexpected trust anchor: %+v", anchor) + } +} + +func TestTrustAnchorRejectsNonP256GenesisValidator(t *testing.T) { + pub := []byte("not-a-p256-public-key") + config := Config{ + Version: ProtocolVersion, ChainName: "zephyr-invalid-trust", GenesisUnix: 1, + InitialShardCount: 1, MaxShardCount: 1, NativeSymbol: "ZPH", + Validators: []Validator{{ID: types.ValidatorIDFromPublicKey(pub), ConsensusPublicKey: pub, VotingPower: 1}}, + } + if _, err := config.TrustAnchor(); err != ErrValidator { + t.Fatalf("expected invalid genesis trust anchor, got %v", err) + } +} diff --git a/internal/v2/lab/da_fault_test.go b/internal/v2/lab/da_fault_test.go new file mode 100644 index 00000000..886695e8 --- /dev/null +++ b/internal/v2/lab/da_fault_test.go @@ -0,0 +1,59 @@ +package lab + +import ( + "bytes" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/da" +) + +func TestV2LabDataAvailabilityWithholdingGate(t *testing.T) { + payload := bytes.Repeat([]byte("zephyr-v2-da-fault/"), 4096) + commitment, chunks, samples, err := da.EncodeBlob(payload, 8, 4) + if err != nil { + t.Fatal(err) + } + // Losing up to parity capacity must not lose the block payload. + withinTolerance := cloneDAChunks(chunks) + withinTolerance[0] = nil + withinTolerance[5] = nil + withinTolerance[11] = nil + recovered, err := da.ReconstructBlob(commitment, withinTolerance, samples) + if err != nil || !bytes.Equal(recovered, payload) { + t.Fatalf("DA recovery within parity budget failed: %v", err) + } + + // Withholding more than parity capacity must fail closed. + withheld := cloneDAChunks(chunks) + for _, index := range []int{0, 1, 2, 3, 4} { + withheld[index] = nil + } + if _, err := da.ReconstructBlob(commitment, withheld, samples); err != da.ErrReconstruction { + t.Fatalf("expected withholding failure, got %v", err) + } +} + +func TestV2LabDataAvailabilityCorruptionIsTreatedAsMissing(t *testing.T) { + payload := bytes.Repeat([]byte("authenticated-da"), 2048) + commitment, chunks, samples, err := da.EncodeBlob(payload, 6, 3) + if err != nil { + t.Fatal(err) + } + corrupt := cloneDAChunks(chunks) + corrupt[2][0] ^= 0xff + corrupt[8][len(corrupt[8])-1] ^= 0x01 + recovered, err := da.ReconstructBlob(commitment, corrupt, samples) + if err != nil || !bytes.Equal(recovered, payload) { + t.Fatalf("authenticated corruption recovery failed: %v", err) + } +} + +func cloneDAChunks(in [][]byte) [][]byte { + out := make([][]byte, len(in)) + for i, chunk := range in { + if chunk != nil { + out[i] = append([]byte(nil), chunk...) + } + } + return out +} diff --git a/internal/v2/lab/lab_test.go b/internal/v2/lab/lab_test.go new file mode 100644 index 00000000..ab7b8562 --- /dev/null +++ b/internal/v2/lab/lab_test.go @@ -0,0 +1,226 @@ +package lab + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/node" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +type cluster struct { + network types.NetworkID + native types.TokenID + validators v2consensus.ValidatorSet + keys map[types.ValidatorID]*ecdsa.PrivateKey + runtimes []*node.Runtime + candidates []node.Candidate +} + +func TestV2LabSevenValidatorsCertifiedHappyPath(t *testing.T) { + c := newCluster(t, 7) + proposal := c.proposal(t) + votes := c.votes(t, proposal, []int{0, 1, 2, 3, 4}) + certificate, err := c.validators.BuildCertificate(proposal, votes) + if err != nil { + t.Fatal(err) + } + for i := range c.runtimes { + if _, err := c.runtimes[i].Commit(c.candidates[i], certificate, c.validators); err != nil { + t.Fatalf("validator %d failed certified commit: %v", i, err) + } + if c.runtimes[i].Height != 1 { + t.Fatalf("validator %d did not finalize height 1", i) + } + } +} + +func TestV2LabFourThreePartitionStallsThenHeals(t *testing.T) { + c := newCluster(t, 7) + proposal := c.proposal(t) + left := c.votes(t, proposal, []int{0, 1, 2, 3}) + right := c.votes(t, proposal, []int{4, 5, 6}) + if _, err := c.validators.BuildCertificate(proposal, left); !errors.Is(err, v2consensus.ErrInsufficientPower) { + t.Fatalf("4/3 left side unexpectedly reached quorum: %v", err) + } + if _, err := c.validators.BuildCertificate(proposal, right); !errors.Is(err, v2consensus.ErrInsufficientPower) { + t.Fatalf("4/3 right side unexpectedly reached quorum: %v", err) + } + for i, runtime := range c.runtimes { + if runtime.Height != 0 { + t.Fatalf("validator %d committed while partitioned", i) + } + } + + healed := append(append([]v2consensus.Vote{}, left...), right[0]) + certificate, err := c.validators.BuildCertificate(proposal, healed) + if err != nil { + t.Fatal(err) + } + for i := range c.runtimes { + if _, err := c.runtimes[i].Commit(c.candidates[i], certificate, c.validators); err != nil { + t.Fatalf("validator %d failed after partition heal: %v", i, err) + } + } +} + +func TestV2LabFiveTwoPartitionFinalizesQuorumSideAndMinorityCatchesUp(t *testing.T) { + c := newCluster(t, 7) + proposal := c.proposal(t) + quorumVotes := c.votes(t, proposal, []int{0, 1, 2, 3, 4}) + minorityVotes := c.votes(t, proposal, []int{5, 6}) + if _, err := c.validators.BuildCertificate(proposal, minorityVotes); !errors.Is(err, v2consensus.ErrInsufficientPower) { + t.Fatalf("2-validator minority unexpectedly reached quorum: %v", err) + } + certificate, err := c.validators.BuildCertificate(proposal, quorumVotes) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 5; i++ { + if _, err := c.runtimes[i].Commit(c.candidates[i], certificate, c.validators); err != nil { + t.Fatalf("quorum validator %d failed commit: %v", i, err) + } + } + if c.runtimes[5].Height != 0 || c.runtimes[6].Height != 0 { + t.Fatal("minority committed before receiving certificate") + } + for i := 5; i < 7; i++ { + if _, err := c.runtimes[i].Commit(c.candidates[i], certificate, c.validators); err != nil { + t.Fatalf("minority validator %d failed certificate catch-up: %v", i, err) + } + } +} + +func TestV2LabConflictingProposalIsNotVoted(t *testing.T) { + c := newCluster(t, 7) + proposer, err := c.validators.Proposer(1, 0) + if err != nil { + t.Fatal(err) + } + conflicting := c.candidates[0].Header + conflicting.DataRoot = types.HashBytes("conflicting-data-root", []byte("evil")) + proposal, err := v2consensus.SignProposal(c.keys[proposer.ID], conflicting, 0) + if err != nil { + t.Fatal(err) + } + for i := range c.candidates { + if err := node.VerifyProposalAgainstCandidate(c.candidates[i], proposal, c.validators); !errors.Is(err, node.ErrCandidateState) { + t.Fatalf("validator %d accepted conflicting proposal: %v", i, err) + } + } +} + +func newCluster(t *testing.T, validatorCount int) *cluster { + t.Helper() + network := types.NetworkID(types.HashBytes("network", []byte("v2-lab"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + validators := v2consensus.ValidatorSet{Network: network} + keys := make(map[types.ValidatorID]*ecdsa.PrivateKey, validatorCount) + for i := 0; i < validatorCount; i++ { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + validators.Validators = append(validators.Validators, v2consensus.Validator{ID: id, PublicKey: pub, Power: 10_000}) + keys[id] = key + } + validatorRoot, err := validators.Root() + if err != nil { + t.Fatal(err) + } + + aliceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + alicePub := elliptic.Marshal(elliptic.P256(), aliceKey.PublicKey.X, aliceKey.PublicKey.Y) + alice := types.AccountIDFromPublicKey(alicePub) + bob := types.AccountIDFromPublicKey([]byte("v2-lab-bob")) + inputID := types.ObjectIDFromTransaction(types.HashBytes("v2-lab", []byte("input")), 0) + inputOut, _ := object.NewCoinOutput(alice, native, 100) + input := object.Object{ID: inputID, Version: 1, Owner: alice, Kind: inputOut.Kind, Data: inputOut.Data} + + stores := make([]*worldstate.Memory, validatorCount) + for i := range stores { + stores[i] = worldstate.NewMemory() + if _, err := stores[i].Apply(nil, []object.Object{input}); err != nil { + t.Fatal(err) + } + } + root := stores[0].Root() + witness, proof, ok := stores[0].Proof(inputID) + if !ok { + t.Fatal("missing lab witness") + } + witnessHash := witness.Hash() + payment, _ := object.NewCoinOutput(bob, native, 25) + change, _ := object.NewCoinOutput(alice, native, 74) + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: inputID, Version: 1, ObjectHash: witnessHash}}, + Outputs: []object.OutputSpec{payment, change}, Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Fee: 1, Witnesses: []tx.Witness{{Object: witness, Proof: proof}}, + } + transaction.Salt[0] = 1 + if err := transaction.Sign(aliceKey); err != nil { + t.Fatal(err) + } + + runtimes := make([]*node.Runtime, validatorCount) + candidates := make([]node.Candidate, validatorCount) + for i := 0; i < validatorCount; i++ { + runtime, err := node.NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: stores[i]}, 4) + if err != nil { + t.Fatal(err) + } + runtimes[i] = runtime + candidate, err := runtime.BuildCandidate(1, map[uint32]node.ShardBatch{0: {Transactions: []tx.Transaction{transaction}}}) + if err != nil { + t.Fatal(err) + } + candidates[i] = candidate + if i > 0 && v2consensus.HeaderConsensusHash(candidates[i].Header) != v2consensus.HeaderConsensusHash(candidates[0].Header) { + t.Fatalf("validator %d derived a different candidate header", i) + } + } + return &cluster{network: network, native: native, validators: validators, keys: keys, runtimes: runtimes, candidates: candidates} +} + +func (c *cluster) proposal(t *testing.T) v2consensus.Proposal { + t.Helper() + proposer, err := c.validators.Proposer(1, 0) + if err != nil { + t.Fatal(err) + } + proposal, err := v2consensus.SignProposal(c.keys[proposer.ID], c.candidates[0].Header, 0) + if err != nil { + t.Fatal(err) + } + return proposal +} + +func (c *cluster) votes(t *testing.T, proposal v2consensus.Proposal, indices []int) []v2consensus.Vote { + t.Helper() + votes := make([]v2consensus.Vote, 0, len(indices)) + for _, index := range indices { + if err := node.VerifyProposalAgainstCandidate(c.candidates[index], proposal, c.validators); err != nil { + t.Fatal(err) + } + validator := c.validators.Validators[index] + vote, err := v2consensus.SignVote(c.keys[validator.ID], c.network, proposal.Header.Height, proposal.Round, v2consensus.HeaderConsensusHash(proposal.Header)) + if err != nil { + t.Fatal(err) + } + votes = append(votes, vote) + } + return votes +} diff --git a/internal/v2/lightapi/dto.go b/internal/v2/lightapi/dto.go new file mode 100644 index 00000000..0b220c04 --- /dev/null +++ b/internal/v2/lightapi/dto.go @@ -0,0 +1,13 @@ +package lightapi + +import "encoding/json" + +func (v validatorDTO) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + ID string `json:"id"` + PublicKey []byte `json:"publicKey"` + Power string `json:"power"` + }{ + ID: v.ID, PublicKey: v.PublicKey, Power: v.Power, + }) +} diff --git a/internal/v2/lightapi/server.go b/internal/v2/lightapi/server.go new file mode 100644 index 00000000..b011e45f --- /dev/null +++ b/internal/v2/lightapi/server.go @@ -0,0 +1,195 @@ +package lightapi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strconv" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +var ErrSnapshot = errors.New("invalid finalized v2 light snapshot") + +type Snapshot struct { + Header sharding.GlobalHeader + Certificate v2consensus.Certificate + Commitments []sharding.Commitment + Validators v2consensus.ValidatorSet +} + +func (s Snapshot) Validate() error { + if s.Header.Network != s.Validators.Network || s.Certificate.Network != s.Header.Network || + s.Certificate.Height != s.Header.Height || s.Certificate.HeaderHash != v2consensus.HeaderConsensusHash(s.Header) || + s.Header.CertificateHash != s.Certificate.Hash() { + return ErrSnapshot + } + validatorRoot, err := s.Validators.Root() + if err != nil || validatorRoot != s.Header.ValidatorRoot { + return ErrSnapshot + } + if err := s.Validators.VerifyCertificate(s.Certificate); err != nil { + return ErrSnapshot + } + root, err := sharding.CommitmentRoot(s.Commitments) + if err != nil || root != s.Header.ShardCommitmentRoot { + return ErrSnapshot + } + return nil +} + +type Provider interface { + LatestSnapshot() (Snapshot, error) + ShardState(shardID uint32) (worldstate.Backend, bool) +} + +type Server struct { + Provider Provider +} + +type validatorDTO struct { + ID string `json:"id"` + PublicKey []byte `json:"publicKey"` + Power string `json:"power"` +} + +type statusResponse struct { + Network string `json:"network"` + Height uint64 `json:"height"` + Header []byte `json:"header"` + Certificate []byte `json:"certificate"` + Validators []validatorDTO `json:"validators"` +} + +type objectProofResponse struct { + Network string `json:"network"` + Height uint64 `json:"height"` + ShardID uint32 `json:"shardId"` + Header []byte `json:"header"` + Certificate []byte `json:"certificate"` + Commitment []byte `json:"commitment"` + CommitmentProof []byte `json:"commitmentProof"` + ObjectID string `json:"objectId"` + ObjectPresent bool `json:"objectPresent"` + Object []byte `json:"object,omitempty"` + StateProof []byte `json:"stateProof"` + Validators []validatorDTO `json:"validators"` +} + +func (s Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/v2/light/status", s.handleStatus) + mux.HandleFunc("/v2/light/object", s.handleObject) + return mux +} + +func (s Server) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + snapshot, err := s.snapshot() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "snapshot_unavailable") + return + } + certificate, err := snapshot.Certificate.MarshalBinary() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "snapshot_unavailable") + return + } + writeJSON(w, http.StatusOK, statusResponse{ + Network: snapshot.Header.Network.String(), Height: snapshot.Header.Height, + Header: snapshot.Header.CanonicalBytes(), Certificate: certificate, + Validators: validatorList(snapshot.Validators), + }) +} + +func (s Server) handleObject(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + shardValue := r.URL.Query().Get("shard") + objectValue := r.URL.Query().Get("id") + shard64, err := strconv.ParseUint(shardValue, 10, 32) + if err != nil || len(objectValue) != 64 { + writeError(w, http.StatusBadRequest, "invalid_query") + return + } + objectBytes, err := hex.DecodeString(objectValue) + if err != nil || len(objectBytes) != 32 { + writeError(w, http.StatusBadRequest, "invalid_object_id") + return + } + var objectID types.ObjectID + copy(objectID[:], objectBytes) + shardID := uint32(shard64) + + snapshot, err := s.snapshot() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "snapshot_unavailable") + return + } + commitment, commitmentProof, err := sharding.CommitmentProof(snapshot.Commitments, shardID) + if err != nil { + writeError(w, http.StatusNotFound, "shard_not_found") + return + } + store, ok := s.Provider.ShardState(shardID) + if !ok || store == nil || store.Root() != commitment.StateRoot { + writeError(w, http.StatusServiceUnavailable, "shard_state_unavailable") + return + } + obj, proof, present := store.Proof(objectID) + certificate, err := snapshot.Certificate.MarshalBinary() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "snapshot_unavailable") + return + } + response := objectProofResponse{ + Network: snapshot.Header.Network.String(), Height: snapshot.Header.Height, ShardID: shardID, + Header: snapshot.Header.CanonicalBytes(), Certificate: certificate, + Commitment: commitment.CanonicalBytes(), CommitmentProof: commitmentProof.MarshalBinary(), + ObjectID: objectID.String(), ObjectPresent: present, StateProof: proof.MarshalBinary(), + Validators: validatorList(snapshot.Validators), + } + if present { + response.Object = obj.CanonicalBytes() + } + writeJSON(w, http.StatusOK, response) +} + +func (s Server) snapshot() (Snapshot, error) { + if s.Provider == nil { + return Snapshot{}, ErrSnapshot + } + snapshot, err := s.Provider.LatestSnapshot() + if err != nil || snapshot.Validate() != nil { + return Snapshot{}, ErrSnapshot + } + return snapshot, nil +} + +func validatorList(set v2consensus.ValidatorSet) []validatorDTO { + out := make([]validatorDTO, len(set.Validators)) + for i, validator := range set.Validators { + out[i] = validatorDTO{ID: validator.ID.String(), PublicKey: append([]byte(nil), validator.PublicKey...), Power: strconv.FormatUint(validator.Power, 10)} + } + return out +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} diff --git a/internal/v2/lightapi/server_test.go b/internal/v2/lightapi/server_test.go new file mode 100644 index 00000000..d1e62bd4 --- /dev/null +++ b/internal/v2/lightapi/server_test.go @@ -0,0 +1,123 @@ +package lightapi + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "net/http" + "net/http/httptest" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +type fakeProvider struct { + snapshot Snapshot + state worldstate.Backend +} + +func (f fakeProvider) LatestSnapshot() (Snapshot, error) { return f.snapshot, nil } +func (f fakeProvider) ShardState(shardID uint32) (worldstate.Backend, bool) { + return f.state, shardID == 0 +} + +func TestLightObjectEndpointReturnsVerifiableBundle(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("light-api"))) + owner := types.AccountIDFromPublicKey([]byte("owner")) + token := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + id := types.ObjectIDFromTransaction(types.HashBytes("seed", []byte("coin")), 0) + out, _ := object.NewCoinOutput(owner, token, 100) + obj := object.Object{ID: id, Version: 1, Owner: owner, Kind: out.Kind, Data: out.Data} + store := worldstate.NewMemory() + root, err := store.Apply(nil, []object.Object{obj}) + if err != nil { + t.Fatal(err) + } + commitment := sharding.Commitment{ShardID: 0, StateRoot: root, ReceiptRoot: merkle.Root(nil), DataRoot: merkle.Root(nil)} + commitmentRoot, err := sharding.CommitmentRoot([]sharding.Commitment{commitment}) + if err != nil { + t.Fatal(err) + } + validatorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), validatorKey.PublicKey.X, validatorKey.PublicKey.Y) + validatorID := types.ValidatorIDFromPublicKey(pub) + validators := v2consensus.ValidatorSet{Network: network, Validators: []v2consensus.Validator{{ID: validatorID, PublicKey: pub, Power: 10}}} + validatorRoot, err := validators.Root() + if err != nil { + t.Fatal(err) + } + header := sharding.GlobalHeader{Version: 2, Network: network, Height: 1, ShardCommitmentRoot: commitmentRoot, ValidatorRoot: validatorRoot, DataRoot: merkle.Root(nil)} + proposal, err := v2consensus.SignProposal(validatorKey, header, 0) + if err != nil { + t.Fatal(err) + } + headerHash := v2consensus.HeaderConsensusHash(header) + vote, err := v2consensus.SignVote(validatorKey, network, 1, 0, headerHash) + if err != nil { + t.Fatal(err) + } + certificate, err := validators.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + header.CertificateHash = certificate.Hash() + provider := fakeProvider{snapshot: Snapshot{Header: header, Certificate: certificate, Commitments: []sharding.Commitment{commitment}, Validators: validators}, state: store} + server := Server{Provider: provider}.Handler() + + req := httptest.NewRequest(http.MethodGet, "/v2/light/object?shard=0&id="+id.String(), nil) + res := httptest.NewRecorder() + server.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("unexpected status %d: %s", res.Code, res.Body.String()) + } + if body := res.Body.String(); len(body) < 100 || !contains(body, id.String()) { + t.Fatalf("proof bundle missing object identity: %s", body) + } +} + +func TestSnapshotRejectsValidatorSetNotCommittedByHeader(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("light-validator-root"))) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + validators := v2consensus.ValidatorSet{Network: network, Validators: []v2consensus.Validator{{ID: id, PublicKey: pub, Power: 1}}} + header := sharding.GlobalHeader{ + Version: 2, Network: network, Height: 1, + ShardCommitmentRoot: types.HashBytes("shards", []byte("root")), + ValidatorRoot: types.HashBytes("validators", []byte("foreign")), + DataRoot: merkle.Root(nil), + } + headerHash := v2consensus.HeaderConsensusHash(header) + vote, err := v2consensus.SignVote(key, network, 1, 0, headerHash) + if err != nil { + t.Fatal(err) + } + certificate := v2consensus.Certificate{Network: network, Height: 1, Round: 0, HeaderHash: headerHash, Votes: []v2consensus.Vote{vote}} + if err := validators.VerifyCertificate(certificate); err != nil { + t.Fatal(err) + } + header.CertificateHash = certificate.Hash() + if err := (Snapshot{Header: header, Certificate: certificate, Validators: validators}).Validate(); err != ErrSnapshot { + t.Fatalf("expected validator-root mismatch rejection, got %v", err) + } +} + +func contains(value, needle string) bool { + for i := 0; i+len(needle) <= len(value); i++ { + if value[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/v2/merkle/merkle.go b/internal/v2/merkle/merkle.go new file mode 100644 index 00000000..8c9604c1 --- /dev/null +++ b/internal/v2/merkle/merkle.go @@ -0,0 +1,111 @@ +package merkle + +import ( + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrIndex = errors.New("merkle proof index out of range") + +type Proof struct { + Index uint32 + LeafCount uint32 + Siblings []types.Hash +} + +func Root(leaves []types.Hash) types.Hash { + if len(leaves) == 0 { + return emptyLeaf() + } + level := append([]types.Hash(nil), leaves...) + target := nextPowerOfTwo(len(level)) + for len(level) < target { + level = append(level, emptyLeaf()) + } + for len(level) > 1 { + next := make([]types.Hash, len(level)/2) + for i := 0; i < len(level); i += 2 { + next[i/2] = branch(level[i], level[i+1]) + } + level = next + } + return level[0] +} + +func BuildProof(leaves []types.Hash, index int) (Proof, error) { + if index < 0 || index >= len(leaves) { + return Proof{}, ErrIndex + } + leafCount := len(leaves) + level := append([]types.Hash(nil), leaves...) + target := nextPowerOfTwo(len(level)) + for len(level) < target { + level = append(level, emptyLeaf()) + } + proof := Proof{Index: uint32(index), LeafCount: uint32(leafCount)} + position := index + for len(level) > 1 { + sibling := position ^ 1 + proof.Siblings = append(proof.Siblings, level[sibling]) + next := make([]types.Hash, len(level)/2) + for i := 0; i < len(level); i += 2 { + next[i/2] = branch(level[i], level[i+1]) + } + position /= 2 + level = next + } + return proof, nil +} + +func Verify(root, leaf types.Hash, proof Proof) bool { + if proof.LeafCount == 0 || proof.Index >= proof.LeafCount { + return false + } + target := nextPowerOfTwo(int(proof.LeafCount)) + requiredDepth := 0 + for n := target; n > 1; n /= 2 { + requiredDepth++ + } + if len(proof.Siblings) != requiredDepth { + return false + } + current := leaf + position := int(proof.Index) + for _, sibling := range proof.Siblings { + if position%2 == 0 { + current = branch(current, sibling) + } else { + current = branch(sibling, current) + } + position /= 2 + } + return current == root +} + +func Leaf(domain string, payload []byte) types.Hash { + return types.Hash(codec.DomainHash("zephyr/merkle/leaf/v2/"+domain, payload)) +} + +func branch(left, right types.Hash) types.Hash { + var payload [64]byte + copy(payload[:32], left[:]) + copy(payload[32:], right[:]) + return types.Hash(codec.DomainHash("zephyr/merkle/branch/v2", payload[:])) +} + +func emptyLeaf() types.Hash { + return types.Hash(codec.DomainHash("zephyr/merkle/empty/v2", nil)) +} + +func nextPowerOfTwo(n int) int { + if n <= 1 { + return 1 + } + p := 1 + for p < n { + p <<= 1 + } + return p +} diff --git a/internal/v2/merkle/merkle_test.go b/internal/v2/merkle/merkle_test.go new file mode 100644 index 00000000..b47ab3ff --- /dev/null +++ b/internal/v2/merkle/merkle_test.go @@ -0,0 +1,29 @@ +package merkle + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestProof(t *testing.T) { + leaves := []types.Hash{ + Leaf("test", []byte("a")), + Leaf("test", []byte("b")), + Leaf("test", []byte("c")), + } + root := Root(leaves) + for i, leaf := range leaves { + proof, err := BuildProof(leaves, i) + if err != nil { + t.Fatal(err) + } + if !Verify(root, leaf, proof) { + t.Fatalf("proof %d failed", i) + } + bad := types.HashBytes("bad", []byte{byte(i)}) + if Verify(root, bad, proof) { + t.Fatalf("bad leaf %d verified", i) + } + } +} diff --git a/internal/v2/merkle/wire.go b/internal/v2/merkle/wire.go new file mode 100644 index 00000000..ad3ebcbd --- /dev/null +++ b/internal/v2/merkle/wire.go @@ -0,0 +1,45 @@ +package merkle + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func (p Proof) MarshalBinary() []byte { + var w codec.Writer + w.U32(p.Index) + w.U32(p.LeafCount) + w.U32(uint32(len(p.Siblings))) + for _, sibling := range p.Siblings { + w.Fixed(sibling[:]) + } + return w.BytesCopy() +} + +func ParseProof(data []byte) (Proof, error) { + r := codec.NewReader(data) + index, err := r.U32() + if err != nil { + return Proof{}, ErrIndex + } + leafCount, err := r.U32() + if err != nil || leafCount == 0 || index >= leafCount { + return Proof{}, ErrIndex + } + count, err := r.U32() + if err != nil || count > 32 { + return Proof{}, ErrIndex + } + proof := Proof{Index: index, LeafCount: leafCount, Siblings: make([]types.Hash, int(count))} + for i := range proof.Siblings { + raw, err := r.Fixed(32) + if err != nil { + return Proof{}, ErrIndex + } + copy(proof.Siblings[i][:], raw) + } + if r.Done() != nil { + return Proof{}, ErrIndex + } + return proof, nil +} diff --git a/internal/v2/network/p2p/compute.go b/internal/v2/network/p2p/compute.go new file mode 100644 index 00000000..afda5db8 --- /dev/null +++ b/internal/v2/network/p2p/compute.go @@ -0,0 +1,21 @@ +package p2p + +import ( + "context" + "fmt" + + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/protocol" +) + +func (n *Node) ComputeProtocol() protocol.ID { + return protocol.ID(fmt.Sprintf("/zephyr/%s/v2/compute", n.networkID.String())) +} + +func (n *Node) SetComputeHandler(handler Handler) { + n.install(n.ComputeProtocol(), handler) +} + +func (n *Node) SendCompute(ctx context.Context, remote peer.ID, payload []byte) ([]byte, error) { + return n.request(ctx, remote, n.ComputeProtocol(), payload) +} diff --git a/internal/v2/network/p2p/gossip.go b/internal/v2/network/p2p/gossip.go new file mode 100644 index 00000000..9a91c70d --- /dev/null +++ b/internal/v2/network/p2p/gossip.go @@ -0,0 +1,154 @@ +package p2p + +import ( + "context" + "errors" + "fmt" + + pubsub "github.com/libp2p/go-libp2p-pubsub" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const DefaultMaxGossipBytes = 4 << 20 + +var ( + ErrGossipConfig = errors.New("invalid Zephyr GossipSub configuration") + ErrGossipPayload = errors.New("Zephyr GossipSub payload exceeds limit") +) + +type Gossip struct { + ps *pubsub.PubSub + network types.NetworkID + maxPayload int +} + +type ShardSubscription struct { + Shard uint32 + Tx *pubsub.Subscription + DA *pubsub.Subscription + tx *pubsub.Topic + da *pubsub.Topic +} + +func NewGossip(ctx context.Context, node *Node, maxPayload int) (*Gossip, error) { + if node == nil || node.host == nil || types.IsZero32([32]byte(node.networkID)) { + return nil, ErrGossipConfig + } + if maxPayload <= 0 { + maxPayload = DefaultMaxGossipBytes + } + ps, err := pubsub.NewGossipSub(ctx, node.host, pubsub.WithMaxMessageSize(maxPayload), pubsub.WithFloodPublish(true)) + if err != nil { + return nil, err + } + return &Gossip{ps: ps, network: node.networkID, maxPayload: maxPayload}, nil +} + +func (g *Gossip) JoinShard(shard uint32) (*ShardSubscription, error) { + if g == nil || g.ps == nil { + return nil, ErrGossipConfig + } + txTopic, err := g.ps.Join(g.topic(shard, "tx")) + if err != nil { + return nil, err + } + daTopic, err := g.ps.Join(g.topic(shard, "da")) + if err != nil { + _ = txTopic.Close() + return nil, err + } + txSub, err := txTopic.Subscribe() + if err != nil { + _ = txTopic.Close() + _ = daTopic.Close() + return nil, err + } + daSub, err := daTopic.Subscribe() + if err != nil { + txSub.Cancel() + _ = txTopic.Close() + _ = daTopic.Close() + return nil, err + } + return &ShardSubscription{Shard: shard, Tx: txSub, DA: daSub, tx: txTopic, da: daTopic}, nil +} + +func (s *ShardSubscription) Close() error { + if s == nil { + return nil + } + if s.Tx != nil { + s.Tx.Cancel() + } + if s.DA != nil { + s.DA.Cancel() + } + var first error + if s.tx != nil { + first = s.tx.Close() + } + if s.da != nil { + if err := s.da.Close(); first == nil { + first = err + } + } + return first +} + +func (g *Gossip) PublishTransaction(ctx context.Context, subscription *ShardSubscription, payload []byte) error { + if subscription == nil || subscription.tx == nil { + return ErrGossipConfig + } + return g.publish(ctx, subscription.tx, payload) +} + +func (g *Gossip) PublishDA(ctx context.Context, subscription *ShardSubscription, payload []byte) error { + if subscription == nil || subscription.da == nil { + return ErrGossipConfig + } + return g.publish(ctx, subscription.da, payload) +} + +func (g *Gossip) NextTransaction(ctx context.Context, subscription *ShardSubscription) ([]byte, error) { + if subscription == nil || subscription.Tx == nil { + return nil, ErrGossipConfig + } + return g.next(ctx, subscription.Tx) +} + +func (g *Gossip) NextDA(ctx context.Context, subscription *ShardSubscription) ([]byte, error) { + if subscription == nil || subscription.DA == nil { + return nil, ErrGossipConfig + } + return g.next(ctx, subscription.DA) +} + +func (g *Gossip) publish(ctx context.Context, topic *pubsub.Topic, payload []byte) error { + if len(payload) > g.maxPayload { + return ErrGossipPayload + } + return topic.Publish(ctx, payload) +} + +func (g *Gossip) next(ctx context.Context, subscription *pubsub.Subscription) ([]byte, error) { + message, err := subscription.Next(ctx) + if err != nil { + return nil, err + } + if len(message.Data) > g.maxPayload { + return nil, ErrGossipPayload + } + return append([]byte(nil), message.Data...), nil +} + +func (g *Gossip) topic(shard uint32, kind string) string { + return fmt.Sprintf("/zephyr/%s/v2/shard/%d/%s", g.network.String(), shard, kind) +} + +func GossipTopic(network types.NetworkID, shard uint32, kind string) (string, error) { + if types.IsZero32([32]byte(network)) || (kind != "tx" && kind != "da") { + return "", ErrGossipConfig + } + return fmt.Sprintf("/zephyr/%s/v2/shard/%d/%s", network.String(), shard, kind), nil +} diff --git a/internal/v2/network/p2p/gossip_test.go b/internal/v2/network/p2p/gossip_test.go new file mode 100644 index 00000000..da7629d0 --- /dev/null +++ b/internal/v2/network/p2p/gossip_test.go @@ -0,0 +1,29 @@ +package p2p + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestGossipTopicsAreNetworkAndShardScoped(t *testing.T) { + a := types.NetworkID(types.HashBytes("network", []byte("a"))) + b := types.NetworkID(types.HashBytes("network", []byte("b"))) + a7, err := GossipTopic(a, 7, "tx") + if err != nil { + t.Fatal(err) + } + a8, _ := GossipTopic(a, 8, "tx") + b7, _ := GossipTopic(b, 7, "tx") + da7, _ := GossipTopic(a, 7, "da") + if a7 == a8 || a7 == b7 || a7 == da7 { + t.Fatal("gossip topic domains are not separated") + } +} + +func TestGossipTopicRejectsUnknownKind(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("topic-reject"))) + if _, err := GossipTopic(network, 0, "everything"); err != ErrGossipConfig { + t.Fatalf("expected gossip config error, got %v", err) + } +} diff --git a/internal/v2/network/p2p/node.go b/internal/v2/network/p2p/node.go new file mode 100644 index 00000000..29d37f46 --- /dev/null +++ b/internal/v2/network/p2p/node.go @@ -0,0 +1,210 @@ +package p2p + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "time" + + libp2p "github.com/libp2p/go-libp2p" + p2pcrypto "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/protocol" + ma "github.com/multiformats/go-multiaddr" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + DefaultMaxMessageBytes = 4 << 20 + DefaultStreamTimeout = 10 * time.Second +) + +var ( + ErrConfig = errors.New("invalid Zephyr p2p configuration") + ErrFrameTooLarge = errors.New("Zephyr p2p frame exceeds limit") + ErrNoHandler = errors.New("Zephyr p2p protocol handler unavailable") +) + +type Handler func(context.Context, peer.ID, []byte) ([]byte, error) + +type Handlers struct { + Consensus Handler + Transaction Handler + LightProof Handler +} + +type Config struct { + Network types.NetworkID + Identity p2pcrypto.PrivKey + ListenAddrs []string + MaxMessageBytes uint32 + StreamTimeout time.Duration + Handlers Handlers +} + +type Node struct { + host host.Host + networkID types.NetworkID + maxMessage uint32 + timeout time.Duration + consensus protocol.ID + transaction protocol.ID + lightProof protocol.ID +} + +func New(cfg Config) (*Node, error) { + if types.IsZero32([32]byte(cfg.Network)) { + return nil, ErrConfig + } + identity := cfg.Identity + if identity == nil { + var err error + identity, _, err = p2pcrypto.GenerateEd25519Key(rand.Reader) + if err != nil { + return nil, err + } + } + if len(cfg.ListenAddrs) == 0 { + cfg.ListenAddrs = []string{"/ip4/0.0.0.0/udp/0/quic-v1", "/ip6/::/udp/0/quic-v1"} + } + if cfg.MaxMessageBytes == 0 { + cfg.MaxMessageBytes = DefaultMaxMessageBytes + } + if cfg.StreamTimeout <= 0 { + cfg.StreamTimeout = DefaultStreamTimeout + } + + h, err := libp2p.New(libp2p.Identity(identity), libp2p.ListenAddrStrings(cfg.ListenAddrs...)) + if err != nil { + return nil, err + } + prefix := fmt.Sprintf("/zephyr/%s/v2", cfg.Network.String()) + n := &Node{ + host: h, networkID: cfg.Network, maxMessage: cfg.MaxMessageBytes, timeout: cfg.StreamTimeout, + consensus: protocol.ID(prefix + "/consensus"), transaction: protocol.ID(prefix + "/tx"), lightProof: protocol.ID(prefix + "/light-proof"), + } + n.install(n.consensus, cfg.Handlers.Consensus) + n.install(n.transaction, cfg.Handlers.Transaction) + n.install(n.lightProof, cfg.Handlers.LightProof) + return n, nil +} + +func (n *Node) Close() error { return n.host.Close() } +func (n *Node) Host() host.Host { return n.host } +func (n *Node) ID() peer.ID { return n.host.ID() } +func (n *Node) Addrs() []ma.Multiaddr { return append([]ma.Multiaddr(nil), n.host.Addrs()...) } +func (n *Node) ConsensusProtocol() protocol.ID { return n.consensus } +func (n *Node) TransactionProtocol() protocol.ID { return n.transaction } +func (n *Node) LightProofProtocol() protocol.ID { return n.lightProof } + +func (n *Node) AddrInfo() peer.AddrInfo { + return peer.AddrInfo{ID: n.host.ID(), Addrs: n.Addrs()} +} +func (n *Node) Connect(ctx context.Context, remote peer.AddrInfo) error { + return n.host.Connect(ctx, remote) +} +func (n *Node) SendConsensus(ctx context.Context, remote peer.ID, payload []byte) ([]byte, error) { + return n.request(ctx, remote, n.consensus, payload) +} +func (n *Node) SendTransaction(ctx context.Context, remote peer.ID, payload []byte) ([]byte, error) { + return n.request(ctx, remote, n.transaction, payload) +} +func (n *Node) FetchLightProof(ctx context.Context, remote peer.ID, payload []byte) ([]byte, error) { + return n.request(ctx, remote, n.lightProof, payload) +} + +func (n *Node) SetConsensusHandler(handler Handler) { n.install(n.consensus, handler) } +func (n *Node) SetTransactionHandler(handler Handler) { n.install(n.transaction, handler) } +func (n *Node) SetLightProofHandler(handler Handler) { n.install(n.lightProof, handler) } + +func (n *Node) install(id protocol.ID, handler Handler) { + if handler == nil { + n.host.RemoveStreamHandler(id) + return + } + n.host.SetStreamHandler(id, func(stream network.Stream) { + defer stream.Close() + _ = stream.SetDeadline(time.Now().Add(n.timeout)) + payload, err := readFrame(stream, n.maxMessage) + if err != nil { + _ = stream.Reset() + return + } + ctx, cancel := context.WithTimeout(context.Background(), n.timeout) + defer cancel() + response, err := handler(ctx, stream.Conn().RemotePeer(), payload) + if err != nil { + _ = stream.Reset() + return + } + if response == nil { + response = []byte{} + } + if err := writeFrame(stream, response, n.maxMessage); err != nil { + _ = stream.Reset() + } + }) +} + +func (n *Node) request(ctx context.Context, remote peer.ID, id protocol.ID, payload []byte) ([]byte, error) { + if len(payload) > int(n.maxMessage) { + return nil, ErrFrameTooLarge + } + ctx, cancel := context.WithTimeout(ctx, n.timeout) + defer cancel() + stream, err := n.host.NewStream(ctx, remote, id) + if err != nil { + return nil, err + } + defer stream.Close() + _ = stream.SetDeadline(time.Now().Add(n.timeout)) + if err := writeFrame(stream, payload, n.maxMessage); err != nil { + _ = stream.Reset() + return nil, err + } + response, err := readFrame(stream, n.maxMessage) + if err != nil { + _ = stream.Reset() + return nil, err + } + return response, nil +} + +func writeFrame(w io.Writer, payload []byte, max uint32) error { + if len(payload) > int(max) { + return ErrFrameTooLarge + } + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(payload))) + writer := bufio.NewWriter(w) + if _, err := writer.Write(length[:]); err != nil { + return err + } + if _, err := writer.Write(payload); err != nil { + return err + } + return writer.Flush() +} + +func readFrame(r io.Reader, max uint32) ([]byte, error) { + var length [4]byte + if _, err := io.ReadFull(r, length[:]); err != nil { + return nil, err + } + size := binary.BigEndian.Uint32(length[:]) + if size > max { + return nil, ErrFrameTooLarge + } + payload := make([]byte, int(size)) + if _, err := io.ReadFull(r, payload); err != nil { + return nil, err + } + return payload, nil +} diff --git a/internal/v2/network/p2p/node_test.go b/internal/v2/network/p2p/node_test.go new file mode 100644 index 00000000..daee1b81 --- /dev/null +++ b/internal/v2/network/p2p/node_test.go @@ -0,0 +1,47 @@ +package p2p + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/libp2p/go-libp2p/core/peer" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestQUICNodesUseNetworkScopedProtocolsAndBoundedFrames(t *testing.T) { + networkID := types.NetworkID(types.HashBytes("network", []byte("p2p-test"))) + server, err := New(Config{ + Network: networkID, + ListenAddrs: []string{"/ip4/127.0.0.1/udp/0/quic-v1"}, + Handlers: Handlers{Transaction: func(_ context.Context, _ peer.ID, payload []byte) ([]byte, error) { + return append([]byte("ok:"), payload...), nil + }}, + }) + if err != nil { + t.Fatal(err) + } + defer server.Close() + client, err := New(Config{Network: networkID, ListenAddrs: []string{"/ip4/127.0.0.1/udp/0/quic-v1"}}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Connect(ctx, server.AddrInfo()); err != nil { + t.Fatal(err) + } + got, err := client.SendTransaction(ctx, server.ID(), []byte("tx")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, []byte("ok:tx")) { + t.Fatalf("unexpected response %q", got) + } + if client.TransactionProtocol() == client.ConsensusProtocol() { + t.Fatal("protocol roles not separated") + } +} diff --git a/internal/v2/node/benchmark_test.go b/internal/v2/node/benchmark_test.go new file mode 100644 index 00000000..1b686ee7 --- /dev/null +++ b/internal/v2/node/benchmark_test.go @@ -0,0 +1,150 @@ +package node + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +const benchmarkBatchSize = 32 + +type benchmarkSigner struct { + key *ecdsa.PrivateKey + account types.AccountID +} + +func BenchmarkV2FinalizedBatch32(b *testing.B) { + for _, workers := range []int{1, 4, 8, 16} { + b.Run(fmt.Sprintf("workers-%d", workers), func(b *testing.B) { + benchmarkFinalizedBatch(b, workers) + }) + } +} + +func benchmarkFinalizedBatch(b *testing.B, workers int) { + b.Helper() + b.ReportAllocs() + b.StopTimer() + + network := types.NetworkID(types.HashBytes("network", []byte("benchmark-v2"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + validators, validatorKeys := benchmarkValidators(b, network, 7) + validatorRoot, err := validators.Root() + if err != nil { + b.Fatal(err) + } + signers := make([]benchmarkSigner, benchmarkBatchSize) + for i := range signers { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + b.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + signers[i] = benchmarkSigner{key: key, account: types.AccountIDFromPublicKey(pub)} + } + recipient := types.AccountIDFromPublicKey([]byte("benchmark-recipient")) + + for iteration := 0; iteration < b.N; iteration++ { + store := worldstate.NewMemory() + inputs := make([]object.Object, benchmarkBatchSize) + for i, signer := range signers { + seed := types.HashBytes("benchmark-input", []byte(fmt.Sprintf("%d/%d", iteration, i))) + id := types.ObjectIDFromTransaction(seed, 0) + out, err := object.NewCoinOutput(signer.account, native, 100) + if err != nil { + b.Fatal(err) + } + inputs[i] = object.Object{ID: id, Version: 1, Owner: signer.account, Kind: out.Kind, Data: out.Data} + } + root, err := store.Apply(nil, inputs) + if err != nil { + b.Fatal(err) + } + transactions := make([]tx.Transaction, benchmarkBatchSize) + for i, signer := range signers { + witness, proof, ok := store.Proof(inputs[i].ID) + if !ok { + b.Fatal("missing benchmark witness") + } + witnessHash := witness.Hash() + payment, _ := object.NewCoinOutput(recipient, native, 25) + change, _ := object.NewCoinOutput(signer.account, native, 74) + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: inputs[i].ID, Version: 1, ObjectHash: witnessHash}}, + Outputs: []object.OutputSpec{payment, change}, Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Fee: 1, Witnesses: []tx.Witness{{Object: witness, Proof: proof}}, + } + transaction.Salt[0] = byte(i + 1) + transaction.Salt[1] = byte(iteration) + if err := transaction.Sign(signer.key); err != nil { + b.Fatal(err) + } + transactions[i] = transaction + } + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, workers) + if err != nil { + b.Fatal(err) + } + + b.StartTimer() + candidate, err := runtime.BuildCandidate(1, map[uint32]ShardBatch{0: {Transactions: transactions}}) + if err != nil { + b.Fatal(err) + } + proposer, err := validators.Proposer(1, 0) + if err != nil { + b.Fatal(err) + } + proposal, err := v2consensus.SignProposal(validatorKeys[proposer.ID], candidate.Header, 0) + if err != nil { + b.Fatal(err) + } + headerHash := v2consensus.HeaderConsensusHash(candidate.Header) + votes := make([]v2consensus.Vote, 0, 5) + for _, validator := range validators.Validators[:5] { + vote, err := v2consensus.SignVote(validatorKeys[validator.ID], network, 1, 0, headerHash) + if err != nil { + b.Fatal(err) + } + votes = append(votes, vote) + } + certificate, err := validators.BuildCertificate(proposal, votes) + if err != nil { + b.Fatal(err) + } + if _, err := runtime.Commit(candidate, certificate, validators); err != nil { + b.Fatal(err) + } + b.StopTimer() + } + + if elapsed := b.Elapsed().Seconds(); elapsed > 0 { + b.ReportMetric(float64(b.N*benchmarkBatchSize)/elapsed, "finalized-tx/s") + } +} + +func benchmarkValidators(b *testing.B, network types.NetworkID, count int) (v2consensus.ValidatorSet, map[types.ValidatorID]*ecdsa.PrivateKey) { + b.Helper() + set := v2consensus.ValidatorSet{Network: network, Validators: make([]v2consensus.Validator, 0, count)} + keys := make(map[types.ValidatorID]*ecdsa.PrivateKey, count) + for i := 0; i < count; i++ { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + b.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + set.Validators = append(set.Validators, v2consensus.Validator{ID: id, PublicKey: pub, Power: 10_000}) + keys[id] = key + } + return set, keys +} diff --git a/internal/v2/node/commit_preflight.go b/internal/v2/node/commit_preflight.go new file mode 100644 index 00000000..3c781753 --- /dev/null +++ b/internal/v2/node/commit_preflight.go @@ -0,0 +1,79 @@ +package node + +import ( + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +// preflightCandidateState reconstructs every shard commitment against the +// currently committed state before Runtime.Commit mutates any backend. This +// catches stale/missing objects, duplicate/conflicting outputs and tampered +// commitment/receipt/data roots deterministically across all shards. +// +// It does not make independent durable backends transactionally atomic against +// a later storage I/O failure; that requires the global commit coordinator. +func (r *Runtime) preflightCandidateState(candidate Candidate) (map[uint32]sharding.Commitment, []int, error) { + if r == nil || len(candidate.Commitments) != int(r.ShardCount) { + return nil, nil, ErrCandidateState + } + commitmentRoot, err := sharding.CommitmentRoot(candidate.Commitments) + if err != nil || commitmentRoot != candidate.Header.ShardCommitmentRoot { + return nil, nil, ErrCandidateState + } + + commitments := make(map[uint32]sharding.Commitment, r.ShardCount) + for _, commitment := range candidate.Commitments { + if commitment.ShardID >= r.ShardCount { + return nil, nil, ErrCandidateState + } + if _, duplicate := commitments[commitment.ShardID]; duplicate { + return nil, nil, ErrCandidateState + } + commitments[commitment.ShardID] = commitment + } + + dataLeaves := make([]types.Hash, r.ShardCount) + for shard := uint32(0); shard < r.ShardCount; shard++ { + commitment, ok := commitments[shard] + if !ok { + return nil, nil, ErrCandidateState + } + stateRoot := r.States[shard].Root() + if delta, changed := candidate.deltas[shard]; changed { + simulator, ok := r.States[shard].(worldstate.Simulator) + if !ok { + return nil, nil, ErrStateSimulation + } + stateRoot, err = simulator.Simulate(delta.Consumed, delta.Created) + if err != nil { + return nil, nil, err + } + } + if stateRoot != commitment.StateRoot { + return nil, nil, ErrCandidateState + } + + receiptRoot, err := (sharding.ReceiptBatch{Receipts: candidate.Receipts[shard]}).Root() + if err != nil || receiptRoot != commitment.ReceiptRoot { + return nil, nil, ErrCandidateState + } + dataLeaves[shard] = merkle.Leaf("shard-data-root", commitment.DataRoot[:]) + } + if merkle.Root(dataLeaves) != candidate.Header.DataRoot { + return nil, nil, ErrCandidateState + } + + shards := make([]int, 0, len(candidate.deltas)) + for shard := range candidate.deltas { + if shard >= r.ShardCount { + return nil, nil, ErrCandidateState + } + shards = append(shards, int(shard)) + } + sort.Ints(shards) + return commitments, shards, nil +} diff --git a/internal/v2/node/commit_preflight_test.go b/internal/v2/node/commit_preflight_test.go new file mode 100644 index 00000000..98d95969 --- /dev/null +++ b/internal/v2/node/commit_preflight_test.go @@ -0,0 +1,152 @@ +package node + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/execution" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestCommitPreflightRejectsStaleLaterShardBeforeAnyApply(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("preflight-stale"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + state0 := worldstate.NewMemory() + state1 := worldstate.NewMemory() + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: state0, 1: state1}, 1) + if err != nil { + t.Fatal(err) + } + + old0 := systemObjectForShard(0, 1, "old-0") + old1 := systemObjectForShard(1, 1, "old-1") + if _, err := state0.Apply(nil, []object.Object{old0}); err != nil { + t.Fatal(err) + } + if _, err := state1.Apply(nil, []object.Object{old1}); err != nil { + t.Fatal(err) + } + new0 := systemObjectForShard(0, 2, "new-0") + new1 := systemObjectForShard(1, 2, "new-1") + deltas := map[uint32]shardDelta{ + 0: {Consumed: []types.ObjectID{old0.ID}, Created: []object.Object{new0}}, + 1: {Consumed: []types.ObjectID{old1.ID}, Created: []object.Object{new1}}, + } + candidate := manualCandidateForDeltas(t, runtime, deltas) + + root0Before := state0.Root() + interloper := systemObjectForShard(1, 3, "interloper") + if _, err := state1.Apply([]types.ObjectID{old1.ID}, []object.Object{interloper}); err != nil { + t.Fatal(err) + } + commitSchedulerCandidateExpectError(t, runtime, key, validators, candidate) + + if state0.Root() != root0Before { + t.Fatal("shard 0 mutated before stale shard 1 was rejected") + } + if _, ok := state0.GetObject(old0.ID); !ok { + t.Fatal("shard 0 consumed object despite failed all-shard preflight") + } + if _, ok := state0.GetObject(new0.ID); ok { + t.Fatal("shard 0 created object despite failed all-shard preflight") + } + if runtime.Height != 0 { + t.Fatalf("failed preflight advanced runtime height to %d", runtime.Height) + } +} + +func TestCommitPreflightRejectsTamperedUntouchedShardRoot(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("preflight-root"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: worldstate.NewMemory(), 1: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + candidate, err := runtime.BuildCandidate(1, nil) + if err != nil { + t.Fatal(err) + } + candidate.Commitments[1].StateRoot = types.Hash{99} + root, err := sharding.CommitmentRoot(candidate.Commitments) + if err != nil { + t.Fatal(err) + } + candidate.Header.ShardCommitmentRoot = root + commitSchedulerCandidateExpectError(t, runtime, key, validators, candidate) + if runtime.Height != 0 { + t.Fatal("tampered untouched shard commitment finalized") + } +} + +func TestCommitPreflightRejectsTamperedHeaderDataRoot(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("preflight-data"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + candidate, err := runtime.BuildCandidate(1, nil) + if err != nil { + t.Fatal(err) + } + candidate.Header.DataRoot = types.Hash{77} + commitSchedulerCandidateExpectError(t, runtime, key, validators, candidate) + if runtime.Height != 0 { + t.Fatal("tampered global data root finalized") + } +} + +func manualCandidateForDeltas(t *testing.T, runtime *Runtime, deltas map[uint32]shardDelta) Candidate { + t.Helper() + commitments := make([]sharding.Commitment, 0, runtime.ShardCount) + dataLeaves := make([]types.Hash, runtime.ShardCount) + receipts := make(map[uint32][]sharding.CrossShardReceipt, runtime.ShardCount) + for shard := uint32(0); shard < runtime.ShardCount; shard++ { + stateRoot := runtime.States[shard].Root() + if delta, ok := deltas[shard]; ok { + simulator := runtime.States[shard].(worldstate.Simulator) + var err error + stateRoot, err = simulator.Simulate(delta.Consumed, delta.Created) + if err != nil { + t.Fatal(err) + } + } + receiptRoot, err := (sharding.ReceiptBatch{}).Root() + if err != nil { + t.Fatal(err) + } + dataRoot := merkle.Root(nil) + commitments = append(commitments, sharding.Commitment{ShardID: shard, StateRoot: stateRoot, ReceiptRoot: receiptRoot, DataRoot: dataRoot}) + dataLeaves[shard] = merkle.Leaf("shard-data-root", dataRoot[:]) + } + commitmentRoot, err := sharding.CommitmentRoot(commitments) + if err != nil { + t.Fatal(err) + } + return Candidate{ + Header: sharding.GlobalHeader{ + Version: 2, Network: runtime.Network, Height: 1, ParentHash: runtime.ParentHash, + ShardCommitmentRoot: commitmentRoot, ValidatorRoot: runtime.ValidatorRoot, + NextValidatorRoot: runtime.ValidatorRoot, DataRoot: merkle.Root(dataLeaves), + }, + Commitments: commitments, + Results: make(map[uint32][]execution.Result), + Receipts: receipts, + deltas: deltas, + } +} + +func systemObjectForShard(shard, index uint32, label string) object.Object { + return object.Object{ + ID: types.ObjectIDForShard(types.HashBytes("preflight-object", []byte(label)), index, shard), + Version: 1, + Kind: object.KindSystem, + Data: []byte(label), + } +} diff --git a/internal/v2/node/economic_checkpoint.go b/internal/v2/node/economic_checkpoint.go new file mode 100644 index 00000000..21b2d031 --- /dev/null +++ b/internal/v2/node/economic_checkpoint.go @@ -0,0 +1,341 @@ +package node + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "path/filepath" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + economicCheckpointVersion uint16 = 1 + economicCheckpointMagic = "ZEC2" + maxEconomicCheckpointBytes = 64 << 20 +) + +var ErrEconomicCheckpoint = errors.New("invalid Zephyr v2 economic runtime checkpoint") + +func (r *Runtime) EconomicCheckpointBytes() ([]byte, error) { + if r == nil { + return nil, ErrEconomicCheckpoint + } + r.mu.Lock() + defer r.mu.Unlock() + return r.economicCheckpointBytesLocked() +} + +func (r *Runtime) economicCheckpointBytesLocked() ([]byte, error) { + if r.economicCollector == nil || r.economicCollector.LastHeight() != r.Height { + return nil, ErrEconomicCheckpoint + } + collectorRaw, err := r.economicCollector.CheckpointBytes() + if err != nil { + return nil, err + } + var payload codec.Writer + payload.U16(economicCheckpointVersion) + payload.Fixed(r.Network[:]) + payload.Fixed(r.NativeToken[:]) + payload.Fixed(r.ValidatorRoot[:]) + payload.U32(r.ShardCount) + payload.U64(r.Height) + payload.Fixed(r.ParentHash[:]) + payload.U64(r.economicEpochLength) + payload.U64(r.economicBalances.TotalSupply) + payload.U64(r.economicBalances.StakedSupply) + payload.U64(r.economicBalances.ProtocolReserve) + payload.U64(r.economicBalances.BaseFee) + payload.Bytes(collectorRaw) + payload.Bool(r.economicEngine != nil) + if r.economicEngine != nil { + engineRaw, err := r.economicEngine.CheckpointBytes() + if err != nil { + return nil, err + } + payload.Bytes(engineRaw) + } + payload.Bool(r.pendingEconomic != nil) + if r.pendingEconomic != nil { + if r.economicEngine == nil { + return nil, ErrEconomicCheckpoint + } + pendingRaw, err := r.pendingEconomic.PendingCheckpointBytes() + if err != nil { + return nil, err + } + payload.Bytes(pendingRaw) + } + rawPayload := payload.BytesCopy() + if len(rawPayload) == 0 || len(rawPayload) > maxEconomicCheckpointBytes { + return nil, ErrEconomicCheckpoint + } + digest := codec.DomainHash("zephyr/economic-runtime-checkpoint/v2", rawPayload) + var file bytes.Buffer + file.WriteString(economicCheckpointMagic) + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(rawPayload))) + file.Write(length[:]) + file.Write(rawPayload) + file.Write(digest[:]) + return file.Bytes(), nil +} + +// RestoreEconomicCheckpointBytes restores only the economic subsystem. The +// caller must first recover the normal consensus/world-state runtime to the +// exact same height and ParentHash. A stale checkpoint therefore fails closed +// instead of moving consensus height or silently replaying economics against a +// different state root. +func (r *Runtime) RestoreEconomicCheckpointBytes(data []byte, registry *compute.WorkRegistry, engineConfig economics.ShadowEpochEngineConfig) error { + if r == nil { + return ErrEconomicCheckpoint + } + decoded, err := decodeEconomicCheckpoint(data, registry, engineConfig) + if err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if r.economicCollector != nil || r.economicEngine != nil || r.pendingEconomic != nil || + decoded.network != r.Network || decoded.nativeToken != r.NativeToken || decoded.validatorRoot != r.ValidatorRoot || + decoded.shardCount != r.ShardCount || decoded.height != r.Height || decoded.parentHash != r.ParentHash || + decoded.collector.LastHeight() != r.Height { + return ErrEconomicCheckpoint + } + if err := validateRecoveredEconomics(decoded.collector, decoded.engine, decoded.pending, decoded.epochLength, decoded.balances); err != nil { + return err + } + r.economicCollector = decoded.collector + r.economicEngine = decoded.engine + r.pendingEconomic = decoded.pending + r.economicEpochLength = decoded.epochLength + r.economicBalances = decoded.balances + return nil +} + +func (r *Runtime) SaveEconomicCheckpoint(path string) error { + raw, err := r.EconomicCheckpointBytes() + if err != nil { + return err + } + return writeAtomicEconomicCheckpoint(path, raw) +} + +func (r *Runtime) RestoreEconomicCheckpoint(path string, registry *compute.WorkRegistry, engineConfig economics.ShadowEpochEngineConfig) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + if len(raw) > maxEconomicCheckpointBytes+8+32 { + return ErrEconomicCheckpoint + } + return r.RestoreEconomicCheckpointBytes(raw, registry, engineConfig) +} + +type decodedEconomicCheckpoint struct { + network types.NetworkID + nativeToken types.TokenID + validatorRoot types.Hash + shardCount uint32 + height uint64 + parentHash types.Hash + epochLength uint64 + balances economics.MonetaryBalanceSnapshot + collector *economics.EpochCollector + engine *economics.ShadowEpochEngine + pending *economics.ShadowEpochPreview +} + +func decodeEconomicCheckpoint(data []byte, registry *compute.WorkRegistry, engineConfig economics.ShadowEpochEngineConfig) (decodedEconomicCheckpoint, error) { + if len(data) < 8+32 || len(data) > maxEconomicCheckpointBytes+8+32 || string(data[:4]) != economicCheckpointMagic { + return decodedEconomicCheckpoint{}, ErrEconomicCheckpoint + } + length := binary.BigEndian.Uint32(data[4:8]) + if length == 0 || int(length) > maxEconomicCheckpointBytes || len(data) != 8+int(length)+32 { + return decodedEconomicCheckpoint{}, ErrEconomicCheckpoint + } + payload := data[8 : 8+int(length)] + digest := codec.DomainHash("zephyr/economic-runtime-checkpoint/v2", payload) + if !bytes.Equal(digest[:], data[8+int(length):]) { + return decodedEconomicCheckpoint{}, ErrEconomicCheckpoint + } + r := codec.NewReader(payload) + version, err := r.U16() + if err != nil || version != economicCheckpointVersion { + return decodedEconomicCheckpoint{}, ErrEconomicCheckpoint + } + out := decodedEconomicCheckpoint{} + networkRaw, err := r.Fixed(32) + if err != nil { + return out, ErrEconomicCheckpoint + } + copy(out.network[:], networkRaw) + nativeRaw, err := r.Fixed(32) + if err != nil { + return out, ErrEconomicCheckpoint + } + copy(out.nativeToken[:], nativeRaw) + validatorRaw, err := r.Fixed(32) + if err != nil { + return out, ErrEconomicCheckpoint + } + copy(out.validatorRoot[:], validatorRaw) + out.shardCount, err = r.U32() + if err != nil || out.shardCount == 0 { + return out, ErrEconomicCheckpoint + } + out.height, err = r.U64() + if err != nil { + return out, ErrEconomicCheckpoint + } + parentRaw, err := r.Fixed(32) + if err != nil { + return out, ErrEconomicCheckpoint + } + copy(out.parentHash[:], parentRaw) + out.epochLength, err = r.U64() + if err != nil { + return out, ErrEconomicCheckpoint + } + out.balances.TotalSupply, err = r.U64() + if err != nil { + return out, ErrEconomicCheckpoint + } + out.balances.StakedSupply, err = r.U64() + if err != nil { + return out, ErrEconomicCheckpoint + } + out.balances.ProtocolReserve, err = r.U64() + if err != nil { + return out, ErrEconomicCheckpoint + } + out.balances.BaseFee, err = r.U64() + if err != nil { + return out, ErrEconomicCheckpoint + } + collectorRaw, err := r.Bytes(maxEconomicCheckpointBytes) + if err != nil { + return out, ErrEconomicCheckpoint + } + out.collector, err = economics.RestoreEpochCollector(collectorRaw, registry) + if err != nil { + return out, err + } + hasEngine, err := r.Bool() + if err != nil { + return out, ErrEconomicCheckpoint + } + if hasEngine { + engineRaw, err := r.Bytes(maxEconomicCheckpointBytes) + if err != nil { + return out, ErrEconomicCheckpoint + } + out.engine, err = economics.RestoreShadowEpochEngine(engineRaw, out.network, engineConfig) + if err != nil { + return out, err + } + } + hasPending, err := r.Bool() + if err != nil { + return out, ErrEconomicCheckpoint + } + if hasPending { + if out.engine == nil { + return out, ErrEconomicCheckpoint + } + pendingRaw, err := r.Bytes(maxEconomicCheckpointBytes) + if err != nil { + return out, ErrEconomicCheckpoint + } + pending, err := economics.RestoreShadowEpochPreview(pendingRaw, out.engine) + if err != nil { + return out, err + } + out.pending = &pending + } + if r.Done() != nil { + return out, ErrEconomicCheckpoint + } + return out, nil +} + +func validateRecoveredEconomics(collector *economics.EpochCollector, engine *economics.ShadowEpochEngine, pending *economics.ShadowEpochPreview, epochLength uint64, balances economics.MonetaryBalanceSnapshot) error { + if collector == nil { + return ErrEconomicCheckpoint + } + if engine == nil { + if pending != nil || epochLength != 0 || balances != (economics.MonetaryBalanceSnapshot{}) { + return ErrEconomicCheckpoint + } + return nil + } + if epochLength < 2 || balances.TotalSupply == 0 || balances.StakedSupply > balances.TotalSupply || + balances.ProtocolReserve > balances.TotalSupply || balances.BaseFee == 0 { + return ErrEconomicCheckpoint + } + previous, hasPrevious := engine.PreviousState() + if pending != nil { + if pending.State.Epoch+1 != collector.Epoch() { + return ErrEconomicCheckpoint + } + if hasPrevious { + if previous.Epoch+1 != pending.State.Epoch { + return ErrEconomicCheckpoint + } + } else if pending.State.Epoch != 1 { + return ErrEconomicCheckpoint + } + return nil + } + if hasPrevious { + if previous.Epoch+1 != collector.Epoch() { + return ErrEconomicCheckpoint + } + } else if collector.Epoch() != 1 { + return ErrEconomicCheckpoint + } + return nil +} + +func writeAtomicEconomicCheckpoint(path string, raw []byte) error { + if path == "" || len(raw) == 0 || len(raw) > maxEconomicCheckpointBytes+8+32 { + return ErrEconomicCheckpoint + } + dir := filepath.Dir(path) + base := filepath.Base(path) + tmp, err := os.OpenFile(filepath.Join(dir, "."+base+".tmp"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err = tmp.Write(raw); err == nil { + err = tmp.Sync() + } + closeErr := tmp.Close() + if err == nil { + err = closeErr + } + if err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Chmod(tmpPath, 0o600); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + dirHandle, err := os.Open(dir) + if err != nil { + return err + } + defer dirHandle.Close() + return dirHandle.Sync() +} diff --git a/internal/v2/node/economic_checkpoint_test.go b/internal/v2/node/economic_checkpoint_test.go new file mode 100644 index 00000000..22179c58 --- /dev/null +++ b/internal/v2/node/economic_checkpoint_test.go @@ -0,0 +1,141 @@ +package node + +import ( + "os" + "path/filepath" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestEconomicCheckpointRestoresPendingEpochAndFinalizesIt(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("economic-checkpoint"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + store := worldstate.NewMemory() + runtime, engineConfig := newCheckpointEconomicsRuntime(t, network, native, validatorRoot, store) + + commitEmptySchedulerBlock(t, runtime, key, validators, 1) + commitEmptySchedulerBlock(t, runtime, key, validators, 2) + pendingBefore, ok := runtime.PendingEconomicState() + if !ok || pendingBefore.Epoch != 1 { + t.Fatalf("expected pending first epoch before checkpoint: %#v", pendingBefore) + } + + path := filepath.Join(t.TempDir(), "economics.checkpoint") + if err := runtime.SaveEconomicCheckpoint(path); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("checkpoint permissions = %o, want 600", info.Mode().Perm()) + } + + restored, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, 1) + if err != nil { + t.Fatal(err) + } + // Normal consensus/world-state recovery owns these anchors. Economic restore + // is deliberately forbidden from inventing them. + restored.Height = runtime.Height + restored.ParentHash = runtime.ParentHash + if err := restored.RestoreEconomicCheckpoint(path, nil, engineConfig); err != nil { + t.Fatal(err) + } + pendingAfter, ok := restored.PendingEconomicState() + if !ok || pendingAfter != pendingBefore { + t.Fatalf("pending state changed across restart: %#v != %#v", pendingAfter, pendingBefore) + } + candidate, err := restored.BuildCandidate(3, nil) + if err != nil { + t.Fatal(err) + } + commitSchedulerCandidate(t, restored, key, validators, candidate) + finalized, ok := restored.FinalizedEconomicState() + if !ok || finalized != pendingBefore { + t.Fatalf("restored pending state did not finalize: %#v", finalized) + } + if _, exists := store.GetObject(economics.MonetaryStateObjectID(network)); !exists { + t.Fatal("restored pending monetary object was not committed to world state") + } +} + +func TestEconomicCheckpointRejectsWrongChainAnchorAndTamper(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("economic-checkpoint-reject"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + store := worldstate.NewMemory() + runtime, engineConfig := newCheckpointEconomicsRuntime(t, network, native, validatorRoot, store) + commitEmptySchedulerBlock(t, runtime, key, validators, 1) + + raw, err := runtime.EconomicCheckpointBytes() + if err != nil { + t.Fatal(err) + } + wrongAnchor, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, 1) + if err != nil { + t.Fatal(err) + } + wrongAnchor.Height = runtime.Height + wrongAnchor.ParentHash = types.Hash{99} + if err := wrongAnchor.RestoreEconomicCheckpointBytes(raw, nil, engineConfig); err == nil { + t.Fatal("economic checkpoint restored against a different parent hash") + } + + tampered := append([]byte(nil), raw...) + tampered[len(tampered)/2] ^= 0xff + matching, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, 1) + if err != nil { + t.Fatal(err) + } + matching.Height = runtime.Height + matching.ParentHash = runtime.ParentHash + if err := matching.RestoreEconomicCheckpointBytes(tampered, nil, engineConfig); err == nil { + t.Fatal("tampered economic checkpoint was accepted") + } +} + +func newCheckpointEconomicsRuntime(t *testing.T, network types.NetworkID, native types.TokenID, validatorRoot types.Hash, store worldstate.Backend) (*Runtime, economics.ShadowEpochEngineConfig) { + t.Helper() + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, 1) + if err != nil { + t.Fatal(err) + } + collector, err := economics.NewEpochCollector(economics.EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: native, + InitialCirculatingSupply: map[uint32]uint64{0: 1_000_000}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 100}, + VelocityPolicy: economics.VelocityPolicy{MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000}, + FeePolicy: economics.CompatibilityFeePolicy(), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomics(collector); err != nil { + t.Fatal(err) + } + index := economics.ComputeIndexConfig{MinSamplesPerClass: 1, MinCoverageBps: 10_000, EWMABps: 10_000} + index.WeightsBps[compute.WorkCPUGeneral] = 10_000 + engineConfig := economics.ShadowEpochEngineConfig{ + ComputeIndex: index, ComputeScarcity: economics.DefaultComputeScarcityConfig(), + Monetary: economics.DefaultShadowPolicy(), + ComputeFeedback: economics.DefaultComputeFeedbackPolicy(economics.ComputeFeedbackObserveOnly), + } + engine, err := economics.NewShadowEpochEngine(network, engineConfig) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomicEpochs(engine, 2, economics.MonetaryBalanceSnapshot{TotalSupply: 1_000_000, BaseFee: 1}); err != nil { + t.Fatal(err) + } + return runtime, engineConfig +} + +var _ v2consensus.ValidatorSet diff --git a/internal/v2/node/economic_epoch_scheduler_test.go b/internal/v2/node/economic_epoch_scheduler_test.go new file mode 100644 index 00000000..52616f06 --- /dev/null +++ b/internal/v2/node/economic_epoch_scheduler_test.go @@ -0,0 +1,187 @@ +package node + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestShadowEconomicEpochStateEntersNextConsensusCandidate(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("economic-epoch-scheduler"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + validatorKey, validators, validatorRoot := schedulerValidatorSet(t, network) + store := worldstate.NewMemory() + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, 1) + if err != nil { + t.Fatal(err) + } + + collector, err := economics.NewEpochCollector(economics.EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: native, + InitialCirculatingSupply: map[uint32]uint64{0: 1_000_000}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 100}, + VelocityPolicy: economics.VelocityPolicy{ + MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000, + }, + FeePolicy: economics.CompatibilityFeePolicy(), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomics(collector); err != nil { + t.Fatal(err) + } + index := economics.ComputeIndexConfig{MinSamplesPerClass: 1, MinCoverageBps: 10_000, EWMABps: 10_000} + index.WeightsBps[compute.WorkCPUGeneral] = 10_000 + engine, err := economics.NewShadowEpochEngine(network, economics.ShadowEpochEngineConfig{ + ComputeIndex: index, + ComputeScarcity: economics.DefaultComputeScarcityConfig(), + Monetary: economics.DefaultShadowPolicy(), + ComputeFeedback: economics.DefaultComputeFeedbackPolicy(economics.ComputeFeedbackObserveOnly), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomicEpochs(engine, 2, economics.MonetaryBalanceSnapshot{ + TotalSupply: 1_000_000, BaseFee: 1, + }); err != nil { + t.Fatal(err) + } + + commitEmptySchedulerBlock(t, runtime, validatorKey, validators, 1) + if _, pending := runtime.PendingEconomicState(); pending { + t.Fatal("epoch closed before configured boundary") + } + + commitEmptySchedulerBlock(t, runtime, validatorKey, validators, 2) + pending, ok := runtime.PendingEconomicState() + if !ok || pending.Epoch != 1 || !pending.Shadow || pending.TotalSupply != 1_000_000 { + t.Fatalf("unexpected pending epoch state: %#v", pending) + } + if _, exists := store.GetObject(economics.MonetaryStateObjectID(network)); exists { + t.Fatal("pending monetary state entered world state before a consensus candidate") + } + + rootBefore := store.Root() + candidate, err := runtime.BuildCandidate(3, nil) + if err != nil { + t.Fatal(err) + } + if candidate.Commitments[0].StateRoot == rootBefore { + t.Fatal("next candidate did not commit the pending monetary object") + } + commitSchedulerCandidate(t, runtime, validatorKey, validators, candidate) + + if _, pending := runtime.PendingEconomicState(); pending { + t.Fatal("finalized pending state was not cleared") + } + finalized, ok := runtime.FinalizedEconomicState() + if !ok || finalized.Epoch != 1 || !finalized.Shadow { + t.Fatalf("shadow monetary state was not finalized: %#v", finalized) + } + obj, exists := store.GetObject(economics.MonetaryStateObjectID(network)) + if !exists || obj.Version != 1 { + t.Fatalf("monetary system object missing after consensus finality: %#v", obj) + } + parsed, err := economics.ParseMonetaryEpochState(obj.Data) + if err != nil || parsed != finalized { + t.Fatalf("committed monetary object mismatch: %#v %v", parsed, err) + } + if finalized.ShadowGrossMintTarget == 0 { + t.Fatal("expected a shadow issuance suggestion for simulation") + } + if supply, _ := runtime.economicCollector.CirculatingSupply(0); supply != 1_000_000 { + t.Fatalf("shadow issuance mutated live circulating supply: %d", supply) + } +} + +func TestShadowEconomicEpochSchedulerRejectsOneBlockEpochs(t *testing.T) { + network := types.NetworkID{1} + native := types.TokenID{2} + runtime, err := NewRuntime(network, native, types.Hash{3}, map[uint32]worldstate.Backend{0: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + collector, err := economics.NewEpochCollector(economics.EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: native, + InitialCirculatingSupply: map[uint32]uint64{0: 1_000}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 100}, + VelocityPolicy: economics.VelocityPolicy{ + MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000, + }, + FeePolicy: economics.CompatibilityFeePolicy(), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomics(collector); err != nil { + t.Fatal(err) + } + index := economics.ComputeIndexConfig{MinSamplesPerClass: 1, MinCoverageBps: 10_000, EWMABps: 10_000} + index.WeightsBps[compute.WorkCPUGeneral] = 10_000 + engine, err := economics.NewShadowEpochEngine(network, economics.ShadowEpochEngineConfig{ + ComputeIndex: index, ComputeScarcity: economics.DefaultComputeScarcityConfig(), + Monetary: economics.DefaultShadowPolicy(), ComputeFeedback: economics.DefaultComputeFeedbackPolicy(economics.ComputeFeedbackObserveOnly), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomicEpochs(engine, 1, economics.MonetaryBalanceSnapshot{TotalSupply: 1_000, BaseFee: 1}); err != ErrRuntimeConfig { + t.Fatalf("one-block shadow epoch accepted: %v", err) + } +} + +func schedulerValidatorSet(t *testing.T, network types.NetworkID) (*ecdsa.PrivateKey, v2consensus.ValidatorSet, types.Hash) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKey := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + validators := v2consensus.ValidatorSet{ + Network: network, + Validators: []v2consensus.Validator{{ + ID: types.ValidatorIDFromPublicKey(publicKey), PublicKey: publicKey, Power: 10, + }}, + } + root, err := validators.Root() + if err != nil { + t.Fatal(err) + } + return key, validators, root +} + +func commitEmptySchedulerBlock(t *testing.T, runtime *Runtime, key *ecdsa.PrivateKey, validators v2consensus.ValidatorSet, height uint64) { + t.Helper() + candidate, err := runtime.BuildCandidate(height, nil) + if err != nil { + t.Fatal(err) + } + commitSchedulerCandidate(t, runtime, key, validators, candidate) +} + +func commitSchedulerCandidate(t *testing.T, runtime *Runtime, key *ecdsa.PrivateKey, validators v2consensus.ValidatorSet, candidate Candidate) { + t.Helper() + proposal, err := v2consensus.SignProposal(key, candidate.Header, 0) + if err != nil { + t.Fatal(err) + } + vote, err := v2consensus.SignVote(key, runtime.Network, candidate.Header.Height, 0, v2consensus.HeaderConsensusHash(candidate.Header)) + if err != nil { + t.Fatal(err) + } + certificate, err := validators.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + if _, err := runtime.Commit(candidate, certificate, validators); err != nil { + t.Fatal(err) + } +} diff --git a/internal/v2/node/economics_runtime.go b/internal/v2/node/economics_runtime.go new file mode 100644 index 00000000..e1cda6ef --- /dev/null +++ b/internal/v2/node/economics_runtime.go @@ -0,0 +1,99 @@ +package node + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" +) + +// EnableShadowEconomics attaches finalized economic telemetry to the v2 runtime. +// It is intentionally genesis-only for now: attaching a collector after blocks +// have finalized would require authenticated historical replay first. The node +// owns an independent deep copy so callers cannot mutate telemetry outside the +// runtime synchronization boundary. +func (r *Runtime) EnableShadowEconomics(collector *economics.EpochCollector) error { + if r == nil || collector == nil { + return ErrRuntimeConfig + } + r.mu.Lock() + defer r.mu.Unlock() + if r.Height != 0 || r.economicCollector != nil || collector.ShardCount() != r.ShardCount || collector.NativeTokenID() != r.NativeToken { + return ErrRuntimeConfig + } + owned := collector.Clone() + if owned == nil { + return ErrRuntimeConfig + } + r.economicCollector = owned + return nil +} + +// EnableShadowEconomicEpochs enables automatic shadow epoch closure. The +// closed epoch is evaluated only after its boundary block finalizes; the +// resulting MonetaryEpochState is then inserted into the first consensus +// candidate of the next epoch. No live minting occurs. +// +// Epoch length is deliberately at least two blocks so accepting the previous +// epoch object and closing the current epoch are separate consensus heights. +func (r *Runtime) EnableShadowEconomicEpochs(engine *economics.ShadowEpochEngine, epochLength uint64, balances economics.MonetaryBalanceSnapshot) error { + if r == nil || engine == nil { + return ErrRuntimeConfig + } + r.mu.Lock() + defer r.mu.Unlock() + if r.Height != 0 || r.economicCollector == nil || r.economicEngine != nil || r.pendingEconomic != nil || + r.economicCollector.Epoch() != 1 || engine.Network != r.Network || epochLength < 2 || + balances.TotalSupply == 0 || balances.StakedSupply > balances.TotalSupply || + balances.ProtocolReserve > balances.TotalSupply || balances.BaseFee == 0 { + return ErrRuntimeConfig + } + owned := engine.Clone() + if owned == nil { + return ErrRuntimeConfig + } + r.economicEngine = owned + r.economicEpochLength = epochLength + r.economicBalances = balances + return nil +} + +// EconomicEpochSnapshot returns the current shadow epoch inputs derived only +// from successfully finalized Runtime.Commit calls. +func (r *Runtime) EconomicEpochSnapshot() ([]economics.ShardEpochMetrics, []compute.VerifiedWork, error) { + if r == nil { + return nil, nil, ErrRuntimeConfig + } + r.mu.Lock() + defer r.mu.Unlock() + if r.economicCollector == nil { + return nil, nil, ErrRuntimeConfig + } + return r.economicCollector.FinalizeEpoch() +} + +// PendingEconomicState returns the epoch state waiting to be committed through +// the next normal v2 candidate, if any. +func (r *Runtime) PendingEconomicState() (economics.MonetaryEpochState, bool) { + if r == nil { + return economics.MonetaryEpochState{}, false + } + r.mu.Lock() + defer r.mu.Unlock() + if r.pendingEconomic == nil { + return economics.MonetaryEpochState{}, false + } + return r.pendingEconomic.State, true +} + +// FinalizedEconomicState returns the last shadow monetary state accepted by a +// consensus-finalized candidate. +func (r *Runtime) FinalizedEconomicState() (economics.MonetaryEpochState, bool) { + if r == nil { + return economics.MonetaryEpochState{}, false + } + r.mu.Lock() + defer r.mu.Unlock() + if r.economicEngine == nil { + return economics.MonetaryEpochState{}, false + } + return r.economicEngine.PreviousState() +} diff --git a/internal/v2/node/economics_runtime_ownership_test.go b/internal/v2/node/economics_runtime_ownership_test.go new file mode 100644 index 00000000..dc8a0d33 --- /dev/null +++ b/internal/v2/node/economics_runtime_ownership_test.go @@ -0,0 +1,39 @@ +package node + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestRuntimeOwnsIndependentEconomicsCollector(t *testing.T) { + network := types.NetworkID{1} + native := types.TokenID{2} + runtime, err := NewRuntime(network, native, types.Hash{3}, map[uint32]worldstate.Backend{0: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + collector, err := economics.NewEpochCollector(economics.EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: native, + InitialCirculatingSupply: map[uint32]uint64{0: 1_000}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 100}, + VelocityPolicy: economics.VelocityPolicy{ + MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000, + }, + FeePolicy: economics.CompatibilityFeePolicy(), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomics(collector); err != nil { + t.Fatal(err) + } + if err := collector.AdvanceEpoch(2); err != nil { + t.Fatal(err) + } + if runtime.economicCollector == nil || runtime.economicCollector.Epoch() != 1 { + t.Fatalf("external collector mutation leaked into runtime: runtime epoch=%d", runtime.economicCollector.Epoch()) + } +} diff --git a/internal/v2/node/economics_runtime_test.go b/internal/v2/node/economics_runtime_test.go new file mode 100644 index 00000000..1f3ae255 --- /dev/null +++ b/internal/v2/node/economics_runtime_test.go @@ -0,0 +1,119 @@ +package node + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestRuntimeEconomicsAdvancesOnlyAfterValidQCCommit(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("economics-runtime"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + validatorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + validatorPub := elliptic.Marshal(elliptic.P256(), validatorKey.PublicKey.X, validatorKey.PublicKey.Y) + validators := v2consensus.ValidatorSet{ + Network: network, + Validators: []v2consensus.Validator{{ + ID: types.ValidatorIDFromPublicKey(validatorPub), PublicKey: validatorPub, Power: 10, + }}, + } + validatorRoot, err := validators.Root() + if err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + collector, err := economics.NewEpochCollector(economics.EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: native, + InitialCirculatingSupply: map[uint32]uint64{0: 1_000}, + OpeningComputeBacklog: map[uint32]uint64{}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 100}, + VelocityPolicy: economics.VelocityPolicy{ + MinAgeBlocks: 1, FullWeightAgeBlocks: 10, MaxVelocityBps: 10_000, + }, + FeePolicy: economics.CompatibilityFeePolicy(), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomics(collector); err != nil { + t.Fatal(err) + } + + candidate, err := runtime.BuildCandidate(1, nil) + if err != nil { + t.Fatal(err) + } + proposal, err := v2consensus.SignProposal(validatorKey, candidate.Header, 0) + if err != nil { + t.Fatal(err) + } + vote, err := v2consensus.SignVote(validatorKey, network, 1, 0, v2consensus.HeaderConsensusHash(candidate.Header)) + if err != nil { + t.Fatal(err) + } + certificate, err := validators.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + if _, err := runtime.Commit(candidate, certificate, validators); err != nil { + t.Fatal(err) + } + metrics, _, err := runtime.EconomicEpochSnapshot() + if err != nil { + t.Fatal(err) + } + if len(metrics) != 1 || metrics[0].ResourceCapacity != 100 || metrics[0].CirculatingNativeSupply != 1_000 { + t.Fatalf("unexpected finalized economics: %#v", metrics) + } + + candidate2, err := runtime.BuildCandidate(2, nil) + if err != nil { + t.Fatal(err) + } + if _, err := runtime.Commit(candidate2, v2consensus.Certificate{}, validators); err == nil { + t.Fatal("invalid certificate unexpectedly committed") + } + after, _, err := runtime.EconomicEpochSnapshot() + if err != nil { + t.Fatal(err) + } + if after[0].ResourceCapacity != metrics[0].ResourceCapacity { + t.Fatalf("failed commit advanced economics: before=%#v after=%#v", metrics[0], after[0]) + } +} + +func TestRuntimeRejectsMismatchedEconomicsCollector(t *testing.T) { + network := types.NetworkID{1} + native := types.TokenID{2} + runtime, err := NewRuntime(network, native, types.Hash{3}, map[uint32]worldstate.Backend{0: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + collector, err := economics.NewEpochCollector(economics.EpochCollectorConfig{ + Epoch: 1, ShardCount: 1, NativeToken: types.TokenID{9}, + InitialCirculatingSupply: map[uint32]uint64{0: 1}, + ResourceCapacityPerBlock: map[uint32]uint64{0: 1}, + VelocityPolicy: economics.VelocityPolicy{ + MinAgeBlocks: 1, FullWeightAgeBlocks: 1, MaxVelocityBps: 1, + }, + FeePolicy: economics.CompatibilityFeePolicy(), + }) + if err != nil { + t.Fatal(err) + } + if err := runtime.EnableShadowEconomics(collector); err != ErrRuntimeConfig { + t.Fatalf("mismatched collector accepted: %v", err) + } +} diff --git a/internal/v2/node/global_commit_economics_test.go b/internal/v2/node/global_commit_economics_test.go new file mode 100644 index 00000000..9a4a118e --- /dev/null +++ b/internal/v2/node/global_commit_economics_test.go @@ -0,0 +1,80 @@ +package node + +import ( + "path/filepath" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestGlobalCommitJournalRestoresShadowEconomicsAcrossEpochBoundary(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("global-journal-economics"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + store := worldstate.NewMemory() + journalPath := filepath.Join(t.TempDir(), "global-commit.journal") + + runtime, engineConfig := newCheckpointEconomicsRuntime(t, network, native, validatorRoot, store) + if err := runtime.EnableGlobalCommitJournal(journalPath); err != nil { + t.Fatal(err) + } + commitEmptySchedulerBlock(t, runtime, key, validators, 1) + + restarted1, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, 1) + if err != nil { + t.Fatal(err) + } + if err := restarted1.EnableGlobalCommitJournal(journalPath); err != nil { + t.Fatal(err) + } + if err := restarted1.RecoverGlobalCommitJournal(validators, nil, engineConfig); err != nil { + t.Fatal(err) + } + metrics, _, err := restarted1.EconomicEpochSnapshot() + if err != nil { + t.Fatal(err) + } + if len(metrics) != 1 || metrics[0].Epoch != 1 || metrics[0].ResourceCapacity != 100 { + t.Fatalf("journal did not restore mid-epoch economics: %#v", metrics) + } + + commitEmptySchedulerBlock(t, restarted1, key, validators, 2) + pendingBefore, ok := restarted1.PendingEconomicState() + if !ok || pendingBefore.Epoch != 1 || !pendingBefore.Shadow { + t.Fatalf("epoch boundary did not produce pending shadow state: %#v", pendingBefore) + } + if _, exists := store.GetObject(economics.MonetaryStateObjectID(network)); exists { + t.Fatal("pending economic state entered world state before next consensus candidate") + } + + restarted2, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: store}, 1) + if err != nil { + t.Fatal(err) + } + if err := restarted2.EnableGlobalCommitJournal(journalPath); err != nil { + t.Fatal(err) + } + if err := restarted2.RecoverGlobalCommitJournal(validators, nil, engineConfig); err != nil { + t.Fatal(err) + } + pendingAfter, ok := restarted2.PendingEconomicState() + if !ok || pendingAfter != pendingBefore { + t.Fatalf("pending shadow state changed across global journal restart: %#v != %#v", pendingAfter, pendingBefore) + } + + candidate, err := restarted2.BuildCandidate(3, nil) + if err != nil { + t.Fatal(err) + } + commitSchedulerCandidate(t, restarted2, key, validators, candidate) + finalized, ok := restarted2.FinalizedEconomicState() + if !ok || finalized != pendingBefore { + t.Fatalf("restored pending economics did not finalize: %#v", finalized) + } + objectState, exists := store.GetObject(economics.MonetaryStateObjectID(network)) + if !exists || objectState.Version != 1 { + t.Fatal("consensus-finalized monetary object missing after recovered epoch") + } +} diff --git a/internal/v2/node/global_commit_journal.go b/internal/v2/node/global_commit_journal.go new file mode 100644 index 00000000..29cc349e --- /dev/null +++ b/internal/v2/node/global_commit_journal.go @@ -0,0 +1,430 @@ +package node + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "path/filepath" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + globalCommitJournalVersion uint16 = 1 + globalCommitJournalMagic = "ZGC2" + globalCommitPreparing uint8 = 1 + globalCommitCommitted uint8 = 2 + maxGlobalCommitJournalBytes = 256 << 20 + maxJournalObjects = 1_000_000 +) + +var ErrGlobalCommitJournal = errors.New("invalid Zephyr v2 global commit journal") + +type journalShardDelta struct { + ShardID uint32 + PreRoot types.Hash + PostRoot types.Hash + Consumed []types.ObjectID + Created []object.Object +} + +type globalCommitIntent struct { + Status uint8 + Network types.NetworkID + NativeToken types.TokenID + ValidatorRoot types.Hash + ShardCount uint32 + PreHeight uint64 + PreParentHash types.Hash + Header sharding.GlobalHeader + Certificate v2consensus.Certificate + Commitments []sharding.Commitment + Deltas []journalShardDelta + EconomicCheckpoint []byte +} + +func (r *Runtime) buildGlobalCommitIntent(candidate Candidate, certificate v2consensus.Certificate, commitments map[uint32]sharding.Commitment, economicCheckpoint []byte) (globalCommitIntent, error) { + if r == nil || candidate.Header.Height != r.Height+1 || candidate.Header.ParentHash != r.ParentHash || len(commitments) != int(r.ShardCount) { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + intent := globalCommitIntent{ + Status: globalCommitPreparing, Network: r.Network, NativeToken: r.NativeToken, + ValidatorRoot: r.ValidatorRoot, ShardCount: r.ShardCount, PreHeight: r.Height, + PreParentHash: r.ParentHash, Header: candidate.Header, Certificate: certificate, + Commitments: append([]sharding.Commitment(nil), candidate.Commitments...), + EconomicCheckpoint: append([]byte(nil), economicCheckpoint...), + } + shards := make([]int, 0, len(candidate.deltas)) + for shard := range candidate.deltas { + shards = append(shards, int(shard)) + } + sort.Ints(shards) + for _, shardValue := range shards { + shard := uint32(shardValue) + commitment, ok := commitments[shard] + if !ok { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + delta := candidate.deltas[shard] + intent.Deltas = append(intent.Deltas, journalShardDelta{ + ShardID: shard, PreRoot: r.States[shard].Root(), PostRoot: commitment.StateRoot, + Consumed: append([]types.ObjectID(nil), delta.Consumed...), + Created: cloneJournalObjects(delta.Created), + }) + } + if err := intent.Validate(); err != nil { + return globalCommitIntent{}, err + } + return intent, nil +} + +func (i globalCommitIntent) Validate() error { + if (i.Status != globalCommitPreparing && i.Status != globalCommitCommitted) || + types.IsZero32([32]byte(i.Network)) || types.IsZero32([32]byte(i.NativeToken)) || + types.IsZero32([32]byte(i.ValidatorRoot)) || i.ShardCount == 0 || + i.Header.Network != i.Network || i.Header.Height != i.PreHeight+1 || i.Header.ParentHash != i.PreParentHash || + i.Header.ValidatorRoot != i.ValidatorRoot || i.Certificate.Network != i.Network || + i.Certificate.Height != i.Header.Height || i.Certificate.HeaderHash != v2consensus.HeaderConsensusHash(i.Header) || + len(i.Commitments) != int(i.ShardCount) || len(i.Deltas) > int(i.ShardCount) || + len(i.EconomicCheckpoint) > maxEconomicCheckpointBytes+8+32 { + return ErrGlobalCommitJournal + } + root, err := sharding.CommitmentRoot(i.Commitments) + if err != nil || root != i.Header.ShardCommitmentRoot { + return ErrGlobalCommitJournal + } + commitments := make(map[uint32]sharding.Commitment, i.ShardCount) + for _, commitment := range i.Commitments { + if commitment.ShardID >= i.ShardCount { + return ErrGlobalCommitJournal + } + if _, duplicate := commitments[commitment.ShardID]; duplicate { + return ErrGlobalCommitJournal + } + commitments[commitment.ShardID] = commitment + } + seenDelta := make(map[uint32]struct{}, len(i.Deltas)) + for _, delta := range i.Deltas { + if delta.ShardID >= i.ShardCount || len(delta.Consumed) > maxJournalObjects || len(delta.Created) > maxJournalObjects || + types.IsZero32([32]byte(delta.PreRoot)) || types.IsZero32([32]byte(delta.PostRoot)) { + return ErrGlobalCommitJournal + } + if _, duplicate := seenDelta[delta.ShardID]; duplicate { + return ErrGlobalCommitJournal + } + seenDelta[delta.ShardID] = struct{}{} + commitment, ok := commitments[delta.ShardID] + if !ok || commitment.StateRoot != delta.PostRoot { + return ErrGlobalCommitJournal + } + for _, id := range delta.Consumed { + if types.IsZero32([32]byte(id)) { + return ErrGlobalCommitJournal + } + } + for _, created := range delta.Created { + if created.Validate() != nil { + return ErrGlobalCommitJournal + } + } + } + return nil +} + +func (i globalCommitIntent) MarshalBinary() ([]byte, error) { + if err := i.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.U16(globalCommitJournalVersion) + w.U8(i.Status) + w.Fixed(i.Network[:]) + w.Fixed(i.NativeToken[:]) + w.Fixed(i.ValidatorRoot[:]) + w.U32(i.ShardCount) + w.U64(i.PreHeight) + w.Fixed(i.PreParentHash[:]) + headerRaw, err := i.Header.MarshalBinary() + if err != nil { + return nil, err + } + w.Bytes(headerRaw) + certificateRaw, err := v2consensus.MarshalCertificate(i.Certificate) + if err != nil { + return nil, err + } + w.Bytes(certificateRaw) + + orderedCommitments := append([]sharding.Commitment(nil), i.Commitments...) + sort.Slice(orderedCommitments, func(a, b int) bool { return orderedCommitments[a].ShardID < orderedCommitments[b].ShardID }) + w.U32(uint32(len(orderedCommitments))) + for _, commitment := range orderedCommitments { + w.U32(commitment.ShardID) + w.Fixed(commitment.StateRoot[:]) + w.Fixed(commitment.ReceiptRoot[:]) + w.Fixed(commitment.DataRoot[:]) + } + + orderedDeltas := append([]journalShardDelta(nil), i.Deltas...) + sort.Slice(orderedDeltas, func(a, b int) bool { return orderedDeltas[a].ShardID < orderedDeltas[b].ShardID }) + w.U32(uint32(len(orderedDeltas))) + for _, delta := range orderedDeltas { + w.U32(delta.ShardID) + w.Fixed(delta.PreRoot[:]) + w.Fixed(delta.PostRoot[:]) + w.U32(uint32(len(delta.Consumed))) + for _, id := range delta.Consumed { + w.Fixed(id[:]) + } + w.U32(uint32(len(delta.Created))) + for _, created := range delta.Created { + w.Bytes(created.CanonicalBytes()) + } + } + w.Bytes(i.EconomicCheckpoint) + return w.BytesCopy(), nil +} + +func parseGlobalCommitIntent(data []byte) (globalCommitIntent, error) { + r := codec.NewReader(data) + version, err := r.U16() + if err != nil || version != globalCommitJournalVersion { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + intent := globalCommitIntent{} + intent.Status, err = r.U8() + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + networkRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(intent.Network[:], networkRaw) + nativeRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(intent.NativeToken[:], nativeRaw) + validatorRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(intent.ValidatorRoot[:], validatorRaw) + intent.ShardCount, err = r.U32() + if err != nil || intent.ShardCount == 0 || intent.ShardCount > 1_000_000 { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + intent.PreHeight, err = r.U64() + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + parentRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(intent.PreParentHash[:], parentRaw) + headerRaw, err := r.Bytes(1 << 20) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + intent.Header, err = sharding.ParseGlobalHeader(headerRaw) + if err != nil { + return globalCommitIntent{}, err + } + certificateRaw, err := r.Bytes(64 << 20) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + intent.Certificate, err = v2consensus.ParseCertificate(certificateRaw) + if err != nil { + return globalCommitIntent{}, err + } + + commitmentCount, err := r.U32() + if err != nil || commitmentCount != intent.ShardCount { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + intent.Commitments = make([]sharding.Commitment, int(commitmentCount)) + for index := range intent.Commitments { + intent.Commitments[index].ShardID, err = r.U32() + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + stateRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(intent.Commitments[index].StateRoot[:], stateRaw) + receiptRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(intent.Commitments[index].ReceiptRoot[:], receiptRaw) + dataRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(intent.Commitments[index].DataRoot[:], dataRaw) + } + + deltaCount, err := r.U32() + if err != nil || deltaCount > intent.ShardCount { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + intent.Deltas = make([]journalShardDelta, int(deltaCount)) + for index := range intent.Deltas { + delta := &intent.Deltas[index] + delta.ShardID, err = r.U32() + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + preRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(delta.PreRoot[:], preRaw) + postRaw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(delta.PostRoot[:], postRaw) + consumedCount, err := r.U32() + if err != nil || consumedCount > maxJournalObjects { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + delta.Consumed = make([]types.ObjectID, int(consumedCount)) + for consumedIndex := range delta.Consumed { + raw, err := r.Fixed(32) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + copy(delta.Consumed[consumedIndex][:], raw) + } + createdCount, err := r.U32() + if err != nil || createdCount > maxJournalObjects { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + delta.Created = make([]object.Object, int(createdCount)) + for createdIndex := range delta.Created { + raw, err := r.Bytes(object.MaxObjectDataBytes + 128) + if err != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + created, err := object.ParseObject(raw) + if err != nil { + return globalCommitIntent{}, err + } + delta.Created[createdIndex] = created + } + } + intent.EconomicCheckpoint, err = r.Bytes(maxEconomicCheckpointBytes + 8 + 32) + if err != nil || r.Done() != nil { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + if err := intent.Validate(); err != nil { + return globalCommitIntent{}, err + } + return intent, nil +} + +func encodeGlobalCommitJournal(intent globalCommitIntent) ([]byte, error) { + payload, err := intent.MarshalBinary() + if err != nil || len(payload) == 0 || len(payload) > maxGlobalCommitJournalBytes { + return nil, ErrGlobalCommitJournal + } + digest := codec.DomainHash("zephyr/global-commit-journal/v2", payload) + var out bytes.Buffer + out.WriteString(globalCommitJournalMagic) + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(payload))) + out.Write(length[:]) + out.Write(payload) + out.Write(digest[:]) + return out.Bytes(), nil +} + +func decodeGlobalCommitJournal(data []byte) (globalCommitIntent, error) { + if len(data) < 8+32 || len(data) > maxGlobalCommitJournalBytes+8+32 || string(data[:4]) != globalCommitJournalMagic { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + length := binary.BigEndian.Uint32(data[4:8]) + if length == 0 || int(length) > maxGlobalCommitJournalBytes || len(data) != 8+int(length)+32 { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + payload := data[8 : 8+int(length)] + digest := codec.DomainHash("zephyr/global-commit-journal/v2", payload) + if !bytes.Equal(digest[:], data[8+int(length):]) { + return globalCommitIntent{}, ErrGlobalCommitJournal + } + return parseGlobalCommitIntent(payload) +} + +func writeGlobalCommitJournal(path string, intent globalCommitIntent) error { + raw, err := encodeGlobalCommitJournal(intent) + if err != nil { + return err + } + return writeAtomicGlobalCommitFile(path, raw) +} + +func readGlobalCommitJournal(path string) (globalCommitIntent, error) { + raw, err := os.ReadFile(path) + if err != nil { + return globalCommitIntent{}, err + } + return decodeGlobalCommitJournal(raw) +} + +func writeAtomicGlobalCommitFile(path string, raw []byte) error { + if path == "" || len(raw) == 0 || len(raw) > maxGlobalCommitJournalBytes+8+32 { + return ErrGlobalCommitJournal + } + dir := filepath.Dir(path) + base := filepath.Base(path) + tmp, err := os.OpenFile(filepath.Join(dir, "."+base+".tmp"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err = tmp.Write(raw); err == nil { + err = tmp.Sync() + } + closeErr := tmp.Close() + if err == nil { + err = closeErr + } + if err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Chmod(tmpPath, 0o600); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + dirHandle, err := os.Open(dir) + if err != nil { + return err + } + defer dirHandle.Close() + return dirHandle.Sync() +} + +func cloneJournalObjects(source []object.Object) []object.Object { + out := make([]object.Object, len(source)) + for index, item := range source { + out[index] = item + out[index].Data = append([]byte(nil), item.Data...) + } + return out +} diff --git a/internal/v2/node/global_commit_journal_test.go b/internal/v2/node/global_commit_journal_test.go new file mode 100644 index 00000000..2e69f1ac --- /dev/null +++ b/internal/v2/node/global_commit_journal_test.go @@ -0,0 +1,67 @@ +package node + +import ( + "bytes" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestGlobalCommitJournalRoundTripAndTamperRejection(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("global-journal"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + state0 := worldstate.NewMemory() + state1 := worldstate.NewMemory() + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: state0, 1: state1}, 1) + if err != nil { + t.Fatal(err) + } + old0 := systemObjectForShard(0, 1, "journal-old-0") + old1 := systemObjectForShard(1, 1, "journal-old-1") + if _, err := state0.Apply(nil, []object.Object{old0}); err != nil { + t.Fatal(err) + } + if _, err := state1.Apply(nil, []object.Object{old1}); err != nil { + t.Fatal(err) + } + candidate := manualCandidateForDeltas(t, runtime, map[uint32]shardDelta{ + 0: {Consumed: []types.ObjectID{old0.ID}, Created: []object.Object{systemObjectForShard(0, 2, "journal-new-0")}}, + 1: {Consumed: []types.ObjectID{old1.ID}, Created: []object.Object{systemObjectForShard(1, 2, "journal-new-1")}}, + }) + certificate := schedulerCertificate(t, runtime, key, validators, candidate) + commitments, _, err := runtime.preflightCandidateState(candidate) + if err != nil { + t.Fatal(err) + } + intent, err := runtime.buildGlobalCommitIntent(candidate, certificate, commitments, []byte("economic-checkpoint")) + if err != nil { + t.Fatal(err) + } + raw, err := encodeGlobalCommitJournal(intent) + if err != nil { + t.Fatal(err) + } + parsed, err := decodeGlobalCommitJournal(raw) + if err != nil { + t.Fatal(err) + } + rawAgain, err := encodeGlobalCommitJournal(parsed) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw, rawAgain) { + t.Fatal("global commit journal is not canonical across round trip") + } + if parsed.Header != intent.Header || parsed.Certificate.Hash() != intent.Certificate.Hash() || len(parsed.Deltas) != 2 { + t.Fatalf("global commit journal lost certified state: %#v", parsed) + } + + tampered := append([]byte(nil), raw...) + tampered[len(tampered)/2] ^= 0xff + if _, err := decodeGlobalCommitJournal(tampered); err == nil { + t.Fatal("tampered global commit journal was accepted") + } +} diff --git a/internal/v2/node/global_commit_recovery.go b/internal/v2/node/global_commit_recovery.go new file mode 100644 index 00000000..6b7e6b83 --- /dev/null +++ b/internal/v2/node/global_commit_recovery.go @@ -0,0 +1,177 @@ +package node + +import ( + "errors" + "os" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +// EnableGlobalCommitJournal configures the crash-recovery journal used to make +// a QC-authorized multi-shard state transition recoverable across process or I/O +// failure. If a journal already exists, the runtime fails closed until +// RecoverGlobalCommitJournal validates it. +func (r *Runtime) EnableGlobalCommitJournal(path string) error { + if r == nil || path == "" { + return ErrRuntimeConfig + } + r.mu.Lock() + defer r.mu.Unlock() + if r.globalCommitJournalPath != "" { + return ErrRuntimeConfig + } + r.globalCommitJournalPath = path + if info, err := os.Stat(path); err == nil { + if info.Size() > 0 { + r.recoveryRequired = true + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// RecoverGlobalCommitJournal verifies the certified commit intent and repairs a +// PREPARING journal by applying only shards still at their exact pre-root. A +// shard already at its post-root is never applied twice. A COMMITTED journal +// requires every shard to already be at its post-root. +func (r *Runtime) RecoverGlobalCommitJournal(validators v2consensus.ValidatorSet, registry *compute.WorkRegistry, engineConfig economics.ShadowEpochEngineConfig) error { + if r == nil { + return ErrGlobalCommitJournal + } + r.mu.Lock() + defer r.mu.Unlock() + if r.globalCommitJournalPath == "" { + return ErrGlobalCommitJournal + } + intent, err := readGlobalCommitJournal(r.globalCommitJournalPath) + if err != nil { + return err + } + if intent.Network != r.Network || intent.NativeToken != r.NativeToken || intent.ValidatorRoot != r.ValidatorRoot || intent.ShardCount != r.ShardCount { + return ErrGlobalCommitJournal + } + validatorRoot, err := validators.Root() + if err != nil || validatorRoot != r.ValidatorRoot || validators.Network != r.Network { + return ErrGlobalCommitJournal + } + if err := validators.VerifyCertificate(intent.Certificate); err != nil { + return err + } + if intent.Certificate.HeaderHash != v2consensus.HeaderConsensusHash(intent.Header) || intent.Certificate.Height != intent.Header.Height { + return ErrGlobalCommitJournal + } + postParent := v2consensus.HeaderConsensusHash(intent.Header) + atPreAnchor := r.Height == intent.PreHeight && r.ParentHash == intent.PreParentHash + atPostAnchor := r.Height == intent.Header.Height && r.ParentHash == postParent + unanchoredRestart := r.Height == 0 && types.IsZero32([32]byte(r.ParentHash)) + // A freshly constructed runtime has no durable consensus anchor of its own. + // In that case the certified journal is allowed to restore the anchor, but + // only after the shard loop below proves every state root is exactly at the + // journal's pre/post boundary. Any other non-matching runtime anchor fails + // closed so recovery cannot silently move an already anchored runtime. + if !atPreAnchor && !atPostAnchor && !unanchoredRestart { + return ErrGlobalCommitJournal + } + + commitments := make(map[uint32]types.Hash, intent.ShardCount) + for _, commitment := range intent.Commitments { + commitments[commitment.ShardID] = commitment.StateRoot + } + deltas := make(map[uint32]journalShardDelta, len(intent.Deltas)) + for _, delta := range intent.Deltas { + deltas[delta.ShardID] = delta + } + + for shard := uint32(0); shard < r.ShardCount; shard++ { + postRoot, ok := commitments[shard] + if !ok { + return ErrGlobalCommitJournal + } + current := r.States[shard].Root() + delta, changed := deltas[shard] + if !changed { + if current != postRoot { + return ErrGlobalCommitJournal + } + continue + } + if current == delta.PostRoot { + continue + } + if intent.Status == globalCommitCommitted || current != delta.PreRoot || postRoot != delta.PostRoot { + return ErrGlobalCommitJournal + } + simulator, ok := r.States[shard].(worldstate.Simulator) + if !ok { + return ErrStateSimulation + } + previewRoot, err := simulator.Simulate(delta.Consumed, delta.Created) + if err != nil || previewRoot != delta.PostRoot { + return ErrGlobalCommitJournal + } + appliedRoot, err := r.States[shard].Apply(delta.Consumed, delta.Created) + if err != nil || appliedRoot != delta.PostRoot { + r.recoveryRequired = true + if err != nil { + return errors.Join(ErrRuntimeRecoveryRequired, err) + } + return errors.Join(ErrRuntimeRecoveryRequired, ErrGlobalCommitJournal) + } + } + + if err := r.restoreJournalEconomics(intent.EconomicCheckpoint, registry, engineConfig, intent.Header.Height, postParent); err != nil { + return err + } + if intent.Status != globalCommitCommitted { + intent.Status = globalCommitCommitted + if err := writeGlobalCommitJournal(r.globalCommitJournalPath, intent); err != nil { + r.recoveryRequired = true + return errors.Join(ErrRuntimeRecoveryRequired, err) + } + } + r.Height = intent.Header.Height + r.ParentHash = postParent + r.recoveryRequired = false + return nil +} + +func (r *Runtime) restoreJournalEconomics(raw []byte, registry *compute.WorkRegistry, engineConfig economics.ShadowEpochEngineConfig, height uint64, parent types.Hash) error { + if len(raw) == 0 { + if r.economicCollector != nil || r.economicEngine != nil || r.pendingEconomic != nil { + return ErrGlobalCommitJournal + } + return nil + } + decoded, err := decodeEconomicCheckpoint(raw, registry, engineConfig) + if err != nil { + return err + } + if decoded.network != r.Network || decoded.nativeToken != r.NativeToken || decoded.validatorRoot != r.ValidatorRoot || + decoded.shardCount != r.ShardCount || decoded.height != height || decoded.parentHash != parent || decoded.collector.LastHeight() != height { + return ErrGlobalCommitJournal + } + r.economicCollector = decoded.collector + r.economicEngine = decoded.engine + r.pendingEconomic = decoded.pending + r.economicEpochLength = decoded.epochLength + r.economicBalances = decoded.balances + return nil +} + +func (r *Runtime) postCommitEconomicCheckpoint(collector *economics.EpochCollector, engine *economics.ShadowEpochEngine, pending *economics.ShadowEpochPreview, balances economics.MonetaryBalanceSnapshot, height uint64, parent types.Hash) ([]byte, error) { + if collector == nil { + return nil, nil + } + temporary := Runtime{ + Network: r.Network, NativeToken: r.NativeToken, ValidatorRoot: r.ValidatorRoot, + ShardCount: r.ShardCount, Height: height, ParentHash: parent, + economicCollector: collector, economicEngine: engine, + economicEpochLength: r.economicEpochLength, economicBalances: balances, pendingEconomic: pending, + } + return temporary.economicCheckpointBytesLocked() +} diff --git a/internal/v2/node/global_commit_recovery_test.go b/internal/v2/node/global_commit_recovery_test.go new file mode 100644 index 00000000..0a5f4242 --- /dev/null +++ b/internal/v2/node/global_commit_recovery_test.go @@ -0,0 +1,147 @@ +package node + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +type failBeforeApplyBackend struct { + *worldstate.Memory + fail bool +} + +func (b *failBeforeApplyBackend) Apply(consumed []types.ObjectID, created []object.Object) (types.Hash, error) { + if b.fail { + return b.Root(), errors.New("simulated shard storage failure before apply") + } + return b.Memory.Apply(consumed, created) +} + +func TestGlobalCommitJournalCompletesPartialMultiShardApply(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("global-recovery-partial"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + state0 := worldstate.NewMemory() + state1 := &failBeforeApplyBackend{Memory: worldstate.NewMemory()} + old0 := systemObjectForShard(0, 1, "recover-old-0") + old1 := systemObjectForShard(1, 1, "recover-old-1") + if _, err := state0.Apply(nil, []object.Object{old0}); err != nil { + t.Fatal(err) + } + if _, err := state1.Memory.Apply(nil, []object.Object{old1}); err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: state0, 1: state1}, 1) + if err != nil { + t.Fatal(err) + } + journalPath := filepath.Join(t.TempDir(), "global-commit.journal") + if err := runtime.EnableGlobalCommitJournal(journalPath); err != nil { + t.Fatal(err) + } + new0 := systemObjectForShard(0, 2, "recover-new-0") + new1 := systemObjectForShard(1, 2, "recover-new-1") + candidate := manualCandidateForDeltas(t, runtime, map[uint32]shardDelta{ + 0: {Consumed: []types.ObjectID{old0.ID}, Created: []object.Object{new0}}, + 1: {Consumed: []types.ObjectID{old1.ID}, Created: []object.Object{new1}}, + }) + state1.fail = true + commitSchedulerCandidateExpectError(t, runtime, key, validators, candidate) + if !runtime.RecoveryRequired() || runtime.Height != 0 { + t.Fatal("partial global apply did not fail-stop at the pre-commit anchor") + } + intent, err := readGlobalCommitJournal(journalPath) + if err != nil { + t.Fatal(err) + } + if intent.Status != globalCommitPreparing { + t.Fatalf("partial apply journal status = %d, want PREPARING", intent.Status) + } + if _, ok := state0.GetObject(new0.ID); !ok { + t.Fatal("first shard was not applied before injected second-shard failure") + } + if _, ok := state1.GetObject(old1.ID); !ok { + t.Fatal("second shard unexpectedly mutated before injected failure") + } + + state1.fail = false + if err := runtime.RecoverGlobalCommitJournal(validators, nil, economics.ShadowEpochEngineConfig{}); err != nil { + t.Fatal(err) + } + if runtime.RecoveryRequired() || runtime.Height != 1 { + t.Fatalf("global recovery did not advance the certified commit: height=%d recovery=%v", runtime.Height, runtime.RecoveryRequired()) + } + if _, ok := state1.GetObject(new1.ID); !ok { + t.Fatal("recovery did not complete the missing shard transition") + } + intent, err = readGlobalCommitJournal(journalPath) + if err != nil { + t.Fatal(err) + } + if intent.Status != globalCommitCommitted { + t.Fatalf("recovered journal status = %d, want COMMITTED", intent.Status) + } + if _, err := runtime.BuildCandidate(2, nil); err != nil { + t.Fatalf("runtime remained blocked after successful recovery: %v", err) + } +} + +func TestCommittedGlobalJournalRestoresRuntimeAnchorAfterRestart(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("global-recovery-committed"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + state := worldstate.NewMemory() + oldObject := systemObjectForShard(0, 1, "restart-old") + if _, err := state.Apply(nil, []object.Object{oldObject}); err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: state}, 1) + if err != nil { + t.Fatal(err) + } + journalPath := filepath.Join(t.TempDir(), "global-commit.journal") + if err := runtime.EnableGlobalCommitJournal(journalPath); err != nil { + t.Fatal(err) + } + candidate := manualCandidateForDeltas(t, runtime, map[uint32]shardDelta{ + 0: {Consumed: []types.ObjectID{oldObject.ID}, Created: []object.Object{systemObjectForShard(0, 2, "restart-new")}}, + }) + commitSchedulerCandidate(t, runtime, key, validators, candidate) + if runtime.Height != 1 { + t.Fatal("initial certified commit did not advance") + } + info, err := os.Stat(journalPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("global journal mode = %o, want 600", info.Mode().Perm()) + } + + restarted, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: state}, 1) + if err != nil { + t.Fatal(err) + } + if err := restarted.EnableGlobalCommitJournal(journalPath); err != nil { + t.Fatal(err) + } + if !restarted.RecoveryRequired() { + t.Fatal("existing committed journal did not force explicit restart recovery") + } + if err := restarted.RecoverGlobalCommitJournal(validators, nil, economics.ShadowEpochEngineConfig{}); err != nil { + t.Fatal(err) + } + if restarted.Height != 1 || restarted.ParentHash != runtime.ParentHash || restarted.RecoveryRequired() { + t.Fatalf("committed journal did not restore runtime anchor: height=%d parent=%x", restarted.Height, restarted.ParentHash) + } + if _, err := restarted.BuildCandidate(2, nil); err != nil { + t.Fatalf("restarted runtime cannot continue after journal recovery: %v", err) + } +} diff --git a/internal/v2/node/multishard_test.go b/internal/v2/node/multishard_test.go new file mode 100644 index 00000000..b7f5cda4 --- /dev/null +++ b/internal/v2/node/multishard_test.go @@ -0,0 +1,218 @@ +package node + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestTwoShardTransferFinalizesReceiptThenImportsOnce(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("two-shard"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + aliceKey, alice := accountOnShard(t, 0, 2) + _, bob := accountOnShard(t, 1, 2) + shard0 := worldstate.NewMemory() + shard1 := worldstate.NewMemory() + + inputID := types.ObjectIDForShard(types.HashBytes("genesis", []byte("alice-coin")), 0, 0) + inputOut, err := object.NewCoinOutput(alice, native, 100) + if err != nil { + t.Fatal(err) + } + input := object.Object{ID: inputID, Version: 1, Owner: alice, Kind: inputOut.Kind, Data: inputOut.Data} + root0, err := shard0.Apply(nil, []object.Object{input}) + if err != nil { + t.Fatal(err) + } + witness, proof, ok := shard0.Proof(inputID) + if !ok { + t.Fatal("missing source witness") + } + witnessHash := witness.Hash() + payment, _ := object.NewCoinOutput(bob, native, 25) + change, _ := object.NewCoinOutput(alice, native, 74) + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: root0, + Inputs: []tx.InputRef{{ObjectID: inputID, Version: 1, ObjectHash: witnessHash}}, + Outputs: []object.OutputSpec{payment, change}, Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Fee: 1, Witnesses: []tx.Witness{{Object: witness, Proof: proof}}, + } + transaction.Salt[0] = 1 + if err := transaction.Sign(aliceKey); err != nil { + t.Fatal(err) + } + + validatorKey, validators := singleValidatorSet(t, network) + validatorRoot, err := validators.Root() + if err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: shard0, 1: shard1}, 2) + if err != nil { + t.Fatal(err) + } + candidate1, err := runtime.BuildCandidate(1, map[uint32]ShardBatch{0: {Transactions: []tx.Transaction{transaction}}}) + if err != nil { + t.Fatal(err) + } + if len(candidate1.Receipts[0]) != 1 || len(candidate1.Results[0]) != 1 || len(candidate1.Results[0][0].Outbound) != 1 { + t.Fatalf("expected one cross-shard receipt: %+v", candidate1.Results[0]) + } + if shard1.Root() != candidate1.Commitments[1].StateRoot { + t.Fatal("destination state changed before receipt import") + } + _, certificate1 := certifyCandidate(t, validators, validatorKey, candidate1) + finalized1, err := runtime.Commit(candidate1, certificate1, validators) + if err != nil { + t.Fatal(err) + } + + receipt := candidate1.Receipts[0][0] + commitment, commitmentProof, err := sharding.CommitmentProof(candidate1.Commitments, 0) + if err != nil { + t.Fatal(err) + } + receiptProof, err := (sharding.ReceiptBatch{Receipts: candidate1.Receipts[0]}).Proof(receipt) + if err != nil { + t.Fatal(err) + } + importReceipt := ReceiptImport{ + Header: finalized1, Certificate: certificate1, Validators: validators, + Commitment: commitment, CommitmentProof: commitmentProof, + Receipt: receipt, ReceiptProof: receiptProof, + } + + root1Before := shard1.Root() + candidate2, err := runtime.BuildCandidate(2, map[uint32]ShardBatch{1: {Imports: []ReceiptImport{importReceipt}}}) + if err != nil { + t.Fatal(err) + } + if shard1.Root() != root1Before { + t.Fatal("receipt import mutated state before destination QC") + } + _, certificate2 := certifyCandidate(t, validators, validatorKey, candidate2) + if _, err := runtime.Commit(candidate2, certificate2, validators); err != nil { + t.Fatal(err) + } + destinationObject, err := receipt.DestinationObject() + if err != nil { + t.Fatal(err) + } + if _, ok := shard1.GetObject(destinationObject.ID); !ok { + t.Fatal("destination coin was not materialized") + } + marker, err := sharding.ReceiptMarker(receipt) + if err != nil { + t.Fatal(err) + } + if _, ok := shard1.GetObject(marker.ID); !ok { + t.Fatal("durable receipt marker was not committed") + } + if _, err := runtime.BuildCandidate(3, map[uint32]ShardBatch{1: {Imports: []ReceiptImport{importReceipt}}}); err != sharding.ErrReceiptReplay { + t.Fatalf("expected durable receipt replay rejection, got %v", err) + } +} + +func TestReceiptImportRejectsSelfSignedForeignValidatorSet(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("receipt-validator-root"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + trustedKey, trustedValidators := singleValidatorSet(t, network) + trustedRoot, err := trustedValidators.Root() + if err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(network, native, trustedRoot, map[uint32]worldstate.Backend{0: worldstate.NewMemory(), 1: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + candidate, err := runtime.BuildCandidate(1, nil) + if err != nil { + t.Fatal(err) + } + _, cert := certifyCandidate(t, trustedValidators, trustedKey, candidate) + if _, err := runtime.Commit(candidate, cert, trustedValidators); err != nil { + t.Fatal(err) + } + + attackerKey, attackerValidators := singleValidatorSet(t, network) + fakeHeader := candidate.Header + fakeHeader.Height = 1 + fakeHeader.CertificateHash = types.Hash{} + fakeHash := v2consensus.HeaderConsensusHash(fakeHeader) + fakeVote, err := v2consensus.SignVote(attackerKey, network, 1, 0, fakeHash) + if err != nil { + t.Fatal(err) + } + fakeCert := v2consensus.Certificate{Network: network, Height: 1, Round: 0, HeaderHash: fakeHash, Votes: []v2consensus.Vote{fakeVote}} + if err := attackerValidators.VerifyCertificate(fakeCert); err != nil { + t.Fatal(err) + } + fakeHeader.CertificateHash = fakeCert.Hash() + if root, _ := attackerValidators.Root(); root == fakeHeader.ValidatorRoot { + t.Fatal("test requires attacker validator root to differ") + } + + receipt := sharding.CrossShardReceipt{ + SourceShard: 0, DestinationShard: 1, SourceHeight: 1, + TransactionID: types.HashBytes("tx", []byte("fake")), OutputIndex: 0, + Output: object.OutputSpec{Owner: types.AccountIDFromPublicKey([]byte("recipient")), Kind: object.KindSystem}, + SourceStateRoot: candidate.Commitments[0].StateRoot, + } + if err := runtime.validateReceiptImport(1, ReceiptImport{Header: fakeHeader, Certificate: fakeCert, Validators: attackerValidators, Receipt: receipt}); err != ErrReceiptImport { + t.Fatalf("expected uncommitted validator-set rejection, got %v", err) + } +} + +func accountOnShard(t *testing.T, shard, shardCount uint32) (*ecdsa.PrivateKey, types.AccountID) { + t.Helper() + for i := 0; i < 10_000; i++ { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + account := types.AccountIDFromPublicKey(pub) + if types.AccountShard(account, shardCount) == shard { + return key, account + } + } + t.Fatal("could not generate account on requested shard") + return nil, types.AccountID{} +} + +func singleValidatorSet(t *testing.T, network types.NetworkID) (*ecdsa.PrivateKey, v2consensus.ValidatorSet) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + return key, v2consensus.ValidatorSet{Network: network, Validators: []v2consensus.Validator{{ID: id, PublicKey: pub, Power: 10}}} +} + +func certifyCandidate(t *testing.T, validators v2consensus.ValidatorSet, key *ecdsa.PrivateKey, candidate Candidate) (v2consensus.Proposal, v2consensus.Certificate) { + t.Helper() + proposal, err := v2consensus.SignProposal(key, candidate.Header, 0) + if err != nil { + t.Fatal(err) + } + headerHash := v2consensus.HeaderConsensusHash(candidate.Header) + vote, err := v2consensus.SignVote(key, candidate.Header.Network, candidate.Header.Height, 0, headerHash) + if err != nil { + t.Fatal(err) + } + certificate, err := validators.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + return proposal, certificate +} diff --git a/internal/v2/node/network_wire.go b/internal/v2/node/network_wire.go new file mode 100644 index 00000000..d9cb8180 --- /dev/null +++ b/internal/v2/node/network_wire.go @@ -0,0 +1,301 @@ +package node + +import ( + "errors" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + NetworkMessageProposal uint8 = 1 + NetworkMessageCommit uint8 = 2 + MaxNetworkShards = 1024 + MaxNetworkTransactions = 65536 + MaxNetworkImports = 65536 + MaxNetworkMessageBytes = 64 << 20 +) + +var ErrNetworkWire = errors.New("invalid v2 node network message") + +type BlockData struct { + Batches map[uint32]ShardBatch +} + +type ConsensusMessage struct { + Kind uint8 + Proposal v2consensus.Proposal + Block BlockData + Certificate *v2consensus.Certificate +} + +func (b BlockData) MarshalBinary() ([]byte, error) { + if len(b.Batches) > MaxNetworkShards { + return nil, ErrNetworkWire + } + shards := make([]int, 0, len(b.Batches)) + for shard := range b.Batches { + shards = append(shards, int(shard)) + } + sort.Ints(shards) + var w codec.Writer + w.U32(uint32(len(shards))) + for _, shardValue := range shards { + shard := uint32(shardValue) + batch := b.Batches[shard] + if len(batch.Transactions) > MaxNetworkTransactions || len(batch.Imports) > MaxNetworkImports { + return nil, ErrNetworkWire + } + w.U32(shard) + w.Fixed(batch.DataRoot[:]) + w.U32(uint32(len(batch.Transactions))) + for _, transaction := range batch.Transactions { + raw, err := transaction.MarshalBinary() + if err != nil { + return nil, err + } + w.Bytes(raw) + } + w.U32(uint32(len(batch.Imports))) + for _, receiptImport := range batch.Imports { + raw, err := receiptImport.MarshalBinary() + if err != nil { + return nil, err + } + w.Bytes(raw) + } + } + payload := w.BytesCopy() + if len(payload) > MaxNetworkMessageBytes { + return nil, ErrNetworkWire + } + return payload, nil +} + +func ParseBlockData(data []byte) (BlockData, error) { + if len(data) > MaxNetworkMessageBytes { + return BlockData{}, ErrNetworkWire + } + r := codec.NewReader(data) + count, err := r.U32() + if err != nil || count > MaxNetworkShards { + return BlockData{}, ErrNetworkWire + } + out := BlockData{Batches: make(map[uint32]ShardBatch, int(count))} + for i := uint32(0); i < count; i++ { + shard, err := r.U32() + if err != nil { + return BlockData{}, ErrNetworkWire + } + if _, duplicate := out.Batches[shard]; duplicate { + return BlockData{}, ErrNetworkWire + } + rootRaw, err := r.Fixed(32) + if err != nil { + return BlockData{}, ErrNetworkWire + } + var dataRoot types.Hash + copy(dataRoot[:], rootRaw) + txCount, err := r.U32() + if err != nil || txCount > MaxNetworkTransactions { + return BlockData{}, ErrNetworkWire + } + batch := ShardBatch{Transactions: make([]tx.Transaction, int(txCount)), DataRoot: dataRoot} + for j := range batch.Transactions { + raw, err := r.Bytes(tx.MaxWireBytes) + if err != nil { + return BlockData{}, ErrNetworkWire + } + batch.Transactions[j], err = tx.ParseTransaction(raw) + if err != nil { + return BlockData{}, err + } + } + importCount, err := r.U32() + if err != nil || importCount > MaxNetworkImports { + return BlockData{}, ErrNetworkWire + } + batch.Imports = make([]ReceiptImport, int(importCount)) + for j := range batch.Imports { + raw, err := r.Bytes(MaxNetworkMessageBytes) + if err != nil { + return BlockData{}, ErrNetworkWire + } + batch.Imports[j], err = ParseReceiptImport(raw) + if err != nil { + return BlockData{}, err + } + } + out.Batches[shard] = batch + } + if r.Done() != nil { + return BlockData{}, ErrNetworkWire + } + return out, nil +} + +func (m ConsensusMessage) MarshalBinary() ([]byte, error) { + if m.Kind != NetworkMessageProposal && m.Kind != NetworkMessageCommit { + return nil, ErrNetworkWire + } + proposal, err := m.Proposal.MarshalBinary() + if err != nil { + return nil, err + } + block, err := m.Block.MarshalBinary() + if err != nil { + return nil, err + } + var w codec.Writer + w.U8(m.Kind) + w.Bytes(proposal) + w.Bytes(block) + if m.Kind == NetworkMessageCommit { + if m.Certificate == nil { + return nil, ErrNetworkWire + } + certificate, err := m.Certificate.MarshalBinary() + if err != nil { + return nil, err + } + w.Bytes(certificate) + } + payload := w.BytesCopy() + if len(payload) > MaxNetworkMessageBytes { + return nil, ErrNetworkWire + } + return payload, nil +} + +func ParseConsensusMessage(data []byte) (ConsensusMessage, error) { + if len(data) == 0 || len(data) > MaxNetworkMessageBytes { + return ConsensusMessage{}, ErrNetworkWire + } + r := codec.NewReader(data) + kind, err := r.U8() + if err != nil || (kind != NetworkMessageProposal && kind != NetworkMessageCommit) { + return ConsensusMessage{}, ErrNetworkWire + } + proposalRaw, err := r.Bytes(2048) + if err != nil { + return ConsensusMessage{}, ErrNetworkWire + } + proposal, err := v2consensus.ParseProposal(proposalRaw) + if err != nil { + return ConsensusMessage{}, err + } + blockRaw, err := r.Bytes(MaxNetworkMessageBytes) + if err != nil { + return ConsensusMessage{}, ErrNetworkWire + } + block, err := ParseBlockData(blockRaw) + if err != nil { + return ConsensusMessage{}, err + } + message := ConsensusMessage{Kind: kind, Proposal: proposal, Block: block} + if kind == NetworkMessageCommit { + certificateRaw, err := r.Bytes(4 << 20) + if err != nil { + return ConsensusMessage{}, ErrNetworkWire + } + certificate, err := v2consensus.ParseCertificate(certificateRaw) + if err != nil { + return ConsensusMessage{}, err + } + message.Certificate = &certificate + } + if r.Done() != nil { + return ConsensusMessage{}, ErrNetworkWire + } + return message, nil +} + +func (r ReceiptImport) MarshalBinary() ([]byte, error) { + certificate, err := r.Certificate.MarshalBinary() + if err != nil { + return nil, err + } + validators, err := r.Validators.MarshalBinary() + if err != nil { + return nil, err + } + receipt, err := r.Receipt.CanonicalBytes() + if err != nil { + return nil, err + } + var w codec.Writer + w.Bytes(r.Header.CanonicalBytes()) + w.Bytes(certificate) + w.Bytes(validators) + w.Bytes(r.Commitment.CanonicalBytes()) + w.Bytes(r.CommitmentProof.MarshalBinary()) + w.Bytes(receipt) + w.Bytes(r.ReceiptProof.MarshalBinary()) + return w.BytesCopy(), nil +} + +func ParseReceiptImport(data []byte) (ReceiptImport, error) { + r := codec.NewReader(data) + headerRaw, err := r.Bytes(512) + if err != nil { + return ReceiptImport{}, ErrNetworkWire + } + header, err := sharding.ParseGlobalHeader(headerRaw) + if err != nil { + return ReceiptImport{}, err + } + certificateRaw, err := r.Bytes(4 << 20) + if err != nil { + return ReceiptImport{}, ErrNetworkWire + } + certificate, err := v2consensus.ParseCertificate(certificateRaw) + if err != nil { + return ReceiptImport{}, err + } + validatorsRaw, err := r.Bytes(4 << 20) + if err != nil { + return ReceiptImport{}, ErrNetworkWire + } + validators, err := v2consensus.ParseValidatorSet(validatorsRaw) + if err != nil { + return ReceiptImport{}, err + } + commitmentRaw, err := r.Bytes(512) + if err != nil { + return ReceiptImport{}, ErrNetworkWire + } + commitment, err := sharding.ParseCommitment(commitmentRaw) + if err != nil { + return ReceiptImport{}, err + } + commitmentProofRaw, err := r.Bytes(64 << 10) + if err != nil { + return ReceiptImport{}, ErrNetworkWire + } + commitmentProof, err := merkle.ParseProof(commitmentProofRaw) + if err != nil { + return ReceiptImport{}, err + } + receiptRaw, err := r.Bytes(1 << 20) + if err != nil { + return ReceiptImport{}, ErrNetworkWire + } + receipt, err := sharding.ParseCrossShardReceipt(receiptRaw) + if err != nil { + return ReceiptImport{}, err + } + receiptProofRaw, err := r.Bytes(64 << 10) + if err != nil { + return ReceiptImport{}, ErrNetworkWire + } + receiptProof, err := merkle.ParseProof(receiptProofRaw) + if err != nil || r.Done() != nil { + return ReceiptImport{}, ErrNetworkWire + } + return ReceiptImport{Header: header, Certificate: certificate, Validators: validators, Commitment: commitment, CommitmentProof: commitmentProof, Receipt: receipt, ReceiptProof: receiptProof}, nil +} diff --git a/internal/v2/node/proposal.go b/internal/v2/node/proposal.go new file mode 100644 index 00000000..b95b3bbb --- /dev/null +++ b/internal/v2/node/proposal.go @@ -0,0 +1,19 @@ +package node + +import ( + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" +) + +// VerifyProposalAgainstCandidate is the pre-vote safety gate. Cryptographic +// proposal validity is necessary but not sufficient: an honest validator only +// votes when its independently executed candidate produces the exact same +// consensus GlobalHeader. +func VerifyProposalAgainstCandidate(candidate Candidate, proposal v2consensus.Proposal, validators v2consensus.ValidatorSet) error { + if err := validators.VerifyProposal(proposal); err != nil { + return err + } + if v2consensus.HeaderConsensusHash(candidate.Header) != v2consensus.HeaderConsensusHash(proposal.Header) { + return ErrCandidateState + } + return nil +} diff --git a/internal/v2/node/recovery_state.go b/internal/v2/node/recovery_state.go new file mode 100644 index 00000000..23bd6fd2 --- /dev/null +++ b/internal/v2/node/recovery_state.go @@ -0,0 +1,14 @@ +package node + +// RecoveryRequired reports whether a state backend returned an uncertain +// result during a consensus-finalized apply. Once set, the runtime refuses to +// build or commit further candidates until the normal recovery path reconstructs +// a safe state anchor. +func (r *Runtime) RecoveryRequired() bool { + if r == nil { + return true + } + r.mu.Lock() + defer r.mu.Unlock() + return r.recoveryRequired +} diff --git a/internal/v2/node/recovery_state_test.go b/internal/v2/node/recovery_state_test.go new file mode 100644 index 00000000..728e9d8d --- /dev/null +++ b/internal/v2/node/recovery_state_test.go @@ -0,0 +1,64 @@ +package node + +import ( + "errors" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +type uncertainBackend struct { + *worldstate.Memory + failAfterApply bool +} + +func (b *uncertainBackend) Apply(consumed []types.ObjectID, created []object.Object) (types.Hash, error) { + root, err := b.Memory.Apply(consumed, created) + if err != nil { + return root, err + } + if b.failAfterApply { + return root, errors.New("simulated durable write acknowledgement failure") + } + return root, nil +} + +func TestRuntimeFailStopsAfterUncertainStateApply(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("fail-stop"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + key, validators, validatorRoot := schedulerValidatorSet(t, network) + backend := &uncertainBackend{Memory: worldstate.NewMemory()} + oldObject := systemObjectForShard(0, 1, "fail-stop-old") + if _, err := backend.Memory.Apply(nil, []object.Object{oldObject}); err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: backend}, 1) + if err != nil { + t.Fatal(err) + } + newObject := systemObjectForShard(0, 2, "fail-stop-new") + candidate := manualCandidateForDeltas(t, runtime, map[uint32]shardDelta{ + 0: {Consumed: []types.ObjectID{oldObject.ID}, Created: []object.Object{newObject}}, + }) + backend.failAfterApply = true + commitSchedulerCandidateExpectError(t, runtime, key, validators, candidate) + + if !runtime.RecoveryRequired() { + t.Fatal("runtime did not enter recovery-required state after uncertain apply") + } + if runtime.Height != 0 { + t.Fatalf("uncertain apply advanced consensus height to %d", runtime.Height) + } + if _, ok := backend.GetObject(newObject.ID); !ok { + t.Fatal("test backend did not simulate a post-mutation acknowledgement failure") + } + if _, err := runtime.BuildCandidate(1, nil); !errors.Is(err, ErrRuntimeRecoveryRequired) { + t.Fatalf("runtime continued building after uncertain state apply: %v", err) + } + if _, err := runtime.Commit(candidate, v2consensus.Certificate{}, validators); !errors.Is(err, ErrRuntimeRecoveryRequired) { + t.Fatalf("runtime continued committing after uncertain state apply: %v", err) + } +} diff --git a/internal/v2/node/runtime.go b/internal/v2/node/runtime.go new file mode 100644 index 00000000..12c81440 --- /dev/null +++ b/internal/v2/node/runtime.go @@ -0,0 +1,402 @@ +package node + +import ( + "errors" + "sync" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/economics" + "github.com/zephyr-chain/zephyr-chain/internal/v2/execution" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +var ( + ErrRuntimeConfig = errors.New("invalid v2 runtime configuration") + ErrCandidateHeight = errors.New("invalid v2 candidate height") + ErrCandidateState = errors.New("v2 candidate does not match committed state") + ErrCandidateCert = errors.New("v2 candidate certificate mismatch") + ErrStateSimulation = errors.New("v2 backend does not support state simulation") + ErrReceiptImport = errors.New("invalid v2 receipt import") + ErrRuntimeRecoveryRequired = errors.New("v2 runtime recovery required") +) + +type ReceiptImport struct { + Header sharding.GlobalHeader + Certificate v2consensus.Certificate + Validators v2consensus.ValidatorSet + Commitment sharding.Commitment + CommitmentProof merkle.Proof + Receipt sharding.CrossShardReceipt + ReceiptProof merkle.Proof +} + +type ShardBatch struct { + Transactions []tx.Transaction + Imports []ReceiptImport + DataRoot types.Hash +} + +type shardDelta struct { + Consumed []types.ObjectID + Created []object.Object +} + +type Candidate struct { + Header sharding.GlobalHeader + Commitments []sharding.Commitment + Results map[uint32][]execution.Result + Receipts map[uint32][]sharding.CrossShardReceipt + deltas map[uint32]shardDelta + economicObservations map[uint32]economics.FinalizedShardObservation + economicPendingStateHash types.Hash +} + +type Runtime struct { + mu sync.Mutex + Network types.NetworkID + NativeToken types.TokenID + ValidatorRoot types.Hash + ShardCount uint32 + States map[uint32]worldstate.Backend + Workers int + Height uint64 + ParentHash types.Hash + economicCollector *economics.EpochCollector + economicEngine *economics.ShadowEpochEngine + economicEpochLength uint64 + economicBalances economics.MonetaryBalanceSnapshot + pendingEconomic *economics.ShadowEpochPreview + globalCommitJournalPath string + recoveryRequired bool +} + +func NewRuntime(network types.NetworkID, nativeToken types.TokenID, validatorRoot types.Hash, states map[uint32]worldstate.Backend, workers int) (*Runtime, error) { + if types.IsZero32([32]byte(network)) || types.IsZero32([32]byte(nativeToken)) || types.IsZero32([32]byte(validatorRoot)) || len(states) == 0 { + return nil, ErrRuntimeConfig + } + count := uint32(len(states)) + for shard := uint32(0); shard < count; shard++ { + if states[shard] == nil { + return nil, ErrRuntimeConfig + } + } + return &Runtime{Network: network, NativeToken: nativeToken, ValidatorRoot: validatorRoot, ShardCount: count, States: states, Workers: workers}, nil +} + +func (r *Runtime) BuildCandidate(height uint64, batches map[uint32]ShardBatch) (Candidate, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.recoveryRequired { + return Candidate{}, ErrRuntimeRecoveryRequired + } + if height != r.Height+1 || height == 0 { + return Candidate{}, ErrCandidateHeight + } + candidate := Candidate{ + Results: make(map[uint32][]execution.Result), + Receipts: make(map[uint32][]sharding.CrossShardReceipt), + deltas: make(map[uint32]shardDelta), + economicObservations: make(map[uint32]economics.FinalizedShardObservation), + } + commitments := make([]sharding.Commitment, 0, r.ShardCount) + dataLeaves := make([]types.Hash, 0, r.ShardCount) + for shard := uint32(0); shard < r.ShardCount; shard++ { + store := r.States[shard] + batch := batches[shard] + currentRoot := store.Root() + delta := shardDelta{} + results := make([]execution.Result, 0, len(batch.Transactions)) + if len(batch.Transactions) > 0 { + for _, transaction := range batch.Transactions { + if transaction.ShardID != shard || transaction.StateRoot != currentRoot { + return Candidate{}, ErrCandidateState + } + } + executor := execution.BatchExecutor{Engine: execution.Engine{Network: r.Network, NativeToken: r.NativeToken, ShardCount: r.ShardCount, Height: height}, Workers: r.Workers} + var err error + results, err = executor.ExecuteBatch(batch.Transactions) + if err != nil { + return Candidate{}, err + } + for _, result := range results { + delta.Consumed = append(delta.Consumed, result.Consumed...) + delta.Created = append(delta.Created, result.Created...) + } + candidate.Results[shard] = results + } + for _, receiptImport := range batch.Imports { + if err := r.validateReceiptImport(shard, receiptImport); err != nil { + return Candidate{}, err + } + destinationObject, err := receiptImport.Receipt.DestinationObject() + if err != nil { + return Candidate{}, ErrReceiptImport + } + marker, err := sharding.ReceiptMarker(receiptImport.Receipt) + if err != nil { + return Candidate{}, ErrReceiptImport + } + if _, exists := store.GetObject(destinationObject.ID); exists { + return Candidate{}, sharding.ErrReceiptReplay + } + if _, exists := store.GetObject(marker.ID); exists { + return Candidate{}, sharding.ErrReceiptReplay + } + delta.Created = append(delta.Created, destinationObject, marker) + } + + if shard == 0 && r.pendingEconomic != nil { + stateHash, err := r.pendingEconomic.State.Hash() + if err != nil { + return Candidate{}, err + } + if err := appendEconomicStateDelta(&delta, *r.pendingEconomic); err != nil { + return Candidate{}, err + } + candidate.economicPendingStateHash = stateHash + } + + newRoot := currentRoot + if len(delta.Consumed) > 0 || len(delta.Created) > 0 { + simulator, ok := store.(worldstate.Simulator) + if !ok { + return Candidate{}, ErrStateSimulation + } + var err error + newRoot, err = simulator.Simulate(delta.Consumed, delta.Created) + if err != nil { + return Candidate{}, err + } + candidate.deltas[shard] = delta + } + receipts := make([]sharding.CrossShardReceipt, 0) + for _, result := range results { + for _, outbound := range result.Outbound { + receipts = append(receipts, sharding.CrossShardReceipt{SourceShard: shard, DestinationShard: outbound.DestinationShard, SourceHeight: height, TransactionID: result.TxID, OutputIndex: outbound.OutputIndex, Output: outbound.Output, SourceStateRoot: newRoot}) + } + } + receiptRoot, err := (sharding.ReceiptBatch{Receipts: receipts}).Root() + if err != nil { + return Candidate{}, err + } + candidate.Receipts[shard] = receipts + dataRoot := batch.DataRoot + if types.IsZero32([32]byte(dataRoot)) { + dataRoot = merkle.Root(nil) + } + commitments = append(commitments, sharding.Commitment{ShardID: shard, StateRoot: newRoot, ReceiptRoot: receiptRoot, DataRoot: dataRoot}) + dataLeaves = append(dataLeaves, merkle.Leaf("shard-data-root", dataRoot[:])) + + if r.economicCollector != nil { + imports := make([]sharding.CrossShardReceipt, 0, len(batch.Imports)) + for _, receiptImport := range batch.Imports { + imports = append(imports, receiptImport.Receipt) + } + candidate.economicObservations[shard] = economics.FinalizedShardObservation{ + Transactions: append([]tx.Transaction(nil), batch.Transactions...), + Results: append([]execution.Result(nil), results...), + Imports: imports, + } + } + } + commitmentRoot, err := sharding.CommitmentRoot(commitments) + if err != nil { + return Candidate{}, err + } + candidate.Commitments = commitments + candidate.Header = sharding.GlobalHeader{Version: 2, Network: r.Network, Height: height, ParentHash: r.ParentHash, ShardCommitmentRoot: commitmentRoot, ValidatorRoot: r.ValidatorRoot, NextValidatorRoot: r.ValidatorRoot, DataRoot: merkle.Root(dataLeaves)} + return candidate, nil +} + +func appendEconomicStateDelta(delta *shardDelta, preview economics.ShadowEpochPreview) error { + if delta == nil || len(preview.Created) != 1 || preview.Created[0].ID != economics.MonetaryStateObjectID(preview.State.Network) { + return ErrCandidateState + } + monetaryID := preview.Created[0].ID + for _, id := range delta.Consumed { + if id == monetaryID { + return ErrCandidateState + } + } + for _, created := range delta.Created { + if created.ID == monetaryID { + return ErrCandidateState + } + } + delta.Consumed = append(delta.Consumed, preview.Consumed...) + delta.Created = append(delta.Created, preview.Created...) + return nil +} + +func (r *Runtime) validateReceiptImport(destinationShard uint32, receiptImport ReceiptImport) error { + if receiptImport.Header.Network != r.Network || receiptImport.Validators.Network != r.Network || receiptImport.Certificate.Network != r.Network || receiptImport.Receipt.DestinationShard != destinationShard || receiptImport.Header.Height > r.Height || receiptImport.Header.Height != receiptImport.Receipt.SourceHeight { + return ErrReceiptImport + } + validatorRoot, err := receiptImport.Validators.Root() + if err != nil || validatorRoot != receiptImport.Header.ValidatorRoot { + return ErrReceiptImport + } + if receiptImport.Header.CertificateHash != receiptImport.Certificate.Hash() || receiptImport.Certificate.HeaderHash != v2consensus.HeaderConsensusHash(receiptImport.Header) || receiptImport.Certificate.Height != receiptImport.Header.Height { + return ErrReceiptImport + } + if err := receiptImport.Validators.VerifyCertificate(receiptImport.Certificate); err != nil { + return ErrReceiptImport + } + if err := sharding.VerifyFinalizedReceipt(receiptImport.Header, receiptImport.Commitment, receiptImport.CommitmentProof, receiptImport.Receipt, receiptImport.ReceiptProof); err != nil { + return ErrReceiptImport + } + return nil +} + +func (r *Runtime) Commit(candidate Candidate, certificate v2consensus.Certificate, validators v2consensus.ValidatorSet) (sharding.GlobalHeader, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.recoveryRequired { + return sharding.GlobalHeader{}, ErrRuntimeRecoveryRequired + } + if candidate.Header.Height != r.Height+1 || candidate.Header.ParentHash != r.ParentHash || candidate.Header.Network != r.Network { + return sharding.GlobalHeader{}, ErrCandidateState + } + validatorRoot, err := validators.Root() + if err != nil || validatorRoot != candidate.Header.ValidatorRoot || validatorRoot != r.ValidatorRoot { + return sharding.GlobalHeader{}, ErrCandidateCert + } + if certificate.HeaderHash != v2consensus.HeaderConsensusHash(candidate.Header) || certificate.Height != candidate.Header.Height || certificate.Network != r.Network { + return sharding.GlobalHeader{}, ErrCandidateCert + } + if err := validators.VerifyCertificate(certificate); err != nil { + return sharding.GlobalHeader{}, err + } + + pendingApplied := r.pendingEconomic != nil + if pendingApplied { + expected, err := r.pendingEconomic.State.Hash() + if err != nil || candidate.economicPendingStateHash != expected { + return sharding.GlobalHeader{}, ErrCandidateState + } + } else if !types.IsZero32([32]byte(candidate.economicPendingStateHash)) { + return sharding.GlobalHeader{}, ErrCandidateState + } + + commitments, shards, err := r.preflightCandidateState(candidate) + if err != nil { + return sharding.GlobalHeader{}, err + } + + var economicPreview *economics.EpochCollector + if r.economicCollector != nil { + if len(candidate.economicObservations) != int(r.ShardCount) { + return sharding.GlobalHeader{}, ErrCandidateState + } + economicPreview, err = r.economicCollector.PreviewFinalizedBlock(candidate.Header.Height, candidate.economicObservations) + if err != nil { + return sharding.GlobalHeader{}, err + } + } + + var enginePreview *economics.ShadowEpochEngine + if r.economicEngine != nil { + enginePreview = r.economicEngine.Clone() + if enginePreview == nil { + return sharding.GlobalHeader{}, ErrRuntimeConfig + } + if pendingApplied { + if err := enginePreview.Accept(*r.pendingEconomic); err != nil { + return sharding.GlobalHeader{}, err + } + } + } else if pendingApplied { + return sharding.GlobalHeader{}, ErrRuntimeConfig + } + + var nextPending *economics.ShadowEpochPreview + nextBalances := r.economicBalances + if r.economicEngine != nil && r.economicEpochLength > 0 && candidate.Header.Height%r.economicEpochLength == 0 { + if economicPreview == nil { + return sharding.GlobalHeader{}, ErrRuntimeConfig + } + metrics, verifiedWork, err := economicPreview.FinalizeEpoch() + if err != nil { + return sharding.GlobalHeader{}, err + } + aggregate, err := economics.AggregateEpochMetrics(metrics) + if err != nil || nextBalances.TotalSupply < aggregate.BurnedFees { + return sharding.GlobalHeader{}, ErrRuntimeConfig + } + nextBalances.TotalSupply -= aggregate.BurnedFees + preview, err := enginePreview.PreviewCloseEpoch(metrics, verifiedWork, nextBalances) + if err != nil { + return sharding.GlobalHeader{}, err + } + if err := economicPreview.AdvanceEpoch(preview.State.Epoch + 1); err != nil { + return sharding.GlobalHeader{}, err + } + nextPending = &preview + } + + postPending := r.pendingEconomic + if nextPending != nil { + postPending = nextPending + } else if pendingApplied { + postPending = nil + } + finalized := candidate.Header + finalized.CertificateHash = certificate.Hash() + postParent := v2consensus.HeaderConsensusHash(finalized) + + var journalIntent *globalCommitIntent + if r.globalCommitJournalPath != "" { + economicCheckpoint, err := r.postCommitEconomicCheckpoint(economicPreview, enginePreview, postPending, nextBalances, finalized.Height, postParent) + if err != nil { + return sharding.GlobalHeader{}, err + } + intent, err := r.buildGlobalCommitIntent(candidate, certificate, commitments, economicCheckpoint) + if err != nil { + return sharding.GlobalHeader{}, err + } + if err := writeGlobalCommitJournal(r.globalCommitJournalPath, intent); err != nil { + r.recoveryRequired = true + return sharding.GlobalHeader{}, errors.Join(ErrRuntimeRecoveryRequired, err) + } + journalIntent = &intent + } + + for _, shardValue := range shards { + shard := uint32(shardValue) + delta := candidate.deltas[shard] + root, err := r.States[shard].Apply(delta.Consumed, delta.Created) + if err != nil { + r.recoveryRequired = true + return sharding.GlobalHeader{}, errors.Join(ErrRuntimeRecoveryRequired, err) + } + if root != commitments[shard].StateRoot { + r.recoveryRequired = true + return sharding.GlobalHeader{}, errors.Join(ErrRuntimeRecoveryRequired, ErrCandidateState) + } + } + + if journalIntent != nil { + journalIntent.Status = globalCommitCommitted + if err := writeGlobalCommitJournal(r.globalCommitJournalPath, *journalIntent); err != nil { + r.recoveryRequired = true + return sharding.GlobalHeader{}, errors.Join(ErrRuntimeRecoveryRequired, err) + } + } + + if economicPreview != nil { + r.economicCollector = economicPreview + } + if enginePreview != nil { + r.economicEngine = enginePreview + } + r.pendingEconomic = postPending + r.economicBalances = nextBalances + r.Height = finalized.Height + r.ParentHash = postParent + return finalized, nil +} diff --git a/internal/v2/node/runtime_test.go b/internal/v2/node/runtime_test.go new file mode 100644 index 00000000..555431c7 --- /dev/null +++ b/internal/v2/node/runtime_test.go @@ -0,0 +1,103 @@ +package node + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestCandidateDoesNotMutateBeforeQCAndCommitsAfterQC(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("node-runtime"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + stateStore := worldstate.NewMemory() + + validatorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + validatorPub := elliptic.Marshal(elliptic.P256(), validatorKey.PublicKey.X, validatorKey.PublicKey.Y) + validatorID := types.ValidatorIDFromPublicKey(validatorPub) + validators := v2consensus.ValidatorSet{Network: network, Validators: []v2consensus.Validator{{ID: validatorID, PublicKey: validatorPub, Power: 10}}} + validatorRoot, err := validators.Root() + if err != nil { + t.Fatal(err) + } + + aliceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + alicePub := elliptic.Marshal(elliptic.P256(), aliceKey.PublicKey.X, aliceKey.PublicKey.Y) + alice := types.AccountIDFromPublicKey(alicePub) + bob := types.AccountIDFromPublicKey([]byte("bob")) + inputID := types.ObjectIDFromTransaction(types.HashBytes("genesis", []byte("coin")), 0) + inputOut, _ := object.NewCoinOutput(alice, native, 100) + input := object.Object{ID: inputID, Version: 1, Owner: alice, Kind: inputOut.Kind, Data: inputOut.Data} + root, err := stateStore.Apply(nil, []object.Object{input}) + if err != nil { + t.Fatal(err) + } + witness, proof, ok := stateStore.Proof(inputID) + if !ok { + t.Fatal("missing genesis input") + } + witnessHash := witness.Hash() + toBob, _ := object.NewCoinOutput(bob, native, 25) + change, _ := object.NewCoinOutput(alice, native, 74) + transaction := tx.Transaction{ + Version: tx.Version, Network: network, ShardID: 0, StateRoot: root, + Inputs: []tx.InputRef{{ObjectID: inputID, Version: 1, ObjectHash: witnessHash}}, + Outputs: []object.OutputSpec{toBob, change}, Operations: []tx.Operation{{Kind: tx.OpTransfer}}, + Fee: 1, Witnesses: []tx.Witness{{Object: witness, Proof: proof}}, + } + transaction.Salt[0] = 1 + if err := transaction.Sign(aliceKey); err != nil { + t.Fatal(err) + } + + runtime, err := NewRuntime(network, native, validatorRoot, map[uint32]worldstate.Backend{0: stateStore}, 2) + if err != nil { + t.Fatal(err) + } + candidate, err := runtime.BuildCandidate(1, map[uint32]ShardBatch{0: {Transactions: []tx.Transaction{transaction}}}) + if err != nil { + t.Fatal(err) + } + if stateStore.Root() != root { + t.Fatal("candidate simulation mutated committed state before QC") + } + if candidate.Commitments[0].StateRoot == root { + t.Fatal("candidate did not calculate a new state root") + } + + proposal, err := v2consensus.SignProposal(validatorKey, candidate.Header, 0) + if err != nil { + t.Fatal(err) + } + if err := validators.VerifyProposal(proposal); err != nil { + t.Fatal(err) + } + headerHash := v2consensus.HeaderConsensusHash(candidate.Header) + vote, err := v2consensus.SignVote(validatorKey, network, 1, 0, headerHash) + if err != nil { + t.Fatal(err) + } + certificate, err := validators.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + finalized, err := runtime.Commit(candidate, certificate, validators) + if err != nil { + t.Fatal(err) + } + if types.IsZero32([32]byte(finalized.CertificateHash)) || runtime.Height != 1 || stateStore.Root() != candidate.Commitments[0].StateRoot { + t.Fatal("QC commit did not finalize candidate state") + } +} diff --git a/internal/v2/node/scheduler_helpers_test.go b/internal/v2/node/scheduler_helpers_test.go new file mode 100644 index 00000000..b04e8b23 --- /dev/null +++ b/internal/v2/node/scheduler_helpers_test.go @@ -0,0 +1,33 @@ +package node + +import ( + "crypto/ecdsa" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" +) + +func schedulerCertificate(t *testing.T, runtime *Runtime, key *ecdsa.PrivateKey, validators v2consensus.ValidatorSet, candidate Candidate) v2consensus.Certificate { + t.Helper() + proposal, err := v2consensus.SignProposal(key, candidate.Header, 0) + if err != nil { + t.Fatal(err) + } + vote, err := v2consensus.SignVote(key, runtime.Network, candidate.Header.Height, 0, v2consensus.HeaderConsensusHash(candidate.Header)) + if err != nil { + t.Fatal(err) + } + certificate, err := validators.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + return certificate +} + +func commitSchedulerCandidateExpectError(t *testing.T, runtime *Runtime, key *ecdsa.PrivateKey, validators v2consensus.ValidatorSet, candidate Candidate) { + t.Helper() + certificate := schedulerCertificate(t, runtime, key, validators, candidate) + if _, err := runtime.Commit(candidate, certificate, validators); err == nil { + t.Fatal("candidate commit unexpectedly succeeded") + } +} diff --git a/internal/v2/node/service.go b/internal/v2/node/service.go new file mode 100644 index 00000000..eb69b3eb --- /dev/null +++ b/internal/v2/node/service.go @@ -0,0 +1,415 @@ +package node + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "errors" + "sync" + + "github.com/libp2p/go-libp2p/core/peer" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/execution" + "github.com/zephyr-chain/zephyr-chain/internal/v2/lightapi" + p2p "github.com/zephyr-chain/zephyr-chain/internal/v2/network/p2p" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +var ( + ErrServiceConfig = errors.New("invalid v2 consensus service configuration") + ErrNotValidator = errors.New("node is not an active v2 validator") + ErrNotProposer = errors.New("node is not the scheduled v2 proposer") + ErrNoQuorum = errors.New("v2 proposal did not collect 2/3+ voting power") + ErrProposalState = errors.New("v2 proposal does not match local execution") + ErrDoubleVote = errors.New("refusing conflicting vote for same v2 height/round") + ErrMempoolConflict = errors.New("v2 mempool object conflict") + ErrNoSnapshot = errors.New("no finalized v2 snapshot available") +) + +type voteSlot struct { + Height uint64 + Round uint64 +} + +type Service struct { + mu sync.Mutex + proposeMu sync.Mutex + Runtime *Runtime + Validators v2consensus.ValidatorSet + Key *ecdsa.PrivateKey + P2P *p2p.Node + Peers []peer.ID + + mempool map[uint32][]tx.Transaction + mempoolInputs map[types.ObjectID]types.Hash + imports map[uint32][]ReceiptImport + votes map[voteSlot]v2consensus.Vote + latest *lightapi.Snapshot +} + +func NewService(runtime *Runtime, validators v2consensus.ValidatorSet, key *ecdsa.PrivateKey, transport *p2p.Node) (*Service, error) { + if runtime == nil || transport == nil || validators.Network != runtime.Network || validators.Validate() != nil { + return nil, ErrServiceConfig + } + root, err := validators.Root() + if err != nil || root != runtime.ValidatorRoot { + return nil, ErrServiceConfig + } + service := &Service{ + Runtime: runtime, Validators: validators, Key: key, P2P: transport, + mempool: make(map[uint32][]tx.Transaction), mempoolInputs: make(map[types.ObjectID]types.Hash), + imports: make(map[uint32][]ReceiptImport), votes: make(map[voteSlot]v2consensus.Vote), + } + if key != nil { + id, err := validatorID(key) + if err != nil || !containsValidator(validators, id) { + return nil, ErrNotValidator + } + } + transport.SetConsensusHandler(service.HandleConsensus) + transport.SetTransactionHandler(service.HandleTransaction) + return service, nil +} + +func (s *Service) SetPeers(peers []peer.ID) { + s.mu.Lock() + defer s.mu.Unlock() + seen := make(map[peer.ID]struct{}, len(peers)) + s.Peers = s.Peers[:0] + for _, id := range peers { + if id == "" || id == s.P2P.ID() { + continue + } + if _, duplicate := seen[id]; duplicate { + continue + } + seen[id] = struct{}{} + s.Peers = append(s.Peers, id) + } +} + +func (s *Service) Submit(transaction tx.Transaction) error { + s.mu.Lock() + defer s.mu.Unlock() + if transaction.Network != s.Runtime.Network || transaction.ShardID >= s.Runtime.ShardCount { + return ErrCandidateState + } + store := s.Runtime.States[transaction.ShardID] + if store == nil || transaction.StateRoot != store.Root() { + return ErrCandidateState + } + engine := execution.Engine{Network: s.Runtime.Network, NativeToken: s.Runtime.NativeToken, ShardCount: s.Runtime.ShardCount, Height: s.Runtime.Height + 1} + if _, err := engine.Execute(transaction); err != nil { + return err + } + id := transaction.ID() + for _, input := range transaction.Inputs { + if existing, conflict := s.mempoolInputs[input.ObjectID]; conflict && existing != id { + return ErrMempoolConflict + } + } + for _, pending := range s.mempool[transaction.ShardID] { + if pending.ID() == id { + return nil + } + } + for _, input := range transaction.Inputs { + s.mempoolInputs[input.ObjectID] = id + } + s.mempool[transaction.ShardID] = append(s.mempool[transaction.ShardID], transaction) + return nil +} + +func (s *Service) QueueReceiptImport(shard uint32, receiptImport ReceiptImport) error { + s.mu.Lock() + defer s.mu.Unlock() + if shard >= s.Runtime.ShardCount || s.Runtime.validateReceiptImport(shard, receiptImport) != nil { + return ErrReceiptImport + } + s.imports[shard] = append(s.imports[shard], receiptImport) + return nil +} + +func (s *Service) HandleTransaction(_ context.Context, _ peer.ID, payload []byte) ([]byte, error) { + transaction, err := tx.ParseTransaction(payload) + if err != nil { + return nil, err + } + if err := s.Submit(transaction); err != nil { + return nil, err + } + id := transaction.ID() + return append([]byte(nil), id[:]...), nil +} + +func (s *Service) HandleConsensus(_ context.Context, _ peer.ID, payload []byte) ([]byte, error) { + message, err := ParseConsensusMessage(payload) + if err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + switch message.Kind { + case NetworkMessageProposal: + return s.handleProposalLocked(message) + case NetworkMessageCommit: + return s.handleCommitLocked(message) + default: + return nil, ErrNetworkWire + } +} + +func (s *Service) Propose(ctx context.Context) (lightapi.Snapshot, error) { + s.proposeMu.Lock() + defer s.proposeMu.Unlock() + + s.mu.Lock() + if s.Key == nil { + s.mu.Unlock() + return lightapi.Snapshot{}, ErrNotValidator + } + height := s.Runtime.Height + 1 + const round uint64 = 0 + expected, err := s.Validators.Proposer(height, round) + if err != nil { + s.mu.Unlock() + return lightapi.Snapshot{}, err + } + localID, err := validatorID(s.Key) + if err != nil || localID != expected.ID { + s.mu.Unlock() + return lightapi.Snapshot{}, ErrNotProposer + } + block := s.pendingBlockLocked() + candidate, err := s.Runtime.BuildCandidate(height, block.Batches) + if err != nil { + s.mu.Unlock() + return lightapi.Snapshot{}, err + } + proposal, err := v2consensus.SignProposal(s.Key, candidate.Header, round) + if err != nil { + s.mu.Unlock() + return lightapi.Snapshot{}, err + } + selfVote, err := s.signVoteLocked(proposal) + peers := append([]peer.ID(nil), s.Peers...) + if err != nil { + s.mu.Unlock() + return lightapi.Snapshot{}, err + } + s.mu.Unlock() + + proposalWire, err := (ConsensusMessage{Kind: NetworkMessageProposal, Proposal: proposal, Block: block}).MarshalBinary() + if err != nil { + return lightapi.Snapshot{}, err + } + votesByID := map[types.ValidatorID]v2consensus.Vote{selfVote.Voter: selfVote} + for _, remote := range peers { + response, err := s.P2P.SendConsensus(ctx, remote, proposalWire) + if err != nil { + continue + } + vote, err := v2consensus.ParseVote(response) + if err != nil || vote.HeaderHash != v2consensus.HeaderConsensusHash(proposal.Header) || vote.Height != height || vote.Round != round || s.Validators.VerifyVote(vote) != nil { + continue + } + votesByID[vote.Voter] = vote + } + votes := make([]v2consensus.Vote, 0, len(votesByID)) + for _, vote := range votesByID { + votes = append(votes, vote) + } + certificate, err := s.Validators.BuildCertificate(proposal, votes) + if err != nil { + return lightapi.Snapshot{}, ErrNoQuorum + } + + s.mu.Lock() + finalized, err := s.Runtime.Commit(candidate, certificate, s.Validators) + if err != nil { + s.mu.Unlock() + return lightapi.Snapshot{}, err + } + snapshot := s.recordFinalizedLocked(finalized, certificate, candidate.Commitments) + s.clearCommittedStateLocked(block) + s.mu.Unlock() + + commitWire, err := (ConsensusMessage{Kind: NetworkMessageCommit, Proposal: proposal, Block: block, Certificate: &certificate}).MarshalBinary() + if err == nil { + for _, remote := range peers { + _, _ = s.P2P.SendConsensus(ctx, remote, commitWire) + } + } + return snapshot, nil +} + +func (s *Service) handleProposalLocked(message ConsensusMessage) ([]byte, error) { + if s.Key == nil { + return nil, ErrNotValidator + } + if err := s.Validators.VerifyProposal(message.Proposal); err != nil { + return nil, err + } + if message.Proposal.Header.Height != s.Runtime.Height+1 { + return nil, ErrCandidateHeight + } + candidate, err := s.Runtime.BuildCandidate(message.Proposal.Header.Height, message.Block.Batches) + if err != nil { + return nil, err + } + if !sameProposalHeader(candidate.Header, message.Proposal.Header) { + return nil, ErrProposalState + } + vote, err := s.signVoteLocked(message.Proposal) + if err != nil { + return nil, err + } + return vote.MarshalBinary() +} + +func (s *Service) handleCommitLocked(message ConsensusMessage) ([]byte, error) { + if message.Certificate == nil || s.Validators.VerifyProposal(message.Proposal) != nil { + return nil, ErrCandidateCert + } + certificate := *message.Certificate + if certificate.HeaderHash != v2consensus.HeaderConsensusHash(message.Proposal.Header) || certificate.Height != message.Proposal.Header.Height || certificate.Round != message.Proposal.Round || s.Validators.VerifyCertificate(certificate) != nil { + return nil, ErrCandidateCert + } + if message.Proposal.Header.Height <= s.Runtime.Height { + if message.Proposal.Header.Height == s.Runtime.Height && s.latest != nil && v2consensus.HeaderConsensusHash(s.latest.Header) == v2consensus.HeaderConsensusHash(message.Proposal.Header) { + return []byte{1}, nil + } + return nil, ErrCandidateHeight + } + if message.Proposal.Header.Height != s.Runtime.Height+1 { + return nil, ErrCandidateHeight + } + candidate, err := s.Runtime.BuildCandidate(message.Proposal.Header.Height, message.Block.Batches) + if err != nil || !sameProposalHeader(candidate.Header, message.Proposal.Header) { + return nil, ErrProposalState + } + finalized, err := s.Runtime.Commit(candidate, certificate, s.Validators) + if err != nil { + return nil, err + } + s.recordFinalizedLocked(finalized, certificate, candidate.Commitments) + s.clearCommittedStateLocked(message.Block) + return []byte{1}, nil +} + +func (s *Service) signVoteLocked(proposal v2consensus.Proposal) (v2consensus.Vote, error) { + if s.Key == nil { + return v2consensus.Vote{}, ErrNotValidator + } + slot := voteSlot{Height: proposal.Header.Height, Round: proposal.Round} + target := v2consensus.HeaderConsensusHash(proposal.Header) + if prior, exists := s.votes[slot]; exists { + if prior.HeaderHash != target { + return v2consensus.Vote{}, ErrDoubleVote + } + return prior, nil + } + vote, err := v2consensus.SignVote(s.Key, s.Runtime.Network, proposal.Header.Height, proposal.Round, target) + if err != nil { + return v2consensus.Vote{}, err + } + if err := s.Validators.VerifyVote(vote); err != nil { + return v2consensus.Vote{}, err + } + s.votes[slot] = vote + return vote, nil +} + +func (s *Service) pendingBlockLocked() BlockData { + batches := make(map[uint32]ShardBatch) + for shard := uint32(0); shard < s.Runtime.ShardCount; shard++ { + transactions := append([]tx.Transaction(nil), s.mempool[shard]...) + imports := append([]ReceiptImport(nil), s.imports[shard]...) + if len(transactions) > 0 || len(imports) > 0 { + batches[shard] = ShardBatch{Transactions: transactions, Imports: imports} + } + } + return BlockData{Batches: batches} +} + +func (s *Service) clearCommittedStateLocked(block BlockData) { + // Proof-carrying transactions are bound to the pre-commit state root, so any + // uncommitted transaction must refresh its witnesses after finality. + s.mempool = make(map[uint32][]tx.Transaction) + s.mempoolInputs = make(map[types.ObjectID]types.Hash) + for shard, batch := range block.Batches { + if len(batch.Imports) > 0 { + s.imports[shard] = nil + } + } + for slot := range s.votes { + if slot.Height <= s.Runtime.Height { + delete(s.votes, slot) + } + } +} + +func (s *Service) recordFinalizedLocked(header sharding.GlobalHeader, certificate v2consensus.Certificate, commitments []sharding.Commitment) lightapi.Snapshot { + snapshot := lightapi.Snapshot{Header: header, Certificate: certificate, Commitments: append([]sharding.Commitment(nil), commitments...), Validators: cloneValidatorSet(s.Validators)} + s.latest = &snapshot + return cloneSnapshot(snapshot) +} + +func (s *Service) LatestSnapshot() (lightapi.Snapshot, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.latest == nil { + return lightapi.Snapshot{}, ErrNoSnapshot + } + return cloneSnapshot(*s.latest), nil +} + +func (s *Service) ShardState(shardID uint32) (worldstate.Backend, bool) { + s.mu.Lock() + defer s.mu.Unlock() + store, ok := s.Runtime.States[shardID] + return store, ok +} + +func sameProposalHeader(a, b sharding.GlobalHeader) bool { + return bytes.Equal(a.CanonicalBytes(), b.CanonicalBytes()) +} + +func validatorID(key *ecdsa.PrivateKey) (types.ValidatorID, error) { + if key == nil || key.Curve != elliptic.P256() || key.PublicKey.X == nil || key.PublicKey.Y == nil { + return types.ValidatorID{}, ErrNotValidator + } + public := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + return types.ValidatorIDFromPublicKey(public), nil +} + +func containsValidator(set v2consensus.ValidatorSet, id types.ValidatorID) bool { + for _, validator := range set.Validators { + if validator.ID == id { + return true + } + } + return false +} + +func cloneValidatorSet(set v2consensus.ValidatorSet) v2consensus.ValidatorSet { + out := v2consensus.ValidatorSet{Network: set.Network, Validators: make([]v2consensus.Validator, len(set.Validators))} + for i, validator := range set.Validators { + out.Validators[i] = validator + out.Validators[i].PublicKey = append([]byte(nil), validator.PublicKey...) + } + return out +} + +func cloneSnapshot(snapshot lightapi.Snapshot) lightapi.Snapshot { + out := snapshot + out.Certificate.Votes = append([]v2consensus.Vote(nil), snapshot.Certificate.Votes...) + out.Commitments = append([]sharding.Commitment(nil), snapshot.Commitments...) + out.Validators = cloneValidatorSet(snapshot.Validators) + return out +} diff --git a/internal/v2/node/service_test.go b/internal/v2/node/service_test.go new file mode 100644 index 00000000..75c7fc5d --- /dev/null +++ b/internal/v2/node/service_test.go @@ -0,0 +1,107 @@ +package node + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + "time" + + "github.com/libp2p/go-libp2p/core/peer" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + p2p "github.com/zephyr-chain/zephyr-chain/internal/v2/network/p2p" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestConsensusServiceFinalizesAcrossThreeQUICValidators(t *testing.T) { + networkID := types.NetworkID(types.HashBytes("network", []byte("service-three"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + keys := make([]*ecdsa.PrivateKey, 3) + validators := v2consensus.ValidatorSet{Network: networkID, Validators: make([]v2consensus.Validator, 3)} + for i := range keys { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + keys[i] = key + public := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + validators.Validators[i] = v2consensus.Validator{ID: types.ValidatorIDFromPublicKey(public), PublicKey: public, Power: 10} + } + validatorRoot, err := validators.Root() + if err != nil { + t.Fatal(err) + } + + nodes := make([]*p2p.Node, 3) + services := make([]*Service, 3) + for i := range nodes { + node, err := p2p.New(p2p.Config{Network: networkID, ListenAddrs: []string{"/ip4/127.0.0.1/udp/0/quic-v1"}, MaxMessageBytes: MaxNetworkMessageBytes}) + if err != nil { + t.Fatal(err) + } + nodes[i] = node + defer node.Close() + runtime, err := NewRuntime(networkID, native, validatorRoot, map[uint32]worldstate.Backend{0: worldstate.NewMemory()}, 2) + if err != nil { + t.Fatal(err) + } + service, err := NewService(runtime, validators, keys[i], node) + if err != nil { + t.Fatal(err) + } + services[i] = service + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for i := range nodes { + for j := range nodes { + if i == j { + continue + } + if err := nodes[i].Connect(ctx, nodes[j].AddrInfo()); err != nil { + t.Fatal(err) + } + } + peerIDs := make([]peer.ID, 0, len(nodes)-1) + for j := range nodes { + if i != j { + peerIDs = append(peerIDs, nodes[j].ID()) + } + } + services[i].SetPeers(peerIDs) + } + expected, err := validators.Proposer(1, 0) + if err != nil { + t.Fatal(err) + } + proposer := -1 + for i, key := range keys { + id, _ := validatorID(key) + if id == expected.ID { + proposer = i + break + } + } + if proposer < 0 { + t.Fatal("scheduled proposer not found") + } + snapshot, err := services[proposer].Propose(ctx) + if err != nil { + t.Fatal(err) + } + if snapshot.Header.Height != 1 { + t.Fatalf("unexpected finalized height %d", snapshot.Header.Height) + } + for i, service := range services { + if service.Runtime.Height != 1 { + t.Fatalf("validator %d did not commit height 1", i) + } + latest, err := service.LatestSnapshot() + if err != nil || latest.Header.Height != 1 { + t.Fatalf("validator %d has no finalized snapshot: %v", i, err) + } + } +} diff --git a/internal/v2/node/validator_transition.go b/internal/v2/node/validator_transition.go new file mode 100644 index 00000000..47532a13 --- /dev/null +++ b/internal/v2/node/validator_transition.go @@ -0,0 +1,42 @@ +package node + +import ( + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +// ScheduleValidatorTransition commits the next committee root into a candidate +// before proposal signing. The current committee still signs/finalizes this +// header; the next committee only becomes active for the following height. +func ScheduleValidatorTransition(candidate *Candidate, current, next v2consensus.ValidatorSet) error { + if candidate == nil || current.Network != candidate.Header.Network || next.Network != candidate.Header.Network { + return ErrCandidateCert + } + currentRoot, err := current.Root() + if err != nil || currentRoot != candidate.Header.ValidatorRoot { + return ErrCandidateCert + } + nextRoot, err := next.Root() + if err != nil || types.IsZero32([32]byte(nextRoot)) { + return ErrCandidateCert + } + candidate.Header.NextValidatorRoot = nextRoot + return nil +} + +// CommitWithValidatorTransition finalizes a candidate with the current +// validator set and, only after that QC-backed commit succeeds, advances the +// runtime trust root for the next height. If no NextValidatorRoot is present, +// the current root remains active. +func (r *Runtime) CommitWithValidatorTransition(candidate Candidate, certificate v2consensus.Certificate, current v2consensus.ValidatorSet) (sharding.GlobalHeader, error) { + finalized, err := r.Commit(candidate, certificate, current) + if err != nil { + return sharding.GlobalHeader{}, err + } + nextRoot := finalized.EffectiveNextValidatorRoot() + r.mu.Lock() + r.ValidatorRoot = nextRoot + r.mu.Unlock() + return finalized, nil +} diff --git a/internal/v2/node/validator_transition_test.go b/internal/v2/node/validator_transition_test.go new file mode 100644 index 00000000..3d4ab091 --- /dev/null +++ b/internal/v2/node/validator_transition_test.go @@ -0,0 +1,84 @@ +package node + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + v2consensus "github.com/zephyr-chain/zephyr-chain/internal/v2/consensus" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestValidatorTransitionActivatesOnlyAfterCurrentQC(t *testing.T) { + network := types.NetworkID(types.HashBytes("network", []byte("validator-transition"))) + native := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + currentKey, current := transitionValidatorSet(t, network, "current") + _, next := transitionValidatorSet(t, network, "next") + currentRoot, err := current.Root() + if err != nil { + t.Fatal(err) + } + nextRoot, err := next.Root() + if err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(network, native, currentRoot, map[uint32]worldstate.Backend{0: worldstate.NewMemory()}, 1) + if err != nil { + t.Fatal(err) + } + candidate, err := runtime.BuildCandidate(1, nil) + if err != nil { + t.Fatal(err) + } + if err := ScheduleValidatorTransition(&candidate, current, next); err != nil { + t.Fatal(err) + } + if candidate.Header.ValidatorRoot != currentRoot || candidate.Header.NextValidatorRoot != nextRoot { + t.Fatal("candidate did not bind current and next validator roots") + } + proposal, err := v2consensus.SignProposal(currentKey, candidate.Header, 0) + if err != nil { + t.Fatal(err) + } + vote, err := v2consensus.SignVote(currentKey, network, 1, 0, v2consensus.HeaderConsensusHash(candidate.Header)) + if err != nil { + t.Fatal(err) + } + certificate, err := current.BuildCertificate(proposal, []v2consensus.Vote{vote}) + if err != nil { + t.Fatal(err) + } + finalized, err := runtime.CommitWithValidatorTransition(candidate, certificate, current) + if err != nil { + t.Fatal(err) + } + if runtime.ValidatorRoot != nextRoot || finalized.EffectiveNextValidatorRoot() != nextRoot { + t.Fatal("next validator root did not activate after QC") + } + if err := v2consensus.VerifyCertifiedTransition(finalized, certificate, current, next); err != nil { + t.Fatal(err) + } + + candidate2, err := runtime.BuildCandidate(2, nil) + if err != nil { + t.Fatal(err) + } + if candidate2.Header.ValidatorRoot != nextRoot { + t.Fatal("following candidate does not use transitioned validator root") + } + _ = merkle.Root(nil) +} + +func transitionValidatorSet(t *testing.T, network types.NetworkID, label string) (*ecdsa.PrivateKey, v2consensus.ValidatorSet) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + id := types.ValidatorIDFromPublicKey(pub) + return key, v2consensus.ValidatorSet{Network: network, Validators: []v2consensus.Validator{{ID: id, PublicKey: pub, Power: uint64(len(label) + 1)}}} +} diff --git a/internal/v2/object/object.go b/internal/v2/object/object.go new file mode 100644 index 00000000..a414908d --- /dev/null +++ b/internal/v2/object/object.go @@ -0,0 +1,213 @@ +package object + +import ( + "errors" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +type Kind uint16 + +const ( + KindUnknown Kind = iota + KindCoin + KindTokenDefinition + KindContract + KindContractState + KindComputeOffer + KindComputeJob + KindComputeAssignment + KindComputeResult + KindSystem +) + +const MaxObjectDataBytes = 1 << 20 + +var ( + ErrInvalidObject = errors.New("invalid protocol object") + ErrInvalidCoin = errors.New("invalid coin object") +) + +type Object struct { + ID types.ObjectID + Version uint64 + Owner types.AccountID + Kind Kind + Data []byte +} + +type OutputSpec struct { + Owner types.AccountID + Kind Kind + Data []byte +} + +func (o Object) Validate() error { + if types.IsZero32([32]byte(o.ID)) || o.Version == 0 || o.Kind == KindUnknown || len(o.Data) > MaxObjectDataBytes { + return ErrInvalidObject + } + return validateOwnerForKind(o.Owner, o.Kind) +} + +func (o OutputSpec) Validate() error { + if o.Kind == KindUnknown || len(o.Data) > MaxObjectDataBytes { + return ErrInvalidObject + } + return validateOwnerForKind(o.Owner, o.Kind) +} + +func validateOwnerForKind(owner types.AccountID, kind Kind) error { + switch kind { + case KindSystem: + return nil + default: + if types.IsZero32([32]byte(owner)) { + return ErrInvalidObject + } + return nil + } +} + +func (o Object) CanonicalBytes() []byte { + var w codec.Writer + w.Fixed(o.ID[:]) + w.U64(o.Version) + w.Fixed(o.Owner[:]) + w.U16(uint16(o.Kind)) + w.Bytes(o.Data) + return w.BytesCopy() +} + +func ParseObject(data []byte) (Object, error) { + r := codec.NewReader(data) + idBytes, err := r.Fixed(32) + if err != nil { + return Object{}, ErrInvalidObject + } + version, err := r.U64() + if err != nil { + return Object{}, ErrInvalidObject + } + ownerBytes, err := r.Fixed(32) + if err != nil { + return Object{}, ErrInvalidObject + } + kind, err := r.U16() + if err != nil { + return Object{}, ErrInvalidObject + } + payload, err := r.Bytes(MaxObjectDataBytes) + if err != nil { + return Object{}, ErrInvalidObject + } + if err := r.Done(); err != nil { + return Object{}, ErrInvalidObject + } + var id types.ObjectID + var owner types.AccountID + copy(id[:], idBytes) + copy(owner[:], ownerBytes) + out := Object{ID: id, Version: version, Owner: owner, Kind: Kind(kind), Data: payload} + if err := out.Validate(); err != nil { + return Object{}, err + } + return out, nil +} + +func ParseOutputSpec(data []byte) (OutputSpec, error) { + r := codec.NewReader(data) + ownerBytes, err := r.Fixed(32) + if err != nil { + return OutputSpec{}, ErrInvalidObject + } + kind, err := r.U16() + if err != nil { + return OutputSpec{}, ErrInvalidObject + } + payload, err := r.Bytes(MaxObjectDataBytes) + if err != nil { + return OutputSpec{}, ErrInvalidObject + } + if err := r.Done(); err != nil { + return OutputSpec{}, ErrInvalidObject + } + var owner types.AccountID + copy(owner[:], ownerBytes) + out := OutputSpec{Owner: owner, Kind: Kind(kind), Data: payload} + if err := out.Validate(); err != nil { + return OutputSpec{}, err + } + return out, nil +} + +func (o Object) Hash() types.Hash { + return types.Hash(codec.DomainHash("zephyr/object/v2", o.CanonicalBytes())) +} + +func (o OutputSpec) CanonicalBytes() []byte { + var w codec.Writer + w.Fixed(o.Owner[:]) + w.U16(uint16(o.Kind)) + w.Bytes(o.Data) + return w.BytesCopy() +} + +func (o OutputSpec) Hash() types.Hash { + return types.Hash(codec.DomainHash("zephyr/output-spec/v2", o.CanonicalBytes())) +} + +type Coin struct { + Token types.TokenID + Amount uint64 + CreatedHeight uint64 +} + +func (c Coin) MarshalBinary() []byte { + var w codec.Writer + w.Fixed(c.Token[:]) + w.U64(c.Amount) + w.U64(c.CreatedHeight) + return w.BytesCopy() +} + +func ParseCoin(data []byte) (Coin, error) { + r := codec.NewReader(data) + tokenBytes, err := r.Fixed(32) + if err != nil { + return Coin{}, ErrInvalidCoin + } + amount, err := r.U64() + if err != nil || amount == 0 { + return Coin{}, ErrInvalidCoin + } + createdHeight, err := r.U64() + if err != nil { + return Coin{}, ErrInvalidCoin + } + if err := r.Done(); err != nil { + return Coin{}, ErrInvalidCoin + } + var token types.TokenID + copy(token[:], tokenBytes) + if types.IsZero32([32]byte(token)) { + return Coin{}, ErrInvalidCoin + } + return Coin{Token: token, Amount: amount, CreatedHeight: createdHeight}, nil +} + +func NewCoinOutput(owner types.AccountID, token types.TokenID, amount uint64) (OutputSpec, error) { + return NewCoinOutputAtHeight(owner, token, amount, 0) +} + +func NewCoinOutputAtHeight(owner types.AccountID, token types.TokenID, amount, createdHeight uint64) (OutputSpec, error) { + if types.IsZero32([32]byte(owner)) || types.IsZero32([32]byte(token)) || amount == 0 { + return OutputSpec{}, ErrInvalidCoin + } + out := OutputSpec{ + Owner: owner, + Kind: KindCoin, + Data: Coin{Token: token, Amount: amount, CreatedHeight: createdHeight}.MarshalBinary(), + } + return out, out.Validate() +} diff --git a/internal/v2/provider/service.go b/internal/v2/provider/service.go new file mode 100644 index 00000000..f3bd2714 --- /dev/null +++ b/internal/v2/provider/service.go @@ -0,0 +1,267 @@ +package provider + +import ( + "context" + "crypto/sha256" + "errors" + "strings" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + MaxCapabilityBytes = 64 + MaxInlineInput = 1 << 20 + MaxParameters = 1 << 20 + MaxInlineOutput = 1 << 20 +) + +var ( + ErrRequest = errors.New("invalid compute provider request") + ErrExecutor = errors.New("compute executor is unavailable") + ErrInputRoot = errors.New("compute input does not match declared root") + ErrOutput = errors.New("invalid compute provider output") + ErrDuplicateExec = errors.New("duplicate compute executor capability") +) + +type Request struct { + JobID types.JobID + WorkloadHash types.Hash + InputRoot types.Hash + Capability string + Input []byte + Parameters []byte +} + +type Response struct { + JobID types.JobID + ResultRoot types.Hash + Output []byte + Stored bool +} + +type Executor interface { + Capability() string + Execute(context.Context, []byte, []byte) ([]byte, error) +} + +type Store interface { + Put(types.Hash, []byte) error + Get(types.Hash) ([]byte, error) +} + +type Service struct { + mu sync.RWMutex + executors map[string]Executor + store Store +} + +func New(store Store, executors ...Executor) (*Service, error) { + if store == nil { + return nil, ErrRequest + } + s := &Service{executors: make(map[string]Executor), store: store} + for _, executor := range executors { + if err := s.Register(executor); err != nil { + return nil, err + } + } + return s, nil +} + +func (s *Service) Register(executor Executor) error { + if executor == nil { + return ErrExecutor + } + capability := strings.TrimSpace(executor.Capability()) + if capability == "" || len(capability) > MaxCapabilityBytes { + return ErrExecutor + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.executors[capability]; exists { + return ErrDuplicateExec + } + s.executors[capability] = executor + return nil +} + +func (s *Service) Handle(ctx context.Context, payload []byte) ([]byte, error) { + request, err := ParseRequest(payload) + if err != nil { + return nil, err + } + response, err := s.Execute(ctx, request) + if err != nil { + return nil, err + } + return response.MarshalBinary() +} + +func (s *Service) Execute(ctx context.Context, request Request) (Response, error) { + if err := request.Validate(); err != nil { + return Response{}, err + } + input := request.Input + if len(input) == 0 { + var err error + input, err = s.store.Get(request.InputRoot) + if err != nil { + return Response{}, ErrInputRoot + } + } + if InputRoot(input) != request.InputRoot { + return Response{}, ErrInputRoot + } + s.mu.RLock() + executor := s.executors[request.Capability] + s.mu.RUnlock() + if executor == nil { + return Response{}, ErrExecutor + } + output, err := executor.Execute(ctx, append([]byte(nil), input...), append([]byte(nil), request.Parameters...)) + if err != nil { + return Response{}, err + } + root := ResultRoot(output) + if err := s.store.Put(root, output); err != nil { + return Response{}, err + } + response := Response{JobID: request.JobID, ResultRoot: root, Stored: true} + if len(output) <= MaxInlineOutput { + response.Output = append([]byte(nil), output...) + } + return response, nil +} + +func (r Request) Validate() error { + capability := strings.TrimSpace(r.Capability) + if types.IsZero32([32]byte(r.JobID)) || types.IsZero32([32]byte(r.WorkloadHash)) || types.IsZero32([32]byte(r.InputRoot)) || capability == "" || len(capability) > MaxCapabilityBytes || len(r.Input) > MaxInlineInput || len(r.Parameters) > MaxParameters { + return ErrRequest + } + if len(r.Input) > 0 && InputRoot(r.Input) != r.InputRoot { + return ErrInputRoot + } + return nil +} + +func (r Request) MarshalBinary() ([]byte, error) { + if err := r.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.Fixed(r.JobID[:]) + w.Fixed(r.WorkloadHash[:]) + w.Fixed(r.InputRoot[:]) + w.String(strings.TrimSpace(r.Capability)) + w.Bytes(r.Input) + w.Bytes(r.Parameters) + return w.BytesCopy(), nil +} + +func ParseRequest(data []byte) (Request, error) { + r := codec.NewReader(data) + jobRaw, err := r.Fixed(32) + if err != nil { + return Request{}, ErrRequest + } + workloadRaw, err := r.Fixed(32) + if err != nil { + return Request{}, ErrRequest + } + inputRootRaw, err := r.Fixed(32) + if err != nil { + return Request{}, ErrRequest + } + capability, err := r.String(MaxCapabilityBytes) + if err != nil { + return Request{}, ErrRequest + } + input, err := r.Bytes(MaxInlineInput) + if err != nil { + return Request{}, ErrRequest + } + parameters, err := r.Bytes(MaxParameters) + if err != nil || r.Done() != nil { + return Request{}, ErrRequest + } + var jobID types.JobID + var workload, inputRoot types.Hash + copy(jobID[:], jobRaw) + copy(workload[:], workloadRaw) + copy(inputRoot[:], inputRootRaw) + request := Request{JobID: jobID, WorkloadHash: workload, InputRoot: inputRoot, Capability: capability, Input: input, Parameters: parameters} + if err := request.Validate(); err != nil { + return Request{}, err + } + return request, nil +} + +func (r Response) MarshalBinary() ([]byte, error) { + if types.IsZero32([32]byte(r.JobID)) || types.IsZero32([32]byte(r.ResultRoot)) || len(r.Output) > MaxInlineOutput { + return nil, ErrOutput + } + if len(r.Output) > 0 && ResultRoot(r.Output) != r.ResultRoot { + return nil, ErrOutput + } + var w codec.Writer + w.Fixed(r.JobID[:]) + w.Fixed(r.ResultRoot[:]) + w.Bool(r.Stored) + w.Bytes(r.Output) + return w.BytesCopy(), nil +} + +func ParseResponse(data []byte) (Response, error) { + r := codec.NewReader(data) + jobRaw, err := r.Fixed(32) + if err != nil { + return Response{}, ErrOutput + } + rootRaw, err := r.Fixed(32) + if err != nil { + return Response{}, ErrOutput + } + stored, err := r.Bool() + if err != nil { + return Response{}, ErrOutput + } + output, err := r.Bytes(MaxInlineOutput) + if err != nil || r.Done() != nil { + return Response{}, ErrOutput + } + var jobID types.JobID + var resultRoot types.Hash + copy(jobID[:], jobRaw) + copy(resultRoot[:], rootRaw) + response := Response{JobID: jobID, ResultRoot: resultRoot, Stored: stored, Output: output} + if _, err := response.MarshalBinary(); err != nil { + return Response{}, err + } + return response, nil +} + +func InputRoot(input []byte) types.Hash { + return types.Hash(codec.DomainHash("zephyr/compute/input/v2", input)) +} + +func ResultRoot(output []byte) types.Hash { + return types.Hash(codec.DomainHash("zephyr/compute/result/v2", output)) +} + +type HashExecutor struct{} + +func (HashExecutor) Capability() string { return "sha256" } +func (HashExecutor) Execute(_ context.Context, input, _ []byte) ([]byte, error) { + hash := sha256.Sum256(input) + return hash[:], nil +} + +type IdentityExecutor struct{} + +func (IdentityExecutor) Capability() string { return "identity" } +func (IdentityExecutor) Execute(_ context.Context, input, _ []byte) ([]byte, error) { + return append([]byte(nil), input...), nil +} diff --git a/internal/v2/provider/service_test.go b/internal/v2/provider/service_test.go new file mode 100644 index 00000000..3626dfa8 --- /dev/null +++ b/internal/v2/provider/service_test.go @@ -0,0 +1,57 @@ +package provider + +import ( + "context" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestProviderExecutesRegisteredCapabilityAndCommitsResult(t *testing.T) { + store := DiskStore{Dir: t.TempDir()} + service, err := New(store, HashExecutor{}, IdentityExecutor{}) + if err != nil { + t.Fatal(err) + } + input := []byte("scientific-work-unit") + request := Request{ + JobID: types.JobID(types.HashBytes("job", []byte("provider"))), + WorkloadHash: types.HashBytes("workload", []byte("sha256")), + InputRoot: InputRoot(input), Capability: "sha256", Input: input, + } + wire, err := request.MarshalBinary() + if err != nil { + t.Fatal(err) + } + responseWire, err := service.Handle(context.Background(), wire) + if err != nil { + t.Fatal(err) + } + response, err := ParseResponse(responseWire) + if err != nil { + t.Fatal(err) + } + if response.JobID != request.JobID || !response.Stored || ResultRoot(response.Output) != response.ResultRoot { + t.Fatalf("unexpected provider response: %+v", response) + } + stored, err := store.Get(response.ResultRoot) + if err != nil || ResultRoot(stored) != response.ResultRoot { + t.Fatal("content-addressed result was not persisted") + } +} + +func TestProviderRejectsUnknownExecutorAndTamperedInput(t *testing.T) { + service, err := New(DiskStore{Dir: t.TempDir()}, HashExecutor{}) + if err != nil { + t.Fatal(err) + } + base := Request{JobID: types.JobID(types.HashBytes("job", []byte("reject"))), WorkloadHash: types.HashBytes("workload", []byte("reject")), InputRoot: InputRoot([]byte("good")), Capability: "unknown", Input: []byte("good")} + if _, err := service.Execute(context.Background(), base); err != ErrExecutor { + t.Fatalf("expected unknown executor rejection, got %v", err) + } + base.Capability = "sha256" + base.Input = []byte("tampered") + if _, err := service.Execute(context.Background(), base); err != ErrInputRoot { + t.Fatalf("expected input-root rejection, got %v", err) + } +} diff --git a/internal/v2/provider/store.go b/internal/v2/provider/store.go new file mode 100644 index 00000000..b80b00df --- /dev/null +++ b/internal/v2/provider/store.go @@ -0,0 +1,59 @@ +package provider + +import ( + "errors" + "os" + "path/filepath" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrStore = errors.New("compute provider content store error") + +type DiskStore struct{ Dir string } + +func (s DiskStore) Put(root types.Hash, data []byte) error { + if s.Dir == "" || types.IsZero32([32]byte(root)) || ResultRoot(data) != root && InputRoot(data) != root { + return ErrStore + } + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return err + } + path := filepath.Join(s.Dir, root.String()) + tmp, err := os.CreateTemp(s.Dir, ".zephyr-provider-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +func (s DiskStore) Get(root types.Hash) ([]byte, error) { + if s.Dir == "" || types.IsZero32([32]byte(root)) { + return nil, ErrStore + } + data, err := os.ReadFile(filepath.Join(s.Dir, root.String())) + if err != nil { + return nil, err + } + if InputRoot(data) != root && ResultRoot(data) != root { + return nil, ErrStore + } + return data, nil +} diff --git a/internal/v2/sharding/global_header_binary.go b/internal/v2/sharding/global_header_binary.go new file mode 100644 index 00000000..05ea90ec --- /dev/null +++ b/internal/v2/sharding/global_header_binary.go @@ -0,0 +1,12 @@ +package sharding + +// MarshalBinary returns the canonical wire representation accepted by +// ParseGlobalHeader. Validation is performed before returning bytes so durable +// journal records cannot encode a header that the recovery path would reject. +func (h GlobalHeader) MarshalBinary() ([]byte, error) { + raw := h.CanonicalBytes() + if _, err := ParseGlobalHeader(raw); err != nil { + return nil, err + } + return raw, nil +} diff --git a/internal/v2/sharding/receipts.go b/internal/v2/sharding/receipts.go new file mode 100644 index 00000000..8e3e03fd --- /dev/null +++ b/internal/v2/sharding/receipts.go @@ -0,0 +1,148 @@ +package sharding + +import ( + "errors" + "sort" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ( + ErrReceiptProof = errors.New("invalid cross-shard receipt proof") + ErrReceiptReplay = errors.New("cross-shard receipt already consumed") +) + +type ReceiptBatch struct { + Receipts []CrossShardReceipt +} + +func (b ReceiptBatch) Root() (types.Hash, error) { + hashes, _, err := b.sortedHashes() + if err != nil { + return types.Hash{}, err + } + return merkle.Root(hashes), nil +} + +func (b ReceiptBatch) Proof(receipt CrossShardReceipt) (merkle.Proof, error) { + target, err := receipt.Hash() + if err != nil { + return merkle.Proof{}, err + } + hashes, _, err := b.sortedHashes() + if err != nil { + return merkle.Proof{}, err + } + for i, hash := range hashes { + if hash == target { + return merkle.BuildProof(hashes, i) + } + } + return merkle.Proof{}, ErrReceiptProof +} + +func (b ReceiptBatch) sortedHashes() ([]types.Hash, []CrossShardReceipt, error) { + items := append([]CrossShardReceipt(nil), b.Receipts...) + for _, receipt := range items { + if err := receipt.Validate(); err != nil { + return nil, nil, err + } + } + sort.Slice(items, func(i, j int) bool { + a, _ := items[i].Hash() + b, _ := items[j].Hash() + return a.String() < b.String() + }) + hashes := make([]types.Hash, len(items)) + for i, receipt := range items { + hashes[i], _ = receipt.Hash() + if i > 0 && hashes[i] == hashes[i-1] { + return nil, nil, ErrReceiptReplay + } + } + return hashes, items, nil +} + +// VerifyFinalizedReceipt proves both that the source shard commitment belongs +// to a finalized global header and that the receipt belongs to that shard's +// receipt root. A destination shard therefore never trusts the source shard by +// assertion alone. +func VerifyFinalizedReceipt(header GlobalHeader, commitment Commitment, commitmentProof merkle.Proof, receipt CrossShardReceipt, receiptProof merkle.Proof) error { + if err := receipt.Validate(); err != nil { + return err + } + if commitment.ShardID != receipt.SourceShard || header.Height != receipt.SourceHeight { + return ErrReceiptProof + } + if !merkle.Verify(header.ShardCommitmentRoot, commitment.Hash(), commitmentProof) { + return ErrReceiptProof + } + receiptHash, err := receipt.Hash() + if err != nil { + return err + } + if !merkle.Verify(commitment.ReceiptRoot, receiptHash, receiptProof) { + return ErrReceiptProof + } + return nil +} + +// ReceiptMarker is a consensus-state object proving that a finalized receipt +// has already been consumed on its destination shard. Keeping this marker in +// the same Merkle state as balances makes anti-replay survive restart, +// checkpoint and snapshot recovery. +func ReceiptMarker(receipt CrossShardReceipt) (object.Object, error) { + hash, err := receipt.Hash() + if err != nil { + return object.Object{}, err + } + return object.Object{ + ID: types.ObjectIDForShard(hash, 0xfffffffe, receipt.DestinationShard), + Version: 1, + Kind: object.KindSystem, + Data: append([]byte(nil), hash[:]...), + }, nil +} + +// ReceiptTracker is useful for transport/in-process duplicate suppression, but +// it is not the consensus anti-replay source of truth. Consensus uses +// ReceiptMarker in destination shard state. +type ReceiptTracker struct { + mu sync.Mutex + consumed map[types.Hash]uint64 +} + +func NewReceiptTracker() *ReceiptTracker { + return &ReceiptTracker{consumed: make(map[types.Hash]uint64)} +} + +func (t *ReceiptTracker) Consume(destinationShard uint32, header GlobalHeader, commitment Commitment, commitmentProof merkle.Proof, receipt CrossShardReceipt, receiptProof merkle.Proof) error { + if receipt.DestinationShard != destinationShard { + return ErrReceiptProof + } + if err := VerifyFinalizedReceipt(header, commitment, commitmentProof, receipt, receiptProof); err != nil { + return err + } + hash, _ := receipt.Hash() + t.mu.Lock() + defer t.mu.Unlock() + if _, exists := t.consumed[hash]; exists { + return ErrReceiptReplay + } + t.consumed[hash] = header.Height + return nil +} + +func (t *ReceiptTracker) Consumed(receipt CrossShardReceipt) bool { + hash, err := receipt.Hash() + if err != nil { + return false + } + t.mu.Lock() + defer t.mu.Unlock() + _, ok := t.consumed[hash] + return ok +} diff --git a/internal/v2/sharding/receipts_test.go b/internal/v2/sharding/receipts_test.go new file mode 100644 index 00000000..e55d53a6 --- /dev/null +++ b/internal/v2/sharding/receipts_test.go @@ -0,0 +1,54 @@ +package sharding + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestFinalizedCrossShardReceiptAndReplayProtection(t *testing.T) { + owner := types.AccountIDFromPublicKey([]byte("receipt-owner")) + token := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + output, err := object.NewCoinOutput(owner, token, 25) + if err != nil { + t.Fatal(err) + } + receipt := CrossShardReceipt{ + SourceShard: 0, DestinationShard: 1, SourceHeight: 7, + TransactionID: types.HashBytes("tx", []byte("cross-shard")), OutputIndex: 0, + Output: output, SourceStateRoot: types.HashBytes("state", []byte("source")), + } + batch := ReceiptBatch{Receipts: []CrossShardReceipt{receipt}} + receiptRoot, err := batch.Root() + if err != nil { + t.Fatal(err) + } + receiptProof, err := batch.Proof(receipt) + if err != nil { + t.Fatal(err) + } + commitments := []Commitment{ + {ShardID: 0, StateRoot: receipt.SourceStateRoot, ReceiptRoot: receiptRoot, DataRoot: types.HashBytes("data", []byte("0"))}, + {ShardID: 1, StateRoot: types.HashBytes("state", []byte("dest")), ReceiptRoot: types.HashBytes("receipt", []byte("empty")), DataRoot: types.HashBytes("data", []byte("1"))}, + } + commitmentRoot, err := CommitmentRoot(commitments) + if err != nil { + t.Fatal(err) + } + commitment, commitmentProof, err := CommitmentProof(commitments, 0) + if err != nil { + t.Fatal(err) + } + header := GlobalHeader{Version: 2, Network: types.NetworkID(types.HashBytes("network", []byte("v2"))), Height: 7, ShardCommitmentRoot: commitmentRoot} + tracker := NewReceiptTracker() + if err := tracker.Consume(1, header, commitment, commitmentProof, receipt, receiptProof); err != nil { + t.Fatal(err) + } + if !tracker.Consumed(receipt) { + t.Fatal("receipt was not recorded as consumed") + } + if err := tracker.Consume(1, header, commitment, commitmentProof, receipt, receiptProof); err != ErrReceiptReplay { + t.Fatalf("expected replay rejection, got %v", err) + } +} diff --git a/internal/v2/sharding/sharding.go b/internal/v2/sharding/sharding.go new file mode 100644 index 00000000..64527b82 --- /dev/null +++ b/internal/v2/sharding/sharding.go @@ -0,0 +1,204 @@ +package sharding + +import ( + "bytes" + "errors" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ( + ErrShardCount = errors.New("invalid shard count") + ErrReceipt = errors.New("invalid cross-shard receipt") +) + +type Router struct { + ShardCount uint32 +} + +func (r Router) ShardForAccount(account types.AccountID) (uint32, error) { + if r.ShardCount == 0 || types.IsZero32([32]byte(account)) { + return 0, ErrShardCount + } + return types.AccountShard(account, r.ShardCount), nil +} + +func (r Router) ShardForObject(id types.ObjectID) (uint32, error) { + if r.ShardCount == 0 || types.IsZero32([32]byte(id)) { + return 0, ErrShardCount + } + shard := types.ObjectShard(id) + if shard >= r.ShardCount { + return 0, ErrShardCount + } + return shard, nil +} + +type Commitment struct { + ShardID uint32 + StateRoot types.Hash + ReceiptRoot types.Hash + DataRoot types.Hash +} + +func (c Commitment) CanonicalBytes() []byte { + var w codec.Writer + w.U32(c.ShardID) + w.Fixed(c.StateRoot[:]) + w.Fixed(c.ReceiptRoot[:]) + w.Fixed(c.DataRoot[:]) + return w.BytesCopy() +} + +func (c Commitment) Hash() types.Hash { + return merkle.Leaf("shard-commitment", c.CanonicalBytes()) +} + +func CommitmentRoot(commitments []Commitment) (types.Hash, error) { + sorted, err := sortedCommitments(commitments) + if err != nil { + return types.Hash{}, err + } + leaves := make([]types.Hash, len(sorted)) + for i, c := range sorted { + leaves[i] = c.Hash() + } + return merkle.Root(leaves), nil +} + +func CommitmentProof(commitments []Commitment, shardID uint32) (Commitment, merkle.Proof, error) { + sorted, err := sortedCommitments(commitments) + if err != nil { + return Commitment{}, merkle.Proof{}, err + } + for i, c := range sorted { + if c.ShardID == shardID { + leaves := make([]types.Hash, len(sorted)) + for j, item := range sorted { + leaves[j] = item.Hash() + } + proof, err := merkle.BuildProof(leaves, i) + return c, proof, err + } + } + return Commitment{}, merkle.Proof{}, ErrReceipt +} + +type GlobalHeader struct { + Version uint16 + Network types.NetworkID + Height uint64 + ParentHash types.Hash + ShardCommitmentRoot types.Hash + ValidatorRoot types.Hash + NextValidatorRoot types.Hash + DataRoot types.Hash + CertificateHash types.Hash +} + +// EffectiveNextValidatorRoot returns the committee root authorized for the +// following height. A zero next-root means "no rotation" and therefore keeps +// the current committee active. +func (h GlobalHeader) EffectiveNextValidatorRoot() types.Hash { + if types.IsZero32([32]byte(h.NextValidatorRoot)) { + return h.ValidatorRoot + } + return h.NextValidatorRoot +} + +func (h GlobalHeader) CanonicalBytes() []byte { + var w codec.Writer + w.U16(h.Version) + w.Fixed(h.Network[:]) + w.U64(h.Height) + w.Fixed(h.ParentHash[:]) + w.Fixed(h.ShardCommitmentRoot[:]) + w.Fixed(h.ValidatorRoot[:]) + w.Fixed(h.NextValidatorRoot[:]) + w.Fixed(h.DataRoot[:]) + w.Fixed(h.CertificateHash[:]) + return w.BytesCopy() +} + +func (h GlobalHeader) Hash() types.Hash { + return types.Hash(codec.DomainHash("zephyr/global-header/v2", h.CanonicalBytes())) +} + +type CrossShardReceipt struct { + SourceShard uint32 + DestinationShard uint32 + SourceHeight uint64 + TransactionID types.Hash + OutputIndex uint32 + Output object.OutputSpec + SourceStateRoot types.Hash +} + +func (r CrossShardReceipt) Validate() error { + if r.SourceShard == r.DestinationShard || r.SourceHeight == 0 || + types.IsZero32([32]byte(r.TransactionID)) || types.IsZero32([32]byte(r.SourceStateRoot)) { + return ErrReceipt + } + return r.Output.Validate() +} + +func (r CrossShardReceipt) CanonicalBytes() ([]byte, error) { + if err := r.Validate(); err != nil { + return nil, err + } + var w codec.Writer + w.U32(r.SourceShard) + w.U32(r.DestinationShard) + w.U64(r.SourceHeight) + w.Fixed(r.TransactionID[:]) + w.U32(r.OutputIndex) + w.Bytes(r.Output.CanonicalBytes()) + w.Fixed(r.SourceStateRoot[:]) + return w.BytesCopy(), nil +} + +func (r CrossShardReceipt) Hash() (types.Hash, error) { + payload, err := r.CanonicalBytes() + if err != nil { + return types.Hash{}, err + } + return merkle.Leaf("cross-shard-receipt", payload), nil +} + +func (r CrossShardReceipt) DestinationObject() (object.Object, error) { + if err := r.Validate(); err != nil { + return object.Object{}, err + } + return object.Object{ + ID: types.ObjectIDForShard(r.TransactionID, r.OutputIndex, r.DestinationShard), + Version: 1, + Owner: r.Output.Owner, + Kind: r.Output.Kind, + Data: append([]byte(nil), r.Output.Data...), + }, nil +} + +func sortedCommitments(in []Commitment) ([]Commitment, error) { + if len(in) == 0 { + return nil, ErrShardCount + } + out := append([]Commitment(nil), in...) + sort.Slice(out, func(i, j int) bool { return out[i].ShardID < out[j].ShardID }) + for i := range out { + if i > 0 && out[i-1].ShardID == out[i].ShardID { + return nil, ErrShardCount + } + if types.IsZero32([32]byte(out[i].StateRoot)) { + return nil, ErrShardCount + } + } + return out, nil +} + +func SameCommitment(a, b Commitment) bool { + return a.ShardID == b.ShardID && bytes.Equal(a.CanonicalBytes(), b.CanonicalBytes()) +} diff --git a/internal/v2/sharding/sharding_test.go b/internal/v2/sharding/sharding_test.go new file mode 100644 index 00000000..cf4532f0 --- /dev/null +++ b/internal/v2/sharding/sharding_test.go @@ -0,0 +1,27 @@ +package sharding + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/merkle" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestShardCommitmentProof(t *testing.T) { + commitments := []Commitment{ + {ShardID: 2, StateRoot: types.HashBytes("s", []byte("2")), ReceiptRoot: types.HashBytes("r", []byte("2")), DataRoot: types.HashBytes("d", []byte("2"))}, + {ShardID: 0, StateRoot: types.HashBytes("s", []byte("0")), ReceiptRoot: types.HashBytes("r", []byte("0")), DataRoot: types.HashBytes("d", []byte("0"))}, + {ShardID: 1, StateRoot: types.HashBytes("s", []byte("1")), ReceiptRoot: types.HashBytes("r", []byte("1")), DataRoot: types.HashBytes("d", []byte("1"))}, + } + root, err := CommitmentRoot(commitments) + if err != nil { + t.Fatal(err) + } + c, proof, err := CommitmentProof(commitments, 1) + if err != nil { + t.Fatal(err) + } + if !merkle.Verify(root, c.Hash(), proof) { + t.Fatal("commitment proof failed") + } +} diff --git a/internal/v2/sharding/wire.go b/internal/v2/sharding/wire.go new file mode 100644 index 00000000..8be2ea92 --- /dev/null +++ b/internal/v2/sharding/wire.go @@ -0,0 +1,126 @@ +package sharding + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func ParseCommitment(data []byte) (Commitment, error) { + r := codec.NewReader(data) + shardID, err := r.U32() + if err != nil { + return Commitment{}, ErrShardCount + } + stateRoot, err := readHash(r) + if err != nil { + return Commitment{}, ErrShardCount + } + receiptRoot, err := readHash(r) + if err != nil { + return Commitment{}, ErrShardCount + } + dataRoot, err := readHash(r) + if err != nil || r.Done() != nil || types.IsZero32([32]byte(stateRoot)) { + return Commitment{}, ErrShardCount + } + return Commitment{ShardID: shardID, StateRoot: stateRoot, ReceiptRoot: receiptRoot, DataRoot: dataRoot}, nil +} + +func ParseGlobalHeader(data []byte) (GlobalHeader, error) { + r := codec.NewReader(data) + version, err := r.U16() + if err != nil || version != 2 { + return GlobalHeader{}, ErrShardCount + } + networkBytes, err := r.Fixed(32) + if err != nil { + return GlobalHeader{}, ErrShardCount + } + var network types.NetworkID + copy(network[:], networkBytes) + height, err := r.U64() + if err != nil || height == 0 { + return GlobalHeader{}, ErrShardCount + } + parentHash, err := readHash(r) + if err != nil { + return GlobalHeader{}, ErrShardCount + } + shardRoot, err := readHash(r) + if err != nil { + return GlobalHeader{}, ErrShardCount + } + validatorRoot, err := readHash(r) + if err != nil { + return GlobalHeader{}, ErrShardCount + } + nextValidatorRoot, err := readHash(r) + if err != nil { + return GlobalHeader{}, ErrShardCount + } + dataRoot, err := readHash(r) + if err != nil { + return GlobalHeader{}, ErrShardCount + } + certificateHash, err := readHash(r) + if err != nil || r.Done() != nil || types.IsZero32([32]byte(network)) || types.IsZero32([32]byte(shardRoot)) || types.IsZero32([32]byte(validatorRoot)) { + return GlobalHeader{}, ErrShardCount + } + return GlobalHeader{ + Version: version, Network: network, Height: height, ParentHash: parentHash, + ShardCommitmentRoot: shardRoot, ValidatorRoot: validatorRoot, NextValidatorRoot: nextValidatorRoot, + DataRoot: dataRoot, CertificateHash: certificateHash, + }, nil +} + +func ParseCrossShardReceipt(data []byte) (CrossShardReceipt, error) { + r := codec.NewReader(data) + source, err := r.U32() + if err != nil { + return CrossShardReceipt{}, ErrReceipt + } + destination, err := r.U32() + if err != nil { + return CrossShardReceipt{}, ErrReceipt + } + height, err := r.U64() + if err != nil { + return CrossShardReceipt{}, ErrReceipt + } + txHash, err := readHash(r) + if err != nil { + return CrossShardReceipt{}, ErrReceipt + } + index, err := r.U32() + if err != nil { + return CrossShardReceipt{}, ErrReceipt + } + outputBytes, err := r.Bytes(object.MaxObjectDataBytes + 64) + if err != nil { + return CrossShardReceipt{}, ErrReceipt + } + output, err := object.ParseOutputSpec(outputBytes) + if err != nil { + return CrossShardReceipt{}, ErrReceipt + } + stateRoot, err := readHash(r) + if err != nil || r.Done() != nil { + return CrossShardReceipt{}, ErrReceipt + } + receipt := CrossShardReceipt{SourceShard: source, DestinationShard: destination, SourceHeight: height, TransactionID: txHash, OutputIndex: index, Output: output, SourceStateRoot: stateRoot} + if err := receipt.Validate(); err != nil { + return CrossShardReceipt{}, err + } + return receipt, nil +} + +func readHash(r *codec.Reader) (types.Hash, error) { + raw, err := r.Fixed(32) + if err != nil { + return types.Hash{}, err + } + var hash types.Hash + copy(hash[:], raw) + return hash, nil +} diff --git a/internal/v2/state/clone.go b/internal/v2/state/clone.go new file mode 100644 index 00000000..367c3326 --- /dev/null +++ b/internal/v2/state/clone.go @@ -0,0 +1,23 @@ +package state + +import "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + +// Clone returns an independent copy of the sparse Merkle tree. It is used by +// proposal simulation so validators can calculate a candidate state root +// without mutating committed state before a quorum certificate exists. +func (t *Tree) Clone() *Tree { + t.mu.RLock() + defer t.mu.RUnlock() + clone := &Tree{ + values: make(map[types.Hash][]byte, len(t.values)), + nodes: make(map[nodeKey]types.Hash, len(t.nodes)), + defaults: t.defaults, + } + for key, value := range t.values { + clone.values[key] = append([]byte(nil), value...) + } + for key, value := range t.nodes { + clone.nodes[key] = value + } + return clone +} diff --git a/internal/v2/state/preview.go b/internal/v2/state/preview.go new file mode 100644 index 00000000..9d4b7ff0 --- /dev/null +++ b/internal/v2/state/preview.go @@ -0,0 +1,72 @@ +package state + +import ( + "bytes" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +// Preview returns the state root that would result from applying updates while +// leaving the committed tree untouched. It stores only nodes on affected paths +// in an overlay instead of cloning the full tree, which keeps proposal +// simulation proportional to the changed working set rather than total state. +func (t *Tree) Preview(updates map[types.Hash][]byte) types.Hash { + t.mu.RLock() + defer t.mu.RUnlock() + if len(updates) == 0 { + return t.getNode(0, [32]byte{}) + } + + keys := make([]types.Hash, 0, len(updates)) + for key := range updates { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return bytes.Compare(keys[i][:], keys[j][:]) < 0 }) + + overlay := make(map[nodeKey]types.Hash, len(keys)*Depth/2) + lookup := func(depth int, prefix [32]byte) types.Hash { + key := nodeKey{Depth: uint16(depth), Prefix: prefixAtDepth(prefix, depth)} + if hash, ok := overlay[key]; ok { + return hash + } + if hash, ok := t.nodes[key]; ok { + return hash + } + return t.defaults[depth] + } + + for _, key := range keys { + rawKey := [32]byte(key) + leafKey := nodeKey{Depth: Depth, Prefix: rawKey} + value := updates[key] + if value == nil { + overlay[leafKey] = t.defaults[Depth] + } else { + overlay[leafKey] = leafHash(key, value) + } + + for depth := Depth - 1; depth >= 0; depth-- { + bit := bitAt(rawKey, depth) + childPrefix := prefixAtDepth(rawKey, depth+1) + siblingPrefix := childPrefix + toggleBit(&siblingPrefix, depth) + child := lookup(depth+1, childPrefix) + sibling := lookup(depth+1, siblingPrefix) + var left, right types.Hash + if bit == 0 { + left, right = child, sibling + } else { + left, right = sibling, child + } + parentKey := nodeKey{Depth: uint16(depth), Prefix: prefixAtDepth(rawKey, depth)} + overlay[parentKey] = branchHash(left, right) + } + } + + rootKey := nodeKey{Depth: 0, Prefix: [32]byte{}} + if root, ok := overlay[rootKey]; ok { + return root + } + return t.getNode(0, [32]byte{}) +} diff --git a/internal/v2/state/preview_test.go b/internal/v2/state/preview_test.go new file mode 100644 index 00000000..16170fb2 --- /dev/null +++ b/internal/v2/state/preview_test.go @@ -0,0 +1,52 @@ +package state + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestPreviewMatchesApplyWithoutMutatingCommittedTree(t *testing.T) { + tree := NewTree() + keyA := types.HashBytes("key", []byte("a")) + keyB := types.HashBytes("key", []byte("b")) + keyC := types.HashBytes("key", []byte("c")) + keyD := types.HashBytes("key", []byte("d")) + tree.Apply(map[types.Hash][]byte{ + keyA: []byte("one"), + keyB: []byte("two"), + keyC: []byte("three"), + }) + before := tree.Root() + updates := map[types.Hash][]byte{ + keyA: []byte("ONE"), + keyB: nil, + keyD: []byte("four"), + } + + preview := tree.Preview(updates) + if tree.Root() != before { + t.Fatal("preview mutated committed sparse Merkle root") + } + if value, ok := tree.Get(keyA); !ok || string(value) != "one" { + t.Fatal("preview mutated committed values") + } + + clone := tree.Clone() + applied := clone.Apply(updates) + if preview != applied { + t.Fatalf("preview root %s does not match applied root %s", preview, applied) + } + if preview == before { + t.Fatal("preview did not reflect updates") + } +} + +func TestPreviewEmptyUpdatesReturnsCurrentRoot(t *testing.T) { + tree := NewTree() + key := types.HashBytes("key", []byte("only")) + tree.Update(key, []byte("value")) + if got := tree.Preview(nil); got != tree.Root() { + t.Fatalf("empty preview changed root: %s != %s", got, tree.Root()) + } +} diff --git a/internal/v2/state/smt.go b/internal/v2/state/smt.go new file mode 100644 index 00000000..8b6799fd --- /dev/null +++ b/internal/v2/state/smt.go @@ -0,0 +1,312 @@ +package state + +import ( + "bytes" + "errors" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const Depth = 256 + +var ( + ErrInvalidProof = errors.New("invalid sparse merkle proof") + ErrProofValueMismatch = errors.New("proof existence does not match supplied value") +) + +type nodeKey struct { + Depth uint16 + Prefix [32]byte +} + +type Proof struct { + Exists bool + Bitmap [32]byte + Siblings []types.Hash +} + +type Tree struct { + mu sync.RWMutex + values map[types.Hash][]byte + nodes map[nodeKey]types.Hash + defaults [Depth + 1]types.Hash +} + +func NewTree() *Tree { + t := &Tree{ + values: make(map[types.Hash][]byte), + nodes: make(map[nodeKey]types.Hash), + } + t.defaults[Depth] = types.Hash(codec.DomainHash("zephyr/smt/empty-leaf/v2", nil)) + for d := Depth - 1; d >= 0; d-- { + t.defaults[d] = branchHash(t.defaults[d+1], t.defaults[d+1]) + } + return t +} + +func (t *Tree) Root() types.Hash { + t.mu.RLock() + defer t.mu.RUnlock() + return t.getNode(0, [32]byte{}) +} + +func (t *Tree) Get(key types.Hash) ([]byte, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + v, ok := t.values[key] + if !ok { + return nil, false + } + out := append([]byte(nil), v...) + return out, true +} + +func (t *Tree) Update(key types.Hash, value []byte) types.Hash { + t.mu.Lock() + defer t.mu.Unlock() + t.updateLocked(key, value) + return t.getNode(0, [32]byte{}) +} + +func (t *Tree) Apply(updates map[types.Hash][]byte) types.Hash { + t.mu.Lock() + defer t.mu.Unlock() + for key, value := range updates { + t.updateLocked(key, value) + } + return t.getNode(0, [32]byte{}) +} + +func (t *Tree) updateLocked(key types.Hash, value []byte) { + rawKey := [32]byte(key) + leafPrefix := prefixAtDepth(rawKey, Depth) + leafNode := nodeKey{Depth: Depth, Prefix: leafPrefix} + if value == nil { + delete(t.values, key) + delete(t.nodes, leafNode) + } else { + copyValue := append([]byte(nil), value...) + t.values[key] = copyValue + h := leafHash(key, copyValue) + t.nodes[leafNode] = h + } + + for depth := Depth - 1; depth >= 0; depth-- { + bit := bitAt(rawKey, depth) + childPrefix := prefixAtDepth(rawKey, depth+1) + siblingPrefix := childPrefix + toggleBit(&siblingPrefix, depth) + child := t.getNode(depth+1, childPrefix) + sibling := t.getNode(depth+1, siblingPrefix) + + var left, right types.Hash + if bit == 0 { + left, right = child, sibling + } else { + left, right = sibling, child + } + parent := branchHash(left, right) + parentKey := nodeKey{Depth: uint16(depth), Prefix: prefixAtDepth(rawKey, depth)} + if parent == t.defaults[depth] { + delete(t.nodes, parentKey) + } else { + t.nodes[parentKey] = parent + } + } +} + +func (t *Tree) Prove(key types.Hash) Proof { + t.mu.RLock() + defer t.mu.RUnlock() + + rawKey := [32]byte(key) + _, exists := t.values[key] + proof := Proof{Exists: exists} + for i := 0; i < Depth; i++ { + depth := Depth - i + siblingPrefix := prefixAtDepth(rawKey, depth) + toggleBit(&siblingPrefix, depth-1) + sibling := t.getNode(depth, siblingPrefix) + if sibling != t.defaults[depth] { + setBitmapBit(&proof.Bitmap, i) + proof.Siblings = append(proof.Siblings, sibling) + } + } + return proof +} + +func (p Proof) MarshalBinary() []byte { + var w codec.Writer + w.Bool(p.Exists) + w.Fixed(p.Bitmap[:]) + w.U16(uint16(len(p.Siblings))) + for _, sibling := range p.Siblings { + w.Fixed(sibling[:]) + } + return w.BytesCopy() +} + +func ParseProof(data []byte) (Proof, error) { + r := codec.NewReader(data) + exists, err := r.Bool() + if err != nil { + return Proof{}, ErrInvalidProof + } + bitmap, err := r.Fixed(32) + if err != nil { + return Proof{}, ErrInvalidProof + } + count, err := r.U16() + if err != nil || count > Depth { + return Proof{}, ErrInvalidProof + } + proof := Proof{Exists: exists, Siblings: make([]types.Hash, int(count))} + copy(proof.Bitmap[:], bitmap) + for i := range proof.Siblings { + raw, err := r.Fixed(32) + if err != nil { + return Proof{}, ErrInvalidProof + } + copy(proof.Siblings[i][:], raw) + } + if err := r.Done(); err != nil { + return Proof{}, ErrInvalidProof + } + bits := 0 + for i := 0; i < Depth; i++ { + if bitmapBit(proof.Bitmap, i) { + bits++ + } + } + if bits != len(proof.Siblings) { + return Proof{}, ErrInvalidProof + } + return proof, nil +} + +func Verify(root, key types.Hash, value []byte, proof Proof) bool { + if proof.Exists != (value != nil) { + return false + } + defaults := defaultHashes() + rawKey := [32]byte(key) + var current types.Hash + if proof.Exists { + current = leafHash(key, value) + } else { + current = defaults[Depth] + } + + siblingIndex := 0 + for i := 0; i < Depth; i++ { + depth := Depth - i + sibling := defaults[depth] + if bitmapBit(proof.Bitmap, i) { + if siblingIndex >= len(proof.Siblings) { + return false + } + sibling = proof.Siblings[siblingIndex] + siblingIndex++ + } + + bitIndex := depth - 1 + if bitAt(rawKey, bitIndex) == 0 { + current = branchHash(current, sibling) + } else { + current = branchHash(sibling, current) + } + } + return siblingIndex == len(proof.Siblings) && current == root +} + +func (t *Tree) getNode(depth int, prefix [32]byte) types.Hash { + if h, ok := t.nodes[nodeKey{Depth: uint16(depth), Prefix: prefixAtDepth(prefix, depth)}]; ok { + return h + } + return t.defaults[depth] +} + +func defaultHashes() [Depth + 1]types.Hash { + var defaults [Depth + 1]types.Hash + defaults[Depth] = types.Hash(codec.DomainHash("zephyr/smt/empty-leaf/v2", nil)) + for d := Depth - 1; d >= 0; d-- { + defaults[d] = branchHash(defaults[d+1], defaults[d+1]) + } + return defaults +} + +func leafHash(key types.Hash, value []byte) types.Hash { + var w codec.Writer + w.Fixed(key[:]) + w.Bytes(value) + return types.Hash(codec.DomainHash("zephyr/smt/leaf/v2", w.BytesCopy())) +} + +func branchHash(left, right types.Hash) types.Hash { + var payload [64]byte + copy(payload[:32], left[:]) + copy(payload[32:], right[:]) + return types.Hash(codec.DomainHash("zephyr/smt/branch/v2", payload[:])) +} + +func prefixAtDepth(key [32]byte, depth int) [32]byte { + if depth <= 0 { + return [32]byte{} + } + if depth >= Depth { + return key + } + out := key + fullBytes := depth / 8 + remainingBits := depth % 8 + if remainingBits == 0 { + for i := fullBytes; i < len(out); i++ { + out[i] = 0 + } + return out + } + mask := byte(0xFF << (8 - remainingBits)) + out[fullBytes] &= mask + for i := fullBytes + 1; i < len(out); i++ { + out[i] = 0 + } + return out +} + +func bitAt(key [32]byte, bitIndex int) uint8 { + byteIndex := bitIndex / 8 + shift := 7 - (bitIndex % 8) + return (key[byteIndex] >> shift) & 1 +} + +func toggleBit(key *[32]byte, bitIndex int) { + byteIndex := bitIndex / 8 + shift := 7 - (bitIndex % 8) + key[byteIndex] ^= 1 << shift +} + +func setBitmapBit(bitmap *[32]byte, index int) { + byteIndex := index / 8 + shift := uint(index % 8) + bitmap[byteIndex] |= 1 << shift +} + +func bitmapBit(bitmap [32]byte, index int) bool { + byteIndex := index / 8 + shift := uint(index % 8) + return bitmap[byteIndex]&(1<> 8)}) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Update(keys[i%len(keys)], []byte{1, 2, 3, 4, 5, 6, 7, 8}) + } +} + +func BenchmarkSparseMerkleProofVerify(b *testing.B) { + tree := NewTree() + k := key("bench") + tree.Update(k, []byte("value")) + root := tree.Root() + proof := tree.Prove(k) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if !Verify(root, k, []byte("value"), proof) { + b.Fatal("proof failed") + } + } +} diff --git a/internal/v2/transport/transport.go b/internal/v2/transport/transport.go new file mode 100644 index 00000000..c843e0fc --- /dev/null +++ b/internal/v2/transport/transport.go @@ -0,0 +1,42 @@ +package transport + +import ( + "context" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/sharding" + "github.com/zephyr-chain/zephyr-chain/internal/v2/tx" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +type Role uint8 + +const ( + RoleCitizen Role = iota + 1 + RoleFullNode + RoleValidator + RoleArchive + RoleComputeProvider +) + +type PeerIdentity struct { + NodeID types.NodeID + ValidatorID *types.ValidatorID + Roles []Role +} + +type ConsensusTransport interface { + BroadcastProposal(ctx context.Context, payload []byte) error + BroadcastVote(ctx context.Context, payload []byte) error + FetchCertifiedBlock(ctx context.Context, height uint64) ([]byte, error) +} + +type TransactionTransport interface { + Submit(ctx context.Context, transaction tx.Transaction) error + Relay(ctx context.Context, transaction tx.Transaction, shardID uint32) error +} + +type LightTransport interface { + FetchFinalizedHeader(ctx context.Context, height uint64) (sharding.GlobalHeader, error) + FetchShardCommitment(ctx context.Context, height uint64, shardID uint32) (sharding.Commitment, []byte, error) + FetchObjectProof(ctx context.Context, root types.Hash, id types.ObjectID) ([]byte, error) +} diff --git a/internal/v2/tx/token_ops.go b/internal/v2/tx/token_ops.go new file mode 100644 index 00000000..1479616f --- /dev/null +++ b/internal/v2/tx/token_ops.go @@ -0,0 +1,6 @@ +package tx + +const ( + OpMintToken uint16 = 14 + OpBurnToken uint16 = 15 +) diff --git a/internal/v2/tx/transaction.go b/internal/v2/tx/transaction.go new file mode 100644 index 00000000..95b49057 --- /dev/null +++ b/internal/v2/tx/transaction.go @@ -0,0 +1,425 @@ +package tx + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "math/big" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/state" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + Version uint16 = 2 + + OpTransfer uint16 = 1 + OpCreateToken uint16 = 2 + OpDeployContract uint16 = 3 + OpContractCall uint16 = 4 + OpComputeOffer uint16 = 5 + OpComputeJob uint16 = 6 + OpComputeResult uint16 = 7 + OpComputeAccept uint16 = 8 + OpComputeIngestAssignment uint16 = 9 + OpComputeIngestResult uint16 = 10 + OpComputeFinalize uint16 = 11 + OpComputeResolveReplicated uint16 = 12 + OpComputeExpire uint16 = 13 + + MaxInputs = 4096 + MaxOutputs = 4096 + MaxOperations = 64 + MaxOpPayload = 4 << 20 + MaxWireBytes = 32 << 20 + MaxIntentBytes = 24 << 20 +) + +var ( + ErrVersion = errors.New("invalid transaction version") + ErrNetwork = errors.New("invalid transaction network") + ErrSender = errors.New("invalid transaction sender") + ErrSignature = errors.New("invalid transaction signature") + ErrCanonicalSig = errors.New("transaction signature must use low-S P-256 form") + ErrStructure = errors.New("invalid proof-carrying transaction structure") + ErrWitness = errors.New("invalid transaction state witness") + ErrExpired = errors.New("transaction expired") + ErrWire = errors.New("invalid canonical transaction wire payload") +) + +type InputRef struct { + ObjectID types.ObjectID + Version uint64 + ObjectHash types.Hash +} + +type Operation struct { + Kind uint16 + Payload []byte +} + +type Witness struct { + Object object.Object + Proof state.Proof +} + +type Transaction struct { + Version uint16 + Network types.NetworkID + ShardID uint32 + Sender types.AccountID + SenderPublicKey []byte + StateRoot types.Hash + Inputs []InputRef + Outputs []object.OutputSpec + Operations []Operation + Fee uint64 + ValidUntilHeight uint64 + Salt [16]byte + Signature []byte + Witnesses []Witness +} + +func (t Transaction) IntentBytes() []byte { + var w codec.Writer + w.U16(t.Version) + w.Fixed(t.Network[:]) + w.U32(t.ShardID) + w.Fixed(t.Sender[:]) + w.Bytes(t.SenderPublicKey) + w.Fixed(t.StateRoot[:]) + w.U32(uint32(len(t.Inputs))) + for _, in := range t.Inputs { + w.Fixed(in.ObjectID[:]) + w.U64(in.Version) + w.Fixed(in.ObjectHash[:]) + } + w.U32(uint32(len(t.Outputs))) + for _, out := range t.Outputs { + w.Bytes(out.CanonicalBytes()) + } + w.U32(uint32(len(t.Operations))) + for _, op := range t.Operations { + w.U16(op.Kind) + w.Bytes(op.Payload) + } + w.U64(t.Fee) + w.U64(t.ValidUntilHeight) + w.Fixed(t.Salt[:]) + return w.BytesCopy() +} + +func (t Transaction) SigningDigest() [32]byte { + return codec.DomainHash("zephyr/transaction/signing/v2", t.IntentBytes()) +} + +func (t Transaction) ID() types.Hash { + return types.Hash(codec.DomainHash("zephyr/transaction/id/v2", t.IntentBytes())) +} + +func (t Transaction) MarshalBinary() ([]byte, error) { + if err := t.ValidateStatic(); err != nil { + return nil, err + } + if len(t.Witnesses) != len(t.Inputs) { + return nil, ErrWitness + } + var w codec.Writer + w.Bytes(t.IntentBytes()) + w.Bytes(t.Signature) + w.U32(uint32(len(t.Witnesses))) + for _, witness := range t.Witnesses { + w.Bytes(witness.Object.CanonicalBytes()) + w.Bytes(witness.Proof.MarshalBinary()) + } + payload := w.BytesCopy() + if len(payload) > MaxWireBytes { + return nil, ErrWire + } + return payload, nil +} + +func ParseTransaction(data []byte) (Transaction, error) { + if len(data) == 0 || len(data) > MaxWireBytes { + return Transaction{}, ErrWire + } + r := codec.NewReader(data) + intent, err := r.Bytes(MaxIntentBytes) + if err != nil { + return Transaction{}, ErrWire + } + t, err := parseIntent(intent) + if err != nil { + return Transaction{}, err + } + signature, err := r.Bytes(64) + if err != nil || len(signature) != 64 { + return Transaction{}, ErrWire + } + t.Signature = signature + count, err := r.U32() + if err != nil || count > MaxInputs || int(count) != len(t.Inputs) { + return Transaction{}, ErrWitness + } + t.Witnesses = make([]Witness, int(count)) + for i := range t.Witnesses { + objectBytes, err := r.Bytes(object.MaxObjectDataBytes + 128) + if err != nil { + return Transaction{}, ErrWire + } + obj, err := object.ParseObject(objectBytes) + if err != nil { + return Transaction{}, ErrWire + } + proofBytes, err := r.Bytes(16 << 10) + if err != nil { + return Transaction{}, ErrWire + } + proof, err := state.ParseProof(proofBytes) + if err != nil { + return Transaction{}, ErrWire + } + t.Witnesses[i] = Witness{Object: obj, Proof: proof} + } + if err := r.Done(); err != nil { + return Transaction{}, ErrWire + } + if err := t.ValidateStatic(); err != nil { + return Transaction{}, err + } + return t, nil +} + +func (t *Transaction) Sign(privateKey *ecdsa.PrivateKey) error { + if privateKey == nil || privateKey.Curve != elliptic.P256() { + return ErrSender + } + t.Version = Version + t.SenderPublicKey = elliptic.Marshal(elliptic.P256(), privateKey.PublicKey.X, privateKey.PublicKey.Y) + t.Sender = types.AccountIDFromPublicKey(t.SenderPublicKey) + digest := t.SigningDigest() + r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:]) + if err != nil { + return err + } + s = normalizeLowS(s) + t.Signature = append(pad32(r), pad32(s)...) + return nil +} + +func (t Transaction) ValidateStatic() error { + if t.Version != Version { + return ErrVersion + } + if types.IsZero32([32]byte(t.Network)) || types.IsZero32([32]byte(t.StateRoot)) { + return ErrNetwork + } + if len(t.SenderPublicKey) != 65 { + return ErrSender + } + x, y := elliptic.Unmarshal(elliptic.P256(), t.SenderPublicKey) + if x == nil || y == nil { + return ErrSender + } + if types.AccountIDFromPublicKey(t.SenderPublicKey) != t.Sender { + return ErrSender + } + var zeroSalt [16]byte + if t.Salt == zeroSalt || len(t.Inputs) > MaxInputs || len(t.Outputs) > MaxOutputs || len(t.Operations) == 0 || len(t.Operations) > MaxOperations { + return ErrStructure + } + seenInputs := map[types.ObjectID]struct{}{} + for _, in := range t.Inputs { + if types.IsZero32([32]byte(in.ObjectID)) || in.Version == 0 || types.IsZero32([32]byte(in.ObjectHash)) { + return ErrStructure + } + if _, ok := seenInputs[in.ObjectID]; ok { + return ErrStructure + } + seenInputs[in.ObjectID] = struct{}{} + } + for _, out := range t.Outputs { + if err := out.Validate(); err != nil { + return ErrStructure + } + } + for _, op := range t.Operations { + if op.Kind == 0 || len(op.Payload) > MaxOpPayload { + return ErrStructure + } + } + return verifySignature(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, t.SigningDigest(), t.Signature) +} + +func (t Transaction) ValidateAtHeight(height uint64) error { + if err := t.ValidateStatic(); err != nil { + return err + } + if t.ValidUntilHeight > 0 && height > t.ValidUntilHeight { + return ErrExpired + } + return nil +} + +func (t Transaction) VerifyForNetwork(network types.NetworkID) error { + if t.Network != network { + return ErrNetwork + } + return t.ValidateStatic() +} + +func (t Transaction) VerifyWitnesses() error { + if len(t.Witnesses) != len(t.Inputs) { + return ErrWitness + } + for i, in := range t.Inputs { + witness := t.Witnesses[i] + if witness.Object.ID != in.ObjectID || witness.Object.Version != in.Version || witness.Object.Hash() != in.ObjectHash { + return ErrWitness + } + hash := witness.Object.Hash() + if !state.Verify(t.StateRoot, types.Hash(in.ObjectID), hash[:], witness.Proof) { + return ErrWitness + } + } + return nil +} + +func parseIntent(data []byte) (Transaction, error) { + r := codec.NewReader(data) + version, err := r.U16() + if err != nil { + return Transaction{}, ErrWire + } + networkBytes, err := r.Fixed(32) + if err != nil { + return Transaction{}, ErrWire + } + var network types.NetworkID + copy(network[:], networkBytes) + shardID, err := r.U32() + if err != nil { + return Transaction{}, ErrWire + } + senderBytes, err := r.Fixed(32) + if err != nil { + return Transaction{}, ErrWire + } + var sender types.AccountID + copy(sender[:], senderBytes) + publicKey, err := r.Bytes(65) + if err != nil { + return Transaction{}, ErrWire + } + stateRootBytes, err := r.Fixed(32) + if err != nil { + return Transaction{}, ErrWire + } + var stateRoot types.Hash + copy(stateRoot[:], stateRootBytes) + inputCount, err := r.U32() + if err != nil || inputCount > MaxInputs { + return Transaction{}, ErrWire + } + inputs := make([]InputRef, int(inputCount)) + for i := range inputs { + id, err := r.Fixed(32) + if err != nil { + return Transaction{}, ErrWire + } + copy(inputs[i].ObjectID[:], id) + inputs[i].Version, err = r.U64() + if err != nil { + return Transaction{}, ErrWire + } + h, err := r.Fixed(32) + if err != nil { + return Transaction{}, ErrWire + } + copy(inputs[i].ObjectHash[:], h) + } + outputCount, err := r.U32() + if err != nil || outputCount > MaxOutputs { + return Transaction{}, ErrWire + } + outputs := make([]object.OutputSpec, int(outputCount)) + for i := range outputs { + raw, err := r.Bytes(object.MaxObjectDataBytes + 64) + if err != nil { + return Transaction{}, ErrWire + } + outputs[i], err = object.ParseOutputSpec(raw) + if err != nil { + return Transaction{}, ErrWire + } + } + opCount, err := r.U32() + if err != nil || opCount == 0 || opCount > MaxOperations { + return Transaction{}, ErrWire + } + operations := make([]Operation, int(opCount)) + for i := range operations { + operations[i].Kind, err = r.U16() + if err != nil { + return Transaction{}, ErrWire + } + operations[i].Payload, err = r.Bytes(MaxOpPayload) + if err != nil { + return Transaction{}, ErrWire + } + } + fee, err := r.U64() + if err != nil { + return Transaction{}, ErrWire + } + validUntil, err := r.U64() + if err != nil { + return Transaction{}, ErrWire + } + salt, err := r.Fixed(16) + if err != nil || r.Done() != nil { + return Transaction{}, ErrWire + } + var saltArray [16]byte + copy(saltArray[:], salt) + return Transaction{Version: version, Network: network, ShardID: shardID, Sender: sender, SenderPublicKey: publicKey, StateRoot: stateRoot, Inputs: inputs, Outputs: outputs, Operations: operations, Fee: fee, ValidUntilHeight: validUntil, Salt: saltArray}, nil +} + +func verifySignature(publicKey *ecdsa.PublicKey, digest [32]byte, signature []byte) error { + if len(signature) != 64 { + return ErrSignature + } + r := new(big.Int).SetBytes(signature[:32]) + s := new(big.Int).SetBytes(signature[32:]) + order := elliptic.P256().Params().N + if r.Sign() <= 0 || s.Sign() <= 0 || r.Cmp(order) >= 0 || s.Cmp(order) >= 0 { + return ErrSignature + } + half := new(big.Int).Rsh(new(big.Int).Set(order), 1) + if s.Cmp(half) > 0 { + return ErrCanonicalSig + } + if !ecdsa.Verify(publicKey, digest[:], r, s) { + return ErrSignature + } + return nil +} + +func normalizeLowS(s *big.Int) *big.Int { + order := elliptic.P256().Params().N + half := new(big.Int).Rsh(new(big.Int).Set(order), 1) + if s.Cmp(half) > 0 { + return new(big.Int).Sub(order, s) + } + return s +} + +func pad32(v *big.Int) []byte { + out := make([]byte, 32) + raw := v.Bytes() + copy(out[32-len(raw):], raw) + return out +} diff --git a/internal/v2/tx/transaction_test.go b/internal/v2/tx/transaction_test.go new file mode 100644 index 00000000..aaa30794 --- /dev/null +++ b/internal/v2/tx/transaction_test.go @@ -0,0 +1,78 @@ +package tx + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" + "github.com/zephyr-chain/zephyr-chain/internal/v2/worldstate" +) + +func TestProofCarryingTransactionWireRoundTrip(t *testing.T) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + network := types.NetworkID(types.HashBytes("network", []byte("test"))) + token := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + sender := types.AccountIDFromPublicKey(elliptic.Marshal(elliptic.P256(), privateKey.PublicKey.X, privateKey.PublicKey.Y)) + store := worldstate.NewMemory() + seed := types.HashBytes("seed", []byte("coin")) + id := types.ObjectIDFromTransaction(seed, 0) + coinOut, err := object.NewCoinOutput(sender, token, 100) + if err != nil { + t.Fatal(err) + } + coin := object.Object{ID: id, Version: 1, Owner: coinOut.Owner, Kind: coinOut.Kind, Data: coinOut.Data} + root, err := store.Apply(nil, []object.Object{coin}) + if err != nil { + t.Fatal(err) + } + got, proof, ok := store.Proof(id) + if !ok { + t.Fatal("missing coin") + } + h := got.Hash() + recipient := types.AccountIDFromPublicKey([]byte("recipient")) + out, err := object.NewCoinOutput(recipient, token, 99) + if err != nil { + t.Fatal(err) + } + + transaction := Transaction{ + Version: Version, Network: network, ShardID: 0, StateRoot: root, + Inputs: []InputRef{{ObjectID: id, Version: 1, ObjectHash: h}}, + Outputs: []object.OutputSpec{out}, + Operations: []Operation{{Kind: OpTransfer}}, + Fee: 1, ValidUntilHeight: 100, + Witnesses: []Witness{{Object: got, Proof: proof}}, + } + transaction.Salt[0] = 1 + if err := transaction.Sign(privateKey); err != nil { + t.Fatal(err) + } + if err := transaction.ValidateStatic(); err != nil { + t.Fatal(err) + } + if err := transaction.VerifyWitnesses(); err != nil { + t.Fatal(err) + } + + wire, err := transaction.MarshalBinary() + if err != nil { + t.Fatal(err) + } + decoded, err := ParseTransaction(wire) + if err != nil { + t.Fatal(err) + } + if decoded.ID() != transaction.ID() { + t.Fatal("transaction ID changed after wire round-trip") + } + if err := decoded.VerifyWitnesses(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/v2/types/types.go b/internal/v2/types/types.go new file mode 100644 index 00000000..1eabc6b7 --- /dev/null +++ b/internal/v2/types/types.go @@ -0,0 +1,98 @@ +package types + +import ( + "encoding/binary" + "encoding/hex" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" +) + +type Hash [32]byte +type NetworkID [32]byte +type AccountID [32]byte +type NodeID [32]byte +type ValidatorID [32]byte +type ObjectID [32]byte +type TokenID [32]byte +type ContractID [32]byte +type JobID [32]byte + +func (h Hash) String() string { return hex.EncodeToString(h[:]) } +func (n NetworkID) String() string { return hex.EncodeToString(n[:]) } +func (a AccountID) String() string { return hex.EncodeToString(a[:]) } +func (n NodeID) String() string { return hex.EncodeToString(n[:]) } +func (v ValidatorID) String() string { return hex.EncodeToString(v[:]) } +func (o ObjectID) String() string { return hex.EncodeToString(o[:]) } +func (t TokenID) String() string { return hex.EncodeToString(t[:]) } +func (c ContractID) String() string { return hex.EncodeToString(c[:]) } +func (j JobID) String() string { return hex.EncodeToString(j[:]) } + +func IsZero32(v [32]byte) bool { + var zero [32]byte + return v == zero +} + +func HashBytes(domain string, payload []byte) Hash { + return Hash(codec.DomainHash(domain, payload)) +} + +func AccountIDFromPublicKey(publicKey []byte) AccountID { + return AccountID(codec.DomainHash("zephyr/account-id/v2", publicKey)) +} + +func NodeIDFromPublicKey(publicKey []byte) NodeID { + return NodeID(codec.DomainHash("zephyr/node-id/v2", publicKey)) +} + +func ValidatorIDFromPublicKey(publicKey []byte) ValidatorID { + return ValidatorID(codec.DomainHash("zephyr/validator-id/v2", publicKey)) +} + +// AccountShard chooses the execution shard for newly created account-owned +// objects. The object ID itself permanently records the chosen shard so later +// shard-count changes cannot silently move an existing object. +func AccountShard(account AccountID, shardCount uint32) uint32 { + if shardCount <= 1 { + return 0 + } + return uint32(binary.BigEndian.Uint64(account[:8]) % uint64(shardCount)) +} + +func ObjectShard(id ObjectID) uint32 { + return binary.BigEndian.Uint32(id[:4]) +} + +func ObjectIDForShard(txID Hash, index, shardID uint32) ObjectID { + var w codec.Writer + w.Fixed(txID[:]) + w.U32(index) + w.U32(shardID) + hash := codec.DomainHash("zephyr/object-id/v2", w.BytesCopy()) + binary.BigEndian.PutUint32(hash[:4], shardID) + return ObjectID(hash) +} + +func ObjectIDFromTransaction(txID Hash, index uint32) ObjectID { + return ObjectIDForShard(txID, index, 0) +} + +func TokenIDFromTransaction(txID Hash, operationIndex uint32) TokenID { + var w codec.Writer + w.Fixed(txID[:]) + w.U32(operationIndex) + return TokenID(codec.DomainHash("zephyr/token-id/v2", w.BytesCopy())) +} + +func ContractIDFromTransaction(txID Hash, operationIndex uint32) ContractID { + var w codec.Writer + w.Fixed(txID[:]) + w.U32(operationIndex) + return ContractID(codec.DomainHash("zephyr/contract-id/v2", w.BytesCopy())) +} + +func JobIDFromTransaction(txID Hash, operationIndex uint32) JobID { + var w codec.Writer + w.Fixed(txID[:]) + w.U32(operationIndex) + return JobID(codec.DomainHash("zephyr/compute-job-id/v2", w.BytesCopy())) +} diff --git a/internal/v2/worldstate/durable.go b/internal/v2/worldstate/durable.go new file mode 100644 index 00000000..0582aa44 --- /dev/null +++ b/internal/v2/worldstate/durable.go @@ -0,0 +1,482 @@ +package worldstate + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "hash/crc32" + "io" + "os" + "path/filepath" + "sort" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/codec" + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/state" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +const ( + walMagic = "ZWL2" + checkpointMagic = "ZCP2" + persistenceVersion = uint16(1) + maxWALRecordBytes = 64 << 20 + maxCheckpointBytes = 1 << 30 + maxCheckpointObjects = 10_000_000 +) + +var ( + ErrPersistenceNetwork = errors.New("v2 persisted state belongs to another network") + ErrPersistenceCorrupt = errors.New("v2 persisted state is corrupt") + ErrPersistenceClosed = errors.New("v2 persisted state is closed") +) + +type Disk struct { + mu sync.Mutex + dir string + network types.NetworkID + mem *Memory + objects map[types.ObjectID]object.Object + seq uint64 + wal *os.File + closed bool +} + +func OpenDisk(dir string, network types.NetworkID) (*Disk, error) { + if types.IsZero32([32]byte(network)) { + return nil, ErrPersistenceNetwork + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + d := &Disk{ + dir: dir, network: network, mem: NewMemory(), + objects: make(map[types.ObjectID]object.Object), + } + if err := d.loadCheckpoint(); err != nil { + return nil, err + } + wal, err := os.OpenFile(filepath.Join(dir, "state.wal"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + d.wal = wal + if err := d.replayWAL(); err != nil { + _ = wal.Close() + return nil, err + } + if _, err := wal.Seek(0, io.SeekEnd); err != nil { + _ = wal.Close() + return nil, err + } + return d, nil +} + +func (d *Disk) Root() types.Hash { return d.mem.Root() } + +func (d *Disk) GetObject(id types.ObjectID) (object.Object, bool) { return d.mem.GetObject(id) } + +func (d *Disk) Proof(id types.ObjectID) (object.Object, state.Proof, bool) { return d.mem.Proof(id) } + +func (d *Disk) Apply(consumed []types.ObjectID, created []object.Object) (types.Hash, error) { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return d.mem.Root(), ErrPersistenceClosed + } + if err := d.prevalidate(consumed, created); err != nil { + return d.mem.Root(), err + } + next := d.seq + 1 + payload := encodeWALPayload(d.network, next, consumed, created) + if err := d.appendWAL(payload); err != nil { + return d.mem.Root(), err + } + root, err := d.mem.Apply(consumed, created) + if err != nil { + return d.mem.Root(), ErrPersistenceCorrupt + } + d.applyMirror(consumed, created) + d.seq = next + return root, nil +} + +func (d *Disk) Sequence() uint64 { + d.mu.Lock() + defer d.mu.Unlock() + return d.seq +} + +// Checkpoint atomically materializes the current object set and then resets the +// WAL. A crash before WAL truncation is safe because replay ignores records at +// or below the checkpoint sequence. +func (d *Disk) Checkpoint() error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return ErrPersistenceClosed + } + objects := make([]object.Object, 0, len(d.objects)) + for _, item := range d.objects { + copyItem := item + copyItem.Data = append([]byte(nil), item.Data...) + objects = append(objects, copyItem) + } + sort.Slice(objects, func(i, j int) bool { return bytes.Compare(objects[i].ID[:], objects[j].ID[:]) < 0 }) + + var payload codec.Writer + payload.U16(persistenceVersion) + payload.Fixed(d.network[:]) + payload.U64(d.seq) + payload.U32(uint32(len(objects))) + for _, item := range objects { + payload.Bytes(item.CanonicalBytes()) + } + root := d.mem.Root() + payload.Fixed(root[:]) + rawPayload := payload.BytesCopy() + digest := codec.DomainHash("zephyr/state-checkpoint/v2", rawPayload) + var file bytes.Buffer + file.WriteString(checkpointMagic) + var n [4]byte + binary.BigEndian.PutUint32(n[:], uint32(len(rawPayload))) + file.Write(n[:]) + file.Write(rawPayload) + file.Write(digest[:]) + + tmp := filepath.Join(d.dir, "state.checkpoint.tmp") + final := filepath.Join(d.dir, "state.checkpoint") + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err = f.Write(file.Bytes()); err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err == nil { + err = closeErr + } + if err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, final); err != nil { + _ = os.Remove(tmp) + return err + } + if err := syncDir(d.dir); err != nil { + return err + } + if err := d.wal.Truncate(0); err != nil { + return err + } + if _, err := d.wal.Seek(0, io.SeekStart); err != nil { + return err + } + return d.wal.Sync() +} + +func (d *Disk) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return nil + } + d.closed = true + if err := d.wal.Sync(); err != nil { + _ = d.wal.Close() + return err + } + return d.wal.Close() +} + +func (d *Disk) prevalidate(consumed []types.ObjectID, created []object.Object) error { + seenConsumed := make(map[types.ObjectID]struct{}, len(consumed)) + for _, id := range consumed { + if _, duplicate := seenConsumed[id]; duplicate { + return ErrObjectNotFound + } + seenConsumed[id] = struct{}{} + if _, ok := d.objects[id]; !ok { + return ErrObjectNotFound + } + } + seenCreated := make(map[types.ObjectID]struct{}, len(created)) + for _, item := range created { + if err := item.Validate(); err != nil { + return err + } + if _, duplicate := seenCreated[item.ID]; duplicate { + return ErrObjectExists + } + seenCreated[item.ID] = struct{}{} + if _, exists := d.objects[item.ID]; exists { + if _, replacing := seenConsumed[item.ID]; !replacing { + return ErrObjectExists + } + } + } + return nil +} + +func (d *Disk) appendWAL(payload []byte) error { + if len(payload) == 0 || len(payload) > maxWALRecordBytes { + return ErrPersistenceCorrupt + } + var header [8]byte + copy(header[:4], walMagic) + binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) + checksum := crc32.Checksum(payload, crc32.MakeTable(crc32.Castagnoli)) + var sum [4]byte + binary.BigEndian.PutUint32(sum[:], checksum) + if _, err := d.wal.Write(header[:]); err != nil { + return err + } + if _, err := d.wal.Write(payload); err != nil { + return err + } + if _, err := d.wal.Write(sum[:]); err != nil { + return err + } + return d.wal.Sync() +} + +func encodeWALPayload(network types.NetworkID, seq uint64, consumed []types.ObjectID, created []object.Object) []byte { + var w codec.Writer + w.U16(persistenceVersion) + w.Fixed(network[:]) + w.U64(seq) + w.U32(uint32(len(consumed))) + for _, id := range consumed { + w.Fixed(id[:]) + } + w.U32(uint32(len(created))) + for _, item := range created { + w.Bytes(item.CanonicalBytes()) + } + return w.BytesCopy() +} + +func parseWALPayload(payload []byte) (types.NetworkID, uint64, []types.ObjectID, []object.Object, error) { + r := codec.NewReader(payload) + version, err := r.U16() + if err != nil || version != persistenceVersion { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + networkBytes, err := r.Fixed(32) + if err != nil { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + var network types.NetworkID + copy(network[:], networkBytes) + seq, err := r.U64() + if err != nil || seq == 0 { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + consumedCount, err := r.U32() + if err != nil || consumedCount > 1_000_000 { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + consumed := make([]types.ObjectID, int(consumedCount)) + for i := range consumed { + raw, err := r.Fixed(32) + if err != nil { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + copy(consumed[i][:], raw) + } + createdCount, err := r.U32() + if err != nil || createdCount > 1_000_000 { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + created := make([]object.Object, int(createdCount)) + for i := range created { + raw, err := r.Bytes(object.MaxObjectDataBytes + 128) + if err != nil { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + created[i], err = object.ParseObject(raw) + if err != nil { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + } + if err := r.Done(); err != nil { + return types.NetworkID{}, 0, nil, nil, ErrPersistenceCorrupt + } + return network, seq, consumed, created, nil +} + +func (d *Disk) replayWAL() error { + if _, err := d.wal.Seek(0, io.SeekStart); err != nil { + return err + } + reader := bufio.NewReader(d.wal) + var offset int64 + for { + var header [8]byte + n, err := io.ReadFull(reader, header[:]) + if err == io.EOF { + break + } + if err == io.ErrUnexpectedEOF { + if err := d.wal.Truncate(offset); err != nil { + return err + } + break + } + if err != nil || n != len(header) || string(header[:4]) != walMagic { + return ErrPersistenceCorrupt + } + length := binary.BigEndian.Uint32(header[4:]) + if length == 0 || length > maxWALRecordBytes { + return ErrPersistenceCorrupt + } + payload := make([]byte, int(length)) + if _, err := io.ReadFull(reader, payload); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + if err := d.wal.Truncate(offset); err != nil { + return err + } + break + } + return err + } + var checksumRaw [4]byte + if _, err := io.ReadFull(reader, checksumRaw[:]); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + if err := d.wal.Truncate(offset); err != nil { + return err + } + break + } + return err + } + expected := binary.BigEndian.Uint32(checksumRaw[:]) + actual := crc32.Checksum(payload, crc32.MakeTable(crc32.Castagnoli)) + if expected != actual { + return ErrPersistenceCorrupt + } + network, seq, consumed, created, err := parseWALPayload(payload) + if err != nil { + return err + } + if network != d.network { + return ErrPersistenceNetwork + } + offset += int64(8 + len(payload) + 4) + if seq <= d.seq { + continue + } + if seq != d.seq+1 { + return ErrPersistenceCorrupt + } + if err := d.prevalidate(consumed, created); err != nil { + return ErrPersistenceCorrupt + } + if _, err := d.mem.Apply(consumed, created); err != nil { + return ErrPersistenceCorrupt + } + d.applyMirror(consumed, created) + d.seq = seq + } + _, err := d.wal.Seek(0, io.SeekEnd) + return err +} + +func (d *Disk) loadCheckpoint() error { + path := filepath.Join(d.dir, "state.checkpoint") + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if len(raw) < 4+4+32 || string(raw[:4]) != checkpointMagic { + return ErrPersistenceCorrupt + } + length := binary.BigEndian.Uint32(raw[4:8]) + if length == 0 || length > maxCheckpointBytes || len(raw) != 8+int(length)+32 { + return ErrPersistenceCorrupt + } + payload := raw[8 : 8+int(length)] + digest := codec.DomainHash("zephyr/state-checkpoint/v2", payload) + if !bytes.Equal(digest[:], raw[8+int(length):]) { + return ErrPersistenceCorrupt + } + r := codec.NewReader(payload) + version, err := r.U16() + if err != nil || version != persistenceVersion { + return ErrPersistenceCorrupt + } + networkBytes, err := r.Fixed(32) + if err != nil { + return ErrPersistenceCorrupt + } + var network types.NetworkID + copy(network[:], networkBytes) + if network != d.network { + return ErrPersistenceNetwork + } + seq, err := r.U64() + if err != nil { + return ErrPersistenceCorrupt + } + count, err := r.U32() + if err != nil || count > maxCheckpointObjects { + return ErrPersistenceCorrupt + } + objects := make([]object.Object, int(count)) + for i := range objects { + itemBytes, err := r.Bytes(object.MaxObjectDataBytes + 128) + if err != nil { + return ErrPersistenceCorrupt + } + objects[i], err = object.ParseObject(itemBytes) + if err != nil { + return ErrPersistenceCorrupt + } + } + rootBytes, err := r.Fixed(32) + if err != nil || r.Done() != nil { + return ErrPersistenceCorrupt + } + var expectedRoot types.Hash + copy(expectedRoot[:], rootBytes) + if len(objects) > 0 { + if _, err := d.mem.Apply(nil, objects); err != nil { + return ErrPersistenceCorrupt + } + } + if d.mem.Root() != expectedRoot { + return ErrPersistenceCorrupt + } + for _, item := range objects { + d.objects[item.ID] = item + } + d.seq = seq + return nil +} + +func (d *Disk) applyMirror(consumed []types.ObjectID, created []object.Object) { + for _, id := range consumed { + delete(d.objects, id) + } + for _, item := range created { + copyItem := item + copyItem.Data = append([]byte(nil), item.Data...) + d.objects[item.ID] = copyItem + } +} + +func syncDir(dir string) error { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} diff --git a/internal/v2/worldstate/durable_test.go b/internal/v2/worldstate/durable_test.go new file mode 100644 index 00000000..28706ad2 --- /dev/null +++ b/internal/v2/worldstate/durable_test.go @@ -0,0 +1,116 @@ +package worldstate + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestDiskPersistsWALAndCheckpoint(t *testing.T) { + dir := t.TempDir() + network := types.NetworkID(types.HashBytes("network", []byte("disk-test"))) + owner := types.AccountIDFromPublicKey([]byte("owner")) + token := types.TokenID(types.HashBytes("token", []byte("ZPH"))) + id := types.ObjectIDFromTransaction(types.HashBytes("seed", []byte("coin")), 0) + out, err := object.NewCoinOutput(owner, token, 100) + if err != nil { + t.Fatal(err) + } + coin := object.Object{ID: id, Version: 1, Owner: owner, Kind: out.Kind, Data: out.Data} + + store, err := OpenDisk(dir, network) + if err != nil { + t.Fatal(err) + } + root, err := store.Apply(nil, []object.Object{coin}) + if err != nil { + t.Fatal(err) + } + if store.Sequence() != 1 { + t.Fatalf("expected sequence 1") + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + reopened, err := OpenDisk(dir, network) + if err != nil { + t.Fatal(err) + } + if reopened.Root() != root { + t.Fatal("WAL replay changed root") + } + if _, ok := reopened.GetObject(id); !ok { + t.Fatal("WAL replay lost object") + } + if err := reopened.Checkpoint(); err != nil { + t.Fatal(err) + } + if err := reopened.Close(); err != nil { + t.Fatal(err) + } + + afterCheckpoint, err := OpenDisk(dir, network) + if err != nil { + t.Fatal(err) + } + if afterCheckpoint.Root() != root || afterCheckpoint.Sequence() != 1 { + t.Fatal("checkpoint restore changed state") + } + if err := afterCheckpoint.Close(); err != nil { + t.Fatal(err) + } +} + +func TestDiskRejectsWrongNetworkAndIgnoresTornTail(t *testing.T) { + dir := t.TempDir() + network := types.NetworkID(types.HashBytes("network", []byte("a"))) + store, err := OpenDisk(dir, network) + if err != nil { + t.Fatal(err) + } + if err := store.Checkpoint(); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + wrong := types.NetworkID(types.HashBytes("network", []byte("b"))) + if _, err := OpenDisk(dir, wrong); !errors.Is(err, ErrPersistenceNetwork) { + t.Fatalf("expected wrong-network rejection, got %v", err) + } + + walPath := filepath.Join(dir, "state.wal") + f, err := os.OpenFile(walPath, os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("ZWL2\x00")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + recovered, err := OpenDisk(dir, network) + if err != nil { + t.Fatal(err) + } + if recovered.Sequence() != 0 { + t.Fatal("torn tail advanced sequence") + } + if err := recovered.Close(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(walPath) + if err != nil { + t.Fatal(err) + } + if info.Size() != 0 { + t.Fatalf("torn WAL tail was not truncated: %d", info.Size()) + } +} diff --git a/internal/v2/worldstate/simulate.go b/internal/v2/worldstate/simulate.go new file mode 100644 index 00000000..5e776191 --- /dev/null +++ b/internal/v2/worldstate/simulate.go @@ -0,0 +1,57 @@ +package worldstate + +import ( + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +// Simulator is implemented by state backends that can calculate the root of a +// delta without mutating committed state. +type Simulator interface { + Simulate(consumed []types.ObjectID, created []object.Object) (types.Hash, error) +} + +func (m *Memory) Simulate(consumed []types.ObjectID, created []object.Object) (types.Hash, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + seenConsumed := make(map[types.ObjectID]struct{}, len(consumed)) + for _, id := range consumed { + if _, duplicate := seenConsumed[id]; duplicate { + return m.tree.Root(), ErrObjectNotFound + } + seenConsumed[id] = struct{}{} + if _, ok := m.objects[id]; !ok { + return m.tree.Root(), ErrObjectNotFound + } + } + seenCreated := make(map[types.ObjectID]struct{}, len(created)) + for _, item := range created { + if err := item.Validate(); err != nil { + return m.tree.Root(), err + } + if _, duplicate := seenCreated[item.ID]; duplicate { + return m.tree.Root(), ErrObjectExists + } + seenCreated[item.ID] = struct{}{} + if _, exists := m.objects[item.ID]; exists { + if _, replacing := seenConsumed[item.ID]; !replacing { + return m.tree.Root(), ErrObjectExists + } + } + } + + updates := make(map[types.Hash][]byte, len(consumed)+len(created)) + for _, id := range consumed { + updates[types.Hash(id)] = nil + } + for _, item := range created { + hash := item.Hash() + updates[types.Hash(item.ID)] = hash[:] + } + return m.tree.Preview(updates), nil +} + +func (d *Disk) Simulate(consumed []types.ObjectID, created []object.Object) (types.Hash, error) { + return d.mem.Simulate(consumed, created) +} diff --git a/internal/v2/worldstate/store.go b/internal/v2/worldstate/store.go new file mode 100644 index 00000000..345a7798 --- /dev/null +++ b/internal/v2/worldstate/store.go @@ -0,0 +1,105 @@ +package worldstate + +import ( + "errors" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/state" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ( + ErrObjectNotFound = errors.New("object not found") + ErrObjectExists = errors.New("object already exists") +) + +type Backend interface { + Root() types.Hash + GetObject(id types.ObjectID) (object.Object, bool) + Proof(id types.ObjectID) (object.Object, state.Proof, bool) + Apply(consumed []types.ObjectID, created []object.Object) (types.Hash, error) +} + +type Memory struct { + mu sync.RWMutex + tree *state.Tree + objects map[types.ObjectID]object.Object +} + +func NewMemory() *Memory { + return &Memory{tree: state.NewTree(), objects: make(map[types.ObjectID]object.Object)} +} + +func (m *Memory) Root() types.Hash { + m.mu.RLock() + defer m.mu.RUnlock() + return m.tree.Root() +} + +func (m *Memory) GetObject(id types.ObjectID) (object.Object, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + o, ok := m.objects[id] + if !ok { + return object.Object{}, false + } + o.Data = append([]byte(nil), o.Data...) + return o, true +} + +func (m *Memory) Proof(id types.ObjectID) (object.Object, state.Proof, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + o, ok := m.objects[id] + if !ok { + return object.Object{}, m.tree.Prove(types.Hash(id)), false + } + o.Data = append([]byte(nil), o.Data...) + return o, m.tree.Prove(types.Hash(id)), true +} + +func (m *Memory) Apply(consumed []types.ObjectID, created []object.Object) (types.Hash, error) { + m.mu.Lock() + defer m.mu.Unlock() + + seenConsumed := map[types.ObjectID]struct{}{} + for _, id := range consumed { + if _, duplicate := seenConsumed[id]; duplicate { + return m.tree.Root(), ErrObjectNotFound + } + seenConsumed[id] = struct{}{} + if _, ok := m.objects[id]; !ok { + return m.tree.Root(), ErrObjectNotFound + } + } + seenCreated := map[types.ObjectID]struct{}{} + for _, o := range created { + if err := o.Validate(); err != nil { + return m.tree.Root(), err + } + if _, duplicate := seenCreated[o.ID]; duplicate { + return m.tree.Root(), ErrObjectExists + } + seenCreated[o.ID] = struct{}{} + if _, ok := m.objects[o.ID]; ok { + if _, replacingConsumed := seenConsumed[o.ID]; !replacingConsumed { + return m.tree.Root(), ErrObjectExists + } + } + } + + updates := make(map[types.Hash][]byte, len(consumed)+len(created)) + for _, id := range consumed { + delete(m.objects, id) + updates[types.Hash(id)] = nil + } + for _, o := range created { + copyObject := o + copyObject.Data = append([]byte(nil), o.Data...) + m.objects[o.ID] = copyObject + h := o.Hash() + updates[types.Hash(o.ID)] = h[:] + } + return m.tree.Apply(updates), nil +} diff --git a/internal/v2/worldstate/store_test.go b/internal/v2/worldstate/store_test.go new file mode 100644 index 00000000..4b20210a --- /dev/null +++ b/internal/v2/worldstate/store_test.go @@ -0,0 +1,34 @@ +package worldstate + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/object" + "github.com/zephyr-chain/zephyr-chain/internal/v2/state" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestObjectProofTracksIncrementalRoot(t *testing.T) { + store := NewMemory() + owner := types.AccountIDFromPublicKey([]byte("owner")) + token := types.TokenID(types.HashBytes("token", []byte("zph"))) + txid := types.HashBytes("tx", []byte("seed")) + id := types.ObjectIDFromTransaction(txid, 0) + out, err := object.NewCoinOutput(owner, token, 10) + if err != nil { + t.Fatal(err) + } + o := object.Object{ID: id, Version: 1, Owner: out.Owner, Kind: out.Kind, Data: out.Data} + root, err := store.Apply(nil, []object.Object{o}) + if err != nil { + t.Fatal(err) + } + got, proof, ok := store.Proof(id) + if !ok { + t.Fatal("object missing") + } + h := got.Hash() + if !state.Verify(root, types.Hash(id), h[:], proof) { + t.Fatal("object proof failed") + } +} diff --git a/mobile/citizen.go b/mobile/citizen.go new file mode 100644 index 00000000..9c9a852b --- /dev/null +++ b/mobile/citizen.go @@ -0,0 +1,126 @@ +package mobile + +import ( + "encoding/hex" + "encoding/json" + "errors" + "sync" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/citizen" + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrCitizenAnchor = errors.New("invalid Citizen trust anchor") + +type CitizenNode struct { + mu sync.RWMutex + anchor citizen.TrustAnchor +} + +type verifiedObjectJSON struct { + Network string `json:"network"` + Height uint64 `json:"height"` + ShardID uint32 `json:"shardId"` + ObjectID string `json:"objectId"` + ObjectPresent bool `json:"objectPresent"` + StateRoot string `json:"stateRoot"` + NextValidatorRoot string `json:"nextValidatorRoot"` +} + +type modeJSON struct { + VerifyHeaders bool `json:"verifyHeaders"` + Relay bool `json:"relay"` + SampleDA bool `json:"sampleDA"` + ExecuteRecent bool `json:"executeRecent"` + ServeCache bool `json:"serveCache"` +} + +// NewCitizenNode exposes only mobile-binding-friendly argument/return types. +// The genesis/checkpoint anchor is the only trusted bootstrap input; subsequent +// validator roots advance only after a locally verified quorum certificate. +func NewCitizenNode(networkHex, validatorRootHex string) (*CitizenNode, error) { + network, err := parse32(networkHex) + if err != nil { + return nil, ErrCitizenAnchor + } + root, err := parse32(validatorRootHex) + if err != nil { + return nil, ErrCitizenAnchor + } + var networkID types.NetworkID + var validatorRoot types.Hash + copy(networkID[:], network[:]) + copy(validatorRoot[:], root[:]) + if types.IsZero32([32]byte(networkID)) || types.IsZero32([32]byte(validatorRoot)) { + return nil, ErrCitizenAnchor + } + return &CitizenNode{anchor: citizen.TrustAnchor{Network: networkID, ValidatorRoot: validatorRoot}}, nil +} + +func (n *CitizenNode) NetworkID() string { + n.mu.RLock() + defer n.mu.RUnlock() + return n.anchor.Network.String() +} + +func (n *CitizenNode) ValidatorRoot() string { + n.mu.RLock() + defer n.mu.RUnlock() + return n.anchor.ValidatorRoot.String() +} + +func (n *CitizenNode) TrustAnchorJSON() string { + n.mu.RLock() + defer n.mu.RUnlock() + encoded, _ := json.Marshal(map[string]string{"network": n.anchor.Network.String(), "validatorRoot": n.anchor.ValidatorRoot.String()}) + return string(encoded) +} + +// VerifyObjectBundle takes the exact JSON returned by /v2/light/object. It +// updates the mobile trust anchor only after all QC, validator-root, shard and +// Sparse-Merkle proofs verify locally. +func (n *CitizenNode) VerifyObjectBundle(bundleJSON string) (string, error) { + n.mu.RLock() + anchor := n.anchor + n.mu.RUnlock() + verified, err := citizen.VerifyObjectBundleJSON([]byte(bundleJSON), anchor) + if err != nil { + return "", err + } + n.mu.Lock() + n.anchor = verified.NextAnchor + n.mu.Unlock() + encoded, err := json.Marshal(verifiedObjectJSON{ + Network: verified.Network.String(), Height: verified.Height, ShardID: verified.ShardID, + ObjectID: verified.ObjectID.String(), ObjectPresent: verified.Present, StateRoot: verified.StateRoot.String(), + NextValidatorRoot: verified.NextAnchor.ValidatorRoot.String(), + }) + if err != nil { + return "", err + } + return string(encoded), nil +} + +// SelectCitizenMode mirrors the resource policy used by the Go verifier while +// exposing primitive parameters friendly to Android/iOS bindings. +func SelectCitizenMode(batteryPercent int, charging, wifi, lowPower, appActive bool) string { + if batteryPercent < 0 { + batteryPercent = 0 + } + if batteryPercent > 100 { + batteryPercent = 100 + } + mode := citizen.SelectMode(citizen.PowerState{BatteryPercent: uint8(batteryPercent), Charging: charging, WiFi: wifi, LowPower: lowPower, AppActive: appActive}) + encoded, _ := json.Marshal(modeJSON{VerifyHeaders: mode.VerifyHeaders, Relay: mode.Relay, SampleDA: mode.SampleDA, ExecuteRecent: mode.ExecuteRecent, ServeCache: mode.ServeCache}) + return string(encoded) +} + +func parse32(value string) ([32]byte, error) { + var out [32]byte + raw, err := hex.DecodeString(value) + if err != nil || len(raw) != len(out) { + return out, ErrCitizenAnchor + } + copy(out[:], raw) + return out, nil +} diff --git a/mobile/citizen_test.go b/mobile/citizen_test.go new file mode 100644 index 00000000..8b8a8303 --- /dev/null +++ b/mobile/citizen_test.go @@ -0,0 +1,43 @@ +package mobile + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCitizenNodeRejectsBadAnchorAndDoesNotAdvanceOnBadBundle(t *testing.T) { + if _, err := NewCitizenNode("00", "11"); err != ErrCitizenAnchor { + t.Fatalf("expected bad anchor rejection, got %v", err) + } + network := strings.Repeat("11", 32) + root := strings.Repeat("22", 32) + node, err := NewCitizenNode(network, root) + if err != nil { + t.Fatal(err) + } + before := node.ValidatorRoot() + if _, err := node.VerifyObjectBundle(`{"network":"bad"}`); err == nil { + t.Fatal("invalid proof bundle was accepted") + } + if node.ValidatorRoot() != before { + t.Fatal("trust anchor advanced after invalid bundle") + } +} + +func TestMobileCitizenModeIsAdaptive(t *testing.T) { + var low map[string]bool + if err := json.Unmarshal([]byte(SelectCitizenMode(10, false, true, false, true)), &low); err != nil { + t.Fatal(err) + } + if !low["verifyHeaders"] || low["relay"] || low["sampleDA"] || low["executeRecent"] { + t.Fatalf("unexpected low-power mode: %+v", low) + } + var charging map[string]bool + if err := json.Unmarshal([]byte(SelectCitizenMode(80, true, true, false, true)), &charging); err != nil { + t.Fatal(err) + } + if !charging["verifyHeaders"] || !charging["relay"] || !charging["sampleDA"] || !charging["executeRecent"] || !charging["serveCache"] { + t.Fatalf("unexpected charging mode: %+v", charging) + } +}