diff --git a/BITCOIN_PLAN.md b/BITCOIN_PLAN.md deleted file mode 100644 index cce16ee..0000000 --- a/BITCOIN_PLAN.md +++ /dev/null @@ -1,154 +0,0 @@ -# Pulse Bitcoin/Lightning Integration - Production Hardening Plan - -> **48-Hour Sprint to transform Pulse from "feature-complete" to "production-hardened"** - -## 🎯 Executive Summary - -This document outlines the critical path to securely integrate Bitcoin Lightning payments (zaps) into Pulse. The focus is on **cryptographic guarantees** rather than superficial features, ensuring user funds are protected at every step. - -## 🚨 Critical Security Risks Identified - -### High-Priority Threats -1. **Invoice Swapping Attack**: Malicious LNURL server returns high-value invoice for low-value zap request -2. **Wallet URI Exploits**: Malicious strings injected into external wallet calls -3. **Fake Receipt Injection**: Relays broadcasting fake "paid" statuses -4. **Privacy Leaks**: Sensitive data exposed in app switcher/background - -## πŸ“‹ 48-Hour Hardening Sprint - -### Phase 0: BOLT11 Parser Foundation (4 hours) - **CRITICAL** -**Status**: πŸ”„ In Progress -**Priority**: P0 -**Files**: `Bolt11Parser.swift`, `Bolt11Validator.swift` - -**Implementation**: -- Proper BOLT11 invoice parsing without external dependencies -- TLV (Type-Length-Value) data extraction -- Bech32 decoding validation -- Malicious pattern detection (SQL injection, XSS attempts) - -**Dependencies**: None -**Testing**: Unit tests with malicious invoice vectors - -### Phase 1: NIP-57 JSON Normalization (3 hours) - **CRITICAL** -**Status**: βšͺ Pending -**Priority**: P0 -**Files**: `NostrNormalization.swift`, `EventNormalization.swift` - -**Implementation**: -- Deterministic JSON serialization with sorted keys -- SHA-256 hash calculation for description_hash -- Cross-client compatibility verification -- NIP-57 specification compliance - -**Dependencies**: Phase 0 completion -**Testing**: Hash comparison with reference clients (Damus, Amethyst) - -### Phase 2: Enhanced Amount Guard (2 hours) - **CRITICAL** -**Status**: βšͺ Pending -**Priority**: P0 -**Files**: `ZapSecurityGuard.swift`, `AmountGuard.swift` - -**Implementation**: -- Three-way amount verification (UI β†’ Zap Request β†’ Invoice) -- Millisat precision checking -- Invoice expiration validation -- Defense-in-depth consistency checks - -**Dependencies**: Phase 0 & 1 completion -**Testing**: Amount mismatch scenarios - -### Phase 2b: Signature & Key Validation (2 hours) - **CRITICAL** -**Status**: βšͺ Pending -**Priority**: P0 -**Files**: `NostrIdentityManager.swift`, `NostrTransport.swift`, `ZapManager.swift` - -**Implementation**: -- Verify NIP-57 zap request signatures before hashing/invoice checks -- Validate pubkey formats and event kinds -- Reject unsigned or malformed zap requests early - -**Dependencies**: Phase 1 completion -**Testing**: Invalid signature vectors, malformed pubkeys - -### Phase 3: Wallet URI Sanitization (2 hours) - **HIGH** -**Status**: βšͺ Pending -**Priority**: P1 -**Files**: `WalletURISanitizer.swift`, `LNURLService+Security.swift` - -**Implementation**: -- Wallet scheme whitelisting -- URI encoding and validation -- Malicious character filtering -- Length-based DoS prevention - -**Dependencies**: Phase 0 completion -**Testing**: URI injection attempts - -### Phase 3b: Network Defenses (2 hours) - **HIGH** -**Status**: βšͺ Pending -**Priority**: P1 -**Files**: `LNURLService.swift`, `NostrTransport.swift` - -**Implementation**: -- Request timeouts and cancellation propagation -- Retry/backoff with jitter for LNURL fetches -- Rate limiting for relay events and LNURL requests - -**Dependencies**: None -**Testing**: Timeout handling, retry behavior, flood simulation - -### Phase 4: Privacy-Sensitive UI (1 hour) - **MEDIUM** -**Status**: βšͺ Pending -**Priority**: P2 -**Files**: `PrivacyExtensions.swift`, `ZapDisplayView+Privacy.swift` - -**Implementation**: -- `.privacySensitive()` modifiers for sensitive data -- Secure text display components -- App switcher data protection -- Optional reveal/hide functionality - -**Dependencies**: None -**Testing**: UI state preservation checks - -### Phase 4b: Invoice Constraints & Logging Hygiene (2 hours) - **MEDIUM** -**Status**: βšͺ Pending -**Priority**: P2 -**Files**: `Bolt11Validator.swift`, `ZapSecurityGuard.swift`, `ErrorManager.swift` - -**Implementation**: -- Enforce invoice length caps and min/max amounts -- Reject unsupported or multi-currency tags -- Scrub invoices/LNURLs/payment hashes from logs in prod builds - -**Dependencies**: Phase 0 completion -**Testing**: Oversized invoice vectors, log redaction checks - -### Phase 5: Security Testing & Audit (4 hours) - **CRITICAL** -**Status**: βšͺ Pending -**Priority**: P0 -**Files**: `ProductionSecurityTests.swift`, `SecurityHardeningTests.swift` - -**Implementation**: -- Malicious relay simulation -- Error log scrubbing audit -- Performance under attack conditions -- Integration testing with real wallets - -**Dependencies**: All previous phases -**Testing**: Full security test suite - -## πŸ› οΈ Technical Implementation Details - -### NIP-57 Description Hash Verification -```swift -// Critical security check - prevents invoice swapping -func verifyDescriptionHash(zapRequest: NostrEvent, invoice: String) throws { - let requestHash = try zapRequest.descriptionHash() - let invoiceHash = try extractDescriptionHash(from: invoice) - - guard requestHash == invoiceHash else { - throw ZapError.descriptionHashMismatch - } -} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c64a625 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Pulse is an iOS decentralized messaging app written in Swift. It uses BLE/MultipeerConnectivity for local mesh networking and Nostr relays for global reach. No servers β€” all peer-to-peer. + +## Build & Run + +The Xcode project lives at `Pulse/Pulse.xcodeproj`. Open it in Xcode 26+. + +```bash +# Build for simulator +xcodebuild -project Pulse/Pulse.xcodeproj -scheme Pulse \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,OS=26.0,name=iPhone 17' \ + build + +# Run all tests +xcodebuild -project Pulse/Pulse.xcodeproj -scheme PulseTests \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,OS=26.0,name=iPhone 17' \ + test + +# Run a single test class +xcodebuild -project Pulse/Pulse.xcodeproj -scheme PulseTests \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,OS=26.0,name=iPhone 17' \ + -only-testing:PulseTests/NostrEventValidatorTests \ + test +``` + +Deployment target: iOS 26.0 for the app, iOS 17.4 for test targets. Swift 5.0 for the app target. + +## Architecture + +All source is under `Pulse/Pulse/`. The app uses `@MainActor` isolation throughout managers and networking. + +### Dual-Transport System + +`UnifiedTransportManager` coordinates two transport paths: +- **Mesh** (`MeshManager` + `BLEAdvertiser`): Local peer discovery via MultipeerConnectivity and CoreBluetooth. Uses `MCSession` with service type `_pulse-mesh._tcp`. +- **Nostr** (`NostrTransport`): WebSocket connections to Nostr relays for global messaging and location channels. + +Both implement `TransportProtocol`. Messages go through `MessageRouter` (multi-hop routing, max 7 hops) and `MessageDeduplicationService` before delivery. + +### Key Singletons + +Most managers use `static let shared` singletons: +- `ChatManager` β€” conversation state, link previews, message handling +- `MeshManager` β€” MultipeerConnectivity session, peer discovery (this one is `@StateObject` in `PulseApp`, injected as `@EnvironmentObject`) +- `IdentityManager` + `NostrIdentityManager` β€” key generation/storage, Nostr profile +- `PersistenceManager` β€” SwiftData container (`PersistedMessage`, `PersistedConversation`, `PersistedGroup`) +- `KeychainManager` β€” secure key storage with `.whenUnlockedThisDeviceOnly` + +### Security Stack + +`NostrEventValidator` verifies secp256k1 Schnorr signatures. `RateLimiter` prevents relay event flooding (60 events/sec). `SecureNetworkSession` enforces TLS certificate validation. `ClipboardManager` auto-clears sensitive data after 30 seconds. + +### Data Layer + +SwiftData models prefixed with `Persisted*` (`PersistedMessage`, `PersistedConversation`, `PersistedGroup`). In-memory models (`Message`, `PulsePeer`, `Group`) are used in the view layer. `PersistenceManager.shared.container` is injected via `.modelContainer()` at the app root. + +### Crypto + +- Curve25519 (X25519) key exchange + ChaCha20-Poly1305 for mesh message encryption +- Ed25519 for mesh message signing +- secp256k1 Schnorr for Nostr event signing +- All via Apple CryptoKit except secp256k1 + +## Test Suite + +Tests are in `Pulse/PulseTests/`. Notable test infrastructure: +- `MeshSimulator/` β€” virtual peer network with `ChaosEngine`, `TopologyController`, `VirtualPeer` for simulating mesh conditions +- Security tests cover rate limiting, Nostr event validation, and sensitive string scrubbing + +## Bundle ID & Background Modes + +Bundle ID: `com.jesse.pulse-mesh`. Background task ID: `com.jesse.pulse-mesh.discovery`. Bluetooth central/peripheral background modes enabled. diff --git a/Pulse/Pulse.xcodeproj/project.pbxproj b/Pulse/Pulse.xcodeproj/project.pbxproj index 3f3a6bf..86aa3af 100644 --- a/Pulse/Pulse.xcodeproj/project.pbxproj +++ b/Pulse/Pulse.xcodeproj/project.pbxproj @@ -69,22 +69,18 @@ 0A000309 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000308 /* HapticManager.swift */; }; 0A000410 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000411 /* NostrIdentity.swift */; }; 0A000412 /* NostrIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000413 /* NostrIdentityManager.swift */; }; - 0A000414 /* Zap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000415 /* Zap.swift */; }; - 0A000416 /* LNURLService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000417 /* LNURLService.swift */; }; - 0A000418 /* ZapManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000419 /* ZapManager.swift */; }; - 0A00041A /* ZapButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00041B /* ZapButton.swift */; }; - 0A00041C /* ZapAmountSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00041D /* ZapAmountSheet.swift */; }; - 0A00041E /* ZapDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00041F /* ZapDisplayView.swift */; }; - 0A000420 /* Bolt11Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000422 /* Bolt11Parser.swift */; }; - 0A000421 /* Bolt11Validator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000423 /* Bolt11Validator.swift */; }; 0A000424 /* NostrNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000426 /* NostrNormalization.swift */; }; 0A000425 /* EventNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000427 /* EventNormalization.swift */; }; - 0A000428 /* AmountGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00042A /* AmountGuard.swift */; }; - 0A000429 /* ZapSecurityGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00042B /* ZapSecurityGuard.swift */; }; 0A00042C /* NostrEventValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00042D /* NostrEventValidator.swift */; }; - 0A00042E /* WalletURISanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00042F /* WalletURISanitizer.swift */; }; 0A000430 /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000431 /* RateLimiter.swift */; }; 0A000432 /* PrivacyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000433 /* PrivacyExtensions.swift */; }; + 0A000500 /* SecureNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000501 /* SecureNetworkSession.swift */; }; + 0A000502 /* DebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000503 /* DebugLogger.swift */; }; + 0A000504 /* ClipboardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000505 /* ClipboardManager.swift */; }; + 0A000506 /* PeerConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000507 /* PeerConnectionManager.swift */; }; + 0A000508 /* SentMessageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A000509 /* SentMessageCache.swift */; }; + 0A00050A /* ChatHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00050B /* ChatHeaderView.swift */; }; + 0A00050C /* ChatInputBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00050D /* ChatInputBarView.swift */; }; 88E8D9A62F1CE36A00FE7DC2 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 0A000401 /* P256K */; }; /* End PBXBuildFile section */ @@ -164,22 +160,18 @@ 0A000308 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = ""; }; 0A000411 /* NostrIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentity.swift; sourceTree = ""; }; 0A000413 /* NostrIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentityManager.swift; sourceTree = ""; }; - 0A000415 /* Zap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zap.swift; sourceTree = ""; }; - 0A000417 /* LNURLService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNURLService.swift; sourceTree = ""; }; - 0A000419 /* ZapManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapManager.swift; sourceTree = ""; }; - 0A00041B /* ZapButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapButton.swift; sourceTree = ""; }; - 0A00041D /* ZapAmountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapAmountSheet.swift; sourceTree = ""; }; - 0A00041F /* ZapDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapDisplayView.swift; sourceTree = ""; }; - 0A000422 /* Bolt11Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bolt11Parser.swift; sourceTree = ""; }; - 0A000423 /* Bolt11Validator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bolt11Validator.swift; sourceTree = ""; }; 0A000426 /* NostrNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrNormalization.swift; sourceTree = ""; }; 0A000427 /* EventNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventNormalization.swift; sourceTree = ""; }; - 0A00042A /* AmountGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountGuard.swift; sourceTree = ""; }; - 0A00042B /* ZapSecurityGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapSecurityGuard.swift; sourceTree = ""; }; 0A00042D /* NostrEventValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventValidator.swift; sourceTree = ""; }; - 0A00042F /* WalletURISanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletURISanitizer.swift; sourceTree = ""; }; 0A000431 /* RateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimiter.swift; sourceTree = ""; }; 0A000433 /* PrivacyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyExtensions.swift; sourceTree = ""; }; + 0A000501 /* SecureNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureNetworkSession.swift; sourceTree = ""; }; + 0A000503 /* DebugLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLogger.swift; sourceTree = ""; }; + 0A000505 /* ClipboardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardManager.swift; sourceTree = ""; }; + 0A000507 /* PeerConnectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerConnectionManager.swift; sourceTree = ""; }; + 0A000509 /* SentMessageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentMessageCache.swift; sourceTree = ""; }; + 0A00050B /* ChatHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHeaderView.swift; sourceTree = ""; }; + 0A00050D /* ChatInputBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputBarView.swift; sourceTree = ""; }; 886480202F133977003A7D61 /* PulseTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PulseTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -220,9 +212,8 @@ 0A0000A2 /* ImageMessageBubble.swift */, 0A0000AE /* EmojiPickerView.swift */, 0A0000B0 /* ReactionDisplayView.swift */, - 0A00041B /* ZapButton.swift */, - 0A00041D /* ZapAmountSheet.swift */, - 0A00041F /* ZapDisplayView.swift */, + 0A00050B /* ChatHeaderView.swift */, + 0A00050D /* ChatInputBarView.swift */, ); path = Components; sourceTree = ""; @@ -233,6 +224,8 @@ 0A000204 /* Typography.swift */, 0A00009F /* ImageUtility.swift */, 0A0000A5 /* AvatarManager.swift */, + 0A000503 /* DebugLogger.swift */, + 0A000505 /* ClipboardManager.swift */, ); path = Utilities; sourceTree = ""; @@ -307,7 +300,6 @@ 0A000020 /* Message.swift */, 0A000022 /* PulseIdentity.swift */, 0A000411 /* NostrIdentity.swift */, - 0A000415 /* Zap.swift */, 0A000026 /* PersistedMessage.swift */, 0A000027 /* PersistedConversation.swift */, 0A0000A6 /* Group.swift */, @@ -324,7 +316,6 @@ 0A000021 /* KeychainManager.swift */, 0A000023 /* IdentityManager.swift */, 0A000413 /* NostrIdentityManager.swift */, - 0A000419 /* ZapManager.swift */, 0A000025 /* ChatManager.swift */, 0A000028 /* PersistenceManager.swift */, 0A000029 /* PowerManager.swift */, @@ -335,6 +326,8 @@ 0A000067 /* VoiceNoteManager.swift */, 0A000097 /* ErrorManager.swift */, 0A000308 /* HapticManager.swift */, + 0A000507 /* PeerConnectionManager.swift */, + 0A000509 /* SentMessageCache.swift */, ); path = Managers; sourceTree = ""; @@ -346,19 +339,14 @@ 0A000052 /* MessageDeduplicationService.swift */, 0A000053 /* MessageRouter.swift */, 0A000054 /* NostrTransport.swift */, - 0A000422 /* Bolt11Parser.swift */, - 0A000423 /* Bolt11Validator.swift */, 0A000426 /* NostrNormalization.swift */, 0A000427 /* EventNormalization.swift */, - 0A00042A /* AmountGuard.swift */, - 0A00042B /* ZapSecurityGuard.swift */, 0A00042D /* NostrEventValidator.swift */, - 0A00042F /* WalletURISanitizer.swift */, 0A000431 /* RateLimiter.swift */, - 0A000417 /* LNURLService.swift */, 0A000055 /* GeohashService.swift */, 0A000056 /* MeshTopologyTracker.swift */, 0A000057 /* UnifiedTransportManager.swift */, + 0A000501 /* SecureNetworkSession.swift */, ); path = Networking; sourceTree = ""; @@ -506,8 +494,6 @@ 0A000410 /* NostrIdentity.swift in Sources */, 0A00000D /* IdentityManager.swift in Sources */, 0A000412 /* NostrIdentityManager.swift in Sources */, - 0A000414 /* Zap.swift in Sources */, - 0A000418 /* ZapManager.swift in Sources */, 0A00000F /* ChatManager.swift in Sources */, 0A000031 /* PersistedMessage.swift in Sources */, 0A000032 /* PersistedConversation.swift in Sources */, @@ -519,17 +505,11 @@ 0A000042 /* MessageDeduplicationService.swift in Sources */, 0A000043 /* MessageRouter.swift in Sources */, 0A000044 /* NostrTransport.swift in Sources */, - 0A000420 /* Bolt11Parser.swift in Sources */, - 0A000421 /* Bolt11Validator.swift in Sources */, 0A000424 /* NostrNormalization.swift in Sources */, 0A000425 /* EventNormalization.swift in Sources */, - 0A000428 /* AmountGuard.swift in Sources */, - 0A000429 /* ZapSecurityGuard.swift in Sources */, 0A00042C /* NostrEventValidator.swift in Sources */, - 0A00042E /* WalletURISanitizer.swift in Sources */, 0A000430 /* RateLimiter.swift in Sources */, 0A000432 /* PrivacyExtensions.swift in Sources */, - 0A000416 /* LNURLService.swift in Sources */, 0A000045 /* GeohashService.swift in Sources */, 0A000046 /* MeshTopologyTracker.swift in Sources */, 0A000047 /* UnifiedTransportManager.swift in Sources */, @@ -554,11 +534,15 @@ 0A000307 /* CreateChannelSheet.swift in Sources */, 0A0000AD /* EmojiPickerView.swift in Sources */, 0A0000AF /* ReactionDisplayView.swift in Sources */, - 0A00041A /* ZapButton.swift in Sources */, - 0A00041C /* ZapAmountSheet.swift in Sources */, - 0A00041E /* ZapDisplayView.swift in Sources */, 0A0000B2 /* GroupListView.swift in Sources */, 0A0000B4 /* GroupChatView.swift in Sources */, + 0A000500 /* SecureNetworkSession.swift in Sources */, + 0A000502 /* DebugLogger.swift in Sources */, + 0A000504 /* ClipboardManager.swift in Sources */, + 0A000506 /* PeerConnectionManager.swift in Sources */, + 0A000508 /* SentMessageCache.swift in Sources */, + 0A00050A /* ChatHeaderView.swift in Sources */, + 0A00050C /* ChatInputBarView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Pulse/Pulse/Managers/MeshManager.swift b/Pulse/Pulse/Managers/MeshManager.swift index 65641dc..7476fda 100644 --- a/Pulse/Pulse/Managers/MeshManager.swift +++ b/Pulse/Pulse/Managers/MeshManager.swift @@ -521,47 +521,43 @@ class MeshManager: NSObject, ObservableObject { nearbyPeers = [ PulsePeer( - id: "1", - handle: "@jesse_codes", - status: .active, - techStack: ["Swift", "Rust"], - distance: 8, - publicKey: key1, + id: "1", + handle: "@jesse_codes", + status: .active, + techStack: ["Swift", "Rust"], + distance: 8, + publicKey: key1, signingPublicKey: sign1, - lightningAddress: "jesse@getalby.com", nostrPubkey: "854c7c6b8ac71f0d545a7d6c3bbfd5f2d6476df7f0a3a735d60d2e0b0a2d3c4e" ), PulsePeer( - id: "2", - handle: "@swift_sarah", - status: .active, - techStack: ["Swift", "iOS"], - distance: 15, - publicKey: key2, + id: "2", + handle: "@swift_sarah", + status: .active, + techStack: ["Swift", "iOS"], + distance: 15, + publicKey: key2, signingPublicKey: sign2, - lightningAddress: "sarah@walletofsatoshi.com", nostrPubkey: "f5a8c7e9b3d1f7e6c2a8d4b6e9f1c3a5d7e8b2c4f6a9d1e3b5c7f8a2d4e6b9" ), PulsePeer( - id: "3", - handle: "@rust_dev", - status: .flowState, - techStack: ["Rust", "WebAssembly"], - distance: 45, - publicKey: key3, + id: "3", + handle: "@rust_dev", + status: .flowState, + techStack: ["Rust", "WebAssembly"], + distance: 45, + publicKey: key3, signingPublicKey: sign3, - lightningAddress: "rustdev@bitrefill.com", nostrPubkey: "c7e9b3d1f7e6c2a8d4b6e9f1c3a5d7e8b2c4f6a9d1e3b5c7f8a2d4e6b9f5a8" ), PulsePeer( - id: "4", - handle: "@pythonista", - status: .idle, - techStack: ["Python", "ML"], - distance: 80, - publicKey: key4, + id: "4", + handle: "@pythonista", + status: .idle, + techStack: ["Python", "ML"], + distance: 80, + publicKey: key4, signingPublicKey: sign4, - lightningAddress: "python@zebedee.io", nostrPubkey: "b3d1f7e6c2a8d4b6e9f1c3a5d7e8b2c4f6a9d1e3b5c7f8a2d4e6b9f5a8c7e9" ) ] diff --git a/Pulse/Pulse/Managers/PeerConnectionManager.swift b/Pulse/Pulse/Managers/PeerConnectionManager.swift index 50eb78a..c51c980 100644 --- a/Pulse/Pulse/Managers/PeerConnectionManager.swift +++ b/Pulse/Pulse/Managers/PeerConnectionManager.swift @@ -64,12 +64,13 @@ class PeerConnectionManager: NSObject, ObservableObject, MCSessionDelegate { peer peerID: MCPeerID, didChange state: MCSessionState ) { + let displayName = peerID.displayName Task { @MainActor in switch state { case .connected: - self.connectedPeerIds.insert(peerID.displayName) + self.connectedPeerIds.insert(displayName) case .notConnected: - self.connectedPeerIds.remove(peerID.displayName) + self.connectedPeerIds.remove(displayName) case .connecting: break @unknown default: @@ -77,4 +78,32 @@ class PeerConnectionManager: NSObject, ObservableObject, MCSessionDelegate { } } } + + nonisolated func session( + _ session: MCSession, + didReceive data: Data, + fromPeer peerID: MCPeerID + ) {} + + nonisolated func session( + _ session: MCSession, + didReceive stream: InputStream, + withName streamName: String, + fromPeer peerID: MCPeerID + ) {} + + nonisolated func session( + _ session: MCSession, + didStartReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + with progress: Progress + ) {} + + nonisolated func session( + _ session: MCSession, + didFinishReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + at localURL: URL?, + withError error: (any Error)? + ) {} } diff --git a/Pulse/Pulse/Managers/ZapManager.swift b/Pulse/Pulse/Managers/ZapManager.swift deleted file mode 100644 index 2cf7c97..0000000 --- a/Pulse/Pulse/Managers/ZapManager.swift +++ /dev/null @@ -1,320 +0,0 @@ -// -// ZapManager.swift -// Pulse -// -// Orchestrates NIP-57 zap flow: request creation, invoice fetching, -// wallet integration, and receipt tracking. -// - -import Foundation -import Combine - -/// Manages Lightning zaps for Pulse messages -@MainActor -final class ZapManager: ObservableObject { - static let shared = ZapManager() - - // MARK: - Published State - - @Published private(set) var pendingZaps: [String: PendingZap] = [:] - @Published private(set) var receivedZaps: [String: [ZapReceipt]] = [:] // messageId -> zaps - @Published private(set) var isProcessing = false - @Published private(set) var lastError: String? - - // MARK: - Services - - private let lnurlService = LNURLService.shared - private let nostrTransport = NostrTransport.shared - private let nostrIdentityManager = NostrIdentityManager.shared - - // User preferences - @Published var preferredWallet: LightningWallet = .automatic - @Published var defaultZapAmount: Int = 1000 // sats - - private var cancellables = Set() - - private init() { - setupZapReceiptListener() - loadPreferences() - } - - enum ZapManagerError: Error, LocalizedError { - case invoiceMissingAmount - case invoiceAmountMismatch(expected: Int, actual: Int) - case receiptSignatureInvalid - case receiptPubkeyMismatch - - var errorDescription: String? { - switch self { - case .invoiceMissingAmount: - return "Invoice does not include an amount." - case .invoiceAmountMismatch(let expected, let actual): - return "Invoice amount mismatch. Expected \(expected) msats, got \(actual) msats." - case .receiptSignatureInvalid: - return "Zap receipt signature is invalid." - case .receiptPubkeyMismatch: - return "Zap receipt signer does not match the LNURL provider." - } - } - } - - // MARK: - Zap Flow - - /// Initiate a zap on a message - func zapMessage( - messageId: String?, - recipientPubkey: String, - lightningAddress: String, - amount: Int, // sats - comment: String? = nil - ) async throws { - isProcessing = true - lastError = nil - defer { isProcessing = false } - - let zapId = UUID().uuidString - let amountMillisats = amount * 1000 - - // Create pending zap for tracking - var pendingZap = PendingZap( - id: zapId, - zapRequestId: "", - recipientPubkey: recipientPubkey, - providerPubkey: nil, - messageId: messageId, - amount: amountMillisats, - comment: comment, - status: .pending, - bolt11: nil, - errorMessage: nil, - createdAt: Date() - ) - pendingZaps[zapId] = pendingZap - - do { - // Step 1: Resolve Lightning Address - let payResponse = try await lnurlService.resolveLightningAddress(lightningAddress) - - // Verify zap support - guard payResponse.supportsZaps else { - throw LNURLServiceError.zapNotSupported - } - - // Step 2: Create zap request event (kind 9734) - let zapRequestEvent = try await nostrTransport.publishZapRequest( - recipientPubkey: recipientPubkey, - lightningAddress: lightningAddress, - amount: amountMillisats, - messageEventId: messageId, - comment: comment - ) - - // Update pending zap with request ID - pendingZap.zapRequestId = zapRequestEvent.id - pendingZap.providerPubkey = payResponse.nostrPubkey - pendingZaps[zapId] = pendingZap - - // Step 3: Request invoice with zap request - let invoiceResponse = try await lnurlService.requestInvoice( - payResponse: payResponse, - amount: amountMillisats, - zapRequest: zapRequestEvent, - comment: comment - ) - - let parsedInvoice = try Bolt11Validator.validate(invoiceResponse.pr) - guard let invoiceAmount = parsedInvoice.amountMillisats else { - throw ZapManagerError.invoiceMissingAmount - } - guard invoiceAmount == amountMillisats else { - throw ZapManagerError.invoiceAmountMismatch( - expected: amountMillisats, - actual: invoiceAmount - ) - } - - // Update status - pendingZap.bolt11 = invoiceResponse.pr - pendingZap.status = .invoiceReady - pendingZaps[zapId] = pendingZap - - // Step 3b: Verify invoice matches zap request + UI intent - _ = try ZapSecurityGuard.validate( - invoice: invoiceResponse.pr, - zapRequest: zapRequestEvent, - expectedAmountMsat: amountMillisats - ) - - // Step 4: Open wallet for payment - pendingZap.status = .paying - pendingZaps[zapId] = pendingZap - - let walletOpened = lnurlService.openWallet( - invoice: invoiceResponse.pr, - preferredWallet: preferredWallet - ) - - if !walletOpened { - throw LNURLServiceError.noWalletInstalled - } - - // Mark as paid (user will complete in wallet) - pendingZap.status = .paid - pendingZaps[zapId] = pendingZap - - // Haptic feedback - HapticManager.shared.impact(.medium) - - } catch { - pendingZap.status = .failed - pendingZap.errorMessage = error.localizedDescription - pendingZaps[zapId] = pendingZap - lastError = error.localizedDescription - throw error - } - } - - /// Quick zap with default amount - func quickZap( - messageId: String?, - recipientPubkey: String, - lightningAddress: String - ) async throws { - try await zapMessage( - messageId: messageId, - recipientPubkey: recipientPubkey, - lightningAddress: lightningAddress, - amount: defaultZapAmount, - comment: nil - ) - } - - // MARK: - Zap Receipt Handling - - private func setupZapReceiptListener() { - // Listen for zap receipts from Nostr transport - nostrTransport.onZapReceived = { [weak self] event in - Task { @MainActor in - self?.handleZapReceipt(event) - } - } - - // Subscribe to zap receipts for our pubkey - if let pubkey = nostrIdentityManager.publicKeyHex { - nostrTransport.subscribeToZapReceipts(for: pubkey) - } - } - - private func handleZapReceipt(_ event: NostrEvent) { - // Validate zap receipt using dedicated validator (includes signature check) - do { - try NostrEventValidator.validateZapReceipt(event) - } catch { - print("Ignoring invalid zap receipt: \(error.localizedDescription)") - return - } - - guard let receipt = ZapReceipt.from(event: event) else { - return - } - - // Update pending zap if we have one matching - for (zapId, var pendingZap) in pendingZaps { - if pendingZap.zapRequestId == receipt.zapRequestId { - // Verify the receipt is from the expected provider - if let providerPubkey = pendingZap.providerPubkey, - providerPubkey.lowercased() != event.pubkey.lowercased() { - return - } - pendingZap.status = .confirmed - pendingZaps[zapId] = pendingZap - - // Play success sound - SoundManager.shared.playZapReceived() - HapticManager.shared.notify(.success) - break - } - } - - // Store receipt by message ID - if let messageId = receipt.messageEventId { - var zapsForMessage = receivedZaps[messageId] ?? [] - // Avoid duplicates - if !zapsForMessage.contains(where: { $0.id == receipt.id }) { - zapsForMessage.append(receipt) - receivedZaps[messageId] = zapsForMessage - } - } - } - - // MARK: - Query Methods - - /// Get total zap amount for a message (in sats) - func totalZapsForMessage(_ messageId: String) -> Int { - let receipts = receivedZaps[messageId] ?? [] - return receipts.reduce(0) { $0 + $1.sats } - } - - /// Get zap count for a message - func zapCountForMessage(_ messageId: String) -> Int { - return receivedZaps[messageId]?.count ?? 0 - } - - /// Get zaps for a specific message - func zapsForMessage(_ messageId: String) -> [ZapReceipt] { - return receivedZaps[messageId] ?? [] - } - - /// Get pending zap by ID - func pendingZap(_ zapId: String) -> PendingZap? { - return pendingZaps[zapId] - } - - /// Clean up expired pending zaps - func cleanupExpiredZaps() { - let expireThreshold = Date().addingTimeInterval(-3600) // 1 hour - - pendingZaps = pendingZaps.filter { _, zap in - zap.createdAt > expireThreshold || zap.status == .confirmed - } - } - - // MARK: - Preferences - - private func loadPreferences() { - if let walletString = UserDefaults.standard.string(forKey: "preferredLightningWallet"), - let wallet = LightningWallet(rawValue: walletString) { - preferredWallet = wallet - } - - let savedAmount = UserDefaults.standard.integer(forKey: "defaultZapAmount") - if savedAmount > 0 { - defaultZapAmount = savedAmount - } - } - - func savePreferences() { - UserDefaults.standard.set(preferredWallet.rawValue, forKey: "preferredLightningWallet") - UserDefaults.standard.set(defaultZapAmount, forKey: "defaultZapAmount") - } - - func setPreferredWallet(_ wallet: LightningWallet) { - preferredWallet = wallet - savePreferences() - } - - func setDefaultZapAmount(_ amount: Int) { - defaultZapAmount = amount - savePreferences() - } -} - -// MARK: - SoundManager Extension - -extension SoundManager { - func playZapReceived() { - // Use existing sound infrastructure or add a zap-specific sound - // For now, use the existing message received sound - messageReceived() - } -} diff --git a/Pulse/Pulse/Models/PulsePeer.swift b/Pulse/Pulse/Models/PulsePeer.swift index 320aec2..45628be 100644 --- a/Pulse/Pulse/Models/PulsePeer.swift +++ b/Pulse/Pulse/Models/PulsePeer.swift @@ -18,18 +18,11 @@ struct PulsePeer: Identifiable, Codable { var signingPublicKey: Data? // For message authenticity var lastSeen: Date = Date() - // NIP-57 Lightning/Zap support - var lightningAddress: String? // lud16 from Nostr kind 0 metadata (e.g., user@getalby.com) var nostrPubkey: String? // 32-byte hex pubkey for Nostr protocol var isActive: Bool { status == .active } - - /// Whether this peer can receive zaps - var canReceiveZaps: Bool { - lightningAddress != nil && !lightningAddress!.isEmpty - } } enum PeerStatus: Int, Codable { diff --git a/Pulse/Pulse/Models/Zap.swift b/Pulse/Pulse/Models/Zap.swift deleted file mode 100644 index b3de68f..0000000 --- a/Pulse/Pulse/Models/Zap.swift +++ /dev/null @@ -1,294 +0,0 @@ -// -// Zap.swift -// Pulse -// -// NIP-57 Lightning Zaps data models. -// Zaps are Bitcoin micropayments sent via Lightning Network. -// - -import Foundation - -// MARK: - Zap Request (Kind 9734) - -/// A zap request created by the sender to initiate a zap -struct ZapRequest: Codable, Identifiable { - let id: String // Event ID - let recipientPubkey: String // Who receives the sats - let messageEventId: String? // Optional: the message being zapped - let amount: Int // Amount in millisats - let relays: [String] // Relays to publish receipt to - let comment: String? // Optional zap comment - let lnurl: String // Bech32-encoded LNURL - let createdAt: Date - - /// Serialize to JSON for embedding in LNURL callback - func toJSON() -> String? { - guard let data = try? JSONEncoder().encode(self), - let json = String(data: data, encoding: .utf8) else { - return nil - } - return json - } -} - -// MARK: - Zap Receipt (Kind 9735) - -/// A zap receipt created by the LNURL server after payment -struct ZapReceipt: Codable, Identifiable { - let id: String // Event ID (from kind 9735) - let senderPubkey: String // Who sent the zap - let recipientPubkey: String // Who received the sats - let amount: Int // Amount in millisats - let bolt11: String // The paid BOLT11 invoice - let preimage: String? // Payment preimage (proof of payment) - let zapRequestId: String // ID of the original zap request - let messageEventId: String? // The message that was zapped - let comment: String? // Zap comment from request - let createdAt: Date - - /// Amount in satoshis - var sats: Int { - amount / 1000 - } - - /// Parse a zap receipt from a Nostr event - static func from(event: NostrEvent) -> ZapReceipt? { - // Extract tags - var bolt11: String? - var preimage: String? - var zapRequestId: String? - var recipientPubkey: String? - var messageEventId: String? - var amount: Int = 0 - var comment: String? - - for tag in event.tags { - guard tag.count >= 2 else { continue } - - switch tag[0] { - case "bolt11": - bolt11 = tag[1] - case "preimage": - preimage = tag[1] - case "description": - // The description tag contains the serialized zap request - if let requestData = tag[1].data(using: .utf8), - let request = try? JSONDecoder().decode(ZapRequest.self, from: requestData) { - zapRequestId = request.id - amount = request.amount - comment = request.comment - messageEventId = request.messageEventId - } - case "p": - recipientPubkey = tag[1] - case "e": - messageEventId = messageEventId ?? tag[1] - case "amount": - if let parsedAmount = Int(tag[1]) { - amount = parsedAmount - } - default: - break - } - } - - guard let bolt11 = bolt11, - let recipientPubkey = recipientPubkey else { - return nil - } - - return ZapReceipt( - id: event.id, - senderPubkey: event.pubkey, - recipientPubkey: recipientPubkey, - amount: amount, - bolt11: bolt11, - preimage: preimage, - zapRequestId: zapRequestId ?? "", - messageEventId: messageEventId, - comment: comment, - createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)) - ) - } -} - -// MARK: - Zap Status - -/// Status of a zap in progress -enum ZapStatus: String, Codable { - case pending // Zap request created, waiting for invoice - case invoiceReady // BOLT11 invoice received, ready for payment - case paying // User opened wallet to pay - case paid // Payment sent, waiting for receipt - case confirmed // Kind 9735 receipt received - case failed // Payment or receipt failed - case expired // Invoice expired before payment -} - -// MARK: - Pending Zap - -/// Tracks a zap that's in progress -struct PendingZap: Identifiable, Codable { - let id: String // UUID for tracking - var zapRequestId: String // The kind 9734 event ID - let recipientPubkey: String - let providerPubkey: String? // LNURL provider pubkey for receipt verification - let messageId: String? - let amount: Int // millisats - let comment: String? - var status: ZapStatus - var bolt11: String? // Invoice once received - var errorMessage: String? - let createdAt: Date - - var sats: Int { - amount / 1000 - } -} - -// MARK: - LNURL Response Types - -/// Response from resolving a Lightning Address -struct LNURLPayResponse: Codable { - let callback: String // URL to request invoice - let maxSendable: Int // Max amount in millisats - let minSendable: Int // Min amount in millisats - let metadata: String // JSON-encoded metadata - let tag: String // Should be "payRequest" - let allowsNostr: Bool? // NIP-57: true if accepts zap requests - let nostrPubkey: String? // NIP-57: pubkey that will sign receipts - - /// Whether this endpoint supports NIP-57 zaps - var supportsZaps: Bool { - allowsNostr == true && nostrPubkey != nil - } - - /// Max amount in sats - var maxSats: Int { - maxSendable / 1000 - } - - /// Min amount in sats - var minSats: Int { - minSendable / 1000 - } -} - -/// Response from requesting an invoice -struct LNURLInvoiceResponse: Codable { - let pr: String // BOLT11 invoice (payment request) - let routes: [[String]]? // Optional routing hints - let successAction: SuccessAction? - - struct SuccessAction: Codable { - let tag: String // "message", "url", or "aes" - let message: String? - let url: String? - let description: String? - } -} - -/// Error response from LNURL -struct LNURLError: Codable, Error { - let status: String // "ERROR" - let reason: String // Human-readable error - - var localizedDescription: String { - reason - } -} - -// MARK: - Lightning Wallet - -/// Supported Lightning wallet apps -enum LightningWallet: String, CaseIterable, Codable { - case automatic = "automatic" - case zeus = "zeus" - case blueWallet = "bluewallet" - case phoenix = "phoenix" - case muun = "muun" - case breez = "breez" - case wallet = "wallet" // Generic "lightning:" scheme - - var displayName: String { - switch self { - case .automatic: return "Automatic" - case .zeus: return "Zeus" - case .blueWallet: return "BlueWallet" - case .phoenix: return "Phoenix" - case .muun: return "Muun" - case .breez: return "Breez" - case .wallet: return "Default Wallet" - } - } - - var urlScheme: String { - switch self { - case .automatic, .wallet: return "lightning:" - case .zeus: return "zeusln:lightning:" - case .blueWallet: return "bluewallet:lightning:" - case .phoenix: return "phoenix://" - case .muun: return "muun:" - case .breez: return "breez:" - } - } - - /// Create a payment URL for this wallet - func paymentURL(invoice: String) -> URL? { - let urlString: String - switch self { - case .phoenix: - urlString = "\(urlScheme)pay?invoice=\(invoice)" - default: - urlString = "\(urlScheme)\(invoice)" - } - return URL(string: urlString) - } -} - -// MARK: - Zap Amount Presets - -/// Common zap amounts in sats -enum ZapAmount: Int, CaseIterable { - case tiny = 21 - case nice = 69 - case hundred = 100 - case blaze = 420 - case oneK = 1000 - case fiveK = 5000 - case tenK = 10000 - case twentyOneK = 21000 - - var displayName: String { - switch self { - case .tiny: return "21" - case .nice: return "69" - case .hundred: return "100" - case .blaze: return "420" - case .oneK: return "1K" - case .fiveK: return "5K" - case .tenK: return "10K" - case .twentyOneK: return "21K" - } - } - - /// Amount in millisats for LNURL - var millisats: Int { - rawValue * 1000 - } -} - -// MARK: - Formatting Helpers - -extension Int { - /// Format satoshi amount for display - var formattedSats: String { - if self >= 1_000_000 { - return String(format: "%.1fM", Double(self) / 1_000_000) - } else if self >= 1000 { - return String(format: "%.1fK", Double(self) / 1000) - } else { - return "\(self)" - } - } -} diff --git a/Pulse/Pulse/Networking/AmountGuard.swift b/Pulse/Pulse/Networking/AmountGuard.swift deleted file mode 100644 index 0d4f019..0000000 --- a/Pulse/Pulse/Networking/AmountGuard.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// AmountGuard.swift -// Pulse -// -// Consistency checks for zap amounts across UI, request, and invoice. -// - -import Foundation - -enum AmountGuardError: Error, LocalizedError { - case missingInvoiceAmount - case uiMismatch - case requestMismatch - - var errorDescription: String? { - switch self { - case .missingInvoiceAmount: - return "Invoice is missing an explicit amount" - case .uiMismatch: - return "UI amount does not match zap request amount" - case .requestMismatch: - return "Invoice amount does not match zap request amount" - } - } -} - -struct AmountGuard { - static func validate( - uiAmountMsat: Int, - zapRequestAmountMsat: Int, - invoiceAmountMsat: Int? - ) throws { - guard let invoiceAmountMsat else { - throw AmountGuardError.missingInvoiceAmount - } - - guard uiAmountMsat == zapRequestAmountMsat else { - throw AmountGuardError.uiMismatch - } - - guard invoiceAmountMsat == zapRequestAmountMsat else { - throw AmountGuardError.requestMismatch - } - } -} diff --git a/Pulse/Pulse/Networking/Bolt11Parser.swift b/Pulse/Pulse/Networking/Bolt11Parser.swift deleted file mode 100644 index 695d941..0000000 --- a/Pulse/Pulse/Networking/Bolt11Parser.swift +++ /dev/null @@ -1,348 +0,0 @@ -// -// Bolt11Parser.swift -// Pulse -// -// BOLT11 invoice parsing for Lightning payments. -// - -import Foundation - -enum Bolt11Network: String { - case bitcoin = "bc" - case testnet = "tb" - case regtest = "bcrt" - case signet = "sb" -} - -enum Bolt11Tag: Equatable { - case paymentHash(Data) - case description(String) - case descriptionHash(Data) - case expiry(UInt64) - case payeePublicKey(Data) - case minFinalCLTVExpiry(UInt64) - case fallbackAddress(Data) - case routingInfo(Data) - case features(Data) - case unknown(Character, Data) -} - -struct Bolt11Invoice: Equatable { - let raw: String - let hrp: String - let network: Bolt11Network - let amountMillisats: Int? - let timestamp: Date - let tags: [Bolt11Tag] - let signature: Data - - var paymentHash: Data? { - tags.first { tag in - if case .paymentHash = tag { return true } - return false - }.flatMap { tag in - if case let .paymentHash(value) = tag { return value } - return nil - } - } - - var description: String? { - tags.first { tag in - if case .description = tag { return true } - return false - }.flatMap { tag in - if case let .description(value) = tag { return value } - return nil - } - } - - var descriptionHash: Data? { - tags.first { tag in - if case .descriptionHash = tag { return true } - return false - }.flatMap { tag in - if case let .descriptionHash(value) = tag { return value } - return nil - } - } -} - -enum Bolt11ParserError: Error, LocalizedError { - case invalidBech32 - case invalidHrp - case unsupportedNetwork - case invalidDataLength - case invalidSignatureLength - case invalidTimestamp - case invalidTagData - case invalidAmount - - var errorDescription: String? { - switch self { - case .invalidBech32: - return "Invalid BOLT11 bech32 encoding" - case .invalidHrp: - return "Invalid BOLT11 invoice prefix" - case .unsupportedNetwork: - return "Unsupported BOLT11 network" - case .invalidDataLength: - return "Invalid BOLT11 data length" - case .invalidSignatureLength: - return "Invalid BOLT11 signature length" - case .invalidTimestamp: - return "Invalid BOLT11 timestamp" - case .invalidTagData: - return "Invalid BOLT11 tag data" - case .invalidAmount: - return "Invalid BOLT11 amount" - } - } -} - -struct Bolt11Parser { - func parse(_ invoice: String) throws -> Bolt11Invoice { - let normalized = invoice.lowercased().replacingOccurrences(of: "lightning:", with: "") - guard let (hrp, values) = Bech32.decodeToValues(normalized) else { - throw Bolt11ParserError.invalidBech32 - } - - guard hrp.hasPrefix("ln") else { - throw Bolt11ParserError.invalidHrp - } - - let (network, amountMillisats) = try parseHrp(hrp) - guard values.count >= 7 + 104 else { - throw Bolt11ParserError.invalidDataLength - } - - let signatureValues = Array(values.suffix(104)) - let dataValues = Array(values.dropLast(104)) - - guard let signature = convertFrom5Bit(signatureValues, allowPadding: false), - signature.count == 65 else { - throw Bolt11ParserError.invalidSignatureLength - } - - var reader = BitReader(values: dataValues) - guard let timestampSeconds = reader.readUInt(bits: 35) else { - throw Bolt11ParserError.invalidTimestamp - } - - let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampSeconds)) - let tags = try parseTags(reader: &reader) - - return Bolt11Invoice( - raw: normalized, - hrp: hrp, - network: network, - amountMillisats: amountMillisats, - timestamp: timestamp, - tags: tags, - signature: signature - ) - } - - private func parseHrp(_ hrp: String) throws -> (Bolt11Network, Int?) { - let prefix = String(hrp.prefix(2)) - guard prefix == "ln" else { - throw Bolt11ParserError.invalidHrp - } - - let remainder = String(hrp.dropFirst(2)) - let networkCandidates: [Bolt11Network] = [.bitcoin, .testnet, .regtest, .signet] - guard let network = networkCandidates.first(where: { remainder.hasPrefix($0.rawValue) }) else { - throw Bolt11ParserError.unsupportedNetwork - } - - let amountPart = String(remainder.dropFirst(network.rawValue.count)) - if amountPart.isEmpty { - return (network, nil) - } - - let (amountString, multiplier) = splitAmount(amountPart) - guard !amountString.isEmpty else { - throw Bolt11ParserError.invalidAmount - } - guard let amountDecimal = Decimal(string: amountString) else { - throw Bolt11ParserError.invalidAmount - } - - let multiplierFactor: Decimal - switch multiplier { - case "m": - multiplierFactor = Decimal(string: "0.001") ?? 0 - case "u": - multiplierFactor = Decimal(string: "0.000001") ?? 0 - case "n": - multiplierFactor = Decimal(string: "0.000000001") ?? 0 - case "p": - multiplierFactor = Decimal(string: "0.000000000001") ?? 0 - case nil: - multiplierFactor = 1 - default: - throw Bolt11ParserError.invalidAmount - } - - let btcAmount = amountDecimal * multiplierFactor - let millisatsDecimal = btcAmount * Decimal(100_000_000_000) - var rounded = Decimal() - NSDecimalRound(&rounded, &millisatsDecimal, 0, .plain) - guard rounded == millisatsDecimal, - rounded >= 0 else { - throw Bolt11ParserError.invalidAmount - } - - let msatsNumber = NSDecimalNumber(decimal: rounded) - if msatsNumber.compare(NSDecimalNumber(value: Int64.max)) == .orderedDescending { - throw Bolt11ParserError.invalidAmount - } - - let millisats = msatsNumber.int64Value - guard millisats <= Int64(Int.max) else { - throw Bolt11ParserError.invalidAmount - } - - return (network, Int(millisats)) - } - - private func splitAmount(_ amountPart: String) -> (String, Character?) { - guard let last = amountPart.last, last.isLetter else { - return (amountPart, nil) - } - return (String(amountPart.dropLast()), last) - } - - private func parseTags(reader: inout BitReader) throws -> [Bolt11Tag] { - var tags: [Bolt11Tag] = [] - while reader.hasBitsAvailable { - guard let typeValue = reader.readUInt(bits: 5), - let lengthValue = reader.readUInt(bits: 10) else { - throw Bolt11ParserError.invalidTagData - } - - guard typeValue < UInt64(Self.bech32Charset.count) else { - throw Bolt11ParserError.invalidTagData - } - let tagType = Self.bech32Charset[Int(typeValue)] - let length = Int(lengthValue) - let tagValues = try reader.readValues(count: length) - let tagData = convertFrom5Bit(tagValues, allowPadding: false) ?? Data() - - switch tagType { - case "p": - guard tagData.count == 32 else { throw Bolt11ParserError.invalidTagData } - tags.append(.paymentHash(tagData)) - case "d": - guard let description = String(data: tagData, encoding: .utf8) else { - throw Bolt11ParserError.invalidTagData - } - tags.append(.description(description)) - case "h": - guard tagData.count == 32 else { throw Bolt11ParserError.invalidTagData } - tags.append(.descriptionHash(tagData)) - case "x": - let expiry = valuesToUInt(tagValues) - tags.append(.expiry(expiry)) - case "n": - guard tagData.count == 33 else { throw Bolt11ParserError.invalidTagData } - tags.append(.payeePublicKey(tagData)) - case "c": - let cltv = valuesToUInt(tagValues) - tags.append(.minFinalCLTVExpiry(cltv)) - case "f": - tags.append(.fallbackAddress(tagData)) - case "r": - tags.append(.routingInfo(tagData)) - case "9": - tags.append(.features(tagData)) - default: - tags.append(.unknown(tagType, tagData)) - } - } - return tags - } - - private func valuesToUInt(_ values: [UInt8]) -> UInt64 { - var result: UInt64 = 0 - for value in values { - result = (result << 5) | UInt64(value) - } - return result - } - - private static let bech32Charset: [Character] = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") - - private func convertFrom5Bit(_ values: [UInt8], allowPadding: Bool) -> Data? { - var result = Data() - var acc: UInt32 = 0 - var bits: UInt32 = 0 - - for value in values { - guard value < 32 else { return nil } - acc = (acc << 5) | UInt32(value) - bits += 5 - while bits >= 8 { - bits -= 8 - result.append(UInt8((acc >> bits) & 0xff)) - } - } - - if !allowPadding && bits > 0 { - let paddingMask = UInt32((1 << bits) - 1) - if (acc & paddingMask) != 0 { - return nil - } - } - - return result - } -} - -private struct BitReader { - private let values: [UInt8] - private var bitIndex: Int = 0 - - init(values: [UInt8]) { - self.values = values - } - - var hasBitsAvailable: Bool { - bitIndex < values.count * 5 - } - - mutating func readUInt(bits: Int) -> UInt64? { - guard bits > 0, bits <= 64 else { return nil } - guard bitIndex + bits <= values.count * 5 else { return nil } - - var remaining = bits - var result: UInt64 = 0 - while remaining > 0 { - let valueIndex = bitIndex / 5 - let offset = bitIndex % 5 - let available = 5 - offset - let take = min(available, remaining) - let value = values[valueIndex] - let shift = available - take - let mask = UInt8((1 << take) - 1) << shift - let extracted = UInt64((value & mask) >> shift) - result = (result << UInt64(take)) | extracted - bitIndex += take - remaining -= take - } - - return result - } - - mutating func readValues(count: Int) throws -> [UInt8] { - guard count >= 0 else { throw Bolt11ParserError.invalidTagData } - var result: [UInt8] = [] - result.reserveCapacity(count) - for _ in 0.. = [.bitcoin, .testnet] - - static func validate(_ invoice: String) throws -> Bolt11Invoice { - guard invoice.count <= maxInvoiceLength else { - throw Bolt11ValidationError.invoiceTooLong - } - - let parsed = try Bolt11Parser().parse(invoice) - - guard supportedNetworks.contains(parsed.network) else { - throw Bolt11ValidationError.unsupportedNetwork - } - - guard let amount = parsed.amountMillisats, amount > 0 else { - throw Bolt11ValidationError.missingAmount - } - - guard parsed.paymentHash != nil else { - throw Bolt11ValidationError.missingPaymentHash - } - - guard parsed.description != nil || parsed.descriptionHash != nil else { - throw Bolt11ValidationError.missingDescription - } - - if let description = parsed.description, !description.isEmpty { - guard isSafeDescription(description) else { - throw Bolt11ValidationError.unsafeDescription - } - } - - return parsed - } - - static func isSafeDescription(_ description: String) -> Bool { - if description.rangeOfCharacter(from: unsafeControlCharacters) != nil { - return false - } - - let lowered = description.lowercased() - let blockedSubstrings = [ - " LNURLPayResponse { - isProcessing = true - lastError = nil - defer { isProcessing = false } - - guard requestLimiter.shouldAllow() else { - throw LNURLServiceError.rateLimited - } - - // Parse Lightning Address - let parts = address.split(separator: "@") - guard parts.count == 2 else { - throw LNURLServiceError.invalidLightningAddress - } - - let username = String(parts[0]) - let domain = String(parts[1]) - - // Construct well-known URL - guard let url = URL(string: "https://\(domain)/.well-known/lnurlp/\(username)") else { - throw LNURLServiceError.invalidLightningAddress - } - - // Fetch LNURL metadata - let (data, response) = try await fetchData(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw LNURLServiceError.serverError - } - - // Check for error response - if let errorResponse = try? JSONDecoder().decode(LNURLError.self, from: data), - errorResponse.status == "ERROR" { - lastError = errorResponse.reason - throw errorResponse - } - - let payResponse = try JSONDecoder().decode(LNURLPayResponse.self, from: data) - - // Validate it's a pay request - guard payResponse.tag == "payRequest" else { - throw LNURLServiceError.invalidResponse - } - - return payResponse - } - - // MARK: - Invoice Request - - /// Request an invoice with embedded zap request - func requestInvoice( - payResponse: LNURLPayResponse, - amount: Int, // millisats - zapRequest: NostrEvent?, - comment: String? - ) async throws -> LNURLInvoiceResponse { - isProcessing = true - lastError = nil - defer { isProcessing = false } - - guard requestLimiter.shouldAllow() else { - throw LNURLServiceError.rateLimited - } - - // Validate amount - guard amount >= payResponse.minSendable, - amount <= payResponse.maxSendable else { - throw LNURLServiceError.amountOutOfRange( - min: payResponse.minSats, - max: payResponse.maxSats - ) - } - - // Build callback URL - guard var urlComponents = URLComponents(string: payResponse.callback) else { - throw LNURLServiceError.invalidCallback - } - - var queryItems = urlComponents.queryItems ?? [] - queryItems.append(URLQueryItem(name: "amount", value: String(amount))) - - // Add comment if supported and provided - if let comment = comment, !comment.isEmpty { - queryItems.append(URLQueryItem(name: "comment", value: comment)) - } - - // Add zap request for NIP-57 - if payResponse.supportsZaps, let zapRequest = zapRequest { - if let zapData = try? JSONEncoder().encode(zapRequest), - let zapJson = String(data: zapData, encoding: .utf8) { - queryItems.append(URLQueryItem(name: "nostr", value: zapJson)) - } - } - - urlComponents.queryItems = queryItems - - guard let callbackURL = urlComponents.url else { - throw LNURLServiceError.invalidCallback - } - - // Request invoice - let (data, response) = try await fetchData(from: callbackURL) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw LNURLServiceError.serverError - } - - // Check for error response - if let errorResponse = try? JSONDecoder().decode(LNURLError.self, from: data), - errorResponse.status == "ERROR" { - lastError = errorResponse.reason - throw errorResponse - } - - return try JSONDecoder().decode(LNURLInvoiceResponse.self, from: data) - } - - // MARK: - Wallet Integration - - /// Open Lightning wallet with invoice - @discardableResult - func openWallet(invoice: String, preferredWallet: LightningWallet = .automatic) -> Bool { - // Try preferred wallet first - if preferredWallet != .automatic, - let url = WalletURISanitizer.buildPaymentURL(invoice: invoice, wallet: preferredWallet), - UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - return true - } - - // Try each wallet in order - for wallet in LightningWallet.allCases where wallet != .automatic { - if let url = WalletURISanitizer.buildPaymentURL(invoice: invoice, wallet: wallet), - UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - return true - } - } - - // Fall back to generic lightning: scheme - if let url = WalletURISanitizer.buildGenericLightningURL(invoice: invoice) { - UIApplication.shared.open(url) - return true - } - - return false - } - - /// Check if any Lightning wallet is installed - func hasLightningWallet() -> Bool { - for wallet in LightningWallet.allCases { - if let url = WalletURISanitizer.buildPaymentURL(invoice: "lnbc1", wallet: wallet) { - // Use canOpenURL which requires LSApplicationQueriesSchemes in Info.plist - if UIApplication.shared.canOpenURL(url) { - return true - } - } - } - - // Check generic lightning: scheme - if let url = WalletURISanitizer.buildGenericLightningURL(invoice: "lnbc1") { - return UIApplication.shared.canOpenURL(url) - } - - return false - } - - // MARK: - Bech32 LNURL Encoding - - /// Encode a URL as bech32 LNURL - func encodeAsLNURL(_ urlString: String) -> String? { - guard let data = urlString.data(using: .utf8) else { - return nil - } - return Bech32.encode(hrp: "lnurl", data: data) - } - - /// Decode a bech32 LNURL to URL string - func decodeLNURL(_ lnurl: String) -> String? { - guard let (hrp, data) = Bech32.decode(lnurl.lowercased()), - hrp == "lnurl" else { - return nil - } - return String(data: data, encoding: .utf8) - } - - // MARK: - Network Defense Helpers - - private func fetchData(from url: URL) async throws -> (Data, HTTPURLResponse) { - var lastError: Error? - - for attempt in 0...maxRetries { - try Task.checkCancellation() - - do { - let (data, response) = try await session.data(from: url) - guard let httpResponse = response as? HTTPURLResponse else { - throw LNURLServiceError.serverError - } - - if (500...599).contains(httpResponse.statusCode), attempt < maxRetries { - lastError = LNURLServiceError.serverError - } else { - return (data, httpResponse) - } - } catch { - lastError = error - } - - guard attempt < maxRetries else { break } - let delay = backoffDelay(for: attempt) - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - - throw lastError ?? LNURLServiceError.serverError - } - - private func backoffDelay(for attempt: Int) -> TimeInterval { - let exponential = baseRetryDelay * pow(2.0, Double(attempt)) - let jitter = Double.random(in: 0...0.2) - return exponential + jitter - } -} - -// MARK: - Errors - -enum LNURLServiceError: Error, LocalizedError { - case invalidLightningAddress - case invalidResponse - case invalidCallback - case serverError - case rateLimited - case amountOutOfRange(min: Int, max: Int) - case noWalletInstalled - case zapNotSupported - - var errorDescription: String? { - switch self { - case .invalidLightningAddress: - return "Invalid Lightning Address format" - case .invalidResponse: - return "Invalid response from Lightning server" - case .invalidCallback: - return "Invalid callback URL" - case .serverError: - return "Lightning server error" - case .rateLimited: - return "Too many Lightning requests. Please try again shortly." - case .amountOutOfRange(let min, let max): - return "Amount must be between \(min) and \(max) sats" - case .noWalletInstalled: - return "No Lightning wallet installed" - case .zapNotSupported: - return "This recipient doesn't support zaps" - } - } -} diff --git a/Pulse/Pulse/Networking/NostrEventValidator.swift b/Pulse/Pulse/Networking/NostrEventValidator.swift index 0bd102d..8af9abd 100644 --- a/Pulse/Pulse/Networking/NostrEventValidator.swift +++ b/Pulse/Pulse/Networking/NostrEventValidator.swift @@ -2,7 +2,7 @@ // NostrEventValidator.swift // Pulse // -// Validation for Nostr events and zap-specific checks. +// Validation for Nostr event signatures. // import Foundation @@ -13,8 +13,6 @@ enum NostrEventValidationError: Error, LocalizedError { case invalidSignatureFormat case invalidEventId case signatureMismatch - case invalidKind - case missingAmount var errorDescription: String? { switch self { @@ -26,32 +24,11 @@ enum NostrEventValidationError: Error, LocalizedError { return "Invalid Nostr event id" case .signatureMismatch: return "Nostr signature verification failed" - case .invalidKind: - return "Unexpected Nostr event kind" - case .missingAmount: - return "Zap request missing amount" } } } struct NostrEventValidator { - static func validateZapRequest(_ event: NostrEvent) throws { - guard event.kind == NostrEventKind.zapRequest.rawValue else { - throw NostrEventValidationError.invalidKind - } - try validateEventSignature(event) - guard extractZapAmount(event) != nil else { - throw NostrEventValidationError.missingAmount - } - } - - static func validateZapReceipt(_ event: NostrEvent) throws { - guard event.kind == NostrEventKind.zapReceipt.rawValue else { - throw NostrEventValidationError.invalidKind - } - try validateEventSignature(event) - } - static func validateEventSignature(_ event: NostrEvent) throws { guard isValidHex(event.pubkey, length: 64) else { throw NostrEventValidationError.invalidPubkey @@ -90,15 +67,6 @@ struct NostrEventValidator { } } - private static func extractZapAmount(_ event: NostrEvent) -> Int? { - for tag in event.tags where tag.count >= 2 { - if tag[0] == "amount", let amount = Int(tag[1]) { - return amount - } - } - return nil - } - private static func isValidHex(_ value: String, length: Int) -> Bool { guard value.count == length else { return false } return value.allSatisfy { $0.isHexDigit } diff --git a/Pulse/Pulse/Networking/NostrTransport.swift b/Pulse/Pulse/Networking/NostrTransport.swift index 6148341..0d9a525 100644 --- a/Pulse/Pulse/Networking/NostrTransport.swift +++ b/Pulse/Pulse/Networking/NostrTransport.swift @@ -21,8 +21,6 @@ enum NostrEventKind: Int, Codable { case repost = 6 case reaction = 7 case giftWrap = 1059 // NIP-17 gift-wrapped private messages - case zapRequest = 9734 // NIP-57 zap request - case zapReceipt = 9735 // NIP-57 zap receipt case auth = 22242 // NIP-42 auth challenge response case pulseMessage = 30078 // Custom kind for Pulse mesh messages case pulseChannel = 30079 // Custom kind for Pulse location channels @@ -389,7 +387,6 @@ final class NostrTransport: ObservableObject, TransportProtocol { var onPacketReceived: ((RoutablePacket) -> Void)? var onPeerDiscovered: ((DiscoveredPeer) -> Void)? var onPeerLost: ((String) -> Void)? - var onZapReceived: ((NostrEvent) -> Void)? // NIP-57 zap receipt handler // Default Pulse-friendly relays private let defaultRelays = [ @@ -406,7 +403,6 @@ final class NostrTransport: ObservableObject, TransportProtocol { // Active subscriptions private var channelSubscriptions: [String: String] = [:] // geohash -> subscriptionId - private var zapReceiptSubscriptionId: String? private init() {} @@ -510,74 +506,7 @@ final class NostrTransport: ObservableObject, TransportProtocol { } } - // MARK: - NIP-57 Zap Support - - /// Publish a zap request (kind 9734) - func publishZapRequest( - recipientPubkey: String, - lightningAddress: String, - amount: Int, // millisats - messageEventId: String?, - comment: String? - ) async throws -> NostrEvent { - guard let identity = NostrIdentityManager.shared.nostrIdentity else { - throw NostrError.notConfigured - } - - // Build tags per NIP-57 - var tags: [[String]] = [ - ["p", recipientPubkey], - ["amount", String(amount)], - ["relays"] + defaultRelays, - ["lnurl", lightningAddress] // Will be encoded by caller - ] - - // Optional: reference the message being zapped - if let eventId = messageEventId { - tags.append(["e", eventId]) - } - - let event = try NostrEvent.createSigned( - identity: identity, - kind: .zapRequest, - content: comment ?? "", - tags: tags - ) - - // Publish to relays - for relay in relays where relay.isConnected { - relay.publish(event) - } - - return event - } - - /// Subscribe to zap receipts (kind 9735) for a pubkey - func subscribeToZapReceipts(for pubkey: String) { - let subscriptionId = "zap-\(pubkey.prefix(8))" - zapReceiptSubscriptionId = subscriptionId - - var filter = NostrFilter() - filter.kinds = [NostrEventKind.zapReceipt.rawValue] - filter.tagFilters["p"] = [pubkey] - filter.since = Int(Date().addingTimeInterval(-86400).timeIntervalSince1970) // Last 24 hours - - for relay in relays where relay.isConnected { - relay.subscribe(filter: filter, subscriptionId: subscriptionId) - } - } - - /// Unsubscribe from zap receipts - func unsubscribeFromZapReceipts() { - guard let subscriptionId = zapReceiptSubscriptionId else { return } - - for relay in relays where relay.isConnected { - relay.unsubscribe(subscriptionId) - } - zapReceiptSubscriptionId = nil - } - - /// Fetch peer metadata (kind 0) to get lightning address + /// Fetch peer metadata (kind 0) func fetchPeerMetadata(pubkey: String) async throws -> [String: Any]? { // Create a one-time subscription for kind 0 let subscriptionId = "meta-\(pubkey.prefix(8))" @@ -599,10 +528,9 @@ final class NostrTransport: ObservableObject, TransportProtocol { return nil } - /// Publish profile metadata (kind 0) with lightning address + /// Publish profile metadata (kind 0) func publishMetadata( name: String, - lightningAddress: String?, about: String? = nil, picture: String? = nil ) async throws { @@ -611,9 +539,6 @@ final class NostrTransport: ObservableObject, TransportProtocol { } var metadata: [String: Any] = ["name": name] - if let lud16 = lightningAddress { - metadata["lud16"] = lud16 - } if let about = about { metadata["about"] = about } @@ -677,12 +602,6 @@ final class NostrTransport: ObservableObject, TransportProtocol { return } - // Handle zap receipts (kind 9735) - if event.kind == NostrEventKind.zapReceipt.rawValue { - onZapReceived?(event) - return - } - // Handle metadata events (kind 0) if event.kind == NostrEventKind.setMetadata.rawValue { // Metadata events are handled by their specific callbacks diff --git a/Pulse/Pulse/Networking/SecureNetworkSession.swift b/Pulse/Pulse/Networking/SecureNetworkSession.swift index 99e4191..8f7db08 100644 --- a/Pulse/Pulse/Networking/SecureNetworkSession.swift +++ b/Pulse/Pulse/Networking/SecureNetworkSession.swift @@ -205,23 +205,6 @@ enum SecureNetworkSession { ) } - /// Create a secure URLSession for LNURL requests - static func createLNURLSession() -> URLSession { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 60 - - // LNURL servers are distributed, so we allow unpinned but validate certificates - let pinningConfig = CertificatePinningConfig( - pinnedPublicKeyHashes: [:], - allowUnpinnedDomains: true, - minimumTLSVersion: .TLSv12, - requireCertificateTransparency: false - ) - - return createSession(configuration: config, pinningConfig: pinningConfig) - } - /// Create a secure URLSession for WebSocket connections (Nostr relays) static func createWebSocketSession() -> URLSession { let config = URLSessionConfiguration.default diff --git a/Pulse/Pulse/Networking/WalletURISanitizer.swift b/Pulse/Pulse/Networking/WalletURISanitizer.swift deleted file mode 100644 index bee59b6..0000000 --- a/Pulse/Pulse/Networking/WalletURISanitizer.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// WalletURISanitizer.swift -// Pulse -// -// Sanitizes Lightning payment URIs before opening external wallets. -// - -import Foundation - -enum WalletURISanitizer { - private static let maxInvoiceLength = 4096 - private static let maxURLLength = 8192 - private static let allowedSchemes: Set = [ - "lightning", - "zeusln", - "bluewallet", - "phoenix", - "muun", - "breez" - ] - private static let allowedInvoiceCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789") - - static func sanitizeInvoice(_ invoice: String) -> String? { - let trimmed = invoice.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let stripped = trimmed.lowercased().replacingOccurrences(of: "lightning:", with: "") - guard stripped.count >= 10, stripped.count <= maxInvoiceLength else { return nil } - guard stripped.hasPrefix("ln") else { return nil } - guard stripped.unicodeScalars.allSatisfy({ allowedInvoiceCharacters.contains($0) }) else { - return nil - } - return stripped - } - - static func buildPaymentURL(invoice: String, wallet: LightningWallet) -> URL? { - guard let sanitized = sanitizeInvoice(invoice) else { return nil } - - let urlString: String - switch wallet { - case .phoenix: - guard let encoded = sanitized.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - return nil - } - urlString = "phoenix://pay?invoice=\(encoded)" - default: - urlString = "\(wallet.urlScheme)\(sanitized)" - } - - guard urlString.count <= maxURLLength, let url = URL(string: urlString) else { - return nil - } - guard let scheme = url.scheme?.lowercased(), allowedSchemes.contains(scheme) else { - return nil - } - return url - } - - static func buildGenericLightningURL(invoice: String) -> URL? { - guard let sanitized = sanitizeInvoice(invoice) else { return nil } - let urlString = "lightning:\(sanitized)" - guard urlString.count <= maxURLLength else { return nil } - return URL(string: urlString) - } -} diff --git a/Pulse/Pulse/Networking/ZapSecurityGuard.swift b/Pulse/Pulse/Networking/ZapSecurityGuard.swift deleted file mode 100644 index 60b1045..0000000 --- a/Pulse/Pulse/Networking/ZapSecurityGuard.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// ZapSecurityGuard.swift -// Pulse -// -// Defense-in-depth checks for NIP-57 zap invoices. -// - -import Foundation - -enum ZapSecurityGuardError: Error, LocalizedError { - case missingZapAmount - case missingDescriptionHash - case descriptionHashMismatch - case invoiceExpired - - var errorDescription: String? { - switch self { - case .missingZapAmount: - return "Zap request missing amount" - case .missingDescriptionHash: - return "Invoice missing description hash" - case .descriptionHashMismatch: - return "Invoice description hash does not match zap request" - case .invoiceExpired: - return "Invoice is expired" - } - } -} - -struct ZapSecurityGuard { - private static let defaultExpirySeconds: TimeInterval = 3600 - - static func validate( - invoice: String, - zapRequest: NostrEvent, - expectedAmountMsat: Int, - now: Date = Date() - ) throws -> Bolt11Invoice { - let parsed = try Bolt11Parser().parse(invoice) - try validate( - invoice: parsed, - zapRequest: zapRequest, - expectedAmountMsat: expectedAmountMsat, - now: now - ) - return parsed - } - - static func validate( - invoice: Bolt11Invoice, - zapRequest: NostrEvent, - expectedAmountMsat: Int, - now: Date = Date() - ) throws { - try NostrEventValidator.validateZapRequest(zapRequest) - guard let zapAmountMsat = zapRequestAmountMsat(zapRequest) else { - throw ZapSecurityGuardError.missingZapAmount - } - - try AmountGuard.validate( - uiAmountMsat: expectedAmountMsat, - zapRequestAmountMsat: zapAmountMsat, - invoiceAmountMsat: invoice.amountMillisats - ) - - guard let invoiceDescriptionHash = invoice.descriptionHash else { - throw ZapSecurityGuardError.missingDescriptionHash - } - - let requestHashHex = try zapRequest.descriptionHash() - guard let requestHashData = Data(hex: requestHashHex) else { - throw ZapSecurityGuardError.descriptionHashMismatch - } - - guard requestHashData == invoiceDescriptionHash else { - throw ZapSecurityGuardError.descriptionHashMismatch - } - - let expirySeconds = expiryInterval(from: invoice) - let expiryDate = invoice.timestamp.addingTimeInterval(expirySeconds) - guard now <= expiryDate else { - throw ZapSecurityGuardError.invoiceExpired - } - } - - private static func zapRequestAmountMsat(_ event: NostrEvent) -> Int? { - for tag in event.tags where tag.count >= 2 { - guard tag[0] == "amount" else { continue } - if let amount = Int(tag[1]) { - return amount - } - } - return nil - } - - private static func expiryInterval(from invoice: Bolt11Invoice) -> TimeInterval { - for tag in invoice.tags { - if case let .expiry(seconds) = tag { - return TimeInterval(seconds) - } - } - return defaultExpirySeconds - } -} diff --git a/Pulse/Pulse/Utilities/DebugLogger.swift b/Pulse/Pulse/Utilities/DebugLogger.swift index 43a4a7b..8173e05 100644 --- a/Pulse/Pulse/Utilities/DebugLogger.swift +++ b/Pulse/Pulse/Utilities/DebugLogger.swift @@ -19,12 +19,14 @@ enum DebugLogger { private static let networkLog = OSLog(subsystem: subsystem, category: "network") private static let cryptoLog = OSLog(subsystem: subsystem, category: "crypto") private static let meshLog = OSLog(subsystem: subsystem, category: "mesh") + private static let securityLog = OSLog(subsystem: subsystem, category: "security") enum Category { case general case network case crypto case mesh + case security var osLog: OSLog { switch self { @@ -32,6 +34,7 @@ enum DebugLogger { case .network: return networkLog case .crypto: return cryptoLog case .mesh: return meshLog + case .security: return securityLog } } } diff --git a/Pulse/Pulse/Views/ChatView.swift b/Pulse/Pulse/Views/ChatView.swift index aed8b78..de9b967 100644 --- a/Pulse/Pulse/Views/ChatView.swift +++ b/Pulse/Pulse/Views/ChatView.swift @@ -127,8 +127,6 @@ struct ChatContentView: View { MessageRow( message: message, isFromMe: message.senderId == "me", - peerPubkey: peer.nostrPubkey ?? peer.id, - peerLightningAddress: peer.lightningAddress, showImageViewer: $showImageViewer, viewerMessage: $viewerMessage ) @@ -538,17 +536,12 @@ struct ChatContentView: View { struct MessageRow: View { let message: Message let isFromMe: Bool - let peerPubkey: String - let peerLightningAddress: String? @Binding var showImageViewer: Bool @Binding var viewerMessage: Message? @EnvironmentObject var chatManager: ChatManager - @ObservedObject private var zapManager = ZapManager.shared @State private var showEmojiPicker = false - @State private var showZapSheet = false - @State private var showZapDetails = false var body: some View { HStack { @@ -614,20 +607,6 @@ struct MessageRow: View { currentUserId: UserDefaults.standard.string(forKey: "myPeerID") ?? "unknown" ) - // Zap display (only show on received messages) - if !isFromMe { - ZapDisplayView( - messageId: message.id, - recipientPubkey: peerPubkey, - lightningAddress: peerLightningAddress, - onZap: { - showZapSheet = true - }, - onShowZapDetails: { - showZapDetails = true - } - ) - } } if !isFromMe { Spacer(minLength: 60) } @@ -639,38 +618,6 @@ struct MessageRow: View { .presentationDetents([.fraction(0.4)]) .presentationDragIndicator(.visible) } - .sheet(isPresented: $showZapSheet) { - ZapAmountSheet( - recipientHandle: message.senderId, - lightningAddress: peerLightningAddress ?? "" - ) { amount, comment in - Task { - do { - try await zapManager.zapMessage( - messageId: message.id, - recipientPubkey: peerPubkey, - lightningAddress: peerLightningAddress ?? "", - amount: amount, - comment: comment - ) - } catch { - // Surface the error to the user - ErrorManager.shared.showError(.unknown(message: "Failed to send zap: \(error.localizedDescription)")) - print("Zap error: \(error)") - } - } - } - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - .sheet(isPresented: $showZapDetails) { - ZapDetailsSheet( - messageId: message.id, - zaps: zapManager.zapsForMessage(message.id) - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } } } @@ -1114,7 +1061,6 @@ struct CodeShareSheet: View { distance: 12, publicKey: nil, signingPublicKey: nil, - lightningAddress: "swiftdev@getalby.com", nostrPubkey: "npub1examplepubkey" ) ) diff --git a/Pulse/Pulse/Views/Components/ChatInputBarView.swift b/Pulse/Pulse/Views/Components/ChatInputBarView.swift index ab16d92..bfab26c 100644 --- a/Pulse/Pulse/Views/Components/ChatInputBarView.swift +++ b/Pulse/Pulse/Views/Components/ChatInputBarView.swift @@ -59,8 +59,8 @@ struct ChatInputBarView: View { Color.black .shadow(color: .black.opacity(0.5), radius: 10, y: -5) ) - .animation(.spring(response: 0.3, value: voiceManager.isRecording) - .animation(.spring(response: 0.3, value: pendingVoiceNote != nil) + .animation(.spring(response: 0.3), value: voiceManager.isRecording) + .animation(.spring(response: 0.3), value: pendingVoiceNote != nil) } @ViewBuilder diff --git a/Pulse/Pulse/Views/Components/ZapAmountSheet.swift b/Pulse/Pulse/Views/Components/ZapAmountSheet.swift deleted file mode 100644 index 5771324..0000000 --- a/Pulse/Pulse/Views/Components/ZapAmountSheet.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// ZapAmountSheet.swift -// Pulse -// -// Bottom sheet for selecting zap amount before sending. -// - -import SwiftUI - -struct ZapAmountSheet: View { - @Environment(\.dismiss) private var dismiss - @State private var themeManager = ThemeManager.shared - - let recipientHandle: String - let lightningAddress: String - let onZap: (Int, String?) -> Void - - @State private var selectedAmount: Int = 1000 - @State private var customAmount: String = "" - @State private var comment: String = "" - @State private var showCustomInput = false - - private let presetAmounts = ZapAmount.allCases - - var body: some View { - NavigationStack { - VStack(spacing: 24) { - // Header - zapHeader - - // Amount grid - amountGrid - - // Custom amount input - if showCustomInput { - customAmountInput - } - - // Comment input - commentInput - - // Zap button - zapButton - - Spacer() - } - .padding() - .background(themeManager.colors.background) - .navigationTitle("Zap \(recipientHandle)") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - } - } - } - - // MARK: - Components - - private var zapHeader: some View { - VStack(spacing: 8) { - Image(systemName: "bolt.circle.fill") - .font(.system(size: 48)) - .foregroundStyle(.yellow) - - Text(lightningAddress) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - private var amountGrid: some View { - VStack(spacing: 12) { - LazyVGrid(columns: [ - GridItem(.flexible()), - GridItem(.flexible()), - GridItem(.flexible()), - GridItem(.flexible()) - ], spacing: 12) { - ForEach(presetAmounts, id: \.rawValue) { amount in - AmountButton( - amount: amount.rawValue, - displayName: amount.displayName, - isSelected: selectedAmount == amount.rawValue && !showCustomInput, - onTap: { - selectedAmount = amount.rawValue - showCustomInput = false - HapticManager.shared.selection() - } - ) - } - } - - // Custom amount toggle - Button(action: { - showCustomInput.toggle() - if showCustomInput { - customAmount = "" - } - }) { - HStack { - Image(systemName: showCustomInput ? "checkmark.circle.fill" : "circle") - .foregroundStyle(showCustomInput ? .blue : .secondary) - Text("Custom amount") - .font(.subheadline) - } - .foregroundStyle(.primary) - } - .padding(.top, 8) - } - } - - private var customAmountInput: some View { - HStack { - TextField("Amount in sats", text: $customAmount) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .onChange(of: customAmount) { _, newValue in - if let amount = Int(newValue), amount > 0 { - selectedAmount = amount - } - } - - Text("sats") - .foregroundStyle(.secondary) - } - .padding(.horizontal) - } - - private var commentInput: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Comment (optional)") - .font(.caption) - .foregroundStyle(.secondary) - - TextField("Add a message...", text: $comment) - .textFieldStyle(.roundedBorder) - } - } - - private var zapButton: some View { - Button(action: { - onZap(selectedAmount, comment.isEmpty ? nil : comment) - dismiss() - }) { - HStack { - Image(systemName: "bolt.fill") - Text("Zap \(selectedAmount.formattedSats) sats") - } - .font(.headline) - .foregroundStyle(.black) - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(Color.yellow) - .cornerRadius(12) - } - } -} - -// MARK: - Amount Button - -struct AmountButton: View { - let amount: Int - let displayName: String - let isSelected: Bool - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(spacing: 4) { - Text(displayName) - .font(.headline) - Text("sats") - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .frame(height: 60) - .background(isSelected ? Color.yellow.opacity(0.3) : Color(.secondarySystemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.yellow : Color.clear, lineWidth: 2) - ) - .cornerRadius(12) - } - .foregroundStyle(.primary) - } -} - -#Preview { - ZapAmountSheet( - recipientHandle: "alice", - lightningAddress: "alice@getalby.com" - ) { amount, comment in - print("Zapping \(amount) sats with comment: \(comment ?? "none")") - } -} diff --git a/Pulse/Pulse/Views/Components/ZapButton.swift b/Pulse/Pulse/Views/Components/ZapButton.swift deleted file mode 100644 index 83af24a..0000000 --- a/Pulse/Pulse/Views/Components/ZapButton.swift +++ /dev/null @@ -1,162 +0,0 @@ -// -// ZapButton.swift -// Pulse -// -// Lightning bolt button for initiating zaps on messages. -// - -import SwiftUI - -struct ZapButton: View { - let messageId: String? - let recipientPubkey: String - let lightningAddress: String? - let totalZapAmount: Int // sats - let zapCount: Int - let onTap: () -> Void - - @State private var isAnimating = false - - var body: some View { - Button(action: { - // Animate on tap - withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { - isAnimating = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isAnimating = false - } - onTap() - }) { - HStack(spacing: 4) { - Image(systemName: "bolt.fill") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(zapCount > 0 ? .yellow : .secondary) - .scaleEffect(isAnimating ? 1.3 : 1.0) - - if totalZapAmount > 0 { - Text(totalZapAmount.formattedSats) - .font(.caption2) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - } - } - .frame(height: 28) - .padding(.horizontal, 8) - .background( - zapCount > 0 ? - Color.yellow.opacity(0.15) : - Color(.secondarySystemBackground) - ) - .cornerRadius(12) - } - .disabled(lightningAddress == nil || lightningAddress!.isEmpty) - .opacity(lightningAddress == nil || lightningAddress!.isEmpty ? 0.5 : 1.0) - } -} - -// MARK: - Zap Pill (for displaying zap summary) - -struct ZapPill: View { - let totalAmount: Int // sats - let zapCount: Int - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 4) { - Image(systemName: "bolt.fill") - .font(.system(size: 12)) - .foregroundStyle(.yellow) - - Text(totalAmount.formattedSats) - .font(.caption2) - .fontWeight(.semibold) - .foregroundStyle(.primary) - - if zapCount > 1 { - Text("(\(zapCount))") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .frame(height: 28) - .padding(.horizontal, 8) - .background(Color.yellow.opacity(0.15)) - .cornerRadius(12) - } - } -} - -// MARK: - Quick Zap Button (single-tap default amount) - -struct QuickZapButton: View { - let defaultAmount: Int - let isLoading: Bool - let onQuickZap: () -> Void - - var body: some View { - Button(action: onQuickZap) { - HStack(spacing: 4) { - if isLoading { - ProgressView() - .scaleEffect(0.7) - } else { - Image(systemName: "bolt.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.yellow) - } - - Text("\(defaultAmount)") - .font(.caption) - .fontWeight(.semibold) - } - .frame(height: 32) - .padding(.horizontal, 12) - .background(Color.yellow.opacity(0.2)) - .cornerRadius(16) - } - .disabled(isLoading) - } -} - -#Preview { - VStack(spacing: 20) { - // Zap button with zaps - ZapButton( - messageId: "msg123", - recipientPubkey: "pubkey", - lightningAddress: "user@getalby.com", - totalZapAmount: 21000, - zapCount: 5, - onTap: {} - ) - - // Zap button without zaps - ZapButton( - messageId: "msg456", - recipientPubkey: "pubkey", - lightningAddress: "user@getalby.com", - totalZapAmount: 0, - zapCount: 0, - onTap: {} - ) - - // Disabled zap button (no lightning address) - ZapButton( - messageId: "msg789", - recipientPubkey: "pubkey", - lightningAddress: nil, - totalZapAmount: 0, - zapCount: 0, - onTap: {} - ) - - // Zap pill - ZapPill(totalAmount: 5000, zapCount: 3) {} - - // Quick zap button - QuickZapButton(defaultAmount: 1000, isLoading: false) {} - } - .padding() -} diff --git a/Pulse/Pulse/Views/Components/ZapDisplayView.swift b/Pulse/Pulse/Views/Components/ZapDisplayView.swift deleted file mode 100644 index 80c28e3..0000000 --- a/Pulse/Pulse/Views/Components/ZapDisplayView.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// ZapDisplayView.swift -// Pulse -// -// Displays zaps on a message (similar to ReactionDisplayView). -// - -import SwiftUI - -struct ZapDisplayView: View { - let messageId: String - let recipientPubkey: String - let lightningAddress: String? - let onZap: () -> Void - let onShowZapDetails: () -> Void - - @ObservedObject private var zapManager = ZapManager.shared - - private var totalAmount: Int { - zapManager.totalZapsForMessage(messageId) - } - - private var zapCount: Int { - zapManager.zapCountForMessage(messageId) - } - - private var zaps: [ZapReceipt] { - zapManager.zapsForMessage(messageId) - } - - var body: some View { - HStack(spacing: 6) { - // Show zap summary if there are zaps - if zapCount > 0 { - ZapPill( - totalAmount: totalAmount, - zapCount: zapCount, - onTap: onShowZapDetails - ) - } - - // Add zap button - ZapButton( - messageId: messageId, - recipientPubkey: recipientPubkey, - lightningAddress: lightningAddress, - totalZapAmount: zapCount > 0 ? 0 : 0, // Don't show amount on button if pill shows it - zapCount: 0, - onTap: onZap - ) - - Spacer() - } - .padding(.top, 6) - } -} - -// MARK: - Zap Details Sheet - -struct ZapDetailsSheet: View { - @Environment(\.dismiss) private var dismiss - - let messageId: String - let zaps: [ZapReceipt] - @State private var revealSensitive = false - - var totalAmount: Int { - zaps.reduce(0) { $0 + $1.sats } - } - - var body: some View { - NavigationStack { - List { - Section { - Toggle("Reveal sensitive data", isOn: $revealSensitive) - .privacySensitiveIfAvailable() - } - - // Summary section - Section { - HStack { - Image(systemName: "bolt.fill") - .foregroundStyle(.yellow) - Text("Total Zapped") - Spacer() - Text(revealSensitive ? "\(totalAmount.formattedSats) sats" : "Hidden") - .fontWeight(.semibold) - .privacySensitiveIfAvailable() - } - } - - // Individual zaps - Section("Zaps") { - ForEach(zaps) { zap in - ZapRow(zap: zap, revealSensitive: revealSensitive) - } - } - } - .navigationTitle("Zap Details") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - } - } - } -} - -// MARK: - Zap Row - -struct ZapRow: View { - let zap: ZapReceipt - let revealSensitive: Bool - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - // Sender info - Text(revealSensitive ? formatPubkey(zap.senderPubkey) : "Hidden") - .font(.subheadline) - .fontWeight(.medium) - .privacySensitiveIfAvailable() - - // Comment if any - if let comment = zap.comment, !comment.isEmpty { - Text(revealSensitive ? comment : "Hidden") - .font(.caption) - .foregroundStyle(.secondary) - .privacySensitiveIfAvailable() - } - - // Timestamp - Text(zap.createdAt, style: .relative) - .font(.caption2) - .foregroundStyle(.tertiary) - } - - Spacer() - - // Amount - HStack(spacing: 4) { - Image(systemName: "bolt.fill") - .font(.caption) - .foregroundStyle(.yellow) - Text(revealSensitive ? "\(zap.sats)" : "β€”") - .fontWeight(.semibold) - .privacySensitiveIfAvailable() - } - } - .padding(.vertical, 4) - } - - private func formatPubkey(_ pubkey: String) -> String { - if pubkey.count > 12 { - return "\(pubkey.prefix(6))...\(pubkey.suffix(4))" - } - return pubkey - } -} - -// MARK: - Zap Animation Overlay - -struct ZapAnimationOverlay: View { - let amount: Int - @Binding var isVisible: Bool - - @State private var opacity: Double = 1.0 - @State private var scale: CGFloat = 0.5 - @State private var yOffset: CGFloat = 0 - - var body: some View { - if isVisible { - HStack(spacing: 4) { - Image(systemName: "bolt.fill") - .foregroundStyle(.yellow) - Text("+\(amount)") - .fontWeight(.bold) - } - .font(.title2) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.ultraThinMaterial) - .cornerRadius(20) - .opacity(opacity) - .scaleEffect(scale) - .offset(y: yOffset) - .onAppear { - withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { - scale = 1.0 - } - withAnimation(.easeOut(duration: 1.5).delay(0.5)) { - opacity = 0 - yOffset = -50 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - isVisible = false - } - } - } - } -} - -#Preview { - VStack(spacing: 20) { - ZapDisplayView( - messageId: "msg123", - recipientPubkey: "pubkey", - lightningAddress: "user@getalby.com", - onZap: {}, - onShowZapDetails: {} - ) - - ZapDetailsSheet( - messageId: "msg123", - zaps: [ - ZapReceipt( - id: "1", - senderPubkey: "abc123def456", - recipientPubkey: "xyz789", - amount: 1000000, - bolt11: "lnbc...", - preimage: nil, - zapRequestId: "req1", - messageEventId: "msg123", - comment: "Great message!", - createdAt: Date() - ), - ZapReceipt( - id: "2", - senderPubkey: "def456abc123", - recipientPubkey: "xyz789", - amount: 21000000, - bolt11: "lnbc...", - preimage: nil, - zapRequestId: "req2", - messageEventId: "msg123", - comment: nil, - createdAt: Date().addingTimeInterval(-3600) - ) - ] - ) - } - .padding() -} diff --git a/Pulse/Pulse/Views/ProfileView.swift b/Pulse/Pulse/Views/ProfileView.swift index ee7d1ab..775e6d4 100644 --- a/Pulse/Pulse/Views/ProfileView.swift +++ b/Pulse/Pulse/Views/ProfileView.swift @@ -16,11 +16,8 @@ struct ProfileView: View { @AppStorage("handle") private var handle = "" @AppStorage("statusMessage") private var statusMessage = "" @AppStorage("userStatus") private var userStatus = 0 - @AppStorage("lightningAddress") private var lightningAddress = "" - @State private var editingHandle = "" @State private var editingStatusMessage = "" - @State private var editingLightningAddress = "" @State private var selectedTechStack: Set = [] @State private var showContent = false @State private var avatarScale: CGFloat = 0.8 @@ -63,11 +60,6 @@ struct ProfileView: View { .opacity(showContent ? 1 : 0) .offset(y: showContent ? 0 : 20) - // Lightning section - lightningSection - .opacity(showContent ? 1 : 0) - .offset(y: showContent ? 0 : 20) - // Identity section identitySection .opacity(showContent ? 1 : 0) @@ -105,7 +97,6 @@ struct ProfileView: View { .onAppear { editingHandle = handle editingStatusMessage = statusMessage - editingLightningAddress = lightningAddress loadTechStack() loadProfileImage() @@ -400,66 +391,6 @@ struct ProfileView: View { .accessibilityAddTraits(isSelected ? [.isSelected] : []) } - // MARK: - Lightning Section - - private var lightningSection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader("Lightning", icon: "bolt.fill") - - TextField("you@getalby.com", text: $editingLightningAddress) - .font(.pulseBodySecondary) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - .padding() - .background(themeManager.colors.cardBackground) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(themeManager.colors.accent.opacity(0.3), lineWidth: 1) - ) - .accessibilityLabel("Lightning Address") - .accessibilityHint("Enter your Lightning Address to receive zaps") - .privacySensitiveIfAvailable() - - Text("Your Lightning Address allows others to send you sats via zaps. Supports NIP-57.") - .font(.pulseCaption) - .foregroundStyle(themeManager.colors.textSecondary) - - // Nostr identity info - if let nostrIdentity = NostrIdentityManager.shared.nostrIdentity { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Nostr npub") - .font(.caption) - .foregroundStyle(themeManager.colors.textSecondary) - Spacer() - Text(String(nostrIdentity.npub.prefix(20)) + "...") - .font(.caption.monospaced()) - .foregroundStyle(themeManager.colors.text) - .privacySensitiveIfAvailable() - - Button { - // SECURITY: Use ClipboardManager with auto-clear for Nostr public key - ClipboardManager.shared.copy(nostrIdentity.npub, sensitive: true) - HapticManager.shared.notify(.success) - } label: { - Image(systemName: "doc.on.doc") - .font(.caption) - .foregroundStyle(themeManager.colors.accent) - } - } - } - .padding() - .background(themeManager.colors.cardBackground) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - } - .padding() - .background(themeManager.colors.cardBackground.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - // MARK: - Identity Section private var identitySection: some View { @@ -560,7 +491,6 @@ struct ProfileView: View { handle = editingHandle statusMessage = editingStatusMessage - lightningAddress = editingLightningAddress UserDefaults.standard.set(Array(selectedTechStack), forKey: "techStack") // Save profile image @@ -568,14 +498,11 @@ struct ProfileView: View { saveProfileImage() } - // Publish Nostr metadata if Lightning address is set - if !editingLightningAddress.isEmpty { - Task { - try? await NostrTransport.shared.publishMetadata( - name: editingHandle, - lightningAddress: editingLightningAddress - ) - } + // Publish Nostr metadata + Task { + try? await NostrTransport.shared.publishMetadata( + name: editingHandle + ) } dismiss() diff --git a/Pulse/Pulse/Views/SettingsView.swift b/Pulse/Pulse/Views/SettingsView.swift index 1e397d1..8a44bb3 100644 --- a/Pulse/Pulse/Views/SettingsView.swift +++ b/Pulse/Pulse/Views/SettingsView.swift @@ -25,11 +25,6 @@ struct SettingsView: View { @AppStorage("shareProfileInDiscovery") private var shareProfileInDiscovery = true @AppStorage("autoAcceptInvites") private var autoAcceptInvites = true - // Lightning settings - @AppStorage("preferredWallet") private var preferredWallet: String = LightningWallet.automatic.rawValue - @AppStorage("defaultZapAmount") private var defaultZapAmount = 1000 - @AppStorage("lightningAddress") private var lightningAddress = "" - // Animation state @State private var showContent = false @@ -60,12 +55,6 @@ struct SettingsView: View { .offset(y: showContent ? 0 : 20) .animation(.easeOut(duration: 0.4).delay(0.15), value: showContent) - // Lightning Section - lightningSection - .opacity(showContent ? 1 : 0) - .offset(y: showContent ? 0 : 20) - .animation(.easeOut(duration: 0.4).delay(0.175), value: showContent) - // About Section aboutSection .opacity(showContent ? 1 : 0) @@ -351,120 +340,6 @@ struct SettingsView: View { dismiss() } - // MARK: - Lightning Section - - private var lightningSection: some View { - VStack(alignment: .leading, spacing: 16) { - sectionHeader("Lightning", icon: "bolt.fill") - - VStack(spacing: 0) { - // Lightning Address display - HStack(spacing: 12) { - Image(systemName: "bolt.circle.fill") - .foregroundStyle(.yellow) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("Lightning Address") - .foregroundStyle(themeManager.colors.text) - if lightningAddress.isEmpty { - Text("Set in Profile to receive zaps") - .font(.pulseCaption) - .foregroundStyle(themeManager.colors.textSecondary) - } else { - Text(lightningAddress) - .font(.pulseCaption) - .foregroundStyle(themeManager.colors.accent) - } - } - - Spacer() - - if !lightningAddress.isEmpty { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(themeManager.colors.success) - } - } - .padding(.vertical, 8) - - Divider().background(themeManager.colors.textSecondary.opacity(0.2)) - - // Preferred Wallet - HStack(spacing: 12) { - Image(systemName: "wallet.pass.fill") - .foregroundStyle(themeManager.colors.accent) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("Preferred Wallet") - .foregroundStyle(themeManager.colors.text) - Text("App to open for payments") - .font(.pulseCaption) - .foregroundStyle(themeManager.colors.textSecondary) - } - - Spacer() - - Picker("", selection: $preferredWallet) { - ForEach(LightningWallet.allCases, id: \.rawValue) { wallet in - Text(wallet.rawValue).tag(wallet.rawValue) - } - } - .pickerStyle(.menu) - .tint(themeManager.colors.accent) - } - .padding(.vertical, 8) - - Divider().background(themeManager.colors.textSecondary.opacity(0.2)) - - // Default Zap Amount - HStack(spacing: 12) { - Image(systemName: "number.circle.fill") - .foregroundStyle(themeManager.colors.accent) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("Default Zap Amount") - .foregroundStyle(themeManager.colors.text) - Text("For quick zaps") - .font(.pulseCaption) - .foregroundStyle(themeManager.colors.textSecondary) - } - - Spacer() - - Picker("", selection: $defaultZapAmount) { - ForEach(ZapAmount.allCases, id: \.rawValue) { amount in - Text(amount.displayName).tag(amount.rawValue) - } - } - .pickerStyle(.menu) - .tint(themeManager.colors.accent) - } - .padding(.vertical, 8) - } - - // Info about zaps - HStack(alignment: .top, spacing: 8) { - Image(systemName: "info.circle") - .foregroundStyle(themeManager.colors.accent) - .font(.caption) - VStack(alignment: .leading, spacing: 4) { - Text("Zaps are Bitcoin micropayments sent via Lightning Network.") - .font(.pulseCaption) - .foregroundStyle(themeManager.colors.textSecondary) - Text("You need a Lightning wallet to send and receive zaps.") - .font(.pulseCaption) - .foregroundStyle(themeManager.colors.textSecondary) - } - } - .padding(.top, 4) - } - .padding() - .background(themeManager.colors.cardBackground) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - // MARK: - About Section private var aboutSection: some View { @@ -486,7 +361,7 @@ struct SettingsView: View { Text("Build") .foregroundStyle(themeManager.colors.text) Spacer() - Text("Phase 5 - BitChat Integration") + Text("Phase 5 - Pure Messaging") .foregroundStyle(themeManager.colors.textSecondary) .font(.pulseCaption) } @@ -502,7 +377,6 @@ struct SettingsView: View { featureRow("Multi-hop Mesh", description: "Up to 7 relay hops") featureRow("Nostr Fallback", description: "Global internet relay") featureRow("Location Channels", description: "Geohash-based rooms") - featureRow("Lightning Zaps", description: "NIP-57 micropayments") } .padding(.top, 8) } diff --git a/Pulse/PulseTests/Bolt11ParserTests.swift b/Pulse/PulseTests/Bolt11ParserTests.swift deleted file mode 100644 index e987031..0000000 --- a/Pulse/PulseTests/Bolt11ParserTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Bolt11ParserTests.swift -// PulseTests -// - -import XCTest -@testable import Pulse - -final class Bolt11ParserTests: XCTestCase { - func testParseValidInvoice() throws { - let invoice = "lnbc20u1pvjluezhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfppqw508d6qejxtdg4y5r3zarvary0c5xw7kepvrhrm9s57hejg0p662ur5j5cr03890fa7k2pypgttmh4897d3raaq85a293e9jpuqwl0rnfuwzam7yr8e690nd2ypcq9hlkdwdvycqa0qza8" - - let parsed = try Bolt11Parser().parse(invoice) - - XCTAssertEqual(parsed.network, .bitcoin) - XCTAssertEqual(parsed.amountMillisats, 2_000_000) - XCTAssertNotNil(parsed.paymentHash) - XCTAssertTrue(parsed.description != nil || parsed.descriptionHash != nil) - } - - func testInvalidBech32Fails() { - XCTAssertThrowsError(try Bolt11Parser().parse("lnbcinvalidinvoice")) - } - - func testUnsafeDescriptionDetection() { - XCTAssertFalse(Bolt11Validator.isSafeDescription("")) - XCTAssertFalse(Bolt11Validator.isSafeDescription("DROP TABLE zaps;")) - XCTAssertTrue(Bolt11Validator.isSafeDescription("Thanks for the zap!")) - } -} diff --git a/Pulse/PulseTests/Bolt11ValidatorTests.swift b/Pulse/PulseTests/Bolt11ValidatorTests.swift deleted file mode 100644 index 5ad1f6d..0000000 --- a/Pulse/PulseTests/Bolt11ValidatorTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Bolt11ValidatorTests.swift -// PulseTests -// - -import XCTest -@testable import Pulse - -final class Bolt11ValidatorTests: XCTestCase { - func testRejectsOverlongInvoice() { - let longInvoice = "lnbc" + String(repeating: "a", count: 5000) - XCTAssertThrowsError(try Bolt11Validator.validate(longInvoice)) - } - - func testRejectsInvalidAmountInvoice() { - let invoice = "lnbc1" + String(repeating: "a", count: 20) - XCTAssertThrowsError(try Bolt11Validator.validate(invoice)) - } -} diff --git a/Pulse/PulseTests/ErrorManagerTests.swift b/Pulse/PulseTests/ErrorManagerTests.swift index 8158231..1cc56c6 100644 --- a/Pulse/PulseTests/ErrorManagerTests.swift +++ b/Pulse/PulseTests/ErrorManagerTests.swift @@ -7,7 +7,7 @@ import XCTest @testable import Pulse final class ErrorManagerTests: XCTestCase { - func testScrubsSensitiveStrings() { + @MainActor func testScrubsSensitiveStrings() { let message = "Failed lnurl1abcdef and lightning:lnbc1deadbeef plus hash 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" let scrubbed = ErrorManager.scrubSensitiveStrings(message) XCTAssertFalse(scrubbed.contains("lnurl1abcdef")) diff --git a/Pulse/PulseTests/NostrEventValidatorTests.swift b/Pulse/PulseTests/NostrEventValidatorTests.swift index f8d9a3c..533ff7e 100644 --- a/Pulse/PulseTests/NostrEventValidatorTests.swift +++ b/Pulse/PulseTests/NostrEventValidatorTests.swift @@ -7,7 +7,7 @@ import XCTest @testable import Pulse final class NostrEventValidatorTests: XCTestCase { - func testValidZapRequestPasses() throws { + func testValidSignaturePasses() throws { guard let identity = NostrIdentity.create() else { XCTFail("Failed to create identity") return @@ -15,12 +15,12 @@ final class NostrEventValidatorTests: XCTestCase { let event = try NostrEvent.createSigned( identity: identity, - kind: .zapRequest, - content: "", - tags: [["amount", "1000"], ["p", "ff".padding(toLength: 64, withPad: "f", startingAt: 0)]] + kind: .textNote, + content: "hello", + tags: [] ) - XCTAssertNoThrow(try NostrEventValidator.validateZapRequest(event)) + XCTAssertNoThrow(try NostrEventValidator.validateEventSignature(event)) } func testInvalidSignatureFails() throws { @@ -31,9 +31,9 @@ final class NostrEventValidatorTests: XCTestCase { var event = try NostrEvent.createSigned( identity: identity, - kind: .zapRequest, - content: "", - tags: [["amount", "1000"]] + kind: .textNote, + content: "hello", + tags: [] ) event = NostrEvent( id: event.id, @@ -45,23 +45,7 @@ final class NostrEventValidatorTests: XCTestCase { sig: String(repeating: "0", count: 128) ) - XCTAssertThrowsError(try NostrEventValidator.validateZapRequest(event)) - } - - func testInvalidKindFails() throws { - guard let identity = NostrIdentity.create() else { - XCTFail("Failed to create identity") - return - } - - let event = try NostrEvent.createSigned( - identity: identity, - kind: .textNote, - content: "", - tags: [] - ) - - XCTAssertThrowsError(try NostrEventValidator.validateZapRequest(event)) + XCTAssertThrowsError(try NostrEventValidator.validateEventSignature(event)) } func testInvalidEventIdFails() throws { @@ -72,8 +56,8 @@ final class NostrEventValidatorTests: XCTestCase { var event = try NostrEvent.createSigned( identity: identity, - kind: .zapReceipt, - content: "", + kind: .textNote, + content: "hello", tags: [] ) @@ -87,6 +71,6 @@ final class NostrEventValidatorTests: XCTestCase { sig: event.sig ) - XCTAssertThrowsError(try NostrEventValidator.validateZapReceipt(event)) + XCTAssertThrowsError(try NostrEventValidator.validateEventSignature(event)) } } diff --git a/Pulse/PulseTests/NostrNormalizationTests.swift b/Pulse/PulseTests/NostrNormalizationTests.swift index bd40cd6..0a5c159 100644 --- a/Pulse/PulseTests/NostrNormalizationTests.swift +++ b/Pulse/PulseTests/NostrNormalizationTests.swift @@ -11,7 +11,7 @@ final class NostrNormalizationTests: XCTestCase { func testCanonicalEventJSON() throws { let pubkey = "abcdef0123456789" let createdAt = 123 - let kind = NostrEventKind.zapRequest.rawValue + let kind = NostrEventKind.textNote.rawValue let tags = [["p", "recipient"], ["amount", "1000"], ["relays", "wss://relay.example"]] let content = "hello" @@ -23,7 +23,7 @@ final class NostrNormalizationTests: XCTestCase { content: content ) - let expected = "[0,\"abcdef0123456789\",123,9734,[[\"p\",\"recipient\"],[\"amount\",\"1000\"],[\"relays\",\"wss://relay.example\"]],\"hello\"]" + let expected = "[0,\"abcdef0123456789\",123,1,[[\"p\",\"recipient\"],[\"amount\",\"1000\"],[\"relays\",\"wss:\\/\\/relay.example\"]],\"hello\"]" XCTAssertEqual(canonical, expected) } @@ -32,7 +32,7 @@ final class NostrNormalizationTests: XCTestCase { id: "", pubkey: "abcdef0123456789", created_at: 123, - kind: NostrEventKind.zapRequest.rawValue, + kind: NostrEventKind.textNote.rawValue, tags: [["p", "recipient"], ["amount", "1000"]], content: "hello", sig: "" diff --git a/Pulse/PulseTests/ProductionSecurityTests.swift b/Pulse/PulseTests/ProductionSecurityTests.swift index f571c8b..4f8c542 100644 --- a/Pulse/PulseTests/ProductionSecurityTests.swift +++ b/Pulse/PulseTests/ProductionSecurityTests.swift @@ -7,33 +7,7 @@ import XCTest @testable import Pulse final class ProductionSecurityTests: XCTestCase { - func testZapReceiptSignatureValidationBlocksTampering() throws { - guard let identity = NostrIdentity.create() else { - XCTFail("Failed to create identity") - return - } - - var event = try NostrEvent.createSigned( - identity: identity, - kind: .zapReceipt, - content: "", - tags: [["amount", "1000"]] - ) - - event = NostrEvent( - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags: event.tags, - content: "tampered", - sig: event.sig - ) - - XCTAssertThrowsError(try NostrEventValidator.validateZapReceipt(event)) - } - - func testScrubSensitiveStringsRedactsInvoices() { + @MainActor func testScrubSensitiveStringsRedactsInvoices() { let message = "Invoice lightning:lnbc1qwerty and lnurl1abc123" let scrubbed = ErrorManager.scrubSensitiveStrings(message) XCTAssertFalse(scrubbed.contains("lnbc1")) diff --git a/Pulse/PulseTests/RunSimulator.swift b/Pulse/PulseTests/RunSimulator.swift deleted file mode 100644 index 559a214..0000000 --- a/Pulse/PulseTests/RunSimulator.swift +++ /dev/null @@ -1,93 +0,0 @@ -#if !canImport(XCTest) -// -// RunSimulator.swift -// Quick mesh simulator demo - runs without Xcode test target -// - -import Foundation - -// Since we can't import the full Pulse module in a script, -// this is a standalone demo of the simulator concepts - -print("═══════════════════════════════════════════════════════") -print(" MESH SIMULATOR - Standalone Demo ") -print("═══════════════════════════════════════════════════════") -print("") - -// Simulated peer -struct DemoPeer { - let id: String - let handle: String - var connections: Set = [] - - mutating func connect(to peerId: String) { - connections.insert(peerId) - } -} - -// Create demo peers -var peers: [String: DemoPeer] = [:] -let handles = ["swift_ninja", "rust_wizard", "py_guru", "go_master", "js_pro"] - -print("Creating \(handles.count) virtual peers...") -for handle in handles { - let id = UUID().uuidString - peers[id] = DemoPeer(id: id, handle: handle) -} - -// Create mesh topology -print("Applying mesh topology...") -let peerIds = Array(peers.keys) -var edgeCount = 0 -for i in 0..") - XCTAssertNil(sanitized) - } - - func testSanitizeInvoiceRejectsMissingPrefix() { - let sanitized = WalletURISanitizer.sanitizeInvoice("bitcoin:123") - XCTAssertNil(sanitized) - } - - func testBuildPaymentURLForPhoenix() { - let url = WalletURISanitizer.buildPaymentURL(invoice: "lnbc1test", wallet: .phoenix) - XCTAssertEqual(url?.scheme, "phoenix") - } - - func testBuildGenericLightningURL() { - let url = WalletURISanitizer.buildGenericLightningURL(invoice: "lnbc1test") - XCTAssertEqual(url?.scheme, "lightning") - } -} diff --git a/Pulse/PulseTests/ZapSecurityGuardTests.swift b/Pulse/PulseTests/ZapSecurityGuardTests.swift deleted file mode 100644 index bef2c90..0000000 --- a/Pulse/PulseTests/ZapSecurityGuardTests.swift +++ /dev/null @@ -1,162 +0,0 @@ -// -// ZapSecurityGuardTests.swift -// PulseTests -// - -import XCTest -@testable import Pulse - -final class ZapSecurityGuardTests: XCTestCase { - func testValidInvoicePassesGuard() throws { - let event = NostrEvent( - id: "id", - pubkey: "abcdef0123456789", - created_at: 100, - kind: NostrEventKind.zapRequest.rawValue, - tags: [["amount", "2000"]], - content: "", - sig: "" - ) - let hashHex = try event.descriptionHash() - guard let hashData = Data(hex: hashHex) else { - XCTFail("Failed to decode hash") - return - } - - let invoice = Bolt11Invoice( - raw: "lnbc-test", - hrp: "lnbc", - network: .bitcoin, - amountMillisats: 2000, - timestamp: Date(timeIntervalSince1970: 100), - tags: [ - .paymentHash(Data(repeating: 0x01, count: 32)), - .descriptionHash(hashData), - .expiry(3600) - ], - signature: Data(repeating: 0x00, count: 65) - ) - - XCTAssertNoThrow( - try ZapSecurityGuard.validate( - invoice: invoice, - zapRequest: event, - expectedAmountMsat: 2000, - now: Date(timeIntervalSince1970: 200) - ) - ) - } - - func testAmountMismatchThrows() throws { - let event = NostrEvent( - id: "id", - pubkey: "abcdef0123456789", - created_at: 100, - kind: NostrEventKind.zapRequest.rawValue, - tags: [["amount", "2000"]], - content: "", - sig: "" - ) - let hashHex = try event.descriptionHash() - guard let hashData = Data(hex: hashHex) else { - XCTFail("Failed to decode hash") - return - } - - let invoice = Bolt11Invoice( - raw: "lnbc-test", - hrp: "lnbc", - network: .bitcoin, - amountMillisats: 1000, - timestamp: Date(timeIntervalSince1970: 100), - tags: [ - .paymentHash(Data(repeating: 0x01, count: 32)), - .descriptionHash(hashData), - .expiry(3600) - ], - signature: Data(repeating: 0x00, count: 65) - ) - - XCTAssertThrowsError( - try ZapSecurityGuard.validate( - invoice: invoice, - zapRequest: event, - expectedAmountMsat: 2000, - now: Date(timeIntervalSince1970: 200) - ) - ) - } - - func testMissingDescriptionHashThrows() { - let event = NostrEvent( - id: "id", - pubkey: "abcdef0123456789", - created_at: 100, - kind: NostrEventKind.zapRequest.rawValue, - tags: [["amount", "2000"]], - content: "", - sig: "" - ) - - let invoice = Bolt11Invoice( - raw: "lnbc-test", - hrp: "lnbc", - network: .bitcoin, - amountMillisats: 2000, - timestamp: Date(timeIntervalSince1970: 100), - tags: [ - .paymentHash(Data(repeating: 0x01, count: 32)) - ], - signature: Data(repeating: 0x00, count: 65) - ) - - XCTAssertThrowsError( - try ZapSecurityGuard.validate( - invoice: invoice, - zapRequest: event, - expectedAmountMsat: 2000, - now: Date(timeIntervalSince1970: 200) - ) - ) - } - - func testExpiredInvoiceThrows() throws { - let event = NostrEvent( - id: "id", - pubkey: "abcdef0123456789", - created_at: 100, - kind: NostrEventKind.zapRequest.rawValue, - tags: [["amount", "2000"]], - content: "", - sig: "" - ) - let hashHex = try event.descriptionHash() - guard let hashData = Data(hex: hashHex) else { - XCTFail("Failed to decode hash") - return - } - - let invoice = Bolt11Invoice( - raw: "lnbc-test", - hrp: "lnbc", - network: .bitcoin, - amountMillisats: 2000, - timestamp: Date(timeIntervalSince1970: 100), - tags: [ - .paymentHash(Data(repeating: 0x01, count: 32)), - .descriptionHash(hashData), - .expiry(10) - ], - signature: Data(repeating: 0x00, count: 65) - ) - - XCTAssertThrowsError( - try ZapSecurityGuard.validate( - invoice: invoice, - zapRequest: event, - expectedAmountMsat: 2000, - now: Date(timeIntervalSince1970: 200) - ) - ) - } -} diff --git a/README.md b/README.md index 4a16d84..1dfdaa4 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,43 @@ -# Pulse +

+ Pulse ZERO +

+ +

Pulse ZERO v1

-**Decentralized messaging for iOS with Lightning payments.** +

Decentralized messaging for iOS.

+ +

+ + + + +

-A high-performance iOS messaging engine written 100% in **Swift**. Pulse facilitates peer-to-peer, decentralized communication without reliance on centralized servers. Built for the 2026 iOS ecosystem with secure key management, mesh networking, Lightning zaps, and real-time data streaming via Nostr relays. +

+A high-performance iOS messaging engine written 100% in Swift.
+Pulse ZERO facilitates peer-to-peer, decentralized communication without reliance on centralized servers.
+Built for the 2026 iOS ecosystem with secure key management, mesh networking, and real-time data streaming via Nostr relays. +

-> No servers. No silos. Just Pulse. +

No servers. No silos. Just Pulse.

--- -## πŸ’‘ The Vision +## The Vision -Pulse is inspired by **Bitchat** and the broader **Nostr** ecosystemβ€”protocols championed by Jack Dorsey and the open-source community. The goal is to move away from "platforms" and toward "protocols," ensuring that your identity and your conversations remain yours, regardless of who owns the network. +Pulse ZERO is inspired by **Bitchat** and the broader **Nostr** ecosystemβ€”protocols championed by Jack Dorsey and the open-source community. The goal is to move away from "platforms" and toward "protocols," ensuring that your identity and your conversations remain yours, regardless of who owns the network. This isn't just an app; it's a step toward sovereign communicationβ€”private, censorship-resistant, and entirely user-owned. --- -## ✨ Features +## How It Works + +Pulse ZERO uses a **dual-transport system** to deliver messages. When peers are nearby, messages travel directly over **Bluetooth LE and MultipeerConnectivity** β€” no internet required. For global reach, messages route through **Nostr relays** over WebSocket connections. Both paths are end-to-end encrypted and signature-verified, so your messages stay private regardless of which transport carries them. A unified routing layer handles deduplication, acknowledgements, and multi-hop forwarding automatically. + +--- + +## Features ### Core Messaging | Category | What Pulse Does | @@ -28,191 +49,148 @@ This isn't just an app; it's a step toward sovereign communicationβ€”private, ce | **Privacy Controls** | Toggles for link previews, discovery profile sharing, and data retention | | **Offline-First** | Local SwiftData persistence; works without internet | -### Lightning Network (NIP-57) -| Category | What Pulse Does | -|----------|-----------------| -| **Zap Requests** | Send Bitcoin tips via Lightning to any Nostr user | -| **Zap Receipts** | Receive and display incoming zaps on messages | -| **Lightning Addresses** | Support for `user@domain.com` style addresses | -| **Wallet Integration** | Opens Zeus, Muun, Phoenix, BlueWallet, or any BOLT11 wallet | -| **BOLT11 Validation** | Full invoice parsing and security verification | - ### Nostr Protocol | Category | What Pulse Does | |----------|-----------------| | **Relay Connections** | Connect to multiple Nostr relays for global reach | | **Event Signing** | secp256k1 Schnorr signatures for Nostr events | | **Location Channels** | Geohash-based public channels for local discovery | -| **Profile Metadata** | NIP-01 profile publishing with Lightning address support | +| **Profile Metadata** | NIP-01 profile publishing | | **NIP-42 Auth** | Relay authentication challenge/response | ### Security Hardening | Category | What Pulse Does | |----------|-----------------| -| **Invoice Security** | Three-way amount verification (UI β†’ Zap Request β†’ Invoice) | | **Signature Validation** | All Nostr events cryptographically verified | | **Rate Limiting** | DoS protection for relay events | | **Certificate Pinning** | TLS validation for all network connections | | **Clipboard Protection** | Auto-clear sensitive data after 30 seconds | | **Privacy UI** | `.privacySensitive()` modifiers hide data in app switcher | -| **Wallet URI Sanitization** | Prevents injection attacks in external wallet calls | | **Secure Keychain** | Keys stored with `WhenUnlockedThisDeviceOnly` access control | --- -## πŸ“Έ Screenshots +## Screenshots -![Pulse screenshot 1](media/screenshot-1.png) -![Pulse screenshot 2](media/screenshot-2.png) -![Pulse screenshot 3](media/screenshot-3.png) -![Pulse screenshot 4](media/screenshot-4.png) -![Pulse screenshot 5](media/screenshot-5.png) +

+ + + + + +

+ +## Walkthrough -## πŸŽ₯ Walkthrough +![Walkthrough preview](media/walkthrough.gif) -[Watch the walkthrough video](media/walkthrough.mp4) +[Watch the full video](media/walkthrough.mp4) --- -## πŸ—οΈ Architecture +## Architecture ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ SwiftUI Views β”‚ -β”‚ ChatView β”‚ ProfileView β”‚ SettingsView β”‚ ZapButton β”‚ Radar β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ ChatManager β”‚ ZapManager β”‚ MeshManager β”‚ IdentityManager β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ UnifiedTransportManager (Mesh + Nostr) β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ MultipeerConnectivity β”‚ BLE Advertiser β”‚ NostrTransport β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ LNURLService β”‚ Bolt11Validator β”‚ SecureNetworkSession β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ++----------------------------------------------------------------+ +| SwiftUI Views | +| ChatView | ProfileView | SettingsView | RadarView | ++----------------------------------------------------------------+ +| ChatManager | MeshManager | IdentityManager | ++----------------------------------------------------------------+ +| UnifiedTransportManager (Mesh + Nostr) | ++----------------------------------------------------------------+ +| MultipeerConnectivity | BLE Advertiser | NostrTransport | ++----------------------------------------------------------------+ +| SecureNetworkSession | NostrEventValidator | RateLimiter | ++----------------------------------------------------------------+ ``` ### Key Components -- **Managers/** – Business logic (chat, mesh, identity, zaps, persistence) -- **Networking/** – Transport protocols, Nostr relay connections, LNURL/BOLT11 handling -- **Models/** – Data types (Message, PulsePeer, NostrIdentity, Zap) -- **Views/** – SwiftUI interface with Liquid Glass design -- **Utilities/** – Clipboard security, debug logging, avatar management +- **Managers/** β€” Business logic (chat, mesh, identity, persistence) +- **Networking/** β€” Transport protocols, Nostr relay connections +- **Models/** β€” Data types (Message, PulsePeer, NostrIdentity) +- **Views/** β€” SwiftUI interface with Liquid Glass design +- **Utilities/** β€” Clipboard security, debug logging, avatar management -### Security Components +--- -| Component | Purpose | -|-----------|---------| -| `Bolt11Validator` | Parses and validates Lightning invoices | -| `NostrEventValidator` | Validates event signatures and format | -| `ZapSecurityGuard` | Three-way amount verification | -| `WalletURISanitizer` | Sanitizes wallet deep links | -| `SecureNetworkSession` | TLS certificate validation | -| `ClipboardManager` | Auto-clears sensitive clipboard data | -| `RateLimiter` | Prevents event flooding | +## Requirements ---- +- Xcode 26+ +- iOS 26.0+ +- Swift 5.0 -## πŸš€ Getting Started +## Getting Started 1. Clone the repo -2. Open `Pulse/Pulse.xcodeproj` in Xcode 26+ +2. Open `Pulse/Pulse.xcodeproj` in Xcode 3. Select an iOS 26 simulator or device 4. Run the `Pulse` scheme ```bash -git clone https://github.com/JesseRod329/Pulse-Messaging-.git -cd Pulse-Messaging-/Pulse +git clone https://github.com/joeynyc/Pulse-ZERO-v1.git +cd Pulse-ZERO-v1/Pulse open Pulse.xcodeproj ``` -### Lightning Wallet Setup - -To send zaps, you'll need a Lightning wallet installed: -- **Zeus** (recommended) - Full node control -- **Phoenix** - Simple and automatic -- **Muun** - Bitcoin + Lightning -- **BlueWallet** - Multi-wallet support - --- -## πŸ§ͺ Tests +## Tests ```bash xcodebuild -project Pulse.xcodeproj -scheme PulseTests \ -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,OS=26.0,name=iPhone 17' \ + -destination 'platform=iOS Simulator,OS=26.2,name=iPhone 17' \ test ``` -### Test Suite - -| Test File | Coverage | -|-----------|----------| -| `PulseIdentityTests` | Identity creation, encryption, signing | -| `Bolt11ValidatorTests` | Invoice parsing, malicious input rejection | -| `Bolt11ParserTests` | BOLT11 field extraction | -| `NostrNormalizationTests` | Deterministic JSON for NIP-57 | -| `SecurityHardeningTests` | Rate limiting, URI sanitization | -| `ProductionSecurityTests` | End-to-end security scenarios | -| `MeshSimulatorTests` | Virtual peer network testing | - ---- - -## πŸ“š Documentation - -| Doc | Description | -|-----|-------------| -| [PULSE_iOS26_ARCHITECTURE.md](PULSE_iOS26_ARCHITECTURE.md) | Technical deep-dive into the system design | -| [PULSE_AUDIT_REPORT.md](PULSE_AUDIT_REPORT.md) | Security audit findings and remediations | -| [BITCOIN_PLAN.md](BITCOIN_PLAN.md) | Lightning integration security hardening plan | -| [IMPROVEMENTS_SUMMARY.md](IMPROVEMENTS_SUMMARY.md) | Changelog of major improvements | -| [QUICK_START.md](QUICK_START.md) | Fast-track setup guide | - --- -## πŸ” Security Model - -### Threat Mitigations +## Security Model | Threat | Mitigation | |--------|------------| -| Invoice Swapping | BOLT11 amount verification against zap request | -| Fake Zap Receipts | Schnorr signature validation on all receipts | -| Wallet URI Injection | Strict scheme whitelist + character filtering | | Relay Event Flooding | Fixed-window rate limiter (60 events/sec) | | MITM Attacks | Certificate validation on all HTTPS/WSS connections | | Clipboard Sniffing | Auto-clear after 30s + clear on background | | Key Extraction | Keychain with biometric/device-only access | -### Cryptographic Primitives - -- **Encryption**: Curve25519 (X25519) key exchange + ChaCha20-Poly1305 -- **Signing**: Ed25519 for mesh messages, secp256k1 Schnorr for Nostr -- **Hashing**: SHA-256 for event IDs and description hashes -- **Key Storage**: iOS Keychain with `.whenUnlockedThisDeviceOnly` - --- -## πŸ™ Inspiration & Credits +## Inspiration & Credits -Pulse draws heavily from: -- **[Nostr](https://nostr.com/)** – The decentralized social protocol -- **Bitchat** – Jack Dorsey's vision for open, censorship-resistant messaging -- **[secp256k1](https://github.com/bitcoin-core/secp256k1)** – Elliptic curve cryptography -- **[NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md)** – Lightning Zaps specification -- **[BOLT11](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md)** – Lightning invoice format +Pulse ZERO draws heavily from: +- **[Nostr](https://nostr.com/)** β€” The decentralized social protocol +- **Bitchat** β€” Jack Dorsey's vision for open, censorship-resistant messaging +- **[secp256k1](https://github.com/bitcoin-core/secp256k1)** β€” Elliptic curve cryptography This project exists because open protocols matter. --- -## πŸ“„ License +## Contributing + +PRs welcome. Please open an issue first to discuss what you'd like to change. + +--- + +## Disclaimer + +Pulse ZERO is provided **for educational and lawful use only**. This software is designed for private, peer-to-peer communication over open protocols. Users are solely responsible for ensuring their use of this software complies with all applicable local, state, federal, and international laws. The authors do not condone and are not responsible for any illegal use of this software, including but not limited to unauthorized surveillance, harassment, or distribution of prohibited content. + +**This software is provided "as is" without warranty of any kind.** See the [LICENSE](LICENSE) for full terms. + +## AI & API Compliance + +Development of Pulse ZERO uses AI-assisted tooling (Claude Code by Anthropic). All AI usage complies with [Anthropic's Acceptable Use Policy](https://www.anthropic.com/policies/aup) and [Terms of Service](https://www.anthropic.com/policies/terms). No AI models are embedded in or distributed with this application. + +## License MIT License. See [LICENSE](LICENSE) for details. ---

- Built with ❀️ by Jesse Rodriguez + Built by Jesse Rodriguez & Joey Rodriguez

diff --git a/media/logo.png b/media/logo.png new file mode 100644 index 0000000..52353bd Binary files /dev/null and b/media/logo.png differ diff --git a/media/screenshot-1.png b/media/screenshot-1.png index ede3f93..3746372 100644 Binary files a/media/screenshot-1.png and b/media/screenshot-1.png differ diff --git a/media/screenshot-2.png b/media/screenshot-2.png index e6b43a9..2246821 100644 Binary files a/media/screenshot-2.png and b/media/screenshot-2.png differ diff --git a/media/screenshot-3.png b/media/screenshot-3.png index 09d1546..a113021 100644 Binary files a/media/screenshot-3.png and b/media/screenshot-3.png differ diff --git a/media/screenshot-4.png b/media/screenshot-4.png index 7da7b0f..3c7af87 100644 Binary files a/media/screenshot-4.png and b/media/screenshot-4.png differ diff --git a/media/screenshot-5.png b/media/screenshot-5.png index 375cd5e..015e01e 100644 Binary files a/media/screenshot-5.png and b/media/screenshot-5.png differ diff --git a/media/walkthrough.gif b/media/walkthrough.gif new file mode 100644 index 0000000..26ea13e Binary files /dev/null and b/media/walkthrough.gif differ diff --git a/media/walkthrough.mp4 b/media/walkthrough.mp4 index d0b2887..dbc53b5 100644 Binary files a/media/walkthrough.mp4 and b/media/walkthrough.mp4 differ