From 3231fd5705cf5dbefdcad3ecf0242b919adca7a0 Mon Sep 17 00:00:00 2001 From: fhayvy Date: Thu, 23 Jul 2026 11:47:52 +0100 Subject: [PATCH] feat(draws): fix #30 verifiable unbiased commit-reveal draws --- contracts/drip-pool/cost_thresholds.txt | 8 +- contracts/drip-pool/src/lib.rs | 413 +++++++++++++++++++++++- contracts/drip-pool/src/test.rs | 214 +++++++++++- scripts/legacy-term-whitelist.json | 3 +- 4 files changed, 621 insertions(+), 17 deletions(-) diff --git a/contracts/drip-pool/cost_thresholds.txt b/contracts/drip-pool/cost_thresholds.txt index 1cdf20d..11b3245 100644 --- a/contracts/drip-pool/cost_thresholds.txt +++ b/contracts/drip-pool/cost_thresholds.txt @@ -5,8 +5,8 @@ create_cpu=100000 create_mem=10000 -join_cpu=80000 -join_mem=12000 +join_cpu=115000 +join_mem=18000 deposit_cpu=150000 deposit_mem=22000 @@ -14,8 +14,8 @@ deposit_mem=22000 drip_cpu=150000 drip_mem=22000 -draw_winner_cpu=85000 -draw_winner_mem=12000 +draw_winner_cpu=145000 +draw_winner_mem=19000 claim_cpu=100000 claim_mem=16000 diff --git a/contracts/drip-pool/src/lib.rs b/contracts/drip-pool/src/lib.rs index 07d26ab..c91371a 100644 --- a/contracts/drip-pool/src/lib.rs +++ b/contracts/drip-pool/src/lib.rs @@ -19,7 +19,7 @@ //! - `upgrade` is admin-only; direct caller path enforces auth for transparent proxy. use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, vec, Address, Env, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, vec, Address, Bytes, BytesN, Env, Vec, }; // ── Lockup duration (ledgers, ~7 days at 5 s/ledger) ────────────────────── @@ -38,6 +38,8 @@ pub enum DataKey { Pool, Participant(Address), Proposal(u32), // pending admin proposal + ParticipantsList, + Draw, } // ── Errors ───────────────────────────────────────────────────────────────── @@ -56,6 +58,15 @@ pub enum Error { ThresholdNotMet = 9, // not enough signatures AlreadySigned = 10, // signer already approved this proposal ProposalNotFound = 11, + DrawActive = 12, + NoDrawActive = 13, + DrawNotCommitted = 14, + InvalidCommitment = 15, + DeadlineNotReached = 16, + DeadlinePassed = 17, + DuplicateParticipant = 18, + InvalidParticipantsList = 19, + DrawNotFrozen = 20, } // ── Structs ──────────────────────────────────────────────────────────────── @@ -82,6 +93,27 @@ pub struct Participant { pub lockup_multiplier: u32, // yield boost in basis points (100 = 1x) } +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct Draw { + pub round_id: u32, + pub status: DrawStatus, + pub commitment: BytesN<32>, + pub freeze_ledger: u32, + pub reveal_deadline: u32, + pub prize_amount: i128, + pub winner: Option
, +} + +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub enum DrawStatus { + None, + Committed, + Finalized, + Cancelled, +} + /// A pending admin action that requires multi-sig approval. #[derive(Clone, Debug, PartialEq)] #[contracttype] @@ -117,6 +149,42 @@ impl DripPool { pool.locked = false; } + fn check_draw_inactive(env: &Env) -> Result<(), Error> { + if let Some(draw) = env.storage().instance().get::<_, Draw>(&DataKey::Draw) { + if draw.status == DrawStatus::Committed && env.ledger().sequence() <= draw.reveal_deadline { + return Err(Error::DrawActive); + } + } + Ok(()) + } + + fn add_to_participants(env: &Env, who: &Address) { + let mut participants: Vec
= env + .storage() + .instance() + .get(&DataKey::ParticipantsList) + .unwrap_or(Vec::new(env)); + if !participants.contains(who) { + participants.push_back(who.clone()); + env.storage().instance().set(&DataKey::ParticipantsList, &participants); + } + } + + fn remove_from_participants(env: &Env, who: &Address) { + let participants: Vec
= env + .storage() + .instance() + .get(&DataKey::ParticipantsList) + .unwrap_or(Vec::new(env)); + let mut updated = Vec::new(env); + for p in participants.iter() { + if &p != who { + updated.push_back(p); + } + } + env.storage().instance().set(&DataKey::ParticipantsList, &updated); + } + // ── Multi-sig helpers ────────────────────────────────────────────────── fn require_signer(env: &Env, signer: &Address) -> Result<(), Error> { let admins: Vec
= env @@ -290,6 +358,7 @@ impl DripPool { // ── Join ─────────────────────────────────────────────────────────────── pub fn join(env: Env, who: Address) -> Result<(), Error> { who.require_auth(); + Self::check_draw_inactive(&env)?; let key = DataKey::Participant(who.clone()); if env.storage().persistent().has(&key) { return Err(Error::AlreadyJoined); @@ -304,6 +373,7 @@ impl DripPool { lockup_multiplier: 100, }, ); + Self::add_to_participants(&env, &who); env.events() .publish((symbol_short!("pool"), symbol_short!("joined")), who); Ok(()) @@ -316,11 +386,13 @@ impl DripPool { pub fn deposit(env: Env, who: Address, amount: i128) -> Result<(), Error> { who.require_auth(); + Self::check_draw_inactive(&env)?; if amount <= 0 { return Err(Error::InvalidAmount); } let key = DataKey::Participant(who.clone()); + let is_new = !env.storage().persistent().has(&key); let mut p: Participant = env.storage().persistent().get(&key).unwrap_or(Participant { joined_at: env.ledger().timestamp(), deposited: 0, @@ -333,6 +405,10 @@ impl DripPool { p.claimable += amount; env.storage().persistent().set(&key, &p); + if is_new { + Self::add_to_participants(&env, &who); + } + let mut pool: Pool = env .storage() .instance() @@ -358,11 +434,13 @@ impl DripPool { lockup_days: u32, ) -> Result<(), Error> { who.require_auth(); + Self::check_draw_inactive(&env)?; if amount <= 0 { return Err(Error::InvalidAmount); } let key = DataKey::Participant(who.clone()); + let is_new = !env.storage().persistent().has(&key); let mut p: Participant = env.storage().persistent().get(&key).unwrap_or(Participant { joined_at: env.ledger().timestamp(), deposited: 0, @@ -381,6 +459,10 @@ impl DripPool { } env.storage().persistent().set(&key, &p); + if is_new { + Self::add_to_participants(&env, &who); + } + let mut pool: Pool = env .storage() .instance() @@ -426,6 +508,7 @@ impl DripPool { // ── Withdraw ─────────────────────────────────────────────────────────── pub fn withdraw(env: Env, who: Address) -> Result { who.require_auth(); + Self::check_draw_inactive(&env)?; let key = DataKey::Participant(who.clone()); let p: Participant = env @@ -451,6 +534,7 @@ impl DripPool { .saturating_mul(p.lockup_multiplier as u128) .saturating_div(100) as i128; env.storage().persistent().remove(&key); + Self::remove_from_participants(&env, &who); // token_client.transfer(&env.current_contract_address(), &who, &amount); @@ -459,6 +543,7 @@ impl DripPool { .instance() .get(&DataKey::Pool) .ok_or(Error::NotInitialized)?; + pool.total_deposited = pool.total_deposited.saturating_sub(p.deposited); Self::release_lock(&mut pool); env.storage().instance().set(&DataKey::Pool, &pool); @@ -470,12 +555,9 @@ impl DripPool { Ok(amount) } - // ── Draw winner ──────────────────────────────────────────────────────── - /// Select a winner from the pool. In production this would use Soroban's - /// PRNG or a verifiable random beacon; here we select the admin as a - /// deterministic placeholder so tests can verify the event is emitted. - /// - /// #255: Emits the `payout` event documenting who won and for how much. + // ── Draw winner (legacy / PRNG fallback) ──────────────────────────────── + /// Select a winner from the pool using the environment's PRNG. + /// Emits the `payout` event documenting who won and for how much. pub fn draw_winner(env: Env, caller: Address, prize: i128) -> Result { caller.require_auth(); Self::require_signer(&env, &caller)?; @@ -483,23 +565,332 @@ impl DripPool { return Err(Error::InvalidAmount); } + Self::check_draw_inactive(&env)?; + + let participants: Vec
= env + .storage() + .instance() + .get(&DataKey::ParticipantsList) + .unwrap_or(Vec::new(&env)); + + let mut total_weight: u64 = 0; + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + if let Some(p) = env + .storage() + .persistent() + .get::<_, Participant>(&DataKey::Participant(addr.clone())) { + if p.deposited > 0 { + total_weight += p.deposited as u64; + } + } + } + + if total_weight == 0 { + return Err(Error::InvalidAmount); + } + + let limit = total_weight; + let cutoff = u64::MAX - (u64::MAX % limit); + let random_val = loop { + let x = env.prng().gen::(); + if x < cutoff { + break x % limit; + } + }; + + let mut current_sum = 0u64; + let mut winner = participants.get_unchecked(0); + + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + if let Some(p) = env + .storage() + .persistent() + .get::<_, Participant>(&DataKey::Participant(addr.clone())) { + if p.deposited > 0 { + current_sum += p.deposited as u64; + if current_sum > random_val { + winner = addr; + break; + } + } + } + } + + // Update winner's claimable reward in state + let mut win_p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(winner.clone())) + .unwrap(); + win_p.claimable = win_p + .claimable + .checked_add(prize) + .ok_or(Error::InvalidAmount)?; + env.storage() + .persistent() + .set(&DataKey::Participant(winner.clone()), &win_p); + + env.events().publish( + (symbol_short!("pool"), symbol_short!("payout")), + (winner.clone(), prize), + ); + Ok(winner) + } + + // ── Verifiable Randomness Draw Lifecycle ──────────────────────────────── + + pub fn commit_draw( + env: Env, + caller: Address, + round_id: u32, + commitment: BytesN<32>, + freeze_ledger: u32, + reveal_deadline: u32, + prize: i128, + ) -> Result<(), Error> { + caller.require_auth(); + Self::require_signer(&env, &caller)?; + + if prize <= 0 { + return Err(Error::InvalidAmount); + } + + // Verify no active draw is currently running + if let Some(draw) = env.storage().instance().get::<_, Draw>(&DataKey::Draw) { + if draw.status == DrawStatus::Committed && env.ledger().sequence() <= draw.reveal_deadline { + return Err(Error::DrawActive); + } + } + + let current_ledger = env.ledger().sequence(); + if freeze_ledger < current_ledger { + return Err(Error::InvalidAmount); // freeze_ledger must be >= current_ledger + } + if reveal_deadline <= freeze_ledger { + return Err(Error::InvalidAmount); // reveal_deadline must be > freeze_ledger + } + + let draw = Draw { + round_id, + status: DrawStatus::Committed, + commitment: commitment.clone(), + freeze_ledger, + reveal_deadline, + prize_amount: prize, + winner: None, + }; + + env.storage().instance().set(&DataKey::Draw, &draw); + + env.events().publish( + (symbol_short!("draw"), symbol_short!("commit")), + (round_id, commitment, freeze_ledger, reveal_deadline, prize), + ); + + Ok(()) + } + + pub fn finalize_draw( + env: Env, + revealed_secret: BytesN<32>, + participants: Vec
, + ) -> Result { + let mut draw = env + .storage() + .instance() + .get::<_, Draw>(&DataKey::Draw) + .ok_or(Error::NoDrawActive)?; + + if draw.status != DrawStatus::Committed { + return Err(Error::DrawNotCommitted); + } + + let current_ledger = env.ledger().sequence(); + if current_ledger <= draw.freeze_ledger { + return Err(Error::DrawNotFrozen); + } + if current_ledger > draw.reveal_deadline { + return Err(Error::DeadlinePassed); + } + + // Verify secret matches commitment + let secret_hash: BytesN<32> = env.crypto().sha256(revealed_secret.as_ref()).into(); + if secret_hash != draw.commitment { + return Err(Error::InvalidCommitment); + } + + // Verify participants list and compute weights + let mut total_weight: u64 = 0; + let mut checked_participants = Vec::new(&env); + + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + if checked_participants.contains(&addr) { + return Err(Error::DuplicateParticipant); + } + checked_participants.push_back(addr.clone()); + + let p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(addr.clone())) + .ok_or(Error::NotJoined)?; + + if p.deposited > 0 { + total_weight = total_weight + .checked_add(p.deposited as u64) + .ok_or(Error::InvalidAmount)?; + } + } + let pool: Pool = env .storage() .instance() .get(&DataKey::Pool) .ok_or(Error::NotInitialized)?; - // Deterministic selection: admin wins (replace with PRNG in prod). - let winner = pool.admin.clone(); + // Verify the participants list is complete: the sum of the deposits of all passed + // participants must equal the pool's total_deposited. + if total_weight as i128 != pool.total_deposited { + return Err(Error::InvalidParticipantsList); + } + + if total_weight == 0 { + return Err(Error::InvalidAmount); // Cannot draw if no eligible deposits + } + + // Generate the seed with Domain Separation: + // Hash of (network_id, contract_address, round_id, freeze_ledger, revealed_secret) + use soroban_sdk::xdr::ToXdr; + let mut seed_bytes = Bytes::new(&env); + seed_bytes.append(env.ledger().network_id().as_ref()); + seed_bytes.append(&env.current_contract_address().to_xdr(&env)); + seed_bytes.append(&draw.round_id.to_xdr(&env)); + seed_bytes.append(&draw.freeze_ledger.to_xdr(&env)); + seed_bytes.append(revealed_secret.as_ref()); + let seed: BytesN<32> = env.crypto().sha256(&seed_bytes).into(); + + // Select winner using rejection sampling to avoid modulo bias + let mut counter: u32 = 0; + let limit = total_weight; + let cutoff = u64::MAX - (u64::MAX % limit); + let random_val = loop { + let mut input = Bytes::new(&env); + input.append(seed.as_ref()); + input.append(&counter.to_xdr(&env)); + counter += 1; + let hash: BytesN<32> = env.crypto().sha256(&input).into(); + + let mut bytes = [0u8; 8]; + for i in 0..8 { + bytes[i] = hash.get_unchecked(i as u32); + } + let x = u64::from_be_bytes(bytes); + + if x < cutoff { + break x % limit; + } + }; + + // Find winner + let mut current_sum = 0u64; + let mut winner = participants.get_unchecked(0); + let mut found = false; + + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + let p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(addr.clone())) + .unwrap(); + + if p.deposited > 0 { + current_sum += p.deposited as u64; + if current_sum > random_val { + winner = addr; + found = true; + break; + } + } + } + + if !found { + winner = participants.get_unchecked(0); + } + + // Update winner's claimable reward in state + let mut win_p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(winner.clone())) + .unwrap(); + win_p.claimable = win_p + .claimable + .checked_add(draw.prize_amount) + .ok_or(Error::InvalidAmount)?; + env.storage() + .persistent() + .set(&DataKey::Participant(winner.clone()), &win_p); + + // Update draw state + draw.status = DrawStatus::Finalized; + draw.winner = Some(winner.clone()); + env.storage().instance().set(&DataKey::Draw, &draw); + + // Publish proof material and payout event + env.events().publish( + (symbol_short!("draw"), symbol_short!("finalized")), + ( + draw.round_id, + winner.clone(), + draw.prize_amount, + seed.clone(), + revealed_secret.clone(), + ), + ); - // #255: DrawWinner / payout_selected event env.events().publish( (symbol_short!("pool"), symbol_short!("payout")), - (winner.clone(), prize), + (winner.clone(), draw.prize_amount), ); + Ok(winner) } + pub fn cancel_draw(env: Env, caller: Address) -> Result<(), Error> { + caller.require_auth(); + + let mut draw = env + .storage() + .instance() + .get::<_, Draw>(&DataKey::Draw) + .ok_or(Error::NoDrawActive)?; + + if draw.status != DrawStatus::Committed { + return Err(Error::DrawNotCommitted); + } + + let current_ledger = env.ledger().sequence(); + let is_admin = Self::require_signer(&env, &caller).is_ok(); + + if !is_admin && current_ledger <= draw.reveal_deadline { + return Err(Error::DeadlineNotReached); + } + + draw.status = DrawStatus::Cancelled; + env.storage().instance().set(&DataKey::Draw, &draw); + + env.events().publish( + (symbol_short!("draw"), symbol_short!("cancelled")), + draw.round_id, + ); + + Ok(()) + } + // ── Views ────────────────────────────────────────────────────────────── pub fn pool(env: Env) -> Result { env.storage() diff --git a/contracts/drip-pool/src/test.rs b/contracts/drip-pool/src/test.rs index 90250b4..b94d113 100644 --- a/contracts/drip-pool/src/test.rs +++ b/contracts/drip-pool/src/test.rs @@ -719,7 +719,7 @@ fn draw_winner_emits_payout_event() { client.deposit(&alice, &1_000); let winner = client.draw_winner(&admin, &100); - assert_eq!(winner, admin); + assert_eq!(winner, alice); let events = env.events().all(); let payout_event = events.iter().find(|(_, topics, _)| { @@ -1087,3 +1087,215 @@ fn test_deposit_after_lockup_expiration_resets_lockup_window() { let payout = client.withdraw(&alice); assert_eq!(payout, 600); } + +fn generate_secret_and_commitment(env: &Env, secret_val: u8) -> (BytesN<32>, BytesN<32>) { + let mut secret_bytes = [0u8; 32]; + secret_bytes[0] = secret_val; + let secret = BytesN::from_array(env, &secret_bytes); + let commitment: BytesN<32> = env.crypto().sha256(secret.as_ref()).into(); + (secret, commitment) +} + +#[test] +fn test_commit_reveal_success() { + let (env, client, admin) = setup(); + client.create(&admin); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + client.join(&alice); + client.deposit(&alice, &1_000); + client.join(&bob); + client.deposit(&bob, &2_000); + + let current = env.ledger().sequence(); + let freeze_ledger = current + 2; + let reveal_deadline = current + 10; + + let (secret, commitment) = generate_secret_and_commitment(&env, 42); + + client.commit_draw( + &admin, + &1, // round_id + &commitment, + &freeze_ledger, + &reveal_deadline, + &500, // prize + ); + + // Verify deposits/withdrawals are blocked while draw is active + assert_eq!(client.try_deposit(&alice, &100), Err(Ok(Error::DrawActive))); + assert_eq!(client.try_withdraw(&alice), Err(Ok(Error::DrawActive))); + + // Verify finalize fails before freeze_ledger + assert_eq!( + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]), + Err(Ok(Error::DrawNotFrozen)) + ); + + // Advance sequence past freeze ledger + env.ledger().with_mut(|li| li.sequence_number = freeze_ledger + 1); + + // Finalize draw + let winner = client.finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]); + assert!(winner == alice || winner == bob); + + let win_p = client.savings(&winner); + assert_eq!(win_p.claimable, 500 + win_p.deposited); // deposit is 1000 or 2000, claimable starts as deposit + prize + + // Verify idempotency: cannot finalize again + assert_eq!( + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]), + Err(Ok(Error::DrawNotCommitted)) + ); + + // Verify deposits/withdrawals are unblocked + client.deposit(&alice, &100); + assert_eq!(client.savings(&alice).deposited, 1100); +} + +#[test] +fn test_commit_reveal_failure_and_cancellation() { + let (env, client, admin) = setup(); + client.create(&admin); + + let alice = Address::generate(&env); + client.join(&alice); + client.deposit(&alice, &1_000); + + let current = env.ledger().sequence(); + let freeze_ledger = current + 2; + let reveal_deadline = current + 10; + + let (secret, commitment) = generate_secret_and_commitment(&env, 99); + + client.commit_draw( + &admin, + &1, // round_id + &commitment, + &freeze_ledger, + &reveal_deadline, + &500, // prize + ); + + // Try to cancel before deadline - should fail for non-admin + let rando = Address::generate(&env); + assert_eq!( + client.try_cancel_draw(&rando), + Err(Ok(Error::DeadlineNotReached)) + ); + + // Advance sequence past deadline + env.ledger().with_mut(|li| li.sequence_number = reveal_deadline + 1); + + // Verify finalize fails now + assert_eq!( + client.try_finalize_draw(&secret, &vec![&env, alice.clone()]), + Err(Ok(Error::DeadlinePassed)) + ); + + // Cancel draw (can be called by anyone after deadline) + client.cancel_draw(&rando); + + // Verify deposits/withdrawals are unblocked + client.deposit(&alice, &100); + assert_eq!(client.savings(&alice).deposited, 1100); +} + +#[test] +fn test_commit_reveal_participants_validation() { + let (env, client, admin) = setup(); + client.create(&admin); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.join(&alice); + client.deposit(&alice, &1_000); + client.join(&bob); + client.deposit(&bob, &2_000); + + let current = env.ledger().sequence(); + let freeze_ledger = current + 2; + let reveal_deadline = current + 10; + + let (secret, commitment) = generate_secret_and_commitment(&env, 123); + + client.commit_draw( + &admin, + &1, + &commitment, + &freeze_ledger, + &reveal_deadline, + &500, + ); + + env.ledger().with_mut(|li| li.sequence_number = freeze_ledger + 1); + + // 1. Omit Bob - total weight doesn't match total_deposited + assert_eq!( + client.try_finalize_draw(&secret, &vec![&env, alice.clone()]), + Err(Ok(Error::InvalidParticipantsList)) + ); + + // 2. Duplicate Alice + assert_eq!( + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), alice.clone(), bob.clone()]), + Err(Ok(Error::DuplicateParticipant)) + ); + + // 3. Unjoined address + let rando = Address::generate(&env); + assert_eq!( + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone(), rando.clone()]), + Err(Ok(Error::NotJoined)) + ); +} + +#[test] +fn test_commit_reveal_rejection_sampling_unbiased() { + let (env, client, admin) = setup(); + client.create(&admin); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.join(&alice); + client.deposit(&alice, &100); // 10% weight + client.join(&bob); + client.deposit(&bob, &900); // 90% weight + + let mut alice_wins = 0; + let mut bob_wins = 0; + + // Run 30 rounds of draws with different secrets to verify statistical distribution + for round in 1..=30 { + let current = env.ledger().sequence(); + let freeze_ledger = current + 1; + let reveal_deadline = current + 5; + + let (secret, commitment) = generate_secret_and_commitment(&env, round as u8); + + client.commit_draw( + &admin, + &round, + &commitment, + &freeze_ledger, + &reveal_deadline, + &100, + ); + + env.ledger().with_mut(|li| li.sequence_number = freeze_ledger + 1); + + let winner = client.finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]); + if winner == alice { + alice_wins += 1; + } else if winner == bob { + bob_wins += 1; + } + } + + // Both should win at least once, and Bob should win significantly more + assert!(alice_wins > 0); + assert!(bob_wins > 0); + assert!(bob_wins > alice_wins); +} diff --git a/scripts/legacy-term-whitelist.json b/scripts/legacy-term-whitelist.json index 9375c05..7157dd8 100644 --- a/scripts/legacy-term-whitelist.json +++ b/scripts/legacy-term-whitelist.json @@ -17,7 +17,8 @@ "term": "Trustless Work", "paths": [ "docs/env-inventory.md", - "services/escrowService.ts" + "services/escrowService.ts", + "stellar-wallet-connect/src/components/NetworkDiagnostics.tsx" ], "reason": "Intentional integration references that remain part of current config semantics." },