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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,35 @@ jobs:
- name: cargo check --target wasm32v1-none (contracts)
run: cargo check ${{ env.CONTRACTS }} --target wasm32v1-none

wasm-size:
name: WASM size budget + optimized-binary exec gate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain with wasm target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32v1-none
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Install binaryen (wasm-opt) — pinned to the budget baseline
# Budgets in scripts/wasm_size_check.sh were measured with binaryen
# 130; distro packages lag and older wasm-opt produces measurably
# larger output (apt's binaryen exceeded every budget by ~5-15%).
run: |
curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz | tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Optimize WASM (-Oz) and enforce size budgets
run: make optimize && bash scripts/wasm_size_check.sh
- name: Execute optimized campaign wasm in the Soroban host VM
run: |
OPTIMIZED_WASM=$PWD/target/wasm32v1-none/release/orbitchain_campaign.optimized.wasm \
cargo test -p orbitchain-campaign --test optimized_wasm_exec

mobile-wallet-e2e:
name: Mobile wallet deep-link (Playwright)
runs-on: ubuntu-latest
Expand Down
41 changes: 32 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
## make fmt - Format code
## make clippy - Lint code

.PHONY: build build-wasm build-tools test fmt lint clean optimize help e2e \
.PHONY: build build-wasm build-tools test fmt lint clean optimize optimize-verify help e2e \
setup deploy-testnet deploy-sandbox sandbox-start audit deny fuzz

# Default target
Expand Down Expand Up @@ -70,12 +70,12 @@ sandbox-start:
@echo " RPC endpoint: http://localhost:8000/soroban/rpc"

# Deploy to local sandbox
deploy-sandbox: build-wasm
deploy-sandbox: optimize
@echo "🚀 Deploying to local sandbox..."
bash scripts/deploy.sh sandbox

# Deploy to Stellar testnet
deploy-testnet: build-wasm
deploy-testnet: optimize
@echo "🚀 Deploying to testnet..."
bash scripts/deploy.sh testnet

Expand Down Expand Up @@ -109,11 +109,34 @@ fuzz:
@cd fuzz && cargo fuzz run fuzz_claim_refund -- -max_total_time=60 || true
@echo "✅ Fuzz smoke tests complete"

# Optimize WASM binaries using wasm-opt (-Oz)
optimize: build
@echo "🔧 Optimizing WASM binaries with wasm-opt..."
@for wasm in target/wasm32v1-none/release/*.wasm; do before=$$(wc -c < "$$wasm"); wasm-opt -Oz "$$wasm" -o "$$wasm.opt" && mv "$$wasm.opt" "$$wasm"; after=$$(wc -c < "$$wasm"); echo " $$(basename $$wasm): $${before}B -> $${after}B"; done
@echo "✅ Optimization complete"
# Optimize WASM binaries using wasm-opt (-Oz).
#
# Issue #117 – writes each artifact to <name>.optimized.wasm instead of
# overwriting cargo's output in place: cargo does not fingerprint its own
# artifacts, so an in-place overwrite silently re-optimizes already-optimized
# files on the next run and reports misleading deltas.
optimize: build-wasm
@echo "🔧 Optimizing WASM binaries with wasm-opt (-Oz)..."
@for wasm in target/wasm32v1-none/release/*.wasm; do \
case "$$wasm" in *.optimized.wasm) continue;; esac; \
out="$${wasm%.wasm}.optimized.wasm"; \
before=$$(wc -c < "$$wasm" | tr -d ' '); \
wasm-opt -Oz "$$wasm" -o "$$out"; \
after=$$(wc -c < "$$out" | tr -d ' '); \
pct=$$(awk "BEGIN{printf \"%.1f\", 100*($$before-$$after)/$$before}"); \
echo " $$(basename $$wasm): $${before}B -> $${after}B (-$${pct}%)"; \
done
@echo "✅ Optimization complete (artifacts: *.optimized.wasm)"

# Issue #117 – size + functional regression gate for the optimized binaries:
# budget check via scripts/wasm_size_check.sh, then the campaign lifecycle
# executed inside the Soroban host VM against the wasm-opt output.
optimize-verify: optimize
@bash scripts/wasm_size_check.sh
@echo "🧪 Executing optimized campaign wasm in the Soroban host VM..."
OPTIMIZED_WASM=$(CURDIR)/target/wasm32v1-none/release/orbitchain_campaign.optimized.wasm \
cargo test -p orbitchain-campaign --test optimized_wasm_exec
@echo "✅ Optimized WASM verified"

# Show help
help:
Expand All @@ -122,7 +145,7 @@ help:
@echo " make build - Build WASM contract and CLI tools"
@echo " make build-wasm - Build Soroban WASM contract only"
@echo " make build-tools - Build CLI tools only"
@echo " make test - Run all tests"
@echo " make test - Run all tests"\n @echo " make optimize - Build + shrink WASM with wasm-opt -Oz (issue #117)"\n @echo " make optimize-verify - Optimize, enforce size budgets, exec-test the output"
@echo " make fmt - Format code"
@echo " make lint - Run linter"
@echo " make clean - Clean build artifacts"
Expand Down
101 changes: 101 additions & 0 deletions campaign/tests/optimized_wasm_exec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! Issue #117 – Functional regression gate for the wasm-opt'd binary.
//!
//! Loads the OPTIMIZED wasm (not the Rust-compiled native code) into the
//! Soroban host VM and drives a full campaign lifecycle through it. If
//! `wasm-opt` ever miscompiles or strips something the host needs, this
//! fails loudly instead of surfacing on-chain.
//!
//! Run via `make optimize-verify` (which builds + optimizes + points
//! `OPTIMIZED_WASM` at the artifact). Skipped when the env var is unset so
//! plain `cargo test` stays green without binaryen installed.

// `register_contract_wasm` is the SDK 26 deprecated-but-stable v1 test API;
// the crate root carries the same blanket allow for the v1 surfaces.
#![allow(deprecated)]

use orbitchain_campaign::types::{MilestoneData, MilestoneStatus, StellarAsset};
use soroban_sdk::testutils::{Address as _, Ledger};
use soroban_sdk::{vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec};

fn optimized_wasm() -> Option<std::vec::Vec<u8>> {
let path = std::env::var("OPTIMIZED_WASM").ok()?;
Some(std::fs::read(path).expect("OPTIMIZED_WASM path unreadable"))
}

#[test]
fn optimized_wasm_runs_full_campaign_lifecycle() {
let Some(wasm) = optimized_wasm() else {
eprintln!("OPTIMIZED_WASM not set; skipping optimized-wasm execution gate");
return;
};

let env = Env::default();
env.ledger().set_timestamp(86400 * 365);
env.mock_all_auths();

// Register the *optimized wasm bytes* — invocations below run inside the
// host VM against the wasm-opt output, not against native Rust.
let contract_id = env.register_contract_wasm(None, wasm.as_slice());

// hello() — cheapest possible execution proof.
let hello: Symbol = env.invoke_contract(
&contract_id,
&Symbol::new(&env, "hello"),
Vec::<Val>::new(&env),
);
assert_eq!(hello, Symbol::new(&env, "campaign"));

// version() — exported const round-trips.
let version: u32 = env.invoke_contract(
&contract_id,
&Symbol::new(&env, "version"),
Vec::<Val>::new(&env),
);
assert_eq!(version, 1);

// initialize() — struct/enum arg decoding through the optimized module.
let creator = Address::generate(&env);
let end_time: u64 = env.ledger().timestamp() + 100_000;
let goal: i128 = 1000;
// Use the crate's own contracttypes so the host encoding (maps keyed by
// field name) is exactly what the deployed module expects.
let assets = vec![
&env,
StellarAsset {
asset_code: soroban_sdk::String::from_str(&env, "XLM"),
issuer: Some(Address::generate(&env)),
},
];
let milestones = vec![
&env,
MilestoneData {
index: 0,
target_amount: goal,
released_amount: 0,
description_hash: BytesN::from_array(&env, &[0u8; 32]),
status: MilestoneStatus::Locked,
released_at: None,
released_at_ledger: None,
release_tx: None,
released_to: None,
},
];
let args: Vec<Val> = vec![
&env,
creator.into_val(&env),
goal.into_val(&env),
end_time.into_val(&env),
assets.into_val(&env),
milestones.into_val(&env),
0i128.into_val(&env),
];
let _: Val = env.invoke_contract(&contract_id, &Symbol::new(&env, "initialize"), args);

// Read back through a view to prove storage round-trips.
let status: Val = env.invoke_contract(
&contract_id,
&Symbol::new(&env, "get_campaign_status"),
Vec::<Val>::new(&env),
);
let _ = status;
}
73 changes: 73 additions & 0 deletions docs/wasm-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# WASM binary size — measurement, optimization, and regression gating

Issue #117. How the contract binaries are shrunk, how the reduction is
verified to be safe, and what was measured along the way.

## Results

`wasm-opt -Oz` (binaryen 130 — the version is pinned in CI, since older
distro-packaged binaryen produces measurably larger output) over the
`opt-level="z"` + fat-LTO cargo output:

| Contract | cargo output | after `wasm-opt -Oz` | reduction |
|---|---:|---:|---:|
| `orbitchain_campaign` | 85,322 B | 63,758 B | **−25.3%** |
| `orbitchain_core` | 38,767 B | 25,919 B | −33.1% |
| `orbitchain_batch_donor` | 18,072 B | 13,260 B | −26.6% |
| `orbitchain_token_bridge` | 1,454 B | 1,010 B | −30.5% |
| `orbitchain_common` | 817 B | 791 B | −3.2% |

All three Soroban custom sections (`contractspecv0`, `contractenvmetav0`,
`contractmetav0`) survive optimization intact — the spec remains readable by
`stellar contract info interface`.

## How to run

```bash
make optimize # build + wasm-opt → *.optimized.wasm (non-destructive)
make optimize-verify # optimize + size budgets + host-VM execution gate
```

`make deploy-sandbox` / `make deploy-testnet` now run `optimize` first and
`scripts/deploy.sh` installs the `.optimized.wasm` artifact, so the size win
actually reaches the chain instead of living only in a Makefile target.

## Why "without measurable regression" is enforced, not assumed

Two gates run in `make optimize-verify` and in the `wasm-size` CI job:

1. **Size budgets** (`scripts/wasm_size_check.sh`) — each optimized binary is
compared against a checked-in byte budget (measured baseline + ~5%
headroom). A regression that erases the win — or a broken `wasm-opt`
invocation, which typically shows up as a near-unoptimized size — fails CI.
Budgets are raised deliberately, in the PR that grows the contract.
2. **Host-VM execution** (`campaign/tests/optimized_wasm_exec.rs`) — the
*optimized* campaign binary is loaded into the Soroban host VM and driven
through `hello` → `version` → `initialize` (real `contracttype` struct
arguments) → a storage-backed view. If `wasm-opt` ever miscompiles or
strips something the host needs, this fails in CI instead of on-chain.

## What was measured and rejected: `#[inline]` annotations

The issue proposed targeted `#[inline]`/`#[inline(never)]` annotations. This
was tried and measured before being rejected:

- `#[inline(never)]` on the 13 hottest storage helpers (30+ call sites each)
plus `#[cold]`/`#[inline(never)]` on the panic helper produced a
**byte-identical binary** — same size, same MD5 — as the unannotated build.
- Gating the `#[derive(Debug)]`s behind `cfg(test)` (to drop ~2.7 KB of
`core::fmt` machinery) produced **85,330 bytes vs 85,322 baseline**: a net
zero. The `fmt` code is retained via SDK-internal vtables, not our derives.

Measurement integrity was verified with a canary: changing an exported
constant flips the build's MD5 and reverting restores the *exact* original
hash, so builds are deterministic and the toolchain demonstrably recompiled
between measurements. The nulls are real: at `opt-level="z"` with fat LTO and
`codegen-units=1`, LLVM already makes these inlining decisions optimally, and
annotations only add maintenance surface. The honest lever at the toolchain
level is `wasm-opt`, which is also what `stellar contract optimize` wraps.

Remaining size, for future reference: after optimization the binary is ~53%
code, ~38% `contractspecv0`. The spec section embeds every exported doc
comment; trimming docs would shrink it but is a documentation-quality trade,
not a code-size optimization, and is deliberately not done here.
2 changes: 1 addition & 1 deletion scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ esac

# ── Paths ─────────────────────────────────────────────────────────────────────
WASM_PATH="target/wasm32v1-none/release/orbitchain_core.wasm"
OPTIMIZED_WASM_PATH="target/wasm32v1-none/release/orbitchain_core.wasm"
OPTIMIZED_WASM_PATH="target/wasm32v1-none/release/orbitchain_core.optimized.wasm"
DEPLOYMENTS_DIR="deployments"
DEPLOYMENT_FILE="${DEPLOYMENTS_DIR}/${NETWORK}.json"

Expand Down
55 changes: 55 additions & 0 deletions scripts/wasm_size_check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Issue #117 – WASM size regression baseline.
#
# Compares each wasm-opt'd contract against a checked-in byte budget and fails
# if any exceeds it. Budgets are the measured optimized sizes at the time this
# gate landed plus ~5% headroom, so ordinary growth fits but a regression that
# would erase the optimization win (or a broken wasm-opt invocation, which
# typically shows up as a near-unoptimized size) fails loudly.
#
# Measured baselines (wasm-opt 130, -Oz, 2026-07-29):
# orbitchain_campaign 85322 -> 63758 (-25.3%)
# orbitchain_core 38767 -> 25919 (-33.1%)
# orbitchain_batch_donor 18072 -> 13260 (-26.6%)
# orbitchain_token_bridge 1454 -> 1010 (-30.5%)
# orbitchain_common 817 -> 791 ( -3.2%)
#
# To raise a budget intentionally, change it here in the same PR that grows
# the contract, so the growth is visible in review.
set -euo pipefail

DIR="target/wasm32v1-none/release"

# Budgets were measured with binaryen 130 — older wasm-opt produces larger
# output and will trip them. CI pins the version; report it for debugging.
echo "ℹ️ $(wasm-opt --version)"

check() {
local name="$1" budget="$2"
local f="$DIR/${name}.optimized.wasm"
if [[ ! -f "$f" ]]; then
echo "❌ $f missing — run 'make optimize' first"
exit 1
fi
local size
size=$(wc -c < "$f" | tr -d ' ')
if (( size > budget )); then
echo "❌ ${name}: ${size}B exceeds budget ${budget}B"
FAILED=1
else
printf "✅ %-28s %7dB (budget %7dB)\n" "$name" "$size" "$budget"
fi
}

FAILED=0
check orbitchain_campaign 67000
check orbitchain_core 27500
check orbitchain_batch_donor 14000
check orbitchain_token_bridge 1100
check orbitchain_common 850

if (( FAILED )); then
echo "❌ WASM size budget exceeded — see docs/wasm-size.md"
exit 1
fi
echo "✅ All optimized WASM binaries within budget"
Loading