diff --git a/contracts/campaign-escrow/src/error.rs b/contracts/campaign-escrow/src/error.rs index 30b07ad..3fad0b9 100644 --- a/contracts/campaign-escrow/src/error.rs +++ b/contracts/campaign-escrow/src/error.rs @@ -1,5 +1,6 @@ use soroban_sdk::contracterror; + /// Errors returned by the campaign-escrow contract. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -48,6 +49,9 @@ pub enum Error { /// The application is frozen pending dispute arbitration, so it can /// neither be paid out nor have its proof state changed. PayoutFrozen = 25, + /// The provided payout asset refers to an address that is not a + /// responsive/valid SEP-41 token contract. + InvalidAsset = 26, /// `resolve_dispute` was called before `MIN_EVIDENCE_WINDOW` had elapsed /// since the dispute was opened by `freeze_for_dispute`. The other party /// still has time to submit counter-evidence. diff --git a/contracts/campaign-escrow/src/lib.rs b/contracts/campaign-escrow/src/lib.rs index 20fec4d..f8a514c 100644 --- a/contracts/campaign-escrow/src/lib.rs +++ b/contracts/campaign-escrow/src/lib.rs @@ -21,7 +21,7 @@ pub use error::Error; pub use types::{Application, Campaign, DisputeResolution, ProtocolConfig}; use ads_bazaar_shared::{ApplicationStatus, CampaignId, CampaignStatus, PayoutAsset}; -use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String}; +use soroban_sdk::{contract, contractimpl, token, vec, Address, BytesN, Env, String, Symbol}; /// Version string stored at `initialize` time. `upgrade` swaps the WASM /// binary but does not bump this on its own — see the TODO on `upgrade` @@ -227,6 +227,13 @@ impl CampaignEscrowContract { /// /// Validates `total_budget > 0`, `max_creators > 0`, that both deadlines /// are in the future and that `application_deadline < completion_deadline`. + /// + /// **Token validation:** calls `decimals()` on `asset.token` via + /// `env.try_invoke_contract` before storing anything. If the address is not + /// a deployed contract, does not implement the SEP-41 interface, or returns + /// any error, `create_campaign` returns `Error::InvalidAsset` immediately. + /// This prevents creators from applying to — and doing work for — a campaign + /// that can never be funded. #[allow(clippy::too_many_arguments)] pub fn create_campaign( env: Env, @@ -258,6 +265,30 @@ impl CampaignEscrowContract { business.require_auth(); + // Probe the token address with a cheap read-only cross-contract call + // (`decimals`) before storing anything. `env.try_invoke_contract` + // returns a Result instead of trapping, so a non-contract address, a + // missing SEP-41 entrypoint, or any other host-level failure all land + // in the outer Err arm and are mapped to Error::InvalidAsset. + // This fires before the campaign is written to storage, so a bad token + // address is always caught at creation time — not silently deferred to + // fund_campaign after creators have already applied and done work. + let decimals_sym = Symbol::new(&env, "decimals"); + let probe_result = env.try_invoke_contract::( + &asset.token, + &decimals_sym, + vec![&env], + ); + match probe_result { + // Outer Ok means the call returned (inner Ok = got a u32 back, + // inner Err = type conversion failed but the call itself succeeded + // — still a live contract). Either is acceptable. + Ok(_) => {} + // Outer Err means the host could not dispatch the call at all + // (non-contract address, missing entrypoint, abort). + Err(_) => return Err(Error::InvalidAsset), + } + let id = storage::next_campaign_id(&env); let campaign = Campaign { id, diff --git a/contracts/campaign-escrow/src/test.rs b/contracts/campaign-escrow/src/test.rs index e6f070b..d13fa45 100644 --- a/contracts/campaign-escrow/src/test.rs +++ b/contracts/campaign-escrow/src/test.rs @@ -233,6 +233,41 @@ mod test_happy_path { assert_eq!(campaign.approved_count, 2); } + #[test] + fn create_campaign_rejects_non_contract_token() { + let (env, contract_id) = setup_env(); + let client = CampaignEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let dispute = Address::generate(&env); + let business = Address::generate(&env); + client.initialize(&admin, &dispute, &50); + + // A random account address — not a deployed token contract. + // create_campaign must catch this via try_invoke_contract and return + // Error::InvalidAsset rather than aborting with a host trap or + // deferring the failure to fund_campaign. + let bogus_token = Address::generate(&env); + let asset = usdc(&env, &bogus_token); + + let now = env.ledger().timestamp(); + let result = client.try_create_campaign( + &business, + &asset, + &1_000, + &1, + &(now + 86_400), + &(now + 604_800), + &soroban_sdk::String::from_str(&env, "ipfs://brief"), + ); + + assert_eq!( + result, + Err(Ok(Error::InvalidAsset)), + "expected Error::InvalidAsset for a non-contract token address, got: {:?}", + result + ); + } + #[test] fn fee_calculation_50bps() { let (env, contract_id) = setup_env();