Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 9 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<i128>` with the configured price per call for `api_id`

Expand Down Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions contracts/vault/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>` | 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
Expand Down Expand Up @@ -64,6 +72,14 @@ pub struct VaultMeta {

```
Instance Storage
├── Symbol("meta")
│ └── VaultMeta
│ ├── owner: Address
│ └── balance: i128
├── Symbol("AllowedDepositor")
│ └── Option<Address>
└── StorageKey::OfferingMetadata(offering_id: String)
└── String (IPFS CID or URI, max 256 chars)
├── StorageKey::Meta
│ └── VaultMeta
│ ├── owner: Address
Expand All @@ -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
Expand Down
147 changes: 147 additions & 0 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
Expand Down Expand Up @@ -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<String> {
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();
Expand Down
Loading
Loading