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
92 changes: 92 additions & 0 deletions .github/workflows/CI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ on:
branches: [main]
pull_request:
branches: [main]
# Advisories are published continuously, so a lockfile that was clean at
# merge time can become vulnerable without anything in the repo changing.
# This re-runs the audit weekly against the committed Cargo.lock.
schedule:
- cron: "0 6 * * 1"

env:
CARGO_TERM_COLOR: always
Expand Down Expand Up @@ -160,6 +165,93 @@ jobs:
if-no-files-found: error
retention-days: 14

security:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable

# The advisory database is a git clone of ~200MB of history; caching it
# keyed on the week keeps the job to a few seconds on most runs while
# still refreshing regularly.
- name: Cache advisory database
uses: actions/cache@v4
with:
path: ~/.cargo/advisory-db
key: advisory-db-${{ github.run_id }}
restore-keys: advisory-db-

- name: Cache audit tooling
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/cargo-audit
~/.cargo/bin/cargo-deny
key: ${{ runner.os }}-audit-tools-v1

- name: Install cargo-audit and cargo-deny
run: |
command -v cargo-audit >/dev/null || cargo install --locked cargo-audit
command -v cargo-deny >/dev/null || cargo install --locked cargo-deny

# Gates on actual vulnerabilities. Deliberately not --deny warnings:
# that promotes *yanked* crates to errors, and soroban-sdk pins yanked
# versions of spin and keccak inside its own tree that no cargo update
# can resolve. Yanked and unmaintained advisories are handled by cargo
# deny below, where deny.toml records each one with a reason and a review
# date rather than failing the build indefinitely.
- name: cargo audit
run: cargo audit --file Cargo.lock

# Covers advisories, the license allowlist, duplicate versions, and
# source registries. See contracts/deny.toml for the triage process.
- name: cargo deny
run: cargo deny --all-features check

coverage:
name: Code Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: llvm-tools-preview
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
contracts/target
key: ${{ runner.os }}-cargo-cov-${{ hashFiles('contracts/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-cov-
${{ runner.os }}-cargo-
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Generate coverage
run: cargo llvm-cov --workspace --lcov --output-path lcov.info

# Enforces the per-crate floor as well as the workspace threshold. A
# workspace average hides a crate with no tests at all, which is exactly
# how multisig_transfer reached 755 lines with zero coverage.
- name: Check coverage thresholds
run: ./scripts/check-coverage.sh

- name: Upload lcov report
uses: actions/upload-artifact@v4
with:
name: coverage-lcov
path: contracts/lcov.info
retention-days: 14

# ─────────────────────────────────────────────
# BACKEND — NestJS
# ─────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions contracts/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ test_snapshots/
# deployments/testnet.example.json documents the shape.
deployments/*
!deployments/*.example.json
# Coverage reports produced by cargo-llvm-cov.
lcov.info
142 changes: 142 additions & 0 deletions contracts/BUILD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Build profiles, WASM size, and quality gates

## Release profile

Soroban charges for resource usage and enforces a size limit on deployed
contracts, so the release profile in [`Cargo.toml`](Cargo.toml) is tuned for the
smallest artifact that is still safe to run.

| Setting | Value | Why |
|---|---|---|
| `opt-level` | `"z"` | Optimize for size rather than speed. |
| `lto` | `true` | Cross-crate inlining and dead-code elimination. |
| `codegen-units` | `1` | Lets LLVM see the whole crate at once. |
| `strip` | `"symbols"` | Symbol names are dead weight on-chain. |
| `debug` | `0` | No debug info in a deployed artifact. |
| `panic` | `"abort"` | Unwinding tables are unusable in Soroban. |
| `overflow-checks` | `true` | **Kept deliberately** — see below. |

### Why `overflow-checks` stays on

It costs size and gas. These contracts move asset value, share counts, and
dividend splits, where a silent wraparound is far worse than a trapped
transaction. The trap is not the end state we want either — converting these
into typed errors is tracked in [SC-43] — but until then, trapping beats
wrapping.

### `release-with-logs`

A second profile inherits `release` and re-enables `debug-assertions`, for
debugging on testnet where a precondition failure should be loud:

```sh
cargo build --workspace --target wasm32-unknown-unknown --profile release-with-logs
```

Never deploy this to mainnet. Debug assertions change failure behaviour and add
size.

## Measured WASM size

Built with `cargo build --workspace --target wasm32-unknown-unknown --release`,
before any `stellar contract optimize` pass.

| Contract | Before | After | Change |
|---|---:|---:|---:|
| `assetsup` | 162,775 | 103,140 | **−36.6%** |
| `contrib` | 79,282 | 47,758 | **−39.8%** |
| `multisig_wallet` | 74,214 | 43,157 | **−41.8%** |
| `asset_maintenance` | 65,756 | 23,421 | **−64.4%** |
| `multisig_transfer` | 58,609 | 31,707 | **−45.9%** |
| **Total** | **440,636** | **249,183** | **−43.4%** |

`asset-maintenance` gains the most because it was carrying the most
monomorphized code that `lto` could collapse. `assetsup` remains the largest by
a wide margin and is the crate to watch against the deployment limit — it is
the subject of the split assessment in [SC-46].

These are the baselines. Tracking size per PR and enforcing a budget is
[SC-52].

All 298 tests pass unchanged under `panic = "abort"`.

## Quality gates in CI

| Job | Command | Gate |
|---|---|---|
| Format Check | `cargo fmt --all -- --check` | Fails on any formatting drift. |
| Clippy Lint | `cargo clippy --all-targets --all-features -- -D warnings` | Warnings are errors. |
| Test Suite | `cargo test --all` | All tests must pass. |
| Build Check | `cargo build --all` | Native build. |
| Build WASM | `cargo build --package contrib --target wasm32-unknown-unknown --release` | Deployability. Extending this to all contracts is [SC-34]. |
| Dependency Audit | `cargo audit`, `cargo deny check` | Advisories, licences, sources. |
| Code Coverage | `cargo llvm-cov` + `scripts/check-coverage.sh` | Workspace and per-crate floors. |

### Dependency audit

[`deny.toml`](deny.toml) configures four checks: advisories, a permissive
licence allowlist, duplicate-version detection, and a crates.io-only source
policy. The job also runs weekly on a schedule, because a lockfile that was
clean at merge time can become vulnerable without anything in the repo
changing.

Run it locally:

```sh
cargo install --locked cargo-deny cargo-audit
cd contracts
cargo deny --all-features check
cargo audit --file Cargo.lock --deny warnings
```

**Triaging an advisory** — the process is documented inline in `deny.toml`.
The short version: determine whether the vulnerable path is reachable from a
contract entrypoint; if it is, fix it rather than ignoring it; if it genuinely
is not, add it to `ignore` with the reasoning and a review date no more than 90
days out.

Two ignores are currently in place, both unmaintained-crate notices for
compile-time proc macros reached through `soroban-sdk` (`derivative`, `paste`).
Neither puts code into a deployed contract.

Yanked crates are set to `warn` rather than `deny`: `soroban-sdk` 22 pins yanked
versions of `spin` and `keccak` inside its own tree, unresolvable from this
workspace by any `cargo update`. Denying would mean a permanently red job that
everyone learns to ignore. The SDK upgrade in [SC-35] is expected to clear both.

### Coverage

```sh
cargo install --locked cargo-llvm-cov
cd contracts
cargo llvm-cov --workspace --lcov --output-path lcov.info
./scripts/check-coverage.sh
```

Two gates, and the second is the one that matters:

1. **Workspace floor** — currently 65%.
2. **Per-crate floor** — a workspace average happily hides a crate with no
tests behind well-tested siblings, which is exactly how `multisig_transfer`
reached 755 lines with zero tests unnoticed.

Measured when this landed:

| Crate | Coverage | Floor |
|---|---:|---:|
| `multisig-wallet` | 94% | 90% |
| `asset-maintenance` | 76% | 70% |
| `assetsup` | 74% | 70% |
| `contrib` | 38% | 35% |
| `multisig_transfer` | 0% | exempt |
| **Workspace** | **67%** | **65%** |

Floors sit a few points under the measured value so ordinary churn does not
fail the build while a real regression does. Ratchet them upward as coverage
improves.

Any crate at zero coverage **fails**, unless it is named in the
`ZERO_COVERAGE_EXEMPT` list in the script with its tracking issue.
`multisig_transfer` is the only entry, pending [SC-32]. That keeps the build
green for a known, tracked hole while still failing for any new crate that
arrives without tests — removing the entry is how the gate gets tightened.
24 changes: 11 additions & 13 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ members = [
[workspace.dependencies]
soroban-sdk = "23"

# Deployable WASM is charged for by size and subject to a ledger size limit, so
# the release profile is tuned for the smallest artifact that is still safe.
[profile.release]
opt-level = "z"
overflow-checks = true
opt-level = "z" # optimize for size over speed
lto = true # cross-crate inlining and dead code elimination
codegen-units = 1 # one unit lets LLVM see the whole crate at once
strip = "symbols" # symbol names are dead weight on-chain
debug = 0 # no debug info in a deployed artifact
panic = "abort" # unwinding tables are unusable in Soroban anyway
# Kept deliberately. Overflow checks cost a little size and gas, but these
# contracts move asset value and share counts; a silent wrap is worse than a
# trapped transaction. Converting the traps into typed errors is [SC-43].
overflow-checks = true

# Same size profile, but with debug assertions left on so a testnet deployment
# reports precondition failures instead of silently misbehaving. Never use for
# mainnet: debug assertions change failure behaviour and add size.
[profile.release-with-logs]
inherits = "release"
debug-assertions = true
2 changes: 2 additions & 0 deletions contracts/asset-maintenance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
name = "asset-maintenance"
version = "0.1.0"
edition = "2021"
# Deployed as WASM to Stellar, never published to crates.io.
publish = false

[lib]
crate-type = ["cdylib"]
Expand Down
2 changes: 2 additions & 0 deletions contracts/assetsup/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
name = "assetsup"
version = "0.1.0"
edition = "2021"
# Deployed as WASM to Stellar, never published to crates.io.
publish = false

[lib]
crate-type = ["lib", "cdylib"]
Expand Down
2 changes: 2 additions & 0 deletions contracts/contrib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
name = "contrib"
version = "0.1.0"
edition = "2021"
# Deployed as WASM to Stellar, never published to crates.io.
publish = false

[lib]
crate-type = ["lib", "cdylib"]
Expand Down
Loading
Loading