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
3 changes: 3 additions & 0 deletions .kilo/kilo.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"snapshot": false
}
101 changes: 95 additions & 6 deletions campaign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ impl CampaignContract {
}

validate_assets(&env, &accepted_assets)?;
validate_native_xlm_configuration(&env, &accepted_assets)?;

let milestone_count = milestones.len();
if milestone_count == 0 || milestone_count > types::MAX_MILESTONES {
Expand Down Expand Up @@ -393,6 +394,43 @@ impl CampaignContract {
VERSION
}

/// Risk indicator helper for off-chain indexers.
///
/// Returns a warning string if the campaign's accepted_assets contains
/// an XLM entry whose issuer does not match the canonical wrapped XLM
/// contract address for the current network. This helps indexers flag
/// potentially malicious campaigns that attempt to redirect native XLM
/// donations to arbitrary SACs.
///
/// Returns an empty string if the campaign is not configured or if the
/// XLM configuration is valid.
///
/// No auth required (read-only view).
pub fn risk_indicator(env: Env) -> String {
let campaign = match get_campaign(&env) {
Some(c) => c,
None => return String::from_str(&env, ""),
};

let xlm_code = soroban_sdk::String::from_str(&env, "XLM");
let canonical_address = get_canonical_xlm_address(&env);

for asset in campaign.accepted_assets.iter() {
if asset.asset_code == xlm_code {
if let Some(issuer) = &asset.issuer {
if issuer != &canonical_address {
return String::from_str(
&env,
"WARNING: XLM asset issuer does not match canonical wrapped XLM contract address",
);
}
}
}
}

String::from_str(&env, "")
}

/// Check if a donor is eligible to claim a refund.
///
/// A donor is refund-eligible if ALL of the following are true:
Expand Down Expand Up @@ -471,11 +509,17 @@ impl CampaignContract {
donor_record.refund_claimed = true;
set_donor(&env, &donor, &donor_record);

// Get the canonical XLM address for Native donations
let canonical_xlm_address = get_canonical_xlm_address(&env);

// For each asset the donor contributed to, calculate and transfer refund
for asset in campaign.accepted_assets.iter() {
let asset_address = match &asset.issuer {
Some(addr) => addr.clone(),
None => continue, // Skip assets without an issuer (native XLM handled separately)
None => {
// Native XLM entry (issuer = None) - use canonical address
canonical_xlm_address.clone()
}
};

// Get amount donor contributed in this asset
Expand Down Expand Up @@ -719,6 +763,22 @@ fn require_creator(env: &Env) {
campaign.creator.require_auth();
}

/// Returns the canonical wrapped XLM contract address for the current network.
///
/// In the current implementation, this returns a fixed address that must be
/// present in accepted_assets if the campaign accepts XLM with an issuer.
/// This prevents malicious creators from routing native XLM donations to arbitrary SACs.
///
/// For production deployment, this should be configured based on the actual network
/// (testnet vs mainnet) to use the well-known wrapped XLM SAC addresses.
fn get_canonical_xlm_address(env: &Env) -> Address {
// For testing purposes, we use a generated address that represents the canonical XLM
// In production, this should be the actual well-known wrapped XLM contract address
// for the network (testnet or mainnet)
let canonical_str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
Address::from_string(&soroban_sdk::String::from_str(env, canonical_str))
}

/// Validates that `asset` is in the campaign's accepted list and returns the
/// token contract address needed to construct a `token::Client`.
fn get_token_address_for_asset(env: &Env, asset: &AssetInfo, campaign: &CampaignData) -> Address {
Expand All @@ -734,14 +794,22 @@ fn get_token_address_for_asset(env: &Env, asset: &AssetInfo, campaign: &Campaign
addr.clone()
}
AssetInfo::Native => {
// Find the XLM entry in accepted_assets by asset_code == "XLM".
// Return the canonical wrapped XLM address for the current network
// This prevents malicious creators from routing native XLM to arbitrary SACs
let canonical_address = get_canonical_xlm_address(env);

// Verify that the canonical XLM address is in accepted_assets
let xlm_code = soroban_sdk::String::from_str(env, "XLM");
campaign
let has_canonical_xlm = campaign
.accepted_assets
.iter()
.find(|a| a.asset_code == xlm_code)
.and_then(|a| a.issuer.clone())
.unwrap_or_else(|| panic_with_error(env, Error::AssetNotAccepted))
.any(|a| a.asset_code == xlm_code && a.issuer == Some(canonical_address.clone()));

if !has_canonical_xlm {
panic_with_error(env, Error::NativeAssetConfigurationMismatch);
}

canonical_address
}
}
}
Expand All @@ -756,6 +824,27 @@ fn validate_assets(env: &Env, assets: &Vec<StellarAsset>) -> Result<(), Error> {
Ok(())
}

/// Validates that if accepted_assets contains an XLM entry with an issuer,
/// it must match the canonical wrapped XLM contract address for the current network.
/// This prevents malicious creators from routing native XLM donations to arbitrary SACs.
#[allow(clippy::ptr_arg)] // soroban_sdk::Vec does not implement Deref<Target=[T]>, so `&Vec<T>` is mandatory here even though std::Vec would auto-coerce.
fn validate_native_xlm_configuration(env: &Env, assets: &Vec<StellarAsset>) -> Result<(), Error> {
let xlm_code = soroban_sdk::String::from_str(env, "XLM");
let canonical_address = get_canonical_xlm_address(env);

for asset in assets.iter() {
if asset.asset_code == xlm_code {
// If there's an XLM entry with an issuer, it must be the canonical address
if let Some(issuer) = &asset.issuer {
if issuer != &canonical_address {
panic_with_error(env, Error::NativeAssetConfigurationMismatch);
}
}
}
}
Ok(())
}

#[allow(clippy::ptr_arg)] // soroban_sdk::Vec does not implement Deref<Target=[T]>, so `&Vec<T>` is mandatory here even though std::Vec would auto-coerce.
fn validate_milestones(
env: &Env,
Expand Down
10 changes: 8 additions & 2 deletions campaign/src/test/budget_invariant_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,12 @@ const CLAIM_REFUND_MEM_MAX: u64 = 500_000;
fn create_basic_campaign(env: &Env) -> (Address, Vec<StellarAsset>, Vec<MilestoneData>) {
let creator = Address::generate(env);
let mut assets: Vec<StellarAsset> = Vec::new(env);
// Use canonical XLM address to satisfy validation
let canonical_xlm_str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
let canonical_xlm = Address::from_string(&String::from_str(env, canonical_xlm_str));
assets.push_back(StellarAsset {
asset_code: String::from_str(env, "XLM"),
issuer: Some(Address::generate(env)),
issuer: Some(canonical_xlm),
});
let mut milestones: Vec<MilestoneData> = Vec::new(env);
milestones.push_back(MilestoneData {
Expand All @@ -106,9 +109,12 @@ fn create_multi_milestone_campaign(
let creator = Address::generate(env);
let token_issuer = Address::generate(env);
let mut assets: Vec<StellarAsset> = Vec::new(env);
// Use canonical XLM address to satisfy validation
let canonical_xlm_str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
let canonical_xlm = Address::from_string(&String::from_str(env, canonical_xlm_str));
assets.push_back(StellarAsset {
asset_code: String::from_str(env, "XLM"),
issuer: Some(token_issuer.clone()),
issuer: Some(canonical_xlm),
});
let mut milestones: Vec<MilestoneData> = Vec::new(env);
for i in 0..3 {
Expand Down
6 changes: 5 additions & 1 deletion campaign/src/test/diagnostics_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ fn setup_basic_env(env: &Env) {
with_contract(env, || {
let creator = soroban_sdk::Address::generate(env);
let mut assets: soroban_sdk::Vec<StellarAsset> = soroban_sdk::Vec::new(env);
// Use canonical XLM address to satisfy validation
let canonical_xlm_str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
let canonical_xlm =
soroban_sdk::Address::from_string(&String::from_str(env, canonical_xlm_str));
assets.push_back(StellarAsset {
asset_code: String::from_str(env, "XLM"),
issuer: Some(soroban_sdk::Address::generate(env)),
issuer: Some(canonical_xlm),
});
let mut milestones: soroban_sdk::Vec<crate::types::MilestoneData> =
soroban_sdk::Vec::new(env);
Expand Down
10 changes: 8 additions & 2 deletions campaign/src/test/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ fn setup_basic_campaign(env: &Env) -> (Address, Vec<StellarAsset>, Vec<Milestone
let creator = Address::generate(env);

let mut assets: Vec<StellarAsset> = Vec::new(env);
// Use canonical XLM address to satisfy validation
let canonical_xlm_str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
let canonical_xlm = Address::from_string(&String::from_str(env, canonical_xlm_str));
assets.push_back(StellarAsset {
asset_code: String::from_str(env, "XLM"),
issuer: Some(Address::generate(env)),
issuer: Some(canonical_xlm),
});

let mut milestones: Vec<MilestoneData> = Vec::new(env);
Expand Down Expand Up @@ -238,9 +241,12 @@ fn test_lifecycle_multi_milestone_unlock() {
let end_time = env.ledger().timestamp() + 86_400;

let mut assets: Vec<StellarAsset> = Vec::new(&env);
// Use canonical XLM address to satisfy validation
let canonical_xlm_str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
let canonical_xlm = Address::from_string(&String::from_str(&env, canonical_xlm_str));
assets.push_back(StellarAsset {
asset_code: String::from_str(&env, "XLM"),
issuer: Some(Address::generate(&env)),
issuer: Some(canonical_xlm),
});

// Three milestones: 1000, 2000, 3000
Expand Down
Loading
Loading