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
42 changes: 42 additions & 0 deletions multisig/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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<Address> {
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);
}
}
44 changes: 44 additions & 0 deletions multisig/src/storage_test.rs
Original file line number Diff line number Diff line change
@@ -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));
});
}
7 changes: 7 additions & 0 deletions multisig/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address, u32>: every signer and their voting weight.
Signers,
/// Approval weight an action needs for `is_approved`.
Expand Down
7 changes: 7 additions & 0 deletions options_market/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_std]

mod storage;
mod types;

#[cfg(test)]
mod test;
98 changes: 98 additions & 0 deletions options_market/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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<OptionPosition> {
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<u64>`. 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<u64> {
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<u64> {
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
}
111 changes: 111 additions & 0 deletions options_market/src/test.rs
Original file line number Diff line number Diff line change
@@ -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);
});
}
69 changes: 69 additions & 0 deletions options_market/src/types.rs
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions price_oracle/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_std]

mod storage;
mod types;

#[cfg(test)]
mod storage_test;
Loading
Loading