diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml new file mode 100644 index 0000000..6b15fb3 --- /dev/null +++ b/.github/workflows/a11y.yml @@ -0,0 +1,35 @@ +name: Accessibility + +on: + pull_request: + paths: + - "frontend/**" + - ".github/workflows/a11y.yml" + +jobs: + axe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + working-directory: frontend + + - name: Build + run: npm run build + working-directory: frontend + + - name: Install Playwright + axe + run: | + npm install --save-dev @playwright/test@1.44.1 @axe-core/playwright@4.9.1 + npx playwright install --with-deps chromium + working-directory: frontend + + - name: Run axe checks + run: npx playwright test a11y --reporter=list + working-directory: frontend diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..97da6fc --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,47 @@ +name: Fuzz + +on: + pull_request: + paths: + - "contracts/strategies/blend_leverage/src/leverage.rs" + - "contracts/strategies/blend_leverage/fuzz/**" + - ".github/workflows/fuzz.yml" + schedule: + # Nightly at 02:00 UTC + - cron: "0 2 * * *" + workflow_dispatch: + inputs: + duration: + description: "Fuzz duration in seconds" + default: "300" + +jobs: + fuzz: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@nightly + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + + - name: Short fuzz (PR / manual) + if: github.event_name != 'schedule' + run: | + SECS="${{ github.event.inputs.duration || '30' }}" + cargo fuzz run fuzz_leverage -- -max_total_time="$SECS" -max_len=25 + working-directory: contracts/strategies/blend_leverage + + - name: Nightly fuzz (longer) + if: github.event_name == 'schedule' + run: cargo fuzz run fuzz_leverage -- -max_total_time=600 -max_len=25 + working-directory: contracts/strategies/blend_leverage + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashes + path: contracts/strategies/blend_leverage/fuzz/artifacts/fuzz_leverage/ + if-no-files-found: ignore diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 5cb9e18..b558c24 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -23,6 +23,6 @@ jobs: - name: Build rate_calc Rust binary run: cargo build --bin rate_calc - - name: Run Parity Tests + - name: Run Parity + Snapshot Tests working-directory: ./frontend run: npm run test diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..47e6fc2 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,83 @@ +name: PR Preview Deploy + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - name: frontend + dir: frontend + project: turbolong-frontend + - name: landing + dir: landing + project: turbolong-landing + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build ${{ matrix.name }} + if: matrix.name == 'frontend' + run: npm install && npm run build + working-directory: ${{ matrix.dir }} + + # landing is static — no build step needed + - name: Set deploy dir + id: dirs + run: | + if [ "${{ matrix.name }}" = "frontend" ]; then + echo "dist=${{ matrix.dir }}/dist" >> "$GITHUB_OUTPUT" + else + echo "dist=${{ matrix.dir }}" >> "$GITHUB_OUTPUT" + fi + + - name: Deploy to Cloudflare Pages + id: cf + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CF_API_TOKEN }} + accountId: ${{ secrets.CF_ACCOUNT_ID }} + command: pages deploy ${{ steps.dirs.outputs.dist }} --project-name=${{ matrix.project }} --branch=pr-${{ github.event.pull_request.number }} + + - name: Post preview URL comment + uses: actions/github-script@v7 + with: + script: | + const name = '${{ matrix.name }}'; + const url = '${{ steps.cf.outputs.deployment-url }}'; + const marker = ``; + const body = `${marker}\n**${name} preview** → ${url}`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/contracts/strategies/blend_leverage/fuzz/Cargo.toml b/contracts/strategies/blend_leverage/fuzz/Cargo.toml new file mode 100644 index 0000000..583bcb5 --- /dev/null +++ b/contracts/strategies/blend_leverage/fuzz/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "blend_leverage_fuzz" +version = "0.0.1" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.blend_leverage_strategy] +path = ".." +features = [] + +# Disable default features to avoid soroban-sdk's wasm target requirement +[profile.release] +opt-level = 3 +debug = false +overflow-checks = true + +[[bin]] +name = "fuzz_leverage" +path = "fuzz_targets/fuzz_leverage.rs" +test = false +doc = false diff --git a/contracts/strategies/blend_leverage/fuzz/fuzz_targets/fuzz_leverage.rs b/contracts/strategies/blend_leverage/fuzz/fuzz_targets/fuzz_leverage.rs new file mode 100644 index 0000000..d625b83 --- /dev/null +++ b/contracts/strategies/blend_leverage/fuzz/fuzz_targets/fuzz_leverage.rs @@ -0,0 +1,110 @@ +//! Fuzz target for `compute_step` and `compute_totals`. +//! +//! Properties verified: +//! 1. No panic at any plausible input. +//! 2. No arithmetic overflow (checked_mul / checked_add used internally). +//! 3. compute_totals == manual accumulation of compute_step. +//! 4. total_supply >= total_borrow always. +//! 5. Final step always has borrow == 0. + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// ── Inlined from contracts/strategies/blend_leverage/src/leverage.rs ───────── +// (The contract crate is cdylib + no_std; we copy the two pure functions here +// so the fuzzer can link against them without the soroban-sdk wasm machinery.) + +const SCALAR_7: i128 = 10_000_000; + +#[inline] +fn compute_step(balance: i128, c_factor: i128, is_final: bool) -> (i128, i128) { + if is_final { + (balance, 0) + } else { + let borrow = balance.checked_mul(c_factor).unwrap_or(0) / SCALAR_7; + (balance, borrow) + } +} + +fn loop_step_count(n_loops: u32) -> u32 { + (n_loops + 1).min(21) +} + +fn compute_totals(initial_amount: i128, c_factor: i128, n_loops: u32) -> (i128, i128) { + let count = loop_step_count(n_loops); + let mut total_supply = 0i128; + let mut total_borrow = 0i128; + let mut balance = initial_amount; + + for i in 0..count { + let is_final = i == n_loops.min(20); + let (s, b) = compute_step(balance, c_factor, is_final); + total_supply = total_supply.checked_add(s).unwrap_or(total_supply); + total_borrow = total_borrow.checked_add(b).unwrap_or(total_borrow); + balance = b; + } + (total_supply, total_borrow) +} + +// ── Fuzz entry point ────────────────────────────────────────────────────────── + +fuzz_target!(|data: &[u8]| { + if data.len() < 17 { + return; + } + + // Decode inputs from raw bytes + let initial_amount = i128::from_le_bytes(data[0..16].try_into().unwrap()); + let n_loops_raw = data[16]; + + // Constrain to plausible ranges: + // initial_amount: 1 .. 10^15 (up to ~100M tokens at 7 decimals) + // c_factor: 0 .. SCALAR_7 (0%–100% collateral factor) + // n_loops: 0 .. 20 + let initial_amount = initial_amount.abs() % 1_000_000_000_000_000i128 + 1; + let c_factor = if data.len() >= 25 { + i64::from_le_bytes(data[17..25].try_into().unwrap()).unsigned_abs() as i128 % (SCALAR_7 + 1) + } else { + 5_000_000i128 // 50% default + }; + let n_loops: u32 = (n_loops_raw % 21) as u32; + + // ── Property 1: compute_step never panics ───────────────────────────── + let (s0, b0) = compute_step(initial_amount, c_factor, false); + let (sf, _) = compute_step(initial_amount, c_factor, true); + + // ── Property 2: final step borrow == 0 ─────────────────────────────── + assert_eq!(sf, initial_amount); + let _ = b0; // suppress unused warning; value is checked implicitly + + // ── Property 3: compute_totals never panics ─────────────────────────── + let (total_supply, total_borrow) = compute_totals(initial_amount, c_factor, n_loops); + + // ── Property 4: total_supply >= total_borrow ────────────────────────── + assert!( + total_supply >= total_borrow, + "supply {total_supply} < borrow {total_borrow} (init={initial_amount} c={c_factor} n={n_loops})" + ); + + // ── Property 5: totals match manual step accumulation ───────────────── + let count = loop_step_count(n_loops); + let mut manual_supply = 0i128; + let mut manual_borrow = 0i128; + let mut bal = initial_amount; + for i in 0..count { + let is_final = i == n_loops.min(20); + let (s, b) = compute_step(bal, c_factor, is_final); + manual_supply = manual_supply.checked_add(s).unwrap_or(manual_supply); + manual_borrow = manual_borrow.checked_add(b).unwrap_or(manual_borrow); + bal = b; + } + assert_eq!(total_supply, manual_supply); + assert_eq!(total_borrow, manual_borrow); + + // ── Property 6: non-negative outputs ───────────────────────────────── + assert!(s0 >= 0); + assert!(b0 >= 0); + assert!(total_supply >= 0); + assert!(total_borrow >= 0); +}); diff --git a/docs/preview-deploys.md b/docs/preview-deploys.md new file mode 100644 index 0000000..c67de89 --- /dev/null +++ b/docs/preview-deploys.md @@ -0,0 +1,44 @@ +# PR Preview Deploys + +Every pull request automatically deploys both `frontend` and `landing` to +Cloudflare Pages. The platform bot posts a comment with the preview URLs as +soon as each deploy finishes. + +## How it works + +`.github/workflows/preview.yml` runs on every PR: + +1. Builds `frontend/` with Vite (same as production). +2. Deploys `frontend/dist` and `landing/` to their respective Cloudflare Pages + projects on a branch named `pr-`. +3. Posts (or updates) a comment on the PR with the preview URL. + +## Required secrets + +Add these in **Settings → Secrets and variables → Actions** of the repository: + +| Secret | Where to get it | +|---|---| +| `CF_API_TOKEN` | Cloudflare dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Workers" template (scope: Pages) | +| `CF_ACCOUNT_ID` | Cloudflare dashboard → right sidebar on any zone page, or `https://dash.cloudflare.com/` | + +The token needs the **Cloudflare Pages: Edit** permission for both projects. + +## Cloudflare Pages projects + +Create two projects once (they can be empty — the workflow pushes to them): + +``` +npx wrangler pages project create turbolong-frontend +npx wrangler pages project create turbolong-landing +``` + +Or create them via the Cloudflare dashboard (Workers & Pages → Create → Pages). + +## Preview URL format + +``` +https://pr-..pages.dev +``` + +Previews are retained by Cloudflare for 30 days after the branch is deleted. diff --git a/frontend/index.html b/frontend/index.html index f904f23..8320996 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,6 +11,8 @@ + Skip to main content +