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),