|
| 1 | +# Soroban Vault Agent Guide |
| 2 | + |
| 3 | +This file contains Soroban-specific guidance for future agents working in `contract/vault/soroban`. |
| 4 | + |
| 5 | +## Scope |
| 6 | + |
| 7 | +This directory is the Soroban executor layer for the shared vault kernel plus the closely related |
| 8 | +Soroban-side companion contracts: |
| 9 | + |
| 10 | +- `templar-soroban-runtime` in `contract/vault/soroban` |
| 11 | +- `templar-soroban-governance` in `contract/vault/soroban/governance` |
| 12 | +- `templar-soroban-share-token` in `contract/vault/soroban/share-token` |
| 13 | +- `templar-soroban-blend-adapter` in `contract/vault/soroban/blend-adapter` |
| 14 | +- shared ABI/types in `contract/vault/soroban/shared-types` |
| 15 | +- the Soroban ERC-4626 proxy in `contract/proxy-4626-soroban` |
| 16 | + |
| 17 | +Read these first before making non-trivial changes: |
| 18 | + |
| 19 | +- `contract/vault/soroban/README.md` |
| 20 | +- `contract/vault/soroban/STRIDE.md` |
| 21 | +- `contract/vault/soroban/SIZE_BUDGET.md` |
| 22 | +- `contract/vault/soroban/src/contract/entrypoints.rs` |
| 23 | +- `contract/vault/soroban/src/effects/mod.rs` |
| 24 | +- `contract/vault/soroban/src/storage/mod.rs` |
| 25 | +- `contract/vault/soroban/governance/src/lib.rs` |
| 26 | +- `contract/vault/soroban/share-token/src/lib.rs` |
| 27 | +- `contract/vault/soroban/blend-adapter/src/lib.rs` |
| 28 | +- `contract/vault/soroban/shared-types/src/lib.rs` |
| 29 | + |
| 30 | +## Why This Area Is High Risk |
| 31 | + |
| 32 | +- The runtime is the Soroban execution boundary for the canonical vault kernel state machine. |
| 33 | +- It owns custody, storage serialization, address mapping, auth enforcement, and effect execution. |
| 34 | +- Soroban artifact size is a shipping constraint, not a cleanup task. |
| 35 | +- Governance is split across contracts: timelock/orchestration lives in the governance contract, |
| 36 | + but the runtime still applies the canonical state changes. |
| 37 | + |
| 38 | +## Canonical Invariants |
| 39 | + |
| 40 | +Preserve these unless the architecture is being changed deliberately and the change is documented, |
| 41 | +measured, and re-verified: |
| 42 | + |
| 43 | +- The runtime is the sole canonical owner of custody, `total_assets`, `total_shares`, fee anchor, |
| 44 | + payout settlement, and accepted external-asset accounting state. |
| 45 | +- Governance proposal submission, timelock maturity, approval/revocation, and abdication live in |
| 46 | + the governance contract, but vault-bound mutations are still applied by the runtime via |
| 47 | + `execute_governance(env, caller, payload)`. |
| 48 | +- The share token only allows vault-authorized `mint()` and `burn()`. User transfers still require |
| 49 | + `from.require_auth()`. |
| 50 | +- Read-only preview and getter surfaces must stay non-authoritative. |
| 51 | +- Serialized `VaultState` remains a practical resource boundary because Soroban persists a single |
| 52 | + `StateBlob`. Pending withdrawals are the main long-lived growth vector. |
| 53 | +- `extend_ttl()` is permissionless and is part of the operational liveness model. |
| 54 | + |
| 55 | +## Binary Size Gate |
| 56 | + |
| 57 | +The Soroban runtime deploy artifact must stay at or below `128 KiB` (`131072` bytes). |
| 58 | + |
| 59 | +Use these commands: |
| 60 | + |
| 61 | +- `just -f contract/vault/soroban/justfile build` |
| 62 | +- `just -f contract/vault/soroban/justfile size-budget-check` |
| 63 | +- `just -f contract/vault/soroban/justfile wasm-analyze 250 120` |
| 64 | +- `just -f contract/vault/soroban/justfile wasm-analyze-print all 120` |
| 65 | + |
| 66 | +Important details: |
| 67 | + |
| 68 | +- The size gate checks `target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.deploy.wasm`. |
| 69 | +- `build` also emits `templar_soroban_runtime.optimized.wasm`; the deploy artifact is the |
| 70 | + contractspec-stripped one. |
| 71 | +- `scripts/strip_contractspec.py` typically saves about `7 KiB` by removing `contractspecv0`. |
| 72 | +- The current recovered evidence from `2026-04-23` recorded `137127` bytes, which is `6055` |
| 73 | + bytes over budget. |
| 74 | + |
| 75 | +Common growth pitfalls: |
| 76 | + |
| 77 | +- Duplicate normalization logic at runtime boundaries |
| 78 | +- Extra allocation-heavy helper layers in hot or state-heavy paths |
| 79 | +- Generic-heavy shared helpers and command surfaces that increase monomorphization |
| 80 | +- Public ABI/event/spec changes that pull in more generated metadata |
| 81 | + |
| 82 | +When the size gate fails: |
| 83 | + |
| 84 | +1. Rebuild with `just -f contract/vault/soroban/justfile build`. |
| 85 | +2. Run `just -f contract/vault/soroban/justfile size-budget-check`. |
| 86 | +3. Run `just -f contract/vault/soroban/justfile wasm-analyze 250 120`. |
| 87 | +4. Prefer deleting duplicated logic or narrowing compile surface before introducing new helper |
| 88 | + layers or architectural churn. |
| 89 | +5. Treat low-delta slimming as the default path. Escalate to a topology split only if measured |
| 90 | + evidence still leaves the runtime above `131072` bytes. |
| 91 | + |
| 92 | +## Release WASM Inspection Workflow |
| 93 | + |
| 94 | +Do not stop at `size-budget-check`. When the artifact grows, inspect the built release artifacts |
| 95 | +directly and keep the commands/results in the task notes or PR description. |
| 96 | + |
| 97 | +Release artifacts to inspect after `just -f contract/vault/soroban/justfile build`: |
| 98 | + |
| 99 | +- `target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.wasm` |
| 100 | +- `target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.optimized.wasm` |
| 101 | +- `target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.deploy.wasm` |
| 102 | + |
| 103 | +Recommended workflow: |
| 104 | + |
| 105 | +1. Confirm exact byte sizes. |
| 106 | +2. Inspect section layout with `wasm-objdump`. |
| 107 | +3. Use `twiggy` to find the biggest retained items and dominator chains. |
| 108 | +4. If needed, dump WAT and inspect suspicious symbols, long match arms, repeated helpers, or |
| 109 | + unexpectedly large data/custom sections. |
| 110 | +5. Compare before/after outputs when evaluating a refactor. Do not rely on intuition. |
| 111 | + |
| 112 | +Commands: |
| 113 | + |
| 114 | +- `stat -c '%s %n' target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.wasm target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.optimized.wasm target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.deploy.wasm` |
| 115 | +- `wasm-objdump -h target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.deploy.wasm` |
| 116 | +- `wasm-objdump -x target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.deploy.wasm > /tmp/templar_soroban_runtime.deploy.objdump.txt` |
| 117 | +- `twiggy top target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.wasm -n 80` |
| 118 | +- `twiggy dominators target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.wasm` |
| 119 | +- `twiggy monos target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.wasm` |
| 120 | +- `wasm2wat target/wasm32-unknown-unknown/release-soroban/templar_soroban_runtime.deploy.wasm -o /tmp/templar_soroban_runtime.deploy.wat` |
| 121 | + |
| 122 | +What to look for: |
| 123 | + |
| 124 | +- Large `code` or `data` sections in `wasm-objdump -h` |
| 125 | +- Unexpected custom sections that survived stripping |
| 126 | +- Large monomorphized functions in `twiggy monos` |
| 127 | +- Dominator chains caused by generic-heavy helpers, large enum dispatch, or duplicated conversion |
| 128 | + paths |
| 129 | +- ABI/spec/event-related growth after adding command variants, event payload fields, or public |
| 130 | + methods |
| 131 | +- Helper layers that look small in Rust source but retain large downstream call trees in `twiggy` |
| 132 | + |
| 133 | +Interpretation guidance: |
| 134 | + |
| 135 | +- If `deploy.wasm` grew but `optimized.wasm` did not, check stripping/output-path issues first. |
| 136 | +- If both `optimized.wasm` and `deploy.wasm` grew similarly, inspect retained code shape with |
| 137 | + `twiggy`. |
| 138 | +- If section growth is concentrated in custom/data sections, inspect serialization payloads, |
| 139 | + event/spec metadata, and embedded strings before changing control flow. |
| 140 | +- If code growth is concentrated in a few dominators, attack those first. Small deletions there |
| 141 | + can collapse a large retained subtree. |
| 142 | +- Prefer comparing `twiggy top`, `twiggy dominators`, and `wasm-objdump -h` before and after a |
| 143 | + patch. Size debugging should be evidence-driven. |
| 144 | + |
| 145 | +## Codegen Reduction Tips |
| 146 | + |
| 147 | +Primary reference: |
| 148 | + |
| 149 | +- Rust and WebAssembly code-size guide: <https://rustwasm.github.io/docs/book/reference/code-size.html> |
| 150 | + |
| 151 | +This workspace already uses strong size-oriented release settings in the root `Cargo.toml`: |
| 152 | + |
| 153 | +- `codegen-units = 1` |
| 154 | +- `opt-level = "z"` |
| 155 | +- `lto = "fat"` |
| 156 | +- `panic = "abort"` |
| 157 | +- `strip = "symbols"` |
| 158 | +- Soroban release profile with `overflow-checks = false` |
| 159 | + |
| 160 | +Do not casually undo those. Also remember that Cargo only reads profile settings from the |
| 161 | +workspace-root `Cargo.toml`, not from member crates. |
| 162 | + |
| 163 | +When the runtime bloats, check these levers before reaching for a bigger refactor: |
| 164 | + |
| 165 | +- Measure `opt-level = "s"` versus `opt-level = "z"` for `release-soroban`. Rust’s official docs |
| 166 | + note that `"s"` can sometimes produce a smaller binary than `"z"`. Do not assume `"z"` wins. |
| 167 | +- Keep release builds non-incremental and size-oriented. Incremental compilation trades away some |
| 168 | + optimizations and is not the right mode for artifact measurement. |
| 169 | +- Confirm debug info and symbol/name sections are actually gone before changing source. Cargo’s |
| 170 | + profile docs and the Rust/Wasm guide both note that debug info and the wasm `names` section can |
| 171 | + be major size contributors. In this repo, `debug = false`, `strip = "symbols"`, and the |
| 172 | + optimizer/strip pipeline should already remove them, so unexpected growth here usually means a |
| 173 | + build-path regression. |
| 174 | +- Check for duplicate crate versions with `cargo tree -d`. Cargo’s docs note that avoiding multiple |
| 175 | + versions of the same package can help executable size as well as build time. |
| 176 | +- Inspect feature flow with `cargo tree -e features` and `cargo tree -e features -i <crate>`. |
| 177 | + Cargo feature unification can silently turn on defaults or widen a dependency’s surface far |
| 178 | + beyond what this crate asked for directly. |
| 179 | +- Prune dependency features aggressively. In size-critical crates, prefer `default-features = false` |
| 180 | + when viable, and do not enable convenience features in the runtime unless they are required in |
| 181 | + shipping WASM. |
| 182 | +- Treat every new public method, event field, command variant, and shared-type variant as a size |
| 183 | + decision. Public ABI growth often pulls both more code and more spec metadata into the artifact. |
| 184 | +- Reduce monomorphization pressure. Be suspicious of generic-heavy helper layers, duplicated |
| 185 | + conversion helpers, broad enums, and wrappers that instantiate the same logic across many type |
| 186 | + combinations. |
| 187 | +- Audit inlining decisions explicitly. `#[inline]` and especially `#[inline(always)]` can improve |
| 188 | + speed while making wasm larger by cloning helper bodies into many call sites. In size-critical |
| 189 | + code, do not add inline attributes casually, and re-check existing ones when a function starts |
| 190 | + dominating `twiggy` output. |
| 191 | +- For hot generic helpers used with many concrete types, consider whether a trait-object or |
| 192 | + non-generic boundary would be smaller. The Rust and WebAssembly guidance explicitly calls out |
| 193 | + generic monomorphization as a common source of wasm bloat. Do not change dispatch style blindly; |
| 194 | + measure the tradeoff. |
| 195 | +- Dynamic dispatch is a size lever, not a default style rule. If the same generic helper is being |
| 196 | + instantiated across many concrete types, a trait object like `&dyn Trait` can collapse the code |
| 197 | + to one emitted implementation. The tradeoff is lost specialization and indirect-call overhead. |
| 198 | + Use it only where the call frequency and optimization loss are acceptable. |
| 199 | +- Consider `#[inline(never)]` on cold, reused helpers if `twiggy` or LLVM IR suggests they are |
| 200 | + being inlined into many call sites and inflating the code section. Do this surgically and only |
| 201 | + after measurement; forcing no-inline on hot code can hurt performance and occasionally even block |
| 202 | + other size wins. |
| 203 | +- Avoid string formatting in release-critical runtime paths when static strings or compact error |
| 204 | + codes are sufficient. The Rust and WebAssembly guidance calls out `format!`, `to_string`, and |
| 205 | + related formatting machinery as a common source of code growth. |
| 206 | +- Avoid panicking paths in shipping runtime code when a normal error return will do. Panics and |
| 207 | + panic formatting can retain surprising amounts of code. If an invariant truly cannot fail, keep |
| 208 | + the path minimal and document why. |
| 209 | +- Reuse the existing kernel abort helpers in |
| 210 | + [contract/vault/kernel/src/abort.rs](/data/projects/contracts/contract/vault/kernel/src/abort.rs) |
| 211 | + when you need a documented impossible-path trap on wasm32. In particular, prefer the existing |
| 212 | + `abort!`, `unwrap_abort!`, and `unwrap_abort_result!` macros over introducing fresh panic-heavy |
| 213 | + helpers. These already compile to `core::arch::wasm32::unreachable()` on wasm32 while preserving |
| 214 | + a normal panic in non-wasm test builds. |
| 215 | +- Watch for implicit panics, not just `panic!()`: indexing, division, and `unwrap()` can all pull |
| 216 | + in panic machinery. Prefer `.get()`, checked arithmetic, and explicit error handling where that |
| 217 | + preserves the intended semantics. |
| 218 | +- Prefer one shared implementation over many near-identical helpers, but only if the shared path |
| 219 | + does not introduce a larger generic surface. Measure both shapes if unsure. |
| 220 | +- Be careful with derives and serialization surface area. Extra derived impls and broad serde/postcard |
| 221 | + reachability can retain code that looks cheap at the source level. |
| 222 | +- If `twiggy` shows allocator symbols such as `dlmalloc`, `__rust_alloc`, or `__rust_realloc` high |
| 223 | + in the retained-size list, treat allocation reduction as a first-class size tactic. The Rust and |
| 224 | + WebAssembly guide notes that the default wasm allocator is roughly ten kilobytes. Avoiding |
| 225 | + allocation entirely is best; switching allocators is only worth considering after compatibility |
| 226 | + and runtime-cost review. |
| 227 | +- Re-check whether the growth is in the Rust-generated code or in custom/data/spec sections before |
| 228 | + editing logic. If the bytes are not in `code`, logic changes may be the wrong fix. |
| 229 | +- If the optimizer path changes, compare the raw release artifact against the optimizer output. |
| 230 | + The Rust and Wasm guidance notes that post-processing with `wasm-opt -Os` or `-Oz` can save |
| 231 | + additional size, and our Soroban build already relies on the Stellar optimizer path for this |
| 232 | + reason. |
| 233 | +- `wasm-opt` is still worth considering when the Soroban optimizer path changes or when you need an |
| 234 | + independent measurement. Binaryen’s docs describe `wasm-opt` as the standard post-link optimizer |
| 235 | + for making wasm smaller and faster. It is not currently installed in this workspace, so do not |
| 236 | + add it as a required step without updating the tooling docs. |
| 237 | +- `wasm-snip` is a last-resort tool, not a default optimization pass. The Rust and WebAssembly |
| 238 | + guide suggests it mainly for code such as panic infrastructure that provably cannot execute at |
| 239 | + runtime. For contracts, do not use it unless the removed path is demonstrably unreachable and |
| 240 | + the result is re-optimized and re-tested. |
| 241 | +- If `twiggy` is not enough to explain a large function, inspect LLVM IR for the release wasm build: |
| 242 | + `cargo rustc --profile release-soroban --target wasm32-unknown-unknown -p templar-soroban-runtime -- --emit llvm-ir` |
| 243 | + Then inspect the generated `.ll` file in `target/wasm32-unknown-unknown/release-soroban/deps/`. |
| 244 | + The Rust and WebAssembly guide recommends this when you need to see what got inlined into a |
| 245 | + retained function. |
| 246 | + |
| 247 | +Practical order of attack: |
| 248 | + |
| 249 | +1. Measure section growth with `wasm-objdump -h`. |
| 250 | +2. Attribute retained code with `twiggy top`, `dominators`, and `monos`. |
| 251 | +3. Check `cargo tree -d` and `cargo tree -e features -i <crate>` before assuming the bloat is in |
| 252 | + your own code. |
| 253 | +4. Trim features, duplicate versions, and ABI/spec surface before changing architecture. |
| 254 | +5. Compare `"s"` vs `"z"` if code shape changed materially. |
| 255 | +6. Check for accidental inlining via `#[inline]`, `#[inline(always)]`, or large cold helpers being |
| 256 | + cloned into many sites. |
| 257 | +7. Use LLVM IR inspection if a retained giant still lacks a clear source-level cause. |
| 258 | +8. Escalate to a larger split only after the measured low-delta levers are exhausted. |
| 259 | + |
| 260 | +## Security Notes |
| 261 | + |
| 262 | +- `initialize()` is highly sensitive because front-running it would seize governance/curator |
| 263 | + control. Keep deployment and initialization assumptions explicit. |
| 264 | +- Review auth on every privileged Soroban entrypoint. Do not rely on outer routing alone. |
| 265 | +- Soroban transactions are atomic, but adapter correctness, state ordering, and accepted external |
| 266 | + asset snapshots still matter for accounting safety. |
| 267 | +- Changes to postcard serialization, versioning, migration, or storage keys are security-relevant. |
| 268 | +- The kernel-to-Soroban address mapping is critical for effect routing. Treat changes there as |
| 269 | + high impact. |
| 270 | +- `withdraw()` and `redeem()` include an idle-only atomic path that bypasses the queued withdrawal |
| 271 | + lifecycle. Do not change queue semantics without checking both paths. |
| 272 | +- Governance abdication is irreversible. Any change to governance action kind mapping or timelock |
| 273 | + policy needs a high-suspicion review. |
| 274 | +- `RemoveMarket`, skim recipient changes, and share-token authority changes can all become |
| 275 | + authority drift or asset-loss bugs if altered casually. |
| 276 | + |
| 277 | +## Working Norms |
| 278 | + |
| 279 | +- Prefer small, measurable patches over broad Soroban refactors. |
| 280 | +- If you touch anything that can affect size, measure the artifact after the change. |
| 281 | +- Preserve the existing contract split: runtime, governance, share-token, and blend adapter each |
| 282 | + have distinct authority boundaries. |
| 283 | +- Avoid broad shared-type expansion. Shared ABI crates can silently increase runtime size. |
| 284 | +- Be explicit about wire formats, storage formats, and event payloads. |
| 285 | +- Keep tests out of runtime implementation modules. Do not add inline `#[cfg(test)] mod tests` |
| 286 | + blocks to files such as `src/contract/entrypoints.rs`; put runtime tests in `src/tests.rs` or |
| 287 | + integration tests under `tests/`. |
| 288 | +- If behavior changes around governance bridging, withdrawal lifecycle, or share-token auth, |
| 289 | + update the docs in this directory in the same change. |
| 290 | + |
| 291 | +## Verification |
| 292 | + |
| 293 | +Minimum runtime verification: |
| 294 | + |
| 295 | +- `cargo test -p templar-soroban-runtime -- --nocapture` |
| 296 | +- `cargo test -p templar-soroban-runtime --test integration_tests -- --nocapture` |
| 297 | +- `cargo test -p templar-soroban-runtime --test property_tests -- --nocapture` |
| 298 | + |
| 299 | +Size verification: |
| 300 | + |
| 301 | +- `just -f contract/vault/soroban/justfile build` |
| 302 | +- `just -f contract/vault/soroban/justfile size-budget-check` |
| 303 | + |
| 304 | +When relevant, also run: |
| 305 | + |
| 306 | +- `cargo test -p templar-soroban-governance -- --nocapture` |
| 307 | +- `cargo test -p templar-soroban-share-token -- --nocapture` |
| 308 | +- `cargo test -p templar-soroban-blend-adapter -- --nocapture` |
| 309 | +- `just -f contract/vault/soroban/justfile check-4626-proxy` |
| 310 | + |
| 311 | +Use `wasm-analyze` when: |
| 312 | + |
| 313 | +- the deploy artifact grows unexpectedly |
| 314 | +- you add a new shared type, command variant, event shape, or generic helper |
| 315 | +- you move code across runtime/governance/share-token/adapter boundaries |
| 316 | + |
| 317 | +Use direct `twiggy` and `wasm-objdump` on the release artifacts when: |
| 318 | + |
| 319 | +- a change regresses `size-budget-check` |
| 320 | +- you need to know whether growth came from code, data, or custom sections |
| 321 | +- you need to attribute bloat to a specific retained function tree rather than a crate-level guess |
| 322 | + |
| 323 | +If an important verification step cannot run, say so explicitly. |
| 324 | + |
| 325 | +## Documentation Maintenance |
| 326 | + |
| 327 | +- Keep `README.md`, `STRIDE.md`, `SIZE_BUDGET.md`, and this file aligned. |
| 328 | +- Update this file when Soroban build paths, size rules, authority boundaries, or verification |
| 329 | + commands change. |
0 commit comments