diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d13adeb..75f9c6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,8 @@ jobs: cargo build --target wasm32-unknown-unknown --release cd ../revenue_pool cargo build --target wasm32-unknown-unknown --release + + - name: Check WASM size + run: | + chmod +x scripts/check-wasm-size.sh + ./scripts/check-wasm-size.sh diff --git a/Cargo.toml b/Cargo.toml index b597b45..1d13e4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,12 @@ soroban-sdk = "22" overflow-checks = true [profile.release] -opt-level = "z" -overflow-checks = true -debug = 0 -strip = "symbols" -debug-assertions = false -panic = "abort" -codegen-units = 1 -lto = true +# Size optimization settings to keep WASM under Soroban's 64KB limit +opt-level = "z" # Optimize for size (more aggressive than "s") +overflow-checks = true # Keep overflow checks for safety +debug = 0 # No debug info +strip = "symbols" # Remove symbol table and debug info +debug-assertions = false # Disable debug assertions in release +panic = "abort" # Smaller panic handler (no unwinding) +codegen-units = 1 # Better optimization (slower compile, smaller binary) +lto = true # Link-time optimization across all crates diff --git a/EVENT_SCHEMA.md b/EVENT_SCHEMA.md index acd8fab..00e4c60 100644 --- a/EVENT_SCHEMA.md +++ b/EVENT_SCHEMA.md @@ -65,6 +65,32 @@ Emitted when the owner withdraws to a designated address via `withdraw_to(to, am --- +### `metadata_set` + +Emitted when metadata is set for an offering via `set_metadata(offering_id, metadata)`. + +| Field | Location | Type | Description | +|---------|----------|--------|---------------| +| topic 0 | topics | Symbol | `"metadata_set"` | +| topic 1 | topics | String | offering_id | +| topic 2 | topics | Address| caller (owner/issuer) | +| data | data | String | metadata (IPFS CID or URI) | + +--- + +### `metadata_updated` + +Emitted when existing metadata is updated via `update_metadata(offering_id, metadata)`. + +| Field | Location | Type | Description | +|---------|----------|--------|---------------| +| topic 0 | topics | Symbol | `"metadata_updated"` | +| topic 1 | topics | String | offering_id | +| topic 2 | topics | Address| caller (owner/issuer) | +| data | data | (String, String) | (old_metadata, new_metadata) | + +--- + ## Not yet implemented - **OwnershipTransfer**: not present in current vault; would list old_owner, new_owner. diff --git a/README.md b/README.md index ca4b9be..4340a79 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ Soroban smart contracts for the Callora API marketplace: prepaid vault (USDC) an - `deposit(caller, amount)` — owner or allowed depositor increases ledger balance - `deduct(amount)` — decrease balance for an API call (backend uses this after metering usage) - `balance()` — current ledger balance + - `set_metadata(caller, offering_id, metadata)` — owner-only; attach off-chain metadata reference (IPFS CID or URI) to an offering + - `update_metadata(caller, offering_id, metadata)` — owner-only; update existing offering metadata + - `get_metadata(offering_id)` — retrieve metadata reference for an offering +- **`callora-revenue-pool`** contract (settlement): + - `init(admin, usdc_token)` — set admin and USDC token + - `distribute(caller, to, amount)` — admin sends USDC from this contract to a developer + - Flow: vault deduct → vault transfers USDC to revenue pool → admin calls `distribute(to, amount)` - `set_price(caller, api_id, price)` — owner or allowed depositor sets the **price per API call** for `api_id` in smallest USDC units (e.g. 1 = 1 cent) - `get_price(api_id)` — returns `Option` with the configured price per call for `api_id` @@ -60,11 +67,23 @@ All tests use `#[should_panic]` assertions for guaranteed validation. This resol 3. **Build WASM (for deployment):** ```bash - cd contracts/vault - cargo build --target wasm32-unknown-unknown --release + # Build vault contract + cargo build --target wasm32-unknown-unknown --release -p callora-vault + + # Or use the convenience script from project root + ./scripts/check-wasm-size.sh ``` - Or use `soroban contract build` if you use the Soroban CLI workflow. + The vault contract WASM binary is optimized to ~17.5KB (17,926 bytes), well under Soroban's 64KB limit. The release profile in `Cargo.toml` uses aggressive size optimizations: + - `opt-level = "z"` - optimize for size + - `lto = true` - link-time optimization + - `strip = "symbols"` - remove debug symbols + - `codegen-units = 1` - better optimization at cost of compile time + + To verify the WASM size stays under 64KB, run: + ```bash + ./scripts/check-wasm-size.sh + ``` ## Development diff --git a/contracts/vault/STORAGE.md b/contracts/vault/STORAGE.md index 186905f..6394e13 100644 --- a/contracts/vault/STORAGE.md +++ b/contracts/vault/STORAGE.md @@ -10,6 +10,14 @@ The Callora Vault contract uses Soroban's instance storage to persist contract s ### Instance Storage +| Key | Type | Description | Usage | +|-----|------|-------------|-------| +| `Symbol("meta")` | `VaultMeta` | Primary vault metadata (owner, balance, min_deposit) | Core vault state | +| `Symbol("usdc")` | `Address` | USDC token contract address | Token transfers | +| `Symbol("admin")` | `Address` | Admin (e.g. backend) for distribute | Access control | +| `Symbol("revenue_pool")` | `Option
` | Optional settlement contract; receives USDC on deduct | Deduct flow | +| `Symbol("max_deduct")` | `i128` | Maximum amount per single deduct (configurable at init) | Deduct limit | +| `StorageKey::OfferingMetadata(offering_id)` | `String` | Off-chain metadata reference (IPFS CID or URI) per offering | Offering metadata | The contract defines the following storage keys: ```rust @@ -64,6 +72,14 @@ pub struct VaultMeta { ``` Instance Storage +├── Symbol("meta") +│ └── VaultMeta +│ ├── owner: Address +│ └── balance: i128 +├── Symbol("AllowedDepositor") +│ └── Option
+└── StorageKey::OfferingMetadata(offering_id: String) + └── String (IPFS CID or URI, max 256 chars) ├── StorageKey::Meta │ └── VaultMeta │ ├── owner: Address @@ -76,6 +92,31 @@ Instance Storage ## Upgrade Implications +### Offering Metadata Storage + +The contract supports per-offering metadata storage, allowing issuers to attach off-chain references (IPFS CIDs or HTTPS URIs) to individual offerings. + +**Storage Pattern:** +- Each offering's metadata is stored under a unique key: `StorageKey::OfferingMetadata(offering_id)` +- Metadata is a string with a maximum length of 256 characters +- Multiple offerings can have independent metadata entries + +**Access Control:** +- Only the vault owner (issuer) can set or update metadata +- Metadata operations emit events for indexing and tracking + +**Off-chain Usage Pattern:** +Clients should: +1. Call `get_metadata(offering_id)` to retrieve the reference +2. If IPFS CID: Fetch from IPFS gateway (e.g., `https://ipfs.io/ipfs/{CID}`) +3. If HTTPS URI: Fetch directly via HTTP GET +4. Parse the JSON metadata (expected fields: name, description, image, attributes, etc.) + +**Storage Constraints:** +- Maximum metadata length: 256 characters (sufficient for IPFS CIDv0/v1 and reasonable URIs) +- Empty strings are allowed (can be used to clear metadata semantically) +- Oversized input is rejected with a panic + ### Current Layout Considerations - **Single Key Design**: All vault state is consolidated under one storage key, simplifying migrations - **Immutable Structure**: `VaultMeta` structure fields are not optional, ensuring data consistency diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f0beba6..87d7d58 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -30,6 +30,7 @@ #![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Symbol}; use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec}; #[contracttype] @@ -39,9 +40,20 @@ pub struct VaultMeta { pub balance: i128, } +/// Maximum allowed length for metadata strings (IPFS CID or URI). +/// IPFS CIDv1 (base32) is typically ~59 chars, CIDv0 is 46 chars. +/// HTTPS URIs can vary, but we cap at 256 chars to prevent storage abuse. +/// This limit balances flexibility with storage cost constraints. +pub const MAX_METADATA_LENGTH: u32 = 256; + #[contracttype] pub enum StorageKey { Meta, + AllowedDepositor, + /// Offering metadata: maps offering_id (String) -> metadata (String) + /// The metadata string typically contains an IPFS CID (e.g., "QmXxx..." or "bafyxxx...") + /// or an HTTPS URI (e.g., "https://example.com/metadata/offering123.json") + OfferingMetadata(String), AllowedDepositors, ApiPrice(Symbol), Paused, @@ -228,6 +240,141 @@ impl CalloraVault { Self::get_meta(env).balance } + // ======================================================================== + // Offering Metadata Management + // ======================================================================== + + /// Set metadata for an offering. Only the owner (issuer) can set metadata. + /// + /// # Parameters + /// - `caller`: Must be the vault owner (authenticated via require_auth) + /// - `offering_id`: Unique identifier for the offering (e.g., "offering-001") + /// - `metadata`: Off-chain metadata reference (IPFS CID or HTTPS URI) + /// + /// # Metadata Format + /// The metadata string should contain: + /// - IPFS CID (v0): e.g., "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco" + /// - IPFS CID (v1): e.g., "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" + /// - HTTPS URI: e.g., "https://example.com/metadata/offering123.json" + /// + /// # Off-chain Usage Pattern + /// Clients should: + /// 1. Call `get_metadata(offering_id)` to retrieve the reference + /// 2. If IPFS CID: Fetch from IPFS gateway (e.g., https://ipfs.io/ipfs/{CID}) + /// 3. If HTTPS URI: Fetch directly via HTTP GET + /// 4. Parse the JSON metadata (expected fields: name, description, image, etc.) + /// + /// # Storage Limits + /// - Maximum metadata length: 256 characters + /// - Exceeding this limit will cause a panic + /// + /// # Events + /// Emits a "metadata_set" event with topics: (metadata_set, offering_id, caller) + /// and data: metadata string + /// + /// # Errors + /// - Panics if caller is not the owner + /// - Panics if metadata exceeds MAX_METADATA_LENGTH + /// - Panics if offering_id already has metadata (use update_metadata instead) + pub fn set_metadata( + env: Env, + caller: Address, + offering_id: String, + metadata: String, + ) -> String { + caller.require_auth(); + Self::require_owner(&env, &caller); + + // Validate metadata length + let metadata_len = metadata.len(); + assert!( + metadata_len <= MAX_METADATA_LENGTH, + "metadata exceeds maximum length of {} characters", + MAX_METADATA_LENGTH + ); + + // Check if metadata already exists + let key = StorageKey::OfferingMetadata(offering_id.clone()); + assert!( + !env.storage().instance().has(&key), + "metadata already exists for this offering; use update_metadata to modify" + ); + + // Store metadata + env.storage().instance().set(&key, &metadata); + + // Emit event: topics = (metadata_set, offering_id, caller), data = metadata + env.events().publish( + (Symbol::new(&env, "metadata_set"), offering_id, caller), + metadata.clone(), + ); + + metadata + } + + /// Update existing metadata for an offering. Only the owner (issuer) can update. + /// + /// # Parameters + /// - `caller`: Must be the vault owner (authenticated via require_auth) + /// - `offering_id`: Unique identifier for the offering + /// - `metadata`: New off-chain metadata reference (IPFS CID or HTTPS URI) + /// + /// # Events + /// Emits a "metadata_updated" event with topics: (metadata_updated, offering_id, caller) + /// and data: (old_metadata, new_metadata) tuple + /// + /// # Errors + /// - Panics if caller is not the owner + /// - Panics if metadata exceeds MAX_METADATA_LENGTH + /// - Panics if offering_id has no existing metadata (use set_metadata first) + pub fn update_metadata( + env: Env, + caller: Address, + offering_id: String, + metadata: String, + ) -> String { + caller.require_auth(); + Self::require_owner(&env, &caller); + + // Validate metadata length + let metadata_len = metadata.len(); + assert!( + metadata_len <= MAX_METADATA_LENGTH, + "metadata exceeds maximum length of {} characters", + MAX_METADATA_LENGTH + ); + + // Check if metadata exists + let key = StorageKey::OfferingMetadata(offering_id.clone()); + let old_metadata: String = env.storage().instance().get(&key).unwrap_or_else(|| { + panic!("no metadata exists for this offering; use set_metadata first") + }); + + // Update metadata + env.storage().instance().set(&key, &metadata); + + // Emit event: topics = (metadata_updated, offering_id, caller), data = (old, new) + env.events().publish( + (Symbol::new(&env, "metadata_updated"), offering_id, caller), + (old_metadata, metadata.clone()), + ); + + metadata + } + + /// Get metadata for an offering. Returns None if no metadata is set. + /// + /// # Parameters + /// - `offering_id`: Unique identifier for the offering + /// + /// # Returns + /// - `Some(metadata)` if metadata exists + /// - `None` if no metadata has been set for this offering + pub fn get_metadata(env: Env, offering_id: String) -> Option { + let key = StorageKey::OfferingMetadata(offering_id); + env.storage().instance().get(&key) + } + pub fn transfer_ownership(env: Env, new_owner: Address) { let mut meta = Self::get_meta(env.clone()); meta.owner.require_auth(); diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 3332271..311d43a 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -264,6 +264,112 @@ fn deposit_after_depositor_cleared_is_rejected() { client.deposit(&depositor, &50); } +// ============================================================================ +// Offering Metadata Tests +// ============================================================================ + +#[test] +fn set_and_retrieve_metadata() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + // Set metadata for an offering + let offering_id = String::from_str(&env, "offering-001"); + let metadata = String::from_str(&env, "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"); + + let result = client.set_metadata(&owner, &offering_id, &metadata); + assert_eq!(result, metadata); + + // Retrieve metadata + let retrieved = client.get_metadata(&offering_id); + assert_eq!(retrieved, Some(metadata)); +} + +#[test] +fn set_metadata_emits_event() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + + env.mock_all_auths(); + + // Initialize first + env.as_contract(&contract_id, || { + CalloraVault::init(env.clone(), owner.clone(), Some(100)); + }); + + let offering_id = String::from_str(&env, "offering-002"); + let metadata = String::from_str( + &env, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + ); + + // Call set_metadata inside as_contract to capture events + let events = env.as_contract(&contract_id, || { + CalloraVault::set_metadata( + env.clone(), + owner.clone(), + offering_id.clone(), + metadata.clone(), + ); + env.events().all() + }); + + // Verify event was emitted + let last_event = events.last().expect("expected metadata_set event"); + + // Verify event structure + assert_eq!(last_event.0, contract_id); + + let topics = &last_event.1; + assert_eq!(topics.len(), 3); + + let topic0: Symbol = topics.get(0).unwrap().into_val(&env); + let topic1: String = topics.get(1).unwrap().into_val(&env); + let topic2: Address = topics.get(2).unwrap().into_val(&env); + + assert_eq!(topic0, Symbol::new(&env, "metadata_set")); + assert_eq!(topic1, offering_id); + assert_eq!(topic2, owner); + + // Data should be the metadata string + let data: String = last_event.2.into_val(&env); + assert_eq!(data, metadata); +} + +#[test] +fn update_metadata_and_verify() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-003"); + let old_metadata = String::from_str(&env, "QmOldMetadata123"); + let new_metadata = String::from_str(&env, "QmNewMetadata456"); + + // Set initial metadata + client.set_metadata(&owner, &offering_id, &old_metadata); + + // Update metadata + let result = client.update_metadata(&owner, &offering_id, &new_metadata); + assert_eq!(result, new_metadata); + + // Verify updated metadata + let retrieved = client.get_metadata(&offering_id); + assert_eq!(retrieved, Some(new_metadata)); +} + #[test] #[should_panic(expected = "amount must be positive")] fn deposit_zero_panics() { @@ -341,6 +447,9 @@ fn test_transfer_ownership() { client.init(&owner, &Some(100)); + env.mock_all_auths(); + + // transfer ownership via client // Owner authorizes transfer (require_auth in contract) client.transfer_ownership(&new_owner); @@ -408,6 +517,89 @@ fn unauthorized_cannot_set_price() { } #[test] +fn update_metadata_emits_event() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + + env.mock_all_auths(); + + // Initialize first + env.as_contract(&contract_id, || { + CalloraVault::init(env.clone(), owner.clone(), Some(100)); + }); + + let offering_id = String::from_str(&env, "offering-004"); + let old_metadata = String::from_str(&env, "https://example.com/old.json"); + let new_metadata = String::from_str(&env, "https://example.com/new.json"); + + // Set initial metadata + env.as_contract(&contract_id, || { + CalloraVault::set_metadata( + env.clone(), + owner.clone(), + offering_id.clone(), + old_metadata.clone(), + ); + }); + + // Update and capture events + let events = env.as_contract(&contract_id, || { + CalloraVault::update_metadata( + env.clone(), + owner.clone(), + offering_id.clone(), + new_metadata.clone(), + ); + env.events().all() + }); + + // Verify event was emitted + let last_event = events.last().expect("expected metadata_updated event"); + + // Verify event structure + assert_eq!(last_event.0, contract_id); + + let topics = &last_event.1; + assert_eq!(topics.len(), 3); + + let topic0: Symbol = topics.get(0).unwrap().into_val(&env); + let topic1: String = topics.get(1).unwrap().into_val(&env); + let topic2: Address = topics.get(2).unwrap().into_val(&env); + + assert_eq!(topic0, Symbol::new(&env, "metadata_updated")); + assert_eq!(topic1, offering_id); + assert_eq!(topic2, owner); + + // Data should be tuple of (old_metadata, new_metadata) + let data: (String, String) = last_event.2.into_val(&env); + assert_eq!(data.0, old_metadata); + assert_eq!(data.1, new_metadata); +} + +#[test] +#[should_panic(expected = "unauthorized: owner only")] +fn unauthorized_cannot_set_metadata() { + let env = Env::default(); + let owner = Address::generate(&env); + let unauthorized = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-005"); + let metadata = String::from_str(&env, "QmUnauthorized"); + + // Unauthorized user tries to set metadata (should panic) + client.set_metadata(&unauthorized, &offering_id, &metadata); +} + +#[test] +#[should_panic(expected = "new_owner must be different from current owner")] +fn test_transfer_ownership_same_address_fails() { #[should_panic(expected = "insufficient balance")] fn deduct_greater_than_balance_panics() { let env = Env::default(); @@ -440,6 +632,10 @@ fn balance_unchanged_after_failed_deduct() { // Mock the owner as the invoker env.mock_all_auths(); + env.mock_all_auths(); + + // This should panic because new_owner is the same as current owner + client.transfer_ownership(&owner); // Attempt to deduct more than balance, which should panic let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { client.deduct(&owner, &101); @@ -452,6 +648,243 @@ fn balance_unchanged_after_failed_deduct() { assert_eq!(client.balance(), 100); } +#[test] +#[should_panic(expected = "unauthorized: owner only")] +fn unauthorized_cannot_update_metadata() { + let env = Env::default(); + let owner = Address::generate(&env); + let unauthorized = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-006"); + let metadata = String::from_str(&env, "QmInitial"); + + // Owner sets metadata + client.set_metadata(&owner, &offering_id, &metadata); + + // Unauthorized user tries to update (should panic) + let new_metadata = String::from_str(&env, "QmUnauthorized"); + client.update_metadata(&unauthorized, &offering_id, &new_metadata); +} + +#[test] +fn empty_metadata_is_allowed() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-007"); + let empty_metadata = String::from_str(&env, ""); + + // Empty string should be allowed + client.set_metadata(&owner, &offering_id, &empty_metadata); + + let retrieved = client.get_metadata(&offering_id); + assert_eq!(retrieved, Some(empty_metadata)); +} + +#[test] +#[should_panic(expected = "metadata exceeds maximum length")] +fn oversized_metadata_is_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-008"); + + // Create a string that exceeds MAX_METADATA_LENGTH (256 chars) + let oversized = "a".repeat(257); + let oversized_metadata = String::from_str(&env, &oversized); + + // Should panic due to length constraint + client.set_metadata(&owner, &offering_id, &oversized_metadata); +} + +#[test] +#[should_panic(expected = "metadata exceeds maximum length")] +fn oversized_update_is_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-009"); + let initial_metadata = String::from_str(&env, "QmInitial"); + + // Set initial metadata + client.set_metadata(&owner, &offering_id, &initial_metadata); + + // Try to update with oversized metadata + let oversized = "b".repeat(257); + let oversized_metadata = String::from_str(&env, &oversized); + + // Should panic due to length constraint + client.update_metadata(&owner, &offering_id, &oversized_metadata); +} + +#[test] +fn repeated_updates_to_same_offering() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-010"); + + // Set initial metadata + let metadata1 = String::from_str(&env, "QmVersion1"); + client.set_metadata(&owner, &offering_id, &metadata1); + assert_eq!(client.get_metadata(&offering_id), Some(metadata1)); + + // Update multiple times + let metadata2 = String::from_str(&env, "QmVersion2"); + client.update_metadata(&owner, &offering_id, &metadata2); + assert_eq!(client.get_metadata(&offering_id), Some(metadata2)); + + let metadata3 = String::from_str(&env, "QmVersion3"); + client.update_metadata(&owner, &offering_id, &metadata3); + assert_eq!(client.get_metadata(&offering_id), Some(metadata3)); + + let metadata4 = String::from_str(&env, "QmVersion4"); + client.update_metadata(&owner, &offering_id, &metadata4); + assert_eq!(client.get_metadata(&offering_id), Some(metadata4)); +} + +#[test] +#[should_panic(expected = "metadata already exists for this offering")] +fn cannot_set_metadata_twice() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-011"); + let metadata1 = String::from_str(&env, "QmFirst"); + let metadata2 = String::from_str(&env, "QmSecond"); + + // Set metadata + client.set_metadata(&owner, &offering_id, &metadata1); + + // Try to set again (should panic) + client.set_metadata(&owner, &offering_id, &metadata2); +} + +#[test] +#[should_panic(expected = "no metadata exists for this offering")] +fn cannot_update_nonexistent_metadata() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-012"); + let metadata = String::from_str(&env, "QmNonexistent"); + + // Try to update without setting first (should panic) + client.update_metadata(&owner, &offering_id, &metadata); +} + +#[test] +fn get_nonexistent_metadata_returns_none() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + let offering_id = String::from_str(&env, "offering-nonexistent"); + + // Should return None for nonexistent metadata + let retrieved = client.get_metadata(&offering_id); + assert_eq!(retrieved, None); +} + +#[test] +fn metadata_at_max_length_is_accepted() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + let offering_id = String::from_str(&env, "offering-013"); + + // Create a string exactly at MAX_METADATA_LENGTH (256 chars) + let max_length = "x".repeat(256); + let max_metadata = String::from_str(&env, &max_length); + + // Should succeed + client.set_metadata(&owner, &offering_id, &max_metadata); + + let retrieved = client.get_metadata(&offering_id); + assert_eq!(retrieved, Some(max_metadata)); +} + +#[test] +fn multiple_offerings_can_have_metadata() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + // Set metadata for multiple offerings + let offering1 = String::from_str(&env, "offering-A"); + let metadata1 = String::from_str(&env, "QmMetadataA"); + client.set_metadata(&owner, &offering1, &metadata1); + + let offering2 = String::from_str(&env, "offering-B"); + let metadata2 = String::from_str(&env, "QmMetadataB"); + client.set_metadata(&owner, &offering2, &metadata2); + + let offering3 = String::from_str(&env, "offering-C"); + let metadata3 = String::from_str(&env, "QmMetadataC"); + client.set_metadata(&owner, &offering3, &metadata3); + + // Verify all metadata is stored independently + assert_eq!(client.get_metadata(&offering1), Some(metadata1)); + assert_eq!(client.get_metadata(&offering2), Some(metadata2)); + assert_eq!(client.get_metadata(&offering3), Some(metadata3)); +} + #[test] #[should_panic] fn test_transfer_ownership_not_owner() { diff --git a/scripts/check-wasm-size.sh b/scripts/check-wasm-size.sh new file mode 100755 index 0000000..821a448 --- /dev/null +++ b/scripts/check-wasm-size.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Check that vault contract WASM binary stays under 64KB limit + +set -e + +# Build the vault contract in release mode +echo "Building vault contract..." +cargo build --target wasm32-unknown-unknown --release -p callora-vault + +# Get the WASM file size +WASM_FILE="target/wasm32-unknown-unknown/release/callora_vault.wasm" +SIZE=$(wc -c < "$WASM_FILE") +SIZE_KB=$((SIZE / 1024)) +MAX_SIZE=$((64 * 1024)) # 64KB in bytes + +echo "Vault WASM size: $SIZE bytes (${SIZE_KB}KB)" +echo "Maximum allowed: $MAX_SIZE bytes (64KB)" + +# Check if size exceeds limit +if [ "$SIZE" -gt "$MAX_SIZE" ]; then + echo "❌ ERROR: WASM binary exceeds 64KB limit!" + echo " Current: ${SIZE_KB}KB" + echo " Limit: 64KB" + exit 1 +else + REMAINING=$((MAX_SIZE - SIZE)) + REMAINING_KB=$((REMAINING / 1024)) + echo "✅ WASM size check passed!" + echo " Remaining headroom: ${REMAINING_KB}KB" + exit 0 +fi