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
150 changes: 147 additions & 3 deletions contracts/predictify-hybrid/src/custom_token_tests.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
#![cfg(test)]

use crate::err::Error;
use crate::tokens::{Asset, TokenRegistry};
use crate::{PredictifyHybrid, PredictifyHybridClient};
use crate::types::{OracleConfig, OracleProvider};
use core::convert::TryInto;
use soroban_sdk::{
testutils::{Address as _, Ledger, LedgerInfo},
token::StellarAssetClient,
vec, Address, Env, String, Symbol,
vec, Address, Env, String, Symbol, Vec,
};

// Test setup with flexible token configuration
Expand All @@ -29,7 +32,7 @@ impl CustomTokenTestSetup {
// Register contract
let contract_id = env.register(PredictifyHybrid, ());
let client = PredictifyHybridClient::new(&env, &contract_id);
client.initialize(&\1, &None, &None);
client.initialize(&admin, &None, &None);

// Setup custom token
let token_admin = Address::generate(&env);
Expand Down Expand Up @@ -372,7 +375,7 @@ fn test_fee_collection_custom_token() {
assert_eq!(admin_balance_before, 0);

// Withdraw fees from vault
let withdrawn_amount = client.withdraw_fees(&setup.admin, &fee_amount);
let withdrawn_amount = client.withdraw_collected_fees(&setup.admin, &fee_amount);
assert_eq!(withdrawn_amount, fee_amount);

// Verify admin balance increased by withdrawn amount
Expand Down Expand Up @@ -420,3 +423,144 @@ fn test_deposit_and_withdraw_custom_token() {
assert_eq!(internal_balance_after.amount, 0);
}

#[test]
fn test_register_asset_persists_live_decimals() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(PredictifyHybrid, ());
let admin = Address::generate(&env);

let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin);
let token_id = token_contract.address();
let token_client = soroban_sdk::token::Client::new(&env, &token_id);

let live_decimals: u32 = token_client.decimals().try_into().unwrap();

let asset = Asset::new(token_id.clone(), Symbol::new(&env, "TEST"), 99);

env.as_contract(&contract_id, || {
// Register should persist live SAC decimals instead of the passed-in value
TokenRegistry::register_asset(&env, &asset).unwrap();
});

env.as_contract(&contract_id, || {
let global_assets = TokenRegistry::get_global_assets(&env);
let stored = global_assets
.iter()
.find(|a| a.contract == token_id)
.unwrap();
assert_eq!(stored.decimals, live_decimals);
assert_eq!(stored.symbol, Symbol::new(&env, "TEST"));
});
}

#[test]
fn test_register_asset_re_registration_same_contract() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(PredictifyHybrid, ());
let admin = Address::generate(&env);

let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin);
let token_id = token_contract.address();
let asset = Asset::new(token_id.clone(), Symbol::new(&env, "TEST"), 7);

env.as_contract(&contract_id, || {
// First registration
TokenRegistry::register_asset(&env, &asset).unwrap();

// Re-registration with same contract should succeed (stored decimals match live SAC)
let result = TokenRegistry::register_asset(&env, &asset);
assert!(result.is_ok());

// Verify only one entry exists for this contract
let global_assets = TokenRegistry::get_global_assets(&env);
let count = global_assets
.iter()
.filter(|a| a.contract == token_id)
.count();
assert_eq!(count, 1);
});
}

#[test]
fn test_register_asset_mismatched_decimals() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(PredictifyHybrid, ());
let admin = Address::generate(&env);

let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin);
let token_id = token_contract.address();
let asset = Asset::new(token_id.clone(), Symbol::new(&env, "TEST"), 7);

env.as_contract(&contract_id, || {
// First registration succeeds
TokenRegistry::register_asset(&env, &asset).unwrap();

// Manually modify stored decimals to create mismatch with live SAC
let global_key = Symbol::new(&env, "allowed_assets_global");
let global_assets: Vec<Asset> = env
.storage()
.persistent()
.get(&global_key)
.unwrap();
let mut modified_assets: Vec<Asset> = Vec::new(&env);
for a in global_assets.iter() {
if a.contract == token_id {
modified_assets.push_back(Asset::new(
a.contract.clone(),
a.symbol.clone(),
18,
));
} else {
modified_assets.push_back(a);
}
}
env.storage()
.persistent()
.set(&global_key, &modified_assets);

// Re-register should fail with AssetDecimalsMismatch
let result = TokenRegistry::register_asset(&env, &asset);
assert_eq!(result, Err(Error::AssetDecimalsMismatch));
});
}

#[test]
fn test_asset_validate_edge_decimals() {
let env = Env::default();

// 0 decimals - invalid (below minimum)
let asset_0 = Asset::new(Address::generate(&env), Symbol::new(&env, "ZERO"), 0);
assert!(!asset_0.validate(&env));
assert_eq!(
asset_0.validate_for_market(&env),
Err(Error::InvalidInput)
);

// 1 decimal - valid (minimum)
let asset_1 = Asset::new(Address::generate(&env), Symbol::new(&env, "ONE"), 1);
assert!(asset_1.validate(&env));
assert!(asset_1.validate_for_market(&env).is_ok());

// 18 decimals - valid (maximum)
let asset_18 = Asset::new(Address::generate(&env), Symbol::new(&env, "MAX"), 18);
assert!(asset_18.validate(&env));
assert!(asset_18.validate_for_market(&env).is_ok());

// 19 decimals - invalid (above maximum)
let asset_19 = Asset::new(Address::generate(&env), Symbol::new(&env, "OVER"), 19);
assert!(!asset_19.validate(&env));
assert_eq!(
asset_19.validate_for_market(&env),
Err(Error::InvalidInput)
);
}

13 changes: 13 additions & 0 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ pub enum Error {
GasBudgetExceeded = 417,
/// Admin address has not been set. Contract initialization is incomplete.
AdminNotSet = 418,
/// Asset decimals mismatch. Stored decimals differ from the live SAC decimals.
/// This prevents silently inflated or deflated stakes via normalize_amount.
AssetDecimalsMismatch = 419,

// ===== METADATA LENGTH LIMIT ERRORS (420-434) =====
/// Market question exceeds maximum allowed length.
Expand Down Expand Up @@ -1194,6 +1197,11 @@ impl ErrorHandler {
ErrorCategory::System,
RecoveryStrategy::ManualIntervention,
),
Error::AssetDecimalsMismatch => (
ErrorSeverity::Medium,
ErrorCategory::Validation,
RecoveryStrategy::Abort,
),
Error::DisputeFeeFailed => (
ErrorSeverity::Critical,
ErrorCategory::Financial,
Expand Down Expand Up @@ -1387,6 +1395,9 @@ impl Error {
Error::InvalidExtensionDays => "Invalid extension days value",
Error::ExtensionDenied => "Market extension not allowed",
Error::AdminNotSet => "Admin address not set",
Error::AssetDecimalsMismatch => {
"Asset decimals mismatch: stored decimals differ from live SAC"
}
Error::OracleStale => "Oracle data is stale",
Error::OracleNoConsensus => "Oracle consensus not reached",
Error::OracleVerified => "Oracle result already verified",
Expand Down Expand Up @@ -1481,6 +1492,7 @@ impl Error {
Error::InvalidExtensionDays => "INVALID_EXTENSION_DAYS",
Error::ExtensionDenied => "EXTENSION_DENIED",
Error::AdminNotSet => "ADMIN_NOT_SET",
Error::AssetDecimalsMismatch => "ASSET_DECIMALS_MISMATCH",
Error::OracleStale => "ORACLE_STALE",
Error::OracleNoConsensus => "ORACLE_NO_CONSENSUS",
Error::OracleVerified => "ORACLE_VERIFIED",
Expand Down Expand Up @@ -1596,6 +1608,7 @@ mod tests {
Error::ExtensionDenied,
Error::GasBudgetExceeded,
Error::AdminNotSet,
Error::AssetDecimalsMismatch,
Error::InvalidOracleFeed,
// Metadata length limit errors
Error::QuestionTooLong,
Expand Down
2 changes: 2 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ mod storage;
#[cfg(test)]
mod storage_layout_tests;
pub mod tokens;
#[cfg(test)]
mod custom_token_tests;
mod types;
mod upgrade_manager;
mod utils;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,7 @@ fn test_set_event_bet_limits_forged_admin_rejected() {
#[test]
fn test_set_oracle_val_cfg_global_authorized_admin_succeeds() {
let (env, cid, admin) = setup();
let result = client(&env, &cid).try_set_oracle_val_cfg_global(&admin, &300u64, &9500u32);
let result = client(&env, &cid).try_set_oracle_val_cfg_global(&admin, &300u64, &9500u32, &None::<u32>);
assert_auth_ok_contract!(result, "set_oracle_val_cfg_global rejected authorized admin");
}

Expand All @@ -1014,7 +1014,7 @@ fn test_set_oracle_val_cfg_global_authorized_admin_succeeds() {
fn test_set_oracle_val_cfg_global_forged_admin_rejected() {
let (env, cid, _admin) = setup();
let attacker = Address::generate(&env);
let result = client(&env, &cid).try_set_oracle_val_cfg_global(&attacker, &300u64, &9500u32);
let result = client(&env, &cid).try_set_oracle_val_cfg_global(&attacker, &300u64, &9500u32, &None::<u32>);
assert_unauthorized_contract!(result);
}

Expand All @@ -1026,7 +1026,7 @@ fn test_set_oracle_val_cfg_event_authorized_admin_succeeds() {
let (env, cid, admin) = setup();
let market_id = make_market(&env, &cid, &admin);
let result = client(&env, &cid).try_set_oracle_val_cfg_event(
&admin, &market_id, &300u64, &9500u32,
&admin, &market_id, &300u64, &9500u32, &None::<u32>,
);
assert_auth_ok_contract!(result, "set_oracle_val_cfg_event rejected authorized admin");
}
Expand All @@ -1038,7 +1038,7 @@ fn test_set_oracle_val_cfg_event_forged_admin_rejected() {
let market_id = make_market(&env, &cid, &admin);
let attacker = Address::generate(&env);
let result = client(&env, &cid).try_set_oracle_val_cfg_event(
&attacker, &market_id, &300u64, &9500u32,
&attacker, &market_id, &300u64, &9500u32, &None::<u32>,
);
assert_unauthorized_contract!(result);
}
Expand Down
45 changes: 45 additions & 0 deletions contracts/predictify-hybrid/src/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use crate::err::Error;
use alloc::{format, string::ToString};
use core::convert::TryInto;
use soroban_sdk::{token, Address, Env, String, Symbol, Vec};

/// Canonical internal scale (7 decimals)
Expand Down Expand Up @@ -244,6 +245,50 @@ impl TokenRegistry {
}
}

/// Registers an asset in the global registry with decimal validation.
///
/// Gets the actual decimals from the live SAC (Stellar Asset Contract) and
/// persists them. On re-registration (same contract address), validates that
/// the live decimals match the stored decimals to prevent denomination mistakes
/// that would silently inflate or deflate stakes via `normalize_amount`.
///
/// # Errors
/// * `Error::InvalidInput` if the SAC decimals are invalid (e.g., negative).
/// * `Error::AssetDecimalsMismatch` if the asset is already registered with
/// different decimals than the live SAC reports.
pub fn register_asset(env: &Env, asset: &Asset) -> Result<(), Error> {
let token_client = token::Client::new(env, &asset.contract);
let live_decimals: u32 = token_client
.decimals()
.try_into()
.map_err(|_| Error::InvalidInput)?;

let global_key = Symbol::new(env, "allowed_assets_global");
let global_assets: Vec<Asset> = env
.storage()
.persistent()
.get(&global_key)
.unwrap_or(Vec::new(env));

// Check if contract is already registered
if let Some(existing) = global_assets.iter().find(|a| a.contract == asset.contract) {
if existing.decimals != live_decimals {
return Err(Error::AssetDecimalsMismatch);
}
// Already registered with matching decimals - nothing to do
return Ok(());
}

// Register with live SAC decimals
let registered = Asset {
contract: asset.contract.clone(),
symbol: asset.symbol.clone(),
decimals: live_decimals,
};
Self::add_global(env, &registered);
Ok(())
}

pub fn initialize_with_defaults(env: &Env) {
let global_key = Symbol::new(env, "allowed_assets_global");
let mut global_assets: Vec<Asset> = Vec::new(env);
Expand Down
Loading