diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 65e9a19..3ea4c78 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,3 +15,4 @@ Closes # - [ ] No new lint warnings - [ ] Docs updated if needed - [ ] PR targets `develop` +- [ ] Supabase queries audited for SQL injection (no raw SQL, parameterized methods used) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index b6c05e7..a1ad2f7 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -19,8 +19,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7f87b1..7c1c45e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: @@ -71,8 +69,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 22 @@ -114,7 +110,7 @@ jobs: - name: Install Rust toolchain (pinned via rust-toolchain.toml) uses: dtolnay/rust-toolchain@master with: - toolchain: "1.85.0" + toolchain: "1.88.0" targets: wasm32-unknown-unknown components: rustfmt, clippy @@ -134,6 +130,23 @@ jobs: run: cargo test --all working-directory: apps/contracts + proptest: + name: Property-based tests (proptest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain (pinned via rust-toolchain.toml) + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.88.0" + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/contracts/proptest + - name: Run proptest suite + run: cargo test + working-directory: apps/contracts/proptest + fuzz: name: Fuzz (time-limited, corpus only) runs-on: ubuntu-latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0b50723..ae69a03 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,6 +34,7 @@ jobs: uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} + # security-and-quality includes checks for SQL injection (CWE-089) queries: security-and-quality # Rust requires an explicit build so CodeQL can trace it diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 80a5f39..7c7363f 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -13,8 +13,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7ec293a..f503141 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,8 +17,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 042dc87..fd6a8c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,6 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/supabase.yml b/.github/workflows/supabase.yml index 720e7e1..3ae351b 100644 --- a/.github/workflows/supabase.yml +++ b/.github/workflows/supabase.yml @@ -24,9 +24,22 @@ jobs: - name: Start Supabase local stack run: supabase start - - name: Reset DB (applies all migrations + seed) + - name: Apply all migrations (forward) run: supabase db reset + - name: Verify rollback scripts exist for every migration + run: | + missing=0 + for f in supabase/migrations/*.sql; do + base=$(basename "$f" .sql) + down="supabase/migrations/rollbacks/${base}.down.sql" + if [ ! -f "$down" ]; then + echo "Missing rollback: $down" + missing=1 + fi + done + exit $missing + - name: Stop Supabase local stack if: always() run: supabase stop diff --git a/.github/workflows/zap-scan.yml b/.github/workflows/zap-scan.yml new file mode 100644 index 0000000..8d20ef2 --- /dev/null +++ b/.github/workflows/zap-scan.yml @@ -0,0 +1,32 @@ +name: OWASP ZAP Integration Scan + +on: + schedule: + - cron: '0 0 * * 0' # Weekly on Sunday at midnight + workflow_dispatch: + +jobs: + zap_scan: + runs-on: ubuntu-latest + name: Scan the web application + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: ZAP Baseline Scan + uses: zaproxy/action-baseline@v0.12.0 + with: + target: 'https://staging.solarproof.example.com' # Replace with actual staging URL + rules_file_name: '.zap/rules.tsv' + cmd_options: '-a' + issue_title: 'ZAP Scan Report: High/Critical Findings' + token: ${{ secrets.GITHUB_TOKEN }} + fail_action: false + + - name: Archive ZAP Report + uses: actions/upload-artifact@v4 + with: + name: zap-scan-report + path: | + report_md.md + report_html.html diff --git a/.zap/rules.tsv b/.zap/rules.tsv new file mode 100644 index 0000000..a494647 --- /dev/null +++ b/.zap/rules.tsv @@ -0,0 +1,5 @@ +# OWASP ZAP Baseline Scan Rules +# Format: \t +# Document false positives below: +# Example: Ignore Content Security Policy (CSP) Header Not Set because we handle it at the Edge level +# 10038 IGNORE diff --git a/CHANGELOG.md b/CHANGELOG.md index d225298..016d31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,121 @@ +## [1.3.0](https://github.com/AnnabelJoe/solarproof/compare/v1.2.0...v1.3.0) (2026-05-28) + +### Features + +* add /api/health and /api/ready endpoints ([#275](https://github.com/AnnabelJoe/solarproof/issues/275)) ([4f761a4](https://github.com/AnnabelJoe/solarproof/commit/4f761a428a2c3a9cb239d8ee1c2f7150d73acfe1)) + +### Documentation + +* document pnpm --frozen-lockfile requirement ([#302](https://github.com/AnnabelJoe/solarproof/issues/302)) ([4072e11](https://github.com/AnnabelJoe/solarproof/commit/4072e11a05c90cf952132bfd1b7b96d514021f1b)) + +## [1.2.0](https://github.com/AnnabelJoe/solarproof/compare/v1.1.0...v1.2.0) (2026-05-28) + +### Features + +* add governance voting UI ([#265](https://github.com/AnnabelJoe/solarproof/issues/265)) ([b59d23a](https://github.com/AnnabelJoe/solarproof/commit/b59d23a57fe4ba0c2e671f9ba4022ab2ea027ebb)) + +## [1.1.0](https://github.com/AnnabelJoe/solarproof/compare/v1.0.0...v1.1.0) (2026-05-28) + +### Features + +* responsive dashboard, certificate detail page, toast notifications, and accessibility improvements ([704c0a5](https://github.com/AnnabelJoe/solarproof/commit/704c0a5dc414eaeb4d15da15d4579a10f1bc076a)) + +## 1.0.0 (2026-05-19) + +### Features + +* **#12:** add toast notification system for transaction feedback ([c3ae97c](https://github.com/AnnabelJoe/solarproof/commit/c3ae97c0a0ff2bf5adf9115aa15e890686d85002)), closes [#12](https://github.com/AnnabelJoe/solarproof/issues/12) +* **#13:** implement certificate retirement flow in the UI ([093620a](https://github.com/AnnabelJoe/solarproof/commit/093620afbd16c89f5f0b3f3b4b092a6c6858d4f1)), closes [#13](https://github.com/AnnabelJoe/solarproof/issues/13) +* **#14:** add chart visualizations for energy generation over time ([5a35ae7](https://github.com/AnnabelJoe/solarproof/commit/5a35ae7263925dc9af3e7f1fc4467485838136ca)), closes [#14](https://github.com/AnnabelJoe/solarproof/issues/14) +* add /certificate/[id] chain-of-custody detail page ([cb622a8](https://github.com/AnnabelJoe/solarproof/commit/cb622a8eb3b7977627674f1772a1eedc6f3ca981)) +* add audit registry deduplication + local Soroban integration scripts ([3db8f31](https://github.com/AnnabelJoe/solarproof/commit/3db8f31344c4929d1b7c30b2b5d7d047e75ef521)) +* add automated CodeQL security scanning ([#88](https://github.com/AnnabelJoe/solarproof/issues/88)) ([8021a4f](https://github.com/AnnabelJoe/solarproof/commit/8021a4f4da2650681931c97af63a07069b7dedd6)) +* add copy-to-clipboard functionality for IDs and hashes ([c35dd6a](https://github.com/AnnabelJoe/solarproof/commit/c35dd6aec0ebf160773d37fca1d0b9fe6e089a7f)), closes [#23](https://github.com/AnnabelJoe/solarproof/issues/23) +* add Docker Compose setup for local development ([#82](https://github.com/AnnabelJoe/solarproof/issues/82)) ([6c6fb8b](https://github.com/AnnabelJoe/solarproof/commit/6c6fb8be3ec44d352dc8535251c00adb0d5379d8)) +* add npm and cargo audit to CI pipeline ([#91](https://github.com/AnnabelJoe/solarproof/issues/91)) ([24549f0](https://github.com/AnnabelJoe/solarproof/commit/24549f054f0dc39da01ac3f36ca4d6bf2d6da508)) +* add OpenAPI spec, /api/docs endpoint, Swagger UI, and CI validation ([#107](https://github.com/AnnabelJoe/solarproof/issues/107)) ([ed079cf](https://github.com/AnnabelJoe/solarproof/commit/ed079cf9d41213e5165e344235af97c9fa30f322)) +* add POST /api/readings/batch endpoint ([514e01f](https://github.com/AnnabelJoe/solarproof/commit/514e01f2765186c0ee1ce90da6f2590f5cfaa29b)) +* add real-time dashboard updates with WebSocket support ([091499c](https://github.com/AnnabelJoe/solarproof/commit/091499c5f355201c159d4afa13bb45d1f5c6bb77)), closes [#9](https://github.com/AnnabelJoe/solarproof/issues/9) +* add Redis/Upstash caching for certificate queries ([#43](https://github.com/AnnabelJoe/solarproof/issues/43)) ([a7f7583](https://github.com/AnnabelJoe/solarproof/commit/a7f7583f4036a1e32010ebfab1a94173b2769898)) +* add Vercel Analytics and Speed Insights ([#94](https://github.com/AnnabelJoe/solarproof/issues/94)) ([e79473c](https://github.com/AnnabelJoe/solarproof/commit/e79473cfa2752867a1a4bd5efde09f66d4e878b3)) +* **api:** append-only audit_log for operator actions ([767e161](https://github.com/AnnabelJoe/solarproof/commit/767e16117bef0576b71fc6ec5004789a39769471)), closes [#44](https://github.com/AnnabelJoe/solarproof/issues/44) +* **api:** implement CORS policy for API routes ([235e9b8](https://github.com/AnnabelJoe/solarproof/commit/235e9b8046fc1af0d131c146a72bcfc977c52aee)), closes [#46](https://github.com/AnnabelJoe/solarproof/issues/46) +* **api:** implement Ed25519 signature verification in POST /api/readings ([#26](https://github.com/AnnabelJoe/solarproof/issues/26)) ([6f92870](https://github.com/AnnabelJoe/solarproof/commit/6f928706880f1e686251c4ccde2656180963f976)) +* **api:** implement idempotency for meter reading submissions ([#28](https://github.com/AnnabelJoe/solarproof/issues/28)) ([8c8ebc2](https://github.com/AnnabelJoe/solarproof/commit/8c8ebc20392fe015f2aea6903523a1a96ea1d3fb)) +* **api:** implement webhook notifications for certificate events ([#38](https://github.com/AnnabelJoe/solarproof/issues/38)) ([b2a5323](https://github.com/AnnabelJoe/solarproof/commit/b2a5323ee4f15b2e36263fbad7c2c128ccbad911)) +* **api:** retry with exponential backoff for anchor/mint transactions ([89294a1](https://github.com/AnnabelJoe/solarproof/commit/89294a132c7b5551fca7aff3f0d50d52cf44d763)), closes [#31](https://github.com/AnnabelJoe/solarproof/issues/31) +* **auth:** implement JWT + Supabase Auth for operator routes ([#40](https://github.com/AnnabelJoe/solarproof/issues/40)) ([94311fc](https://github.com/AnnabelJoe/solarproof/commit/94311fc9129580848ea0c1a3cd30b057b1dea687)) +* **backup:** daily pg_dump to S3 with 30-day retention and Slack alerts ([#90](https://github.com/AnnabelJoe/solarproof/issues/90)) ([bb7d2de](https://github.com/AnnabelJoe/solarproof/commit/bb7d2deeb7b3606ec8f80288526db678673e3e49)) +* bitmap vote storage for community_governance ([#71](https://github.com/AnnabelJoe/solarproof/issues/71)) ([6b9378d](https://github.com/AnnabelJoe/solarproof/commit/6b9378d6ff6ef975fd72e9a0f9abc3a1fd6f7433)) +* configurable quorum_bps and threshold_bps in community_governance ([#64](https://github.com/AnnabelJoe/solarproof/issues/64)) ([9cb685b](https://github.com/AnnabelJoe/solarproof/commit/9cb685b1bf742412e25e9d267b35ec5fceab2fda)) +* configure Vercel preview deployments for every PR ([#78](https://github.com/AnnabelJoe/solarproof/issues/78)) ([ad997f7](https://github.com/AnnabelJoe/solarproof/commit/ad997f7d27153d247e503636c37784ce8aa408ec)) +* **contracts:** add multisig_admin contract for 2-of-3 admin ops ([#69](https://github.com/AnnabelJoe/solarproof/issues/69)) ([740383b](https://github.com/AnnabelJoe/solarproof/commit/740383b4d550e3fda0ad82a550eb8df780c4f297)) +* **contracts:** add version tracking and migration support ([#70](https://github.com/AnnabelJoe/solarproof/issues/70)) ([b057beb](https://github.com/AnnabelJoe/solarproof/commit/b057beb8a0f86801d15285bc6b8b280aa7b0f68d)) +* **contracts:** cargo-fuzz targets for mint, anchor, vote ([30ffc81](https://github.com/AnnabelJoe/solarproof/commit/30ffc814d321a78282e2f9991e323b0d46d68cd0)), closes [#67](https://github.com/AnnabelJoe/solarproof/issues/67) +* **contracts:** emit events for mint/retire/anchor/propose/vote ([b4cc652](https://github.com/AnnabelJoe/solarproof/commit/b4cc652a828639cd1a29e26e1d98c375912ae832)), closes [#60](https://github.com/AnnabelJoe/solarproof/issues/60) +* cursor-based paginated certificate list at /certificates ([1d55113](https://github.com/AnnabelJoe/solarproof/commit/1d55113b5cd575a6a4708ba810b71c04a92b3f6f)) +* **energy_token:** implement retire() for REC compliance ([#54](https://github.com/AnnabelJoe/solarproof/issues/54)) ([842042a](https://github.com/AnnabelJoe/solarproof/commit/842042ac24abb3079172a119e59ecefcb98ef8ce)) +* env var validation at startup with @t3-oss/env-nextjs ([#79](https://github.com/AnnabelJoe/solarproof/issues/79)) ([f386b18](https://github.com/AnnabelJoe/solarproof/commit/f386b18c3fdebf5d6ab3b853c97d31c6dd2df310)) +* **governance:** add contract upgrade mechanism with 48h timelock ([f335d8c](https://github.com/AnnabelJoe/solarproof/commit/f335d8c8627abf522f3e121f6705b18905a1eb5b)), closes [#55](https://github.com/AnnabelJoe/solarproof/issues/55) +* **governance:** add proposal execution timelock ([#65](https://github.com/AnnabelJoe/solarproof/issues/65)) ([e2ed39e](https://github.com/AnnabelJoe/solarproof/commit/e2ed39eb8c4ac5e636157646718ec4f3c40b4907)) +* **health:** comprehensive health check endpoint with DB + Stellar RPC checks ([#45](https://github.com/AnnabelJoe/solarproof/issues/45)) ([c0ebc47](https://github.com/AnnabelJoe/solarproof/commit/c0ebc471243bd4b80aa56a0148c80016496a527a)) +* implement POST /api/certificates/[id]/retire ([#34](https://github.com/AnnabelJoe/solarproof/issues/34)) ([1d7e055](https://github.com/AnnabelJoe/solarproof/commit/1d7e055641e9dfa96ba0e7ba55fbd976074273d1)) +* implement SEP-41 token interface compliance for energy_token ([#61](https://github.com/AnnabelJoe/solarproof/issues/61)) ([d9788f9](https://github.com/AnnabelJoe/solarproof/commit/d9788f9690294cac690219b1d6555c9d63cce6a1)) +* implement Supabase RLS for multi-operator isolation ([#36](https://github.com/AnnabelJoe/solarproof/issues/36)) ([5e7bdf2](https://github.com/AnnabelJoe/solarproof/commit/5e7bdf2d6f677501f07f43f6384531fa04af4419)) +* implement token transfer pause mechanism ([#66](https://github.com/AnnabelJoe/solarproof/issues/66)) ([e7fc62a](https://github.com/AnnabelJoe/solarproof/commit/e7fc62a36670474926325bcd7c8ab7e8ab64ab58)) +* **infra:** set up staging environment on Vercel ([#89](https://github.com/AnnabelJoe/solarproof/issues/89)) ([7ad4dae](https://github.com/AnnabelJoe/solarproof/commit/7ad4daeecc306b586397c6b50784d316e0cff15a)) +* initial SolarProof — cryptographic renewable energy certification ([404fce6](https://github.com/AnnabelJoe/solarproof/commit/404fce6acdbc814b42293fd2328152291caf3833)) +* integrate Sentry error monitoring in Next.js app ([#83](https://github.com/AnnabelJoe/solarproof/issues/83)) ([da4fe77](https://github.com/AnnabelJoe/solarproof/commit/da4fe77ff4bbaa0eff8ac552817cc3c04e942b17)) +* load Stellar signing key from AWS Secrets Manager with rotation support ([7b81c1a](https://github.com/AnnabelJoe/solarproof/commit/7b81c1a2e8a1d669a2b0cad467dc8b296af9be59)), closes [#50](https://github.com/AnnabelJoe/solarproof/issues/50) +* **meters:** add meter management UI and API routes ([e379c87](https://github.com/AnnabelJoe/solarproof/commit/e379c87458715989fac4006598d3986d565560d0)), closes [#18](https://github.com/AnnabelJoe/solarproof/issues/18) +* **migrations:** add rollback scripts and operator_sessions migration ([#37](https://github.com/AnnabelJoe/solarproof/issues/37)) ([d0e432c](https://github.com/AnnabelJoe/solarproof/commit/d0e432cb132c442839ab4674df3454d57c6bb585)) +* mobile responsive, skeleton loaders, dark mode, ARIA a11y ([ae45c53](https://github.com/AnnabelJoe/solarproof/commit/ae45c535882691c29b5686483599448d19d42ad1)) +* **monitoring:** add uptime checks for /api/health and /verify ([#84](https://github.com/AnnabelJoe/solarproof/issues/84)) ([cd5d44e](https://github.com/AnnabelJoe/solarproof/commit/cd5d44e0161b6b23def3770d2761aa140d2acdd7)) +* optimize audit_registry storage — hash-only on-chain ([#59](https://github.com/AnnabelJoe/solarproof/issues/59)) ([6f233cd](https://github.com/AnnabelJoe/solarproof/commit/6f233cda8b2e0aebbf56bb046c2cbf74a3058dff)) +* overflow protection for energy_token mint arithmetic ([#51](https://github.com/AnnabelJoe/solarproof/issues/51)) ([9857d35](https://github.com/AnnabelJoe/solarproof/commit/9857d356cbdb2142e22005a8eede107fe89f6edb)) +* pin Rust toolchain and harden CI for Soroban contracts ([#77](https://github.com/AnnabelJoe/solarproof/issues/77)) ([d61094a](https://github.com/AnnabelJoe/solarproof/commit/d61094a4469aa22adf81fe4180d8186466e2f9b9)) +* pre-flight account and trustline checks before mint ([7de0635](https://github.com/AnnabelJoe/solarproof/commit/7de06358546a1fcfece5385c9a9397c4ec8b7c80)) +* **queue:** async Stellar transaction queue with job status API ([0fc6c1d](https://github.com/AnnabelJoe/solarproof/commit/0fc6c1d525483ae422585c51f5c5069d796c37ab)), closes [#42](https://github.com/AnnabelJoe/solarproof/issues/42) +* rate limiting, meter name, tracer-sim, verify chain-of-custody ([5b2f413](https://github.com/AnnabelJoe/solarproof/commit/5b2f413aba7ac18fc7bacca90cab0f89ec2da64e)), closes [#27](https://github.com/AnnabelJoe/solarproof/issues/27) [#30](https://github.com/AnnabelJoe/solarproof/issues/30) [#32](https://github.com/AnnabelJoe/solarproof/issues/32) [#35](https://github.com/AnnabelJoe/solarproof/issues/35) +* resolve issues [#10](https://github.com/AnnabelJoe/solarproof/issues/10) [#15](https://github.com/AnnabelJoe/solarproof/issues/15) [#16](https://github.com/AnnabelJoe/solarproof/issues/16) [#17](https://github.com/AnnabelJoe/solarproof/issues/17) — i18n, governance form, voting UI, verify stepper ([b7ecc3b](https://github.com/AnnabelJoe/solarproof/commit/b7ecc3b023a61aa935f46aae1e071e952050b9ea)) +* **scripts:** add idempotent deploy-testnet and deploy-mainnet scripts ([e7c4855](https://github.com/AnnabelJoe/solarproof/commit/e7c4855a584b8e5c9e878847436a9736d7799770)), closes [#63](https://github.com/AnnabelJoe/solarproof/issues/63) +* **stellar:** add 10s timeout and circuit breaker to all RPC calls ([866fffe](https://github.com/AnnabelJoe/solarproof/commit/866fffe5e0caddebcbc6ed3ed355deaaff198421)), closes [#41](https://github.com/AnnabelJoe/solarproof/issues/41) +* structured log aggregation via Logtail (Better Stack) ([23bc6ac](https://github.com/AnnabelJoe/solarproof/commit/23bc6acd212628b8db4385eeae8c35259e057f70)), closes [#92](https://github.com/AnnabelJoe/solarproof/issues/92) +* structured logging, API versioning, pagination, governance tests ([7b74132](https://github.com/AnnabelJoe/solarproof/commit/7b741323a974253f90206aef4b5115bd74638504)), closes [#33](https://github.com/AnnabelJoe/solarproof/issues/33) [#39](https://github.com/AnnabelJoe/solarproof/issues/39) [#47](https://github.com/AnnabelJoe/solarproof/issues/47) [#58](https://github.com/AnnabelJoe/solarproof/issues/58) +* Supabase IaC migrations and CI validation ([#95](https://github.com/AnnabelJoe/solarproof/issues/95)) ([302f23e](https://github.com/AnnabelJoe/solarproof/commit/302f23ee8dec27c6a5ffdea0bbfd7dff369d981a)) +* **web:** add custom 404 and 500 error pages ([d8d9895](https://github.com/AnnabelJoe/solarproof/commit/d8d98958bdbdae336dd5127183ae6ac311ce34b2)), closes [#22](https://github.com/AnnabelJoe/solarproof/issues/22) +* **web:** make contract addresses and network config env-configurable ([#62](https://github.com/AnnabelJoe/solarproof/issues/62)) ([b393066](https://github.com/AnnabelJoe/solarproof/commit/b393066d14b9ede145da192deac3e80a7577b92a)) + +### Bug Fixes + +* **#11:** persist Freighter wallet connection state across page refreshes ([32045d4](https://github.com/AnnabelJoe/solarproof/commit/32045d487cae237ca191e49542196a35b80776c7)), closes [#11](https://github.com/AnnabelJoe/solarproof/issues/11) +* **a11y:** improve keyboard accessibility on verify page ([1751ba4](https://github.com/AnnabelJoe/solarproof/commit/1751ba41c1f0d88d9bc0cc84b653991ca304256f)) +* add error boundaries to isolate component failures ([39f715b](https://github.com/AnnabelJoe/solarproof/commit/39f715b23df292e1f23238bbf37d93e85a2c7484)) +* add input validation to verify and retire API routes ([#29](https://github.com/AnnabelJoe/solarproof/issues/29)) ([4ea58a6](https://github.com/AnnabelJoe/solarproof/commit/4ea58a623b5efbbbba1bff7d023ed65893d40543)) +* add spinner and disable buttons during form submission ([7ef2928](https://github.com/AnnabelJoe/solarproof/commit/7ef292884c30c921125e35f8d5f5a6ed64608c1a)), closes [#21](https://github.com/AnnabelJoe/solarproof/issues/21) +* **audit-registry:** add access control to anchor() ([a042f18](https://github.com/AnnabelJoe/solarproof/commit/a042f187585493850aee021b4c4c329c9451b532)), closes [#52](https://github.com/AnnabelJoe/solarproof/issues/52) +* cargo fmt, Rust 1.88.0 toolchain, remove pnpm version conflict ([ea5a393](https://github.com/AnnabelJoe/solarproof/commit/ea5a3939b2bb065a42f88c130c4be4261412f545)) +* **ci:** commit pnpm lockfile for frozen-lockfile enforcement ([#86](https://github.com/AnnabelJoe/solarproof/issues/86)) ([0752cbd](https://github.com/AnnabelJoe/solarproof/commit/0752cbd2c335ffe4ff2dc3160c35565651da2dfa)) +* **governance:** add reentrancy guard to vote() ([0e051d2](https://github.com/AnnabelJoe/solarproof/commit/0e051d28111f263df7595da3bb9c15d592fb42a4)), closes [#53](https://github.com/AnnabelJoe/solarproof/issues/53) +* regenerate lockfile, bump rust-toolchain.toml to 1.88.0 ([158d740](https://github.com/AnnabelJoe/solarproof/commit/158d740e4f676589fd5e5e10d7b180ef56c3842a)) +* remove empty with: blocks from pnpm/action-setup steps ([b8ebced](https://github.com/AnnabelJoe/solarproof/commit/b8ebced91c374231093bde16ca98f20c65030669)) +* resolve all build, type, and lint errors ([8a67ea0](https://github.com/AnnabelJoe/solarproof/commit/8a67ea0c1af167b756621ca9e683f5b763042973)) +* resolve issues [#19](https://github.com/AnnabelJoe/solarproof/issues/19), [#20](https://github.com/AnnabelJoe/solarproof/issues/20), [#24](https://github.com/AnnabelJoe/solarproof/issues/24), [#25](https://github.com/AnnabelJoe/solarproof/issues/25) ([78448aa](https://github.com/AnnabelJoe/solarproof/commit/78448aa04a1460fe8756316666862022d11ed900)) +* secrets management - placeholder .env.example + secret scanning ([#85](https://github.com/AnnabelJoe/solarproof/issues/85)) ([17435bd](https://github.com/AnnabelJoe/solarproof/commit/17435bd161268aa661935ec2eb5f2cf576f9b0d4)) + +### Documentation + +* add API reference for all endpoints ([#96](https://github.com/AnnabelJoe/solarproof/issues/96)) ([d66dbe4](https://github.com/AnnabelJoe/solarproof/commit/d66dbe44ef4cb0e58a117be8836b67c625d68717)) +* add contract deployment guide and deployments.md ([#108](https://github.com/AnnabelJoe/solarproof/issues/108)) ([e084e5e](https://github.com/AnnabelJoe/solarproof/commit/e084e5e9d88c951aa981fa097b58ea55dc851af5)) +* add descriptions and examples to .env.example ([#105](https://github.com/AnnabelJoe/solarproof/issues/105)) ([e834f15](https://github.com/AnnabelJoe/solarproof/commit/e834f15888be68aff8ed82a2b87c824f03d0e29a)) +* add developer onboarding guide ([#97](https://github.com/AnnabelJoe/solarproof/issues/97)) ([3b77c50](https://github.com/AnnabelJoe/solarproof/commit/3b77c50d1e260cd09a589e7176c826c9ed1acb22)) +* add end-user guide for public verifier ([#106](https://github.com/AnnabelJoe/solarproof/issues/106)) ([a151440](https://github.com/AnnabelJoe/solarproof/commit/a151440df3a6c5e490b8aefb56d34de3f227a2e2)) +* **adr:** add ADR template, index, and 4 ADRs ([#99](https://github.com/AnnabelJoe/solarproof/issues/99)) ([28db52c](https://github.com/AnnabelJoe/solarproof/commit/28db52cfe55c76627cf8893adcffed5b89dc6905)) +* **contracts:** add interface and error code docs for all three contracts ([b6d39fd](https://github.com/AnnabelJoe/solarproof/commit/b6d39fdb384eb72ed8c619fb6b5b29ddb8ce32e6)), closes [#98](https://github.com/AnnabelJoe/solarproof/issues/98) +* **contracts:** add NatSpec-style doc comments to all public functions ([#68](https://github.com/AnnabelJoe/solarproof/issues/68)) ([ed9a056](https://github.com/AnnabelJoe/solarproof/commit/ed9a05610dcf8e14f172fbbc0cc717b74b8c6c7e)) +* expand CONTRIBUTING.md with branch naming, commit format, PR checklist, and review expectations ([b26a136](https://github.com/AnnabelJoe/solarproof/commit/b26a136d1b76536be00a630c3e4d5e4786126065)), closes [#100](https://github.com/AnnabelJoe/solarproof/issues/100) +* prepare contracts for security audit ([#75](https://github.com/AnnabelJoe/solarproof/issues/75)) ([295766b](https://github.com/AnnabelJoe/solarproof/commit/295766b5e8196f281cddcb483d6acb45bcbf736f)) + # Changelog All notable changes to this project will be documented in this file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65ee0b8..253a573 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ git clone https://github.com/AnnabelJoe/solarproof.git cd solarproof git checkout develop git checkout -b feat/your-feature -pnpm install +pnpm install --frozen-lockfile ``` --- @@ -120,3 +120,38 @@ Contract changes require extra care: ## Reporting Issues Use the [issue templates](../../issues/new/choose). For security vulnerabilities, see [SECURITY.md](SECURITY.md). + +--- + +## Regression Tests + +Every closed bug issue must have a corresponding regression test to prevent the bug from being silently reintroduced. + +### Process + +1. **When a bug is fixed**, add a regression test in the same PR as the fix (or in a follow-up PR referencing the issue). +2. **File location** — regression tests live in `apps/web/src/app/api/__tests__/regression.test.ts` for API-layer bugs. For contract bugs, add tests in the relevant contract's `#[cfg(test)]` module. +3. **Naming convention** — test names must include the issue number: + - TypeScript: `it('test_issue__', ...)` + - Rust: `#[test] fn test_issue__()` +4. **Link the issue** — add a comment at the top of the test or describe block referencing the issue number and a one-line description of the bug. +5. **CI** — regression tests run automatically in CI via `pnpm test` (TypeScript) and `cargo test` (Rust). No extra configuration is needed. + +### Example + +```ts +// Regression for #29 — API routes accepted raw input without schema validation +it('test_issue_29_readings_rejects_negative_kwh', async () => { + const res = await POST(makeRequest({ meter_id: METER_ID, kwh: -1, ... })) + expect(res.status).toBe(400) +}) +``` + +### Covered issues + +| Issue | Description | Test file | +|-------|-------------|-----------| +| #29 | Input validation on all API routes | `src/app/api/__tests__/regression.test.ts` | +| #49 | Stellar account existence check before minting | `src/app/api/__tests__/regression.test.ts` | +| #51 | Overflow protection in energy_token mint arithmetic | `apps/contracts/energy_token/src/lib.rs` | +| #73 | Reading deduplication in audit_registry | `apps/contracts/audit_registry/src/lib.rs`, `src/app/api/__tests__/regression.test.ts` | diff --git a/apps/contracts/audit_registry/src/lib.rs b/apps/contracts/audit_registry/src/lib.rs index 4d06c79..ec0dd83 100644 --- a/apps/contracts/audit_registry/src/lib.rs +++ b/apps/contracts/audit_registry/src/lib.rs @@ -36,7 +36,9 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, contracterror, symbol_short, Address, BytesN, Env}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, +}; const VERSION: &str = "1.0.0"; @@ -57,7 +59,7 @@ pub struct AuditAnchor { pub anchored_at_ledger: u32, } -/// Enumeration of all storage keys used by this contract. + /// Enumeration of all storage keys used by this contract. #[contracttype] pub enum DataKey { /// `Address` — the contract administrator. @@ -66,6 +68,8 @@ pub enum DataKey { ApiSigner, /// `AuditAnchor` — keyed by the 32-byte reading hash. Anchor(BytesN<32>), + /// `bool` — keyed by the 32-byte nonce. + Nonce(BytesN<32>), /// `u32` — total number of anchors stored. TotalAnchors, Version, @@ -101,32 +105,38 @@ impl AuditRegistry { panic!("already initialized"); } env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::ApiSigner, &api_signer); + env.storage() + .instance() + .set(&DataKey::ApiSigner, &api_signer); env.storage().instance().set(&DataKey::TotalAnchors, &0_u32); - env.storage().instance().set(&DataKey::Version, &soroban_sdk::String::from_str(&env, VERSION)); + env.storage().instance().set( + &DataKey::Version, + &soroban_sdk::String::from_str(&env, VERSION), + ); } /// Returns the contract version string (e.g. `"1.0.0"`). pub fn get_version(env: Env) -> soroban_sdk::String { - env.storage().instance() + env.storage() + .instance() .get(&DataKey::Version) .unwrap_or_else(|| soroban_sdk::String::from_str(&env, VERSION)) } /// Migrate state schema to a new version. Admin-only. /// - /// # Arguments - /// * `new_version` — version string to store (e.g. `"2.0.0"`). - /// - /// # Authorization - /// Requires `admin` authorisation. - /// /// # Panics /// * `"not initialized"` if the contract has not been initialised. pub fn migrate(env: Env, new_version: soroban_sdk::String) { - let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); admin.require_auth(); - env.storage().instance().set(&DataKey::Version, &new_version); + env.storage() + .instance() + .set(&DataKey::Version, &new_version); } /// Update the authorised API signer address. Admin-only. @@ -134,42 +144,57 @@ impl AuditRegistry { /// # Panics /// * `"not initialized"` if the contract has not been initialised. pub fn set_api_signer(env: Env, new_signer: soroban_sdk::Address) { - let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); admin.require_auth(); - env.storage().instance().set(&DataKey::ApiSigner, &new_signer); + env.storage() + .instance() + .set(&DataKey::ApiSigner, &new_signer); } /// Returns the current authorised API signer address. pub fn api_signer(env: Env) -> soroban_sdk::Address { - env.storage().instance().get(&DataKey::ApiSigner).expect("not initialized") + env.storage() + .instance() + .get(&DataKey::ApiSigner) + .expect("not initialized") } /// Anchor a reading hash on-chain. /// - /// Only the whitelisted `api_signer` address may call this function. - /// - /// # Arguments - /// * `caller` — must be the registered `api_signer`. - /// * `reading_hash` — SHA-256 of `(meter_id || kwh_stroops_le || timestamp_le)`. - /// - /// # Panics - /// * `"unauthorized"` if `caller` is not the registered `api_signer`. - /// * `"reading already anchored"` if `reading_hash` has been anchored before. - /// /// # Events /// Emits `(topic: "anchor", data: reading_hash)`. - pub fn anchor(env: Env, caller: soroban_sdk::Address, reading_hash: BytesN<32>) -> Result<(), Error> { + pub fn anchor( + env: Env, + caller: soroban_sdk::Address, + reading_hash: BytesN<32>, + nonce: soroban_sdk::BytesN<32>, + ) -> Result<(), Error> { caller.require_auth(); - let api_signer: Address = env.storage().instance().get(&DataKey::ApiSigner).expect("not initialized"); + let api_signer: Address = env + .storage() + .instance() + .get(&DataKey::ApiSigner) + .expect("not initialized"); if caller != api_signer { return Err(Error::Unauthorized); } + let nonce_key = DataKey::Nonce(nonce.clone()); + if env.storage().persistent().has(&nonce_key) { + return Err(Error::AlreadyAnchored); + } + let key = DataKey::Anchor(reading_hash.clone()); if env.storage().persistent().has(&key) { return Err(Error::AlreadyAnchored); } + env.storage().persistent().set(&nonce_key, &true); + let anchor = AuditAnchor { reading_hash: reading_hash.clone(), anchored_at_ledger: env.ledger().sequence(), @@ -177,40 +202,54 @@ impl AuditRegistry { env.storage().persistent().set(&key, &anchor); - let count: u32 = env.storage().instance().get(&DataKey::TotalAnchors).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalAnchors, &(count + 1)); + let count: u32 = env + .storage() + .instance() + .get(&DataKey::TotalAnchors) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalAnchors, &(count + 1)); env.events().publish( (symbol_short!("anchor"),), - (reading_hash, env.ledger().sequence(), env.ledger().timestamp()), + ( + reading_hash, + env.ledger().sequence(), + env.ledger().timestamp(), + ), ); Ok(()) } /// Returns the `AuditAnchor` for `reading_hash`, or `None` if not anchored. - /// - /// # Arguments - /// * `reading_hash` — 32-byte SHA-256 hash to look up. pub fn verify(env: Env, reading_hash: BytesN<32>) -> Option { - env.storage().persistent().get(&DataKey::Anchor(reading_hash)) + env.storage() + .persistent() + .get(&DataKey::Anchor(reading_hash)) } /// Returns `true` if `reading_hash` has been anchored, `false` otherwise. pub fn is_anchored(env: Env, reading_hash: BytesN<32>) -> bool { - env.storage().persistent().has(&DataKey::Anchor(reading_hash)) + env.storage() + .persistent() + .has(&DataKey::Anchor(reading_hash)) } /// Returns the total number of reading hashes anchored so far. pub fn total_anchors(env: Env) -> u32 { - env.storage().instance().get(&DataKey::TotalAnchors).unwrap_or(0) + env.storage() + .instance() + .get(&DataKey::TotalAnchors) + .unwrap_or(0) } /// Returns the admin address. - /// - /// # Panics - /// * `"not initialized"` if the contract has not been initialised. pub fn admin(env: Env) -> soroban_sdk::Address { - env.storage().instance().get(&DataKey::Admin).expect("not initialized") + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized") } } @@ -250,10 +289,12 @@ mod tests { } #[test] - fn test_unauthorized_caller_rejected() { - let (env, _api_signer, client) = setup(); - let attacker = soroban_sdk::Address::generate(&env); - assert_eq!(client.anchor(&attacker, &hash(&env)), Err(Error::Unauthorized)); + fn test_anchor_records_ledger_sequence() { + let (env, api_signer, client) = setup(); + let h = hash(&env); + client.anchor(&api_signer, &h).unwrap(); + let anchor = client.verify(&h).unwrap(); + let _ = anchor.anchored_at_ledger; } #[test] @@ -264,6 +305,48 @@ mod tests { assert_eq!(client.anchor(&api_signer, &h), Err(Error::AlreadyAnchored)); } + #[test] + fn test_duplicate_anchor_does_not_increment_total() { + let (env, api_signer, client) = setup(); + let h = hash(&env); + client.anchor(&api_signer, &h).unwrap(); + let _ = client.anchor(&api_signer, &h); + assert_eq!(client.total_anchors(), 1); + } + + #[test] + fn test_different_hashes_are_independent() { + let (env, api_signer, client) = setup(); + let h1 = BytesN::from_array(&env, &[0xAAu8; 32]); + let h2 = BytesN::from_array(&env, &[0xBBu8; 32]); + client.anchor(&api_signer, &h1).unwrap(); + client.anchor(&api_signer, &h2).unwrap(); + assert!(client.is_anchored(&h1)); + assert!(client.is_anchored(&h2)); + assert_eq!(client.total_anchors(), 2); + } + + #[test] + fn test_unauthorized_caller_rejected() { + let (env, _api_signer, client) = setup(); + let attacker = soroban_sdk::Address::generate(&env); + assert_eq!( + client.anchor(&attacker, &hash(&env)), + Err(Error::Unauthorized) + ); + } + + #[test] + fn test_old_signer_rejected_after_rotation() { + let (env, old_signer, client) = setup(); + let new_signer = soroban_sdk::Address::generate(&env); + client.set_api_signer(&new_signer); + assert_eq!( + client.anchor(&old_signer, &hash(&env)), + Err(Error::Unauthorized) + ); + } + #[test] fn test_not_anchored_returns_none() { let (env, _api_signer, client) = setup(); @@ -272,15 +355,63 @@ mod tests { assert!(client.verify(&h).is_none()); } + #[test] + fn test_total_anchors_starts_at_zero() { + let (env, _api_signer, client) = setup(); + assert_eq!(client.total_anchors(), 0); + let _ = client.verify(&BytesN::from_array(&env, &[0u8; 32])); + assert_eq!(client.total_anchors(), 0); + } + #[test] fn test_total_anchors_increments() { let (env, api_signer, client) = setup(); for i in 0u8..5 { - client.anchor(&api_signer, &BytesN::from_array(&env, &[i; 32])); + client + .anchor(&api_signer, &BytesN::from_array(&env, &[i; 32])) + .unwrap(); } assert_eq!(client.total_anchors(), 5); } + #[test] + fn test_admin_query() { + let (env, _api_signer, client) = setup(); + let _admin = client.admin(); + let _ = &env; + } + + #[test] + fn test_api_signer_query() { + let (env, api_signer, client) = setup(); + assert_eq!(client.api_signer(), api_signer); + } + + #[test] + fn test_large_number_of_anchors() { + let (env, api_signer, client) = setup(); + let count: u8 = 50; + for i in 0..count { + let h = BytesN::from_array(&env, &[i; 32]); + client.anchor(&api_signer, &h).unwrap(); + } + assert_eq!(client.total_anchors(), u32::from(count)); + assert!(client.is_anchored(&BytesN::from_array(&env, &[0u8; 32]))); + assert!(client.is_anchored(&BytesN::from_array(&env, &[count - 1; 32]))); + } + + #[test] + fn test_boundary_hash_values() { + let (env, api_signer, client) = setup(); + let all_zeros = BytesN::from_array(&env, &[0x00u8; 32]); + let all_ones = BytesN::from_array(&env, &[0xFFu8; 32]); + client.anchor(&api_signer, &all_zeros).unwrap(); + client.anchor(&api_signer, &all_ones).unwrap(); + assert!(client.is_anchored(&all_zeros)); + assert!(client.is_anchored(&all_ones)); + assert_eq!(client.total_anchors(), 2); + } + #[test] #[should_panic(expected = "already initialized")] fn test_double_initialize_rejected() { @@ -294,16 +425,26 @@ mod tests { fn test_set_api_signer_updates_authorized_caller() { let (env, _old_signer, client) = setup(); let new_signer = soroban_sdk::Address::generate(&env); - // admin is mock_all_auths so set_api_signer passes client.set_api_signer(&new_signer); let h = hash(&env); - client.anchor(&new_signer, &h); + client.anchor(&new_signer, &h).unwrap(); assert!(client.is_anchored(&h)); } + #[test] + fn test_migrate_updates_version() { + let (env, _api_signer, client) = setup(); + let new_ver = soroban_sdk::String::from_str(&env, "2.0.0"); + client.migrate(&new_ver); + assert_eq!(client.get_version(), new_ver); + } + #[test] fn test_version() { let (env, _api_signer, client) = setup(); - assert_eq!(client.get_version(), soroban_sdk::String::from_str(&env, "1.0.0")); + assert_eq!( + client.get_version(), + soroban_sdk::String::from_str(&env, "1.0.0") + ); } } diff --git a/apps/contracts/community_governance/src/lib.rs b/apps/contracts/community_governance/src/lib.rs index 870e24e..7d5fa57 100644 --- a/apps/contracts/community_governance/src/lib.rs +++ b/apps/contracts/community_governance/src/lib.rs @@ -23,7 +23,9 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Map, String}; +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Map, String, +}; // --------------------------------------------------------------------------- // Types @@ -110,7 +112,7 @@ const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; /// 24 hours expressed in ledgers (10-second ledger time). const EXECUTE_TIMELOCK_LEDGERS: u32 = 8_640; -const DEFAULT_QUORUM_BPS: u32 = 1_000; // 10% +const DEFAULT_QUORUM_BPS: u32 = 1_000; // 10% const DEFAULT_THRESHOLD_BPS: u32 = 5_100; // 51% const VERSION: &str = "1.0.0"; @@ -131,9 +133,15 @@ fn voter_index(env: &Env, voter: &Address) -> u32 { if let Some(idx) = env.storage().instance().get::<_, u32>(&key) { return idx; } - let count: u32 = env.storage().instance().get(&DataKey::VoterCount).unwrap_or(0); + let count: u32 = env + .storage() + .instance() + .get(&DataKey::VoterCount) + .unwrap_or(0); env.storage().instance().set(&key, &count); - env.storage().instance().set(&DataKey::VoterCount, &(count + 1)); + env.storage() + .instance() + .set(&DataKey::VoterCount, &(count + 1)); count } @@ -155,7 +163,9 @@ fn bitmap_set(env: &Env, proposal_id: u32, idx: u32) { let bit = idx % 128; let key = bitmap_key(proposal_id, word_idx); let word: u128 = env.storage().persistent().get(&key).unwrap_or(0_u128); - env.storage().persistent().set(&key, &(word | (1_u128 << bit))); + env.storage() + .persistent() + .set(&key, &(word | (1_u128 << bit))); } // ── contract ────────────────────────────────────────────────────────────────── @@ -172,21 +182,36 @@ impl CommunityGovernance { /// # Panics /// * `"already initialized"` if called more than once. pub fn initialize(env: Env, admin: Address, quorum: u32, voting_period_ledgers: u32) { - if env.storage().instance().has(&DataKey::Admin) { panic!("already initialized"); } + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::QuorumBps, &DEFAULT_QUORUM_BPS); - env.storage().instance().set(&DataKey::ThresholdBps, &DEFAULT_THRESHOLD_BPS); - env.storage().instance().set(&DataKey::VotingPeriod, &voting_period_ledgers); - env.storage().instance().set(&DataKey::ProposalCount, &0_u32); + env.storage() + .instance() + .set(&DataKey::QuorumBps, &DEFAULT_QUORUM_BPS); + env.storage() + .instance() + .set(&DataKey::ThresholdBps, &DEFAULT_THRESHOLD_BPS); + env.storage() + .instance() + .set(&DataKey::VotingPeriod, &voting_period_ledgers); + env.storage() + .instance() + .set(&DataKey::ProposalCount, &0_u32); env.storage().instance().set(&DataKey::VoterCount, &0_u32); let proposals: Map = Map::new(&env); - env.storage().instance().set(&DataKey::Proposals, &proposals); - env.storage().instance().set(&DataKey::Version, &String::from_str(&env, VERSION)); + env.storage() + .instance() + .set(&DataKey::Proposals, &proposals); + env.storage() + .instance() + .set(&DataKey::Version, &String::from_str(&env, VERSION)); } /// Returns the contract version string (e.g. `"1.0.0"`). pub fn get_version(env: Env) -> String { - env.storage().instance() + env.storage() + .instance() .get(&DataKey::Version) .unwrap_or_else(|| String::from_str(&env, VERSION)) } @@ -202,9 +227,15 @@ impl CommunityGovernance { /// # Panics /// * `"not initialized"` if the contract has not been initialised. pub fn migrate(env: Env, new_version: String) { - let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); admin.require_auth(); - env.storage().instance().set(&DataKey::Version, &new_version); + env.storage() + .instance() + .set(&DataKey::Version, &new_version); } /// Set quorum in basis points (1–10 000). Admin-only. @@ -216,7 +247,10 @@ impl CommunityGovernance { /// Returns the current quorum in basis points. pub fn get_quorum_bps(env: Env) -> u32 { - env.storage().instance().get(&DataKey::QuorumBps).unwrap_or(DEFAULT_QUORUM_BPS) + env.storage() + .instance() + .get(&DataKey::QuorumBps) + .unwrap_or(DEFAULT_QUORUM_BPS) } /// Set approval threshold in basis points (1–10 000). Admin-only. @@ -228,7 +262,10 @@ impl CommunityGovernance { /// Returns the current approval threshold in basis points. pub fn get_threshold_bps(env: Env) -> u32 { - env.storage().instance().get(&DataKey::ThresholdBps).unwrap_or(DEFAULT_THRESHOLD_BPS) + env.storage() + .instance() + .get(&DataKey::ThresholdBps) + .unwrap_or(DEFAULT_THRESHOLD_BPS) } /// Submit a new proposal. @@ -245,20 +282,40 @@ impl CommunityGovernance { /// The new proposal's ID. pub fn propose(env: Env, proposer: Address, title: String, description: String) -> u32 { proposer.require_auth(); - let mut count: u32 = env.storage().instance().get(&DataKey::ProposalCount).unwrap_or(0); + let mut count: u32 = env + .storage() + .instance() + .get(&DataKey::ProposalCount) + .unwrap_or(0); count += 1; - let period: u32 = env.storage().instance().get(&DataKey::VotingPeriod).expect("not initialized"); + let period: u32 = env + .storage() + .instance() + .get(&DataKey::VotingPeriod) + .expect("not initialized"); let proposal = Proposal { - id: count, proposer, title, description, - yes_votes: 0, no_votes: 0, + id: count, + proposer, + title, + description, + yes_votes: 0, + no_votes: 0, end_ledger: env.ledger().sequence() + period, status: ProposalStatus::Active, execute_after: 0, }; - let mut proposals: Map = env.storage().instance().get(&DataKey::Proposals).expect("not initialized"); + let mut proposals: Map = env + .storage() + .instance() + .get(&DataKey::Proposals) + .expect("not initialized"); proposals.set(count, proposal); - env.storage().instance().set(&DataKey::ProposalCount, &count); - env.storage().instance().set(&DataKey::Proposals, &proposals); + env.storage() + .instance() + .set(&DataKey::ProposalCount, &count); + env.storage() + .instance() + .set(&DataKey::Proposals, &proposals); env.events().publish((symbol_short!("propose"),), count); count } @@ -283,7 +340,12 @@ impl CommunityGovernance { voter.require_auth(); // ── reentrancy guard ────────────────────────────────────────────── - if env.storage().instance().get::<_, bool>(&DataKey::VoteLock).unwrap_or(false) { + if env + .storage() + .instance() + .get::<_, bool>(&DataKey::VoteLock) + .unwrap_or(false) + { panic!("reentrant call"); } env.storage().instance().set(&DataKey::VoteLock, &true); @@ -296,20 +358,34 @@ impl CommunityGovernance { panic!("already voted"); } - let mut proposals: Map = env.storage().instance().get(&DataKey::Proposals).expect("not initialized"); + let mut proposals: Map = env + .storage() + .instance() + .get(&DataKey::Proposals) + .expect("not initialized"); let mut p = proposals.get(proposal_id).expect("proposal not found"); assert!(p.status == ProposalStatus::Active, "proposal not active"); - assert!(env.ledger().sequence() <= p.end_ledger, "voting period ended"); + assert!( + env.ledger().sequence() <= p.end_ledger, + "voting period ended" + ); // ── effects: update all state before any external calls ─────────── - if approve { p.yes_votes += 1; } else { p.no_votes += 1; } + if approve { + p.yes_votes += 1; + } else { + p.no_votes += 1; + } proposals.set(proposal_id, p); - env.storage().instance().set(&DataKey::Proposals, &proposals); + env.storage() + .instance() + .set(&DataKey::Proposals, &proposals); // Record vote in bitmap (single persistent write per 128 voters) bitmap_set(&env, proposal_id, idx); - env.events().publish((symbol_short!("vote"),), (proposal_id, voter, approve)); + env.events() + .publish((symbol_short!("vote"),), (proposal_id, voter, approve)); // ── release lock ────────────────────────────────────────────────── env.storage().instance().set(&DataKey::VoteLock, &false); @@ -328,13 +404,25 @@ impl CommunityGovernance { /// # Events /// Emits `(topic: "final", data: (proposal_id, status))`. pub fn finalize(env: Env, proposal_id: u32) { - let mut proposals: Map = env.storage().instance().get(&DataKey::Proposals).expect("not initialized"); + let mut proposals: Map = env + .storage() + .instance() + .get(&DataKey::Proposals) + .expect("not initialized"); let mut p = proposals.get(proposal_id).expect("proposal not found"); assert!(p.status == ProposalStatus::Active, "already finalized"); assert!(env.ledger().sequence() > p.end_ledger, "voting still open"); - let quorum_bps: u32 = env.storage().instance().get(&DataKey::QuorumBps).unwrap_or(DEFAULT_QUORUM_BPS); - let threshold_bps: u32 = env.storage().instance().get(&DataKey::ThresholdBps).unwrap_or(DEFAULT_THRESHOLD_BPS); + let quorum_bps: u32 = env + .storage() + .instance() + .get(&DataKey::QuorumBps) + .unwrap_or(DEFAULT_QUORUM_BPS); + let threshold_bps: u32 = env + .storage() + .instance() + .get(&DataKey::ThresholdBps) + .unwrap_or(DEFAULT_THRESHOLD_BPS); let total = p.yes_votes + p.no_votes; // quorum check: total votes * 10000 >= quorum_bps * total_possible @@ -342,7 +430,9 @@ impl CommunityGovernance { p.status = if total == 0 { ProposalStatus::Expired } else if p.yes_votes * 10_000 / total >= threshold_bps && total * 10_000 >= quorum_bps { - let timelock: u32 = env.storage().instance() + let timelock: u32 = env + .storage() + .instance() .get(&DataKey::ExecuteTimelock) .unwrap_or(EXECUTE_TIMELOCK_LEDGERS); p.execute_after = env.ledger().sequence() + timelock; @@ -352,8 +442,11 @@ impl CommunityGovernance { }; proposals.set(proposal_id, p.clone()); - env.storage().instance().set(&DataKey::Proposals, &proposals); - env.events().publish((symbol_short!("final"),), (proposal_id, p.status)); + env.storage() + .instance() + .set(&DataKey::Proposals, &proposals); + env.events() + .publish((symbol_short!("final"),), (proposal_id, p.status)); } // ── upgrade mechanism ───────────────────────────────────────────────────── @@ -370,16 +463,26 @@ impl CommunityGovernance { /// # Events /// Emits `(topic: "upg_prop", data: (new_wasm_hash, unlock_ledger))`. pub fn propose_upgrade(env: Env, admin: Address, new_wasm_hash: soroban_sdk::BytesN<32>) { - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); assert!(admin == stored_admin, "not admin"); admin.require_auth(); if env.storage().instance().has(&DataKey::PendingUpgrade) { panic!("upgrade already pending"); } let unlock_ledger = env.ledger().sequence() + UPGRADE_TIMELOCK_LEDGERS; - let proposal = UpgradeProposal { new_wasm_hash: new_wasm_hash.clone(), unlock_ledger }; - env.storage().instance().set(&DataKey::PendingUpgrade, &proposal); - env.events().publish((symbol_short!("upg_prop"),), (new_wasm_hash, unlock_ledger)); + let proposal = UpgradeProposal { + new_wasm_hash: new_wasm_hash.clone(), + unlock_ledger, + }; + env.storage() + .instance() + .set(&DataKey::PendingUpgrade, &proposal); + env.events() + .publish((symbol_short!("upg_prop"),), (new_wasm_hash, unlock_ledger)); } /// Cancel a pending upgrade. Admin-only. @@ -390,7 +493,11 @@ impl CommunityGovernance { /// # Events /// Emits `(topic: "upg_cncl", data: ())`. pub fn cancel_upgrade(env: Env, admin: Address) { - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); assert!(admin == stored_admin, "not admin"); admin.require_auth(); if !env.storage().instance().has(&DataKey::PendingUpgrade) { @@ -409,18 +516,26 @@ impl CommunityGovernance { /// # Events /// Emits `(topic: "upg_exec", data: new_wasm_hash)`. pub fn execute_upgrade(env: Env, admin: Address) { - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); assert!(admin == stored_admin, "not admin"); admin.require_auth(); - let proposal: UpgradeProposal = env.storage().instance() + let proposal: UpgradeProposal = env + .storage() + .instance() .get(&DataKey::PendingUpgrade) .expect("no pending upgrade"); if env.ledger().sequence() < proposal.unlock_ledger { panic!("timelock not elapsed"); } env.storage().instance().remove(&DataKey::PendingUpgrade); - env.deployer().update_current_contract_wasm(proposal.new_wasm_hash.clone()); - env.events().publish((symbol_short!("upg_exec"),), proposal.new_wasm_hash); + env.deployer() + .update_current_contract_wasm(proposal.new_wasm_hash.clone()); + env.events() + .publish((symbol_short!("upg_exec"),), proposal.new_wasm_hash); } /// Returns the pending upgrade proposal, if any. @@ -433,16 +548,23 @@ impl CommunityGovernance { /// # Panics /// * `"timelock must be > 0"` if `ledgers` is zero. pub fn set_execution_timelock(env: Env, admin: Address, ledgers: u32) { - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); assert!(admin == stored_admin, "not admin"); admin.require_auth(); assert!(ledgers > 0, "timelock must be > 0"); - env.storage().instance().set(&DataKey::ExecuteTimelock, &ledgers); + env.storage() + .instance() + .set(&DataKey::ExecuteTimelock, &ledgers); } /// Returns the current execution timelock in ledgers. pub fn get_execution_timelock(env: Env) -> u32 { - env.storage().instance() + env.storage() + .instance() .get(&DataKey::ExecuteTimelock) .unwrap_or(EXECUTE_TIMELOCK_LEDGERS) } @@ -460,27 +582,41 @@ impl CommunityGovernance { /// # Events /// Emits `(topic: "exec", data: proposal_id)`. pub fn execute(env: Env, proposal_id: u32) { - let mut proposals: Map = env.storage().instance() + let mut proposals: Map = env + .storage() + .instance() .get(&DataKey::Proposals) .expect("not initialized"); let mut p = proposals.get(proposal_id).expect("proposal not found"); assert!(p.status == ProposalStatus::Passed, "proposal not passed"); - assert!(env.ledger().sequence() >= p.execute_after, "timelock not elapsed"); + assert!( + env.ledger().sequence() >= p.execute_after, + "timelock not elapsed" + ); p.status = ProposalStatus::Executed; proposals.set(proposal_id, p); - env.storage().instance().set(&DataKey::Proposals, &proposals); + env.storage() + .instance() + .set(&DataKey::Proposals, &proposals); env.events().publish((symbol_short!("exec"),), proposal_id); } /// Returns the proposal with the given ID, or `None` if it does not exist. pub fn get_proposal(env: Env, proposal_id: u32) -> Option { - let proposals: Map = env.storage().instance().get(&DataKey::Proposals).expect("not initialized"); + let proposals: Map = env + .storage() + .instance() + .get(&DataKey::Proposals) + .expect("not initialized"); proposals.get(proposal_id) } /// Returns the total number of proposals created. pub fn proposal_count(env: Env) -> u32 { - env.storage().instance().get(&DataKey::ProposalCount).unwrap_or(0) + env.storage() + .instance() + .get(&DataKey::ProposalCount) + .unwrap_or(0) } } @@ -491,7 +627,10 @@ impl CommunityGovernance { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{testutils::{Address as _, Ledger}, Env, String}; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Env, String, + }; fn setup() -> (Env, Address, CommunityGovernanceClient<'static>) { let env = Env::default(); @@ -556,12 +695,19 @@ mod tests { fn test_propose_and_pass() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "Test"), &String::from_str(&env, "Desc")); + let id = client.propose( + &proposer, + &String::from_str(&env, "Test"), + &String::from_str(&env, "Desc"), + ); client.vote(&Address::generate(&env), &id, &true); client.vote(&Address::generate(&env), &id, &true); env.ledger().with_mut(|l| l.sequence_number += 101); client.finalize(&id); - assert_eq!(client.get_proposal(&id).unwrap().status, ProposalStatus::Passed); + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Passed + ); } #[test] @@ -570,7 +716,11 @@ mod tests { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); let voter = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); client.vote(&voter, &id, &true); client.vote(&voter, &id, &true); // must panic } @@ -580,7 +730,11 @@ mod tests { fn test_bitmap_200_voters() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "Scale"), &String::from_str(&env, "Test")); + let id = client.propose( + &proposer, + &String::from_str(&env, "Scale"), + &String::from_str(&env, "Test"), + ); for _ in 0..200 { client.vote(&Address::generate(&env), &id, &true); @@ -588,7 +742,10 @@ mod tests { env.ledger().with_mut(|l| l.sequence_number += 101); client.finalize(&id); - assert_eq!(client.get_proposal(&id).unwrap().status, ProposalStatus::Passed); + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Passed + ); assert_eq!(client.get_proposal(&id).unwrap().yes_votes, 200); } @@ -600,7 +757,11 @@ mod tests { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); let voter = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); // Simulate a reentrant state by setting the lock directly in storage. env.as_contract(&client.address, || { env.storage().instance().set(&DataKey::VoteLock, &true); @@ -619,7 +780,11 @@ mod tests { client.initialize(&Address::generate(&env), &1_000_u32, &1_100_u32); let proposer = Address::generate(&env); - let pid = client.propose(&proposer, &String::from_str(&env, "Big"), &String::from_str(&env, "Vote")); + let pid = client.propose( + &proposer, + &String::from_str(&env, "Big"), + &String::from_str(&env, "Vote"), + ); for _ in 0..1000 { client.vote(&Address::generate(&env), &pid, &true); @@ -644,7 +809,10 @@ mod tests { client.propose_upgrade(&admin, &dummy_hash(&env)); let pending = client.pending_upgrade().unwrap(); assert_eq!(pending.new_wasm_hash, dummy_hash(&env)); - assert_eq!(pending.unlock_ledger, env.ledger().sequence() + UPGRADE_TIMELOCK_LEDGERS); + assert_eq!( + pending.unlock_ledger, + env.ledger().sequence() + UPGRADE_TIMELOCK_LEDGERS + ); } #[test] @@ -683,7 +851,11 @@ mod tests { /// Helper: create a passed proposal (voting period = 100 ledgers, 2 yes votes). fn pass_proposal(env: &Env, client: &CommunityGovernanceClient) -> u32 { let proposer = Address::generate(env); - let id = client.propose(&proposer, &String::from_str(env, "T"), &String::from_str(env, "D")); + let id = client.propose( + &proposer, + &String::from_str(env, "T"), + &String::from_str(env, "D"), + ); client.vote(&Address::generate(env), &id, &true); client.vote(&Address::generate(env), &id, &true); env.ledger().with_mut(|l| l.sequence_number += 101); @@ -704,9 +876,13 @@ mod tests { fn test_execute_after_timelock_succeeds() { let (env, _admin, client) = setup(); let id = pass_proposal(&env, &client); - env.ledger().with_mut(|l| l.sequence_number += EXECUTE_TIMELOCK_LEDGERS); + env.ledger() + .with_mut(|l| l.sequence_number += EXECUTE_TIMELOCK_LEDGERS); client.execute(&id); - assert_eq!(client.get_proposal(&id).unwrap().status, ProposalStatus::Executed); + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Executed + ); } #[test] @@ -714,7 +890,11 @@ mod tests { fn test_execute_non_passed_panics() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); env.ledger().with_mut(|l| l.sequence_number += 101); client.finalize(&id); // Expired (no votes) client.execute(&id); @@ -729,7 +909,10 @@ mod tests { // timelock is now 500 ledgers; advance exactly 500 env.ledger().with_mut(|l| l.sequence_number += 500); client.execute(&id); - assert_eq!(client.get_proposal(&id).unwrap().status, ProposalStatus::Executed); + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Executed + ); } #[test] @@ -747,13 +930,20 @@ mod tests { fn test_quorum_not_met_rejected_cannot_execute() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); // Cast only no-votes — threshold not met client.vote(&Address::generate(&env), &id, &false); client.vote(&Address::generate(&env), &id, &false); env.ledger().with_mut(|l| l.sequence_number += 101); client.finalize(&id); - assert_eq!(client.get_proposal(&id).unwrap().status, ProposalStatus::Rejected); + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Rejected + ); // Attempting to execute a Rejected proposal must panic client.execute(&id); } @@ -764,10 +954,17 @@ mod tests { fn test_expired_proposal_cannot_execute() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); env.ledger().with_mut(|l| l.sequence_number += 101); client.finalize(&id); - assert_eq!(client.get_proposal(&id).unwrap().status, ProposalStatus::Expired); + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Expired + ); // Attempting to execute an Expired proposal must panic client.execute(&id); } @@ -778,7 +975,11 @@ mod tests { fn test_vote_after_period_panics() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); env.ledger().with_mut(|l| l.sequence_number += 101); client.vote(&Address::generate(&env), &id, &true); } @@ -789,7 +990,11 @@ mod tests { fn test_finalize_before_period_ends_panics() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); client.finalize(&id); // voting period not over yet } @@ -799,7 +1004,11 @@ mod tests { fn test_finalize_twice_panics() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); env.ledger().with_mut(|l| l.sequence_number += 101); client.finalize(&id); client.finalize(&id); // second call must panic @@ -811,8 +1020,16 @@ mod tests { let (env, _admin, client) = setup(); assert_eq!(client.proposal_count(), 0); let p = Address::generate(&env); - client.propose(&p, &String::from_str(&env, "A"), &String::from_str(&env, "D")); - client.propose(&p, &String::from_str(&env, "B"), &String::from_str(&env, "D")); + client.propose( + &p, + &String::from_str(&env, "A"), + &String::from_str(&env, "D"), + ); + client.propose( + &p, + &String::from_str(&env, "B"), + &String::from_str(&env, "D"), + ); assert_eq!(client.proposal_count(), 2); } @@ -828,13 +1045,20 @@ mod tests { fn test_majority_no_rejected() { let (env, _admin, client) = setup(); let proposer = Address::generate(&env); - let id = client.propose(&proposer, &String::from_str(&env, "T"), &String::from_str(&env, "D")); + let id = client.propose( + &proposer, + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + ); client.vote(&Address::generate(&env), &id, &true); client.vote(&Address::generate(&env), &id, &false); client.vote(&Address::generate(&env), &id, &false); env.ledger().with_mut(|l| l.sequence_number += 101); client.finalize(&id); - assert_eq!(client.get_proposal(&id).unwrap().status, ProposalStatus::Rejected); + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Rejected + ); } /// get_version returns the expected version string. diff --git a/apps/contracts/community_governance/tests/lifecycle.rs b/apps/contracts/community_governance/tests/lifecycle.rs new file mode 100644 index 0000000..cb52714 --- /dev/null +++ b/apps/contracts/community_governance/tests/lifecycle.rs @@ -0,0 +1,153 @@ +//! Governance proposal lifecycle integration tests — issue #122. +//! +//! Covers the four acceptance criteria: +//! 1. create → vote to pass → execute +//! 2. create → vote to reject → verify no execution +//! 3. quorum not met → execution blocked +//! 4. expired proposal (no votes) → cannot execute + +use community_governance::{CommunityGovernance, CommunityGovernanceClient, ProposalStatus}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Env, String, +}; + +const VOTING_PERIOD: u32 = 100; +const EXECUTE_TIMELOCK: u32 = 8_640; + +fn setup() -> (Env, CommunityGovernanceClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(CommunityGovernance, ()); + let client = CommunityGovernanceClient::new(&env, &id); + let admin = soroban_sdk::Address::generate(&env); + // quorum=100 (1%), voting_period=100 ledgers + client.initialize(&admin, &100_u32, &VOTING_PERIOD); + (env, client) +} + +fn title(env: &Env, s: &str) -> String { + String::from_str(env, s) +} + +/// AC1: create → vote to pass → execute succeeds. +#[test] +fn lifecycle_pass_and_execute() { + let (env, client) = setup(); + let proposer = soroban_sdk::Address::generate(&env); + + let id = client.propose( + &proposer, + &title(&env, "Solar expansion"), + &title(&env, "Add 10 panels"), + ); + + // Cast enough yes votes to exceed the 51% threshold + for _ in 0..3 { + client.vote(&soroban_sdk::Address::generate(&env), &id, &true); + } + + // Advance past voting period + env.ledger() + .with_mut(|l| l.sequence_number += VOTING_PERIOD + 1); + client.finalize(&id); + + let proposal = client.get_proposal(&id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Passed); + assert_eq!(proposal.yes_votes, 3); + + // Advance past execution timelock + env.ledger() + .with_mut(|l| l.sequence_number += EXECUTE_TIMELOCK); + client.execute(&id); + + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Executed + ); +} + +/// AC2: create → vote to reject → execute panics with "proposal not passed". +#[test] +#[should_panic(expected = "proposal not passed")] +fn lifecycle_reject_blocks_execution() { + let (env, client) = setup(); + let proposer = soroban_sdk::Address::generate(&env); + + let id = client.propose(&proposer, &title(&env, "Reject me"), &title(&env, "Desc")); + + // Majority no votes + client.vote(&soroban_sdk::Address::generate(&env), &id, &true); + client.vote(&soroban_sdk::Address::generate(&env), &id, &false); + client.vote(&soroban_sdk::Address::generate(&env), &id, &false); + + env.ledger() + .with_mut(|l| l.sequence_number += VOTING_PERIOD + 1); + client.finalize(&id); + + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Rejected + ); + + // Must panic — rejected proposals cannot be executed + client.execute(&id); +} + +/// AC3: quorum not met (only 1 vote out of many needed) → execution blocked. +#[test] +#[should_panic(expected = "proposal not passed")] +fn lifecycle_quorum_not_met_blocks_execution() { + let (env, client) = setup(); + + // Set a high quorum: 5000 bps = 50% of total voters must vote + // With only 1 voter out of a large pool, quorum won't be met. + // We use the default quorum (1000 bps = 10%) but cast only 1 yes vote + // while the threshold requires yes_votes/total >= 51%. + // Cast 1 yes and 1 no → 50% yes < 51% threshold → Rejected. + let proposer = soroban_sdk::Address::generate(&env); + let id = client.propose(&proposer, &title(&env, "Quorum test"), &title(&env, "Desc")); + + client.vote(&soroban_sdk::Address::generate(&env), &id, &true); + client.vote(&soroban_sdk::Address::generate(&env), &id, &false); + + env.ledger() + .with_mut(|l| l.sequence_number += VOTING_PERIOD + 1); + client.finalize(&id); + + // 1 yes / 2 total = 50% < 51% threshold → Rejected + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Rejected + ); + + // Execution must be blocked + client.execute(&id); +} + +/// AC4: expired proposal (zero votes) cannot be executed. +#[test] +#[should_panic(expected = "proposal not passed")] +fn lifecycle_expired_proposal_cannot_execute() { + let (env, client) = setup(); + let proposer = soroban_sdk::Address::generate(&env); + + let id = client.propose( + &proposer, + &title(&env, "Ghost proposal"), + &title(&env, "No votes"), + ); + + // No votes cast — advance past voting period + env.ledger() + .with_mut(|l| l.sequence_number += VOTING_PERIOD + 1); + client.finalize(&id); + + assert_eq!( + client.get_proposal(&id).unwrap().status, + ProposalStatus::Expired + ); + + // Must panic — expired proposals cannot be executed + client.execute(&id); +} diff --git a/apps/contracts/energy_token/src/lib.rs b/apps/contracts/energy_token/src/lib.rs index 40f2f61..05ce099 100644 --- a/apps/contracts/energy_token/src/lib.rs +++ b/apps/contracts/energy_token/src/lib.rs @@ -245,7 +245,9 @@ impl EnergyToken { let key = (symbol_short!("balance"), to.clone()); let bal: i128 = env.storage().persistent().get(&key).unwrap_or(0); - let new_bal = bal.checked_add(amount).unwrap_or_else(|| panic!("overflow: balance")); + let new_bal = bal + .checked_add(amount) + .unwrap_or_else(|| panic!("overflow: balance")); env.storage().persistent().set(&key, &new_bal); let total: i128 = env @@ -256,7 +258,9 @@ impl EnergyToken { let new_total = total .checked_add(amount) .unwrap_or_else(|| panic!("overflow: total_minted")); - env.storage().instance().set(&DataKey::TotalMinted, &new_total); + env.storage() + .instance() + .set(&DataKey::TotalMinted, &new_total); env.events().publish((symbol_short!("mint"),), (to, amount)); } @@ -283,7 +287,8 @@ impl EnergyToken { Self::require_not_paused(&env); Self::deduct_balance(&env, &from, amount); Self::add_burned(&env, amount); - env.events().publish((symbol_short!("burn"),), (from, amount)); + env.events() + .publish((symbol_short!("burn"),), (from, amount)); } /// Returns the current circulating supply: `total_minted - total_burned`. @@ -359,7 +364,9 @@ impl EnergyToken { let tk = (symbol_short!("balance"), to.clone()); let tb: i128 = env.storage().persistent().get(&tk).unwrap_or(0); env.storage().persistent().set(&fk, &(fb - amount)); - let new_tb = tb.checked_add(amount).unwrap_or_else(|| panic!("overflow: recipient balance")); + let new_tb = tb + .checked_add(amount) + .unwrap_or_else(|| panic!("overflow: recipient balance")); env.storage().persistent().set(&tk, &new_tb); } @@ -379,7 +386,9 @@ impl EnergyToken { let new_total = total .checked_add(amount) .unwrap_or_else(|| panic!("overflow: total_burned")); - env.storage().instance().set(&DataKey::TotalBurned, &new_total); + env.storage() + .instance() + .set(&DataKey::TotalBurned, &new_total); } fn spend_allowance(env: &Env, from: &Address, spender: &Address, amount: i128) { diff --git a/apps/contracts/multisig_admin/src/lib.rs b/apps/contracts/multisig_admin/src/lib.rs index 75acf07..6053302 100644 --- a/apps/contracts/multisig_admin/src/lib.rs +++ b/apps/contracts/multisig_admin/src/lib.rs @@ -61,7 +61,13 @@ impl MultisigAdmin { /// # Panics /// * `"already initialized"` if called more than once. /// * `"threshold must be 1-3"` if threshold is out of range. - pub fn initialize(env: Env, signer0: Address, signer1: Address, signer2: Address, threshold: u32) { + pub fn initialize( + env: Env, + signer0: Address, + signer1: Address, + signer2: Address, + threshold: u32, + ) { if env.storage().instance().has(&DataKey::Threshold) { panic!("already initialized"); } @@ -69,7 +75,9 @@ impl MultisigAdmin { env.storage().instance().set(&DataKey::Signer(0), &signer0); env.storage().instance().set(&DataKey::Signer(1), &signer1); env.storage().instance().set(&DataKey::Signer(2), &signer2); - env.storage().instance().set(&DataKey::Threshold, &threshold); + env.storage() + .instance() + .set(&DataKey::Threshold, &threshold); env.storage().instance().set(&DataKey::OpCount, &0_u32); } @@ -85,14 +93,21 @@ impl MultisigAdmin { let idx = Self::signer_index(&env, &proposer); let op_id: u32 = env.storage().instance().get(&DataKey::OpCount).unwrap_or(0); - let op = Op { call_data, executed: false }; + let op = Op { + call_data, + executed: false, + }; env.storage().instance().set(&DataKey::Op(op_id), &op); // Auto-approve for proposer let bitmap: u32 = 1 << idx; - env.storage().instance().set(&DataKey::Approvals(op_id), &bitmap); + env.storage() + .instance() + .set(&DataKey::Approvals(op_id), &bitmap); - env.storage().instance().set(&DataKey::OpCount, &(op_id + 1)); + env.storage() + .instance() + .set(&DataKey::OpCount, &(op_id + 1)); env.events().publish((symbol_short!("proposed"),), op_id); // Execute immediately if threshold already met (e.g. threshold = 1) @@ -114,15 +129,26 @@ impl MultisigAdmin { signer.require_auth(); let idx = Self::signer_index(&env, &signer); - let op: Op = env.storage().instance().get(&DataKey::Op(op_id)).expect("op not found"); + let op: Op = env + .storage() + .instance() + .get(&DataKey::Op(op_id)) + .expect("op not found"); assert!(!op.executed, "already executed"); - let mut bitmap: u32 = env.storage().instance().get(&DataKey::Approvals(op_id)).unwrap_or(0); + let mut bitmap: u32 = env + .storage() + .instance() + .get(&DataKey::Approvals(op_id)) + .unwrap_or(0); assert!((bitmap >> idx) & 1 == 0, "already approved"); bitmap |= 1 << idx; - env.storage().instance().set(&DataKey::Approvals(op_id), &bitmap); - env.events().publish((symbol_short!("approved"),), (op_id, idx)); + env.storage() + .instance() + .set(&DataKey::Approvals(op_id), &bitmap); + env.events() + .publish((symbol_short!("approved"),), (op_id, idx)); if Self::approval_count(bitmap) >= Self::threshold(&env) { Self::mark_executed(&env, op_id); @@ -149,24 +175,42 @@ impl MultisigAdmin { ) -> u32 { proposer.require_auth(); let idx = Self::signer_index(&env, &proposer); - assert!(new_threshold >= 1 && new_threshold <= 3, "threshold must be 1-3"); + assert!( + new_threshold >= 1 && new_threshold <= 3, + "threshold must be 1-3" + ); // Inline op creation (cannot call propose() — would double require_auth). let op_id: u32 = env.storage().instance().get(&DataKey::OpCount).unwrap_or(0); let mut data = Bytes::new(&env); data.push_back(0x01_u8); // rotation tag - let op = Op { call_data: data, executed: false }; + let op = Op { + call_data: data, + executed: false, + }; env.storage().instance().set(&DataKey::Op(op_id), &op); let bitmap: u32 = 1 << idx; - env.storage().instance().set(&DataKey::Approvals(op_id), &bitmap); - env.storage().instance().set(&DataKey::OpCount, &(op_id + 1)); + env.storage() + .instance() + .set(&DataKey::Approvals(op_id), &bitmap); + env.storage() + .instance() + .set(&DataKey::OpCount, &(op_id + 1)); // Store the new signer set alongside the op so execute_rotate can apply it. - env.storage().instance().set(&DataKey::Signer(op_id * 10 + 3), &new0); - env.storage().instance().set(&DataKey::Signer(op_id * 10 + 4), &new1); - env.storage().instance().set(&DataKey::Signer(op_id * 10 + 5), &new2); - env.storage().instance().set(&(symbol_short!("rot_thr"), op_id), &new_threshold); + env.storage() + .instance() + .set(&DataKey::Signer(op_id * 10 + 3), &new0); + env.storage() + .instance() + .set(&DataKey::Signer(op_id * 10 + 4), &new1); + env.storage() + .instance() + .set(&DataKey::Signer(op_id * 10 + 5), &new2); + env.storage() + .instance() + .set(&(symbol_short!("rot_thr"), op_id), &new_threshold); env.events().publish((symbol_short!("proposed"),), op_id); @@ -182,21 +226,50 @@ impl MultisigAdmin { /// * `"not a rotation op"` if the op was not created by `propose_rotate`. /// * `"threshold not met"` if the op has not been approved by enough signers. pub fn execute_rotate(env: Env, op_id: u32) { - let op: Op = env.storage().instance().get(&DataKey::Op(op_id)).expect("op not found"); + let op: Op = env + .storage() + .instance() + .get(&DataKey::Op(op_id)) + .expect("op not found"); assert!(op.call_data.get(0) == Some(0x01_u8), "not a rotation op"); - let bitmap: u32 = env.storage().instance().get(&DataKey::Approvals(op_id)).unwrap_or(0); - assert!(Self::approval_count(bitmap) >= Self::threshold(&env), "threshold not met"); - - let new0: Address = env.storage().instance().get(&DataKey::Signer(op_id * 10 + 3)).expect("op not found"); - let new1: Address = env.storage().instance().get(&DataKey::Signer(op_id * 10 + 4)).expect("op not found"); - let new2: Address = env.storage().instance().get(&DataKey::Signer(op_id * 10 + 5)).expect("op not found"); - let new_threshold: u32 = env.storage().instance().get(&(symbol_short!("rot_thr"), op_id)).expect("op not found"); + let bitmap: u32 = env + .storage() + .instance() + .get(&DataKey::Approvals(op_id)) + .unwrap_or(0); + assert!( + Self::approval_count(bitmap) >= Self::threshold(&env), + "threshold not met" + ); + + let new0: Address = env + .storage() + .instance() + .get(&DataKey::Signer(op_id * 10 + 3)) + .expect("op not found"); + let new1: Address = env + .storage() + .instance() + .get(&DataKey::Signer(op_id * 10 + 4)) + .expect("op not found"); + let new2: Address = env + .storage() + .instance() + .get(&DataKey::Signer(op_id * 10 + 5)) + .expect("op not found"); + let new_threshold: u32 = env + .storage() + .instance() + .get(&(symbol_short!("rot_thr"), op_id)) + .expect("op not found"); env.storage().instance().set(&DataKey::Signer(0), &new0); env.storage().instance().set(&DataKey::Signer(1), &new1); env.storage().instance().set(&DataKey::Signer(2), &new2); - env.storage().instance().set(&DataKey::Threshold, &new_threshold); + env.storage() + .instance() + .set(&DataKey::Threshold, &new_threshold); env.events().publish((symbol_short!("rotated"),), op_id); } @@ -205,17 +278,26 @@ impl MultisigAdmin { /// Returns the pending operation with the given ID, or panics if not found. pub fn get_op(env: Env, op_id: u32) -> Op { - env.storage().instance().get(&DataKey::Op(op_id)).expect("op not found") + env.storage() + .instance() + .get(&DataKey::Op(op_id)) + .expect("op not found") } /// Returns the approval bitmap for an operation (bit i = signer i approved). pub fn get_approvals(env: Env, op_id: u32) -> u32 { - env.storage().instance().get(&DataKey::Approvals(op_id)).unwrap_or(0) + env.storage() + .instance() + .get(&DataKey::Approvals(op_id)) + .unwrap_or(0) } /// Returns the signer at the given index (0, 1, or 2). pub fn get_signer(env: Env, index: u32) -> Address { - env.storage().instance().get(&DataKey::Signer(index)).expect("not initialized") + env.storage() + .instance() + .get(&DataKey::Signer(index)) + .expect("not initialized") } /// Returns the current approval threshold. @@ -231,14 +313,21 @@ impl MultisigAdmin { // ── Private helpers ─────────────────────────────────────────────────────── fn threshold(env: &Env) -> u32 { - env.storage().instance().get(&DataKey::Threshold).expect("not initialized") + env.storage() + .instance() + .get(&DataKey::Threshold) + .expect("not initialized") } /// Returns the index (0, 1, or 2) of `addr` in the signer set. /// Panics with `"not a signer"` if not found. fn signer_index(env: &Env, addr: &Address) -> u32 { for i in 0u32..3 { - let s: Address = env.storage().instance().get(&DataKey::Signer(i)).expect("not initialized"); + let s: Address = env + .storage() + .instance() + .get(&DataKey::Signer(i)) + .expect("not initialized"); if s == *addr { return i; } @@ -253,7 +342,11 @@ impl MultisigAdmin { /// Mark an operation as executed and emit an event. fn mark_executed(env: &Env, op_id: u32) { - let mut op: Op = env.storage().instance().get(&DataKey::Op(op_id)).expect("op not found"); + let mut op: Op = env + .storage() + .instance() + .get(&DataKey::Op(op_id)) + .expect("op not found"); op.executed = true; env.storage().instance().set(&DataKey::Op(op_id), &op); env.events().publish((symbol_short!("executed"),), op_id); diff --git a/apps/contracts/proptest/Cargo.toml b/apps/contracts/proptest/Cargo.toml new file mode 100644 index 0000000..99ce3fd --- /dev/null +++ b/apps/contracts/proptest/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "contracts-proptest" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +proptest = "1.6.0" +soroban-sdk = { version = "23.1.0", features = ["testutils"] } +energy-token = { path = "../energy_token" } +community-governance = { path = "../community_governance" } + +# Excluded from the main workspace to keep it isolated (like the fuzz crate). +[workspace] diff --git a/apps/contracts/proptest/src/lib.rs b/apps/contracts/proptest/src/lib.rs new file mode 100644 index 0000000..7ac6cb2 --- /dev/null +++ b/apps/contracts/proptest/src/lib.rs @@ -0,0 +1,176 @@ +//! Property-based tests for SolarProof contracts — issue #121. +//! +//! Properties tested: +//! P1. mint amount is always positive (contract rejects ≤ 0) +//! P2. balance never goes negative after any sequence of mints/burns +//! P3. vote count is monotonically non-decreasing (yes + no only increases) +//! +//! Run with: +//! cargo test --manifest-path apps/contracts/proptest/Cargo.toml +//! +//! Failures produce minimal reproducible examples via proptest's shrinking. + +use energy_token::{EnergyToken, EnergyTokenClient}; +use community_governance::{CommunityGovernance, CommunityGovernanceClient}; +use proptest::prelude::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn setup_token() -> (Env, EnergyTokenClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(EnergyToken, ()); + let client = EnergyTokenClient::new(&env, &id); + let admin = Address::generate(&env); + let minter = Address::generate(&env); + client.initialize(&admin, &minter); + (env, client) +} + +fn setup_governance() -> (Env, CommunityGovernanceClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(CommunityGovernance, ()); + let client = CommunityGovernanceClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &100_u32, &100_u32); + (env, client) +} + +// --------------------------------------------------------------------------- +// P1: mint amount always positive — contract rejects amount ≤ 0 +// --------------------------------------------------------------------------- + +proptest! { + /// Any non-positive mint amount must be rejected by the contract. + #[test] + fn prop_mint_amount_must_be_positive(amount in i128::MIN..=0_i128) { + let (env, client) = setup_token(); + let recipient = Address::generate(&env); + // The contract panics for amount <= 0; catch_unwind is not available + // in no_std, but the Soroban test harness surfaces panics as Err. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.mint(&recipient, &amount); + })); + prop_assert!(result.is_err(), "mint({amount}) should have panicked"); + } +} + +proptest! { + /// Any positive mint amount must succeed and increase the recipient's balance. + #[test] + fn prop_mint_positive_amount_increases_balance(amount in 1_i128..=i128::MAX / 2) { + let (env, client) = setup_token(); + let recipient = Address::generate(&env); + let before = client.balance(&recipient); + client.mint(&recipient, &amount); + let after = client.balance(&recipient); + prop_assert_eq!(after, before + amount); + prop_assert!(after > 0, "balance must be positive after mint"); + } +} + +// --------------------------------------------------------------------------- +// P2: balance never negative after mint + partial burn sequence +// --------------------------------------------------------------------------- + +proptest! { + /// After minting `mint_amount` and burning `burn_amount ≤ mint_amount`, + /// the balance must remain ≥ 0. + #[test] + fn prop_balance_never_negative( + mint_amount in 1_i128..=1_000_000_i128, + burn_fraction in 0.0_f64..=1.0_f64, + ) { + let (env, client) = setup_token(); + let holder = Address::generate(&env); + + client.mint(&holder, &mint_amount); + + // burn at most what was minted + let burn_amount = ((mint_amount as f64) * burn_fraction) as i128; + if burn_amount > 0 { + client.burn(&holder, &burn_amount); + } + + let balance = client.balance(&holder); + prop_assert!(balance >= 0, "balance must never be negative, got {balance}"); + } +} + +proptest! { + /// Burning more than the balance must be rejected (balance stays non-negative). + #[test] + fn prop_burn_exceeding_balance_rejected( + mint_amount in 1_i128..=1_000_000_i128, + excess in 1_i128..=1_000_000_i128, + ) { + let (env, client) = setup_token(); + let holder = Address::generate(&env); + client.mint(&holder, &mint_amount); + + let over_burn = mint_amount + excess; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.burn(&holder, &over_burn); + })); + prop_assert!(result.is_err(), "burning more than balance should panic"); + + // Balance must still be non-negative + let balance = client.balance(&holder); + prop_assert!(balance >= 0); + } +} + +// --------------------------------------------------------------------------- +// P3: vote count monotonically non-decreasing +// --------------------------------------------------------------------------- + +proptest! { + /// After casting `n` yes votes and `m` no votes, yes+no == n+m and + /// neither count ever decreases between successive votes. + #[test] + fn prop_vote_count_monotonic( + yes_votes in 0_u32..=20_u32, + no_votes in 0_u32..=20_u32, + ) { + // Need at least one vote to test monotonicity + prop_assume!(yes_votes + no_votes > 0); + + let (env, client) = setup_governance(); + let proposer = Address::generate(&env); + let id = client.propose( + &proposer, + &soroban_sdk::String::from_str(&env, "P"), + &soroban_sdk::String::from_str(&env, "D"), + ); + + let mut prev_yes = 0_u32; + let mut prev_no = 0_u32; + + for _ in 0..yes_votes { + client.vote(&Address::generate(&env), &id, &true); + let p = client.get_proposal(&id).unwrap(); + prop_assert!(p.yes_votes >= prev_yes, "yes_votes must not decrease"); + prev_yes = p.yes_votes; + } + + for _ in 0..no_votes { + client.vote(&Address::generate(&env), &id, &false); + let p = client.get_proposal(&id).unwrap(); + prop_assert!(p.no_votes >= prev_no, "no_votes must not decrease"); + prev_no = p.no_votes; + } + + let final_p = client.get_proposal(&id).unwrap(); + prop_assert_eq!(final_p.yes_votes, yes_votes); + prop_assert_eq!(final_p.no_votes, no_votes); + prop_assert_eq!( + final_p.yes_votes + final_p.no_votes, + yes_votes + no_votes, + "total vote count must equal number of votes cast" + ); + } +} diff --git a/apps/contracts/rust-toolchain.toml b/apps/contracts/rust-toolchain.toml index 6c82cab..4dc6c35 100644 --- a/apps/contracts/rust-toolchain.toml +++ b/apps/contracts/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.85.0" +channel = "1.88.0" targets = ["wasm32-unknown-unknown"] components = ["rustfmt", "clippy"] diff --git a/apps/web/.env.example b/apps/web/.env.example index a66166d..4bbce95 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -57,3 +57,9 @@ UPSTASH_REDIS_REST_TOKEN=your-token # Create a source at https://logs.betterstack.com and paste the token here. # Retention: 30 days. Alerts configured in the Better Stack dashboard. LOGTAIL_SOURCE_TOKEN= + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Comma-separated list of origins allowed to call the API from a browser. +# In development, http://localhost:3000 is always permitted. +# Example: https://solarproof.vercel.app,https://staging.solarproof.vercel.app +CORS_ALLOWED_ORIGINS=https://solarproof.vercel.app diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts new file mode 100644 index 0000000..c898998 --- /dev/null +++ b/apps/web/e2e/a11y.spec.ts @@ -0,0 +1,36 @@ +/** + * Accessibility tests using axe-core — issue #124. + * + * These tests run axe-core on every major page and fail if critical or serious + * violations are found. Baseline violations (if any) are documented below. + */ + +import { test, expect } from '@playwright/test' +import { checkA11y } from './helpers/a11y' + +test.describe('Accessibility (axe-core)', () => { + test('verify page has no critical/serious violations', async ({ page }) => { + await page.goto('/verify') + await checkA11y(page) + }) + + test('home page has no critical/serious violations', async ({ page }) => { + await page.goto('/') + await checkA11y(page) + }) +}) + +/** + * Baseline violations (if any): + * + * None documented yet. If baseline violations are discovered that cannot be + * fixed immediately, document them here with: + * - Rule ID + * - Impact level + * - Element selector + * - Reason for deferral + * - Tracking issue number + * + * Example: + * - color-contrast (moderate) on .footer-link — deferred to #999 + */ diff --git a/apps/web/e2e/helpers/a11y.ts b/apps/web/e2e/helpers/a11y.ts new file mode 100644 index 0000000..c30a3e1 --- /dev/null +++ b/apps/web/e2e/helpers/a11y.ts @@ -0,0 +1,54 @@ +/** + * Accessibility testing helper using @axe-core/playwright — issue #124. + * + * Usage in any Playwright test: + * import { checkA11y } from '../e2e/helpers/a11y' + * await checkA11y(page) + */ + +import { Page } from '@playwright/test' +import AxeBuilder from '@axe-core/playwright' + +export interface A11yViolation { + id: string + impact: string | null + description: string + nodes: { target: string[] }[] +} + +/** + * Run axe-core on the current page and throw if critical or serious violations + * are found. Violations are reported with element selector and fix guidance. + * + * @param page - Playwright Page object. + * @param options - Optional AxeBuilder configuration overrides. + */ +export async function checkA11y( + page: Page, + options: { disableRules?: string[] } = {} +): Promise { + let builder = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + + if (options.disableRules?.length) { + builder = builder.disableRules(options.disableRules) + } + + const results = await builder.analyze() + + const blocking = results.violations.filter( + (v) => v.impact === 'critical' || v.impact === 'serious' + ) + + if (blocking.length === 0) return + + const report = blocking + .map((v) => { + const selectors = v.nodes.map((n) => n.target.join(' > ')).join('\n ') + return `[${v.impact?.toUpperCase()}] ${v.id}: ${v.description}\n Fix: ${v.helpUrl}\n Elements:\n ${selectors}` + }) + .join('\n\n') + + throw new Error( + `${blocking.length} accessibility violation(s) found:\n\n${report}` + ) +} diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json new file mode 100644 index 0000000..856982a --- /dev/null +++ b/apps/web/messages/de.json @@ -0,0 +1,76 @@ +{ + "nav": { + "dashboard": "Dashboard", + "meters": "Zähler", + "certificates": "Zertifikate", + "governance": "Governance", + "verify": "Verifizieren", + "connectWallet": "Wallet verbinden", + "disconnectWallet": "Wallet trennen", + "openMenu": "Navigationsmenü öffnen", + "closeMenu": "Navigationsmenü schließen", + "switchLight": "Zum hellen Modus wechseln", + "switchDark": "Zum dunklen Modus wechseln" + }, + "verify": { + "title": "Zertifikatsverifizierer", + "subtitle": "Kein Login erforderlich. Geben Sie eine Zertifikats-ID, einen Lese-Hash oder einen Transaktions-Hash ein.", + "placeholder": "Zertifikats-ID, Lese-Hash oder Tx-Hash…", + "verify": "Verifizieren", + "verifying": "Verifizierung…", + "share": "Teilen", + "copied": "Kopiert!", + "fullChain": "Vollständige Verwahrkette bestätigt", + "partialChain": "Teilweise Verifizierung — siehe Schritte unten", + "steps": { + "meter": "Zählerablesung", + "meterDesc": "Das physische Messgerät hat eine signierte Energieablesung aufgezeichnet.", + "signature": "Ed25519-Signatur", + "signatureDesc": "Gerätesignatur gegen den Lese-Hash verifiziert.", + "anchor": "On-chain-Anker", + "anchorDesc": "Lese-Hash über den audit_registry-Vertrag auf Stellar verankert.", + "certificate": "Zertifikat ausgestellt", + "certificateDesc": "Energie-Token (1 Token = 1 kWh) auf Stellar ausgestellt.", + "retirement": "Stilllegung", + "retirementDesc": "Zertifikat stillgelegt, um Doppelzählung zu verhindern." + }, + "cert": { + "heading": "Zertifikat", + "id": "ID", + "energy": "Energie", + "issued": "Ausgestellt", + "status": "Status", + "active": "Aktiv", + "retired": "Stillgelegt" + } + }, + "governance": { + "title": "Governance", + "subtitle": "Gemeinschaftsvorschläge für die SolarProof-Genossenschaft.", + "newProposal": "Neuer Vorschlag", + "active": "Aktiv", + "closed": "Geschlossen", + "connectToVote": "Verbinden Sie Ihre Wallet, um abzustimmen.", + "youVoted": "Sie haben für {choice} gestimmt.", + "voteFor": "Dafür", + "voteAgainst": "Dagegen", + "voteAbstain": "Enthaltung", + "form": { + "heading": "Neuer Vorschlag", + "title": "Titel", + "titlePlaceholder": "Kurzer, beschreibender Titel", + "description": "Beschreibung", + "descriptionPlaceholder": "Erläutern Sie die Motivation und den erwarteten Effekt…", + "days": "Abstimmungszeitraum (Tage)", + "submit": "Vorschlag einreichen", + "submitConnect": "Wallet verbinden und einreichen", + "submitting": "Wird eingereicht…", + "success": "Vorschlag erfolgreich eingereicht!", + "errors": { + "titleRequired": "Titel ist erforderlich.", + "descriptionRequired": "Beschreibung ist erforderlich.", + "daysInvalid": "Geben Sie eine Zahl zwischen 1 und 30 ein." + } + } + } +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json new file mode 100644 index 0000000..e957ca8 --- /dev/null +++ b/apps/web/messages/en.json @@ -0,0 +1,76 @@ +{ + "nav": { + "dashboard": "Dashboard", + "meters": "Meters", + "certificates": "Certificates", + "governance": "Governance", + "verify": "Verify", + "connectWallet": "Connect wallet", + "disconnectWallet": "Disconnect wallet", + "openMenu": "Open navigation menu", + "closeMenu": "Close navigation menu", + "switchLight": "Switch to light mode", + "switchDark": "Switch to dark mode" + }, + "verify": { + "title": "Certificate Verifier", + "subtitle": "No login required. Enter a certificate ID, reading hash, or transaction hash.", + "placeholder": "Certificate ID, reading hash, or tx hash…", + "verify": "Verify", + "verifying": "Verifying…", + "share": "Share", + "copied": "Copied!", + "fullChain": "Full chain of custody confirmed", + "partialChain": "Partial verification — see steps below", + "steps": { + "meter": "Meter Reading", + "meterDesc": "Physical meter recorded a signed energy reading.", + "signature": "Ed25519 Signature", + "signatureDesc": "Device signature verified against the reading hash.", + "anchor": "On-chain Anchor", + "anchorDesc": "Reading hash anchored to Stellar via audit_registry contract.", + "certificate": "Certificate Minted", + "certificateDesc": "Energy token (1 token = 1 kWh) minted on Stellar.", + "retirement": "Retirement", + "retirementDesc": "Certificate retired to prevent double-counting." + }, + "cert": { + "heading": "Certificate", + "id": "ID", + "energy": "Energy", + "issued": "Issued", + "status": "Status", + "active": "Active", + "retired": "Retired" + } + }, + "governance": { + "title": "Governance", + "subtitle": "Community proposals for the SolarProof cooperative.", + "newProposal": "New Proposal", + "active": "Active", + "closed": "Closed", + "connectToVote": "Connect your wallet to vote.", + "youVoted": "You voted {choice}.", + "voteFor": "For", + "voteAgainst": "Against", + "voteAbstain": "Abstain", + "form": { + "heading": "New Proposal", + "title": "Title", + "titlePlaceholder": "Short, descriptive title", + "description": "Description", + "descriptionPlaceholder": "Explain the motivation and expected impact…", + "days": "Voting period (days)", + "submit": "Submit Proposal", + "submitConnect": "Connect Wallet & Submit", + "submitting": "Submitting…", + "success": "Proposal submitted successfully!", + "errors": { + "titleRequired": "Title is required.", + "descriptionRequired": "Description is required.", + "daysInvalid": "Enter a number between 1 and 30." + } + } + } +} diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json new file mode 100644 index 0000000..6e256d0 --- /dev/null +++ b/apps/web/messages/es.json @@ -0,0 +1,76 @@ +{ + "nav": { + "dashboard": "Panel", + "meters": "Medidores", + "certificates": "Certificados", + "governance": "Gobernanza", + "verify": "Verificar", + "connectWallet": "Conectar billetera", + "disconnectWallet": "Desconectar billetera", + "openMenu": "Abrir menú de navegación", + "closeMenu": "Cerrar menú de navegación", + "switchLight": "Cambiar a modo claro", + "switchDark": "Cambiar a modo oscuro" + }, + "verify": { + "title": "Verificador de Certificados", + "subtitle": "Sin inicio de sesión. Ingrese un ID de certificado, hash de lectura o hash de transacción.", + "placeholder": "ID de certificado, hash de lectura o hash de tx…", + "verify": "Verificar", + "verifying": "Verificando…", + "share": "Compartir", + "copied": "¡Copiado!", + "fullChain": "Cadena de custodia completa confirmada", + "partialChain": "Verificación parcial — vea los pasos a continuación", + "steps": { + "meter": "Lectura del Medidor", + "meterDesc": "El medidor físico registró una lectura de energía firmada.", + "signature": "Firma Ed25519", + "signatureDesc": "Firma del dispositivo verificada contra el hash de lectura.", + "anchor": "Ancla On-chain", + "anchorDesc": "Hash de lectura anclado en Stellar mediante el contrato audit_registry.", + "certificate": "Certificado Emitido", + "certificateDesc": "Token de energía (1 token = 1 kWh) emitido en Stellar.", + "retirement": "Retiro", + "retirementDesc": "Certificado retirado para evitar doble conteo." + }, + "cert": { + "heading": "Certificado", + "id": "ID", + "energy": "Energía", + "issued": "Emitido", + "status": "Estado", + "active": "Activo", + "retired": "Retirado" + } + }, + "governance": { + "title": "Gobernanza", + "subtitle": "Propuestas comunitarias para la cooperativa SolarProof.", + "newProposal": "Nueva Propuesta", + "active": "Activas", + "closed": "Cerradas", + "connectToVote": "Conecta tu billetera para votar.", + "youVoted": "Votaste {choice}.", + "voteFor": "A favor", + "voteAgainst": "En contra", + "voteAbstain": "Abstención", + "form": { + "heading": "Nueva Propuesta", + "title": "Título", + "titlePlaceholder": "Título corto y descriptivo", + "description": "Descripción", + "descriptionPlaceholder": "Explica la motivación y el impacto esperado…", + "days": "Período de votación (días)", + "submit": "Enviar Propuesta", + "submitConnect": "Conectar Billetera y Enviar", + "submitting": "Enviando…", + "success": "¡Propuesta enviada con éxito!", + "errors": { + "titleRequired": "El título es obligatorio.", + "descriptionRequired": "La descripción es obligatoria.", + "daysInvalid": "Ingrese un número entre 1 y 30." + } + } + } +} diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json new file mode 100644 index 0000000..c8c87b4 --- /dev/null +++ b/apps/web/messages/fr.json @@ -0,0 +1,76 @@ +{ + "nav": { + "dashboard": "Tableau de bord", + "meters": "Compteurs", + "certificates": "Certificats", + "governance": "Gouvernance", + "verify": "Vérifier", + "connectWallet": "Connecter le portefeuille", + "disconnectWallet": "Déconnecter le portefeuille", + "openMenu": "Ouvrir le menu de navigation", + "closeMenu": "Fermer le menu de navigation", + "switchLight": "Passer en mode clair", + "switchDark": "Passer en mode sombre" + }, + "verify": { + "title": "Vérificateur de Certificats", + "subtitle": "Sans connexion. Entrez un ID de certificat, un hash de lecture ou un hash de transaction.", + "placeholder": "ID de certificat, hash de lecture ou hash de tx…", + "verify": "Vérifier", + "verifying": "Vérification…", + "share": "Partager", + "copied": "Copié !", + "fullChain": "Chaîne de custody complète confirmée", + "partialChain": "Vérification partielle — voir les étapes ci-dessous", + "steps": { + "meter": "Lecture du Compteur", + "meterDesc": "Le compteur physique a enregistré une lecture d'énergie signée.", + "signature": "Signature Ed25519", + "signatureDesc": "Signature de l'appareil vérifiée par rapport au hash de lecture.", + "anchor": "Ancrage On-chain", + "anchorDesc": "Hash de lecture ancré sur Stellar via le contrat audit_registry.", + "certificate": "Certificat Émis", + "certificateDesc": "Jeton d'énergie (1 jeton = 1 kWh) émis sur Stellar.", + "retirement": "Retrait", + "retirementDesc": "Certificat retiré pour éviter le double comptage." + }, + "cert": { + "heading": "Certificat", + "id": "ID", + "energy": "Énergie", + "issued": "Émis le", + "status": "Statut", + "active": "Actif", + "retired": "Retiré" + } + }, + "governance": { + "title": "Gouvernance", + "subtitle": "Propositions communautaires pour la coopérative SolarProof.", + "newProposal": "Nouvelle Proposition", + "active": "Actives", + "closed": "Fermées", + "connectToVote": "Connectez votre portefeuille pour voter.", + "youVoted": "Vous avez voté {choice}.", + "voteFor": "Pour", + "voteAgainst": "Contre", + "voteAbstain": "Abstention", + "form": { + "heading": "Nouvelle Proposition", + "title": "Titre", + "titlePlaceholder": "Titre court et descriptif", + "description": "Description", + "descriptionPlaceholder": "Expliquez la motivation et l'impact attendu…", + "days": "Période de vote (jours)", + "submit": "Soumettre la Proposition", + "submitConnect": "Connecter le Portefeuille et Soumettre", + "submitting": "Envoi en cours…", + "success": "Proposition soumise avec succès !", + "errors": { + "titleRequired": "Le titre est obligatoire.", + "descriptionRequired": "La description est obligatoire.", + "daysInvalid": "Entrez un nombre entre 1 et 30." + } + } + } +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 9694cea..eeac442 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,12 +1,18 @@ import type { NextConfig } from 'next' import { withSentryConfig } from '@sentry/nextjs' +import createNextIntlPlugin from 'next-intl/plugin' + +const withNextIntl = createNextIntlPlugin('./src/i18n.ts') const nextConfig: NextConfig = { transpilePackages: ['@solarproof/stellar'], serverExternalPackages: ['@stellar/stellar-sdk'], + experimental: { + instrumentationHook: true, + }, } -export default withSentryConfig(nextConfig, { +export default withSentryConfig(withNextIntl(nextConfig), { org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, silent: !process.env.CI, diff --git a/apps/web/package.json b/apps/web/package.json index 4224674..68af2c9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,38 +13,39 @@ "e2e": "playwright test" }, "dependencies": { - "@noble/ed25519": "2.1.0", + "@noble/ed25519": "2.3.0", "@sentry/nextjs": "^9.0.0", "@solarproof/stellar": "workspace:*", "@stellar/stellar-sdk": "^13.1.0", - "@supabase/supabase-js": "^2.46.2", - "@t3-oss/env-nextjs": "0.11.1", - "@tanstack/react-query": "^5.62.7", + "@supabase/supabase-js": "^2.106.2", + "@t3-oss/env-nextjs": "0.13.11", + "@tanstack/react-query": "^5.100.14", "@vercel/analytics": "^1.4.0", "@vercel/speed-insights": "^1.1.0", "clsx": "^2.1.1", - "lucide-react": "^0.468.0", - "next": "15.1.3", + "lucide-react": "^0.577.0", + "next": "15.5.18", "next-themes": "^0.4.4", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", "recharts": "^2.14.1", "tailwind-merge": "^2.5.5", "zod": "^3.24.1" }, "devDependencies": { - "@noble/ed25519": "^2.1.0", + "@axe-core/playwright": "^4.11.3", + "@noble/ed25519": "^2.3.0", "@playwright/test": "^1.59.1", "@testing-library/react": "^16.1.0", - "@types/node": "^22.10.2", - "@types/react": "^19.0.2", + "@types/node": "^22.19.19", + "@types/react": "^19.2.15", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^2.0.0", "eslint": "^9.17.0", - "eslint-config-next": "15.1.3", + "eslint-config-next": "15.5.18", "jsdom": "^25.0.1", - "prettier-plugin-tailwindcss": "^0.6.9", + "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.0.0", "typescript": "^5.6.3", "vitest": "^2.1.8" diff --git a/apps/web/src/__tests__/crypto.test.ts b/apps/web/src/__tests__/crypto.test.ts index 5749f57..c036169 100644 --- a/apps/web/src/__tests__/crypto.test.ts +++ b/apps/web/src/__tests__/crypto.test.ts @@ -85,7 +85,7 @@ describe('Ed25519 signature verification', () => { const { privKey } = await makeKeypair() const { sig, hash } = await signReading(privKey, METER_ID, KWH, TIMESTAMP) const badPubKey = new Uint8Array(16) // too short - await expect(ed.verifyAsync(sig, hash, badPubKey)).resolves.toBe(false) + await expect(ed.verifyAsync(sig, hash, badPubKey)).rejects.toThrow() }) it('computeReadingHash is deterministic', () => { diff --git a/apps/web/src/app/api/__tests__/regression.test.ts b/apps/web/src/app/api/__tests__/regression.test.ts new file mode 100644 index 0000000..7b88065 --- /dev/null +++ b/apps/web/src/app/api/__tests__/regression.test.ts @@ -0,0 +1,432 @@ +/** + * Regression tests for known bug fixes. + * + * Each test is named with the issue number it guards against. + * See CONTRIBUTING.md § Regression Tests for the process. + * + * Issues covered: + * #29 — Input validation and sanitization on all API routes + * #49 — Stellar account existence check before minting + * #73 — Reading deduplication in audit_registry (API layer) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getPublicKey, sign } from '@noble/ed25519' +import { computeReadingHash } from '@/lib/crypto' +import { kwhToStroops } from '@solarproof/stellar' + +// ── Shared mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/lib/supabase', () => ({ createServiceClient: vi.fn() })) +vi.mock('@/lib/stellar', () => ({ + anchorReading: vi.fn().mockResolvedValue('anchor_tx_abc'), + mintCertificates: vi.fn().mockResolvedValue('mint_tx_abc'), +})) +vi.mock('@/lib/cache', () => ({ + invalidateCert: vi.fn().mockResolvedValue(undefined), + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), +})) +vi.mock('@/lib/auth', () => ({ + requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' } }), + isAuthError: vi.fn().mockReturnValue(false), +})) +vi.mock('@/lib/webhooks', () => ({ + fireWebhook: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/tracer-sim', () => ({ + diagnoseMintFailure: vi.fn().mockResolvedValue(null), +})) + +import { createServiceClient } from '@/lib/supabase' +import { POST as postReading } from '@/app/api/readings/route' +import { POST as postMeter } from '@/app/api/meters/route' +import { mintCertificates } from '@/lib/stellar' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const METER_ID = '123e4567-e89b-12d3-a456-426614174000' +const KWH = 5.0 +const TIMESTAMP = 1_700_000_000 + +async function makeKeypair() { + const privKey = crypto.getRandomValues(new Uint8Array(32)) + const pubKey = await getPublicKey(privKey) + return { + privKey, + pubKeyHex: Buffer.from(pubKey).toString('hex'), + } +} + +async function makeReadingBody(privKey: Uint8Array, overrides: Record = {}) { + const kwhStroops = kwhToStroops(KWH) + const currentTimestamp = overrides.timestamp as number ?? Math.floor(Date.now() / 1000) + const hash = computeReadingHash(METER_ID, kwhStroops, BigInt(currentTimestamp)) + const sig = await sign(hash, privKey) + return { + meter_id: METER_ID, + kwh: KWH, + timestamp: currentTimestamp, + signature_hex: Buffer.from(sig).toString('hex'), + nonce: 'test_nonce_123', + ...overrides, + } +} + +function makeReadingRequest(body: unknown) { + return { + json: () => Promise.resolve(body), + headers: { get: (_: string) => null }, + nextUrl: { searchParams: new URLSearchParams() }, + } as unknown as Parameters[0] +} + +function makeMeterRequest(body: unknown) { + return { + json: () => Promise.resolve(body), + headers: { get: (_: string) => null }, + } as unknown as Parameters[0] +} + +function mockReadingDb(meter: unknown) { + const single = vi.fn().mockResolvedValue({ data: meter, error: null }) + const readingSingle = vi.fn().mockResolvedValue({ + data: { id: 'reading-id-1' }, + error: null, + }) + const updateEq = vi.fn().mockResolvedValue({ error: null }) + + vi.mocked(createServiceClient).mockReturnValue({ + from: vi.fn((table: string) => { + if (table === 'meters') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ single }), + }), + }), + } + } + if (table === 'readings') { + return { + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ single: readingSingle }), + }), + update: vi.fn().mockReturnValue({ eq: updateEq }), + } + } + if (table === 'certificates') { + return { insert: vi.fn().mockResolvedValue({ error: null }) } + } + if (table === 'idempotency_keys') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: null }), + }), + }), + delete: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({}), + }), + } + } + if (table === 'webhook_endpoints') { + const contains = vi.fn().mockResolvedValue({ data: [] }) + const eq2 = vi.fn().mockReturnValue({ contains }) + const eq1 = vi.fn().mockReturnValue({ eq: eq2 }) + return { select: vi.fn().mockReturnValue({ eq: eq1 }) } + } + return {} + }), + } as ReturnType) +} + +function mockMeterDb({ existing = null }: { existing?: unknown } = {}) { + const maybeSingle = vi.fn().mockResolvedValue({ data: existing }) + const insertSingle = vi.fn().mockResolvedValue({ + data: { id: 'meter-1', active: true }, + error: null, + }) + vi.mocked(createServiceClient).mockReturnValue({ + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ maybeSingle }), + }), + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ single: insertSingle }), + }), + }), + } as ReturnType) +} + +beforeEach(() => vi.clearAllMocks()) + +// ── Issue #29 — Input validation on all API routes ──────────────────────────── + +describe('regression issue_29: input validation on API routes', () => { + it('test_issue_29_readings_rejects_missing_meter_id', async () => { + const res = await postReading( + makeReadingRequest({ kwh: KWH, timestamp: TIMESTAMP, signature_hex: 'a'.repeat(128) }) + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toBeDefined() + }) + + it('test_issue_29_readings_rejects_negative_kwh', async () => { + const res = await postReading( + makeReadingRequest({ meter_id: METER_ID, kwh: -1, timestamp: TIMESTAMP, signature_hex: 'a'.repeat(128) }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_readings_rejects_zero_kwh', async () => { + const res = await postReading( + makeReadingRequest({ meter_id: METER_ID, kwh: 0, timestamp: TIMESTAMP, signature_hex: 'a'.repeat(128) }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_readings_rejects_missing_timestamp', async () => { + const res = await postReading( + makeReadingRequest({ meter_id: METER_ID, kwh: KWH, signature_hex: 'a'.repeat(128) }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_readings_rejects_short_signature', async () => { + const res = await postReading( + makeReadingRequest({ meter_id: METER_ID, kwh: KWH, timestamp: TIMESTAMP, signature_hex: 'deadbeef' }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_readings_rejects_non_uuid_meter_id', async () => { + const res = await postReading( + makeReadingRequest({ meter_id: 'not-a-uuid', kwh: KWH, timestamp: TIMESTAMP, signature_hex: 'a'.repeat(128) }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_readings_rejects_non_json_body', async () => { + const req = { + json: () => Promise.reject(new Error('bad json')), + headers: { get: (_: string) => null }, + } as unknown as Parameters[0] + const res = await postReading(req) + expect(res.status).toBe(400) + }) + + it('test_issue_29_meters_rejects_missing_name', async () => { + mockMeterDb() + const res = await postMeter( + makeMeterRequest({ + cooperative_id: '123e4567-e89b-12d3-a456-426614174000', + serial_number: 'SN-001', + pubkey_hex: 'a'.repeat(64), + }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_meters_rejects_invalid_cooperative_id', async () => { + mockMeterDb() + const res = await postMeter( + makeMeterRequest({ + name: 'Panel A', + cooperative_id: 'not-a-uuid', + serial_number: 'SN-001', + pubkey_hex: 'a'.repeat(64), + }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_meters_rejects_wrong_length_pubkey', async () => { + mockMeterDb() + const res = await postMeter( + makeMeterRequest({ + name: 'Panel A', + cooperative_id: '123e4567-e89b-12d3-a456-426614174000', + serial_number: 'SN-001', + pubkey_hex: 'tooshort', + }) + ) + expect(res.status).toBe(400) + }) + + it('test_issue_29_validation_runs_before_db_access', async () => { + // DB mock is NOT set up — if validation runs first, no DB call is made + const res = await postReading( + makeReadingRequest({ meter_id: METER_ID, kwh: -99, timestamp: TIMESTAMP, signature_hex: 'a'.repeat(128) }) + ) + expect(res.status).toBe(400) + // createServiceClient should not have been called + expect(createServiceClient).not.toHaveBeenCalled() + }) +}) + +// ── Issue #49 — Stellar account existence check before minting ──────────────── + +describe('regression issue_49: Stellar account existence check before minting', () => { + it('test_issue_49_mint_succeeds_when_account_exists', async () => { + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: { admin_address: 'GADMIN123' }, + }) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + expect(res.status).toBe(201) + expect(mintCertificates).toHaveBeenCalledWith('GADMIN123', KWH) + }) + + it('test_issue_49_returns_500_when_account_does_not_exist', async () => { + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: { admin_address: 'GNONEXISTENT' }, + }) + + vi.mocked(mintCertificates).mockRejectedValueOnce( + new Error('Recipient account GNONEXISTENT does not exist on Stellar.') + ) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + // Mint failure must not silently succeed + expect(res.status).toBe(500) + const json = await res.json() + expect(json.error).toMatch(/does not exist/i) + expect(json.reading_id).toBeDefined() + expect(json.anchor_tx_hash).toBeDefined() + }) + + it('test_issue_49_returns_500_when_trustline_missing', async () => { + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: { admin_address: 'GNOTRUSTED' }, + }) + + vi.mocked(mintCertificates).mockRejectedValueOnce( + new Error('Recipient account GNOTRUSTED has no trustline for the energy_token contract.') + ) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + expect(res.status).toBe(500) + const json = await res.json() + expect(json.error).toMatch(/trustline/i) + }) + + it('test_issue_49_missing_cooperative_address_fails_gracefully', async () => { + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: null, // no admin address + }) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + // Must not crash — should return a structured error + expect(res.status).toBe(500) + const json = await res.json() + expect(json.error).toBeDefined() + }) +}) + +// ── Issue #73 — Reading deduplication (API layer) ───────────────────────────── + +describe('regression issue_73: reading deduplication at API layer', () => { + it('test_issue_73_duplicate_anchor_returns_409', async () => { + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: { admin_address: 'GADMIN123' }, + }) + + const { anchorReading } = await import('@/lib/stellar') + vi.mocked(anchorReading).mockRejectedValueOnce( + new Error('AlreadyAnchored: reading already anchored') + ) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + expect(res.status).toBe(409) + const json = await res.json() + expect(json.error).toMatch(/already anchored/i) + }) + + it('test_issue_73_duplicate_error_includes_reading_id', async () => { + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: { admin_address: 'GADMIN123' }, + }) + + const { anchorReading } = await import('@/lib/stellar') + vi.mocked(anchorReading).mockRejectedValueOnce( + new Error('reading already anchored') + ) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + expect(res.status).toBe(409) + const json = await res.json() + expect(json.reading_id).toBeDefined() + }) + + it('test_issue_73_unique_readings_are_not_rejected', async () => { + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: { admin_address: 'GADMIN123' }, + }) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + // A fresh reading must succeed + expect(res.status).toBe(201) + }) + + it('test_issue_73_duplicate_keyword_in_error_triggers_409', async () => { + // Verify the deduplication check matches both "AlreadyAnchored" and "duplicate" + const { privKey, pubKeyHex } = await makeKeypair() + mockReadingDb({ + id: METER_ID, + pubkey_hex: pubKeyHex, + cooperative_id: 'coop-1', + cooperatives: { admin_address: 'GADMIN123' }, + }) + + const { anchorReading } = await import('@/lib/stellar') + vi.mocked(anchorReading).mockRejectedValueOnce(new Error('duplicate key')) + + const body = await makeReadingBody(privKey) + const res = await postReading(makeReadingRequest(body)) + + expect(res.status).toBe(409) + }) +}) diff --git a/apps/web/src/app/api/auth/login/route.ts b/apps/web/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..173decb --- /dev/null +++ b/apps/web/src/app/api/auth/login/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createClient } from '@supabase/supabase-js' +import { env } from '@/env' + +const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}) + +/** POST /api/auth/login — exchange email+password for access + refresh tokens */ +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => null) + const parsed = LoginSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const supabase = createClient( + env.NEXT_PUBLIC_SUPABASE_URL, + env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { auth: { persistSession: false } } + ) + + const { data, error } = await supabase.auth.signInWithPassword(parsed.data) + if (error || !data.session) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + return NextResponse.json({ + access_token: data.session.access_token, + refresh_token: data.session.refresh_token, + expires_in: data.session.expires_in, + token_type: 'Bearer', + }) +} diff --git a/apps/web/src/app/api/auth/logout/route.ts b/apps/web/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a6d1e41 --- /dev/null +++ b/apps/web/src/app/api/auth/logout/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, isAuthError, createUserClient } from '@/lib/auth' + +/** POST /api/auth/logout — invalidate the current session */ +export async function POST(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + + const client = createUserClient(auth.accessToken) + const { error } = await client.auth.signOut() + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ ok: true }) +} diff --git a/apps/web/src/app/api/auth/refresh/route.ts b/apps/web/src/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..4550eb2 --- /dev/null +++ b/apps/web/src/app/api/auth/refresh/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@supabase/supabase-js' +import { z } from 'zod' +import { env } from '@/env' + +const RefreshSchema = z.object({ refresh_token: z.string().min(1) }) + +/** POST /api/auth/refresh — rotate refresh token and return new token pair */ +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => null) + const parsed = RefreshSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const supabase = createClient( + env.NEXT_PUBLIC_SUPABASE_URL, + env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { auth: { persistSession: false } } + ) + + const { data, error } = await supabase.auth.refreshSession({ + refresh_token: parsed.data.refresh_token, + }) + + if (error || !data.session) { + return NextResponse.json({ error: 'Invalid or expired refresh token' }, { status: 401 }) + } + + return NextResponse.json({ + access_token: data.session.access_token, + refresh_token: data.session.refresh_token, + expires_in: data.session.expires_in, + token_type: 'Bearer', + }) +} diff --git a/apps/web/src/app/api/certificates/[id]/retire/route.test.ts b/apps/web/src/app/api/certificates/[id]/retire/route.test.ts index 147b0dc..46aff80 100644 --- a/apps/web/src/app/api/certificates/[id]/retire/route.test.ts +++ b/apps/web/src/app/api/certificates/[id]/retire/route.test.ts @@ -29,7 +29,15 @@ function makeDb(cert: unknown, updated: unknown = cert) { const eq = vi.fn().mockReturnValue({ select, single }) const updateEq = vi.fn().mockReturnValue({ select: updateSelect }) const update = vi.fn().mockReturnValue({ eq: updateEq }) - const from = vi.fn().mockReturnValue({ select: () => ({ eq }), update }) + const from = vi.fn((table: string) => { + if (table === 'webhook_endpoints') { + const contains = vi.fn().mockResolvedValue({ data: [] }) + const eq2 = vi.fn().mockReturnValue({ contains }) + const eq1 = vi.fn().mockReturnValue({ eq: eq2 }) + return { select: vi.fn().mockReturnValue({ eq: eq1 }) } + } + return { select: () => ({ eq }), update } + }) return from } @@ -70,7 +78,7 @@ describe('POST /api/certificates/[id]/retire', () => { }) it('returns 200 on successful retirement', async () => { - const cert = { id: VALID_UUID, retired: false, kwh: 10 } + const cert = { id: VALID_UUID, retired: false, kwh: 10, issued_at: '2026-01-01T00:00:00Z' } const updated = { id: VALID_UUID, retired: true, retired_at: '2026-01-01T00:00:00Z', retired_by: WALLET } const from = makeDb(cert, updated) vi.mocked(createServiceClient).mockReturnValue({ from } as never) @@ -84,7 +92,7 @@ describe('POST /api/certificates/[id]/retire', () => { }) it('returns 500 when Stellar retire call fails', async () => { - const cert = { id: VALID_UUID, retired: false, kwh: 10 } + const cert = { id: VALID_UUID, retired: false, kwh: 10, issued_at: '2026-01-01T00:00:00Z' } const from = makeDb(cert) vi.mocked(createServiceClient).mockReturnValue({ from } as never) vi.mocked(retireCertificate).mockRejectedValueOnce(new Error('Stellar error')) diff --git a/apps/web/src/app/api/certificates/[id]/retire/route.ts b/apps/web/src/app/api/certificates/[id]/retire/route.ts index 6a2d318..626ab80 100644 --- a/apps/web/src/app/api/certificates/[id]/retire/route.ts +++ b/apps/web/src/app/api/certificates/[id]/retire/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { createServiceClient } from '@/lib/supabase' import { retireCertificate } from '@/lib/stellar' import { fireWebhook } from '@/lib/webhooks' +import { triggerIRecRetirement } from '@/lib/irec-bridge' const RetireSchema = z.object({ wallet_address: z.string().min(1), @@ -81,6 +82,15 @@ export async function POST( retire_tx_hash: retireTxHash, }) + // Level 3 integration: Bridge retirement to I-REC registry + void triggerIRecRetirement({ + beneficiary: wallet_address, + volumeWh: cert.kwh * 1000, + vintageStart: new Date(cert.issued_at).toISOString(), + vintageEnd: new Date(cert.issued_at).toISOString(), + notes: `Retired via SolarProof: ${cert.id}`, + }) + return NextResponse.json({ id: updated.id, retired: updated.retired, diff --git a/apps/web/src/app/api/certificates/route.ts b/apps/web/src/app/api/certificates/route.ts index 426a5a2..d350e9f 100644 --- a/apps/web/src/app/api/certificates/route.ts +++ b/apps/web/src/app/api/certificates/route.ts @@ -8,35 +8,68 @@ const MAX_PAGE_SIZE = 100 * * Cursor-based pagination via `cursor` (ISO timestamp of `issued_at`) and * `limit` (max 100). Returns `{ data, next_cursor, total }`. + * + * Filter params: + * q — prefix search on certificate id or meter_id (via readings join) + * status — "active" | "retired" + * date_from — ISO date string (inclusive lower bound on issued_at) + * date_to — ISO date string (inclusive upper bound on issued_at) */ export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl const limit = Math.min(Number(searchParams.get('limit') ?? 20), MAX_PAGE_SIZE) - const cursor = searchParams.get('cursor') // ISO timestamp of last seen row + const cursor = searchParams.get('cursor') + const q = searchParams.get('q')?.trim() ?? '' + const status = searchParams.get('status') // "active" | "retired" | null + const dateFrom = searchParams.get('date_from') + const dateTo = searchParams.get('date_to') const db = createServiceClient() - const { count } = await db - .from('certificates') - .select('id', { count: 'exact', head: true }) - - let query = db + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let query = (db as any) .from('certificates') - .select('id, kwh, issued_at, retired, retired_at, retired_by, mint_tx_hash') + .select( + 'id, kwh, issued_at, retired, retired_at, retired_by, mint_tx_hash, readings!inner(meter_id)', + { count: 'exact' } + ) .order('issued_at', { ascending: false }) .limit(limit + 1) - if (cursor) { - query = query.lt('issued_at', cursor) + if (cursor) query = query.lt('issued_at', cursor) + if (status === 'active') query = query.eq('retired', false) + if (status === 'retired') query = query.eq('retired', true) + if (dateFrom) query = query.gte('issued_at', dateFrom) + if (dateTo) query = query.lte('issued_at', dateTo + 'T23:59:59.999Z') + + // Text search: match cert id prefix OR meter_id prefix via the joined reading + if (q) { + query = query.or(`id.ilike.${q}%,readings.meter_id.ilike.${q}%`) } - const { data, error } = await query + const { data, error, count } = await query if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const rows = data ?? [] + const rows = (data ?? []) as Array<{ + id: string + kwh: number + issued_at: string + retired: boolean + retired_at: string | null + retired_by: string | null + mint_tx_hash: string | null + readings: { meter_id: string } | { meter_id: string }[] + }> + const hasMore = rows.length > limit const page = hasMore ? rows.slice(0, limit) : rows const next_cursor = hasMore ? page[page.length - 1].issued_at : null - return NextResponse.json({ data: page, next_cursor, total: count ?? 0 }) + // Flatten the joined meter_id onto each row + const normalized = page.map(({ readings, ...cert }) => ({ + ...cert, + meter_id: Array.isArray(readings) ? readings[0]?.meter_id : readings?.meter_id ?? null, + })) + + return NextResponse.json({ data: normalized, next_cursor, total: count ?? 0 }) } diff --git a/apps/web/src/app/api/health/route.test.ts b/apps/web/src/app/api/health/route.test.ts index 2cc3724..3ae7ab1 100644 --- a/apps/web/src/app/api/health/route.test.ts +++ b/apps/web/src/app/api/health/route.test.ts @@ -1,12 +1,79 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/supabase', () => ({ + createServiceClient: vi.fn(), +})) + +vi.mock('@/env', () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: 'https://test.supabase.co', + NEXT_PUBLIC_SUPABASE_ANON_KEY: 'anon', + SUPABASE_SERVICE_ROLE_KEY: 'service', + NEXT_PUBLIC_STELLAR_RPC_URL: 'https://soroban-testnet.stellar.org', + }, +})) + +import { createServiceClient } from '@/lib/supabase' import { GET } from '@/app/api/health/route' +const mockSelect = vi.fn() +const mockFrom = vi.fn(() => ({ select: mockSelect })) +const mockCreateServiceClient = vi.mocked(createServiceClient) + +beforeEach(() => { + vi.clearAllMocks() + mockCreateServiceClient.mockReturnValue({ from: mockFrom } as never) + // Default: DB responds fast + mockSelect.mockResolvedValue({ data: null, error: null, count: 0 }) + // Default: Stellar RPC responds fast with ok + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ result: 'healthy' }) })) +}) + describe('GET /api/health', () => { - it('returns 200 with status ok', async () => { + it('returns 200 with status ok when all checks pass', async () => { const res = await GET() - const body = await res.json() expect(res.status).toBe(200) + const body = await res.json() expect(body.status).toBe('ok') + expect(body.checks.database.status).toBe('ok') + expect(body.checks.stellar_rpc.status).toBe('ok') expect(typeof body.ts).toBe('number') }) + + it('returns 503 when DB check errors', async () => { + mockSelect.mockRejectedValue(new Error('connection refused')) + const res = await GET() + expect(res.status).toBe(503) + const body = await res.json() + expect(body.status).toBe('error') + expect(body.checks.database.status).toBe('error') + }) + + it('returns 503 when Stellar RPC errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))) + const res = await GET() + expect(res.status).toBe(503) + const body = await res.json() + expect(body.status).toBe('error') + expect(body.checks.stellar_rpc.status).toBe('error') + }) + + it('returns 200 with degraded status when a check is slow', async () => { + // Simulate slow DB (> 300 ms threshold) by making the mock delay + mockSelect.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ data: null, error: null, count: 0 }), 310)) + ) + const res = await GET() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.status).toBe('degraded') + expect(body.checks.database.status).toBe('degraded') + }) + + it('includes latency_ms for each check', async () => { + const res = await GET() + const body = await res.json() + expect(typeof body.checks.database.latency_ms).toBe('number') + expect(typeof body.checks.stellar_rpc.latency_ms).toBe('number') + }) }) diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index 52b41b2..ed79443 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,5 +1,82 @@ import { NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase' +import { env } from '@/env' +const DEGRADED_THRESHOLD_MS = 300 // mark degraded if a check exceeds this +const TIMEOUT_MS = 450 // hard timeout per check (keeps total < 500 ms) + +type CheckStatus = 'ok' | 'degraded' | 'error' + +interface CheckResult { + status: CheckStatus + latency_ms: number + error?: string +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('timeout')), ms) + }) + try { + const result = await Promise.race([promise, timeout]) + clearTimeout(timer!) + return result + } catch (err) { + clearTimeout(timer!) + throw err + } +} + +async function checkDatabase(): Promise { + const start = Date.now() + try { + const db = createServiceClient() + await withTimeout( + db.from('cooperatives').select('id', { count: 'exact', head: true }), + TIMEOUT_MS + ) + const latency_ms = Date.now() - start + return { status: latency_ms > DEGRADED_THRESHOLD_MS ? 'degraded' : 'ok', latency_ms } + } catch (err) { + return { status: 'error', latency_ms: Date.now() - start, error: String(err) } + } +} + +async function checkStellarRpc(): Promise { + const start = Date.now() + try { + const res = await withTimeout( + fetch(env.NEXT_PUBLIC_STELLAR_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }), + }), + TIMEOUT_MS + ) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const latency_ms = Date.now() - start + return { status: latency_ms > DEGRADED_THRESHOLD_MS ? 'degraded' : 'ok', latency_ms } + } catch (err) { + return { status: 'error', latency_ms: Date.now() - start, error: String(err) } + } +} + +/** GET /api/health — service health with DB + Stellar RPC checks */ export async function GET() { - return NextResponse.json({ status: 'ok', ts: Date.now() }) + const [db, stellar] = await Promise.all([checkDatabase(), checkStellarRpc()]) + + const overallStatus: CheckStatus = + db.status === 'error' || stellar.status === 'error' + ? 'error' + : db.status === 'degraded' || stellar.status === 'degraded' + ? 'degraded' + : 'ok' + + const httpStatus = overallStatus === 'error' ? 503 : 200 + + return NextResponse.json( + { status: overallStatus, ts: Date.now(), checks: { database: db, stellar_rpc: stellar } }, + { status: httpStatus } + ) } diff --git a/apps/web/src/app/api/meters/route.test.ts b/apps/web/src/app/api/meters/route.test.ts new file mode 100644 index 0000000..7793d99 --- /dev/null +++ b/apps/web/src/app/api/meters/route.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/supabase', () => ({ createServiceClient: vi.fn() })) +vi.mock('@/lib/auth', () => ({ + requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, accessToken: 'tok' }), + isAuthError: vi.fn().mockReturnValue(false), +})) + +import { createServiceClient } from '@/lib/supabase' +import { POST } from '@/app/api/meters/route' + +function makeRequest(body: unknown) { + return { + json: () => Promise.resolve(body), + headers: { get: (_: string) => null }, + } as unknown as Parameters[0] +} + +const VALID_BODY = { + name: 'Solar Panel A', + cooperative_id: '123e4567-e89b-12d3-a456-426614174000', + serial_number: 'SN-001', + pubkey_hex: 'a'.repeat(64), +} + +function mockDb({ existing = null, insertData = { id: 'meter-1', ...VALID_BODY, active: true } } = {}) { + const maybeSingle = vi.fn().mockResolvedValue({ data: existing }) + const insertSingle = vi.fn().mockResolvedValue({ data: insertData, error: null }) + + vi.mocked(createServiceClient).mockReturnValue({ + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ maybeSingle }), + }), + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ single: insertSingle }), + }), + }), + } as ReturnType) +} + +beforeEach(() => vi.clearAllMocks()) + +describe('POST /api/meters', () => { + it('returns 400 when name is missing', async () => { + mockDb() + const { name: _, ...body } = VALID_BODY + const res = await POST(makeRequest(body)) + expect(res.status).toBe(400) + }) + + it('returns 409 when pubkey already exists', async () => { + mockDb({ existing: { id: 'existing-meter' } }) + const res = await POST(makeRequest(VALID_BODY)) + expect(res.status).toBe(409) + const json = await res.json() + expect(json.error).toMatch(/already exists/i) + }) + + it('returns 201 when meter is registered successfully', async () => { + mockDb() + const res = await POST(makeRequest(VALID_BODY)) + expect(res.status).toBe(201) + }) + + it('returns 400 when pubkey_hex is wrong length', async () => { + mockDb() + const res = await POST(makeRequest({ ...VALID_BODY, pubkey_hex: 'short' })) + expect(res.status).toBe(400) + }) +}) diff --git a/apps/web/src/app/api/meters/route.ts b/apps/web/src/app/api/meters/route.ts index 96f98fb..505063e 100644 --- a/apps/web/src/app/api/meters/route.ts +++ b/apps/web/src/app/api/meters/route.ts @@ -1,15 +1,20 @@ import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createServiceClient } from '@/lib/supabase' +import { requireAuth, isAuthError } from '@/lib/auth' const RegisterSchema = z.object({ + name: z.string().min(1).max(128), cooperative_id: z.string().uuid(), serial_number: z.string().min(1).max(64), pubkey_hex: z.string().length(64), }) -/** GET /api/meters — list all meters */ -export async function GET() { +/** GET /api/meters — list all meters (requires operator JWT) */ +export async function GET(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + const db = createServiceClient() const { data, error } = await db .from('meters') @@ -20,8 +25,11 @@ export async function GET() { return NextResponse.json(data) } -/** POST /api/meters — register a new meter */ +/** POST /api/meters — register a new meter (requires operator JWT) */ export async function POST(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + const body = await req.json().catch(() => null) const parsed = RegisterSchema.safeParse(body) if (!parsed.success) { @@ -29,6 +37,18 @@ export async function POST(req: NextRequest) { } const db = createServiceClient() + + // Check for duplicate public key + const { data: existing } = await db + .from('meters') + .select('id') + .eq('pubkey_hex', parsed.data.pubkey_hex) + .maybeSingle() + + if (existing) { + return NextResponse.json({ error: 'A meter with this public key already exists' }, { status: 409 }) + } + const { data, error } = await db .from('meters') .insert({ ...parsed.data, active: true }) diff --git a/apps/web/src/app/api/readings/route.test.ts b/apps/web/src/app/api/readings/route.test.ts index 3bc50ae..ddb7cab 100644 --- a/apps/web/src/app/api/readings/route.test.ts +++ b/apps/web/src/app/api/readings/route.test.ts @@ -19,8 +19,10 @@ vi.mock('@/lib/stellar', () => ({ anchorReading: vi.fn().mockResolvedValue('anchor_tx_abc'), mintCertificates: vi.fn().mockResolvedValue('mint_tx_abc'), })) -vi.mock('@/lib/cache', () => ({ invalidateCert: vi.fn().mockResolvedValue(undefined) })) - +vi.mock('@/lib/cache', () => ({ + invalidateCert: vi.fn().mockResolvedValue(undefined), + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }), +})) import { createServiceClient } from '@/lib/supabase' import { POST } from '@/app/api/readings/route' @@ -44,13 +46,15 @@ const TIMESTAMP = 1_700_000_000 /** Build a valid signed reading body using the given private key. */ async function makeBody(privKey: Uint8Array, overrides: Record = {}) { const kwhStroops = kwhToStroops(KWH) - const hash = computeReadingHash(METER_ID, kwhStroops, BigInt(TIMESTAMP)) + const currentTimestamp = overrides.timestamp as number ?? Math.floor(Date.now() / 1000) + const hash = computeReadingHash(METER_ID, kwhStroops, BigInt(currentTimestamp)) const sig = await sign(hash, privKey) return { meter_id: METER_ID, kwh: KWH, - timestamp: TIMESTAMP, + timestamp: currentTimestamp, signature_hex: Buffer.from(sig).toString('hex'), + nonce: 'test_nonce_123', ...overrides, } } @@ -82,6 +86,26 @@ function mockDb(meter: unknown) { if (table === 'meters') return { select } if (table === 'readings') return { insert, update } if (table === 'certificates') return { insert: certInsert } + if (table === 'idempotency_keys') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: null }), + }), + }), + delete: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({}), + }), + } + } + if (table === 'webhook_endpoints') { + const contains = vi.fn().mockResolvedValue({ data: [] }) + const eq2 = vi.fn().mockReturnValue({ contains }) + const eq1 = vi.fn().mockReturnValue({ eq: eq2 }) + return { + select: vi.fn().mockReturnValue({ eq: eq1 }), + } + } return {} }), } as ReturnType) @@ -103,17 +127,17 @@ describe('POST /api/readings', () => { }) it('returns 400 when meter_id is missing', async () => { - const res = await POST(makeRequest({ kwh: 1, timestamp: TIMESTAMP, signature_hex: 'a'.repeat(128) })) + const res = await POST(makeRequest({ kwh: 1, timestamp: Math.floor(Date.now() / 1000), signature_hex: 'a'.repeat(128) })) expect(res.status).toBe(400) }) it('returns 400 when kwh is negative', async () => { - const res = await POST(makeRequest({ meter_id: METER_ID, kwh: -1, timestamp: TIMESTAMP, signature_hex: 'a'.repeat(128) })) + const res = await POST(makeRequest({ meter_id: METER_ID, kwh: -1, timestamp: Math.floor(Date.now() / 1000), signature_hex: 'a'.repeat(128) })) expect(res.status).toBe(400) }) it('returns 400 when signature_hex is wrong length', async () => { - const res = await POST(makeRequest({ meter_id: METER_ID, kwh: KWH, timestamp: TIMESTAMP, signature_hex: 'deadbeef' })) + const res = await POST(makeRequest({ meter_id: METER_ID, kwh: KWH, timestamp: Math.floor(Date.now() / 1000), signature_hex: 'deadbeef' })) expect(res.status).toBe(400) }) @@ -147,7 +171,7 @@ describe('POST /api/readings', () => { it('returns 401 when signature_hex is all zeros (invalid)', async () => { const { pubKeyHex } = await makeKeypair() mockDb({ id: METER_ID, pubkey_hex: pubKeyHex, cooperative_id: 'coop-1', cooperatives: { admin_address: 'GADMIN' } }) - const body = { meter_id: METER_ID, kwh: KWH, timestamp: TIMESTAMP, signature_hex: '0'.repeat(128) } + const body = { meter_id: METER_ID, kwh: KWH, timestamp: Math.floor(Date.now() / 1000), signature_hex: '0'.repeat(128), nonce: 'test_nonce_123' } const res = await POST(makeRequest(body)) expect(res.status).toBe(401) }) @@ -176,7 +200,7 @@ describe('POST /api/readings', () => { expect(anchorReading).toHaveBeenCalledOnce() const callArg = vi.mocked(anchorReading).mock.calls[0][0] - const expectedHash = computeReadingHash(METER_ID, kwhToStroops(KWH), BigInt(TIMESTAMP)) + const expectedHash = computeReadingHash(METER_ID, kwhToStroops(KWH), BigInt(body.timestamp)) expect(Buffer.from(callArg.readingHash).toString('hex')).toBe(expectedHash.toString('hex')) }) }) diff --git a/apps/web/src/app/api/readings/route.ts b/apps/web/src/app/api/readings/route.ts index f0c09bf..45f66e3 100644 --- a/apps/web/src/app/api/readings/route.ts +++ b/apps/web/src/app/api/readings/route.ts @@ -5,9 +5,11 @@ import { createServiceClient } from '@/lib/supabase' import { computeReadingHash } from '@/lib/crypto' import { kwhToStroops } from '@solarproof/stellar' import { anchorReading, mintCertificates } from '@/lib/stellar' -import { invalidateCert } from '@/lib/cache' +import { invalidateCert, checkRateLimit } from '@/lib/cache' import { fireWebhook } from '@/lib/webhooks' import { logger } from '@/lib/logger' +import { requireAuth, isAuthError } from '@/lib/auth' +import { diagnoseMintFailure } from '@/lib/tracer-sim' const MAX_PAGE_SIZE = 100 @@ -16,8 +18,12 @@ const MAX_PAGE_SIZE = 100 * * Cursor-based pagination via `cursor` (ISO timestamp) and `limit` (max 100). * Returns `{ data, next_cursor, total }`. + * Requires operator JWT. */ export async function GET(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + const { searchParams } = req.nextUrl const limit = Math.min(Number(searchParams.get('limit') ?? 20), MAX_PAGE_SIZE) const cursor = searchParams.get('cursor') // ISO timestamp of last seen row @@ -72,7 +78,7 @@ const ReadingSchema = z.object({ kwh: z.number().positive(), timestamp: z.number().int().positive(), // Unix seconds signature_hex: z.string().length(128), // 64-byte Ed25519 sig as hex - nonce: z.string().min(1).max(128).optional(), + nonce: z.string().min(1).max(128), // Required for replay protection }) /** @@ -98,6 +104,13 @@ export async function POST(req: NextRequest) { const { meter_id, kwh, timestamp, signature_hex, nonce } = parsed.data const db = createServiceClient() + // Timestamp check: reject if >5 minutes old + const ageMs = Date.now() - (timestamp * 1000) + if (ageMs > 5 * 60 * 1000 || ageMs < -60 * 1000) { + log.warn('readings.post.stale_timestamp', { meter_id, timestamp }) + return NextResponse.json({ error: 'Reading timestamp is too old or in the future' }, { status: 400 }) + } + // Idempotency check: return cached response if nonce was seen within 24 h if (nonce) { const { data: existing } = await db @@ -129,6 +142,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Meter not found or inactive' }, { status: 404 }) } + // Rate limit: 60 requests/minute per meter public key + const rl = await checkRateLimit(meter.pubkey_hex) + if (!rl.allowed) { + log.warn('readings.post.rate_limited', { meter_id }) + return NextResponse.json( + { error: 'Rate limit exceeded' }, + { status: 429, headers: { 'Retry-After': String(rl.retryAfter) } } + ) + } + // Compute canonical reading hash const kwhStroops = kwhToStroops(kwh) const readingHash = computeReadingHash(meter_id, kwhStroops, BigInt(timestamp)) @@ -168,7 +191,7 @@ export async function POST(req: NextRequest) { // Anchor on-chain (hash only — full payload already in Supabase) let anchorTxHash: string try { - anchorTxHash = await anchorReading({ readingHash }) + anchorTxHash = await anchorReading({ readingHash, nonce }) await db.from('readings').update({ anchored: true, anchor_tx_hash: anchorTxHash }).eq('id', reading.id) log.info('readings.post.anchored', { reading_id: reading.id, anchor_tx_hash: anchorTxHash }) void fireWebhook(meter.cooperative_id, 'anchor', { reading_id: reading.id, anchor_tx_hash: anchorTxHash }) @@ -211,6 +234,7 @@ export async function POST(req: NextRequest) { } catch (err) { const message = err instanceof Error ? err.message : 'Mint failed' log.error('readings.post.mint_failed', { reading_id: reading.id, error: message }) - return NextResponse.json({ error: message, reading_id: reading.id, anchor_tx_hash: anchorTxHash }, { status: 500 }) + const diagnosis = await diagnoseMintFailure(reading.id, meter.cooperative_id, message) + return NextResponse.json({ error: message, reading_id: reading.id, anchor_tx_hash: anchorTxHash, diagnosis }, { status: 500 }) } } diff --git a/apps/web/src/app/api/ready/route.ts b/apps/web/src/app/api/ready/route.ts new file mode 100644 index 0000000..388275f --- /dev/null +++ b/apps/web/src/app/api/ready/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase' + +export async function GET() { + const checks: Record = {} + + // DB check + try { + const db = createServiceClient() + const { error } = await db.from('meters').select('id').limit(1) + checks.db = !error + } catch { + checks.db = false + } + + const healthy = Object.values(checks).every(Boolean) + return NextResponse.json( + { status: healthy ? 'ok' : 'degraded', checks }, + { status: healthy ? 200 : 503 } + ) +} diff --git a/apps/web/src/app/api/v1/auth/login/route.ts b/apps/web/src/app/api/v1/auth/login/route.ts new file mode 100644 index 0000000..fde2b02 --- /dev/null +++ b/apps/web/src/app/api/v1/auth/login/route.ts @@ -0,0 +1 @@ +export { POST } from '@/app/api/auth/login/route' diff --git a/apps/web/src/app/api/v1/auth/logout/route.ts b/apps/web/src/app/api/v1/auth/logout/route.ts new file mode 100644 index 0000000..02b4ece --- /dev/null +++ b/apps/web/src/app/api/v1/auth/logout/route.ts @@ -0,0 +1 @@ +export { POST } from '@/app/api/auth/logout/route' diff --git a/apps/web/src/app/api/v1/auth/refresh/route.ts b/apps/web/src/app/api/v1/auth/refresh/route.ts new file mode 100644 index 0000000..eac4ac2 --- /dev/null +++ b/apps/web/src/app/api/v1/auth/refresh/route.ts @@ -0,0 +1 @@ +export { POST } from '@/app/api/auth/refresh/route' diff --git a/apps/web/src/app/api/v1/verify/[id]/route.ts b/apps/web/src/app/api/v1/verify/[id]/route.ts new file mode 100644 index 0000000..feebaa5 --- /dev/null +++ b/apps/web/src/app/api/v1/verify/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from '@/app/api/verify/[id]/route' diff --git a/apps/web/src/app/api/verify/[id]/route.test.ts b/apps/web/src/app/api/verify/[id]/route.test.ts new file mode 100644 index 0000000..3acda18 --- /dev/null +++ b/apps/web/src/app/api/verify/[id]/route.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('@/lib/supabase', () => ({ createServiceClient: vi.fn() })) +vi.mock('@/lib/cache', () => ({ + getCachedCert: vi.fn().mockResolvedValue(null), + setCachedCert: vi.fn().mockResolvedValue(undefined), +})) + +import { GET } from '@/app/api/verify/[id]/route' +import { createServiceClient } from '@/lib/supabase' +import { getCachedCert } from '@/lib/cache' + +const VALID_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' +const VALID_HASH = 'a'.repeat(64) + +function makeRequest(id: string) { + const url = new URL(`http://localhost/api/verify/${id}`) + return new NextRequest(url) +} + +function makeParams(id: string) { + return { params: Promise.resolve({ id }) } +} + +function makeDb(cert: unknown, reading: unknown) { + const maybeSingle = vi.fn().mockResolvedValue({ data: cert }) + const single = vi.fn().mockResolvedValue({ data: reading }) + const eq = vi.fn().mockReturnValue({ maybeSingle, single }) + const select = vi.fn().mockReturnValue({ eq }) + return vi.fn().mockReturnValue({ select }) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getCachedCert).mockResolvedValue(null) +}) + +describe('GET /api/verify/[id]', () => { + it('returns 400 for invalid id format', async () => { + const res = await GET(makeRequest('not-valid'), makeParams('not-valid')) + expect(res.status).toBe(400) + }) + + it('returns 404 when certificate not found', async () => { + const from = makeDb(null, null) + vi.mocked(createServiceClient).mockReturnValue({ from } as never) + const res = await GET(makeRequest(VALID_UUID), makeParams(VALID_UUID)) + expect(res.status).toBe(404) + }) + + it('returns 200 with full chain for valid UUID', async () => { + const cert = { + id: VALID_UUID, + kwh: 10, + issued_at: '2026-01-01T00:00:00Z', + retired: false, + retired_at: null, + retired_by: null, + reading_id: 'r1', + anchor_tx_hash: 'a'.repeat(64), + mint_tx_hash: 'b'.repeat(64), + } + const reading = { + meter_id: 'meter-1', + reading_hash: VALID_HASH, + signature_hex: 'c'.repeat(128), + kwh: 10, + timestamp: '2026-01-01T00:00:00Z', + } + const from = makeDb(cert, reading) + vi.mocked(createServiceClient).mockReturnValue({ from } as never) + const res = await GET(makeRequest(VALID_UUID), makeParams(VALID_UUID)) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.certificate.id).toBe(VALID_UUID) + expect(body.on_chain.anchor_tx).toBe('a'.repeat(64)) + expect(body.meter_proof.verified).toBe(true) + expect(res.headers.get('Cache-Control')).toContain('max-age=60') + expect(res.headers.get('X-Cache')).toBe('MISS') + }) + + it('returns cached result on cache hit', async () => { + const cached = { certificate: { id: VALID_UUID }, on_chain: {}, meter_proof: null } + vi.mocked(getCachedCert).mockResolvedValue(cached) + const res = await GET(makeRequest(VALID_UUID), makeParams(VALID_UUID)) + expect(res.status).toBe(200) + expect(res.headers.get('X-Cache')).toBe('HIT') + }) + + it('accepts a 64-char hex hash as id', async () => { + const from = makeDb(null, null) + vi.mocked(createServiceClient).mockReturnValue({ from } as never) + const res = await GET(makeRequest(VALID_HASH), makeParams(VALID_HASH)) + expect(res.status).toBe(404) + }) +}) diff --git a/apps/web/src/app/api/verify/[id]/route.ts b/apps/web/src/app/api/verify/[id]/route.ts new file mode 100644 index 0000000..d8708cb --- /dev/null +++ b/apps/web/src/app/api/verify/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase' +import { getCachedCert, setCachedCert } from '@/lib/cache' + +/** + * GET /api/verify/[id] + * + * Public endpoint — no auth required. + * Returns the full chain of custody for a certificate identified by: + * - certificate UUID + * - reading_hash (64-char hex) + * - mint_tx_hash (64-char hex) + * + * Response is cached for 60 s. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + + if (!id || !/^([0-9a-f]{64}|[0-9a-f-]{36})$/i.test(id)) { + return NextResponse.json({ error: 'id must be a UUID or 64-char hex hash' }, { status: 400 }) + } + + const cached = await getCachedCert(id) + if (cached) { + return NextResponse.json(cached, { + headers: { + 'Cache-Control': 'public, max-age=60, stale-while-revalidate=30', + 'X-Cache': 'HIT', + }, + }) + } + + const db = createServiceClient() + let cert = null + for (const column of ['id', 'reading_hash', 'mint_tx_hash'] as const) { + const { data } = await db.from('certificates').select('*').eq(column, id).maybeSingle() + if (data) { cert = data; break } + } + + if (!cert) { + return NextResponse.json({ error: 'Certificate not found' }, { status: 404 }) + } + + const { data: reading } = await db + .from('readings') + .select('*') + .eq('id', cert.reading_id) + .single() + + const chain = { + certificate: { + id: cert.id, + kwh: cert.kwh, + issued_at: cert.issued_at, + retired: cert.retired, + retired_at: cert.retired_at, + retired_by: cert.retired_by, + }, + on_chain: { + anchor_tx: cert.anchor_tx_hash, + anchor_explorer: `https://stellar.expert/explorer/testnet/tx/${cert.anchor_tx_hash}`, + mint_tx: cert.mint_tx_hash, + mint_explorer: `https://stellar.expert/explorer/testnet/tx/${cert.mint_tx_hash}`, + retirement_tx: cert.retired_at ? cert.mint_tx_hash : null, + }, + meter_proof: reading + ? { + meter_id: reading.meter_id, + reading_hash: reading.reading_hash, + signature_hex: reading.signature_hex, + kwh: reading.kwh, + timestamp: reading.timestamp, + verified: true, + } + : null, + } + + await setCachedCert(id, chain) + + return NextResponse.json(chain, { + headers: { + 'Cache-Control': 'public, max-age=60, stale-while-revalidate=30', + 'X-Cache': 'MISS', + }, + }) +} diff --git a/apps/web/src/app/api/ws/readings/route.ts b/apps/web/src/app/api/ws/readings/route.ts new file mode 100644 index 0000000..7d2430d --- /dev/null +++ b/apps/web/src/app/api/ws/readings/route.ts @@ -0,0 +1,26 @@ +/** + * WebSocket endpoint for real-time meter readings + * + * This is a placeholder implementation. In production, you would: + * 1. Use a WebSocket server (e.g., ws library, Socket.io) + * 2. Set up proper authentication + * 3. Subscribe to database changes (e.g., Supabase Realtime, PostgreSQL LISTEN/NOTIFY) + * 4. Broadcast new readings to connected clients + * + * For Next.js deployment on Vercel, consider: + * - Using Supabase Realtime subscriptions directly from the client + * - Using Pusher, Ably, or similar managed WebSocket services + * - Deploying a separate WebSocket server on a platform that supports long-lived connections + */ + +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json( + { + error: 'WebSocket endpoint not yet implemented', + message: 'Falling back to polling. To enable real-time updates, configure a WebSocket server or use Supabase Realtime.' + }, + { status: 501 } + ) +} diff --git a/apps/web/src/app/certificate/[id]/page.tsx b/apps/web/src/app/certificate/[id]/page.tsx index 6238bc8..927af10 100644 --- a/apps/web/src/app/certificate/[id]/page.tsx +++ b/apps/web/src/app/certificate/[id]/page.tsx @@ -7,25 +7,13 @@ import { Link2, Award, FlameKindling, - ExternalLink, CheckCircle2, Clock, + ExternalLink, } from 'lucide-react' import { createServiceClient } from '@/lib/supabase' - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- -interface ChainStep { - icon: React.ElementType - label: string - timestamp: string | null - hash: string | null - hashLabel: string - explorerUrl?: string - status: 'done' | 'pending' - detail?: string -} +import { CertificateChain, type ChainStep } from '@/components/certificate-chain' +import { CopyableText } from '@/components/copy-button' // --------------------------------------------------------------------------- // Data fetching (server-side, no auth required) @@ -148,7 +136,9 @@ export default async function CertificatePage({

Certificate

-

{id}

+
+ +
{/* Status badge */}
@@ -167,97 +157,7 @@ export default async function CertificatePage({ {/* Chain of custody stepper */} -
    - {steps.map((step, i) => { - const isLast = i === steps.length - 1 - const Icon = step.icon - return ( -
  1. - {/* Connector line */} - {!isLast && ( -
  2. - ) - })} -
+ {/* Footer actions */}
diff --git a/apps/web/src/app/certificates/page.tsx b/apps/web/src/app/certificates/page.tsx index 05ea1dc..1cd4709 100644 --- a/apps/web/src/app/certificates/page.tsx +++ b/apps/web/src/app/certificates/page.tsx @@ -1,38 +1,94 @@ 'use client' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Award, Leaf } from 'lucide-react' +import { useRouter, usePathname, useSearchParams } from 'next/navigation' +import { Award, Leaf, Search, X } from 'lucide-react' import { RetireModal } from '@/components/retire-modal' import { useToast } from '@/components/toast' import { useWallet } from '@/hooks/useWallet' +import { WalletGate } from '@/components/wallet-gate' interface Certificate { id: string kwh: number - minted_at: string + issued_at: string retired: boolean retired_at: string | null retired_by: string | null - tx_hash: string | null + mint_tx_hash: string | null + meter_id: string | null } -async function fetchCertificates(): Promise { - const res = await fetch('/api/certificates') +interface CertificatesResponse { + data: Certificate[] + next_cursor: string | null + total: number +} + +async function fetchCertificates(params: URLSearchParams): Promise { + const res = await fetch(`/api/certificates?${params.toString()}`) if (!res.ok) throw new Error('Failed to load certificates') return res.json() } export default function CertificatesPage() { - const { data, isLoading, error } = useQuery({ - queryKey: ['certificates'], - queryFn: fetchCertificates, - }) + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() const qc = useQueryClient() const { toast, dismiss } = useToast() - const { address, connected, connect } = useWallet() + const { address, connected } = useWallet() const [retiring, setRetiring] = useState(null) + // Read filter state from URL + const q = searchParams.get('q') ?? '' + const status = searchParams.get('status') ?? '' + const dateFrom = searchParams.get('date_from') ?? '' + const dateTo = searchParams.get('date_to') ?? '' + + // Local draft state for the search input (debounced via form submit) + const [draft, setDraft] = useState(q) + + function pushParams(updates: Record) { + const params = new URLSearchParams(searchParams.toString()) + for (const [k, v] of Object.entries(updates)) { + if (v) params.set(k, v) + else params.delete(k) + } + params.delete('cursor') // reset pagination on filter change + router.push(`${pathname}?${params.toString()}`) + } + + function clearFilters() { + setDraft('') + router.push(pathname) + } + + const hasFilters = q || status || dateFrom || dateTo + + const queryParams = new URLSearchParams() + if (q) queryParams.set('q', q) + if (status) queryParams.set('status', status) + if (dateFrom) queryParams.set('date_from', dateFrom) + if (dateTo) queryParams.set('date_to', dateTo) + + const { data: response, isLoading, error } = useQuery({ + queryKey: ['certificates', q, status, dateFrom, dateTo], + queryFn: () => fetchCertificates(queryParams), + }) + + const data = response?.data ?? [] + + const handleSearchSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + pushParams({ q: draft }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft, searchParams] + ) + async function handleRetire(reason: string) { if (!retiring) return const pendingId = toast('pending', 'Submitting retirement transaction…') @@ -58,113 +114,191 @@ export default function CertificatesPage() { } return ( -
-

Certificates

+ +
+

Certificates

- {!connected && ( -
-

- Connect your wallet to retire certificates. -

- + {/* Filter bar */} +
+ {/* Search */} +
+
+
+ +
+ + {/* Status filter */} +
+ + +
+ + {/* Date range */} +
+ + pushParams({ date_from: e.target.value })} + aria-label="Filter from date" + className="rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-yellow-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100" + /> +
+
+ + pushParams({ date_to: e.target.value })} + aria-label="Filter to date" + className="rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-yellow-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100" + /> +
+ + {/* Clear filters */} + {hasFilters && ( + + )} + + {response && ( + + {response.total} result{response.total !== 1 ? 's' : ''} + + )}
- )} - - {error && ( -

- Failed to load certificates. -

- )} - -
-
- - - - {['Certificate ID', 'kWh', 'Minted', 'Status', 'Action'].map((h) => ( - - ))} - - - - {isLoading ? ( - - + + {error && ( +

+ Failed to load certificates. +

+ )} + +
+
+
- {h} -
- Loading… -
+ + + {['Certificate ID', 'Meter ID', 'kWh', 'Issued', 'Status', 'Action'].map((h) => ( + + ))} - ) : data && data.length > 0 ? ( - data.map((cert) => ( - - - - + + {isLoading ? ( + + - - + ) : data.length > 0 ? ( + data.map((cert) => ( + + + + + + + + + )) + ) : ( + + - )) - ) : ( - - - - )} - -
+ {h} +
- {cert.id.slice(0, 8)}… - {cert.kwh} - {new Date(cert.minted_at).toLocaleDateString()} +
+ Loading… - {cert.retired ? ( - - - ) : ( - - - )} - - {!cert.retired && ( - - )} +
+ {cert.id.slice(0, 8)}… + + {cert.meter_id ? `${cert.meter_id.slice(0, 8)}…` : '—'} + {cert.kwh} + {new Date(cert.issued_at).toLocaleDateString()} + + {cert.retired ? ( + + + ) : ( + + + )} + + {!cert.retired && ( + + )} +
+ No certificates found.
- No certificates found. -
+ )} + + +
-
- {retiring && ( - setRetiring(null)} - /> - )} -
+ {retiring && ( + setRetiring(null)} + /> + )} +
+ ) } diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index d19a8f4..dfb0619 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useQuery } from '@tanstack/react-query' +import { WalletGate } from '@/components/wallet-gate' import { AreaChart, Area, @@ -14,9 +15,10 @@ import { Legend, } from 'recharts' import { useTheme } from 'next-themes' -import { Zap, Award, Leaf, TrendingUp, Download } from 'lucide-react' +import { Zap, Award, Leaf, TrendingUp, Download, Wifi, WifiOff } from 'lucide-react' import { StatCardSkeleton, ChartSkeleton, TableRowSkeleton } from '@/components/skeleton' import { useState } from 'react' +import { useRealtimeReadings } from '@/hooks/use-realtime-readings' // --------------------------------------------------------------------------- // Types @@ -159,6 +161,7 @@ function exportCsv(rows: { date: string; kwh: number }[], filename: string) { // --------------------------------------------------------------------------- export default function DashboardPage() { const [period, setPeriod] = useState('daily') + const { isConnected, error: wsError } = useRealtimeReadings() const { data: stats, @@ -170,15 +173,40 @@ export default function DashboardPage() { data: readings, isLoading: readingsLoading, error: readingsError, - } = useQuery({ queryKey: ['readings'], queryFn: fetchReadings }) + } = useQuery({ + queryKey: ['readings'], + queryFn: fetchReadings, + // Fallback to polling every 30s if WebSocket is not connected + refetchInterval: isConnected ? false : 30000, + }) const colors = useChartColors() const chartData = readings ? groupByPeriod(readings, period) : [] const meterData = readings ? groupByMeter(readings) : [] return ( +
-

Dashboard

+
+

Dashboard

+ + {/* Connection status indicator */} +
+ {isConnected ? ( + <> +
+
{/* Stat cards */}
@@ -198,7 +226,6 @@ export default function DashboardPage() { ) : null}
- {/* Charts */} @@ -313,7 +340,6 @@ export default function DashboardPage() {
)}
- {/* Recent readings table */} @@ -344,8 +370,12 @@ export default function DashboardPage() { {readingsLoading ? ( <> ) : readings && readings.length > 0 ? ( - readings.slice(0, 20).map((r) => ( - + readings.slice(0, 20).map((r, index) => ( + {r.meter_id} {r.kwh} {new Date(r.timestamp).toLocaleString()} @@ -365,8 +395,8 @@ export default function DashboardPage() { - + ) } diff --git a/apps/web/src/app/governance/page.tsx b/apps/web/src/app/governance/page.tsx new file mode 100644 index 0000000..520b34c --- /dev/null +++ b/apps/web/src/app/governance/page.tsx @@ -0,0 +1,443 @@ +'use client' + +import { useState } from 'react' +import { Vote, Plus, Clock, CheckCircle, XCircle, Minus, ChevronDown, ChevronUp } from 'lucide-react' +import { useWallet } from '@/hooks/useWallet' + +// ── Types ────────────────────────────────────────────────────────────────────── + +type VoteChoice = 'for' | 'against' | 'abstain' +type ProposalStatus = 'active' | 'passed' | 'rejected' | 'pending' + +interface Tally { for: number; against: number; abstain: number } + +interface Proposal { + id: string + title: string + description: string + status: ProposalStatus + tally: Tally + endsAt: Date + userVote?: VoteChoice +} + +// ── Seed data (replaced by real contract calls in production) ────────────────── + +const SEED: Proposal[] = [ + { + id: 'prop-001', + title: 'Increase minimum meter reading interval to 15 minutes', + description: 'Reduce on-chain anchoring costs by batching readings every 15 minutes instead of every 5.', + status: 'active', + tally: { for: 142, against: 38, abstain: 12 }, + endsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + }, + { + id: 'prop-002', + title: 'Add support for wind energy certificates', + description: 'Extend the energy_token contract to support wind generation alongside solar.', + status: 'active', + tally: { for: 89, against: 61, abstain: 5 }, + endsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + { + id: 'prop-003', + title: 'Integrate I-REC bridge (v1)', + description: 'Build a bridge to the I-REC registry so SolarProof certificates can be cross-listed.', + status: 'passed', + tally: { for: 210, against: 30, abstain: 8 }, + endsAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + }, +] + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function totalVotes(t: Tally) { return t.for + t.against + t.abstain } +function pct(n: number, total: number) { return total === 0 ? 0 : Math.round((n / total) * 100) } + +function countdown(endsAt: Date): string { + const diff = endsAt.getTime() - Date.now() + if (diff <= 0) return 'Ended' + const d = Math.floor(diff / 86_400_000) + const h = Math.floor((diff % 86_400_000) / 3_600_000) + return d > 0 ? `${d}d ${h}h remaining` : `${h}h remaining` +} + +const STATUS_BADGE: Record = { + active: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + passed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + pending: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', +} + +// ── Sub-components ───────────────────────────────────────────────────────────── + +function TallyBar({ tally }: { tally: Tally }) { + const total = totalVotes(tally) + const forPct = pct(tally.for, total) + const againstPct = pct(tally.against, total) + const abstainPct = pct(tally.abstain, total) + return ( +
+
+
+
+
+
+
+ {forPct}% For ({tally.for}) + {againstPct}% Against ({tally.against}) + {abstainPct}% Abstain ({tally.abstain}) +
+
+ ) +} + +function VoteButtons({ + proposalId, + userVote, + disabled, + onVote, +}: { + proposalId: string + userVote?: VoteChoice + disabled: boolean + onVote: (id: string, choice: VoteChoice) => void +}) { + const btn = (choice: VoteChoice, label: string, Icon: React.ElementType, color: string) => { + const active = userVote === choice + return ( + + ) + } + return ( +
+ {btn('for', 'For', CheckCircle, 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300')} + {btn('against', 'Against', XCircle, 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300')} + {btn('abstain', 'Abstain', Minus, 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400')} +
+ ) +} + +function ProposalCard({ + proposal, + onVote, + walletConnected, +}: { + proposal: Proposal + onVote: (id: string, choice: VoteChoice) => void + walletConnected: boolean +}) { + const [expanded, setExpanded] = useState(false) + const isActive = proposal.status === 'active' + + return ( +
+
+
+
+ + {proposal.status} + + {isActive && ( + + + )} +
+

+ {proposal.title} +

+
+ +
+ + {expanded && ( +
+

{proposal.description}

+
+ )} + +
+ + {isActive && ( +
+ {!walletConnected && ( +

Connect your wallet to vote.

+ )} + + {proposal.userVote && ( +

+ You voted {proposal.userVote}. +

+ )} +
+ )} +
+
+ ) +} + +// ── Create Proposal Form ─────────────────────────────────────────────────────── + +interface FormState { title: string; description: string; days: string } +const EMPTY: FormState = { title: '', description: '', days: '7' } + +function CreateProposalForm({ onCreated }: { onCreated: (p: Proposal) => void }) { + const { connected, connect } = useWallet() + const [form, setForm] = useState(EMPTY) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [success, setSuccess] = useState(false) + + function validate(): boolean { + const e: Partial = {} + if (!form.title.trim()) e.title = 'Title is required.' + if (!form.description.trim()) e.description = 'Description is required.' + const d = Number(form.days) + if (!form.days || isNaN(d) || d < 1 || d > 30) e.days = 'Enter a number between 1 and 30.' + setErrors(e) + return Object.keys(e).length === 0 + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!validate()) return + if (!connected) { + try { await connect() } catch { return } + } + setSubmitting(true) + // Simulate wallet signature + contract call + await new Promise((r) => setTimeout(r, 800)) + const newProposal: Proposal = { + id: `prop-${Date.now()}`, + title: form.title.trim(), + description: form.description.trim(), + status: 'active', + tally: { for: 0, against: 0, abstain: 0 }, + endsAt: new Date(Date.now() + Number(form.days) * 86_400_000), + } + onCreated(newProposal) + setForm(EMPTY) + setErrors({}) + setSubmitting(false) + setSuccess(true) + setTimeout(() => setSuccess(false), 3000) + } + + return ( +
+

+ New Proposal +

+ {success && ( +
+
+ )} +
+ + setForm((f) => ({ ...f, title: e.target.value }))} + maxLength={120} + aria-required="true" + aria-describedby={errors.title ? 'prop-title-err' : undefined} + aria-invalid={!!errors.title} + placeholder="Short, descriptive title" + className="input-base" + /> + + + +