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);
+}