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
35 changes: 35 additions & 0 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub enum VaultError {
OfferingIdTooLong = 26,
/// Metadata exceeds maximum length (code 27).
MetadataTooLong = 27,
/// Price parsing error or non‑positive price (code 28).
PriceParseError = 28,
}

#[contracttype]
Expand Down Expand Up @@ -113,6 +115,7 @@ pub enum StorageKey {
MaxDeduct,
Paused,
Metadata(String),
Price(String),
PendingOwner,
PendingAdmin,
DepositorList,
Expand Down Expand Up @@ -850,6 +853,38 @@ impl CalloraVault {
Ok(metadata)
}

/// Set price for an offering (owner only).
///
/// # Errors
/// - `VaultError::OfferingIdTooLong` when `offering_id` exceeds maximum length.
/// - `VaultError::PriceParseError` when `price` cannot be parsed to a positive i128.
pub fn set_price(env: Env, caller: Address, offering_id: String, price: String) -> Result<(), VaultError> {
caller.require_auth();
Self::require_owner(env.clone(), caller.clone())?;
if offering_id.len() > MAX_OFFERING_ID_LEN {
return Err(VaultError::OfferingIdTooLong);
}
let price_i128: i128 = price.parse().map_err(|_| VaultError::PriceParseError)?;
if price_i128 <= 0 {
return Err(VaultError::PriceParseError);
}
env.storage()
.instance()
.set(&StorageKey::Price(offering_id.clone()), &price);
env.events().publish(
(Symbol::new(&env, "price_set"), caller, offering_id),
price.clone(),
);
Ok(())
}

/// Get stored price for an offering.
pub fn get_price(env: Env, offering_id: String) -> Option<String> {
env.storage()
.instance()
.get(&StorageKey::Price(offering_id))
}

pub fn update_metadata(
env: Env,
caller: Address,
Expand Down
38 changes: 37 additions & 1 deletion contracts/vault/src/test_setter_validation.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
extern crate std;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{token, Address, Env};
use soroban_sdk::{token, Address, Env, Symbol, String};
use super::*;

fn create_usdc(env: &Env, admin: &Address) -> (Address, token::StellarAssetClient) {
let ca = env.register_stellar_asset_contract_v2(admin.clone());
let addr = ca.address();
(addr.clone(), token::StellarAssetClient::new(env, &addr))
}

fn create_vault(env: &Env) -> (Address, CalloraVaultClient) {
let addr = env.register(CalloraVault, ());
(addr, CalloraVaultClient::new(env, &addr))
}

fn setup(env: &Env) -> (Address, CalloraVaultClient, Address, Address) {
env.mock_all_auths();
let admin = Address::generate(env);
Expand All @@ -19,6 +22,39 @@ fn setup(env: &Env) -> (Address, CalloraVaultClient, Address, Address) {
client.init(&admin, &usdc, &None, &None, &None, &None, &None);
(vault_addr, client, usdc, admin)
}

#[test]
#[should_panic(expected = "OfferingIdTooLong")]
fn set_price_offering_id_too_long() {
let env = Env::default();
let (_, client, _, admin) = setup(&env);
let long_id = "a".repeat((MAX_OFFERING_ID_LEN + 1) as usize);
client.set_price(&admin, &long_id, "100");
}

#[test]
#[should_panic(expected = "PriceParseError")]
fn set_price_zero_price() {
let env = Env::default();
let (_, client, _, admin) = setup(&env);
client.set_price(&admin, "off1", "0");
}

#[test]
fn set_price_successful() {
let env = Env::default();
let (_, client, _, admin) = setup(&env);
client.set_price(&admin, "off1", "1000").unwrap();
// Verify readback
let stored = client.get_price(&"off1".to_string());
assert_eq!(stored, Some("1000".to_string()));
// Verify event emitted (using try call to capture events)
let events = env.events().all();
// Find price_set event
let price_set = events.iter().find(|e| e.topics[0].to_string() == "price_set");
assert!(price_set.is_some(), "price_set event not emitted");
}

#[test]
#[should_panic(expected = "settlement cannot be vault address")]
fn set_settlement_vault_address_panics() {
Expand Down
107 changes: 90 additions & 17 deletions docs/interfaces/vault.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
{
"contract": "callora-vault",
"version": "0.0.1",
"description": "Primary USDC prepaid vault for the Callora API marketplace. Handles deposits, per-call deductions, withdrawals, and optional routing of deducted funds to a settlement or revenue-pool contract.",
Expand Down Expand Up @@ -34,7 +34,8 @@
{ "code": 24, "name": "NoOwnershipTransferPending", "description": "No ownership transfer is pending." },
{ "code": 25, "name": "NoAdminTransferPending", "description": "No admin transfer is pending." },
{ "code": 26, "name": "OfferingIdTooLong", "description": "Offering ID exceeds maximum length." },
{ "code": 27, "name": "MetadataTooLong", "description": "Metadata exceeds maximum length." }
{ "code": 27, "name": "MetadataTooLong", "description": "Metadata exceeds maximum length." },
{ "code": 28, "name": "PriceParseError", "description": "Price parsing error or non‑positive price." }
]
},

Expand All @@ -60,7 +61,7 @@
"functions": [
{
"name": "init",
"description": "Initialize the vault. Can only be called once. The owner must authorize the transaction.",
"description": "Initialize the vault. Can only be called once. Owner must sign the transaction.",
"access": "owner (must sign)",
"params": [
{ "name": "owner", "type": "Address", "optional": false, "description": "Vault owner address; must authorize." },
Expand All @@ -73,39 +74,111 @@
],
"returns": "VaultMeta",
"panics": [
"\"vault already initialized\" — called more than once.",
"\"initial balance must be non-negative\" — initial_balance < 0.",
"\"min_deposit must be non-negative\" — min_deposit < 0.",
"\"max_deduct must be positive\" — max_deduct <= 0.",
"\"min_deposit cannot exceed max_deduct\" — constraint violation.",
"\"usdc_token cannot be vault address\" — self-reference guard.",
"\"revenue_pool cannot be vault address\" — self-reference guard."
"\"vault already initialized\" called more than once.",
"\"initial balance must be non-negative\" initial_balance < 0.",
"\"min_deposit must be non-negative\" min_deposit < 0.",
"\"max_deduct must be positive\" max_deduct <= 0.",
"\"min_deposit cannot exceed max_deduct\" constraint violation.",
"\"usdc_token cannot be vault address\" self-reference guard.",
"\"revenue_pool cannot be vault address\" self-reference guard."
],
"events": [
{ "topics": ["\"init\"", "owner"], "data": "balance (i128)" }
]
},

{
"name": "deposit",
"description": "Transfer USDC from depositor into vault and increase tracked balance. Blocked when paused.",
"access": "owner OR allowed depositor",
"params": [
{ "name": "depositor", "type": "Address", "optional": false, "description": "Must be owner or an allowed depositor; must authorize." },
{ "name": "amount", "type": "i128", "optional": false, "description": "Amount to deposit; must be > 0 and >= min_deposit." }
{ "name": "amount", "type": "i128", "optional": false, "description": "Amount to deposit; must be > 0 and >= min_deposit." }
],
"returns": "i128 (new balance)",
"panics": [
"\"vault is paused\" — circuit breaker is active.",
"\"amount must be positive\" — amount <= 0.",
"\"unauthorized: only owner or allowed depositor can deposit\" — auth failure.",
"\"deposit below minimum: X < Y\" — below min_deposit.",
"\"balance overflow\" — extremely unlikely arithmetic overflow."
"\"vault is paused\" circuit breaker is active.",
"\"amount must be positive\" amount <= 0.",
"\"unauthorized: only owner or allowed depositor can deposit\" auth failure.",
"\"deposit below minimum: X < Y\" below min_deposit.",
"\"balance overflow\" extremely unlikely arithmetic overflow."
],
"events": [
{ "topics": ["\"deposit\"", "depositor"], "data": "(amount, new_balance) tuple" }
]
},
{
"name": "set_price",
"description": "Store off‑chain price for an offering. Owner‑only. Offering ID limited to 64 characters. Price must be a positive integer string.",
"access": "owner",
"params": [
{ "name": "caller", "type": "Address", "optional": false, "description": "Vault owner; must authorize." },
{ "name": "offering_id", "type": "String", "optional": false, "description": "Offering identifier; max 64 characters." },
{ "name": "price", "type": "String", "optional": false, "description": "Price value as string; must parse to a positive i128." }
],
"returns": "void",
"panics": [
"\"unauthorized: owner only\" — caller is not the owner.",
"\"offering_id exceeds max length\" — offering_id.len() > 64.",
"\"price parse error\" — price cannot be parsed to a positive integer."
],
"events": [
{ "topics": ["\"price_set\"", "caller", "offering_id"], "data": "price (String)" }
]
},
{
"name": "get_price",
"description": "Retrieve stored price for an offering. Returns null if not set.",
"access": "any",
"params": [
{ "name": "offering_id", "type": "String", "optional": false, "description": "Offering identifier to look up." }
],
"returns": "String | null",
"panics": [],
"events": []
},
{
"name": "set_metadata",
"description": "Store off-chain metadata (e.g. IPFS CID) for an offering. Owner-only. offering_id max 64 chars; metadata max 256 chars.",
"access": "owner",
"params": [
{ "name": "caller", "type": "Address", "optional": false, "description": "Must be the vault owner; must authorize." },
{ "name": "offering_id", "type": "String", "optional": false, "description": "Offering identifier; max 64 characters." },
{ "name": "metadata", "type": "String", "optional": false, "description": "Metadata value (e.g. IPFS CID or URI); max 256 characters." }
],
"returns": "String (stored metadata)",
"panics": [
"\"unauthorized: owner only\" — caller is not the owner.",
"\"offering_id exceeds max length\" — offering_id.len() > 64.",
"\"metadata exceeds max length\" — metadata.len() > 256."
],
"events": [
{ "topics": ["\"metadata_set\"", "offering_id", "caller"], "data": "metadata (String)" }
]
},
{
"name": "get_metadata",
"description": "Retrieve stored offering metadata. Returns null if not set.",
"access": "any",
"params": [
{ "name": "offering_id", "type": "String", "optional": false, "description": "Offering identifier to look up." }
],
"returns": "String | null",
"panics": [],
"events": []
},
{
"name": "require_owner",
"description": "Utility: panic with 'unauthorized: owner only' if caller is not the vault owner. Exposed publicly so external contracts can use it as a guard.",
"access": "any (panics if caller != owner)",
"params": [
{ "name": "caller", "type": "Address", "optional": false, "description": "Address to validate as owner." }
],
"returns": "void",
"panics": [
"\"unauthorized: owner only\" — caller is not the owner."
],
"events": []
},

{
"name": "deduct",
Expand Down
Loading