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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ All handlers check ──► RoleStore (role-based access control)

MarketFactory ──► deploy MarketToken (SEP-41 LP) + register in DataStore
Reader ──► stateless views over DataStore (no writes)

Periphery (stateless, deployed out-of-band — see Contract Map below):
MarketUtilReader ──► read-only OI-to-pool-depth ratio
FeeBatchSweeper ──► batches FeeHandler.claim_fees across markets/tokens
InsuranceFundRouter ──► routes liquidation penalties, covers pool shortfalls
OrderCleanup ──► cancels expired orders, previews cleanup fees
```

### Contract Map
Expand All @@ -95,6 +101,18 @@ Reader ──► stateless views over DataStore (no writes)
| `referral_storage` | On-chain referral code registry with tier-based rebate/discount config. |
| `reader` | Read-only aggregate views: positions, markets, OI, funding, liquidation checks. Stores only upgrade admin metadata. |
| `exchange_router` | Single user entry point. Supports multicall for atomic multi-step actions. |
| `market_util_reader` | Read-only view of a market's OI-to-pool-depth utilisation and OI-cap status. Stateless — no init, no writes. |
| `fee_batch_sweeper` | Batches `fee_handler.claim_fees` across many market/token pairs in one call. Stateless — delegates auth and accounting to `fee_handler`. |
| `insurance_fund_router` | Configures per-market insurance funds and routes liquidation penalties / shortfall coverage between pool, treasury, and fund. |
| `order_cleanup` | Cancels orders that have sat unexecuted past their configured expiry and previews the resulting cleanup fee. |

`market_util_reader`, `fee_batch_sweeper`, `insurance_fund_router`, and
`order_cleanup` are stateless periphery contracts: none of them expose an
`initialize` entrypoint, and every dependency (`data_store`, `oracle`,
`fee_handler`, `order_handler`) is passed in per-call rather than stored at
init. They are intentionally deployed out-of-band from the core protocol
graph — see [`docs/deployment.md`](docs/deployment.md#7-periphery-contracts-deployed-out-of-band)
— with `make deploy-contract CONTRACT=<name>`.

### Shared Libraries

Expand Down Expand Up @@ -1078,7 +1096,11 @@ contracts/
│ ├── fee_handler/ # fee distribution and claims
│ ├── referral_storage/ # referral codes and tier rebates
│ ├── reader/ # stateless aggregate views
│ └── exchange_router/ # user entry point, multicall
│ ├── exchange_router/ # user entry point, multicall
│ ├── market_util_reader/ # stateless OI-to-pool-depth view (periphery)
│ ├── fee_batch_sweeper/ # batches fee_handler claims (periphery)
│ ├── insurance_fund_router/ # liquidation penalty routing (periphery)
│ └── order_cleanup/ # expired order cancellation (periphery)
└── libs/
├── types/ # shared #[contracttype] structs
Expand Down
96 changes: 92 additions & 4 deletions contracts/reader/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ impl Reader {
data_store: Address,
market_token: Address,
) -> FundingRateInfo {
let market = Self::get_market(env.clone(), data_store.clone(), market_token.clone());
let ds = DataStoreClient::new(&env, &data_store);
const LEDGERS_PER_HOUR: i128 = 720;

Expand All @@ -272,16 +273,20 @@ impl Reader {
let long_funding_rate_per_hour = funding_factor_per_second.saturating_mul(LEDGERS_PER_HOUR);
let short_funding_rate_per_hour = long_funding_rate_per_hour.saturating_neg();

let long_fnd_key = funding_amount_per_size_key(&env, &market_token, &market_token, true);
let short_fnd_key = funding_amount_per_size_key(&env, &market_token, &market_token, false);
// Long side tracks funding in long_token collateral; short in short_token
// (issue #397 — these must match get_funding_info's key derivation).
let long_fnd_key =
funding_amount_per_size_key(&env, &market_token, &market.long_token, true);
let short_fnd_key =
funding_amount_per_size_key(&env, &market_token, &market.short_token, false);
let long_funding_amount_per_size = ds.get_i128(&long_fnd_key);
let short_funding_amount_per_size = ds.get_i128(&short_fnd_key);

let updated_at_key = funding_updated_at_key(&env, &market_token);
let funding_updated_at_ledger = ds.get_u128(&updated_at_key) as u64;

let long_oi_key = open_interest_key(&env, &market_token, &market_token, true);
let short_oi_key = open_interest_key(&env, &market_token, &market_token, false);
let long_oi_key = open_interest_key(&env, &market_token, &market.long_token, true);
let short_oi_key = open_interest_key(&env, &market_token, &market.short_token, false);
let long_open_interest_usd = ds.get_u128(&long_oi_key);
let short_open_interest_usd = ds.get_u128(&short_oi_key);

Expand Down Expand Up @@ -1663,6 +1668,89 @@ mod tests {
assert_eq!(stats.total_accumulated_fees_usd, 0);
}

// ── Issue #397: get_funding_rate_info key derivation ─────────────────────

/// Both `get_funding_info` and `get_funding_rate_info` must read the same
/// long/short funding + OI storage slots — keyed by (market, long_token) and
/// (market, short_token), never by (market, market_token).
#[test]
fn funding_rate_info_matches_funding_info_keys() {
let w = setup();
let env = &w.env;
let ds_c = DsClient::new(env, &w.ds);

let market_tk = Address::generate(env);
let long_tk = Address::generate(env);
let short_tk = Address::generate(env);
let index_tk = Address::generate(env);

ds_c.set_address(&w.admin, &market_index_token_key(env, &market_tk), &index_tk);
ds_c.set_address(&w.admin, &market_long_token_key(env, &market_tk), &long_tk);
ds_c.set_address(&w.admin, &market_short_token_key(env, &market_tk), &short_tk);

ds_c.set_i128(
&w.admin,
&saved_funding_factor_per_second_key(env, &market_tk),
&1_000i128,
);
ds_c.set_i128(
&w.admin,
&funding_amount_per_size_key(env, &market_tk, &long_tk, true),
&111i128,
);
ds_c.set_i128(
&w.admin,
&funding_amount_per_size_key(env, &market_tk, &short_tk, false),
&222i128,
);
ds_c.set_u128(
&w.admin,
&open_interest_key(env, &market_tk, &long_tk, true),
&5_000u128,
);
ds_c.set_u128(
&w.admin,
&open_interest_key(env, &market_tk, &short_tk, false),
&6_000u128,
);

let reader = ReaderClient::new(env, &w.reader);
let info = reader.get_funding_info(&w.ds, &market_tk);
assert_eq!(info.long_funding_amount_per_size, 111);
assert_eq!(info.short_funding_amount_per_size, 222);

let rate_info = reader.get_funding_rate_info(&w.ds, &market_tk);
assert_eq!(rate_info.long_funding_amount_per_size, 111);
assert_eq!(rate_info.short_funding_amount_per_size, 222);
assert_eq!(rate_info.long_open_interest_usd, 5_000);
assert_eq!(rate_info.short_open_interest_usd, 6_000);
assert_eq!(rate_info.long_funding_rate_per_hour, 1_000 * 720);
assert_eq!(rate_info.short_funding_rate_per_hour, -1_000 * 720);
}

/// A market with no funding/OI data written yet reads zeros, not a panic —
/// (market, market_token) slots (the pre-fix bug) are simply never touched.
#[test]
fn funding_rate_info_zero_when_unset() {
let w = setup();
let env = &w.env;
let ds_c = DsClient::new(env, &w.ds);

let market_tk = Address::generate(env);
let long_tk = Address::generate(env);
let short_tk = Address::generate(env);
let index_tk = Address::generate(env);
ds_c.set_address(&w.admin, &market_index_token_key(env, &market_tk), &index_tk);
ds_c.set_address(&w.admin, &market_long_token_key(env, &market_tk), &long_tk);
ds_c.set_address(&w.admin, &market_short_token_key(env, &market_tk), &short_tk);

let rate_info = ReaderClient::new(env, &w.reader).get_funding_rate_info(&w.ds, &market_tk);
assert_eq!(rate_info.long_funding_amount_per_size, 0);
assert_eq!(rate_info.short_funding_amount_per_size, 0);
assert_eq!(rate_info.long_open_interest_usd, 0);
assert_eq!(rate_info.short_open_interest_usd, 0);
}

/// More than MAX_STATS_MARKETS markets must revert with TooManyMarkets.
#[test]
#[should_panic]
Expand Down
104 changes: 85 additions & 19 deletions contracts/test_faucet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@

use soroban_sdk::{
contract, contractclient, contracterror, contractimpl, contracttype, panic_with_error,
symbol_short, Address, Env, Vec,
symbol_short, Address, BytesN, Env, Vec,
};

/// `network_id` (SHA-256 of the network passphrase) for the Stellar public
/// network. Test faucets must never be initialized here (issue #400).
const MAINNET_NETWORK_ID: [u8; 32] = [
0x7a, 0xc3, 0x39, 0x97, 0x54, 0x4e, 0x31, 0x75, 0xd2, 0x66, 0xbd, 0x02, 0x24, 0x39, 0xb2, 0x2c,
0xdb, 0x16, 0x50, 0x8c, 0x01, 0x16, 0x3f, 0x26, 0xe5, 0xcb, 0x2a, 0x3e, 0x10, 0x45, 0xa9, 0x79,
];

#[allow(dead_code)]
#[contractclient(name = "TestTokenClient")]
trait ITestToken {
Expand All @@ -26,6 +33,7 @@ pub enum Error {
TokenNotEnabled = 4,
InvalidAmount = 5,
ClaimTooSoon = 6,
MainnetNotAllowed = 7,
}

#[contracttype]
Expand All @@ -46,6 +54,7 @@ pub struct TestFaucet;
#[contractimpl]
impl TestFaucet {
pub fn initialize(env: Env, admin: Address, cooldown_ledgers: u32) {
require_not_mainnet(&env);
if env.storage().instance().has(&InstanceKey::Admin) {
panic_with_error!(&env, Error::AlreadyInitialized);
}
Expand Down Expand Up @@ -113,34 +122,51 @@ impl TestFaucet {

pub fn claim(env: Env, account: Address, token: Address) -> i128 {
account.require_auth();
let amount = Self::claim_amount(env.clone(), token.clone());
if amount <= 0 {
panic_with_error!(&env, Error::TokenNotEnabled);
}

enforce_cooldown(&env, &account, &token);

let faucet = env.current_contract_address();
TestTokenClient::new(&env, &token).mint(&faucet, &account, &amount);

env.storage().persistent().set(
&DataKey::LastClaim(account.clone(), token.clone()),
&env.ledger().sequence(),
);
env.events()
.publish((symbol_short!("claim"),), (account, token, amount));
amount
do_claim(&env, &account, token)
}

/// Claim multiple tokens in a single transaction.
///
/// Authorizes `account` once for the whole call rather than once per token
/// (issue #399) — looping `Self::claim` per token previously called
/// `account.require_auth()` once per iteration within the same invocation,
/// which hit a Soroban auth-reuse bug and failed for real signed transactions.
pub fn claim_many(env: Env, account: Address, tokens: Vec<Address>) -> Vec<i128> {
account.require_auth();
let mut amounts = Vec::new(&env);
for token in tokens.iter() {
amounts.push_back(Self::claim(env.clone(), account.clone(), token));
amounts.push_back(do_claim(&env, &account, token));
}
amounts
}
}

fn do_claim(env: &Env, account: &Address, token: Address) -> i128 {
let amount = TestFaucet::claim_amount(env.clone(), token.clone());
if amount <= 0 {
panic_with_error!(env, Error::TokenNotEnabled);
}

enforce_cooldown(env, account, &token);

let faucet = env.current_contract_address();
TestTokenClient::new(env, &token).mint(&faucet, account, &amount);

env.storage().persistent().set(
&DataKey::LastClaim(account.clone(), token.clone()),
&env.ledger().sequence(),
);
env.events()
.publish((symbol_short!("claim"),), (account.clone(), token, amount));
amount
}

fn require_not_mainnet(env: &Env) {
if env.ledger().network_id() == BytesN::from_array(env, &MAINNET_NETWORK_ID) {
panic_with_error!(env, Error::MainnetNotAllowed);
}
}

fn get_admin(env: &Env) -> Address {
env.storage()
.instance()
Expand Down Expand Up @@ -230,6 +256,33 @@ mod tests {
faucet.claim(&user, &token_id);
}

/// Issue #399: `claim_many` must authorize `account` once and mint every
/// configured token in a single call, matching what `claim` does per-token.
#[test]
fn claim_many_mints_every_configured_token() {
let (env, admin, token_a_id, faucet) = setup();
let user = Address::generate(&env);

let token_b_id = env.register(TestToken, ());
let token_b = TokenClient::new(&env, &token_b_id);
token_b.initialize(
&faucet.address,
&7,
&String::from_str(&env, "Test Wrapped Bitcoin"),
&String::from_str(&env, "TWBTC"),
);
faucet.set_token(&admin, &token_b_id, &50_0000000);

let amounts = faucet.claim_many(
&user,
&Vec::from_array(&env, [token_a_id.clone(), token_b_id.clone()]),
);

assert_eq!(amounts, Vec::from_array(&env, [100_0000000, 50_0000000]));
assert_eq!(TokenClient::new(&env, &token_a_id).balance(&user), 100_0000000);
assert_eq!(token_b.balance(&user), 50_0000000);
}

#[test]
#[should_panic]
fn admin_must_configure_token() {
Expand All @@ -243,4 +296,17 @@ mod tests {

faucet.claim(&user, &Address::generate(&env));
}

/// Issue #400: initializing against the mainnet `network_id` must panic —
/// the faucet must never come up live on mainnet.
#[test]
#[should_panic]
fn initialize_rejects_mainnet_network_id() {
let env = Env::default();
env.mock_all_auths();
env.ledger().set_network_id(MAINNET_NETWORK_ID);
let admin = Address::generate(&env);
let faucet_id = env.register(TestFaucet, ());
TestFaucetClient::new(&env, &faucet_id).initialize(&admin, &10);
}
}
Loading
Loading