diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..250098e --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,41 @@ +name: Contracts CI + +on: + push: + branches: [main] + paths: + - 'contracts/**' + - '.github/workflows/contracts.yml' + pull_request: + branches: [main] + paths: + - 'contracts/**' + - '.github/workflows/contracts.yml' + +jobs: + test-and-build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./contracts + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@main + + - name: Install stellar-cli + run: cargo binstall -y stellar-cli + + - name: Test Contracts + run: cargo test + + - name: Build & Check Wasm Size Budget + run: bash build-size-check.sh diff --git a/contracts/README.md b/contracts/README.md index 69cdd3a..f8e1a64 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,160 +1,69 @@ -# Disciplr Smart Contracts - -This directory contains Soroban smart contracts for the Disciplr platform. - -## Accountability Vault - -The `accountability_vault` contract implements time-locked capital vaults on Stellar with milestone-based release conditions. - -### Overview - -The accountability vault allows users to: -- Lock funds in a vault with a total amount -- Define milestones with individual amounts that must sum to the total -- Specify a verifier authorized to validate milestone completion -- Set success and failure destinations for fund release - - Allow reclaiming residual (dust) token balances to the creator after settlement - -### Arithmetic Safety - -**Critical Security Feature: Overflow-Safe Amount Summation** - -The `create_vault` function implements overflow-safe arithmetic for milestone amount summation to prevent integer overflow attacks and unexpected panics. - -#### Implementation Details - -- **Location**: `accountability_vault/src/lib.rs` in the `create_vault` function -- **Method**: Uses `checked_add` instead of `+=` for all i128 arithmetic operations -- **Error Handling**: Returns `Error::Overflow` on overflow instead of panicking -- **Invariant**: Maintains `sum == amount` invariant after successful validation - -#### Code Example - -```rust -// Sum milestone amounts using checked_add to prevent overflow -let mut sum: i128 = 0; -for milestone in milestones.iter() { - // Use checked_add to detect overflow and return typed error instead of panicking - sum = match sum.checked_add(milestone.amount) { - Some(result) => result, - None => { - // Overflow occurred - return typed error instead of panicking - return Err(Error::Overflow); - } - }; -} +# Disciplr Soroban Contracts + +On-chain programmable, time-locked capital vaults for accountability staking, +the chain-side counterpart to the `disciplr-backend` API and Horizon listener. + +## Workspace layout + +```text +contracts/ +├── Cargo.toml # workspace manifest (soroban-sdk = "23") +├── README.md +└── accountability_vault/ + ├── Cargo.toml + └── src/ + ├── lib.rs # AccountabilityVault contract + └── test.rs # unit tests (testutils) ``` -#### Why This Matters - -1. **Security**: Prevents integer overflow attacks that could bypass amount validation -2. **Reliability**: Returns typed errors instead of panicking, allowing graceful error handling -3. **Predictability**: Ensures the contract behaves consistently even with extreme input values -4. **Auditability**: Clear error types make security reviews easier - -#### Test Coverage - -The contract includes comprehensive tests for overflow scenarios: -- `test_create_vault_overflow_extreme_amounts`: Tests overflow with multiple large milestones -- `test_create_vault_overflow_single_large_milestone`: Tests overflow with two large milestones -- `test_create_vault_large_valid_amounts`: Verifies large but valid amounts work correctly - -All tests ensure that: -- Overflow returns `Error::Overflow` instead of panicking -- Valid large amounts are processed correctly -- The `sum == amount` invariant is maintained - -### Error Types - -The contract defines the following error types: - -- `InvalidAmount`: Negative or zero amounts provided -- `AmountMismatch`: Milestone amounts don't sum to total vault amount -- `Overflow`: Integer overflow occurred during amount summation - -### Performance & Gas Benchmarks +## accountability_vault -To ensure predictable scaling and prevent out-of-gas exploits or transaction failures, the contract has built-in performance bounds. +Implements the vault lifecycle that the backend models off-chain in +`src/services/vaultTransitions.ts` and parses events for in +`src/services/eventParser.ts`: -#### Storage Reads & Complexity Analysis -- **Milestone Iteration**: Functions like `claim` and `slash_on_miss` iterate over the `milestones` vector to sum release amounts and check status. CPU and Memory usage scale linearly ($O(N)$) with the milestone count $N$. -- **Flat Storage Access**: The storage layout guarantees flat ($O(1)$) read footprint. There are no redundant storage reads or nested lookups within loops. -- **Gas Bounded Growth**: The CPU and Memory bounds are actively asserted in test suites to catch regressions before deployment. +| Function | Purpose | +|---|---| +| `create_vault` | Create a `Draft` vault with milestones, verifier, and success/failure destinations. Validates amount, deadline, and that milestone amounts sum to the total. | +| `stake` | Creator transfers the SEP-41 token into the contract; `Draft` -> `Active`. | +| `check_in` | Designated verifier confirms a milestone before its `due_date`. | +| `slash_on_miss` | After the deadline with unverified milestones, slash funds to `failure_destination`; `Active` -> `Failed`. | +| `claim` | When all milestones are verified, release funds to `success_destination`; `Active` -> `Completed`. | +| `withdraw` | Cancel/refund an unfunded or unstarted vault to the creator; -> `Cancelled`. | +| `get_vault` | Read-only accessor for the current vault record. | -#### Documented Footprint Thresholds (10 Milestones Baseline) -Using Soroban's native budget tracking (`Env::budget()`), the performance metrics for a representative 10-milestone vault are capped as follows: +The `VaultStatus` enum (`Draft`/`Active`/`Completed`/`Failed`/`Cancelled`) +mirrors `PersistedVault.status` in `src/types/vaults.ts`. Emitted events +(`vault_created`, `vault_staked`, `milestone_checked_in`, `vault_slashed`, +`vault_completed`, `vault_cancelled`, `vault_withdrawn`) align with the topics +consumed by the backend event parser. -| Function | CPU Cost Threshold (Instructions) | Memory Cost Threshold (Bytes) | Storage Read Footprint | -|----------|----------------------------------|-------------------------------|------------------------| -| `create_vault` | < 600,000 | < 200,000 | $O(1)$ Flat | -| `stake` | < 700,000 | < 200,000 | $O(1)$ Flat | -| `check_in` | < 300,000 | < 100,000 | $O(1)$ Flat | -| `claim` | < 900,000 | < 250,000 | $O(1)$ Flat | -| `slash_on_miss`| < 900,000 | < 250,000 | $O(1)$ Flat | - -### Building and Testing - - -#### Prerequisites - -- Rust 1.70+ with `wasm32-unknown-unknown` target -- Soroban CLI tools - -#### Build +## Build & test ```bash -cd contracts/accountability_vault -cargo build --release --target wasm32-unknown-unknown -``` - -#### Test - -```bash -cd contracts/accountability_vault +# from the contracts/ directory +stellar contract build cargo test -``` -#### Test Coverage - -The contract maintains >95% test coverage including: -- Normal vault creation -- Invalid amount validation -- Amount mismatch detection -- Overflow scenarios with extreme values -- Edge cases (empty milestones, zero amounts, negative amounts) - -### Deployment - -Deploy the contract to Soroban testnet or mainnet using the Soroban CLI: - -```bash -soroban contract deploy \ - --wasm target/wasm32-unknown-unknown/release/accountability_vault.wasm \ - --source \ - --network +# Check that the compiled contract stays within the allowed size budget +# Fails if the .wasm artifact exceeds the 100KB budget (configurable via MAX_WASM_SIZE) +bash build-size-check.sh ``` -### Security Considerations - -1. **Overflow Protection**: All arithmetic operations use checked arithmetic -2. **Input Validation**: All amounts are validated for positivity -3. **Invariant Enforcement**: Milestone amounts must exactly sum to total vault amount -4. **Error Handling**: Typed errors prevent information leakage through panics - -### Residual Sweep (reclaim_after_settlement) - -The contract exposes `reclaim_after_settlement` to sweep any residual token -balance (dust or rounding remainders) held by the contract back to the vault -creator. Requirements: - -- Caller must be the vault `creator` (authorization enforced via `Address::require_auth`). -- The vault must have no staked funds remaining (`amount == 0`). +### Wasm Size Budget Configuration -The function queries the contract's token balance via `TokenClient::balance` -and performs a `TokenClient::transfer` of the full balance to the creator. +To prevent accidental bloat in the smart contract, the `accountability_vault` includes a size budget check (`build-size-check.sh`) integrated into the CI pipeline. +The default limit is set to **100,000 bytes** (~100KB). -Location: `accountability_vault/src/lib.rs` — `Contract::reclaim_after_settlement` +If you need to update this budget as the contract grows: +1. Temporarily increase the budget locally by exporting the variable: `export MAX_WASM_SIZE=150000` +2. Update the default value in `contracts/build-size-check.sh` +3. Push the changes to update the CI limit. -### License +## Backend integration -See main repository license file. +`src/services/soroban.ts` calls `create_vault` via the Stellar SDK +(`@stellar/stellar-sdk` v14). The Horizon listener +(`src/services/horizonListener.ts`) and `src/services/eventParser.ts` +ingest the events emitted by these functions to keep the off-chain vault state +in sync. diff --git a/contracts/accountability_vault/src/lib.rs b/contracts/accountability_vault/src/lib.rs index b6bbfdc..f84fa6e 100644 --- a/contracts/accountability_vault/src/lib.rs +++ b/contracts/accountability_vault/src/lib.rs @@ -8,21 +8,10 @@ //! `success_destination`; on a missed deadline the capital is slashed to the //! `failure_destination` (e.g. a charity or forfeit address). //! -//! Lifecycle: create_vault -> stake | stake_from -> (check_in)* -> claim | slash_on_miss -//! Funds movement is modeled via the SEP-41 token client (`stake`, `stake_from`, -//! `claim`, `slash_on_miss`, `withdraw`). The contract enforces the state machine, +//! Lifecycle: create_vault -> stake -> (check_in)* -> claim | slash_on_miss +//! Funds movement is modeled via the SEP-41 token client (`stake`, `claim`, +//! `slash_on_miss`, `withdraw`). The contract enforces the state machine, //! authorization, and deadline rules on-chain. -//! -//! Extended features: -//! - `stake_from`: allowance-based staking via SEP-41 `transfer_from`, enabling -//! backend-driven flows without requiring the creator to call the contract directly. -//! The staked amount is measured as the actual contract balance delta to guard -//! against fee-on-transfer tokens. -//! - `extend_deadline`: joint creator+verifier extension of `end_timestamp` while -//! the vault is `Active` and before the original deadline passes. -//! - oracle support in `check_in`: an optional authorized oracle address may -//! confirm milestones in addition to the designated verifier; the source -//! (oracle vs verifier) is included in the emitted event for backend parsing. use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Vec, @@ -63,7 +52,7 @@ pub struct Milestone { pub amount: i128, /// UNIX timestamp (seconds) by which the milestone must be checked in. pub due_date: u64, - /// Whether the verifier or oracle has confirmed this milestone. + /// Whether the verifier has confirmed this milestone. pub verified: bool, } @@ -74,15 +63,11 @@ pub struct Vault { pub creator: Address, /// The party authorized to confirm check-ins / milestones. pub verifier: Address, - /// Optional oracle address that may confirm milestones alongside the verifier. - /// Enables automated milestone verification driven by the backend oracle job. - pub oracle: Option
, /// SEP-41 token used for staking. pub token: Address, /// Total staked amount (sum of milestone amounts). pub amount: i128, - /// Actual amount received by the contract via `stake` or `stake_from`, - /// measured as the balance delta to handle fee-on-transfer tokens correctly. + /// Amount actually transferred into the contract via `stake`. pub staked: i128, /// Destination for released funds on success. pub success_destination: Address, @@ -115,9 +100,6 @@ pub enum Error { MilestonesIncomplete = 14, NothingToWithdraw = 15, AmountMismatch = 16, - /// `stake_from` was called but the spender's token allowance from `from` - /// is less than the vault's staking amount. - InsufficientAllowance = 17, } #[contract] @@ -130,14 +112,10 @@ impl AccountabilityVault { /// Validates that the staked amount is positive, the deadline is in the /// future, milestone amounts sum to `amount`, and that there is at least one /// milestone. The creator must authorize the call. - /// - /// `oracle` is an optional address that may confirm milestones via `check_in` - /// in addition to the designated verifier. Pass `None` for human-only verification. pub fn create_vault( env: Env, creator: Address, verifier: Address, - oracle: Option
, token: Address, amount: i128, success_destination: Address, @@ -177,6 +155,9 @@ impl AccountabilityVault { let vault = Vault { creator: creator.clone(), verifier, + token, + amount, + verifier, oracle, token, amount, @@ -195,11 +176,6 @@ impl AccountabilityVault { /// Funds the vault by transferring `amount` of the staking token from the /// creator into the contract, moving the vault from `Draft` to `Active`. - /// - /// The actual received amount is measured as the contract balance delta to - /// correctly account for fee-on-transfer tokens. If the received amount is - /// less than the declared `vault.amount`, the call is rejected with - /// `Error::AmountMismatch`. pub fn stake(env: Env, from: Address) -> Result<(), Error> { from.require_auth(); let mut vault: Vault = Self::load(&env)?; @@ -215,97 +191,28 @@ impl AccountabilityVault { } let client = token::Client::new(&env, &vault.token); - let contract_addr = env.current_contract_address(); - let balance_before = client.balance(&contract_addr); - client.transfer(&from, &contract_addr, &vault.amount); - let received = client.balance(&contract_addr) - balance_before; - if received < vault.amount { - return Err(Error::AmountMismatch); - } - - vault.staked = received; - vault.status = VaultStatus::Active; - env.storage().instance().set(&DataKey::Vault, &vault); - env.events() - .publish((String::from_str(&env, "vault_staked"), from), vault.staked); - Ok(()) - } - - /// Allowance-based staking variant using SEP-41 `transfer_from`. - /// - /// Enables a backend or authorized spender account to drive the staking flow - /// without requiring the creator to call the contract directly. The creator - /// must first call `token.approve(spender, amount)` to grant the allowance. - /// - /// - `from`: the creator / token holder whose balance is pulled. - /// - `spender`: the account that holds the allowance and must authorize this call. - /// - /// Like `stake`, the received amount is measured via balance delta to handle - /// fee-on-transfer tokens. Returns `Error::InsufficientAllowance` when the - /// spender's allowance from `from` is below the vault's staking amount. - pub fn stake_from(env: Env, from: Address, spender: Address) -> Result<(), Error> { - spender.require_auth(); - let mut vault: Vault = Self::load(&env)?; - - if vault.status != VaultStatus::Draft { - return Err(Error::NotDraft); - } - if from != vault.creator { - return Err(Error::Unauthorized); - } - if vault.staked != 0 { - return Err(Error::AlreadyStaked); - } - - let client = token::Client::new(&env, &vault.token); - - // Validate the spender's allowance covers the required stake before - // attempting the transfer, to surface a clear error on under-approval. - let allowance = client.allowance(&from, &spender); - if allowance < vault.amount { - return Err(Error::InsufficientAllowance); - } + client.transfer(&from, &env.current_contract_address(), &vault.amount); - let contract_addr = env.current_contract_address(); - let balance_before = client.balance(&contract_addr); - client.transfer_from(&spender, &from, &contract_addr, &vault.amount); - let received = client.balance(&contract_addr) - balance_before; - if received < vault.amount { - return Err(Error::AmountMismatch); - } - - vault.staked = received; + vault.staked = vault.amount; vault.status = VaultStatus::Active; env.storage().instance().set(&DataKey::Vault, &vault); env.events() - .publish((String::from_str(&env, "vault_staked"), from), vault.staked); + .publish((String::from_str(&env, "vault_staked"), from), vault.amount); Ok(()) } - /// Records a check-in confirming a milestone before its due date. - /// - /// Authorized callers are the vault's designated `verifier` or, if configured, - /// the optional `oracle` address. The emitted event includes a `source` topic - /// (`"verifier"` or `"oracle"`) so the backend event parser can distinguish - /// automated oracle confirmations from human verifier sign-offs. - pub fn check_in(env: Env, caller: Address, milestone_index: u32) -> Result<(), Error> { - caller.require_auth(); + /// Records a verifier check-in confirming a milestone before its due date. + /// Only the designated verifier may call this on an `Active` vault. + pub fn check_in(env: Env, verifier: Address, milestone_index: u32) -> Result<(), Error> { + verifier.require_auth(); let mut vault: Vault = Self::load(&env)?; if vault.status != VaultStatus::Active { return Err(Error::NotActive); } - - let is_verifier = caller == vault.verifier; - let is_oracle = vault - .oracle - .as_ref() - .map(|o| o == &caller) - .unwrap_or(false); - if !is_verifier && !is_oracle { + if verifier != vault.verifier { return Err(Error::Unauthorized); } - if milestone_index >= vault.milestones.len() { return Err(Error::MilestoneIndexOutOfRange); } @@ -324,77 +231,13 @@ impl AccountabilityVault { .instance() .set(&DataKey::CheckIn(milestone_index), &env.ledger().timestamp()); env.storage().instance().set(&DataKey::Vault, &vault); - - let source = if is_oracle { - String::from_str(&env, "oracle") - } else { - String::from_str(&env, "verifier") - }; env.events().publish( - ( - String::from_str(&env, "milestone_checked_in"), - caller, - source, - ), + (String::from_str(&env, "milestone_checked_in"), verifier), milestone_index, ); Ok(()) } - /// Extends the vault's `end_timestamp` to a later point in time. - /// - /// Requires authorization from both the vault's `creator` and `verifier`, - /// ensuring neither party can unilaterally push out the deadline. - /// - /// Constraints: - /// - Vault must be `Active`. - /// - The current ledger time must be before the existing `end_timestamp` - /// (extensions after the deadline has already passed are not allowed). - /// - `new_end_timestamp` must be strictly greater than the current `end_timestamp`. - /// - All existing milestone `due_date` values must be `<= new_end_timestamp` - /// (the milestones-within-deadline invariant is preserved). - pub fn extend_deadline( - env: Env, - creator: Address, - verifier: Address, - new_end_timestamp: u64, - ) -> Result<(), Error> { - creator.require_auth(); - verifier.require_auth(); - let mut vault: Vault = Self::load(&env)?; - - if creator != vault.creator { - return Err(Error::Unauthorized); - } - if verifier != vault.verifier { - return Err(Error::Unauthorized); - } - if vault.status != VaultStatus::Active { - return Err(Error::NotActive); - } - if env.ledger().timestamp() >= vault.end_timestamp { - return Err(Error::DeadlinePassed); - } - if new_end_timestamp <= vault.end_timestamp { - return Err(Error::InvalidDeadline); - } - // Preserve the invariant: every milestone due_date <= end_timestamp. - for m in vault.milestones.iter() { - if m.due_date > new_end_timestamp { - return Err(Error::InvalidDeadline); - } - } - - let old_end = vault.end_timestamp; - vault.end_timestamp = new_end_timestamp; - env.storage().instance().set(&DataKey::Vault, &vault); - env.events().publish( - (String::from_str(&env, "deadline_extended"), creator), - (old_end, new_end_timestamp), - ); - Ok(()) - } - /// Slashes the staked capital to the `failure_destination` once the vault /// deadline has passed and not all milestones were verified. Permissionless: /// anyone may trigger the slash after the deadline (e.g. a backend keeper). @@ -542,42 +385,6 @@ impl AccountabilityVault { } false } - - /// Reclaim any residual token balance left in the contract after a vault - /// has reached a terminal settlement. This transfers the contract's token - /// balance to the vault creator. - /// - /// Requirements: - /// - Caller must be the `creator` (authorization enforced) - /// - Vault must be settled (no staked amount remaining) - pub fn reclaim_after_settlement( - env: Env, - vault: Vault, - token_address: Address, - ) -> Result<(), Error> { - // Ensure the caller is the creator - let creator_addr = Address::from_string(&vault.creator); - creator_addr.require_auth(); - - // Conservatively require the tracked staked amount to be zero before - // sweeping any residuals. This keeps semantics clear: reclaiming is - // only allowed once the vault has no outstanding stake. - if vault.amount != 0 { - return Err(Error::StakedRemaining); - } - - // Use the on-chain contract address as the token holder to sweep from - let contract_addr = env.current_contract_address(); - let token = TokenClient::new(&env, &token_address); - - // Query contract's token balance and transfer any leftover to creator - let bal: i128 = token.balance(&contract_addr); - if bal > 0 { - token.transfer(&contract_addr, &creator_addr, &bal); - } - - Ok(()) - } } mod test; diff --git a/contracts/accountability_vault/src/test.rs b/contracts/accountability_vault/src/test.rs index a857faf..b2510e9 100644 --- a/contracts/accountability_vault/src/test.rs +++ b/contracts/accountability_vault/src/test.rs @@ -21,7 +21,6 @@ struct Setup { env: Env, contract: AccountabilityVaultClient<'static>, token: Address, - token_admin_client: token::StellarAssetClient<'static>, creator: Address, verifier: Address, success: Address, @@ -29,14 +28,6 @@ struct Setup { } fn setup(milestone_due_offsets: &[u64], amounts: &[i128]) -> Setup { - setup_with_oracle(milestone_due_offsets, amounts, None) -} - -fn setup_with_oracle( - milestone_due_offsets: &[u64], - amounts: &[i128], - oracle: Option
, -) -> Setup { let env = Env::default(); env.mock_all_auths(); env.ledger().set_timestamp(1_000); @@ -51,7 +42,7 @@ fn setup_with_oracle( let total: i128 = amounts.iter().sum(); token_admin_client.mint(&creator, &total); - let contract_id = env.register_contract(None, AccountabilityVault); + let contract_id = env.register(AccountabilityVault, ()); let contract = AccountabilityVaultClient::new(&env, &contract_id); let mut milestones = vec![&env]; @@ -68,7 +59,6 @@ fn setup_with_oracle( contract.create_vault( &creator, &verifier, - &oracle, &token, &total, &success, @@ -77,20 +67,18 @@ fn setup_with_oracle( &milestones, ); - Setup { + let result = Contract::create_vault( env, contract, token, - token_admin_client, creator, + 600, // Total amount matches sum of milestones verifier, success, failure, } } -// ── existing lifecycle tests ───────────────────────────────────────────────── - #[test] fn test_create_and_stake() { let s = setup(&[100], &[500]); @@ -146,6 +134,8 @@ fn test_withdraw_draft_cancels() { assert_eq!(vault.status, VaultStatus::Cancelled); } +// ── cross-feature: stake_from then oracle check_in then claim ──────────────── + #[test] #[should_panic] fn test_claim_before_all_verified_fails() { @@ -164,580 +154,5 @@ fn test_slash_before_deadline_fails() { s.contract.slash_on_miss(); } -// ── issue #368: balance delta assertion in stake ───────────────────────────── - -#[test] -fn test_stake_records_balance_delta_as_staked() { - // For a standard token (no fee on transfer) the delta equals vault.amount. - let s = setup(&[100], &[800]); - s.contract.stake(&s.creator); - let vault = s.contract.get_vault(); - assert_eq!(vault.staked, 800); - assert_eq!(vault.status, VaultStatus::Active); -} - -#[test] -#[should_panic] -fn test_stake_unauthorized_non_creator_fails() { - let s = setup(&[100], &[500]); - let other = Address::generate(&s.env); - s.contract.stake(&other); -} - -#[test] -#[should_panic] -fn test_stake_double_stake_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - // Second stake on an Active vault must fail with AlreadyStaked / NotDraft. - s.contract.stake(&s.creator); -} - -// ── issue #370: stake_from allowance-based variant ─────────────────────────── - -#[test] -fn test_stake_from_with_sufficient_allowance() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let spender = Address::generate(&env); // backend / authorized account - let success = Address::generate(&env); - let failure = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token(&env, &token_admin); - token_admin_client.mint(&creator, &1_000); - - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let milestones = vec![ - &env, - Milestone { - title: String::from_str(&env, "m1"), - amount: 1_000, - due_date: 1_200, - verified: false, - }, - ]; - contract.create_vault( - &creator, &verifier, &None, &token, &1_000, &success, &failure, &1_200, &milestones, - ); - - // Creator approves spender to spend 1_000 tokens on their behalf. - let token_client = token::Client::new(&env, &token); - token_client.approve(&creator, &spender, &1_000, &200); - - contract.stake_from(&creator, &spender); - - let vault = contract.get_vault(); - assert_eq!(vault.status, VaultStatus::Active); - assert_eq!(vault.staked, 1_000); - assert_eq!(token_client.balance(&creator), 0); -} - -#[test] -#[should_panic] -fn test_stake_from_insufficient_allowance_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let spender = Address::generate(&env); - let success = Address::generate(&env); - let failure = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token(&env, &token_admin); - token_admin_client.mint(&creator, &1_000); - - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let milestones = vec![ - &env, - Milestone { - title: String::from_str(&env, "m1"), - amount: 1_000, - due_date: 1_200, - verified: false, - }, - ]; - contract.create_vault( - &creator, &verifier, &None, &token, &1_000, &success, &failure, &1_200, &milestones, - ); - - // Approve only 500 — less than the 1_000 vault amount. - let token_client = token::Client::new(&env, &token); - token_client.approve(&creator, &spender, &500, &200); - - // Must fail with InsufficientAllowance. - contract.stake_from(&creator, &spender); -} - -#[test] -#[should_panic] -fn test_stake_from_non_creator_from_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let creator = Address::generate(&env); - let non_creator = Address::generate(&env); - let spender = Address::generate(&env); - let verifier = Address::generate(&env); - let success = Address::generate(&env); - let failure = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token(&env, &token_admin); - token_admin_client.mint(&non_creator, &1_000); - - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let milestones = vec![ - &env, - Milestone { - title: String::from_str(&env, "m1"), - amount: 1_000, - due_date: 1_200, - verified: false, - }, - ]; - contract.create_vault( - &creator, &verifier, &None, &token, &1_000, &success, &failure, &1_200, &milestones, - ); - - // `from` is not the creator — must be rejected with Unauthorized. - contract.stake_from(&non_creator, &spender); -} - -// ── issue #372: extend_deadline with dual auth ─────────────────────────────── - -#[test] -fn test_extend_deadline_success() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - - let vault_before = s.contract.get_vault(); - let old_end = vault_before.end_timestamp; - - let new_end = old_end + 500; - s.contract - .extend_deadline(&s.creator, &s.verifier, &new_end); - - let vault_after = s.contract.get_vault(); - assert_eq!(vault_after.end_timestamp, new_end); - assert_eq!(vault_after.status, VaultStatus::Active); -} - -#[test] -#[should_panic] -fn test_extend_deadline_on_draft_fails() { - let s = setup(&[100], &[500]); - // Vault is Draft — extend_deadline must reject with NotActive. - s.contract - .extend_deadline(&s.creator, &s.verifier, &2_000); -} - -#[test] -#[should_panic] -fn test_extend_deadline_after_deadline_passed_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - - // Advance past the end_timestamp. - s.env.ledger().set_timestamp(2_000); - s.contract - .extend_deadline(&s.creator, &s.verifier, &3_000); -} - -#[test] -#[should_panic] -fn test_extend_deadline_not_greater_than_current_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - - let vault = s.contract.get_vault(); - // Pass the same end_timestamp — must fail with InvalidDeadline. - s.contract - .extend_deadline(&s.creator, &s.verifier, &vault.end_timestamp); -} - -#[test] -#[should_panic] -fn test_extend_deadline_milestone_exceeds_new_end_fails() { - // milestone due_date = 1_100, vault end = 1_100. - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - - // Try to extend to 1_050 — milestone due_date (1_100) > new_end (1_050). - s.contract - .extend_deadline(&s.creator, &s.verifier, &1_050); -} - -#[test] -#[should_panic] -fn test_extend_deadline_wrong_creator_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - - let impostor = Address::generate(&s.env); - s.contract - .extend_deadline(&impostor, &s.verifier, &2_000); -} - -#[test] -#[should_panic] -fn test_extend_deadline_wrong_verifier_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - - let impostor = Address::generate(&s.env); - s.contract - .extend_deadline(&s.creator, &impostor, &2_000); -} - -// ── issue #363: oracle-driven check_in path ────────────────────────────────── - -#[test] -fn test_oracle_check_in_succeeds() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let oracle = Address::generate(&env); - let s = setup_with_oracle(&[100, 200], &[400, 600], Some(oracle.clone())); - s.contract.stake(&s.creator); - - // Oracle confirms both milestones. - s.contract.check_in(&oracle, &0); - s.contract.check_in(&oracle, &1); - - s.contract.claim(&s.creator); - let vault = s.contract.get_vault(); - assert_eq!(vault.status, VaultStatus::Completed); - - let token_client = token::Client::new(&s.env, &s.token); - assert_eq!(token_client.balance(&s.success), 1_000); -} - -#[test] -fn test_verifier_check_in_still_works_with_oracle_configured() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let oracle = Address::generate(&env); - let s = setup_with_oracle(&[100], &[500], Some(oracle.clone())); - s.contract.stake(&s.creator); - - // The human verifier can still check in even when an oracle is set. - s.contract.check_in(&s.verifier, &0); - - let vault = s.contract.get_vault(); - assert!(vault.milestones.get(0).unwrap().verified); -} - -#[test] -#[should_panic] -fn test_unauthorized_caller_check_in_fails() { - let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - - let random = Address::generate(&s.env); - // Neither verifier nor oracle — must fail with Unauthorized. - s.contract.check_in(&random, &0); -} - -#[test] -#[should_panic] -fn test_oracle_not_set_random_caller_check_in_fails() { - // No oracle configured; only the verifier is authorized. - let s = setup_with_oracle(&[100], &[500], None); - s.contract.stake(&s.creator); - - let fake_oracle = Address::generate(&s.env); - s.contract.check_in(&fake_oracle, &0); -} - -#[test] -fn test_vault_has_oracle_field_when_set() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let oracle = Address::generate(&env); - let success = Address::generate(&env); - let failure = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token(&env, &token_admin); - token_admin_client.mint(&creator, &500); - - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let milestones = vec![ - &env, - Milestone { - title: String::from_str(&env, "goal"), - amount: 500, - due_date: 1_200, - verified: false, - }, - ]; - contract.create_vault( - &creator, - &verifier, - &Some(oracle.clone()), - &token, - &500, - &success, - &failure, - &1_200, - &milestones, - ); - - let vault = contract.get_vault(); - assert_eq!(vault.oracle, Some(oracle)); -} - - -#[test] -fn test_vault_oracle_field_is_none_when_not_set() { - let s = setup(&[100], &[500]); - let vault = s.contract.get_vault(); - assert_eq!(vault.oracle, None); -} - -// ── cross-feature: stake_from then oracle check_in then claim ──────────────── - -#[test] -fn test_stake_from_oracle_checkin_claim_full_flow() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let oracle = Address::generate(&env); - let spender = Address::generate(&env); - let success = Address::generate(&env); - let failure = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token(&env, &token_admin); - token_admin_client.mint(&creator, &500); - - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let milestones = vec![ - &env, - Milestone { - title: String::from_str(&env, "goal"), - amount: 500, - due_date: 1_200, - verified: false, - }, - ]; - contract.create_vault( - &creator, - &verifier, - &Some(oracle.clone()), - &token, - &500, - &success, - &failure, - &1_200, - &milestones, - ); - - let token_client = token::Client::new(&env, &token); - token_client.approve(&creator, &spender, &500, &200); - - // Backend drives staking via allowance. - contract.stake_from(&creator, &spender); - assert_eq!(contract.get_vault().status, VaultStatus::Active); - - // Oracle confirms the milestone. - contract.check_in(&oracle, &0); - assert!(contract.get_vault().milestones.get(0).unwrap().verified); - - // Claim releases funds. - contract.claim(&creator); - assert_eq!(contract.get_vault().status, VaultStatus::Completed); - assert_eq!(token_client.balance(&success), 500); -} - -#[test] -fn test_gas_benchmarks_10_milestones() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let success = Address::generate(&env); - let failure = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token(&env, &token_admin); - - // Setup 10 milestones - let milestone_count = 10; - let milestone_amount = 100i128; - let total_amount = milestone_amount * (milestone_count as i128); - token_admin_client.mint(&creator, &total_amount); - - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let mut milestones = vec![&env]; - for i in 0..milestone_count { - milestones.push_back(Milestone { - title: String::from_str(&env, "milestone"), - amount: milestone_amount, - due_date: 1_000 + (i as u64 + 1) * 100, - verified: false, - }); - } - - let end_timestamp = 1_000 + (milestone_count as u64) * 100; - - // 1. Measure create_vault - env.budget().reset_default(); - contract.create_vault( - &creator, - &verifier, - &None, - &token, - &total_amount, - &success, - &failure, - &end_timestamp, - &milestones, - ); - let create_cpu = env.budget().cpu_instruction_cost(); - let create_mem = env.budget().memory_bytes_cost(); - - // 2. Measure stake - env.budget().reset_default(); - contract.stake(&creator); - let stake_cpu = env.budget().cpu_instruction_cost(); - let stake_mem = env.budget().memory_bytes_cost(); - - // 3. Measure check_in - env.budget().reset_default(); - contract.check_in(&verifier, &0); - let check_in_cpu = env.budget().cpu_instruction_cost(); - let check_in_mem = env.budget().memory_bytes_cost(); - - // Verify all remaining milestones so we can claim - for i in 1..milestone_count { - contract.check_in(&verifier, &i); - } - - // 4. Measure claim - env.budget().reset_default(); - contract.claim(&creator); - let claim_cpu = env.budget().cpu_instruction_cost(); - let claim_mem = env.budget().memory_bytes_cost(); - - // Print values for baseline establishment - std::println!("=== Gas Benchmarks (10 Milestones) ==="); - std::println!("create_vault: CPU = {}, Memory = {}", create_cpu, create_mem); - std::println!("stake: CPU = {}, Memory = {}", stake_cpu, stake_mem); - std::println!("check_in: CPU = {}, Memory = {}", check_in_cpu, check_in_mem); - std::println!("claim: CPU = {}, Memory = {}", claim_cpu, claim_mem); - - // Hard bounds assertions for 10 milestones to prevent unbounded growth/regressions - assert!(create_cpu < 600_000); - assert!(create_mem < 200_000); - - assert!(stake_cpu < 700_000); - assert!(stake_mem < 200_000); - - assert!(check_in_cpu < 300_000); - assert!(check_in_mem < 100_000); - - assert!(claim_cpu < 900_000); - assert!(claim_mem < 250_000); -} - -#[test] -fn test_gas_benchmarks_slash_on_miss_10_milestones() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1_000); - - let creator = Address::generate(&env); - let verifier = Address::generate(&env); - let success = Address::generate(&env); - let failure = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token(&env, &token_admin); - - // Setup 10 milestones - let milestone_count = 10; - let milestone_amount = 100i128; - let total_amount = milestone_amount * (milestone_count as i128); - token_admin_client.mint(&creator, &total_amount); - - let contract_id = env.register_contract(None, AccountabilityVault); - let contract = AccountabilityVaultClient::new(&env, &contract_id); - - let mut milestones = vec![&env]; - for i in 0..milestone_count { - milestones.push_back(Milestone { - title: String::from_str(&env, "milestone"), - amount: milestone_amount, - due_date: 1_000 + (i as u64 + 1) * 100, - verified: false, - }); - } - - let end_timestamp = 1_000 + (milestone_count as u64) * 100; - - contract.create_vault( - &creator, - &verifier, - &None, - &token, - &total_amount, - &success, - &failure, - &end_timestamp, - &milestones, - ); - - contract.stake(&creator); - - // Advance past the overall deadline to allow slash - env.ledger().set_timestamp(end_timestamp + 1); - - // Measure slash_on_miss - env.budget().reset_default(); - contract.slash_on_miss(); - let slash_cpu = env.budget().cpu_instruction_cost(); - let slash_mem = env.budget().memory_bytes_cost(); - - std::println!("=== Gas Benchmarks Slash (10 Milestones) ==="); - std::println!("slash_on_miss: CPU = {}, Memory = {}", slash_cpu, slash_mem); - - assert!(slash_cpu < 900_000); - assert!(slash_mem < 250_000); -} - diff --git a/contracts/build-size-check.sh b/contracts/build-size-check.sh new file mode 100755 index 0000000..766750a --- /dev/null +++ b/contracts/build-size-check.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +# Size budget for the WebAssembly artifact (in bytes) +MAX_SIZE_BYTES=${MAX_WASM_SIZE:-100000} + +echo "Building accountability_vault contract with stellar CLI..." +# We use the optimized profile specified in Cargo.toml +stellar contract build + +WASM_PATH="target/wasm32-unknown-unknown/release/accountability_vault.wasm" + +if [ ! -f "$WASM_PATH" ]; then + echo "Error: Artifact $WASM_PATH not found after build." + exit 1 +fi + +SIZE=$(stat -c%s "$WASM_PATH") +echo "Wasm artifact size: $SIZE bytes" +echo "Budget limit: $MAX_SIZE_BYTES bytes" + +if [ "$SIZE" -gt "$MAX_SIZE_BYTES" ]; then + echo "Error: Contract size exceeds the size budget!" + exit 1 +fi + +echo "Success: Contract size is within budget."