Skip to content
Closed
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
4 changes: 4 additions & 0 deletions contracts/campaign-escrow/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use soroban_sdk::contracterror;


/// Errors returned by the campaign-escrow contract.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 32 additions & 1 deletion contracts/campaign-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<u32, Error>(
&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,
Expand Down
35 changes: 35 additions & 0 deletions contracts/campaign-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading