This guide walks you through every step required to add a new Soroban smart contract to the RemitWise workspace. Follow each section in order and use the checklist at the bottom before submitting your pull request.
- Directory Structure
- Contract Patterns
- Writing Tests
- Gas Benchmarks
- Linting & Formatting
- CI Hooks
- Documentation
- Linking Into the Workspace
- New Contract Checklist
Create a Cargo library crate at the workspace root. Use snake_case for the crate name.
remitwise-contracts/
└── your_contract/
├── Cargo.toml
├── src/
│ ├── lib.rs # Contract entry-point; re-exports public types
│ ├── contract.rs # #[contract] impl block
│ ├── storage.rs # All storage keys and read/write helpers
│ ├── events.rs # Event structs and emit helpers
│ ├── errors.rs # ContractError enum
│ └── types.rs # Shared structs / enums (optional)
└── tests/
├── integration.rs # Full happy-path + edge-case tests
└── gas_bench.rs # Gas / resource benchmarks
[package]
name = "your_contract"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
soroban-sdk = { workspace = true }
[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
codegen-units = 1
lto = trueAdd the new crate to the workspace-level Cargo.toml:
[workspace]
members = [
# ... existing crates ...
"your_contract",
]Rule: Never use raw Symbol strings scattered across the codebase. Centralise every key in storage.rs.
// src/storage.rs
use soroban_sdk::{contracttype, Env, Address};
/// Every storage key for this contract lives here.
#[contracttype]
pub enum DataKey {
/// Keyed by owner address
Record(Address),
/// Singleton admin config
Config,
}
/// Read helpers return `Option<T>` – callers decide whether to panic.
pub fn get_record(env: &Env, owner: &Address) -> Option<YourType> {
env.storage().persistent().get(&DataKey::Record(owner.clone()))
}
pub fn set_record(env: &Env, owner: &Address, value: &YourType) {
env.storage()
.persistent()
.set(&DataKey::Record(owner.clone()), value);
}Storage tier guidance:
| Tier | Use when |
|---|---|
persistent() |
User data that must survive ledger expiry (goals, bills, policies) |
temporary() |
Short-lived caches, nonces |
instance() |
Contract-level config initialised once (admin, fee rates) |
Archiving pattern: When a list grows unboundedly (e.g., paid bills, completed goals) follow the pattern used in bill_payments and savings_goals:
- Expose an
archive_*function that moves records to a separate key. - Expose
get_archived_*,restore_*, andcleanup_old_*functions. - Expose
get_storage_statsso the frontend can monitor growth.
Rule: Every state-changing function must emit at least one event. Use short Symbol topics (≤ 8 chars) for on-chain efficiency.
// src/events.rs
use soroban_sdk::{contracttype, symbol_short, Env};
/// Published when a new record is created.
#[contracttype]
pub struct RecordCreatedEvent {
pub record_id: u64,
pub owner: soroban_sdk::Address,
pub amount: i128,
pub timestamp: u64,
}
pub fn emit_record_created(env: &Env, event: RecordCreatedEvent) {
env.events().publish(
(symbol_short!("created"),),
event,
);
}Event field requirements:
*_idfield identifying the entity acted upon.- Monetary
amountfor any financial event. timestamp— useenv.ledger().timestamp().- Any human-readable context (
name,due_date, etc.) that the frontend needs to render a notification.
Topic naming convention — align with existing contracts:
| Action | Topic symbol |
|---|---|
| Create / initialise | created / init |
| Update / add funds | added / calc |
| Complete / finish | completed |
| Pay | paid |
| Recurring creation | recurring |
| Deactivate / remove | deactive |
Rule: All contract panics must go through a typed error enum. Never call panic!() or .unwrap() directly in contract code.
// src/errors.rs
use soroban_sdk::contracterror;
#[contracterror]
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
#[repr(u32)]
pub enum ContractError {
// Initialisation
AlreadyInitialized = 1,
NotInitialized = 2,
// Auth
Unauthorized = 10,
// Business logic – start at 100 to leave room above
RecordNotFound = 100,
InsufficientFunds = 101,
InvalidAmount = 102,
DeadlineExceeded = 103,
}Reserve ranges per category (as above) so variants never collide when the error list grows.
Use errors in the contract:
if amount <= 0 {
return Err(ContractError::InvalidAmount);
}All tests live in tests/integration.rs. Use soroban_sdk::testutils — never deploy to a live network for unit or integration tests.
// tests/integration.rs
#![cfg(test)]
use soroban_sdk::{testutils::Address as _, Address, Env};
use your_contract::{YourContract, YourContractClient};
fn setup() -> (Env, Address, YourContractClient<'static>) {
let env = Env::default();
env.mock_all_auths(); // mock auth for all calls
let contract_id = env.register_contract(None, YourContract);
let client = YourContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
(env, admin, client)
}
#[test]
fn test_happy_path() {
let (env, admin, client) = setup();
client.initialize(&admin);
// ... assert state
}
#[test]
#[should_panic(expected = "InvalidAmount")]
fn test_rejects_zero_amount() {
let (env, admin, client) = setup();
client.initialize(&admin);
client.do_something(&admin, &0_i128); // must panic
}- Happy path – every public function called with valid inputs.
- Edge cases – boundary values (zero, max), empty lists.
- Auth checks – verify unauthorized callers are rejected.
- Error paths – at least one
#[should_panic]test perContractErrorvariant. - Event assertions – verify emitted events with
env.events().all().
When your contract transfers USDC, mock the SAC using env.register_stellar_asset_contract_v2 (see remittance_split tests for a reference implementation).
Every new contract must ship a tests/gas_bench.rs file.
// tests/gas_bench.rs
#![cfg(test)]
use soroban_sdk::Env;
use your_contract::{YourContract, YourContractClient};
#[test]
fn bench_create_record() {
let env = Env::default();
env.mock_all_auths();
let id = env.register_contract(None, YourContract);
let client = YourContractClient::new(&env, &id);
// ... setup ...
client.create_record(/* args */);
let resources = env.budget().borrow().resource_per_type();
println!("CPU (instructions): {}", resources.cpu_insns);
println!("Memory (bytes): {}", resources.mem_bytes);
// Fail loudly if limits are exceeded
assert!(resources.cpu_insns < 500_000, "CPU budget exceeded");
assert!(resources.mem_bytes < 50_000, "Memory budget exceeded");
}Run your benchmarks locally:
RUST_TEST_THREADS=1 cargo test -p your_contract --test gas_bench -- --nocaptureThen add your contract to scripts/run_gas_benchmarks.sh so results appear in gas_results.json.
The CI pipeline enforces these — fix all warnings locally before pushing.
# Format
cargo fmt --all
# Lint (must pass with zero warnings)
cargo clippy --all-targets --all-features -- -D warnings
# Check WASM build
cargo build --release --target wasm32-unknown-unknown -p your_contractCommon Clippy fixes for Soroban contracts:
- Replace
env.storage()...get().unwrap()with a helper that returnsOption<T>. - Derive
Cloneonly when needed — preferCopyfor small value types. - Avoid
u32 as i128casts; use explicitfrom/try_from.
The repository CI (.github/workflows/) runs on every push and pull request. Your new contract is automatically included in the workspace-wide jobs. You must additionally:
- Add a gas benchmark step — open
.github/workflows/gas-benchmarks.ymland append:
- name: Bench your_contract
run: RUST_TEST_THREADS=1 cargo test -p your_contract --test gas_bench -- --nocapture-
Verify the regression script covers your contract — open
scripts/compare_gas_results.shand confirm your contract name is present in the comparison list. -
Check CI passes end-to-end locally with
cargo test --workspacebefore opening a PR.
- Every
pubfunction must have a///doc comment explaining parameters, return value, panics, and the event emitted. - Every
ContractErrorvariant must have a one-line///comment.
/// Creates a new record for `owner`.
///
/// # Panics
/// - [`ContractError::AlreadyInitialized`] if a record already exists.
/// - [`ContractError::InvalidAmount`] if `amount` is zero or negative.
///
/// # Events
/// Emits [`RecordCreatedEvent`] on success.
pub fn create_record(env: Env, owner: Address, amount: i128) -> u64 { ... }Create your_contract/README.md following the same structure used by the other contracts:
# Your Contract
One-sentence description.
## Key Functions
...
## Events
...
## Error Codes
...Add your contract to the Contracts section in the root README.md following the existing pattern, and link to NEW_CONTRACT_GUIDE.md in the Development section if it isn't already there.
After the files are in place:
# 1. Verify the whole workspace compiles
cargo build --release --target wasm32-unknown-unknown
# 2. Run all tests
cargo test
# 3. Deploy to testnet (optional during development)
soroban contract deploy \
--wasm target/wasm32-unknown-unknown/release/your_contract.wasm \
--source <your-key> \
--network testnetUse this checklist in your pull request description. Every box must be checked before the PR can be merged.
- Crate created under workspace root with
snake_casename - Crate added to workspace-level
Cargo.toml[workspace.members] - Source split into
contract.rs,storage.rs,events.rs,errors.rs
- All keys defined in a
DataKeyenum instorage.rs - Read/write helper functions used consistently — no raw key strings in
contract.rs - Archiving pattern implemented if any list can grow unboundedly
-
get_storage_statsexposed for unbounded storage
- Every state-changing function emits at least one event
- All events include
id,amount(if financial), andtimestamp - Topic symbols are ≤ 8 characters and follow naming convention
-
ContractErrorenum defined with#[contracterror] - No raw
panic!()or.unwrap()in contract code - Each variant has a
///doc comment
-
tests/integration.rscovers every public function (happy path) - Edge-case and error-path tests with
#[should_panic] - Auth rejection tests present
- Event emission verified in at least one test
- All tests pass:
cargo test -p your_contract
-
tests/gas_bench.rscreated with benchmark for each key operation - Contract added to
scripts/run_gas_benchmarks.sh - Benchmarks pass locally:
RUST_TEST_THREADS=1 cargo test -p your_contract --test gas_bench -- --nocapture
-
cargo fmt --allrun with no diff -
cargo clippy --all-targets --all-features -- -D warningspasses with zero warnings - WASM build succeeds:
cargo build --release --target wasm32-unknown-unknown -p your_contract
- Gas benchmark step added to
.github/workflows/gas-benchmarks.yml -
scripts/compare_gas_results.shcovers new contract -
cargo test --workspacepasses locally
- All
pubfunctions have///doc comments (params, panics, events) - All
ContractErrorvariants have///doc comments -
your_contract/README.mdcreated (functions, events, errors sections) - Contract listed in workspace root
README.mdContracts section -
NEW_CONTRACT_GUIDE.mdlinked from rootREADME.mdDevelopment section (if not already)
For questions, open a discussion on the repository or ping the #contracts channel.