Skip to content
Open
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
35 changes: 35 additions & 0 deletions .github/workflows/a11y.yml
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
83 changes: 83 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -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 = `<!-- preview-${name} -->`;
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,
});
}
27 changes: 27 additions & 0 deletions contracts/strategies/blend_leverage/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions contracts/strategies/blend_leverage/fuzz/fuzz_targets/fuzz_leverage.rs
Original file line number Diff line number Diff line change
@@ -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);
});
44 changes: 44 additions & 0 deletions docs/preview-deploys.md
Original file line number Diff line number Diff line change
@@ -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-<number>`.
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/<account-id>` |

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-<number>.<project>.pages.dev
```

Previews are retained by Cloudflare for 30 days after the branch is deleted.
8 changes: 5 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<a href="#main-content" class="skip-to-content">Skip to main content</a>

<!-- Disclaimer modal -->
<div id="disclaimer-overlay" class="disclaimer-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="disclaimer-title">
<div class="disclaimer-modal">
Expand Down Expand Up @@ -70,7 +72,7 @@ <h2 id="disclaimer-title">Important Disclaimer</h2>
</nav>

<!-- Legacy sidebar for mobile drawer -->
<aside class="sidebar">
<aside class="sidebar" role="dialog" aria-modal="true" aria-label="Navigation menu" aria-hidden="true">
<div class="sidebar-top">
<div class="logo">
<img src="logo.svg" alt="Turbolong" class="logo-icon-img" />
Expand Down Expand Up @@ -138,7 +140,7 @@ <h2 id="disclaimer-title">Important Disclaimer</h2>
</div>
</div>

<main>
<main id="main-content">

<!-- Landing page (connect prompt) -->
<div id="connect-prompt" class="connect-prompt">
Expand Down Expand Up @@ -391,7 +393,7 @@ <h2 id="action-card-title">Open Position</h2>
</div>

<div class="form-group">
<label id="leverage-label">Leverage <span class="tooltip" data-tip="Multiplier on your deposit. Higher leverage amplifies both yield and liquidation risk.">?</span></label>
<label id="leverage-label" for="leverage-slider">Leverage <span class="tooltip" data-tip="Multiplier on your deposit. Higher leverage amplifies both yield and liquidation risk.">?</span></label>
<div class="slider-row">
<input type="range" id="leverage-slider" min="1.1" max="12.9" step="0.1" value="2.0" class="slider" />
<input type="number" id="leverage-input" class="input mono leverage-num-input" min="1.1" max="12.9" step="0.1" value="2.0" />
Expand Down
Loading