diff --git a/packages/rs-dapi-client/src/address_ban_info.rs b/packages/rs-dapi-client/src/address_ban_info.rs new file mode 100644 index 00000000000..ff9ae34e9d3 --- /dev/null +++ b/packages/rs-dapi-client/src/address_ban_info.rs @@ -0,0 +1,29 @@ +//! An owned snapshot of an address' ban state, decoupled from the +//! lock-guarded [`crate::AddressList`] internals so it can cross crate +//! and FFI boundaries. + +use chrono::{DateTime, Utc}; + +/// An owned, cloned snapshot of a single address' ban state, returned +/// from [`crate::AddressList::ban_info`]. +/// +/// This is a plain-data mirror of [`crate::AddressStatus`] (plus the +/// address URI) so it can be handed across crate / FFI boundaries +/// without holding the [`crate::AddressList`] lock. +#[derive(Debug, Clone)] +pub struct AddressBanInfo { + /// The address URI as a string. + pub uri: String, + /// Whether the address is *currently effectively* banned, i.e. it + /// has been banned at least once and the ban period has not yet + /// expired. Consistent with the filtering used by + /// [`crate::AddressList::get_live_address`]. + pub banned: bool, + /// Total number of times the address has been banned (drives the + /// exponential backoff). + pub ban_count: usize, + /// The timestamp until which the address remains banned, if any. + pub banned_until: Option>, + /// Human-readable reason for the most recent ban, if recorded. + pub reason: Option, +} diff --git a/packages/rs-dapi-client/src/address_list.rs b/packages/rs-dapi-client/src/address_list.rs index 7f28a51c718..fc1d734ccfe 100644 --- a/packages/rs-dapi-client/src/address_list.rs +++ b/packages/rs-dapi-client/src/address_list.rs @@ -1,5 +1,6 @@ //! Subsystem to manage DAPI nodes. +use crate::address_ban_info::AddressBanInfo; use crate::Uri; use chrono::Utc; use rand::{rngs::SmallRng, seq::IteratorRandom, SeedableRng}; @@ -73,16 +74,33 @@ impl Address { pub struct AddressStatus { ban_count: usize, banned_until: Option>, + /// Human-readable reason for the most recent ban, if any. Cleared + /// on [`AddressStatus::unban`]. Sourced from the error that caused + /// the ban (see `update_address_ban_status`). + ban_reason: Option, } impl AddressStatus { /// Ban the [Address] so it won't be available through [AddressList::get_live_address] for some time. + /// + /// Back-compat shim for [`AddressStatus::ban_with_reason`] with no reason. pub fn ban(&mut self, base_ban_period: &Duration) { + self.ban_with_reason(base_ban_period, None); + } + + /// Ban the [Address] and record the `reason` for the ban. + /// + /// Applies the same exponential backoff as [`AddressStatus::ban`]; + /// the only difference is that `ban_reason` is stored so callers + /// (diagnostics, the iOS explorer) can surface why a node was + /// banned. + pub fn ban_with_reason(&mut self, base_ban_period: &Duration, reason: Option) { let coefficient = (self.ban_count as f64).exp(); let ban_period = Duration::from_secs_f64(base_ban_period.as_secs_f64() * coefficient); self.banned_until = Some(chrono::Utc::now() + ban_period); self.ban_count += 1; + self.ban_reason = reason; } /// Check if [Address] is banned. @@ -94,6 +112,7 @@ impl AddressStatus { pub fn unban(&mut self) { self.ban_count = 0; self.banned_until = None; + self.ban_reason = None; } } @@ -143,14 +162,22 @@ impl AddressList { /// Bans address /// Returns false if the address is not in the list. + /// + /// Back-compat shim for [`AddressList::ban_with_reason`] with no reason. pub fn ban(&self, address: &Address) -> bool { + self.ban_with_reason(address, None) + } + + /// Bans address, recording the `reason` for the ban. + /// Returns false if the address is not in the list. + pub fn ban_with_reason(&self, address: &Address, reason: Option) -> bool { let mut guard = self.addresses.write().unwrap(); let Some(status) = guard.get_mut(address) else { return false; }; - status.ban(&self.base_ban_period); + status.ban_with_reason(&self.base_ban_period, reason); true } @@ -233,7 +260,7 @@ impl AddressList { /// Get all not banned addresses. /// /// Returns a vector of addresses that are not currently banned or whose ban period has expired. - /// The returned addresses use the same filtering logic as [get_live_address], checking if the + /// The returned addresses use the same filtering logic as [`Self::get_live_address`], checking if the /// ban period has expired based on the current time. /// /// # Examples @@ -266,6 +293,38 @@ impl AddressList { .collect() } + /// Get an owned snapshot of every address' ban state. + /// + /// Clones the current state into an owned `Vec` so + /// it can be inspected without holding the internal lock. The + /// `banned` flag reflects the *currently effectively banned* + /// semantics used by [`AddressList::get_live_address`]: the address + /// has been banned at least once (`ban_count > 0`) and its ban + /// period has not yet expired (`banned_until` is in the future). + pub fn ban_info(&self) -> Vec { + let guard = self.addresses.read().unwrap(); + + let now = chrono::Utc::now(); + + guard + .iter() + .map(|(addr, status)| { + let banned = status.ban_count > 0 + && status + .banned_until + .map(|banned_until| banned_until >= now) + .unwrap_or(false); + AddressBanInfo { + uri: addr.to_string(), + banned, + ban_count: status.ban_count, + banned_until: status.banned_until, + reason: status.ban_reason.clone(), + } + }) + .collect() + } + /// Get number of all addresses, both banned and not banned. pub fn len(&self) -> usize { self.addresses.read().unwrap().len() @@ -592,4 +651,108 @@ mod tests { let list = AddressList::default(); assert!(list.is_empty()); } + + #[test] + fn test_address_status_ban_with_reason_stores_reason() { + let mut status = AddressStatus::default(); + assert!(status.ban_reason.is_none()); + + status.ban_with_reason( + &Duration::from_secs(60), + Some("transport error".to_string()), + ); + assert_eq!(status.ban_reason.as_deref(), Some("transport error")); + assert!(status.is_banned()); + } + + #[test] + fn test_address_status_ban_without_reason_is_none() { + let mut status = AddressStatus::default(); + status.ban(&Duration::from_secs(60)); + assert!(status.ban_reason.is_none()); + assert!(status.is_banned()); + } + + #[test] + fn test_address_status_unban_clears_reason() { + let mut status = AddressStatus::default(); + status.ban_with_reason(&Duration::from_secs(60), Some("boom".to_string())); + assert_eq!(status.ban_reason.as_deref(), Some("boom")); + + status.unban(); + assert!(status.ban_reason.is_none()); + assert!(!status.is_banned()); + } + + #[test] + fn test_address_list_ban_with_reason_records_reason() { + let mut list = AddressList::new(); + let addr: Address = "http://127.0.0.1:3000".parse().unwrap(); + list.add(addr.clone()); + + assert!(list.ban_with_reason(&addr, Some("node down".to_string()))); + + let info = list.ban_info(); + assert_eq!(info.len(), 1); + let entry = &info[0]; + assert_eq!(entry.reason.as_deref(), Some("node down")); + assert!(entry.banned); + assert_eq!(entry.ban_count, 1); + assert!(entry.banned_until.is_some()); + } + + #[test] + fn test_address_list_ban_without_reason_records_none() { + let mut list = AddressList::new(); + let addr: Address = "http://127.0.0.1:3000".parse().unwrap(); + list.add(addr.clone()); + + assert!(list.ban(&addr)); + + let info = list.ban_info(); + assert_eq!(info.len(), 1); + assert!(info[0].reason.is_none()); + assert!(info[0].banned); + } + + #[test] + fn test_address_list_unban_clears_reason_in_ban_info() { + let mut list = AddressList::new(); + let addr: Address = "http://127.0.0.1:3000".parse().unwrap(); + list.add(addr.clone()); + + list.ban_with_reason(&addr, Some("oops".to_string())); + assert!(list.unban(&addr)); + + let info = list.ban_info(); + assert_eq!(info.len(), 1); + let entry = &info[0]; + assert!(entry.reason.is_none()); + assert!(!entry.banned); + assert_eq!(entry.ban_count, 0); + assert!(entry.banned_until.is_none()); + } + + #[test] + fn test_ban_info_reflects_unbanned_address() { + let mut list = AddressList::new(); + let addr: Address = "http://127.0.0.1:3000".parse().unwrap(); + list.add(addr.clone()); + + // Never banned: banned == false, no reason, ban_count == 0. + let info = list.ban_info(); + assert_eq!(info.len(), 1); + let entry = &info[0]; + assert!(!entry.banned); + assert_eq!(entry.ban_count, 0); + assert!(entry.banned_until.is_none()); + assert!(entry.reason.is_none()); + assert!(entry.uri.contains("127.0.0.1")); + } + + #[test] + fn test_ban_info_empty_list() { + let list = AddressList::new(); + assert!(list.ban_info().is_empty()); + } } diff --git a/packages/rs-dapi-client/src/dapi_client.rs b/packages/rs-dapi-client/src/dapi_client.rs index 5c20d46dc01..72ccf31c378 100644 --- a/packages/rs-dapi-client/src/dapi_client.rs +++ b/packages/rs-dapi-client/src/dapi_client.rs @@ -187,7 +187,7 @@ pub fn update_address_ban_status( if error.can_retry() { if let Some(address) = error.address.as_ref() { if applied_settings.ban_failed_address { - if address_list.ban(address) { + if address_list.ban_with_reason(address, Some(error.to_string())) { tracing::warn!( ?address, ?error, @@ -352,6 +352,16 @@ mod tests { update_address_ban_status(&address_list, &result, &make_applied_settings(true)); assert!(address_list.is_banned(&addr)); + + // The ban reason must be propagated from the error via this call path, + // not just the ban itself. + let info = address_list.ban_info(); + assert_eq!(info.len(), 1); + let reason = info[0].reason.as_deref().expect("ban reason recorded"); + assert!( + reason.contains("temporary"), + "ban reason should carry the underlying error, got: {reason}" + ); } #[test] diff --git a/packages/rs-dapi-client/src/lib.rs b/packages/rs-dapi-client/src/lib.rs index 864f5baf60b..b31d2b35a2c 100644 --- a/packages/rs-dapi-client/src/lib.rs +++ b/packages/rs-dapi-client/src/lib.rs @@ -2,6 +2,7 @@ #![deny(missing_docs)] +mod address_ban_info; mod address_list; mod connection_pool; mod dapi_client; @@ -13,6 +14,7 @@ pub mod mock; mod request_settings; pub mod transport; +pub use address_ban_info::AddressBanInfo; pub use address_list::Address; pub use address_list::AddressList; pub use address_list::AddressListError; diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index aa4dfc040ff..c8ebc1348fe 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -615,6 +615,29 @@ pub struct WalletIdentityRowFFI { pub identity_id: [u8; 32], } +/// One row of the DAPI address ban-list snapshot. +/// +/// `address` is a heap-owned NUL-terminated UTF-8 string (the node +/// URI); `reason` is a heap-owned NUL-terminated UTF-8 string or +/// `null` when no ban reason was recorded. Both are freed by the +/// paired `platform_wallet_manager_address_ban_info_free`. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AddressBanInfoFFI { + /// Heap-owned node URI string. Always non-null on a successful row. + pub address: *mut c_char, + /// Whether the address is currently effectively banned. + pub banned: bool, + /// Total number of times the address has been banned. + pub ban_count: u32, + /// Unix-epoch millisecond timestamp until which the address is + /// banned; `0` when there is no active ban window. + pub banned_until_ms: i64, + /// Heap-owned human-readable ban reason, or `null` when none was + /// recorded. + pub reason: *mut c_char, +} + /// Subset of [`crate::wallet_restore_types::AccountSpecFFI`] carrying /// only the tag/discriminator fields — no xpub. Used by the /// changeset emit path to populate diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index a34ae66077d..77381873dc9 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -18,17 +18,18 @@ use std::os::raw::c_char; use platform_wallet::manager::accessors::{ AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, AccountMetadataSnapshot, - AccountTransactionSnapshot, AccountUtxoSnapshot, CoreWalletStateSnapshot, - IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, PlatformAddressProviderStateSnapshot, - PlatformAddressSyncConfigSnapshot, TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, + CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, + PlatformAddressProviderStateSnapshot, PlatformAddressSyncConfigSnapshot, + TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, }; use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, - AccountUtxoEntryFFI, AddressInfoFFI, CoreWalletStateFFI, IdentitySyncConfigFFI, - IdentityWalletStateFFI, PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, - TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, + PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -782,6 +783,77 @@ pub unsafe extern "C" fn platform_wallet_identity_manager_wallet_identities_free } } +// --------------------------------------------------------------------------- +// Phase 8 — DAPI address ban list +// --------------------------------------------------------------------------- + +/// Snapshot of every DAPI address' ban state, including the reason +/// each address was banned (when recorded). +/// +/// On success `*out_entries` points at a heap-owned `[AddressBanInfoFFI]` +/// of length `*out_count`; the caller must release it via +/// [`platform_wallet_manager_address_ban_info_free`]. An empty list +/// returns `ok()` with `*out_entries = null`, `*out_count = 0`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_address_ban_info( + manager_handle: Handle, + out_entries: *mut *const AddressBanInfoFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_entries); + check_ptr!(out_count); + *out_entries = std::ptr::null(); + *out_count = 0; + let Some(rows): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.address_ban_info_blocking()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows + .into_iter() + .map(|s| AddressBanInfoFFI { + address: optional_into_raw(Some(s.uri)), + banned: s.banned, + ban_count: u32::try_from(s.ban_count).unwrap_or(u32::MAX), + banned_until_ms: s.banned_until_ms.unwrap_or(0), + reason: optional_into_raw(s.reason), + }) + .collect(); + let count = entries.len(); + let boxed = entries.into_boxed_slice(); + *out_entries = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_address_ban_info_free( + entries: *mut AddressBanInfoFFI, + count: usize, +) { + if entries.is_null() || count == 0 { + return; + } + // Walk every row first to release its heap-owned `address` and + // optional `reason` C strings before reclaiming the parent slice. + let slice = std::slice::from_raw_parts(entries, count); + for entry in slice { + if !entry.address.is_null() { + let _ = CString::from_raw(entry.address); + } + if !entry.reason.is_null() { + let _ = CString::from_raw(entry.reason); + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 578079f9986..cdf679bef28 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -201,12 +201,55 @@ pub struct WalletIdentityRowSnapshot { pub identity_id: [u8; 32], } +/// One row of the DAPI address ban-list snapshot, returned from +/// [`PlatformWalletManager::address_ban_info_blocking`]. +/// +/// A platform-wallet-owned mirror of the SDK's `AddressBanInfo` with +/// `banned_until` already projected to a millisecond Unix timestamp so +/// the FFI layer can marshal it without depending on `chrono`. +#[derive(Debug, Clone)] +pub struct AddressBanInfoSnapshot { + /// The DAPI node URI. + pub uri: String, + /// Whether the address is currently effectively banned (banned at + /// least once and the ban period has not yet expired). + pub banned: bool, + /// Total number of times the address has been banned. + pub ban_count: usize, + /// Unix-epoch millisecond timestamp until which the address is + /// banned, or `None` if there is no active ban window. + pub banned_until_ms: Option, + /// Human-readable reason for the most recent ban, if recorded. + pub reason: Option, +} + impl PlatformWalletManager

{ /// The SDK instance. pub fn sdk(&self) -> &dash_sdk::Sdk { &self.sdk } + /// Snapshot of every DAPI address' ban state, including the reason + /// each address was banned (when recorded). + /// + /// Delegates to the SDK's `address_ban_info`, projecting the + /// `chrono` timestamp into a Unix-epoch millisecond `i64` so the + /// FFI layer can marshal it without a `chrono` dependency. This is + /// a pure read — no async, no lock contention on the wallet manager. + pub fn address_ban_info_blocking(&self) -> Vec { + self.sdk + .address_ban_info() + .into_iter() + .map(|info| AddressBanInfoSnapshot { + uri: info.uri, + banned: info.banned, + ban_count: info.ban_count, + banned_until_ms: info.banned_until.map(|t| t.timestamp_millis()), + reason: info.reason, + }) + .collect() + } + /// Access the SPV runtime for sync control. pub fn spv(&self) -> &SpvRuntime { &self.spv_manager diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 675650f97cd..c3439da71a9 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -26,6 +26,7 @@ pub use http::Uri; #[cfg(feature = "mocks")] use rs_dapi_client::mock::MockDapiClient; pub use rs_dapi_client::Address; +pub use rs_dapi_client::AddressBanInfo; pub use rs_dapi_client::AddressList; pub use rs_dapi_client::RequestSettings; use rs_dapi_client::{ @@ -619,6 +620,16 @@ impl Sdk { SdkInstance::Mock { address_list, .. } => address_list, } } + + /// Return an owned snapshot of every DAPI address' ban state, + /// including the reason the address was banned (when recorded). + /// + /// Delegates to [`AddressList::ban_info`]. Useful for diagnostics + /// and surfacing ban state up through the platform-wallet FFI to + /// the iOS example app. + pub fn address_ban_info(&self) -> Vec { + self.address_list().ban_info() + } } /// If received metadata time differs from local time by more than `tolerance`, the remote node is considered stale. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift index 586397a3fcd..3a350a2a782 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift @@ -482,6 +482,53 @@ extension PlatformWalletManager { ) } } + + // MARK: - Phase 8 — DAPI address ban list + + /// One row of the DAPI address ban-list snapshot. + public struct AddressBanInfo { + /// The DAPI node URI. + public let address: String + /// Whether the address is currently effectively banned. + public let banned: Bool + /// Total number of times the address has been banned. + public let banCount: Int + /// The instant until which the address remains banned, or `nil` + /// when there is no active ban window. + public let bannedUntil: Date? + /// Human-readable reason for the most recent ban, or `nil` when + /// none was recorded. + public let reason: String? + } + + /// Snapshot of every DAPI address' ban state, including the reason + /// each address was banned (when recorded). + public func addressBanInfo() -> [AddressBanInfo] { + guard isConfigured, handle != NULL_HANDLE else { return [] } + var outEntries: UnsafePointer? = nil + var outCount: UInt = 0 + let res = platform_wallet_manager_address_ban_info(handle, &outEntries, &outCount) + guard PlatformWalletResult(res).isSuccess, let ptr = outEntries, outCount > 0 else { return [] } + defer { platform_wallet_manager_address_ban_info_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return (0.. AddressBanInfo? in + let entry = ptr[i] + // The address is the key identifier; skip any (defensive) NULL + // entry rather than surfacing a non-actionable blank row. + guard let addressPtr = entry.address else { return nil } + let address = String(cString: addressPtr) + let reason: String? = entry.reason.map { String(cString: $0) } + let bannedUntil: Date? = entry.banned_until_ms == 0 + ? nil + : Date(timeIntervalSince1970: Double(entry.banned_until_ms) / 1000.0) + return AddressBanInfo( + address: address, + banned: entry.banned, + banCount: Int(entry.ban_count), + bannedUntil: bannedUntil, + reason: reason + ) + } + } } // MARK: - Helpers diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/BannedAddressesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/BannedAddressesView.swift new file mode 100644 index 00000000000..9f125d98575 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/BannedAddressesView.swift @@ -0,0 +1,181 @@ +// BannedAddressesView.swift +// SwiftExampleApp +// +// Read-only diagnostic surface for the DAPI address ban list. Renders +// the synchronous snapshot returned by +// `PlatformWalletManager.addressBanInfo()` — the Rust-side DAPI client's +// per-address ban state, including the reason each address was banned +// (when recorded). +// +// Complements the other Data-section explorers (Storage / Keychain / +// Wallet Memory). Like those, this view only READS via the existing FFI +// wrapper and renders; it makes no policy decisions, performs no +// iteration logic, and writes nothing back. + +import SwiftUI +import SwiftDashSDK + +struct BannedAddressesView: View { + @EnvironmentObject var walletManager: PlatformWalletManager + + /// Snapshot loaded imperatively from `addressBanInfo()`. The wrapper + /// is a synchronous one-shot read (not a SwiftData `@Query`), so the + /// view loads it on appear and re-loads on manual refresh rather + /// than observing reactively. + @State private var entries: [PlatformWalletManager.AddressBanInfo] = [] + @State private var hasLoaded = false + + /// Stable formatter for the `bannedUntil` instant. Built once so we + /// don't allocate a `DateFormatter` per row. + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .medium + return f + }() + + var body: some View { + Group { + if entries.isEmpty { + emptyState + } else { + List { + Section { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + BanRow(entry: entry, dateFormatter: Self.dateFormatter) + } + } header: { + Text("Addresses (\(entries.count))") + } footer: { + sessionCaption + } + } + } + } + .navigationTitle("Banned Addresses") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + load() + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("Refresh") + } + } + .refreshable { load() } + .onAppear { + // Load once on first appearance; pull-to-refresh and the + // toolbar button drive subsequent reloads. + guard !hasLoaded else { return } + load() + } + } + + // MARK: - Empty state + + @ViewBuilder + private var emptyState: some View { + ContentUnavailableView { + Label("No Banned Addresses", systemImage: "nosign") + } description: { + Text(emptyStateMessage) + } actions: { + Button("Refresh") { load() } + } + } + + private var emptyStateMessage: String { + "This list reflects the current SDK session. An empty list can " + + "mean either that no DAPI addresses have been banned, or that " + + "the address pool has not yet been seeded." + } + + private var sessionCaption: some View { + Text( + "Reflects the current SDK session. An empty list can mean " + + "either no bans or an unseeded address pool." + ) + .font(.caption) + .foregroundColor(.secondary) + } + + // MARK: - Load + + private func load() { + entries = walletManager.addressBanInfo() + hasLoaded = true + } +} + +// MARK: - Row + +private struct BanRow: View { + let entry: PlatformWalletManager.AddressBanInfo + let dateFormatter: DateFormatter + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(entry.address) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Spacer(minLength: 8) + BanStatusBadge(banned: entry.banned) + } + + KVLine(label: "Ban Count", value: "\(entry.banCount)") + KVLine( + label: "Banned Until", + value: entry.bannedUntil.map { dateFormatter.string(from: $0) } ?? "—" + ) + + VStack(alignment: .leading, spacing: 2) { + Text("Reason") + .font(.caption) + .foregroundColor(.secondary) + Text(entry.reason ?? "—") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + .padding(.vertical, 2) + } +} + +/// A label/value line where the value is monospaced and trailing-aligned. +private struct KVLine: View { + let label: String + let value: String + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.system(.caption, design: .monospaced)) + } + } +} + +/// Red "Banned" capsule vs green "Live" capsule, mirroring the badge +/// style used by `AccountVariantBadge` in the Wallet Memory explorer. +private struct BanStatusBadge: View { + let banned: Bool + + var body: some View { + Text(banned ? "Banned" : "Live") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background((banned ? Color.red : Color.green).opacity(0.18)) + .foregroundColor(banned ? .red : .green) + .clipShape(Capsule()) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 580313de999..00fb99520fe 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -385,6 +385,10 @@ struct OptionsView: View { Label("Wallet Memory Explorer", systemImage: "memorychip") } + NavigationLink(destination: BannedAddressesView()) { + Label("Banned Addresses", systemImage: "nosign") + } + Button(action: { showingDataManagement = true }) { Label("Manage Local Data", systemImage: "internaldrive") }