diff --git a/multisig/src/storage.rs b/multisig/src/storage.rs new file mode 100644 index 0000000..e8de6be --- /dev/null +++ b/multisig/src/storage.rs @@ -0,0 +1,42 @@ +use soroban_sdk::{Address, Env, Vec}; + +use crate::types::DataKey; + +// Signers (issue #101): dedicated persistent entries plus an O(1) membership +// map, instead of a `Signers` list loaded in full from instance storage on +// every invocation and checked with `Vec::contains`. + +pub fn is_signer(env: &Env, signer: &Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::IsSigner(signer.clone())) + .unwrap_or(false) +} + +pub fn signer_list(env: &Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::Signers) + .unwrap_or(Vec::new(env)) +} + +pub fn add_signer(env: &Env, signer: &Address) { + env.storage() + .persistent() + .set(&DataKey::IsSigner(signer.clone()), &true); + let mut list = signer_list(env); + list.push_back(signer.clone()); + env.storage().persistent().set(&DataKey::Signers, &list); +} + +pub fn remove_signer(env: &Env, signer: &Address) { + env.storage() + .persistent() + .remove(&DataKey::IsSigner(signer.clone())); + let list = signer_list(env); + if let Some(idx) = list.iter().position(|s| &s == signer) { + let mut list = list; + list.remove(idx as u32); + env.storage().persistent().set(&DataKey::Signers, &list); + } +} diff --git a/multisig/src/storage_test.rs b/multisig/src/storage_test.rs new file mode 100644 index 0000000..0fb099d --- /dev/null +++ b/multisig/src/storage_test.rs @@ -0,0 +1,44 @@ +use soroban_sdk::{contract, testutils::Address as _, Address, Env}; + +use crate::storage; + +// Dummy contract purely to give these storage-layer unit tests a contract +// context (issue #101 only touches storage/types here). +#[contract] +struct Dummy; + +fn setup() -> (Env, Address) { + let env = Env::default(); + let id = env.register_contract(None, Dummy); + (env, id) +} + +#[test] +fn is_signer_is_o1_membership() { + let (env, id) = setup(); + env.as_contract(&id, || { + let signer = Address::generate(&env); + assert!(!storage::is_signer(&env, &signer)); + storage::add_signer(&env, &signer); + assert!(storage::is_signer(&env, &signer)); + storage::remove_signer(&env, &signer); + assert!(!storage::is_signer(&env, &signer)); + }); +} + +#[test] +fn signer_list_enumerates_added_signers() { + let (env, id) = setup(); + env.as_contract(&id, || { + let a = Address::generate(&env); + let b = Address::generate(&env); + storage::add_signer(&env, &a); + storage::add_signer(&env, &b); + assert_eq!(storage::signer_list(&env).len(), 2); + + storage::remove_signer(&env, &a); + let remaining = storage::signer_list(&env); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining.get(0), Some(b)); + }); +} diff --git a/multisig/src/types.rs b/multisig/src/types.rs index 32a5af4..3ba0d1e 100644 --- a/multisig/src/types.rs +++ b/multisig/src/types.rs @@ -3,6 +3,13 @@ use soroban_sdk::{contracttype, Address, BytesN, Symbol, Val, Vec}; #[contracttype] #[derive(Clone)] pub enum DataKey { +use soroban_sdk::{contracttype, Address, BytesN, Symbol, Val, Vec}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// O(1) membership check — see issue #101. + IsSigner(Address), /// Map: every signer and their voting weight. Signers, /// Approval weight an action needs for `is_approved`. diff --git a/options_market/src/lib.rs b/options_market/src/lib.rs index e69de29..4cfaf0b 100644 --- a/options_market/src/lib.rs +++ b/options_market/src/lib.rs @@ -0,0 +1,7 @@ +#![no_std] + +mod storage; +mod types; + +#[cfg(test)] +mod test; diff --git a/options_market/src/storage.rs b/options_market/src/storage.rs index e69de29..a13099a 100644 --- a/options_market/src/storage.rs +++ b/options_market/src/storage.rs @@ -0,0 +1,98 @@ +use soroban_sdk::{Address, Env, Vec}; + +use crate::types::{Config, DataKey, OptionPosition, Stats, POSITIONS_PER_PAGE}; + +// ─── Config / Stats (issue #102) ─────────────────────────────────────────── + +pub fn load_config(env: &Env) -> Config { + env.storage().instance().get(&DataKey::Config).unwrap() +} + +pub fn save_config(env: &Env, config: &Config) { + env.storage().instance().set(&DataKey::Config, config); +} + +pub fn load_stats(env: &Env) -> Stats { + env.storage() + .instance() + .get(&DataKey::Stats) + .unwrap_or(Stats { + premiums_collected: 0, + open_interest: 0, + premium_pool: 0, + series_count: 0, + position_counter: 0, + }) +} + +pub fn save_stats(env: &Env, stats: &Stats) { + env.storage().instance().set(&DataKey::Stats, stats); +} + +// ─── Positions (issue #103) ──────────────────────────────────────────────── + +pub fn load_position(env: &Env, position_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Position(position_id)) +} + +pub fn save_position(env: &Env, position_id: u64, position: &OptionPosition) { + env.storage() + .persistent() + .set(&DataKey::Position(position_id), position); +} + +// ─── Paginated user position index (issue #100) ──────────────────────────── +// +// Ids are appended to fixed-size pages (`POSITIONS_PER_PAGE` per page) +// instead of one ever-growing `Vec`. Each page is its own persistent +// entry, so a single trade only reads and rewrites the current page rather +// than the user's whole history — bounded write bytes no matter how many +// positions the user has opened. + +pub fn user_position_count(env: &Env, user: &Address) -> u32 { + env.storage() + .persistent() + .get(&DataKey::UserPositionCount(user.clone())) + .unwrap_or(0) +} + +fn load_page(env: &Env, user: &Address, page: u32) -> Vec { + env.storage() + .persistent() + .get(&DataKey::UserPositionPage(user.clone(), page)) + .unwrap_or(Vec::new(env)) +} + +/// O(1) append: only the current (partially-filled) page is read and +/// rewritten; earlier pages are untouched. +pub fn add_user_position(env: &Env, user: &Address, position_id: u64) { + let count = user_position_count(env, user); + let page_index = count / POSITIONS_PER_PAGE; + let mut page = load_page(env, user, page_index); + page.push_back(position_id); + env.storage() + .persistent() + .set(&DataKey::UserPositionPage(user.clone(), page_index), &page); + env.storage() + .persistent() + .set(&DataKey::UserPositionCount(user.clone()), &(count + 1)); +} + +/// Returns up to `limit` position ids starting at `cursor` (an index into +/// the user's full history, oldest first). +pub fn get_user_positions_page(env: &Env, user: &Address, cursor: u32, limit: u32) -> Vec { + let total = user_position_count(env, user); + let mut out = Vec::new(env); + let mut i = cursor; + let end = total.min(cursor.saturating_add(limit)); + while i < end { + let page = load_page(env, user, i / POSITIONS_PER_PAGE); + if let Some(id) = page.get(i % POSITIONS_PER_PAGE) { + out.push_back(id); + } + i += 1; + } + out +} diff --git a/options_market/src/test.rs b/options_market/src/test.rs index e69de29..1341eee 100644 --- a/options_market/src/test.rs +++ b/options_market/src/test.rs @@ -0,0 +1,111 @@ +use soroban_sdk::{contract, testutils::Address as _, Address, Env}; + +use crate::storage; +use crate::types::{Config, OptionPosition, PositionStatus, Stats}; + +// A dummy contract purely so these storage-layer unit tests have a contract +// context to run `env.storage()` calls against (issues #100/#102/#103 only +// touch storage/types — there is no `#[contract]` in this crate to attach +// entrypoints to yet). +#[contract] +struct Dummy; + +fn setup() -> (Env, Address) { + let env = Env::default(); + let id = env.register_contract(None, Dummy); + (env, id) +} + +#[test] +fn config_round_trips() { + let (env, id) = setup(); + env.as_contract(&id, || { + let config = Config { + admin: Address::generate(&env), + oracle: Address::generate(&env), + token: Address::generate(&env), + fee_recipient: Address::generate(&env), + fee_bps: 50, + paused: false, + }; + storage::save_config(&env, &config); + let loaded = storage::load_config(&env); + assert_eq!(loaded.fee_bps, 50); + assert!(!loaded.paused); + }); +} + +#[test] +fn stats_defaults_to_zero_then_round_trips() { + let (env, id) = setup(); + env.as_contract(&id, || { + let stats = storage::load_stats(&env); + assert_eq!(stats.position_counter, 0); + + let updated = Stats { + premiums_collected: 100, + open_interest: 5, + premium_pool: 100, + series_count: 1, + position_counter: 1, + }; + storage::save_stats(&env, &updated); + assert_eq!(storage::load_stats(&env).position_counter, 1); + }); +} + +#[test] +fn position_status_replaces_the_two_bool_flags() { + let (env, id) = setup(); + env.as_contract(&id, || { + let owner = Address::generate(&env); + let position = OptionPosition { + series_id: 1, + owner: owner.clone(), + side_is_buyer: true, + amount: 10, + collateral_locked: 0, + status: PositionStatus::Open, + opened_at: env.ledger().timestamp(), + }; + storage::save_position(&env, 1, &position); + let loaded = storage::load_position(&env, 1).unwrap(); + assert_eq!(loaded.status, PositionStatus::Open); + + let exercised = OptionPosition { + status: PositionStatus::Exercised, + ..loaded + }; + storage::save_position(&env, 1, &exercised); + assert_eq!( + storage::load_position(&env, 1).unwrap().status, + PositionStatus::Exercised + ); + }); +} + +#[test] +fn user_positions_append_in_o1_and_paginate() { + let (env, id) = setup(); + env.as_contract(&id, || { + let user = Address::generate(&env); + for i in 0..150u64 { + storage::add_user_position(&env, &user, i); + } + assert_eq!(storage::user_position_count(&env, &user), 150); + + let page = storage::get_user_positions_page(&env, &user, 0, 10); + assert_eq!(page.len(), 10); + assert_eq!(page.get(0), Some(0)); + + // Crosses a page boundary (POSITIONS_PER_PAGE == 64). + let page = storage::get_user_positions_page(&env, &user, 60, 10); + assert_eq!(page.len(), 10); + assert_eq!(page.get(0), Some(60)); + assert_eq!(page.get(9), Some(69)); + + // Past the end. + let page = storage::get_user_positions_page(&env, &user, 145, 10); + assert_eq!(page.len(), 5); + }); +} diff --git a/options_market/src/types.rs b/options_market/src/types.rs index e69de29..f4e420a 100644 --- a/options_market/src/types.rs +++ b/options_market/src/types.rs @@ -0,0 +1,69 @@ +use soroban_sdk::{contracttype, Address}; + +/// Consolidated hot configuration, loaded once per call instead of five +/// separate instance reads (CollateralToken, FeeRecipient, FeeRateBps, +/// PremiumPool, TotalPremiumsCollected). See issue #102. +#[contracttype] +#[derive(Clone)] +pub struct Config { + pub admin: Address, + pub oracle: Address, + pub token: Address, + pub fee_recipient: Address, + pub fee_bps: u32, + pub paused: bool, +} + +/// Aggregated counters, written once per call instead of several +/// read-modify-write single-counter entries. See issue #102. +#[contracttype] +#[derive(Clone)] +pub struct Stats { + pub premiums_collected: i128, + pub open_interest: i128, + pub premium_pool: i128, + pub series_count: u64, + pub position_counter: u64, +} + +/// `Open`/`Exercised`/`Settled`/`Refunded` replaces the two `bool` flags +/// (`is_exercised`, `is_settled`) that used to live directly on +/// `OptionPosition`. See issue #103. +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PositionStatus { + Open, + Exercised, + Settled, + Refunded, +} + +/// Compact position layout (issue #103): the boolean flags are folded into +/// `status`, and the struct only carries the fields every reader needs. +/// `opened_at` is a cold field kept here rather than split into a second +/// entry — splitting it out is left for a follow-up once it's clear readers +/// need `OptionPosition` without paying for it. +#[contracttype] +#[derive(Clone)] +pub struct OptionPosition { + pub series_id: u64, + pub owner: Address, + pub side_is_buyer: bool, + pub amount: i128, + pub collateral_locked: i128, + pub status: PositionStatus, + pub opened_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Config, + Stats, + Position(u64), + UserPositionCount(Address), + UserPositionPage(Address, u32), +} + +/// Number of position ids stored per `UserPositionPage` bucket (issue #100). +pub const POSITIONS_PER_PAGE: u32 = 64; diff --git a/price_oracle/src/lib.rs b/price_oracle/src/lib.rs index e69de29..3c1f7e4 100644 --- a/price_oracle/src/lib.rs +++ b/price_oracle/src/lib.rs @@ -0,0 +1,7 @@ +#![no_std] + +mod storage; +mod types; + +#[cfg(test)] +mod storage_test; diff --git a/price_oracle/src/storage.rs b/price_oracle/src/storage.rs new file mode 100644 index 0000000..c86b73a --- /dev/null +++ b/price_oracle/src/storage.rs @@ -0,0 +1,43 @@ +use soroban_sdk::{Address, Env, Vec}; + +use crate::types::DataKey; + +// Feeders (issue #101): moved out of instance storage — which is loaded in +// full on every invocation, including the hot `report_price`/`get_price` +// paths — into dedicated persistent entries, with an O(1) membership map +// instead of `Vec::contains`. + +pub fn is_feeder(env: &Env, feeder: &Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::IsFeeder(feeder.clone())) + .unwrap_or(false) +} + +pub fn feeder_list(env: &Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::Feeders) + .unwrap_or(Vec::new(env)) +} + +pub fn add_feeder(env: &Env, feeder: &Address) { + env.storage() + .persistent() + .set(&DataKey::IsFeeder(feeder.clone()), &true); + let mut list = feeder_list(env); + list.push_back(feeder.clone()); + env.storage().persistent().set(&DataKey::Feeders, &list); +} + +pub fn remove_feeder(env: &Env, feeder: &Address) { + env.storage() + .persistent() + .remove(&DataKey::IsFeeder(feeder.clone())); + let list = feeder_list(env); + if let Some(idx) = list.iter().position(|f| &f == feeder) { + let mut list = list; + list.remove(idx as u32); + env.storage().persistent().set(&DataKey::Feeders, &list); + } +} diff --git a/price_oracle/src/storage_test.rs b/price_oracle/src/storage_test.rs new file mode 100644 index 0000000..3287ddc --- /dev/null +++ b/price_oracle/src/storage_test.rs @@ -0,0 +1,44 @@ +use soroban_sdk::{contract, testutils::Address as _, Address, Env}; + +use crate::storage; + +// Dummy contract purely to give these storage-layer unit tests a contract +// context (issue #101 only touches storage/types here). +#[contract] +struct Dummy; + +fn setup() -> (Env, Address) { + let env = Env::default(); + let id = env.register_contract(None, Dummy); + (env, id) +} + +#[test] +fn is_feeder_is_o1_membership() { + let (env, id) = setup(); + env.as_contract(&id, || { + let feeder = Address::generate(&env); + assert!(!storage::is_feeder(&env, &feeder)); + storage::add_feeder(&env, &feeder); + assert!(storage::is_feeder(&env, &feeder)); + storage::remove_feeder(&env, &feeder); + assert!(!storage::is_feeder(&env, &feeder)); + }); +} + +#[test] +fn feeder_list_enumerates_added_feeders() { + let (env, id) = setup(); + env.as_contract(&id, || { + let a = Address::generate(&env); + let b = Address::generate(&env); + storage::add_feeder(&env, &a); + storage::add_feeder(&env, &b); + assert_eq!(storage::feeder_list(&env).len(), 2); + + storage::remove_feeder(&env, &a); + let remaining = storage::feeder_list(&env); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining.get(0), Some(b)); + }); +} diff --git a/price_oracle/src/types.rs b/price_oracle/src/types.rs index 74bcf5c..476d0e1 100644 --- a/price_oracle/src/types.rs +++ b/price_oracle/src/types.rs @@ -10,6 +10,10 @@ pub enum DataKey { /// before it returns an aggregate at all. Defaults to 1 at initialize /// (the original behavior: any single fresh report is enough). MinReports, + /// O(1) membership check — see issue #101. `Feeders` below stays around + /// only for enumeration (e.g. `get_feeders`), and now lives in + /// persistent storage instead of instance storage. + IsFeeder(Address), Feeders, PriceReport(Symbol, Address), AggregatedPrice(Symbol),