damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 22f2aba96979c0f36d2b7165db08635db43774f4
parent 98f2777fdaa280ba15bb0594deb3361bc53b80eb
Author: ericholguin <ericholguin@apache.org>
Date:   Thu,  6 Feb 2025 14:06:27 -0700

nwc: Wallet Redesign

This PR redesigns the NWC wallet view. A new view is added to introduce zaps to users. The set up wallet view is simplified, with new and existing wallet setup separated.
This also adds new NWC features such as getBalance and listTransactions allowing users to see their balance and previous transactions made.

Changelog-Added: Added view introducing users to Zaps
Changelog-Added: Added new wallet view with balance and transactions list
Changelog-Changed: Improved integration with Nostr Wallet Connect wallets
Closes: https://github.com/damus-io/damus/issues/2900

Signed-off-by: ericholguin <ericholguin@apache.org>
Co-Authored-By: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mdamus/Components/NoteZapButton.swift | 4++--
Mdamus/ContentView.swift | 2++
Mdamus/Models/HomeModel.swift | 20++++++++++++++++----
Mdamus/Models/WalletModel.swift | 30++++++++++++++++++++++++++++++
Adamus/NIP04/NIP04.swift | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/NostrEvent+.swift | 2+-
Mdamus/Nostr/NostrResponse.swift | 2+-
Adamus/Util/ExtraFonts.swift | 15+++++++++++++++
Mdamus/Util/Log.swift | 2++
Ddamus/Util/WalletConnect+.swift | 118-------------------------------------------------------------------------------
Ddamus/Util/WalletConnect.swift | 155-------------------------------------------------------------------------------
Adamus/Util/WalletConnect/Request.swift | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/WalletConnect/Response.swift | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/WalletConnect/WalletConnect+.swift | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/WalletConnect/WalletConnect.swift | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/DMChatView.swift | 42+-----------------------------------------
Adamus/Views/Wallet/BalanceView.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Wallet/ConnectWalletView.swift | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Adamus/Views/Wallet/NWCSettings.swift | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Wallet/TransactionsView.swift | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Wallet/WalletView.swift | 231+++++++++++++++----------------------------------------------------------------
Adamus/Views/Wallet/ZapExplainer.swift | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MdamusTests/WalletConnectTests.swift | 2+-
24 files changed, 1573 insertions(+), 600 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -408,10 +408,22 @@ 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; }; 5C6E1DAF2A194075008FC15A /* PinkGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */; }; 5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; }; + 5C8498022D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; + 5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; + 5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; 5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */; }; 5CB017212D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; }; 5CB017222D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; }; 5CB017232D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; }; + 5CB017252D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; }; + 5CB017262D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; }; + 5CB017272D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; }; + 5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; }; + 5CB0172E2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; }; + 5CB0172F2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; }; + 5CB017312D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; }; + 5CB017322D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; }; + 5CB017332D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; }; 5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */; }; 5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */; }; 5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */; }; @@ -1085,6 +1097,9 @@ D7373BA62B688EA300F7783D /* DamusPurpleTranslationSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA52B688EA200F7783D /* DamusPurpleTranslationSetupView.swift */; }; D7373BA82B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA72B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift */; }; D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA92B68A65A00F7783D /* PurpleAccountUpdateNotify.swift */; }; + D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; }; + D73B74E22D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; }; + D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; }; D73E5E162C6A9619007EB227 /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; }; D73E5E172C6A962A007EB227 /* ImageUploadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */; }; D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; }; @@ -1481,6 +1496,18 @@ D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = D78DB8582C1CE9CA00F0AB12 /* SwipeActions */; }; D78DB85B2C20FE5000F0AB12 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; }; D78DB85F2C20FED300F0AB12 /* ChatBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */; }; + D78F080C2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F080D2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F080E2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F080F2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F08112D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08122D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08132D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08142D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08172D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; + D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; + D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; + D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; }; D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; }; @@ -2368,8 +2395,12 @@ 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; }; 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinkGradient.swift; sourceTree = "<group>"; }; 5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = "<group>"; }; + 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapExplainer.swift; sourceTree = "<group>"; }; 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineView.swift; sourceTree = "<group>"; }; 5CB017202D2D985800A9ED05 /* CoinosButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosButton.swift; sourceTree = "<group>"; }; + 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsView.swift; sourceTree = "<group>"; }; + 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = "<group>"; }; + 5CB017302D4422D600A9ED05 /* NWCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCSettings.swift; sourceTree = "<group>"; }; 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightEvent.swift; sourceTree = "<group>"; }; 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightView.swift; sourceTree = "<group>"; }; 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDescription.swift; sourceTree = "<group>"; }; @@ -2451,6 +2482,7 @@ D7373BA52B688EA200F7783D /* DamusPurpleTranslationSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleTranslationSetupView.swift; sourceTree = "<group>"; }; D7373BA72B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNewUserOnboardingView.swift; sourceTree = "<group>"; }; D7373BA92B68A65A00F7783D /* PurpleAccountUpdateNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleAccountUpdateNotify.swift; sourceTree = "<group>"; }; + D73B74E02D8365B40067BDBC /* ExtraFonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraFonts.swift; sourceTree = "<group>"; }; D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAliases.swift; sourceTree = "<group>"; }; D73E5F802C6AA07A007EB227 /* HighlighterExtensionAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterExtensionAliases.swift; sourceTree = "<group>"; }; D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = "<group>"; }; @@ -2477,6 +2509,9 @@ D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; }; D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = "<group>"; }; D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = "<group>"; }; + D78F080B2D7F78EB00FC6C75 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; }; + D78F08102D7F78F600FC6C75 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = "<group>"; }; + D78F08162D7F7F6C00FC6C75 /* NIP04.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP04.swift; sourceTree = "<group>"; }; D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; }; D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; }; D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; }; @@ -3222,6 +3257,10 @@ 4C7D095A2A098C5C00943473 /* Wallet */ = { isa = PBXGroup; children = ( + 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */, + 5CB017302D4422D600A9ED05 /* NWCSettings.swift */, + 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */, + 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */, 4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */, 4C7D095D2A098C5D00943473 /* WalletView.swift */, 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */, @@ -3247,11 +3286,12 @@ 4C7FF7D628233637009601DB /* Util */ = { isa = PBXGroup; children = ( + D73B74E02D8365B40067BDBC /* ExtraFonts.swift */, D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */, D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */, E04A37C52B544F090029650D /* URIParsing.swift */, 4C1D4FB02A7958E60024F453 /* VersionInfo.swift */, - 4C7D09612A098D0E00943473 /* WalletConnect.swift */, + D78F080A2D7F78B000FC6C75 /* WalletConnect */, 4C198DF329F88D23004C165C /* Images */, 4C198DEA29F88C6B004C165C /* BlurHash */, 4CE4F0F329D779B5005914DB /* PostBox.swift */, @@ -3301,7 +3341,6 @@ D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */, D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */, D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */, - D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */, ); path = Util; sourceTree = "<group>"; @@ -3613,6 +3652,7 @@ children = ( D7DB1FDC2D5A77E500CF06DA /* NIP44 */, D755B28B2D3E7D6500BBEEFA /* NIP37 */, + D78F08152D7F7F5F00FC6C75 /* NIP04 */, 4C45E5002BED4CE10025A428 /* NIP10 */, 4C1D4FB32A7967990024F453 /* build-git-hash.txt */, 4CA3529C2A76AE47003BB08B /* Notify */, @@ -3953,6 +3993,25 @@ path = Chat; sourceTree = "<group>"; }; + D78F080A2D7F78B000FC6C75 /* WalletConnect */ = { + isa = PBXGroup; + children = ( + D78F08102D7F78F600FC6C75 /* Response.swift */, + D78F080B2D7F78EB00FC6C75 /* Request.swift */, + 4C7D09612A098D0E00943473 /* WalletConnect.swift */, + D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */, + ); + path = WalletConnect; + sourceTree = "<group>"; + }; + D78F08152D7F7F5F00FC6C75 /* NIP04 */ = { + isa = PBXGroup; + children = ( + D78F08162D7F7F6C00FC6C75 /* NIP04.swift */, + ); + path = NIP04; + sourceTree = "<group>"; + }; D79C4C152AFEB061003A41B4 /* DamusNotificationService */ = { isa = PBXGroup; children = ( @@ -4404,6 +4463,7 @@ D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */, 4C32B9572A9AD44700DC3548 /* Root.swift in Sources */, 4C3EA64428FF558100C48A62 /* sha256.c in Sources */, + 5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */, 504323A72A34915F006AE6DC /* RelayModel.swift in Sources */, 4CF0ABF62985CD5500D66079 /* UserSearch.swift in Sources */, 4C32B9542A9AD44700DC3548 /* FlatBuffersUtils.swift in Sources */, @@ -4602,6 +4662,7 @@ 4CE879522996B68900F758CC /* RelayType.swift in Sources */, 4CE8795B2996C47A00F758CC /* ZapsModel.swift in Sources */, 4C3A1D3729637E0500558C0F /* PreviewCache.swift in Sources */, + D78F08142D7F78F900FC6C75 /* Response.swift in Sources */, 4C3EA67528FF7A5A00C48A62 /* take.c in Sources */, 4C3AC7A12835A81400E1F516 /* SetupView.swift in Sources */, 4C06670128FC7C5900038D2A /* RelayView.swift in Sources */, @@ -4627,6 +4688,7 @@ 4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */, 4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */, 4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */, + D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */, 4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */, 4C3EA67928FF7ABF00C48A62 /* list.c in Sources */, 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */, @@ -4663,6 +4725,7 @@ 4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */, 4CA9276C2A2910D10098A105 /* ReplyPart.swift in Sources */, D7C6787E2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift in Sources */, + 5CB017252D42C5C400A9ED05 /* TransactionsView.swift in Sources */, 4CE1399029F0661A00AC6A0B /* RepostAction.swift in Sources */, 4CE1399229F0666100AC6A0B /* ShareActionButton.swift in Sources */, 4C42812C298C848200DBF26F /* TranslateView.swift in Sources */, @@ -4712,6 +4775,8 @@ 4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */, 3A4647CF2A413ADC00386AD8 /* CondensedProfilePicturesView.swift in Sources */, 5C14C29B2BBBA29C00079FD2 /* RelaySoftwareDetail.swift in Sources */, + 5CB017312D4422DB00A9ED05 /* NWCSettings.swift in Sources */, + D78F080D2D7F78EF00FC6C75 /* Request.swift in Sources */, D78DB85F2C20FED300F0AB12 /* ChatBubbleView.swift in Sources */, D2277EEA2A089BD5006C3807 /* Router.swift in Sources */, 4C9D6D162B1AA9C6004E5CD9 /* DisplayTabBarNotify.swift in Sources */, @@ -4791,6 +4856,7 @@ 4C9146FE2A2A87C200DDEA40 /* nostrscript.c in Sources */, 4C5F9118283D88E40052CD1C /* FollowingModel.swift in Sources */, 4C1A9A1A29DCA17E00516EAC /* ReplyCounter.swift in Sources */, + D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */, 50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */, 643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */, F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */, @@ -4809,6 +4875,7 @@ F7908E92298B0F0700AB113A /* RelayDetailView.swift in Sources */, 4C9147002A2A891E00DDEA40 /* error.c in Sources */, 4CE879552996BAB900F758CC /* RelayPaidDetail.swift in Sources */, + 5CB0172F2D42C76A00A9ED05 /* BalanceView.swift in Sources */, 4C1253602A76CF890004F4B8 /* ScrollToTopNotify.swift in Sources */, 4CA3529E2A76AE67003BB08B /* FollowNotify.swift in Sources */, 4CF0ABD42980996B00D66079 /* Report.swift in Sources */, @@ -4953,6 +5020,7 @@ 82D6FABC2CD99F7900C925F4 /* refmap.c in Sources */, 82D6FABD2CD99F7900C925F4 /* verifier.c in Sources */, 82D6FABE2CD99F7900C925F4 /* NdbProfile.swift in Sources */, + D78F08112D7F78F900FC6C75 /* Response.swift in Sources */, 82D6FABF2CD99F7900C925F4 /* NdbTagIterator.swift in Sources */, 82D6FAC02CD99F7900C925F4 /* NdbNote.swift in Sources */, 82D6FAC12CD99F7900C925F4 /* AsciiCharacter.swift in Sources */, @@ -4989,6 +5057,7 @@ 82D6FAE02CD99F7900C925F4 /* DisplayTabBarNotify.swift in Sources */, 82D6FAE12CD99F7900C925F4 /* BroadcastNotify.swift in Sources */, 82D6FAE22CD99F7900C925F4 /* ComposeNotify.swift in Sources */, + D73B74E22D8365BA0067BDBC /* ExtraFonts.swift in Sources */, 82D6FAE32CD99F7900C925F4 /* FollowedNotify.swift in Sources */, 82D6FAE42CD99F7900C925F4 /* FollowNotify.swift in Sources */, 82D6FAE52CD99F7900C925F4 /* LikedNotify.swift in Sources */, @@ -5021,6 +5090,7 @@ 82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */, 82D6FAFF2CD99F7900C925F4 /* NoteId.swift in Sources */, 82D6FB002CD99F7900C925F4 /* Referenced.swift in Sources */, + 5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */, 82D6FB012CD99F7900C925F4 /* Block.swift in Sources */, 82D6FB022CD99F7900C925F4 /* MigratedTypes.swift in Sources */, 82D6FB032CD99F7900C925F4 /* DamusDuration.swift in Sources */, @@ -5028,6 +5098,7 @@ 82D6FB052CD99F7900C925F4 /* MusicController.swift in Sources */, 82D6FB062CD99F7900C925F4 /* UserStatusView.swift in Sources */, 82D6FB072CD99F7900C925F4 /* UserStatus.swift in Sources */, + 5CB017262D42C5C400A9ED05 /* TransactionsView.swift in Sources */, 82D6FB082CD99F7900C925F4 /* UserStatusSheet.swift in Sources */, 82D6FB092CD99F7900C925F4 /* SearchHeaderView.swift in Sources */, 82D6FB0A2CD99F7900C925F4 /* DamusGradient.swift in Sources */, @@ -5073,6 +5144,7 @@ 82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */, 82D6FB332CD99F7900C925F4 /* Array.swift in Sources */, 82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */, + 5C8498022D5D150000F74FEB /* ZapExplainer.swift in Sources */, 82D6FB352CD99F7900C925F4 /* OffsetExtension.swift in Sources */, 82D6FB362CD99F7900C925F4 /* RelayFilters.swift in Sources */, 82D6FB372CD99F7900C925F4 /* RelayModelCache.swift in Sources */, @@ -5118,6 +5190,7 @@ 82D6FB5E2CD99F7900C925F4 /* CredentialHandler.swift in Sources */, 82D6FB5F2CD99F7900C925F4 /* KeyboardVisible.swift in Sources */, 82D6FB602CD99F7900C925F4 /* StringUtil.swift in Sources */, + D78F08172D7F7F7500FC6C75 /* NIP04.swift in Sources */, 82D6FB612CD99F7900C925F4 /* Router.swift in Sources */, 82D6FB622CD99F7900C925F4 /* Log.swift in Sources */, 82D6FB632CD99F7900C925F4 /* AVPlayer+Additions.swift in Sources */, @@ -5347,6 +5420,7 @@ 82D6FC432CD99F7900C925F4 /* ReactionView.swift in Sources */, 82D6FC442CD99F7900C925F4 /* EventActionBar.swift in Sources */, 82D6FC452CD99F7900C925F4 /* EventDetailBar.swift in Sources */, + D78F080C2D7F78EF00FC6C75 /* Request.swift in Sources */, 82D6FC462CD99F7900C925F4 /* ShareAction.swift in Sources */, 82D6FC472CD99F7900C925F4 /* RepostAction.swift in Sources */, 82D6FC482CD99F7900C925F4 /* ShareActionButton.swift in Sources */, @@ -5372,6 +5446,7 @@ 82D6FC5A2CD99F7900C925F4 /* QRScanNSECView.swift in Sources */, 82D6FC5B2CD99F7900C925F4 /* NoteContentView.swift in Sources */, 82D6FC5C2CD99F7900C925F4 /* PostButton.swift in Sources */, + 5CB017322D4422DB00A9ED05 /* NWCSettings.swift in Sources */, 82D6FC5D2CD99F7900C925F4 /* PostView.swift in Sources */, 82D6FC5E2CD99F7900C925F4 /* AttachMediaUtility.swift in Sources */, 82D6FC5F2CD99F7900C925F4 /* MediaPicker.swift in Sources */, @@ -5523,6 +5598,7 @@ D73E5E8A2C6A97F4007EB227 /* PurpleStoreKitManager.swift in Sources */, D73E5E8D2C6A97F4007EB227 /* CameraService+Extensions.swift in Sources */, D73E5E8E2C6A97F4007EB227 /* ImageResizer.swift in Sources */, + D78F080E2D7F78EF00FC6C75 /* Request.swift in Sources */, D73E5E8F2C6A97F4007EB227 /* PhotoCaptureProcessor.swift in Sources */, D773BC602C6D538500349F0A /* CommentItem.swift in Sources */, D73E5E902C6A97F4007EB227 /* VideoCaptureProcessor.swift in Sources */, @@ -5537,6 +5613,7 @@ D73E5E992C6A97F4007EB227 /* Liked.swift in Sources */, D73E5E9A2C6A97F4007EB227 /* ProfileUpdate.swift in Sources */, D73E5E9B2C6A97F4007EB227 /* PostBlock.swift in Sources */, + 5CB017332D4422DB00A9ED05 /* NWCSettings.swift in Sources */, D73E5E9C2C6A97F4007EB227 /* Reply.swift in Sources */, D73E5E9D2C6A97F4007EB227 /* SearchModel.swift in Sources */, D73E5E9E2C6A97F4007EB227 /* NostrFilter+Hashable.swift in Sources */, @@ -5544,6 +5621,7 @@ D73E5F912C6AA71B007EB227 /* InputDismissKeyboard.swift in Sources */, D73E5E9F2C6A97F4007EB227 /* CreateAccountModel.swift in Sources */, D73E5EA12C6A97F4007EB227 /* SignalModel.swift in Sources */, + 5CB017272D42C5C400A9ED05 /* TransactionsView.swift in Sources */, D73E5EA22C6A97F4007EB227 /* FollowTarget.swift in Sources */, D73E5EA32C6A97F4007EB227 /* BookmarksManager.swift in Sources */, D73E5EA42C6A97F4007EB227 /* EventsModel.swift in Sources */, @@ -5551,6 +5629,7 @@ D73E5EA62C6A97F4007EB227 /* FollowersModel.swift in Sources */, D73E5EA72C6A97F4007EB227 /* SearchHomeModel.swift in Sources */, D73E5EA82C6A97F4007EB227 /* DirectMessageModel.swift in Sources */, + D78F08132D7F78F900FC6C75 /* Response.swift in Sources */, D73E5EA92C6A97F4007EB227 /* Report.swift in Sources */, D73E5EAA2C6A97F4007EB227 /* ZapsModel.swift in Sources */, D73E5EAB2C6A97F4007EB227 /* DraftsModel.swift in Sources */, @@ -5626,10 +5705,12 @@ D73E5EF22C6A97F4007EB227 /* DamusPurpleURLSheetView.swift in Sources */, D73E5EF32C6A97F4007EB227 /* DamusPurpleVerifyNpubView.swift in Sources */, D73E5EF42C6A97F4007EB227 /* DamusPurpleAccountView.swift in Sources */, + 5CB0172E2D42C76A00A9ED05 /* BalanceView.swift in Sources */, D73E5EF52C6A97F4007EB227 /* DamusPurpleNewUserOnboardingView.swift in Sources */, D73E5EF62C6A97F4007EB227 /* SearchingEventView.swift in Sources */, D73E5EF72C6A97F4007EB227 /* PullDownSearch.swift in Sources */, D73E5EF82C6A97F4007EB227 /* NotificationsView.swift in Sources */, + D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */, D73E5EF92C6A97F4007EB227 /* EventGroupView.swift in Sources */, D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */, D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */, @@ -5684,6 +5765,7 @@ D73E5F292C6A97F4007EB227 /* ReplyDescription.swift in Sources */, D73E5F2A2C6A97F4007EB227 /* RelativeTime.swift in Sources */, D73E5F732C6A9885007EB227 /* TestData.swift in Sources */, + D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */, D73E5F2B2C6A97F4007EB227 /* ReplyPart.swift in Sources */, D73E5F2C2C6A97F4007EB227 /* ProxyView.swift in Sources */, D73E5F2D2C6A97F4007EB227 /* SelectedEventView.swift in Sources */, @@ -5773,6 +5855,7 @@ D703D7A32C670E1D00A400EA /* nostr_bech32.c in Sources */, D703D7992C670DF900A400EA /* sha256.c in Sources */, D703D7972C670DED00A400EA /* wasm.c in Sources */, + 5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */, D703D7842C670C4700A400EA /* SequenceUtils.swift in Sources */, D703D7912C670D1E00A400EA /* DisplayName.swift in Sources */, D703D7B02C6710A500A400EA /* Root.swift in Sources */, @@ -5985,6 +6068,7 @@ D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */, D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, D7CE1B272B0BE224002EDAD4 /* bech32_util.c in Sources */, + D78F08122D7F78F900FC6C75 /* Response.swift in Sources */, D7CCFC102B05880F00323D86 /* Id.swift in Sources */, D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */, D7EDED282B1180940018B19C /* ImageUploadModel.swift in Sources */, @@ -5994,9 +6078,11 @@ D7CE1B332B0BE6DE002EDAD4 /* Nostr.swift in Sources */, D7CE1B3D2B0BE719002EDAD4 /* Verifiable.swift in Sources */, D7CE1B382B0BE719002EDAD4 /* VeriferOptions.swift in Sources */, + D78F080F2D7F78EF00FC6C75 /* Request.swift in Sources */, D7CCFC152B05891000323D86 /* Referenced.swift in Sources */, D7CE1B2B2B0BE243002EDAD4 /* hex.c in Sources */, D798D2222B08598A00234419 /* ReferencedId.swift in Sources */, + D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */, D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */, D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */, D7CE1B192B0BE132002EDAD4 /* builder.c in Sources */, diff --git a/damus/Components/NoteZapButton.swift b/damus/Components/NoteZapButton.swift @@ -232,7 +232,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust flusher = .once({ pe in // send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation Task { @MainActor in - await send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat) + await WalletConnect.send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat) } }) } @@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust // we don't have a delay on one-tap nozaps (since this will be from customize zap view) let delay = damus_state.settings.nozaps ? nil : 5.0 - let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher) + let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher) guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else { print("nwc: failed to send nwc request for zapreq \(reqid.reqid)") diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -367,7 +367,9 @@ struct ContentView: View { self.confirm_mute = true } .onReceive(handle_notify(.attached_wallet)) { nwc in + // Ensure to add NWC relay to the pool and connect it. try? damus_state.pool.add_relay(.nwc(url: nwc.relay)) + damus_state.pool.connect(to: [nwc.relay]) // update the lightning address on our profile when we attach a // wallet with an associated diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -260,7 +260,7 @@ class HomeModel: ContactsDelegate { // TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time guard let nwc_str = damus_state.settings.nostr_wallet_connect, let nwc = WalletConnectURL(str: nwc_str), - let resp = await FullWalletResponse(from: ev, nwc: nwc) else { + let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else { return } @@ -274,12 +274,24 @@ class HomeModel: ContactsDelegate { guard resp.response.error == nil else { print("nwc error: \(resp.response)") - nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) + WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) return } + if resp.response.result_type == .list_transactions { + Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString) + damus_state.wallet.handle_nwc_response(response: resp) + return + } + + if resp.response.result_type == .get_balance { + Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString) + damus_state.wallet.handle_nwc_response(response: resp) + return + } + print("nwc success: \(resp.response.result.debugDescription) [\(relay)]") - nwc_success(state: self.damus_state, resp: resp) + WalletConnect.handle_zap_success(state: self.damus_state, resp: resp) } } @@ -453,7 +465,7 @@ class HomeModel: ContactsDelegate { let nwc = WalletConnectURL(str: nwc_str), nwc.relay == relay_id { - subscribe_to_nwc(url: nwc, pool: pool) + WalletConnect.subscribe(url: nwc, pool: pool) } case .error(let merr): let desc = String(describing: merr) diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift @@ -13,10 +13,17 @@ enum WalletConnectState { case none } +/// Models and manages the user's NWC wallet based on the app's settings class WalletModel: ObservableObject { var settings: UserSettingsStore private(set) var previous_state: WalletConnectState var initial_percent: Int + /// The wallet's balance, in sats. + /// Starts with `nil` to signify it is not loaded yet + @Published private(set) var balance: Int64? = nil + /// The list of NWC transactions made in the wallet + /// Starts with `nil` to signify it is not loaded yet + @Published private(set) var transactions: [WalletConnect.Transaction]? = nil @Published private(set) var connect_state: WalletConnectState @@ -61,4 +68,27 @@ class WalletModel: ObservableObject { self.connect_state = .existing(nwc) self.previous_state = .existing(nwc) } + + /// Handles an NWC response event and updates the model. + /// + /// This takes a response received from the NWC relay and updates the internal state of this model. + /// + /// - Parameter response: The NWC response received from the network + func handle_nwc_response(response: WalletConnect.FullWalletResponse) { + switch response.response.result { + case .get_balance(let balanceResp): + self.balance = balanceResp.balance / 1000 + case .none: + return + case .some(.pay_invoice(_)): + return + case .list_transactions(let transactionsResp): + self.transactions = transactionsResp.transactions + } + } + + func resetWalletStateInformation() { + self.transactions = nil + self.balance = nil + } } diff --git a/damus/NIP04/NIP04.swift b/damus/NIP04/NIP04.swift @@ -0,0 +1,55 @@ +// +// NIP04.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-10. +// +import Foundation + +/// Functions and utilities for the NIP-04 spec +struct NIP04 {} + +extension NIP04 { + /// Encrypts a message using NIP-04. + static func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? { + let iv = random_bytes(count: 16).bytes + guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else { + return nil + } + let utf8_message = Data(message.utf8).bytes + guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else { + return nil + } + + switch encoding { + case .base64: + return encode_dm_base64(content: enc_message.bytes, iv: iv) + case .bech32: + return encode_dm_bech32(content: enc_message.bytes, iv: iv) + } + + } + + /// Creates an event with encrypted `contents` field, using NIP-04 + static func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: FullKeypair, created_at: UInt32, kind: UInt32) -> NostrEvent? { + let privkey = keypair.privkey + + guard let enc_content = encrypt_message(message: message, privkey: privkey, to_pk: to_pk) else { + return nil + } + + return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at) + } + + /// Creates a NIP-04 style direct message event + static func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent? + { + let created = created_at ?? UInt32(Date().timeIntervalSince1970) + + guard let keypair = keypair.to_full() else { + return nil + } + + return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4) + } +} diff --git a/damus/Nostr/NostrEvent+.swift b/damus/Nostr/NostrEvent+.swift @@ -68,7 +68,7 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, guard let note = NostrEvent(content: message, keypair: identity.to_keypair(), kind: 9733, tags: tags), let note_json = encode_json(note), - let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32) + let enc = NIP04.encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32) else { return nil } diff --git a/damus/Nostr/NostrResponse.swift b/damus/Nostr/NostrResponse.swift @@ -45,7 +45,7 @@ enum NostrResponse { static func owned_from_json(json: String) -> NostrResponse? { return json.withCString{ cstr in - let bufsize: Int = max(Int(Double(json.utf8.count) * 4.0), Int(getpagesize())) + let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize())) let data = malloc(bufsize) if data == nil { diff --git a/damus/Util/ExtraFonts.swift b/damus/Util/ExtraFonts.swift @@ -0,0 +1,15 @@ +// +// ExtraFonts.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-13. +// +import SwiftUI + +extension Font { + // Note: When changing the font size accessibility setting, these styles only update after an app restart. It's a current limitation of this. + + static let veryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 1.5, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect + static let veryVeryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 2.1, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect +} + diff --git a/damus/Util/Log.swift b/damus/Util/Log.swift @@ -15,6 +15,8 @@ enum LogCategory: String { case storage case networking case timeline + /// Logs related to Nostr Wallet Connect components + case nwc case push_notifications case damus_purple case image_uploading diff --git a/damus/Util/WalletConnect+.swift b/damus/Util/WalletConnect+.swift @@ -1,118 +0,0 @@ -// -// WalletConnect+.swift -// damus -// -// Created by Daniel D’Aquino on 2023-11-27. -// - -import Foundation - -func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest<PayInvoiceRequest> { - let data = PayInvoiceRequest(invoice: invoice) - return WalletRequest(method: "pay_invoice", params: data) -} - -func make_wallet_balance_request() -> WalletRequest<EmptyRequest> { - return WalletRequest(method: "get_balance", params: nil) -} - -struct EmptyRequest: Codable { -} - -struct PayInvoiceRequest: Codable { - let invoice: String -} - -func make_wallet_connect_request<T>(req: WalletRequest<T>, to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? { - let tags = [to_pk.tag] - let created_at = UInt32(Date().timeIntervalSince1970) - guard let content = encode_json(req) else { - return nil - } - return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194) -} - -func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) { - var filter = NostrFilter(kinds: [.nwc_response]) - filter.authors = [url.pubkey] - filter.limit = 0 - let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") - - pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false) -} - -@discardableResult -func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { - let req = make_wallet_pay_invoice_request(invoice: invoice) - guard let ev = make_wallet_connect_request(req: req, to_pk: url.pubkey, keypair: url.keypair) else { - return nil - } - - try? pool.add_relay(.nwc(url: url.relay)) - subscribe_to_nwc(url: url, pool: pool) - post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) - return ev -} - - -func nwc_success(state: DamusState, resp: FullWalletResponse) { - // find the pending zap and mark it as pending-confirmed - for kv in state.zaps.our_zaps { - let zaps = kv.value - - for zap in zaps { - guard case .pending(let pzap) = zap, - case .nwc(let nwc_state) = pzap.state, - case .postbox_pending(let nwc_req) = nwc_state.state, - nwc_req.id == resp.req_id - else { - continue - } - - if nwc_state.update_state(state: .confirmed) { - // notify the zaps model of an update so it can mark them as paid - state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send() - print("NWC success confirmed") - } - - return - } - } -} - -func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async { - let percent_f = Double(percent) / 100.0 - let donations_msats = Int64(percent_f * Double(base_msats)) - - let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus") - guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else { - // we failed... oh well. no donation for us. - print("damus-donation failed to fetch invoice") - return - } - - print("damus-donation donating...") - nwc_pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) -} - -func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) { - // find a pending zap with the nwc request id associated with this response and remove it - for kv in zapcache.our_zaps { - let zaps = kv.value - - for zap in zaps { - guard case .pending(let pzap) = zap, - case .nwc(let nwc_state) = pzap.state, - case .postbox_pending(let req) = nwc_state.state, - req.id == resp.req_id - else { - continue - } - - // remove the pending zap if there was an error - let reqid = ZapRequestId(from_pending: pzap) - remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) - return - } - } -} diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift @@ -1,155 +0,0 @@ -// -// WalletConnect.swift -// damus -// -// Created by William Casarin on 2023-03-22. -// - -import Foundation - -struct WalletConnectURL: Equatable { - static func == (lhs: WalletConnectURL, rhs: WalletConnectURL) -> Bool { - return lhs.keypair == rhs.keypair && - lhs.pubkey == rhs.pubkey && - lhs.relay == rhs.relay - } - - let relay: RelayURL - let keypair: FullKeypair - let pubkey: Pubkey - let lud16: String? - - func to_url() -> URL { - var urlComponents = URLComponents() - urlComponents.scheme = "nostrwalletconnect" - urlComponents.host = pubkey.hex() - urlComponents.queryItems = [ - URLQueryItem(name: "relay", value: relay.absoluteString), - URLQueryItem(name: "secret", value: keypair.privkey.hex()) - ] - - if let lud16 { - urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16)) - } - - return urlComponents.url! - } - - init?(str: String) { - guard let components = URLComponents(string: str), - components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect", - // The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats - let encoded_pubkey = components.path == "" ? components.host : components.path, - let pubkey = hex_decode_pubkey(encoded_pubkey), - let items = components.queryItems, - let relay = items.first(where: { qi in qi.name == "relay" })?.value, - let relay_url = RelayURL(relay), - let secret = items.first(where: { qi in qi.name == "secret" })?.value, - secret.utf8.count == 64, - let decoded = hex_decode(secret) - else { - return nil - } - - let privkey = Privkey(Data(decoded)) - guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil } - - let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value - let keypair = FullKeypair(pubkey: our_pk, privkey: privkey) - self = WalletConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16) - } - - init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) { - self.pubkey = pubkey - self.relay = relay - self.keypair = keypair - self.lud16 = lud16 - } -} - -struct WalletRequest<T: Codable>: Codable { - let method: String - let params: T? -} - -struct WalletResponseErr: Codable { - let code: String? - let message: String? -} - -struct PayInvoiceResponse: Decodable { - let preimage: String -} - -enum WalletResponseResultType: String { - case pay_invoice -} - -enum WalletResponseResult { - case pay_invoice(PayInvoiceResponse) -} - -struct FullWalletResponse { - let req_id: NoteId - let response: WalletResponse - - init?(from: NostrEvent, nwc: WalletConnectURL) async { - guard let note_id = from.referenced_ids.first else { - return nil - } - - self.req_id = note_id - - let ares = Task { - guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64), - let resp: WalletResponse = decode_json(json) - else { - let resp: WalletResponse? = nil - return resp - } - - return resp - } - - guard let res = await ares.value else { - return nil - } - - self.response = res - } - -} - -struct WalletResponse: Decodable { - let result_type: WalletResponseResultType - let error: WalletResponseErr? - let result: WalletResponseResult? - - private enum CodingKeys: CodingKey { - case result_type, error, result - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let result_type_str = try container.decode(String.self, forKey: .result_type) - - guard let result_type = WalletResponseResultType(rawValue: result_type_str) else { - throw DecodingError.typeMismatch(WalletResponseResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown")) - } - - self.result_type = result_type - self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error) - - guard self.error == nil else { - self.result = nil - return - } - - switch result_type { - case .pay_invoice: - let res = try container.decode(PayInvoiceResponse.self, forKey: .result) - self.result = .pay_invoice(res) - } - } -} - diff --git a/damus/Util/WalletConnect/Request.swift b/damus/Util/WalletConnect/Request.swift @@ -0,0 +1,137 @@ +// +// Request.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-10. +// + +import Foundation + +extension WalletConnect { + /// Models a request to an NWC wallet provider + enum Request: Codable { + /// Pay an invoice + case payInvoice( + /// bolt-11 invoice string + invoice: String + ) + /// Get the current wallet balance + case getBalance + /// Get the current wallet transaction history + case getTransactionList( + /// Starting timestamp in seconds since epoch (inclusive), optional. + from: UInt64?, + /// Ending timestamp in seconds since epoch (inclusive), optional. + until: UInt64?, + /// Maximum number of invoices to return, optional. + limit: Int?, + /// Offset of the first invoice to return, optional. + offset: Int?, + /// Include unpaid invoices, optional, default false. + unpaid: Bool?, + /// "incoming" for invoices, "outgoing" for payments, undefined for both. + type: String? + ) + + + // MARK: - Interface + + /// Converts the NWC request into a raw Nostr event to be sent in the network + /// + /// - Parameters: + /// - to_pk: The destination pubkey (used for encryption) + /// - keypair: The requester's pubkey (used for encryption and signing) + /// - Returns: The NWC request in a raw Nostr Event format, or nil if it cannot be encoded + func to_nostr_event(to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? { + let tags = [to_pk.tag] + let created_at = UInt32(Date().timeIntervalSince1970) + guard let content = encode_json(self) else { + return nil + } + return NIP04.create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: NostrKind.nwc_request.rawValue) + } + + // MARK: - Encoding and decoding + + /// Keys for top-level JSON + private enum CodingKeys: String, CodingKey { + case method + case params + } + + /// Keys for the JSON inside the "params" object + private enum ParamKeys: String, CodingKey { + case invoice + case from, until, limit, offset, unpaid, type + } + + /// Constants for possible request "method" verbs + private enum Method: String { + case payInvoice = "pay_invoice" + case getBalance = "get_balance" + case listTransactions = "list_transactions" + } + + /// Decodes a payload into this request structure + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let method = try container.decode(String.self, forKey: .method) + + + switch method { + case Method.payInvoice.rawValue: + let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + let invoice = try paramsContainer.decode(String.self, forKey: .invoice) + self = .payInvoice(invoice: invoice) + + case Method.getBalance.rawValue: + // No params to decode + self = .getBalance + + case Method.listTransactions.rawValue: + let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + let from = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .from) + let until = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .until) + let limit = try paramsContainer.decodeIfPresent(Int.self, forKey: .limit) + let offset = try paramsContainer.decodeIfPresent(Int.self, forKey: .offset) + let unpaid = try paramsContainer.decodeIfPresent(Bool.self, forKey: .unpaid) + let type = try paramsContainer.decodeIfPresent(String.self, forKey: .type) + self = .getTransactionList(from: from, until: until, limit: limit, offset: offset, unpaid: unpaid, type: type) + + default: + throw DecodingError.dataCorruptedError( + forKey: .method, + in: container, + debugDescription: "Unknown wallet method \"\(method)\"" + ) + } + } + + /// Encodes this request structure into a payload + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .payInvoice(let invoice): + try container.encode(Method.payInvoice.rawValue, forKey: .method) + var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + try paramsContainer.encode(invoice, forKey: .invoice) + + case .getBalance: + try container.encode(Method.getBalance.rawValue, forKey: .method) + // "params": null + try container.encodeNil(forKey: .params) + + case .getTransactionList(let from, let until, let limit, let offset, let unpaid, let type): + try container.encode(Method.listTransactions.rawValue, forKey: .method) + var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + try paramsContainer.encodeIfPresent(from, forKey: .from) + try paramsContainer.encodeIfPresent(until, forKey: .until) + try paramsContainer.encodeIfPresent(limit, forKey: .limit) + try paramsContainer.encodeIfPresent(offset, forKey: .offset) + try paramsContainer.encodeIfPresent(unpaid, forKey: .unpaid) + try paramsContainer.encodeIfPresent(type, forKey: .type) + } + } + } +} diff --git a/damus/Util/WalletConnect/Response.swift b/damus/Util/WalletConnect/Response.swift @@ -0,0 +1,110 @@ +// +// Response.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-10. +// + +extension WalletConnect { + /// Models a response from the NWC provider + struct Response: Decodable { + let result_type: Response.Result.ResultType + let error: WalletResponseErr? + let result: Response.Result? + + private enum CodingKeys: CodingKey { + case result_type, error, result + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let result_type_str = try container.decode(String.self, forKey: .result_type) + + guard let result_type = Response.Result.ResultType(rawValue: result_type_str) else { + throw DecodingError.typeMismatch(Response.Result.ResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown")) + } + + self.result_type = result_type + self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error) + + guard self.error == nil else { + self.result = nil + return + } + + switch result_type { + case .pay_invoice: + let res = try container.decode(Result.PayInvoiceResponse.self, forKey: .result) + self.result = .pay_invoice(res) + case .get_balance: + let res = try container.decode(Result.GetBalanceResponse.self, forKey: .result) + self.result = .get_balance(res) + case .list_transactions: + let res = try container.decode(Result.ListTransactionsResponse.self, forKey: .result) + self.result = .list_transactions(res) + } + } + } + + struct FullWalletResponse { + let req_id: NoteId + let response: Response + + init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async { + guard let note_id = from.referenced_ids.first else { + return nil + } + + self.req_id = note_id + + let ares = Task { + guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64), + let resp: WalletConnect.Response = decode_json(json) + else { + let resp: WalletConnect.Response? = nil + return resp + } + + return resp + } + + guard let res = await ares.value else { + return nil + } + + self.response = res + } + } + + struct WalletResponseErr: Codable { + let code: String? + let message: String? + } +} + +extension WalletConnect.Response { + /// The response data resulting from an NWC request + enum Result { + case pay_invoice(PayInvoiceResponse) + case get_balance(GetBalanceResponse) + case list_transactions(ListTransactionsResponse) + + enum ResultType: String { + case pay_invoice + case get_balance + case list_transactions + } + + struct PayInvoiceResponse: Decodable { + let preimage: String + } + + struct GetBalanceResponse: Decodable { + let balance: Int64 + } + + struct ListTransactionsResponse: Decodable { + let transactions: [WalletConnect.Transaction] + } + } +} diff --git a/damus/Util/WalletConnect/WalletConnect+.swift b/damus/Util/WalletConnect/WalletConnect+.swift @@ -0,0 +1,170 @@ +// +// WalletConnect+.swift +// damus +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +// TODO: Eventually we should move these convenience functions into structured classes responsible for managing this type of functionality, such as `WalletModel` + +extension WalletConnect { + /// Creates and sends a subscription to an NWC relay requesting NWC responses to be sent back. + /// + /// Notes: This assumes there is already a listener somewhere else + /// + /// - Parameters: + /// - url: The Nostr Wallet Connect URL containing connection info to the NWC wallet + /// - pool: The RelayPool to send the subscription request through + static func subscribe(url: WalletConnectURL, pool: RelayPool) { + var filter = NostrFilter(kinds: [.nwc_response]) + filter.authors = [url.pubkey] + filter.limit = 0 + let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") + + pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false) + } + + /// Sends out a request to pay an invoice to the NWC relay, and ensures that: + /// 1. the NWC relay is connected and we are listening to NWC events + /// 2. the NWC relay is connected and we are listening to NWC + /// + /// Note: This does not return information about whether the payment is succesful or not. The actual confirmation is handled elsewhere around `HomeModel` and `WalletModel` + /// + /// - Parameters: + /// - url: The NWC wallet connection URL + /// - pool: The relay pool to connect to + /// - post: The postbox to send events in + /// - delay: The delay before actually sending the request to the network _(this makes it possible to cancel a zap)_ + /// - on_flush: A callback to call after the event has been flushed to the network + /// - Returns: The Nostr Event that was sent to the network, representing the request that was made + @discardableResult + static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { + let req = WalletConnect.Request.payInvoice(invoice: invoice) + guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { + return nil + } + + try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected + WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay + post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) + return ev + } + + /// Sends out a wallet balance request to the NWC relay, and ensures that: + /// 1. the NWC relay is connected and we are listening to NWC events + /// 2. the NWC relay is connected and we are listening to NWC + /// + /// Note: This does not return the actual balance information. The actual balance is handled elsewhere around `HomeModel` and `WalletModel` + /// + /// - Parameters: + /// - url: The NWC wallet connection URL + /// - pool: The relay pool to connect to + /// - post: The postbox to send events in + /// - delay: The delay before actually sending the request to the network + /// - on_flush: A callback to call after the event has been flushed to the network + /// - Returns: The Nostr Event that was sent to the network, representing the request that was made + @discardableResult + static func request_balance_information(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? { + let req = WalletConnect.Request.getBalance + guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { + return nil + } + + try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected + WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay + post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) + return ev + } + + /// Sends out a wallet transaction list request to the NWC relay, and ensures that: + /// 1. the NWC relay is connected and we are listening to NWC events + /// 2. the NWC relay is connected and we are listening to NWC + /// + /// Note: This does not return the actual transaction list. The actual transaction list is handled elsewhere around `HomeModel` and `WalletModel` + /// + /// - Parameters: + /// - url: The NWC wallet connection URL + /// - pool: The relay pool to connect to + /// - post: The postbox to send events in + /// - delay: The delay before actually sending the request to the network + /// - on_flush: A callback to call after the event has been flushed to the network + /// - Returns: The Nostr Event that was sent to the network, representing the request that was made + @discardableResult + static func request_transaction_list(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? { + let req = WalletConnect.Request.getTransactionList(from: nil, until: nil, limit: 10, offset: 0, unpaid: false, type: "") + guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { + return nil + } + + try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected + WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay + post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) + return ev + } + + static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) { + // find the pending zap and mark it as pending-confirmed + for kv in state.zaps.our_zaps { + let zaps = kv.value + + for zap in zaps { + guard case .pending(let pzap) = zap, + case .nwc(let nwc_state) = pzap.state, + case .postbox_pending(let nwc_req) = nwc_state.state, + nwc_req.id == resp.req_id + else { + continue + } + + if nwc_state.update_state(state: .confirmed) { + // notify the zaps model of an update so it can mark them as paid + state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send() + print("NWC success confirmed") + } + + return + } + } + } + + /// Send a donation zap to the Damus team + static func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async { + let percent_f = Double(percent) / 100.0 + let donations_msats = Int64(percent_f * Double(base_msats)) + + let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus") + guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else { + // we failed... oh well. no donation for us. + print("damus-donation failed to fetch invoice") + return + } + + print("damus-donation donating...") + WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) + } + + /// Handles a received Nostr Wallet Connect error + static func handle_error(zapcache: Zaps, evcache: EventCache, resp: WalletConnect.FullWalletResponse) { + // find a pending zap with the nwc request id associated with this response and remove it + for kv in zapcache.our_zaps { + let zaps = kv.value + + for zap in zaps { + guard case .pending(let pzap) = zap, + case .nwc(let nwc_state) = pzap.state, + case .postbox_pending(let req) = nwc_state.state, + req.id == resp.req_id + else { + continue + } + + // remove the pending zap if there was an error + let reqid = ZapRequestId(from_pending: pzap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) + return + } + } + } +} diff --git a/damus/Util/WalletConnect/WalletConnect.swift b/damus/Util/WalletConnect/WalletConnect.swift @@ -0,0 +1,92 @@ +// +// WalletConnect.swift +// damus +// +// Created by William Casarin on 2023-03-22. +// + +import Foundation + +struct WalletConnect {} + +typealias WalletConnectURL = WalletConnect.ConnectURL // Declared to facilitate refactor + +extension WalletConnect { + /// Models a decoded NWC URL, containing information to connect to an NWC wallet. + struct ConnectURL: Equatable { + let relay: RelayURL + let keypair: FullKeypair + let pubkey: Pubkey + let lud16: String? + + static func == (lhs: ConnectURL, rhs: ConnectURL) -> Bool { + return lhs.keypair == rhs.keypair && + lhs.pubkey == rhs.pubkey && + lhs.relay == rhs.relay + } + + func to_url() -> URL { + var urlComponents = URLComponents() + urlComponents.scheme = "nostrwalletconnect" + urlComponents.host = pubkey.hex() + urlComponents.queryItems = [ + URLQueryItem(name: "relay", value: relay.absoluteString), + URLQueryItem(name: "secret", value: keypair.privkey.hex()) + ] + + if let lud16 { + urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16)) + } + + return urlComponents.url! + } + + init?(str: String) { + guard let components = URLComponents(string: str), + components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect", + // The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats + let encoded_pubkey = components.path == "" ? components.host : components.path, + let pubkey = hex_decode_pubkey(encoded_pubkey), + let items = components.queryItems, + let relay = items.first(where: { qi in qi.name == "relay" })?.value, + let relay_url = RelayURL(relay), + let secret = items.first(where: { qi in qi.name == "secret" })?.value, + secret.utf8.count == 64, + let decoded = hex_decode(secret) + else { + return nil + } + + let privkey = Privkey(Data(decoded)) + guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil } + + let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value + let keypair = FullKeypair(pubkey: our_pk, privkey: privkey) + self = ConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16) + } + + init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) { + self.pubkey = pubkey + self.relay = relay + self.keypair = keypair + self.lud16 = lud16 + } + } + + /// Models an NWC wallet transaction + struct Transaction: Decodable, Equatable, Hashable { + let type: String + let invoice: String? + let description: String? + let description_hash: String? + let preimage: String? + let payment_hash: String? + let amount: Int64 + let fees_paid: Int64? + let created_at: UInt64 // unixtimestamp, // invoice/payment creation time + let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable + let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid + //"metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + } +} + diff --git a/damus/Views/DMChatView.swift b/damus/Views/DMChatView.swift @@ -131,7 +131,7 @@ struct DMChatView: View, KeyboardReadable { .map(\.asString) .joined(separator: "") - guard let dm = create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else { + guard let dm = NIP04.create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else { print("error creating dm") return } @@ -176,46 +176,6 @@ struct DMChatView_Previews: PreviewProvider { } } -func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? { - let iv = random_bytes(count: 16).bytes - guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else { - return nil - } - let utf8_message = Data(message.utf8).bytes - guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else { - return nil - } - - switch encoding { - case .base64: - return encode_dm_base64(content: enc_message.bytes, iv: iv) - case .bech32: - return encode_dm_bech32(content: enc_message.bytes, iv: iv) - } - -} - -func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: FullKeypair, created_at: UInt32, kind: UInt32) -> NostrEvent? { - let privkey = keypair.privkey - - guard let enc_content = encrypt_message(message: message, privkey: privkey, to_pk: to_pk) else { - return nil - } - - return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at) -} - -func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent? -{ - let created = created_at ?? UInt32(Date().timeIntervalSince1970) - - guard let keypair = keypair.to_full() else { - return nil - } - - return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4) -} - extension View { /// Layers the given views behind this ``TextEditor``. func textEditorBackground<V>(@ViewBuilder _ content: () -> V) -> some View where V : View { diff --git a/damus/Views/Wallet/BalanceView.swift b/damus/Views/Wallet/BalanceView.swift @@ -0,0 +1,56 @@ +// +// BalanceView.swift +// damus +// +// Created by eric on 1/23/25. +// + +import SwiftUI + +struct BalanceView: View { + var balance: Int64? + + var body: some View { + VStack(spacing: 5) { + Text("Current balance", comment: "Label for displaying current wallet balance") + .foregroundStyle(DamusColors.neutral6) + if let balance { + self.numericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal)) + } + else { + // Make sure we do not show any numeric value to the user when still loading (or when failed to load) + // This is important because if we show a numeric value like "zero" when things are not loaded properly, we risk scaring the user into thinking that they have lost funds. + self.numericalBalanceView(text: "??") + .redacted(reason: .placeholder) + .shimmer(true) + } + } + } + + func numericalBalanceView(text: String) -> some View { + HStack { + Text(verbatim: text) + .lineLimit(1) + .minimumScaleFactor(0.70) + .font(.veryVeryLargeTitle) + .fontWeight(.heavy) + .foregroundStyle(PinkGradient) + + HStack(alignment: .top) { + Text("SATS", comment: "Abbreviation for Satoshis, smallest bitcoin unit") + .font(.caption) + .fontWeight(.heavy) + .foregroundStyle(PinkGradient) + } + } + .padding(.bottom) + } +} + +struct BalanceView_Previews: PreviewProvider { + static var previews: some View { + BalanceView(balance: 100000000) + BalanceView(balance: nil) + } +} + diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift @@ -15,13 +15,13 @@ struct ConnectWalletView: View { @State private var showAlert = false @State var error: String? = nil @State var wallet_scan_result: WalletScanResult = .scanning + @State var show_introduction: Bool = true var nav: NavigationCoordinator var body: some View { MainContent .navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for attaching Nostr Wallet Connect lightning wallet.")) .navigationBarTitleDisplayMode(.inline) - .padding() .onChange(of: wallet_scan_result) { res in scanning = false @@ -48,57 +48,137 @@ struct ConnectWalletView: View { } } - func AreYouSure(nwc: WalletConnectURL) -> some View { - VStack(spacing: 25) { - - Text("Are you sure you want to connect this wallet?", comment: "Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet.") - .fontWeight(.bold) - .multilineTextAlignment(.center) - - Text(nwc.relay.absoluteString) - .font(.body) - .foregroundColor(.gray) - - if let lud16 = nwc.lud16 { - Text(lud16) - .font(.body) - .foregroundColor(.gray) - } - - Button(action: { - model.connect(nwc) - }) { - HStack { - Text("Connect", comment: "Text for button to conect to Nostr Wallet Connect lightning wallet.") - .fontWeight(.semibold) + struct AreYouSure: View { + let nwc: WalletConnectURL + @Binding var show_introduction: Bool + @ObservedObject var model: WalletModel + + var body: some View { + ScrollView { + VStack(spacing: 25) { + + Text("Setup Wallet", comment: "Heading for wallet setup confirmation screen") + .font(.veryLargeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Spacer() + + ConnectGraphic + + Spacer() + + NWCSettings.AccountDetailsView(nwc: nwc) + + Spacer() + + Button(action: { + model.connect(nwc) + show_introduction = false + }) { + HStack { + Text("Connect", comment: "Text for button to conect to Nostr Wallet Connect lightning wallet.") + .fontWeight(.semibold) + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + + Button(action: { + model.cancel() + show_introduction = true + }) { + HStack { + Text("Cancel", comment: "Text for button to cancel out of connecting Nostr Wallet Connect lightning wallet.") + .padding() + } + .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + .buttonStyle(NeutralButtonStyle()) } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .padding(.bottom, 50) + .padding() } - .buttonStyle(GradientButtonStyle()) - - Button(action: { - model.cancel() - }) { - HStack { - Text("Cancel", comment: "Text for button to cancel out of connecting Nostr Wallet Connect lightning wallet.") - .padding() - } - .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + + var ConnectGraphic: some View { + HStack(spacing: 0) { + Button(action: {}, label: { + Image("damus-home") + .resizable() + .frame(width: 30, height: 30) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) + .disabled(true) + .padding(.horizontal, 30) + + Image("chevron-double-right") + .resizable() + .frame(width: 25, height: 25) + + Button(action: {}, label: { + Image("wallet") + .resizable() + .frame(width: 30, height: 30) + .foregroundStyle(LINEAR_GRADIENT) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) + .disabled(true) + .padding(.horizontal, 30) } - .buttonStyle(NeutralButtonStyle()) } } - var ConnectWallet: some View { - VStack(spacing: 25) { + var AutomaticSetup: some View { + VStack(spacing: 10) { + Text("AUTOMATIC SETUP", comment: "Heading for the section that performs an automatic wallet connection setup.") + .font(.caption) + .padding(.top) + .foregroundStyle(PinkGradient) - AlbyButton() { - openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!) - } + Text("Create new wallet", comment: "Button text for creating a new wallet.") + .font(.title) + .fontWeight(.bold) + + Text("Easily create a new wallet and attach it to your account.", comment: "Description for the create new wallet feature.") + .font(.body) + .multilineTextAlignment(.center) + + Spacer() CoinosButton() { + show_introduction = false openURL(URL(string:"https://coinos.io/settings/nostr")!) } + .padding() + } + .frame(minHeight: 250) + .padding(10) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 25) + .stroke(DamusColors.neutral3, lineWidth: 2) + .padding(2) // Avoids border clipping on the sides + ) + .padding(.top, 20) + } + + var ManualSetup: some View { + VStack(spacing: 10) { + Text("MANUAL SETUP", comment: "Label for manual wallet setup.") + .font(.caption) + .padding(.top) + .foregroundStyle(PinkGradient) + + Text("Use existing", comment: "Button text to use an existing wallet.") + .font(.title) + .fontWeight(.bold) + + Text("Attach to any third party provider you already use.", comment: "Information text guiding users on attaching existing provider.") + .font(.body) + .multilineTextAlignment(.center) + + Spacer() Button(action: { if let pasted_nwc = UIPasteboard.general.string { @@ -115,9 +195,10 @@ struct ConnectWalletView: View { Text("Paste NWC Address", comment: "Text for button to connect a lightning wallet.") .fontWeight(.semibold) } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .frame(minWidth: 250, maxWidth: .infinity, maxHeight: 15, alignment: .center) } .buttonStyle(GradientButtonStyle()) + .padding(.horizontal) Button(action: { nav.push(route: Route.WalletScanner(result: $wallet_scan_result)) @@ -127,74 +208,80 @@ struct ConnectWalletView: View { Text("Scan NWC Address", comment: "Text for button to connect a lightning wallet.") .fontWeight(.semibold) } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .frame(minWidth: 250, maxWidth: .infinity, maxHeight: 15, alignment: .center) } .buttonStyle(GradientButtonStyle()) - - - if let err = self.error { - Text(err) - .foregroundColor(.red) - } - } - } - - var TopSection: some View { - HStack(spacing: 0) { - Button(action: {}, label: { - Image("damus-home") - .resizable() - .frame(width: 30, height: 30) - }) - .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) - .disabled(true) - .padding(.horizontal, 30) - - Image("chevron-double-right") - .resizable() - .frame(width: 25, height: 25) - - Button(action: {}, label: { - Image("wallet") - .resizable() - .frame(width: 30, height: 30) - .foregroundStyle(LINEAR_GRADIENT) - }) - .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) - .disabled(true) - .padding(.horizontal, 30) + .padding(.horizontal) + .padding(.bottom) } + .frame(minHeight: 300) + .padding(10) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 25) + .stroke(DamusColors.neutral3, lineWidth: 2) + .padding(2) // Avoids border clipping on the sides + ) + .padding(.top, 20) } - var TitleSection: some View { - VStack(spacing: 25) { - Text("Damus Wallet", comment: "Title text for Damus Wallet view.") - .fontWeight(.bold) - - Text("Securely connect your Damus app to your wallet using Nostr Wallet Connect", comment: "Text to prompt user to connect their wallet using 'Nostr Wallet Connect'.") - .font(.caption) - .multilineTextAlignment(.center) + var ConnectWallet: some View { + ScrollView { + VStack(spacing: 25) { + + Text("Setup Wallet", comment: "Heading for Nostr Wallet Connect setup screen") + .font(.veryLargeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + AutomaticSetup + + ManualSetup + + if let err = self.error { + Text(err) + .foregroundColor(.red) + } + } + .padding(.bottom, 50) + .padding() } } var MainContent: some View { Group { - TopSection switch model.connect_state { case .new(let nwc): - AreYouSure(nwc: nwc) + AreYouSure(nwc: nwc, show_introduction: $show_introduction, model: self.model) + .onAppear() { + show_introduction = false + } case .existing: Text(verbatim: "Shouldn't happen") case .none: - TitleSection ConnectWallet } } + .fullScreenCover(isPresented: $show_introduction, content: { + ZapExplainerView(show_introduction: $show_introduction, nav: nav) + }) } } struct ConnectWalletView_Previews: PreviewProvider { static var previews: some View { ConnectWalletView(model: WalletModel(settings: UserSettingsStore()), nav: .init()) + .previewDisplayName("Main Wallet Connect View") + ConnectWalletView.AreYouSure(nwc: get_test_nwc(), show_introduction: .constant(false), model: WalletModel(settings: test_damus_state.settings)) + .previewDisplayName("Are you sure screen") + } + + static func get_test_nwc() -> WalletConnectURL { + let pk = "9d088f4760422443d4699b485e2ac66e565a2f5da1198c55ddc5679458e3f67a" + let sec = "ff2eefd57196d42089e1b42acc39916d7ecac52e0625bd70597bbd5be14aff18" + let relay = "wss://relay.getalby.com/v1" + let str = "nostrwalletconnect://\(pk)?relay=\(relay)&secret=\(sec)" + + return WalletConnectURL(str: str)! } } diff --git a/damus/Views/Wallet/NWCSettings.swift b/damus/Views/Wallet/NWCSettings.swift @@ -0,0 +1,227 @@ +// +// NWCSettings.swift +// damus +// +// Created by eric on 1/24/25. +// + +import SwiftUI + +struct NWCSettings: View { + + let damus_state: DamusState + let nwc: WalletConnectURL + @ObservedObject var model: WalletModel + @ObservedObject var settings: UserSettingsStore + + + func donation_binding() -> Binding<Double> { + return Binding(get: { + return Double(model.settings.donation_percent) + }, set: { v in + model.settings.donation_percent = Int(v) + }) + } + + static let min_donation: Double = 0.0 + static let max_donation: Double = 100.0 + + var percent: Double { + Double(model.settings.donation_percent) / 100.0 + } + + var tip_msats: String { + let msats = Int64(percent * Double(model.settings.default_zap_amount * 1000)) + let s = format_msats_abbrev(msats) + // TODO: fix formatting and remove this hack + let parts = s.split(separator: ".") + if parts.count == 1 { + return s + } + if let end = parts[safe: 1] { + if end.allSatisfy({ c in c.isNumber }) { + return String(parts[0]) + } else { + return s + } + } + return s + } + + var SupportDamus: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 20) + .fill(DamusGradient.gradient.opacity(0.5)) + + VStack(alignment: .leading, spacing: 20) { + HStack { + Image("logo-nobg") + .resizable() + .frame(width: 50, height: 50) + Text("Support Damus", comment: "Text calling for the user to support Damus through zaps") + .font(.title.bold()) + .foregroundColor(.white) + } + + Text("Help build the future of decentralized communication on the web.", comment: "Text indicating the goal of developing Damus which the user can help with.") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + + Text("An additional percentage of each zap will be sent to support Damus development", comment: "Text indicating that they can contribute zaps to support Damus development.") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + + let binding = donation_binding() + + HStack { + Slider(value: binding, + in: NWCSettings.min_donation...NWCSettings.max_donation, + label: { }) + Text("\(Int(binding.wrappedValue))%", comment: "Percentage of additional zap that should be sent to support Damus development.") + .font(.title.bold()) + .foregroundColor(.white) + .frame(width: 80) + } + + HStack{ + Spacer() + + VStack { + HStack { + Text("\(Image("zap.fill")) \(format_msats_abbrev(Int64(model.settings.default_zap_amount) * 1000))") + .font(.title) + .foregroundColor(percent == 0 ? .gray : .yellow) + .frame(width: 120) + } + + Text("Zap", comment: "Text underneath the number of sats indicating that it's the amount used for zaps.") + .foregroundColor(.white) + } + Spacer() + + Text(verbatim: "+") + .font(.title) + .foregroundColor(.white) + Spacer() + + VStack { + HStack { + Text("\(Image("zap.fill")) \(tip_msats)") + .font(.title) + .foregroundColor(percent == 0 ? .gray : Color.yellow) + .frame(width: 120) + } + + Text(verbatim: percent == 0 ? "🩶" : "💜") + .foregroundColor(.white) + } + Spacer() + } + + EventProfile(damus_state: damus_state, pubkey: damus_state.pubkey, size: .small) + } + .padding(25) + } + .frame(height: 370) + } + + var body: some View { + + VStack(alignment: .leading, spacing: 20) { + + SupportDamus + .padding(.bottom) + + AccountDetailsView(nwc: nwc) + + Button(action: { + self.model.disconnect() + }) { + HStack { + Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + } + .padding() + .onAppear() { + model.initial_percent = model.settings.donation_percent + } + .onChange(of: model.settings.donation_percent) { p in + let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) + guard let profile = profile_txn?.unsafeUnownedValue else { + return + } + + let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: p, reactions: profile.reactions) + + notify(.profile_updated(.manual(pubkey: self.damus_state.pubkey, profile: prof))) + } + .onDisappear { + let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) + + guard let keypair = damus_state.keypair.to_full(), + let profile = profile_txn?.unsafeUnownedValue, + model.initial_percent != profile.damus_donation + else { + return + } + + let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: model.settings.donation_percent, reactions: profile.reactions) + + guard let meta = make_metadata_event(keypair: keypair, metadata: prof) else { + return + } + damus_state.postbox.send(meta) + } + } + + struct AccountDetailsView: View { + let nwc: WalletConnect.ConnectURL + + var body: some View { + VStack(alignment: .leading) { + + Text("Account details", comment: "Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet.") + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.bottom) + + Text("Routing", comment: "Label indicating the routing address for Nostr Wallet Connect payments. In other words, the relay used by the NWC wallet provider") + .font(.headline) + + Text(nwc.relay.absoluteString) + .font(.body) + .fontWeight(.bold) + .foregroundColor(.gray) + .padding(.bottom) + + if let lud16 = nwc.lud16 { + Text("Account", comment: "Label for the user account information with the Nostr Wallet Connect wallet provider.") + .font(.headline) + + Text(lud16) + .font(.body) + .fontWeight(.bold) + .foregroundColor(.gray) + } + } + .frame(maxWidth: .infinity, minHeight: 250, alignment: .leading) + .padding(.horizontal, 20) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 25) + .stroke(DamusColors.neutral3, lineWidth: 2) + ) + } + } +} + +struct NWCSettings_Previews: PreviewProvider { + static let tds = test_damus_state + static var previews: some View { + NWCSettings(damus_state: tds, nwc: test_wallet_connect_url, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings), settings: tds.settings) + } +} diff --git a/damus/Views/Wallet/TransactionsView.swift b/damus/Views/Wallet/TransactionsView.swift @@ -0,0 +1,147 @@ +// +// TransactionsView.swift +// damus +// +// Created by eric on 1/23/25. +// + +import SwiftUI + +struct TransactionView: View { + + let damus_state: DamusState + var transaction: WalletConnect.Transaction + + var body: some View { + let txType = transaction.type == "incoming" ? "arrow-bottom-left" : "arrow-top-right" + let txColor = transaction.type == "incoming" ? DamusColors.success : Color.gray + let txOp = transaction.type == "incoming" ? "+" : "-" + let created_at = Date.init(timeIntervalSince1970: TimeInterval(transaction.created_at)) + let formatter = RelativeDateTimeFormatter() + let relativeDate = formatter.localizedString(for: created_at, relativeTo: Date.now) + let event = decode_nostr_event_json(transaction.description ?? "") + let pubkey = (event?.pubkey ?? ANON_PUBKEY) + + VStack(alignment: .leading) { + HStack(alignment: .center) { + ZStack { + ProfilePicView(pubkey: pubkey, size: 45, highlight: .custom(.damusAdaptableBlack, 0.1), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) + + Image(txType) + .resizable() + .frame(width: 18, height: 18) + .foregroundColor(.white) + .padding(2) + .background(txColor) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.damusAdaptableWhite, lineWidth: 1.0)) + .padding(.top, 25) + .padding(.leading, 35) + } + + VStack(alignment: .leading, spacing: 10) { + + Text(self.userDisplayName(pubkey: pubkey)) + .font(.headline) + .bold() + .foregroundColor(DamusColors.adaptableBlack) + + Text("\(relativeDate)") + .font(.caption) + .foregroundColor(Color.gray) + } + .padding(.horizontal, 10) + + Spacer() + + Text("\(txOp) \(transaction.amount/1000) sats") + .font(.headline) + .foregroundColor(txColor) + .bold() + } + .frame(maxWidth: .infinity, minHeight: 75, alignment: .center) + .padding(.horizontal, 10) + .background(DamusColors.neutral1) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(DamusColors.neutral3, lineWidth: 1) + ) + } + } + + func userDisplayName(pubkey: Pubkey) -> String { + let profile_txn = damus_state.profiles.lookup(id: pubkey, txn_name: "txview-profile") + let profile = profile_txn?.unsafeUnownedValue + + if let display_name = profile?.display_name { + return display_name + } else if let name = profile?.name { + return "@" + name + } else { + return NSLocalizedString("Unknown", comment: "A name label for an unknown user") + } + } + +} + +struct TransactionsView: View { + + let damus_state: DamusState + let transactions: [WalletConnect.Transaction]? + var sortedTransactions: [WalletConnect.Transaction]? { + transactions?.sorted(by: { $0.created_at > $1.created_at }) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Latest transactions", comment: "Heading for latest wallet transactions list") + .foregroundStyle(DamusColors.neutral6) + + if let sortedTransactions { + if sortedTransactions.isEmpty { + emptyTransactions + } else { + ForEach(sortedTransactions, id: \.self) { transaction in + TransactionView(damus_state: damus_state, transaction: transaction) + } + } + } + else { + // Make sure we do not show "No transactions yet" to the user when still loading (or when failed to load) + // This is important because if we show that when things are not loaded properly, we risk scaring the user into thinking that they have lost funds. + emptyTransactions + .redacted(reason: .placeholder) + .shimmer(true) + } + } + } + + var emptyTransactions: some View { + HStack { + Text("No transactions yet", comment: "Message shown when no transactions are available") + .foregroundStyle(DamusColors.neutral6) + } + .frame(maxWidth: .infinity, minHeight: 75, alignment: .center) + .padding(.horizontal, 10) + .background(DamusColors.neutral1) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(DamusColors.neutral3, lineWidth: 1) + ) + } +} + +struct TransactionsView_Previews: PreviewProvider { + static let tds = test_damus_state + static let transaction1: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "{\"id\":\"7c0999a5870ca3ba0186a29a8650152b555cee29b53b5b8747d8a3798042d01c\",\"pubkey\":\"b8851a06dfd79d48fc325234a15e9a46a32a0982a823b54cdf82514b9b120ba1\",\"created_at\":1736383715,\"kind\":9734,\"tags\":[[\"p\",\"520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626\"],[\"amount\",\"21000\"],[\"e\",\"a25e152a4cd1b3bbc3d22e8e9315d8ea1f35c227b2f212c7cff18abff36fa208\"],[\"relays\",\"wss://nos.lol\",\"wss://nostr.wine\",\"wss://premium.primal.net\",\"wss://relay.damus.io\",\"wss://relay.nostr.band\",\"wss://relay.nostrarabia.com\"]],\"content\":\"🫡 Onward!\",\"sig\":\"e77d16822fa21b9c2e6b580b51c470588052c14aeb222f08f0e735027e366157c8742a6d5cb850780c2bf44ac63d89b048e5cc56dd47a1bfc740a3173e578f4e\"}", description_hash: "", preimage: "", payment_hash: "1234567890", amount: 21000, fees_paid: 0, created_at: 1737736866, expires_at: 0, settled_at: 0) + static let transaction2: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789033", amount: 100000000, fees_paid: 0, created_at: 1737690090, expires_at: 0, settled_at: 0) + static let transaction3: WalletConnect.Transaction = WalletConnect.Transaction(type: "outgoing", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789042", amount: 303000, fees_paid: 0, created_at: 1737590101, expires_at: 0, settled_at: 0) + static let transaction4: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "1234567890662", amount: 720000, fees_paid: 0, created_at: 1737090300, expires_at: 0, settled_at: 0) + static var test_transactions: [WalletConnect.Transaction] = [transaction1, transaction2, transaction3, transaction4] + + static var previews: some View { + TransactionsView(damus_state: tds, transactions: test_transactions) + } +} diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift @@ -9,6 +9,7 @@ import SwiftUI struct WalletView: View { let damus_state: DamusState + @State var show_settings: Bool = false @ObservedObject var model: WalletModel @ObservedObject var settings: UserSettingsStore @@ -21,178 +22,20 @@ struct WalletView: View { func MainWalletView(nwc: WalletConnectURL) -> some View { ScrollView { VStack(spacing: 35) { - if !damus_state.settings.nozaps { - SupportDamus - .padding(.vertical, 20) - } - VStack(spacing: 5) { - VStack(spacing: 10) { - Text("Wallet Relay", comment: "Label text indicating that below it is the information about the wallet relay.") - .fontWeight(.semibold) - .padding(.top) - - Divider() - - RelayView(state: damus_state, relay: nwc.relay, showActionButtons: .constant(false), recommended: false) - } - .frame(maxWidth: .infinity, minHeight: 125, alignment: .top) - .padding(.horizontal, 10) - .background(DamusColors.neutral1) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(DamusColors.neutral3, lineWidth: 1) - ) - if let lud16 = nwc.lud16 { - VStack(spacing: 10) { - Text("Wallet Address", comment: "Label text indicating that below it is the wallet address.") - .fontWeight(.semibold) - - Divider() - - Text(lud16) - } - .frame(maxWidth: .infinity, minHeight: 75, alignment: .center) - .padding(.horizontal, 10) - .background(DamusColors.neutral1) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(DamusColors.neutral3, lineWidth: 1) - ) - } - } - - Button(action: { - self.model.disconnect() - }) { - HStack { - Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") - } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + BalanceView(balance: model.balance) + + TransactionsView(damus_state: damus_state, transactions: model.transactions) } - .buttonStyle(GradientButtonStyle()) - .padding(.bottom, 50) // Bottom padding while Scrolling - } .navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for Wallet view")) .navigationBarTitleDisplayMode(.inline) .padding() + .padding(.bottom, 50) } } - - func donation_binding() -> Binding<Double> { - return Binding(get: { - return Double(model.settings.donation_percent) - }, set: { v in - model.settings.donation_percent = Int(v) - }) - } - - static let min_donation: Double = 0.0 - static let max_donation: Double = 100.0 - - var percent: Double { - Double(model.settings.donation_percent) / 100.0 - } - - var tip_msats: String { - let msats = Int64(percent * Double(model.settings.default_zap_amount * 1000)) - let s = format_msats_abbrev(msats) - // TODO: fix formatting and remove this hack - let parts = s.split(separator: ".") - if parts.count == 1 { - return s - } - if let end = parts[safe: 1] { - if end.allSatisfy({ c in c.isNumber }) { - return String(parts[0]) - } else { - return s - } - } - return s - } - - var SupportDamus: some View { - ZStack(alignment: .topLeading) { - RoundedRectangle(cornerRadius: 20) - .fill(DamusGradient.gradient.opacity(0.5)) - - VStack(alignment: .leading, spacing: 20) { - HStack { - Image("logo-nobg") - .resizable() - .frame(width: 50, height: 50) - Text("Support Damus", comment: "Text calling for the user to support Damus through zaps") - .font(.title.bold()) - .foregroundColor(.white) - } - - Text("Help build the future of decentralized communication on the web.", comment: "Text indicating the goal of developing Damus which the user can help with.") - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - - Text("An additional percentage of each zap will be sent to support Damus development", comment: "Text indicating that they can contribute zaps to support Damus development.") - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - - let binding = donation_binding() - - HStack { - Slider(value: binding, - in: WalletView.min_donation...WalletView.max_donation, - label: { }) - Text("\(Int(binding.wrappedValue))%", comment: "Percentage of additional zap that should be sent to support Damus development.") - .font(.title.bold()) - .foregroundColor(.white) - .frame(width: 80) - } - - HStack{ - Spacer() - - VStack { - HStack { - Text("\(Image("zap.fill")) \(format_msats_abbrev(Int64(model.settings.default_zap_amount) * 1000))") - .font(.title) - .foregroundColor(percent == 0 ? .gray : .yellow) - .frame(width: 120) - } - - Text("Zap", comment: "Text underneath the number of sats indicating that it's the amount used for zaps.") - .foregroundColor(.white) - } - Spacer() - - Text(verbatim: "+") - .font(.title) - .foregroundColor(.white) - Spacer() - - VStack { - HStack { - Text("\(Image("zap.fill")) \(tip_msats)") - .font(.title) - .foregroundColor(percent == 0 ? .gray : Color.yellow) - .frame(width: 120) - } - - Text(verbatim: percent == 0 ? "🩶" : "💜") - .foregroundColor(.white) - } - Spacer() - } - - EventProfile(damus_state: damus_state, pubkey: damus_state.pubkey, size: .small) - } - .padding(25) - } - .frame(height: 370) - } - + var body: some View { switch model.connect_state { case .new: @@ -201,38 +44,50 @@ struct WalletView: View { ConnectWalletView(model: model, nav: damus_state.nav) case .existing(let nwc): MainWalletView(nwc: nwc) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { show_settings = true }, + label: { + Image("settings") + .foregroundColor(.gray) + } + ) + } + } .onAppear() { - model.initial_percent = settings.donation_percent + Task { await self.updateWalletInformation() } } - .onChange(of: settings.donation_percent) { p in - let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) - guard let profile = profile_txn?.unsafeUnownedValue else { - return - } - - let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: p, reactions: profile.reactions) - - notify(.profile_updated(.manual(pubkey: self.damus_state.pubkey, profile: prof))) + .refreshable { + model.resetWalletStateInformation() + await self.updateWalletInformation() } - .onDisappear { - let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) - - guard let keypair = damus_state.keypair.to_full(), - let profile = profile_txn?.unsafeUnownedValue, - model.initial_percent != profile.damus_donation - else { - return + .sheet(isPresented: $show_settings, onDismiss: { self.show_settings = false }) { + ScrollView { + NWCSettings(damus_state: damus_state, nwc: nwc, model: model, settings: settings) + .padding(.top, 30) } - - let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: settings.donation_percent, reactions: profile.reactions) - - guard let meta = make_metadata_event(keypair: keypair, metadata: prof) else { - return - } - damus_state.postbox.send(meta) + .presentationDragIndicator(.visible) + .presentationDetents([.large]) } } } + + @MainActor + func updateWalletInformation() async { + guard let url = damus_state.settings.nostr_wallet_connect, + let nwc = WalletConnectURL(str: url) else { + return + } + + let flusher: OnFlush? = nil + + let delay = 0.0 // We don't need a delay when fetching a transaction list or balance + + WalletConnect.request_transaction_list(url: nwc, pool: damus_state.pool, post: damus_state.postbox, delay: delay, on_flush: flusher) + WalletConnect.request_balance_information(url: nwc, pool: damus_state.pool, post: damus_state.postbox, delay: delay, on_flush: flusher) + return + } } let test_wallet_connect_url = WalletConnectURL(pubkey: test_pubkey, relay: .init("wss://relay.damus.io")!, keypair: test_damus_state.keypair.to_full()!, lud16: "jb55@sendsats.com") diff --git a/damus/Views/Wallet/ZapExplainer.swift b/damus/Views/Wallet/ZapExplainer.swift @@ -0,0 +1,203 @@ +// +// ZapExplainer.swift +// damus +// +// Created by eric on 2/12/25. +// + +import SwiftUI + +struct ZapExplainerView: View { + + @Binding var show_introduction: Bool + var nav: NavigationCoordinator + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ScrollView { + VStack { + Text("Get cash instantly from your followers", comment: "Feature description for receiving money instantly.") + .font(.veryLargeTitle) + .multilineTextAlignment(.center) + .padding(.top) + + VStack(alignment: .leading) { + GetPaid + Gift + GiveThanks + } + + WhyZaps + + ScrollView(.horizontal) { + HStack(spacing: 20) { + FindWallet + + LinkAccount + + StartReceiving + } + .padding(5) + } + .scrollIndicators(.hidden) + + Button(action: { + show_introduction = false + }) { + HStack { + Text("Set up wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + .padding(.top, 30) + + Button(action: { + nav.popToRoot() + }) { + HStack { + Text("Maybe later", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .padding() + } + .buttonStyle(NeutralButtonStyle()) + } + .padding(.bottom) + .padding(.horizontal) + } + .scrollIndicators(.never) + .background( + Image("eula-bg") + .resizable() + .blur(radius: 70) + .opacity(colorScheme == .light ? 0.6 : 1.0) + .ignoresSafeArea(), + alignment: .top + ) + } + + var GetPaid: some View { + self.benefitPoint( + imageName: "zap.fill", + heading: NSLocalizedString("Get paid for being you", comment: "Description for monetizing one's presence."), + description: NSLocalizedString("Setting up Zaps lets people know you're ready to start receiving money.", comment: "Information about enabling payments.") + ) + } + + var Gift: some View { + self.benefitPoint( + imageName: "gift", + heading: NSLocalizedString("Let your fans show their support", comment: "Heading pointing out a benefit of connecting a lightning wallet."), + description: NSLocalizedString("You drive the conversation and we want to make it easier for people to support your work beyond follows, reposts, and likes.", comment: "Text explaining the benefit of connecting a lightning wallet for content creators.") + ) + } + + var GiveThanks: some View { + self.benefitPoint( + imageName: "gift", + heading: NSLocalizedString("Give thanks", comment: "Heading explaining a benefit of connecting a lightning wallet."), + description: NSLocalizedString("When supporters tip with Zaps, they can add a note and we can make it easy for you to instantly reply to show your gratitude.", comment: "Description explaining a benefit of connecting a lightning wallet.") + ) + } + + func benefitPoint(imageName: String, heading: String, description: String) -> some View { + VStack(alignment: .leading) { + HStack(alignment: .top, spacing: 10) { + Button(action: {}, label: { + Image(imageName) + .resizable() + .frame(width: 25, height: 25) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), cornerRadius: 9999)) + .disabled(true) + + VStack(alignment: .leading, spacing: 10) { + Text(heading) + .font(.title2) + .fontWeight(.bold) + + Text(description) + .font(.body) + } + .padding(.top, 9) + } + } + .padding(.top) + } + + var WhyZaps: some View { + VStack(alignment: .leading, spacing: 15) { + Text("Why add Zaps?", comment: "Heading to explain the benefits of zaps.") + .font(.title) + .fontWeight(.bold) + + Text("Zaps are an easy way to support the incredible\nvoices that make up the conversation on nostr.\nHere's how it works", comment: "Describing the functional benefits of Zaps.") + .lineLimit(4) + .font(.body) + } + .padding(.top, 30) + } + + var FindWallet: some View { + self.WhyAddZapsBox( + iconName: "wallet.fill", + heading: NSLocalizedString("Find a Wallet", comment: "The heading for one of the \"Why add Zaps?\" boxes"), + description: NSLocalizedString("Choose the third-party payment provider you'd like to use.", comment: "The description for one of the \"Why add Zaps?\" boxes") + ) + } + + var LinkAccount: some View { + self.WhyAddZapsBox( + iconName: "link", + heading: NSLocalizedString("Link your account", comment: "The heading for one of the \"Why add Zaps?\" boxes"), + description: NSLocalizedString("Link to services that support Nostr Wallet Connect like Alby, Coinos and more.", comment: "The description for one of the \"Why add Zaps?\" boxes") + ) + } + + var StartReceiving: some View { + self.WhyAddZapsBox( + iconName: "bitcoin", + heading: NSLocalizedString("Start receiving money", comment: "The heading for one of the \"Why add Zaps?\" boxes"), + description: NSLocalizedString("People will be able to send you cash from your profile. No money goes to Damus.", comment: "The description for one of the \"Why add Zaps?\" boxes") + ) + } + + func WhyAddZapsBox(iconName: String, heading: String, description: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Button(action: {}, label: { + Image(iconName) + .resizable() + .frame(width: 25, height: 25) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), cornerRadius: 9999)) + .disabled(true) + + Text(heading) + .font(.title2) + .fontWeight(.bold) + .padding(.bottom, 2) + + Text(description) + .font(.caption) + + Spacer() + } + .frame(maxWidth: 175, minHeight: 175) + .padding(10) + .background(DamusColors.neutral1) + .cornerRadius(15) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(DamusColors.neutral1, lineWidth: 2) + ) + .padding(.top, 20) + } +} + +struct ZapExplainerView_Previews: PreviewProvider { + static var previews: some View { + ZapExplainerView(show_introduction: .constant(true), nav: .init()) + } +} diff --git a/damusTests/WalletConnectTests.swift b/damusTests/WalletConnectTests.swift @@ -87,7 +87,7 @@ final class WalletConnectTests: XCTestCase { let pool = RelayPool(ndb: .empty) let box = PostBox(pool: pool) - nwc_pay(url: nwc, pool: pool, post: box, invoice: "invoice") + WalletConnect.pay(url: nwc, pool: pool, post: box, invoice: "invoice") XCTAssertEqual(pool.our_descriptors.count, 0) XCTAssertEqual(pool.all_descriptors.count, 1)