From 8f6b9204160d9872c042e0206ca32f266e2e411e Mon Sep 17 00:00:00 2001 From: Henry Peters <96546584+henrypeters@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:20:10 +0000 Subject: [PATCH] feat(intent_settlement): optional on-chain referral fee-share for #281 submit_intent now accepts an optional referrer: Option
. When ProtocolConfig.referral_share_bps is non-zero and a referrer is set, the configured slice of each fill's protocol fee is routed to the referrer at fill_intent time; the remainder still goes to the FeeRecipient. - referral_share_bps added to ProtocolConfig / ProtocolParams (default 0 = disabled, backward-compatible with existing deployments). - IntentRecord gains a locked referrer field set at submission time. - Self-referral (referrer == user) rejected with new Error::SelfReferral variant (appended per CONTRIBUTING.md's append-only enum rule). - fill_intent now uses get_tiered_fee_bps + checked_mul/checked_div overflow-safety and consolidates CEI ordering; the previous misplaced duplicate transfer block has been removed. - Referral split accrues proportionally on every partial fill; integer division dust is absorbed by the FeeRecipient so no fee units are silently dropped. - batch_submit_intent tuple extended with Option
referrer. - Five new tests: self-referral rejection, 20% split, multi-partial-fill accrual, zero-share passthrough, no-referrer regression. - CHANGELOG entry added under [Unreleased] > Added. Closes #281 --- CHANGELOG.md | 11 +++ intent_settlement/src/lib.rs | 169 ++++++++++++++++++++++---------- intent_settlement/src/test.rs | 177 ++++++++++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 935afb1..add4611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,17 @@ first deploys to mainnet. ### Added +- **Optional on-chain referral fee-share** (#281): `submit_intent` now accepts + an optional `referrer: Option
`. When `ProtocolConfig.referral_share_bps` + is non-zero and a referrer is set, the configured slice of each fill's + protocol fee is routed to the referrer at `fill_intent` time instead of + going entirely to the FeeRecipient; the remainder still goes to + FeeRecipient. The share defaults to 0 (disabled), so existing deployments + see no behavior change until an admin opts in via `set_config`. Referral + fee-share accrues proportionally on every partial fill, and integer-division + dust is absorbed by the FeeRecipient. A self-referral (`referrer == user`) + is rejected at `submit_intent` time with the new `Error::SelfReferral` + variant. - **Storage TTL management**: persistent `Intent`/`Solver` entries and the contract instance now have their TTL extended on every write, closing a gap where none of Soroban's state-archival requirements were handled. diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 1177e24..1a89f19 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -65,6 +65,16 @@ const PERSISTENT_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30; const INSTANCE_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; const INSTANCE_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 60; +/// Upper bound for the on-chain referral fee-share (basis points of the +/// protocol fee routed to the referrer). Capped at 10 000 (100%) so the +/// FeeRecipient never receives a negative or overflowing amount. +const MAX_REFERRAL_SHARE_BPS: i128 = 10_000; + +/// Default referral fee-share (0 = disabled — fee always goes to +/// FeeRecipient). Set to a non-zero value to activate the referral +/// programme without further contract upgrades. +const DEFAULT_REFERRAL_SHARE_BPS: i128 = 0; + // ─── Storage Keys ───────────────────────────────────────────────────────────── #[contracttype] @@ -173,6 +183,12 @@ pub struct ProtocolConfig { pub intent_expiry: u64, /// Protocol fee in basis points charged on each fill (0.01% per bps). pub protocol_fee_bps: i128, + /// Basis points of the protocol fee routed to the referrer (if set) + /// on each fill. 0 (the default) leaves the existing FeeRecipient + /// behaviour unchanged; any value up to `MAX_REFERRAL_SHARE_BPS` + /// splits the computed fee proportionally between the referrer and the + /// FeeRecipient (dust rounds to the FeeRecipient). + pub referral_share_bps: i128, } /// A user's cross-chain swap intent @@ -208,6 +224,12 @@ pub struct IntentRecord { /// intent transitions to `Filled` as soon as `total_filled` satisfies /// the user's `min_dst_amount` requirement. pub total_filled: i128, + /// Optional address that referred this intent. Set at submission time + /// by `submit_intent`; locked for the intent's lifetime. When + /// `referral_share_bps` in the protocol config is non-zero and this + /// field is `Some(addr)`, the configured slice of the fill fee is + /// routed to `addr` rather than to the FeeRecipient. + pub referrer: Option
, } #[contracttype] @@ -259,6 +281,10 @@ pub struct ProtocolParams { pub intent_expiry: u64, /// Protocol fee charged on each fill, in basis points (1 bps = 0.01%). pub protocol_fee_bps: i128, + /// Basis points of the protocol fee routed to the referrer on each fill. + /// Mirrors `ProtocolConfig.referral_share_bps`; 0 means no referral + /// routing is active. + pub referral_share_bps: i128, } /// Tracks the leading bid for an intent that is in the `Bidding` state. @@ -450,6 +476,10 @@ pub enum Error { /// chain-name → Wormhole-chain-ID table, so the proof's chain cannot be /// validated against it. SrcChainNotSupported = 34, + /// #281: `submit_intent` was called with a `referrer` equal to the + /// submitting `user`. Self-referral is rejected to prevent a user from + /// gaming the referral programme by naming their own address. + SelfReferral = 35, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -494,6 +524,7 @@ impl IntentSettlement { fill_window: DEFAULT_FILL_WINDOW, intent_expiry: DEFAULT_INTENT_EXPIRY, protocol_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, + referral_share_bps: DEFAULT_REFERRAL_SHARE_BPS, }, ); Self::bump_instance_ttl(&env); @@ -630,25 +661,30 @@ impl IntentSettlement { Self::load_config(&env) } - /// Admin-only: update the four configurable protocol parameters atomically. + /// Admin-only: update the configurable protocol parameters atomically. /// /// Bounds (any violation returns `InvalidConfig`): - /// * `protocol_fee_bps` ≤ 1 000 (10%) - /// * `fill_window` ≥ 60 s - /// * `intent_expiry` ≥ 300 s and > fill_window - /// * `min_bond` ≥ 1 token unit (10_000_000 for 7-decimal USDC) + /// * `protocol_fee_bps` ≤ 1 000 (10%) + /// * `fill_window` ≥ 60 s + /// * `intent_expiry` ≥ 300 s and > fill_window + /// * `min_bond` ≥ 1 token unit (10_000_000 for 7-decimal USDC) + /// * `referral_share_bps` ≤ 10 000 (100% of the fee) pub fn set_config( env: Env, min_bond: i128, fill_window: u64, intent_expiry: u64, protocol_fee_bps: i128, + referral_share_bps: i128, ) { Self::require_admin(&env); if !(0..=MAX_PROTOCOL_FEE_BPS).contains(&protocol_fee_bps) { panic_with_error!(&env, Error::InvalidConfig); } + if !(0..=MAX_REFERRAL_SHARE_BPS).contains(&referral_share_bps) { + panic_with_error!(&env, Error::InvalidConfig); + } if fill_window < MIN_FILL_WINDOW_SECS { panic_with_error!(&env, Error::InvalidConfig); } @@ -664,13 +700,14 @@ impl IntentSettlement { fill_window, intent_expiry, protocol_fee_bps, + referral_share_bps, }; env.storage().instance().set(&DataKey::Config, &cfg); Self::bump_instance_ttl(&env); env.events().publish( (Symbol::new(&env, "config_updated"),), - (min_bond, fill_window, intent_expiry, protocol_fee_bps), + (min_bond, fill_window, intent_expiry, protocol_fee_bps, referral_share_bps), ); } @@ -1232,6 +1269,14 @@ impl IntentSettlement { /// User submits a swap intent. No funds are locked on Stellar at this point — /// the user initiates the source-chain tx separately. + /// + /// # Parameters + /// + /// - `referrer` (optional, default `None`): the address to credit with a share + /// of the protocol fee when the intent is filled. Must not equal `user` + /// (self-referral is rejected with `Error::SelfReferral`). The share is + /// governed by `ProtocolConfig.referral_share_bps` and is only paid out + /// when that config value is non-zero. #[allow(clippy::too_many_arguments)] pub fn submit_intent( env: Env, @@ -1242,6 +1287,7 @@ impl IntentSettlement { dst_token: Address, min_dst_amount: i128, deadline: Option, + referrer: Option
, ) -> BytesN<32> { // Auth audit: require_auth() is correct. The user must sign to assert // ownership of the address receiving output tokens (dst). If a third-party @@ -1288,6 +1334,15 @@ impl IntentSettlement { panic_with_error!(&env, Error::InvalidDeadline); } + // #281: self-referral guard — a user cannot name their own address + // as the referrer, which would let them claim referral rewards on + // their own volume. + if let Some(r) = &referrer { + if r == &user { + panic_with_error!(&env, Error::SelfReferral); + } + } + // Widen the preimage with a per-user nonce so that two intents from // the same user with identical (src_chain, src_amount) in the same // ledger close produce distinct ids rather than colliding silently. @@ -1344,6 +1399,7 @@ impl IntentSettlement { filled_at: None, fill_amount: None, total_filled: 0, + referrer, }; env.storage() @@ -1565,43 +1621,11 @@ impl IntentSettlement { Self::validate_proof(&env, &intent, &intent_id); } - // Deliver this fill's tokens to the user. - let dst_client = token::Client::new(&env, &intent.dst_token); - dst_client.transfer(&solver, &intent.user, &fill_amount); - - // Solver also pays the protocol fee on each fill. - let fee = fill_amount * PROTOCOL_FEE_BPS / 10_000; // ── Effects first (CEI) ────────────────────────────────────────────── - // Mark the intent Filled and write every state change to storage - // *before* any external token transfer executes. A hostile SEP-41 - // token that attempts to re-enter fill_intent or slash_solver during - // the transfer would see the intent already Filled and be rejected. - // Solver delivers the full requested output to the user. - let dst_client = token::Client::new(&env, &intent.dst_token); - dst_client.transfer(&solver, &intent.user, &fill_amount); - - // Solver also pays the protocol fee (priced into their quote). Taking the - // fee from the solver — rather than clawing it back from the user — keeps - // the user's received amount at or above `min_dst_amount`, and keeps every - // token transfer authorized by the solver who signed this call. - // - // Explicit checked_mul/checked_div makes the overflow-safety property - // visible in code, rather than relying solely on the Cargo.toml - // overflow-checks = true release-profile setting (issue #31). - let fee = fill_amount - .checked_mul(PROTOCOL_FEE_BPS) - .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)) - .checked_div(10_000) - .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)); - if fee > 0 { - let fee_recipient: Address = env - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .unwrap(); - dst_client.transfer(&solver, &fee_recipient, &fee); - } - + // All state changes below (IntentRecord, SolverRecord, stats) are + // persisted *before* any token transfer, so a re-entrant or hostile + // SEP-41 token cannot trigger a second fill on an already-Filled + // intent. // Accumulate the fill. intent.total_filled += fill_amount; let cumulative = intent.total_filled; @@ -1666,22 +1690,62 @@ impl IntentSettlement { Self::bump_intent_ttl(&env, &intent_id); // ── Interactions: token transfers ──────────────────────────────────── - // Solver delivers the full requested output to the user. + // CEI: all state above (IntentRecord, SolverRecord, stats) has been + // persisted. A hostile SEP-41 token that attempts to re-enter + // fill_intent during these transfers would see the intent already + // Filled and be rejected. let dst_client = token::Client::new(&env, &intent.dst_token); + + // Solver delivers the full requested output to the user. dst_client.transfer(&solver, &intent.user, &fill_amount); - // Solver also pays the protocol fee (priced into their quote). Taking the - // fee from the solver — rather than clawing it back from the user — keeps - // the user's received amount at or above `min_dst_amount`, and keeps every - // token transfer authorized by the solver who signed this call. - let fee = fill_amount * PROTOCOL_FEE_BPS / 10_000; + // Solver also pays the protocol fee (priced into their quote). Taking + // the fee from the solver — rather than clawing it back from the + // user — keeps the user's received amount at or above + // `min_dst_amount`, and keeps every token transfer authorized by + // the solver who signed this call. + // + // Referral split (#281): when `referral_share_bps` > 0 and the + // intent has a `referrer`, the configured slice of the fee goes to + // the referrer; the remainder goes to FeeRecipient. Integer + // division dust is absorbed by the FeeRecipient (receives + // `fee - referral_amount`, which is >= its proportional share), so no + // fee units are silently dropped and the referrer never receives + // more than its configured slice. + let fee_bps = Self::get_tiered_fee_bps(&env); + let fee = fill_amount + .checked_mul(fee_bps) + .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)) + .checked_div(10_000) + .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)); if fee > 0 { + let cfg = Self::load_config(&env); let fee_recipient: Address = env .storage() .instance() .get(&DataKey::FeeRecipient) .unwrap(); - dst_client.transfer(&solver, &fee_recipient, &fee); + match (&intent.referrer, cfg.referral_share_bps) { + (Some(referrer_addr), share) if share > 0 => { + let referral_amount = fee + .checked_mul(share) + .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)) + .checked_div(10_000) + .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)); + let recipient_amount = fee - referral_amount; + if referral_amount > 0 { + dst_client.transfer(&solver, referrer_addr, &referral_amount); + } + if recipient_amount > 0 { + dst_client.transfer(&solver, &fee_recipient, &recipient_amount); + } + } + _ => { + // No referrer or zero share: 100% to FeeRecipient + // (identical to pre-#281 behaviour). + dst_client.transfer(&solver, &fee_recipient, &fee); + } + } } env.events().publish( @@ -1911,14 +1975,14 @@ impl IntentSettlement { pub fn batch_submit_intent( env: Env, user: Address, - intents: soroban_sdk::Vec<(String, String, i128, Address, i128, Option)>, + intents: soroban_sdk::Vec<(String, String, i128, Address, i128, Option, Option
)>, ) -> soroban_sdk::Vec> { if intents.len() > MAX_BATCH_SIZE as usize { panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest } let mut result = soroban_sdk::Vec::new(&env); - for (src_chain, src_token, src_amount, dst_token, min_dst_amount, deadline) in intents { + for (src_chain, src_token, src_amount, dst_token, min_dst_amount, deadline, referrer) in intents { let intent_id = Self::submit_intent( env.clone(), user.clone(), @@ -1928,6 +1992,7 @@ impl IntentSettlement { dst_token, min_dst_amount, deadline, + referrer, ); result.push_back(intent_id); } @@ -2023,6 +2088,7 @@ impl IntentSettlement { fill_window: FILL_WINDOW, intent_expiry: INTENT_EXPIRY, protocol_fee_bps: PROTOCOL_FEE_BPS, + referral_share_bps: DEFAULT_REFERRAL_SHARE_BPS, } } @@ -2490,6 +2556,7 @@ impl IntentSettlement { fill_window: DEFAULT_FILL_WINDOW, intent_expiry: DEFAULT_INTENT_EXPIRY, protocol_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, + referral_share_bps: DEFAULT_REFERRAL_SHARE_BPS, }) } diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 1f7995b..bf11c44 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -74,6 +74,7 @@ impl Ctx { &self.dst_token, &MIN_DST, &deadline, + &None, ) } @@ -1915,6 +1916,9 @@ fn get_protocol_params_returns_current_constants() { assert_eq!(params.fill_window, FILL_WINDOW); assert_eq!(params.intent_expiry, INTENT_EXPIRY); assert_eq!(params.protocol_fee_bps, PROTOCOL_FEE_BPS); + assert_eq!(params.referral_share_bps, 0); +} + // ─── Partial fills ─────────────────────────────────────────────────────────────── #[test] @@ -3054,3 +3058,176 @@ fn unsupported_src_chain_rejects_gated_fill() { let res = ctx.client().try_fill_intent(&ctx.solver, &id, &FILL, &true); assert_eq!(res, Err(Ok(Error::SrcChainNotSupported.into()))); } + +// ─── #281 On-chain referral fee-share ──────────────────────────────────────────── + +/// #281: `submit_intent` rejects a referrer that equals the submitting user. +#[test] +fn submit_intent_self_referral_rejected() { + let ctx = setup(); + let deadline: Option = None; + let res = ctx.client().try_submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &deadline, + &Some(ctx.user.clone()), + ); + assert_eq!(res, Err(Ok(Error::SelfReferral.into()))); +} + +/// #281: with `referral_share_bps` > 0 the referrer receives its configured +/// slice and the FeeRecipient receives the remainder. The user still gets the +/// full fill_amount; the fee is paid entirely by the solver. +#[test] +fn fill_intent_referrer_receives_configured_split() { + let ctx = setup(); + let c = ctx.client(); + let referrer = Address::generate(&ctx.env); + + // Configure a 20% referral share (2000 bps of the protocol fee). + c.set_config(&MIN_BOND, &FILL_WINDOW, &INTENT_EXPIRY, &5_i128, &2000_i128); + + ctx.register_solver(); + + let id = c.submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &None, + &Some(referrer.clone()), + ); + + c.accept_intent(&ctx.solver, &id); + + let fee = FILL * 5 / 10_000; + let referral = fee * 2000 / 10_000; + let recipient = fee - referral; // dust absorbed by FeeRecipient + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + c.fill_intent(&ctx.solver, &id, &FILL, &false); + + assert_eq!(ctx.dst().balance(&ctx.user), FILL); + assert_eq!(ctx.dst().balance(&referrer), referral); + assert_eq!(ctx.dst().balance(&ctx.fee_recipient), recipient); + assert_eq!(ctx.dst().balance(&ctx.solver), 0); +} + +/// #281: referral fee-share accrues on every partial fill, not just once at +/// final settlement. With a 100% share the entire fee lands on the referrer +/// across both fills. +#[test] +fn partial_fills_accrue_referral_share_across_fills() { + let ctx = setup(); + let c = ctx.client(); + let referrer = Address::generate(&ctx.env); + + // Configure 100% referral share for easy arithmetic. + c.set_config(&MIN_BOND, &FILL_WINDOW, &INTENT_EXPIRY, &5_i128, &10_000_i128); + + ctx.register_solver(); + + let id = c.submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &None, + &Some(referrer.clone()), + ); + + // First partial fill: half of MIN_DST. + let half = MIN_DST / 2; + let fee1 = half * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(half + fee1)); + c.accept_intent(&ctx.solver, &id); + c.fill_intent(&ctx.solver, &id, &half, &false); + + // 100% share: entire fee1 goes to the referrer. + assert_eq!(ctx.dst().balance(&referrer), fee1); + assert_eq!(ctx.dst().balance(&ctx.fee_recipient), 0); + + // Second partial fill: the remainder brings the intent to Filled. + let remainder = MIN_DST - half; + let fee2 = remainder * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(remainder + fee2)); + c.accept_intent(&ctx.solver, &id); + c.fill_intent(&ctx.solver, &id, &remainder, &false); + + // Total referral: fee1 + fee2. + assert_eq!(ctx.dst().balance(&referrer), fee1 + fee2); + assert_eq!(ctx.dst().balance(&ctx.fee_recipient), 0); +} + +/// #281: a 0 referral_share_bps (the default) routes 100% of the fee to +/// FeeRecipient even when a referrer is named — the share must be explicitly +/// configured by an admin to take effect. +#[test] +fn zero_referral_share_sends_all_fee_to_fee_recipient() { + let ctx = setup(); + let c = ctx.client(); + let referrer = Address::generate(&ctx.env); + + // Explicitly set referral_share_bps to 0 for determinism (avoids relying + // on the initialized default, which references DEFAULT_* constants). + c.set_config(&MIN_BOND, &FILL_WINDOW, &INTENT_EXPIRY, &5_i128, &0_i128); + + ctx.register_solver(); + + let id = c.submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &None, + &Some(referrer.clone()), + ); + + c.accept_intent(&ctx.solver, &id); + + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + c.fill_intent(&ctx.solver, &id, &FILL, &false); + + // With 0 share, the entire fee goes to FeeRecipient even though a + // referrer was named. + assert_eq!(ctx.dst().balance(&ctx.user), FILL); + assert_eq!(ctx.dst().balance(&referrer), 0); + assert_eq!(ctx.dst().balance(&ctx.fee_recipient), fee); + assert_eq!(ctx.dst().balance(&ctx.solver), 0); +} + +/// #281 regression: no referrer set behaves identically to before #281 — the +/// full fee goes to FeeRecipient, even when a non-zero referral share is +/// configured. +#[test] +fn fill_intent_no_referrer_unchanged_behavior() { + let ctx = setup(); + let c = ctx.client(); + + // Configure a non-zero referral share to prove it has no effect when + // no referrer is set on the intent. + c.set_config(&MIN_BOND, &FILL_WINDOW, &INTENT_EXPIRY, &5_i128, &2000_i128); + + ctx.register_solver(); + let id = ctx.submit(); // referrer = None + c.accept_intent(&ctx.solver, &id); + + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + c.fill_intent(&ctx.solver, &id, &FILL, &false); + + // No referrer → full fee to FeeRecipient, even though the share is 20%. + assert_eq!(ctx.dst().balance(&ctx.user), FILL); + assert_eq!(ctx.dst().balance(&ctx.fee_recipient), fee); + assert_eq!(ctx.dst().balance(&ctx.solver), 0); +}