Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions .github/workflows/load-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,28 @@ on:
required: true
default: 'https://solarproof-staging.vercel.app'
meter_id:
description: 'Seeded meter UUID'
description: 'Seeded meter UUID (leave blank for placeholder mode)'
required: false
default: ''
scenario:
description: 'Test scenario: baseline (100 VUs, p95 < 500ms) or breakpoint (ramp to breaking point)'
required: false
default: 'baseline'
type: choice
options:
- baseline
- breakpoint
# Run baseline weekly against staging to catch regressions
schedule:
- cron: '0 4 * * 1' # Every Monday at 04:00 UTC

jobs:
load-test:
name: k6 load test (100 VUs, 60 s)
name: k6 load test (${{ github.event.inputs.scenario || 'baseline' }})
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install k6
run: |
sudo gpg -k
Expand All @@ -28,8 +40,19 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install -y k6

- name: Run load test
run: |
k6 run tests/load/readings.js \
-e API_URL=${{ github.event.inputs.api_url }} \
-e METER_ID=${{ github.event.inputs.meter_id || 'placeholder' }}
-e API_URL=${{ github.event.inputs.api_url || 'https://solarproof-staging.vercel.app' }} \
-e METER_ID=${{ github.event.inputs.meter_id || '00000000-0000-0000-0000-000000000001' }} \
-e SCENARIO=${{ github.event.inputs.scenario || 'baseline' }}

- name: Upload k6 results
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results-${{ github.event.inputs.scenario || 'baseline' }}-${{ github.run_id }}
path: '*.json'
if-no-files-found: ignore
retention-days: 30
6 changes: 6 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# TODO (4 issues)

- [x] Issue 1: Load test baseline + breaking point docs + ensure runnable instructions align with acceptance.
- [x] Issue 2: Security audit engagement tracking docs + published audit scope/remediation/re-audit requirements.
- [x] Issue 3: Integration/e2e flow tests for valid/invalid/duplicate (add scenarios or new script) + CI wiring for local Stellar sandbox.
- [x] Issue 4: Ensure fuzz targets exist/wired for energy_token mint, audit_registry anchor, governance vote (cargo-fuzz integration).
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
����������������������������������������������������������������
Binary file not shown.
Binary file not shown.
73 changes: 57 additions & 16 deletions apps/contracts/fuzz/fuzz_targets/fuzz_anchor.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//! Fuzz target: audit_registry::anchor
//!
//! Exercises anchor() with arbitrary 32-byte hashes.
//! Exercises anchor() with arbitrary 32-byte hashes and 32-byte nonces.
//! Verifies that:
//! - any 32-byte hash can be anchored exactly once
//! - duplicate anchors always return AlreadyAnchored
//! - any (hash, nonce) pair can be anchored exactly once
//! - re-anchoring the same hash (different nonce) returns AlreadyAnchored
//! - re-using the same nonce (different hash) returns AlreadyAnchored
//! - total_anchors is monotonically increasing
//! - no panics occur on any valid (hash, nonce) pair

#![no_main]

Expand All @@ -13,11 +15,13 @@ use soroban_sdk::{testutils::Address as _, Address, BytesN, Env};
use audit_registry::{AuditRegistry, AuditRegistryClient, Error};

fuzz_target!(|data: &[u8]| {
if data.len() < 32 {
// Need at least 64 bytes: 32 for hash, 32 for nonce
if data.len() < 64 {
return;
}

let hash_bytes: [u8; 32] = data[..32].try_into().unwrap();
let nonce_bytes: [u8; 32] = data[32..64].try_into().unwrap();

let env = Env::default();
env.mock_all_auths();
Expand All @@ -28,19 +32,56 @@ fuzz_target!(|data: &[u8]| {
client.initialize(&admin, &api_signer);

let hash = BytesN::from_array(&env, &hash_bytes);
let nonce = BytesN::from_array(&env, &nonce_bytes);

// First anchor must succeed
let result = client.anchor(&api_signer, &hash);
assert_eq!(result, Ok(()), "first anchor should succeed");
assert!(client.is_anchored(&hash));
assert_eq!(client.total_anchors(), 1);

// Duplicate anchor must return AlreadyAnchored
let dup = client.anchor(&api_signer, &hash);
assert_eq!(dup, Err(Error::AlreadyAnchored), "duplicate anchor should fail");
assert_eq!(client.total_anchors(), 1, "count must not increment on duplicate");
// ── First anchor must succeed ────────────────────────────────────────────
let result = client.anchor(&api_signer, &hash, &nonce);
assert_eq!(result, Ok(()), "first anchor should succeed for any valid (hash, nonce)");
assert!(client.is_anchored(&hash), "hash must be anchored after first anchor");
assert_eq!(client.total_anchors(), 1, "total_anchors must be 1 after first anchor");

// Verify stored anchor matches input
let stored = client.verify(&hash).expect("anchor should be retrievable");
assert_eq!(stored.reading_hash, hash);
let stored = client.verify(&hash).expect("anchor should be retrievable after first anchor");
assert_eq!(stored.reading_hash, hash, "stored hash must equal the input hash");

// ── Duplicate nonce (same nonce, any hash) must return AlreadyAnchored ───
// Construct a distinct hash by flipping the last byte of the original.
let mut alt_hash_bytes = hash_bytes;
alt_hash_bytes[31] = alt_hash_bytes[31].wrapping_add(1);
let alt_hash = BytesN::from_array(&env, &alt_hash_bytes);

let dup_nonce = client.anchor(&api_signer, &alt_hash, &nonce);
assert_eq!(
dup_nonce,
Err(Error::AlreadyAnchored),
"duplicate nonce must return AlreadyAnchored"
);
assert_eq!(
client.total_anchors(), 1,
"count must not increment when nonce is reused"
);

// ── Duplicate hash (same hash, fresh nonce) must return AlreadyAnchored ──
let mut fresh_nonce_bytes = nonce_bytes;
fresh_nonce_bytes[31] = fresh_nonce_bytes[31].wrapping_add(1);
let fresh_nonce = BytesN::from_array(&env, &fresh_nonce_bytes);

let dup_hash = client.anchor(&api_signer, &hash, &fresh_nonce);
assert_eq!(
dup_hash,
Err(Error::AlreadyAnchored),
"duplicate hash must return AlreadyAnchored even with a fresh nonce"
);
assert_eq!(
client.total_anchors(), 1,
"count must not increment when hash is duplicated"
);

// ── A completely distinct (hash, nonce) pair must succeed ────────────────
// Only do this when we have bytes that produce a genuinely different alt hash.
if alt_hash_bytes != hash_bytes {
let result2 = client.anchor(&api_signer, &alt_hash, &fresh_nonce);
assert_eq!(result2, Ok(()), "second anchor with distinct hash and nonce should succeed");
assert_eq!(client.total_anchors(), 2, "total_anchors must be 2 after second anchor");
}
});
165 changes: 165 additions & 0 deletions docs/audits/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# SolarProof — Smart Contract Security Audit

## Overview

All three Soroban contracts handle real financial value (energy certificates on Stellar).
A professional third-party security audit is required before mainnet launch.

| Item | Detail |
|------|--------|
| **Audit status** | Pre-audit — firm selection in progress |
| **Contracts in scope** | `energy_token`, `audit_registry`, `community_governance` |
| **Target completion** | Before mainnet deployment |
| **Report location** | This directory (`docs/audits/`) |
| **Re-audit policy** | Required after any Critical/High finding remediation or significant contract change |

See [`/docs/AUDIT_SCOPE.md`](../AUDIT_SCOPE.md) for the full technical scope definition.

---

## Audit Firm Selection

### Shortlisted firms (Soroban / Rust / Stellar experience)

| Firm | Specialization | Contact | Status |
|------|---------------|---------|--------|
| Least Authority | Rust, cryptographic protocols | contact@leastauthority.com | Pending RFP |
| OtterSec | Rust smart contracts (Solana/Stellar) | contracts@osec.io | Pending RFP |
| Zellic | Smart contracts, Rust, blockchain | audit@zellic.io | Pending RFP |
| Cure53 | Web/API + cryptography | — | Pending RFP |

> **Action required**: Send the RFP (see `audit-firm-rfp.md`) to at least two firms and
> update this table with responses, quotes, and selected firm.

### Selection criteria

- Demonstrated experience auditing Rust smart contracts
- Familiarity with Soroban SDK / Stellar ecosystem
- Availability to complete initial audit within 4–6 weeks of engagement
- Willingness to perform re-audit after remediation
- References from comparable financial/token contracts

---

## Contracts in Scope

| Contract | Path | Version | Lines (approx.) | Purpose |
|----------|------|---------|-----------------|---------|
| `energy_token` | `apps/contracts/energy_token/src/lib.rs` | 1.0.0 | ~430 | SEP-41 fungible energy certificate token |
| `audit_registry` | `apps/contracts/audit_registry/src/lib.rs` | 1.0.0 | ~340 | Immutable on-chain anchor of meter reading hashes |
| `community_governance` | `apps/contracts/community_governance/src/lib.rs` | 1.0.0 | ~640 | Cooperative proposal + voting with bitmap optimization |

All contracts target **Soroban SDK 23.1.0** on Stellar and are written in Rust.

---

## Audit Timeline

| Phase | Target Date | Owner | Status |
|-------|-------------|-------|--------|
| Firm selection & RFP | TBD | Engineering lead | 🔲 Not started |
| Engagement signed | TBD | Engineering + legal | 🔲 Not started |
| Pre-audit code freeze | TBD | Engineering | 🔲 Not started |
| Initial audit | TBD | Audit firm | 🔲 Not started |
| Preliminary findings delivered | TBD | Audit firm | 🔲 Not started |
| Remediation period | TBD | Engineering | 🔲 Not started |
| Re-audit of Critical/High fixes | TBD | Audit firm | 🔲 Not started |
| Final report published | TBD | Audit firm | 🔲 Not started |

> Update this table as milestones are reached. Set dates once the firm is engaged.

---

## Findings

All findings will be documented here once the audit report is received.
Sections below define the expected structure.

### Critical (must fix before mainnet)

_None identified — audit not yet performed._

### High (must fix before mainnet)

_None identified — audit not yet performed._

### Medium (fix before mainnet or with documented risk acceptance)

_None identified — audit not yet performed._

### Low / Informational

_None identified — audit not yet performed._

---

## Pre-Audit Checklist

The following items must be completed before handing off to the auditing firm.

### Code readiness

- [x] All three contracts compile cleanly (`cargo build --target wasm32-unknown-unknown`)
- [x] Full unit test suite passes (`cargo test --all`)
- [x] Property-based tests pass (`cargo test` in `apps/contracts/proptest/`)
- [x] Fuzz targets defined for `mint`, `anchor`, and `vote`
- [x] No `unwrap()` calls that could cause silent panics on untrusted input
- [x] All access control checks verified (minter-only mint, signer-only anchor, admin-only admin ops)
- [x] Overflow checks present for all i128/u32 arithmetic
- [x] Reentrancy guard in `community_governance::vote()`
- [x] Duplicate anchor prevention (`AlreadyAnchored` error + nonce idempotency)
- [x] Double-vote prevention (bitmap-based, per-voter per-proposal)
- [ ] Persistent storage TTL bump strategy documented

### Documentation readiness

- [x] Inline rustdoc on all public functions
- [x] Invariants documented in module-level comments
- [x] `AUDIT_SCOPE.md` up to date
- [x] Deployment guide in `docs/DEPLOYMENT.md`
- [x] Threat model in `docs/THREAT_MODEL.md`

### Audit deliverables to request

1. Findings report with severity ratings (Critical / High / Medium / Low / Informational)
2. Concrete recommendations for each finding
3. Confirmation of fixed findings after re-audit
4. Final published report (PDF) for inclusion in this directory

---

## Remediation Policy

| Severity | Action required | Timeline |
|----------|----------------|----------|
| Critical | Must be fixed and re-audited before mainnet | Immediately |
| High | Must be fixed and re-audited before mainnet | Before code freeze |
| Medium | Fix before mainnet or provide written risk acceptance | 30 days |
| Low / Info | Fix in next release cycle or accept with documentation | 90 days |

---

## Re-Audit Requirements

A re-audit **must** be performed after any of the following changes:

- Remediation of any Critical or High finding
- Changes to access control logic (mint authorization, anchor signer, admin roles)
- Changes to token supply calculations or burn mechanics
- Changes to voting mechanics or quorum/threshold logic
- Addition of new entry points to any in-scope contract
- Upgrade to a new major version of the Soroban SDK

> When re-audit is triggered, create a new entry in `docs/audits/reaudit-YYYY-MM.md`
> and link it from this file.

---

## Published Reports

| Version | Date | Firm | Scope | Link |
|---------|------|------|-------|------|
| — | — | — | — | Pending first audit |

Reports will be published in this directory as `audit-YYYY-MM-<firm-slug>.pdf`
once received and approved for disclosure.
Loading