diff --git a/VultisigApp/VultisigApp.xcodeproj/project.pbxproj b/VultisigApp/VultisigApp.xcodeproj/project.pbxproj index 25469a59bd..af0ebf6e68 100644 --- a/VultisigApp/VultisigApp.xcodeproj/project.pbxproj +++ b/VultisigApp/VultisigApp.xcodeproj/project.pbxproj @@ -13,6 +13,9 @@ 049BBB682B71E9C5004C231F /* WifiInstruction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BBB672B71E9C5004C231F /* WifiInstruction.swift */; }; 064D3D2833A07505317CC841 /* CosmosTransactionStatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB8B4E59B83927709B99177 /* CosmosTransactionStatusProvider.swift */; }; 06A85C472977E4D585B98B5C /* SingleKeygenMessage+ProtoMappable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D16AB3F011FE4BF1A497C6 /* SingleKeygenMessage+ProtoMappable.swift */; }; + 0BB1F9CE67A3BE99B4FC0E3A /* AgentConversationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC43C18FD2125A5C2742AAB0 /* AgentConversationsView.swift */; }; + 1173BB0D4FDA7444CB9AC6E2 /* AgentBackendClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A107C81612286300C1D9866B /* AgentBackendClient.swift */; }; + 12B3EB2EC4F83BC93D67CF00 /* AgentRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5426324385F4CE10A82D309 /* AgentRoute.swift */; }; 12C88E627244748C55FB97F2 /* EVMTransactionStatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC3C4A790D620AB105DF39D /* EVMTransactionStatusProvider.swift */; }; 13052B972E8C5511000285D2 /* CrossPlatformToolbarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13052B962E8C5511000285D2 /* CrossPlatformToolbarModifier.swift */; }; 13052B992E8C5744000285D2 /* MacOSToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13052B982E8C5744000285D2 /* MacOSToolbarView.swift */; }; @@ -548,7 +551,9 @@ 13EFA1CB2E31389D009838EB /* OTPCharTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFA1CA2E31389C009838EB /* OTPCharTextField.swift */; }; 13FB8AB62EA93B4200712A51 /* THORChainLiquidityProviderResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13FB8AB52EA93B4100712A51 /* THORChainLiquidityProviderResponse.swift */; }; 13FB8ABA2EA93B6A00712A51 /* THORChainLPPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13FB8AB92EA93B6900712A51 /* THORChainLPPosition.swift */; }; + 1BD218EC6EBBA4C7C4DF4F31 /* AgentAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35B621377F63B9A5B4DEE2D1 /* AgentAuthService.swift */; }; 1C7BFBF50387F766821B21C2 /* CircleDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25397BD8D2AFBC8FB7C3DDBB /* CircleDashboardView.swift */; }; + 1EFC82D483A7043778E87203 /* AgentChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8922E2B9288FCFE9626B80C1 /* AgentChatViewModel.swift */; }; 2353311EA5E3A85FD0708F48 /* PushNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB189371E4AEE3FD55C1FE44 /* PushNotificationManager.swift */; }; 2587BEE0C4B62870DEAB2199 /* SolanaTransactionStatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CA9F7989A44AB965FF32F3 /* SolanaTransactionStatusProvider.swift */; }; 267798512F306C9AAD705483 /* UTXOTransactionStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1A1FCF78FFFE8E23B38F0E /* UTXOTransactionStatusResponse.swift */; }; @@ -557,7 +562,9 @@ 2B86966ABA5D0FB899F26AEF /* ReviewDeviceCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE6DF580ABA99F8941A501F /* ReviewDeviceCell.swift */; }; 32ABD407AF52CE45A6DA5B24 /* DilithiumAlreadyGeneratedSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD47F58E085AADDA037E719 /* DilithiumAlreadyGeneratedSheet.swift */; }; 3438005C33774BADE4B163FD /* KyberSwapService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD36D5BDFA9CBF0B16709643 /* KyberSwapService.swift */; }; + 379D2FAE579688A3A6F4F23A /* AgentContextBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57243B95C68821519C8E9D22 /* AgentContextBuilder.swift */; }; 381B08598606C73AB7BED741 /* TransactionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC586168CA29D811C28C56A4 /* TransactionStatus.swift */; }; + 3E02DB6F3E846E8630B17A35 /* FastVaultKeysignService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615D365EDC5D083737E59F94 /* FastVaultKeysignService.swift */; }; 43E0C072714FC369B3B91499 /* HiddenToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCB0CAFF343CC6FF8986E07F /* HiddenToken.swift */; }; 4606B3B02B80465E0045094D /* UTXOTransactionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4606B3AF2B80465E0045094D /* UTXOTransactionsService.swift */; }; 461AD1BB2BC7239C00959278 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 461AD1BA2BC7239C00959278 /* DeviceInfo.swift */; }; @@ -590,6 +597,7 @@ 4F04ED93D3530E7DB36F6A5B /* CosmosTransactionStatusAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD3665CC777D1BD45B08AC8 /* CosmosTransactionStatusAPI.swift */; }; 5589A8E15B5CE4DB4C3090AB /* VaultNotificationToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA8B3D6314175F82FEC27D8 /* VaultNotificationToggleRow.swift */; }; 570871242D51A5905F8DDAC7 /* ForegroundNotificationBannerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE5D50E07EFD9201F09BDC4 /* ForegroundNotificationBannerModifier.swift */; }; + 5E0E7A608BD5D13124215706 /* AgentToolExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3B44655675C6DADF3F5E06 /* AgentToolExecutor.swift */; }; 5EB3252A1288D9B9D78B55E6 /* CustomMessageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BD426AD3A78890574917DE6 /* CustomMessageDecoder.swift */; }; 60A0614473DB30544D99C624 /* DefiCircleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE8825D6F0DBD6E8F28CB49 /* DefiCircleRow.swift */; }; 60A0827D5A92EE75D770B516 /* WebViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603C82E2F7FA7C6F77D2A1F8 /* WebViewCoordinator.swift */; }; @@ -597,14 +605,18 @@ 6AE564F37876C2BB01674194 /* CircleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC7383A8308BD94F081C10 /* CircleService.swift */; }; 6C34A4859C2EE1D9C31F45CD /* DeviceUnregisterRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3F77846C9027CFB58F3364 /* DeviceUnregisterRequest.swift */; }; 6DA260AA517A2E54FDB4A220 /* ForegroundNotificationBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9FDC0FE2F5DEF7240D0D3B7 /* ForegroundNotificationBannerView.swift */; }; + 6DC8A8284C07E537AF83508E /* AgentConversationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1C6F91653A1C18DD2662A7 /* AgentConversationsViewModel.swift */; }; 6F9D0331A5913F75BDE1B874 /* KyberSwapToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C796F13B6313681B4F992A /* KyberSwapToken.swift */; }; 7024891B02B07FF1A3BCCFB4 /* ReviewYourVaultsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089EF1D434FDC5673F1F090A /* ReviewYourVaultsScreen.swift */; }; 73CA1B2867FEA5BBCBEE1080 /* CreateMldsaRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EDDEF72A877329900F12D6 /* CreateMldsaRequest.swift */; }; 744B8E55385C46399CB8AEC8 /* ExtensionMemoServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DBBE9179D24F73ADF22596 /* ExtensionMemoServiceTests.swift */; }; + 76B7C9CBF07DBD9DCEB92408 /* AgentModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60268B5B38001090F27E283 /* AgentModels.swift */; }; 76EBA303440DAF9FE8673F87 /* UTXOTransactionStatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C75B7553CE05272D632058 /* UTXOTransactionStatusProvider.swift */; }; 784957DA9E44EAC291A4E593 /* CircleSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2082307F2DAAB6FA5911FFDC /* CircleSetupView.swift */; }; + 7B745269DA629654EEA2982B /* AgentPasswordPromptScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80CACC29EED869B7D6CD41A0 /* AgentPasswordPromptScreen.swift */; }; 83686209DD51EC3F558B532F /* SingleKeygenType.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA5F6BCDC1260C8E4D57052 /* SingleKeygenType.swift */; }; 8572B6910CB7E64BD6260D6C /* TransactionStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6548A1DE3664347EBFA4F1B5 /* TransactionStatusService.swift */; }; + 8C61F213A6F1A0F4A97024E5 /* AgentChatMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FB37A5BFE7156A8C034D7B /* AgentChatMessageView.swift */; }; 8D8CD1A7DF304D8074C03B4E /* SingleKeygenMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1706BAF79A7E992FFEBF71 /* SingleKeygenMessage.swift */; }; 8F55CD3B2DC382E500B53E85 /* TokensStore+Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F55CD3A2DC382E400B53E85 /* TokensStore+Token.swift */; }; 927BE27A46C4476AA7DA2901 /* SuiTokenPriceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 927BE27A46C4476AA7DA2902 /* SuiTokenPriceTests.swift */; }; @@ -1015,6 +1027,7 @@ A84DEFF6EE720694FC92EC4B /* NotifyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C582CC92369B7BB13C527B /* NotifyRequest.swift */; }; AA0001012E4D000000000001 /* KeygenAnimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002E4D000000000001 /* KeygenAnimationView.swift */; }; AEF2CEAD64A344FB85D7E3DE /* PendingTransactionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F3285329CA1572874A8F92 /* PendingTransactionStorage.swift */; }; + AF7144CC5A9FFD41AD0D9277 /* AgentRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04FFEB77DBA65343B22EBEA /* AgentRouter.swift */; }; B1F76C6989E9227786F79133 /* MacAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69409726FE812D77E37E1F08 /* MacAppDelegate.swift */; }; B20C65452F3AFEFF00AF8334 /* DilithiumKeygen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20C65442F3AFEF900AF8334 /* DilithiumKeygen.swift */; }; B21561E02F18A5D90052C892 /* Vultisig-Logo.icon in Resources */ = {isa = PBXBuildFile; fileRef = B21561DF2F18A5D90052C892 /* Vultisig-Logo.icon */; }; @@ -1252,8 +1265,10 @@ E41DA91BBEFA59413E4FD229 /* NotificationsIntroSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1884D10560CC1D55166D1D84 /* NotificationsIntroSheet.swift */; }; E6B0BA6F0B65023EBC142F76 /* UTXOTransactionStatusAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E423B7D2BCCD2F1943ED30F8 /* UTXOTransactionStatusAPI.swift */; }; E9C0D9C24B2101FC125F4770 /* VaultNotificationSetupSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2987AFF9D2BF542D8A798F9E /* VaultNotificationSetupSheet.swift */; }; + EBEB7C90CFA9975FFDC3E47D /* AgentThinkingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E313D70696CC56709CA322D3 /* AgentThinkingIndicator.swift */; }; ED16FD9B4142BCC4C997BA94 /* PushNotificationManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B48C2DF897CAFC419193329 /* PushNotificationManaging.swift */; }; EFE6369959D9D717F5227F8D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EF791E371FD688E09C0829 /* NotificationService.swift */; }; + F0B1B3639A3012ECC61BDB4C /* AgentChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC815DCE4E60C266CC64D148 /* AgentChatView.swift */; }; F320BF5E96AB5CEACB25A05F /* DeviceRegistrationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180DFF02CBB6957B351A9616 /* DeviceRegistrationRequest.swift */; }; F60EA90567B7702B28A569C8 /* TronResourcesInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A9A754C4091B21ACE6FB82D /* TronResourcesInfoSheet.swift */; }; F6MAHZO7QADAUEOCUKP14KWW /* ThorchainLP.swift in Sources */ = {isa = PBXBuildFile; fileRef = VX9GYT3ZT7ENIW24C32BA8O7 /* ThorchainLP.swift */; }; @@ -1829,6 +1844,7 @@ 2987AFF9D2BF542D8A798F9E /* VaultNotificationSetupSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VaultNotificationSetupSheet.swift; sourceTree = ""; }; 29B3EE2059C84ADD4F8C4F16 /* CircleApiService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CircleApiService.swift; sourceTree = ""; }; 35953B42171F515630BA7E9B /* CircleView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CircleView.swift; sourceTree = ""; }; + 35B621377F63B9A5B4DEE2D1 /* AgentAuthService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentAuthService.swift; path = Agent/AgentAuthService.swift; sourceTree = ""; }; 3BA8B3D6314175F82FEC27D8 /* VaultNotificationToggleRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VaultNotificationToggleRow.swift; sourceTree = ""; }; 4108DBD8BB46387973946D79 /* ForegroundNotificationData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ForegroundNotificationData.swift; sourceTree = ""; }; 4600167D2B71A12D00CE17C7 /* Tss.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:G8Q5XUAJD9:Cortina Ventures PTY LTD"; lastKnownFileType = wrapper.xcframework; path = Tss.xcframework; sourceTree = ""; }; @@ -1868,11 +1884,13 @@ 4B48C2DF897CAFC419193329 /* PushNotificationManaging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationManaging.swift; sourceTree = ""; }; 509045C58D1144858A15FE41 /* ThorchainStagenet2Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThorchainStagenet2Service.swift; sourceTree = ""; }; 544EB5E7CBD182E832CC537C /* PendingTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PendingTransaction.swift; sourceTree = ""; }; + 57243B95C68821519C8E9D22 /* AgentContextBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentContextBuilder.swift; path = Agent/AgentContextBuilder.swift; sourceTree = ""; }; 5A3F77846C9027CFB58F3364 /* DeviceUnregisterRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceUnregisterRequest.swift; sourceTree = ""; }; 5C1F4585DF366BFCCCC513A9 /* DerivationPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DerivationPath.swift; sourceTree = ""; }; 5CE5D50E07EFD9201F09BDC4 /* ForegroundNotificationBannerModifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ForegroundNotificationBannerModifier.swift; sourceTree = ""; }; 5DD47F58E085AADDA037E719 /* DilithiumAlreadyGeneratedSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DilithiumAlreadyGeneratedSheet.swift; sourceTree = ""; }; 5E7878748BB9E16298184860 /* KyberSwapQuote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KyberSwapQuote.swift; sourceTree = ""; }; + 615D365EDC5D083737E59F94 /* FastVaultKeysignService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FastVaultKeysignService.swift; sourceTree = ""; }; 603C82E2F7FA7C6F77D2A1F8 /* WebViewCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "WebViewCoordinator.swift"; sourceTree = ""; }; 6548A1DE3664347EBFA4F1B5 /* TransactionStatusService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionStatusService.swift; sourceTree = ""; }; 68274C3A94E4F6B7BDEC9A94 /* BannerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerViewModifier.swift; sourceTree = ""; }; @@ -1882,7 +1900,9 @@ 78CA9F7989A44AB965FF32F3 /* SolanaTransactionStatusProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SolanaTransactionStatusProvider.swift; sourceTree = ""; }; 7CD3665CC777D1BD45B08AC8 /* CosmosTransactionStatusAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CosmosTransactionStatusAPI.swift; sourceTree = ""; }; 7E4C8B2D53A1F06E8D2B49FA /* TronResourcesLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TronResourcesLoader.swift; sourceTree = ""; }; + 80CACC29EED869B7D6CD41A0 /* AgentPasswordPromptScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentPasswordPromptScreen.swift; path = Agent/AgentPasswordPromptScreen.swift; sourceTree = ""; }; 84EDDEF72A877329900F12D6 /* CreateMldsaRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateMldsaRequest.swift; sourceTree = ""; }; + 8922E2B9288FCFE9626B80C1 /* AgentChatViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentChatViewModel.swift; path = Agent/AgentChatViewModel.swift; sourceTree = ""; }; 8BE8825D6F0DBD6E8F28CB49 /* DefiCircleRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefiCircleRow.swift; sourceTree = ""; }; 8E6A38BCCA67E47B84787AA0 /* ForegroundNotificationParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ForegroundNotificationParser.swift; sourceTree = ""; }; 8F55CD3A2DC382E400B53E85 /* TokensStore+Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TokensStore+Token.swift"; sourceTree = ""; }; @@ -1892,6 +1912,7 @@ 9C3E3E97CE3E3775F004F4EE /* LPUnitsValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LPUnitsValidator.swift; sourceTree = ""; }; 9FB8B4E59B83927709B99177 /* CosmosTransactionStatusProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CosmosTransactionStatusProvider.swift; sourceTree = ""; }; A0C582CC92369B7BB13C527B /* NotifyRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotifyRequest.swift; sourceTree = ""; }; + A107C81612286300C1D9866B /* AgentBackendClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentBackendClient.swift; path = Agent/AgentBackendClient.swift; sourceTree = ""; }; A1B2C3D4E5F6A7B8C9D0E1F2 /* Tooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tooltip.swift; sourceTree = ""; }; A1BE569DF07B97F7C31210E2 /* TronResourcesCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TronResourcesCardView.swift; sourceTree = ""; }; A500603E2C10E3EA008237D9 /* ThorchainSwapProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThorchainSwapProvider.swift; sourceTree = ""; }; @@ -2298,6 +2319,7 @@ A7E88FD1F757A82DCD9EF240 /* BackgroundTransactionPoller.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundTransactionPoller.swift; sourceTree = ""; }; A8F5B728DE1B9BF68381E504 /* SetupPushNotificationsModifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SetupPushNotificationsModifier.swift; sourceTree = ""; }; AA0001002E4D000000000001 /* KeygenAnimationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeygenAnimationView.swift; sourceTree = ""; }; + AC43C18FD2125A5C2742AAB0 /* AgentConversationsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentConversationsView.swift; path = Agent/AgentConversationsView.swift; sourceTree = ""; }; B08FAA81BD1AD27E98344042 /* SolanaTransactionStatusAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SolanaTransactionStatusAPI.swift; sourceTree = ""; }; B0F3285329CA1572874A8F92 /* PendingTransactionStorage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PendingTransactionStorage.swift; sourceTree = ""; }; B20C65442F3AFEF900AF8334 /* DilithiumKeygen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DilithiumKeygen.swift; sourceTree = ""; }; @@ -2418,7 +2440,9 @@ BD1706BAF79A7E992FFEBF71 /* SingleKeygenMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleKeygenMessage.swift; sourceTree = ""; }; BD1A1FCF78FFFE8E23B38F0E /* UTXOTransactionStatusResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UTXOTransactionStatusResponse.swift; sourceTree = ""; }; BWXBB99J8F32LB10N1PT9BIH /* FunctionCallAddThorLP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionCallAddThorLP.swift; sourceTree = ""; }; + C04FFEB77DBA65343B22EBEA /* AgentRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentRouter.swift; path = Agent/Navigation/AgentRouter.swift; sourceTree = ""; }; C36F934AD4667C64D3C753F2 /* NotificationsSettingsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsSettingsScreen.swift; sourceTree = ""; }; + C5426324385F4CE10A82D309 /* AgentRoute.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentRoute.swift; path = Agent/Navigation/AgentRoute.swift; sourceTree = ""; }; CA5D2C05B4B37315FBB2E448 /* MacScreenCaptureService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MacScreenCaptureService.swift; sourceTree = ""; }; CB7D53492FDE4E0ABF45FAFC /* FastVaultPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastVaultPasswordScreen.swift; sourceTree = ""; }; CD36D5BDFA9CBF0B16709643 /* KyberSwapService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KyberSwapService.swift; sourceTree = ""; }; @@ -2427,6 +2451,7 @@ D9AD8EDD2B6730430009F8D5 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WelcomeView.swift; path = VultisigApp/Views/WelcomeView.swift; sourceTree = SOURCE_ROOT; }; D9AD8EE52B6730A40009F8D5 /* PeerDiscoveryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PeerDiscoveryScreen.swift; path = VultisigApp/Views/Keygen/PeerDiscoveryScreen.swift; sourceTree = SOURCE_ROOT; }; D9B6F47A0796793CADF498E8 /* TransactionStatusViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionStatusViewModel.swift; sourceTree = ""; }; + DC815DCE4E60C266CC64D148 /* AgentChatView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentChatView.swift; path = Agent/AgentChatView.swift; sourceTree = ""; }; DDE6DF580ABA99F8941A501F /* ReviewDeviceCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewDeviceCell.swift; sourceTree = ""; }; DE03EA0A2D0D3C6B00AA4BB0 /* godkls.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:5BP27CHH4Y:Vulti Holdings Limited"; lastKnownFileType = wrapper.xcframework; path = godkls.xcframework; sourceTree = ""; }; DE03EA0C2D0D3C7700AA4BB0 /* goschnorr.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:5BP27CHH4Y:Vulti Holdings Limited"; lastKnownFileType = wrapper.xcframework; path = goschnorr.xcframework; sourceTree = ""; }; @@ -2528,16 +2553,21 @@ DEFF58FB2BCFB384005DFDF9 /* MayaChainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaChainService.swift; sourceTree = ""; }; DEFF58FD2BD13E5D005DFDF9 /* ThreadSafeDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeDictionary.swift; sourceTree = ""; }; DEFF59002BD238CF005DFDF9 /* SignedTransactionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignedTransactionResult.swift; sourceTree = ""; }; + E313D70696CC56709CA322D3 /* AgentThinkingIndicator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentThinkingIndicator.swift; path = Agent/AgentThinkingIndicator.swift; sourceTree = ""; }; E422D27702256FB07565C6D0 /* CosmosTransactionStatusResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CosmosTransactionStatusResponse.swift; sourceTree = ""; }; E423B7D2BCCD2F1943ED30F8 /* UTXOTransactionStatusAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UTXOTransactionStatusAPI.swift; sourceTree = ""; }; E7DBBE9179D24F73ADF22596 /* ExtensionMemoServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionMemoServiceTests.swift; sourceTree = ""; }; E8C796F13B6313681B4F992A /* KyberSwapToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KyberSwapToken.swift; sourceTree = ""; }; + EA3B44655675C6DADF3F5E06 /* AgentToolExecutor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentToolExecutor.swift; path = Agent/AgentToolExecutor.swift; sourceTree = ""; }; ECA5F6BCDC1260C8E4D57052 /* SingleKeygenType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleKeygenType.swift; sourceTree = ""; }; + F3FB37A5BFE7156A8C034D7B /* AgentChatMessageView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentChatMessageView.swift; path = Agent/AgentChatMessageView.swift; sourceTree = ""; }; + F60268B5B38001090F27E283 /* AgentModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentModels.swift; path = Agent/AgentModels.swift; sourceTree = ""; }; F84EAAA1C5CB4AC0A9922354 /* FastVaultPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastVaultPasswordViewModel.swift; sourceTree = ""; }; F9FDC0FE2F5DEF7240D0D3B7 /* ForegroundNotificationBannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ForegroundNotificationBannerView.swift; sourceTree = ""; }; FB189371E4AEE3FD55C1FE44 /* PushNotificationManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationManager.swift; sourceTree = ""; }; FBEBFE69D4F5F9620BCA6F9C /* VaultSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VaultSettings.swift; sourceTree = ""; }; FCB0CAFF343CC6FF8986E07F /* HiddenToken.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiddenToken.swift; sourceTree = ""; }; + FF1C6F91653A1C18DD2662A7 /* AgentConversationsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AgentConversationsViewModel.swift; path = Agent/AgentConversationsViewModel.swift; sourceTree = ""; }; VX9GYT3ZT7ENIW24C32BA8O7 /* ThorchainLP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThorchainLP.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2731,6 +2761,18 @@ path = Notifications; sourceTree = ""; }; + 0F65B87BD313EF18B0B2850D /* Agent */ = { + isa = PBXGroup; + children = ( + AC43C18FD2125A5C2742AAB0 /* AgentConversationsView.swift */, + DC815DCE4E60C266CC64D148 /* AgentChatView.swift */, + F3FB37A5BFE7156A8C034D7B /* AgentChatMessageView.swift */, + E313D70696CC56709CA322D3 /* AgentThinkingIndicator.swift */, + 80CACC29EED869B7D6CD41A0 /* AgentPasswordPromptScreen.swift */, + ); + name = Agent; + sourceTree = ""; + }; 130652452EA7CCE8002CAD99 /* Models */ = { isa = PBXGroup; children = ( @@ -4278,6 +4320,7 @@ 139D15A92E9C78D5006F671C /* VultDiscountTiers */, 13C7C1DF2E86EDD500A529FD /* Common */, 139642362E79950C00CFEFAF /* VaultSelector */, + 3E1136AD1C50820BDC7A5CEC /* Agent */, ); path = Features; sourceTree = ""; @@ -4741,6 +4784,14 @@ path = Providers; sourceTree = ""; }; + 3E1136AD1C50820BDC7A5CEC /* Agent */ = { + isa = PBXGroup; + children = ( + 8C1593A246B4423FD9111B34 /* Navigation */, + ); + name = Agent; + sourceTree = ""; + }; 461BD0962B7EFA8000BFF703 /* Services */ = { isa = PBXGroup; children = ( @@ -4790,6 +4841,7 @@ A6SEC0152E1234567890AB02 /* Security */, EE0B29378FFAFF4BCC53B85C /* TransactionStatus */, EB448FA7EDF6CBF9FDF53F3E /* Notification */, + 6DD45109405492206103B1C4 /* Agent */, ); path = Services; sourceTree = ""; @@ -4956,6 +5008,27 @@ path = Model; sourceTree = ""; }; + 6DD45109405492206103B1C4 /* Agent */ = { + isa = PBXGroup; + children = ( + F60268B5B38001090F27E283 /* AgentModels.swift */, + 35B621377F63B9A5B4DEE2D1 /* AgentAuthService.swift */, + A107C81612286300C1D9866B /* AgentBackendClient.swift */, + 57243B95C68821519C8E9D22 /* AgentContextBuilder.swift */, + EA3B44655675C6DADF3F5E06 /* AgentToolExecutor.swift */, + ); + name = Agent; + sourceTree = ""; + }; + 8C1593A246B4423FD9111B34 /* Navigation */ = { + isa = PBXGroup; + children = ( + C5426324385F4CE10A82D309 /* AgentRoute.swift */, + C04FFEB77DBA65343B22EBEA /* AgentRouter.swift */, + ); + name = Navigation; + sourceTree = ""; + }; 9690397CDD1E0CE7CD6E2A18 /* Circle */ = { isa = PBXGroup; children = ( @@ -4995,6 +5068,7 @@ isa = PBXGroup; children = ( A51AE8BA2C931C2000EF9A7A /* FastVaultService.swift */, + 615D365EDC5D083737E59F94 /* FastVaultKeysignService.swift */, ); path = FastVault; sourceTree = ""; @@ -5524,6 +5598,7 @@ A594EC2A2D00BE6C00B74EBC /* SettingsCustomMessageViewModel.swift */, A6D739DF2E0B993A0005697D /* SendDetailsViewModel.swift */, D9B6F47A0796793CADF498E8 /* TransactionStatusViewModel.swift */, + EB0EA2215F91463CB779B0BD /* Agent */, ); path = "View Models"; sourceTree = ""; @@ -6169,6 +6244,7 @@ 049BBB4E2B71E37B004C231F /* Components */, A6E0C90D2DE4E86900A01543 /* Referral */, 0CB754653CA5B091880EF5B1 /* Notifications */, + 0F65B87BD313EF18B0B2850D /* Agent */, ); path = Views; sourceTree = ""; @@ -6518,6 +6594,15 @@ path = Chains; sourceTree = ""; }; + EB0EA2215F91463CB779B0BD /* Agent */ = { + isa = PBXGroup; + children = ( + 8922E2B9288FCFE9626B80C1 /* AgentChatViewModel.swift */, + FF1C6F91653A1C18DD2662A7 /* AgentConversationsViewModel.swift */, + ); + name = Agent; + sourceTree = ""; + }; EB448FA7EDF6CBF9FDF53F3E /* Notification */ = { isa = PBXGroup; children = ( @@ -7941,6 +8026,21 @@ C20EA28920FB1554155AA332 /* ForegroundNotificationParser.swift in Sources */, 6DA260AA517A2E54FDB4A220 /* ForegroundNotificationBannerView.swift in Sources */, 570871242D51A5905F8DDAC7 /* ForegroundNotificationBannerModifier.swift in Sources */, + 0BB1F9CE67A3BE99B4FC0E3A /* AgentConversationsView.swift in Sources */, + F0B1B3639A3012ECC61BDB4C /* AgentChatView.swift in Sources */, + 8C61F213A6F1A0F4A97024E5 /* AgentChatMessageView.swift in Sources */, + EBEB7C90CFA9975FFDC3E47D /* AgentThinkingIndicator.swift in Sources */, + 7B745269DA629654EEA2982B /* AgentPasswordPromptScreen.swift in Sources */, + 76B7C9CBF07DBD9DCEB92408 /* AgentModels.swift in Sources */, + 1BD218EC6EBBA4C7C4DF4F31 /* AgentAuthService.swift in Sources */, + 1173BB0D4FDA7444CB9AC6E2 /* AgentBackendClient.swift in Sources */, + 379D2FAE579688A3A6F4F23A /* AgentContextBuilder.swift in Sources */, + 5E0E7A608BD5D13124215706 /* AgentToolExecutor.swift in Sources */, + 1EFC82D483A7043778E87203 /* AgentChatViewModel.swift in Sources */, + 6DC8A8284C07E537AF83508E /* AgentConversationsViewModel.swift in Sources */, + 12B3EB2EC4F83BC93D67CF00 /* AgentRoute.swift in Sources */, + AF7144CC5A9FFD41AD0D9277 /* AgentRouter.swift in Sources */, + 3E02DB6F3E846E8630B17A35 /* FastVaultKeysignService.swift in Sources */, 60A0827D5A92EE75D770B516 /* WebViewCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/VultisigApp/VultisigApp/ContentView.swift b/VultisigApp/VultisigApp/ContentView.swift index 52767c39d6..40f2a4a134 100644 --- a/VultisigApp/VultisigApp/ContentView.swift +++ b/VultisigApp/VultisigApp/ContentView.swift @@ -53,6 +53,7 @@ struct ContentView: View { .navigationDestination(for: SettingsRoute.self) { router.settingsRouter.build($0) } .navigationDestination(for: CircleRoute.self) { router.circleRouter.build($0) } .navigationDestination(for: TronRoute.self) { router.tronRouter.build($0) } + .navigationDestination(for: AgentRoute.self) { router.agentRouter.build($0) } } .environment(\.router, router.navigationRouter) .colorScheme(.dark) diff --git a/VultisigApp/VultisigApp/Features/Agent/Navigation/AgentRoute.swift b/VultisigApp/VultisigApp/Features/Agent/Navigation/AgentRoute.swift new file mode 100644 index 0000000000..5866878640 --- /dev/null +++ b/VultisigApp/VultisigApp/Features/Agent/Navigation/AgentRoute.swift @@ -0,0 +1,13 @@ +// +// AgentRoute.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation + +enum AgentRoute: Hashable { + case conversations + case chat(conversationId: String?) +} diff --git a/VultisigApp/VultisigApp/Features/Agent/Navigation/AgentRouter.swift b/VultisigApp/VultisigApp/Features/Agent/Navigation/AgentRouter.swift new file mode 100644 index 0000000000..4c32f272c1 --- /dev/null +++ b/VultisigApp/VultisigApp/Features/Agent/Navigation/AgentRouter.swift @@ -0,0 +1,21 @@ +// +// AgentRouter.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import SwiftUI + +struct AgentRouter { + + @ViewBuilder + func build(_ route: AgentRoute) -> some View { + switch route { + case .conversations: + AgentConversationsView() + case .chat(let conversationId): + AgentChatView(conversationId: conversationId) + } + } +} diff --git a/VultisigApp/VultisigApp/Features/Home/HomeScreen.swift b/VultisigApp/VultisigApp/Features/Home/HomeScreen.swift index 85c6e0c426..f714b9b7d4 100644 --- a/VultisigApp/VultisigApp/Features/Home/HomeScreen.swift +++ b/VultisigApp/VultisigApp/Features/Home/HomeScreen.swift @@ -41,8 +41,16 @@ struct HomeScreen: View { @EnvironmentObject var vultExtensionViewModel: VultExtensionViewModel @EnvironmentObject var appViewModel: AppViewModel @Environment(\.modelContext) private var modelContext + var tabs: [HomeTab] { - !(appViewModel.selectedVault?.availableDefiChains.isEmpty ?? true) ? [.wallet, .defi] : [.wallet] + var baseTabs: [HomeTab] = [.wallet] + if !(appViewModel.selectedVault?.availableDefiChains.isEmpty ?? true) { + baseTabs.append(.defi) + } + if SettingsViewModel.shared.agentEnabled { + baseTabs.append(.agent) + } + return baseTabs } init(showingVaultSelector: Bool = false) { @@ -137,6 +145,8 @@ struct HomeScreen: View { vault: selectedVault, showBalanceInHeader: $defiShowPortfolioHeader ) + case .agent: + AgentConversationsView() case .camera: EmptyView() } @@ -149,6 +159,7 @@ struct HomeScreen: View { } header(vault: selectedVault) + .showIf(selectedTab != .agent) } } @@ -318,6 +329,8 @@ extension HomeScreen { showOpaqueHeader = defiShowPortfolioHeader case .wallet: showOpaqueHeader = walletShowPortfolioHeader + case .agent: + showOpaqueHeader = false case .camera: return } diff --git a/VultisigApp/VultisigApp/Features/Home/Model/HomeTab.swift b/VultisigApp/VultisigApp/Features/Home/Model/HomeTab.swift index 70d3962e16..802b6f6b79 100644 --- a/VultisigApp/VultisigApp/Features/Home/Model/HomeTab.swift +++ b/VultisigApp/VultisigApp/Features/Home/Model/HomeTab.swift @@ -8,6 +8,7 @@ enum HomeTab: TabBarItem, CaseIterable { case wallet case defi + case agent // Only used to fake `camera` button for liquid glass case camera @@ -17,6 +18,8 @@ enum HomeTab: TabBarItem, CaseIterable { "Wallet" case .defi: "DeFi" + case .agent: + "Agent" case .camera: "" } @@ -28,6 +31,8 @@ enum HomeTab: TabBarItem, CaseIterable { "wallet" case .defi: "coins-add" + case .agent: + "stars" case .camera: "camera-2" } diff --git a/VultisigApp/VultisigApp/Features/Wallet/ChainDetail/ChainDetailScreenContainer.swift b/VultisigApp/VultisigApp/Features/Wallet/ChainDetail/ChainDetailScreenContainer.swift index 4761656576..4e4fb161f1 100644 --- a/VultisigApp/VultisigApp/Features/Wallet/ChainDetail/ChainDetailScreenContainer.swift +++ b/VultisigApp/VultisigApp/Features/Wallet/ChainDetail/ChainDetailScreenContainer.swift @@ -23,7 +23,14 @@ struct ChainDetailScreenContainer: View { self.group = group self.vault = vault let supportsDefiTab = vault.availableDefiChains.contains(group.chain) - tabs = supportsDefiTab ? [.wallet, .defi] : [.wallet] + var newTabs: [HomeTab] = [.wallet] + if supportsDefiTab { + newTabs.append(.defi) + } + if SettingsViewModel.shared.agentEnabled { + newTabs.append(.agent) + } + self.tabs = newTabs } var body: some View { @@ -58,6 +65,8 @@ struct ChainDetailScreenContainer: View { default: DefiChainMainScreen(vault: vault, group: group) } + case .agent: + AgentConversationsView() case .camera: EmptyView() } diff --git a/VultisigApp/VultisigApp/Model/Chain.swift b/VultisigApp/VultisigApp/Model/Chain.swift index 10d349f5a0..c745ffe64d 100644 --- a/VultisigApp/VultisigApp/Model/Chain.swift +++ b/VultisigApp/VultisigApp/Model/Chain.swift @@ -48,6 +48,34 @@ enum Chain: String, Codable, Hashable, CaseIterable { case hyperliquid case sei + /// Maps removed chain raw values to their replacement chain. + /// This prevents SwiftData from crashing when decoding legacy persisted data. + private static let removedChainMigrations: [String: Chain] = [ + "thorChainStagenet2": .thorChainStagenet, + "thorChainChainnet": .thorChain, + "polygon": .polygonV2 + ] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + if let chain = Chain(rawValue: rawValue) { + self = chain + } else if let migrated = Chain.removedChainMigrations[rawValue] { + self = migrated + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot initialize Chain from invalid String value \(rawValue)" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } + enum MigrationKeys: String, CodingKey { case ticker } diff --git a/VultisigApp/VultisigApp/Navigation/VultisigRouter.swift b/VultisigApp/VultisigApp/Navigation/VultisigRouter.swift index e84c564bb0..1eba5e784c 100644 --- a/VultisigApp/VultisigApp/Navigation/VultisigRouter.swift +++ b/VultisigApp/VultisigApp/Navigation/VultisigRouter.swift @@ -19,6 +19,7 @@ final class VultisigRouter: ObservableObject { let homeRouter: HomeRouter let circleRouter: CircleRouter let tronRouter: TronRouter + let agentRouter: AgentRouter init(navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter @@ -32,5 +33,6 @@ final class VultisigRouter: ObservableObject { self.homeRouter = HomeRouter() self.circleRouter = CircleRouter() self.tronRouter = TronRouter() + self.agentRouter = AgentRouter() } } diff --git a/VultisigApp/VultisigApp/Services/Agent/AgentAuthService.swift b/VultisigApp/VultisigApp/Services/Agent/AgentAuthService.swift new file mode 100644 index 0000000000..07a8664db8 --- /dev/null +++ b/VultisigApp/VultisigApp/Services/Agent/AgentAuthService.swift @@ -0,0 +1,459 @@ +// +// AgentAuthService.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation +import OSLog +import WalletCore + +final class AgentAuthService { + + static let shared = AgentAuthService() + + private let logger = Logger(subsystem: "com.vultisig", category: "AgentAuthService") + private let keychainPrefix = "vultisig_agent_auth_" + + /// In-memory token cache + private var tokens: [String: AgentAuthToken] = [:] + + // MARK: - Public API + + /// Sign in to the agent backend using TSS keysign + func signIn(vault: Vault, password: String) async throws -> AgentAuthToken { + #if DEBUG + print("[AgentAuth] 🔐 signIn starting for vault: \(vault.pubKeyECDSA.prefix(20))...") + #endif + let authMessage = try generateAuthMessage(vault: vault) + #if DEBUG + print("[AgentAuth] 📝 Auth message generated") + #endif + + // EIP-191 hash the message + let messageHash = ethereumSignHash(authMessage) + #if DEBUG + print("[AgentAuth] #️⃣ Message hashed") + #endif + + // Fast vault keysign — runs the full DKLS MPC ceremony + #if DEBUG + print("[AgentAuth] ✍️ Starting FastVault keysign ceremony...") + #endif + let signature = try await performFastVaultKeysign( + vault: vault, + messageHash: messageHash, + password: password + ) + #if DEBUG + print("[AgentAuth] ✅ FastVault keysign succeeded (signature length=\(signature.count))") + #endif + + // Authenticate with verifier + #if DEBUG + print("[AgentAuth] 🌐 Authenticating with verifier...") + #endif + let authResponse = try await authenticate( + publicKey: vault.pubKeyECDSA, + chainCodeHex: vault.hexChainCode, + signature: signature, + message: authMessage + ) + #if DEBUG + print("[AgentAuth] 🌐 Authenticated (token present, expiresIn=\(authResponse.data.expiresIn))") + #endif + + guard !authResponse.data.accessToken.isEmpty else { + #if DEBUG + print("[AgentAuth] ❌ Empty token received") + #endif + throw AgentAuthError.emptyToken + } + + let token = buildAuthToken( + accessToken: authResponse.data.accessToken, + refreshToken: authResponse.data.refreshToken, + expiresIn: authResponse.data.expiresIn + ) + + // Cache and persist + tokens[vault.pubKeyECDSA] = token + persistToken(vaultPubKey: vault.pubKeyECDSA, token: token) + + #if DEBUG + print("[AgentAuth] ✅ Agent auth signed in successfully, token expires: \(token.expiresAt)") + #endif + logger.info("Agent auth signed in successfully") + return token + } + + /// Get a valid cached token, or nil if expired/missing + func getCachedToken(vaultPubKey: String) -> AgentAuthToken? { + #if DEBUG + print("[AgentAuth] 🔍 getCachedToken for: \(vaultPubKey.prefix(20))...") + #endif + if let token = tokens[vaultPubKey] { + if token.token.trimmingCharacters(in: .whitespaces).isEmpty { + invalidateToken(vaultPubKey: vaultPubKey) + return nil + } + let fiveMinutesFromNow = Date().addingTimeInterval(5 * 60) + if token.expiresAt < fiveMinutesFromNow { + return nil + } + return token + } + + // Try loading from Keychain + if let persisted = loadPersistedToken(vaultPubKey: vaultPubKey) { + tokens[vaultPubKey] = persisted + let fiveMinutesFromNow = Date().addingTimeInterval(5 * 60) + if persisted.expiresAt < fiveMinutesFromNow { + return nil + } + return persisted + } + + return nil + } + + /// Refresh token if needed + func refreshIfNeeded(vaultPubKey: String) async -> AgentAuthToken? { + let token = tokens[vaultPubKey] ?? loadPersistedToken(vaultPubKey: vaultPubKey) + guard let token else { return nil } + + let fiveMinutesFromNow = Date().addingTimeInterval(5 * 60) + if token.expiresAt > fiveMinutesFromNow { + return token + } + + guard !token.refreshToken.trimmingCharacters(in: .whitespaces).isEmpty else { + invalidateToken(vaultPubKey: vaultPubKey) + return nil + } + + do { + let response = try await refreshAuthToken(refreshToken: token.refreshToken) + let newToken = buildAuthToken( + accessToken: response.data.accessToken, + refreshToken: response.data.refreshToken, + expiresIn: response.data.expiresIn + ) + tokens[vaultPubKey] = newToken + persistToken(vaultPubKey: vaultPubKey, token: newToken) + return newToken + } catch { + logger.error("Token refresh failed: \(error.localizedDescription)") + invalidateToken(vaultPubKey: vaultPubKey) + return nil + } + } + + /// Validate the current token with the verifier + func validate(vaultPubKey: String) async -> Bool { + guard let token = getCachedToken(vaultPubKey: vaultPubKey) else { return false } + + do { + try await validateTokenWithVerifier(accessToken: token.token) + return true + } catch { + invalidateToken(vaultPubKey: vaultPubKey) + return false + } + } + + /// Disconnect and revoke all tokens + func disconnect(vaultPubKey: String) async { + if let token = getCachedToken(vaultPubKey: vaultPubKey) { + try? await revokeAllTokens(accessToken: token.token) + } + invalidateToken(vaultPubKey: vaultPubKey) + } + + func isSignedIn(vaultPubKey: String) -> Bool { + getCachedToken(vaultPubKey: vaultPubKey) != nil + } + + func invalidateToken(vaultPubKey: String) { + tokens.removeValue(forKey: vaultPubKey) + deletePersistedToken(vaultPubKey: vaultPubKey) + } + + // MARK: - EIP-191 Signing (keccak256 — matching Windows ethereumSigning.ts) + + private func ethereumSignHash(_ message: String) -> String { + // EIP-191: "\x19Ethereum Signed Message:\n" + len + message, then keccak256 + let prefixed = "\u{19}Ethereum Signed Message:\n\(message.count)\(message)" + let prefixedData = Data(prefixed.utf8) + let hash = prefixedData.sha3(.keccak256) + let hex = hash.hexString + #if DEBUG + print("[AgentAuth] #️⃣ EIP-191 keccak256 hash computed (input len=\(message.count))") + #endif + return hex + } + + private func generateAuthMessage(vault: Vault) throws -> String { + let address = deriveEthereumAddress(vault: vault) + let expiresAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(15 * 60)) + + let message: [String: Any] = [ + "message": "Sign into Vultisig Plugin Marketplace", + "nonce": generateNonce(), + "expiresAt": expiresAt, + "address": address + ] + + guard let data = try? JSONSerialization.data(withJSONObject: message, options: [.sortedKeys]), + let jsonString = String(data: data, encoding: .utf8) else { + throw AgentAuthError.authFailed + } + return jsonString + } + + /// Derive Ethereum address from vault's ECDSA public key using WalletCore HD derivation + private func deriveEthereumAddress(vault: Vault) -> String { + // Derive the chain-specific public key using HD key derivation (same as CoinFactory) + let derivedPubKeyHex = PublicKeyHelper.getDerivedPubKey( + hexPubKey: vault.pubKeyECDSA, + hexChainCode: vault.hexChainCode, + derivePath: CoinType.ethereum.derivationPath() + ) + + guard let pubKeyData = Data(hexString: derivedPubKeyHex), + let publicKey = WalletCore.PublicKey(data: pubKeyData, type: .secp256k1) else { + #if DEBUG + print("[AgentAuth] ⚠️ Failed to derive ETH address via WalletCore") + #endif + return "" + } + + let address = CoinType.ethereum.deriveAddressFromPublicKey(publicKey: publicKey) + #if DEBUG + print("[AgentAuth] 📍 Derived ETH address: \(address)") + #endif + return address + } + + private func generateNonce() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return "0x" + bytes.map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Fast Vault Keysign (delegates to shared FastVaultKeysignService) + + /// Uses the shared `FastVaultKeysignService` to perform the full MPC ceremony. + /// Formats the signature as "0x" + r + s + recoveryID (matching Windows' `formatKeysignSignatureHex`). + private func performFastVaultKeysign(vault: Vault, messageHash: String, password: String) async throws -> String { + let input = FastVaultKeysignInput( + vault: vault, + keysignMessages: [messageHash], + derivePath: CoinType.ethereum.derivationPath(), + isECDSA: true, + vaultPassword: password, + chain: "Ethereum" + ) + + #if DEBUG + print("[AgentAuth] ✍️ Delegating keysign to FastVaultKeysignService") + #endif + let result = try await FastVaultKeysignService.shared.keysign(input: input) + + // Extract signature for the message hash + guard let keysignResponse = result.signatures[messageHash] else { + #if DEBUG + print("[AgentAuth] ❌ No signature found for message hash in keysign result") + #endif + throw AgentAuthError.keysignFailed + } + + // Format as "0x" + r + s + recoveryID (matching Windows formatKeysignSignatureHex) + let signature = "0x" + keysignResponse.r + keysignResponse.s + keysignResponse.recoveryID + #if DEBUG + print("[AgentAuth] ✅ Signature formatted (length=\(signature.count))") + #endif + return signature + } + + // MARK: - Verifier API Calls + + private func authenticate(publicKey: String, chainCodeHex: String, signature: String, message: String) async throws -> AgentAuthResponse { + let url = URL(string: Endpoint.verifierAuth())! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "public_key": publicKey, + "chain_code_hex": chainCodeHex, + "signature": signature, + "message": message + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AgentAuthError.authFailed + } + + if httpResponse.statusCode != 200 { + let responseBody = String(data: data, encoding: .utf8) ?? "n/a" + #if DEBUG + print("[AgentAuth] ❌ Verifier returned \(httpResponse.statusCode): \(responseBody)") + #endif + throw AgentAuthError.authFailed + } + + do { + return try JSONDecoder().decode(AgentAuthResponse.self, from: data) + } catch { + #if DEBUG + print("[AgentAuth] ❌ Decoding failed: \(error)") + #endif + throw AgentAuthError.authFailed + } + } + + private func refreshAuthToken(refreshToken: String) async throws -> AgentAuthResponse { + let url = URL(string: Endpoint.verifierAuthRefresh())! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["refresh_token": refreshToken]) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw AgentAuthError.refreshFailed + } + + return try JSONDecoder().decode(AgentAuthResponse.self, from: data) + } + + private func validateTokenWithVerifier(accessToken: String) async throws { + let url = URL(string: Endpoint.verifierAuthMe())! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw AgentAuthError.validationFailed + } + } + + private func revokeAllTokens(accessToken: String) async throws { + let url = URL(string: Endpoint.verifierAuthRevokeAll())! + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + _ = try await URLSession.shared.data(for: request) + } + + // MARK: - Token Building + + private func buildAuthToken(accessToken: String, refreshToken: String, expiresIn: Int) -> AgentAuthToken { + let expiresAt: Date + if expiresIn > 0 { + expiresAt = Date().addingTimeInterval(TimeInterval(expiresIn)) + } else { + // Parse JWT expiry + expiresAt = parseJwtExpiry(token: accessToken) ?? Date().addingTimeInterval(3600) + } + + return AgentAuthToken(token: accessToken, refreshToken: refreshToken, expiresAt: expiresAt) + } + + private func parseJwtExpiry(token: String) -> Date? { + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + + var payload = String(parts[1]) + let remainder = payload.count % 4 + if remainder == 2 { payload += "==" } else if remainder == 3 { payload += "=" } + + let base64 = payload + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + guard let data = Data(base64Encoded: base64), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = json["exp"] as? TimeInterval else { + return nil + } + + return Date(timeIntervalSince1970: exp) + } + + // MARK: - Keychain Persistence + + private func persistToken(vaultPubKey: String, token: AgentAuthToken) { + guard let data = try? JSONEncoder().encode(token) else { return } + let key = keychainPrefix + vaultPubKey + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + + if status != errSecSuccess { + #if DEBUG + print("[AgentAuth] ❌ Failed to persist token in Keychain. OSStatus: \(status)") + #endif + } + } + + private func loadPersistedToken(vaultPubKey: String) -> AgentAuthToken? { + let key = keychainPrefix + vaultPubKey + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let data = item as? Data else { return nil } + return try? JSONDecoder().decode(AgentAuthToken.self, from: data) + } + + private func deletePersistedToken(vaultPubKey: String) { + let key = keychainPrefix + vaultPubKey + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - Errors + +enum AgentAuthError: Error, LocalizedError { + case emptyToken + case keysignFailed + case authFailed + case refreshFailed + case validationFailed + + var errorDescription: String? { + switch self { + case .emptyToken: return "Authentication returned an empty token" + case .keysignFailed: return "Fast vault keysign failed" + case .authFailed: return "Authentication failed" + case .refreshFailed: return "Token refresh failed" + case .validationFailed: return "Token validation failed" + } + } +} diff --git a/VultisigApp/VultisigApp/Services/Agent/AgentBackendClient.swift b/VultisigApp/VultisigApp/Services/Agent/AgentBackendClient.swift new file mode 100644 index 0000000000..88f780dc75 --- /dev/null +++ b/VultisigApp/VultisigApp/Services/Agent/AgentBackendClient.swift @@ -0,0 +1,437 @@ +// +// AgentBackendClient.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation +import OSLog + +final class AgentBackendClient { + + private let logger = Logger(subsystem: "com.vultisig", category: "AgentBackendClient") + + // MARK: - Shared formatters (allocated once, reused on every call) + + static let sharedEncoder = JSONEncoder() + static let sharedDecoder = JSONDecoder() + + /// Primary ISO-8601 formatter — matches timestamps WITH fractional seconds (e.g. 2026-03-06T12:34:56.789Z) + static let sharedISO8601: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + /// Fallback — matches timestamps WITHOUT fractional seconds (e.g. 2026-03-06T12:34:56Z) + /// Bug 5 fix: the backend sometimes omits fractional seconds; without a fallback those + /// dates silently became Date() and scrambled message ordering in loaded chats. + private static let sharedISO8601NoFrac: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// Parse an ISO-8601 string, trying fractional-second format first then falling back. + static func parseISO8601(_ string: String) -> Date? { + sharedISO8601.date(from: string) ?? sharedISO8601NoFrac.date(from: string) + } + + /// Builds a fresh URLSession for each SSE stream. + /// We use ephemeral configuration so there is NO shared connection pool: + /// HTTP/2 ignores the `Connection: close` header and reuses TCP streams, + /// causing -1017 "Connection reset by peer" when the server closes an + /// old SSE connection mid-session. A fresh ephemeral session avoids that. + private static func makeSseSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 300 + config.timeoutIntervalForResource = 600 + return URLSession(configuration: config) + } + + // MARK: - Errors + + enum AgentBackendError: Error, LocalizedError { + case unauthorized + case httpError(status: Int, message: String) + case noBody + case streamEndedWithoutMessage + + var errorDescription: String? { + switch self { + case .unauthorized: + return "Unauthorized – please reconnect" + case .httpError(let status, let message): + return "Error \(status): \(message)" + case .noBody: + return "No response body" + case .streamEndedWithoutMessage: + return "Stream ended without a message event" + } + } + } + + // MARK: - Conversations + + func createConversation(publicKey: String, token: String) async throws -> AgentConversation { + try await doRequest( + method: "POST", + url: Endpoint.agentConversations(), + token: token, + body: ["public_key": publicKey] + ) + } + + func listConversations(publicKey: String, skip: Int, take: Int, token: String) async throws -> AgentListConversationsResponse { + try await doRequest( + method: "POST", + url: Endpoint.agentConversationsList(), + token: token, + body: ["public_key": publicKey, "skip": skip, "take": take] as [String: Any] + ) + } + + func getConversation(id: String, publicKey: String, token: String) async throws -> AgentConversationWithMessages { + try await doRequest( + method: "POST", + url: Endpoint.agentConversation(id: id), + token: token, + body: ["public_key": publicKey] + ) + } + + func deleteConversation(id: String, publicKey: String, token: String) async throws { + let _: AgentEmptyResponse = try await doRequest( + method: "DELETE", + url: Endpoint.agentConversation(id: id), + token: token, + body: ["public_key": publicKey] + ) + } + + func getStarters(request: AgentGetStartersRequest, token: String) async throws -> AgentGetStartersResponse { + try await doRequest( + method: "POST", + url: Endpoint.agentStarters(), + token: token, + body: request + ) + } + + // MARK: - Send Message (non-streaming) + + func sendMessage(convId: String, request: AgentSendMessageRequest, token: String) async throws -> AgentSendMessageResponse { + try await doRequest( + method: "POST", + url: Endpoint.agentConversationMessages(id: convId), + token: token, + body: request + ) + } + + // MARK: - Send Message (SSE streaming) + + func sendMessageStream( + convId: String, + request: AgentSendMessageRequest, + token: String + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let url = URL(string: Endpoint.agentConversationMessages(id: convId))! + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept") + if !token.isEmpty { + urlRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + urlRequest.httpBody = try AgentBackendClient.sharedEncoder.encode(request) + + // Fresh ephemeral session per stream. HTTP/2 ignores Connection: close + // and reuses the same TCP stream across requests — creating a new session + // guarantees a new connection for this SSE stream. + let session = AgentBackendClient.makeSseSession() + defer { session.finishTasksAndInvalidate() } + let (bytes, response) = try await session.bytes(for: urlRequest) + #if DEBUG + print("[AgentBackend] 🌊 SSE response received") + #endif + + guard let httpResponse = response as? HTTPURLResponse else { + #if DEBUG + print("[AgentBackend] ❌ Not an HTTP response") + #endif + throw AgentBackendError.noBody + } + #if DEBUG + print("[AgentBackend] 🌊 SSE HTTP status: \(httpResponse.statusCode)") + #endif + #if DEBUG + print("[AgentBackend] 🌊 Content-Type: \(httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "nil")") + #endif + + if httpResponse.statusCode == 401 { + #if DEBUG + print("[AgentBackend] ❌ 401 Unauthorized") + #endif + throw AgentBackendError.unauthorized + } + + if httpResponse.statusCode >= 400 { + var body = "" + for try await line in bytes.lines { + body += line + } + let errMsg = Self.parseErrorMessage(from: body) ?? body + #if DEBUG + print("[AgentBackend] ❌ HTTP \(httpResponse.statusCode): \(errMsg)") + #endif + throw AgentBackendError.httpError(status: httpResponse.statusCode, message: errMsg) + } + + let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "" + + if !contentType.contains("text/event-stream") { + // Fallback to JSON + var body = "" + for try await line in bytes.lines { + body += line + } + if let data = body.data(using: .utf8) { + let decoded = try JSONDecoder().decode(AgentSendMessageResponse.self, from: data) + if let msg = decoded.message { + continuation.yield(.message(msg)) + } + } + continuation.finish() + return + } + + // Parse SSE + var currentEvent = "" + var hasMessage = false + var lineCount = 0 + + for try await line in bytes.lines { + if Task.isCancelled { + #if DEBUG + print("[AgentBackend] ⚠️ SSE task cancelled") + #endif + continuation.finish() + return + } + lineCount += 1 + #if DEBUG + print("[AgentBackend] 📄 SSE line #\(lineCount): \(line.prefix(120))") + #endif + + if line.hasPrefix("event: ") { + currentEvent = String(line.dropFirst(7)).trimmingCharacters(in: .whitespaces) + continue + } + + if line.hasPrefix("data: ") { + let jsonStr = String(line.dropFirst(6)).trimmingCharacters(in: .init(charactersIn: "\r")) + #if DEBUG + print("[AgentBackend] 📦 SSE data event='\(currentEvent)' json=\(jsonStr.prefix(200))") + #endif + + if let event = self.processSSEEvent(eventName: currentEvent, jsonStr: jsonStr) { + if case .message = event { + hasMessage = true + } + continuation.yield(event) + } else { + #if DEBUG + print("[AgentBackend] ⚠️ SSE processSSEEvent returned nil for event='\(currentEvent)'") + #endif + } + currentEvent = "" + continue + } + } + #if DEBUG + print("[AgentBackend] 🏁 SSE stream ended, total lines: \(lineCount), hasMessage: \(hasMessage)") + #endif + + if !hasMessage { + logger.warning("SSE stream ended without message event") + } + + continuation.finish() + + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + // MARK: - SSE Event Parser + + private func processSSEEvent(eventName: String, jsonStr: String) -> AgentSSEEvent? { + guard let data = jsonStr.data(using: .utf8) else { return nil } + + do { + switch eventName { + case "text_delta": + struct Delta: Decodable { let delta: String } + let parsed = try AgentBackendClient.sharedDecoder.decode(Delta.self, from: data) + return .textDelta(parsed.delta) + + case "title": + struct Title: Decodable { let title: String } + let parsed = try AgentBackendClient.sharedDecoder.decode(Title.self, from: data) + return .title(parsed.title) + + case "actions": + struct Actions: Decodable { let actions: [AgentBackendAction] } + let parsed = try AgentBackendClient.sharedDecoder.decode(Actions.self, from: data) + return .actions(parsed.actions) + + case "suggestions": + struct Suggestions: Decodable { let suggestions: [AgentBackendSuggestion] } + let parsed = try AgentBackendClient.sharedDecoder.decode(Suggestions.self, from: data) + return .suggestions(parsed.suggestions) + + case "tx_ready": + let parsed = try AgentBackendClient.sharedDecoder.decode(AgentTxReady.self, from: data) + return .txReady(parsed) + + case "tokens": + // Can be { tokens: [...] } or direct array + if let wrapper = try? AgentBackendClient.sharedDecoder.decode(TokensWrapper.self, from: data) { + return .tokens(wrapper.tokens) + } + let tokens = try AgentBackendClient.sharedDecoder.decode([AgentTokenSearchResult].self, from: data) + return .tokens(tokens) + + case "message": + struct MessageWrapper: Decodable { let message: AgentBackendMessage } + let parsed = try AgentBackendClient.sharedDecoder.decode(MessageWrapper.self, from: data) + return .message(parsed.message) + + case "error": + struct ErrorPayload: Decodable { let error: String? } + let parsed = try AgentBackendClient.sharedDecoder.decode(ErrorPayload.self, from: data) + return .error(parsed.error ?? "stream error") + + case "done": + return .done + + default: + logger.info("Unhandled SSE event: \(eventName)") + return nil + } + } catch { + logger.error("Failed to parse SSE event '\(eventName)': \(error.localizedDescription)") + return nil + } + } + + // MARK: - Generic Request + + private func doRequest(method: String, url: String, token: String, body: some Encodable) async throws -> T { + guard let requestUrl = URL(string: url) else { + throw AgentBackendError.httpError(status: 0, message: "Invalid URL: \(url)") + } + + var request = URLRequest(url: requestUrl) + request.httpMethod = method + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + if !token.isEmpty { + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.httpBody = try AgentBackendClient.sharedEncoder.encode(body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AgentBackendError.noBody + } + + if httpResponse.statusCode == 401 { + throw AgentBackendError.unauthorized + } + + if httpResponse.statusCode >= 400 { + let text = String(data: data, encoding: .utf8) ?? "" + let errMsg = Self.parseErrorMessage(from: text) ?? text + throw AgentBackendError.httpError(status: httpResponse.statusCode, message: errMsg) + } + + // Handle empty responses (e.g., DELETE) + if data.isEmpty || (String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespaces).isEmpty ?? true) { + if T.self == AgentEmptyResponse.self { + return AgentEmptyResponse() as! T + } + } + + return try AgentBackendClient.sharedDecoder.decode(T.self, from: data) + } + + // Overload for [String: Any] body (non-Encodable dictionaries) + private func doRequest(method: String, url: String, token: String, body: [String: Any]) async throws -> T { + guard let requestUrl = URL(string: url) else { + throw AgentBackendError.httpError(status: 0, message: "Invalid URL: \(url)") + } + + var request = URLRequest(url: requestUrl) + request.httpMethod = method + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + if !token.isEmpty { + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AgentBackendError.noBody + } + + if httpResponse.statusCode == 401 { + throw AgentBackendError.unauthorized + } + + if httpResponse.statusCode >= 400 { + let text = String(data: data, encoding: .utf8) ?? "" + let errMsg = Self.parseErrorMessage(from: text) ?? text + throw AgentBackendError.httpError(status: httpResponse.statusCode, message: errMsg) + } + + if data.isEmpty || (String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespaces).isEmpty ?? true) { + if T.self == AgentEmptyResponse.self { + return AgentEmptyResponse() as! T + } + } + + return try AgentBackendClient.sharedDecoder.decode(T.self, from: data) + } + + // MARK: - Helpers + + private static func parseErrorMessage(from text: String) -> String? { + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? String else { + return nil + } + return error + } +} + +// MARK: - Helper Types + +private struct AgentEmptyResponse: Decodable {} + +private struct TokensWrapper: Decodable { + let tokens: [AgentTokenSearchResult] +} diff --git a/VultisigApp/VultisigApp/Services/Agent/AgentContextBuilder.swift b/VultisigApp/VultisigApp/Services/Agent/AgentContextBuilder.swift new file mode 100644 index 0000000000..92901e6021 --- /dev/null +++ b/VultisigApp/VultisigApp/Services/Agent/AgentContextBuilder.swift @@ -0,0 +1,177 @@ +// +// AgentContextBuilder.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation +import SwiftData + +enum AgentContextBuilder { + + static let instructions = [ + "You are currently running inside the Vultisig iOS App.", + "Do NOT use external tools (like get_eth_balance or get_token_balance) for balances, portfolio, or addresses.", + "To fetch balances or prices, you MUST use the respond_to_user tool with actions: [{type: \"get_balances\"}] or actions: [{type: \"get_market_price\"}].", + "When a user asks for an address from another vault by name (e.g. 'get Savings vault solana address'), LOOK at the injected `context.all_vaults` JSON array in this prompt. Find the vault using fuzzy/partial name matching on the `name` field, and output the exact address found in its `addresses` object. NEVER say you don't have access to other vaults, because they are provided to you in `all_vaults`.", + "When a user asks to send funds to a name or contact (e.g. 'send SOL to Alice' or 'send to phantom'), check BOTH the `context.all_vaults` and `context.address_book` JSON arrays using fuzzy/partial name matching. If you find a single match, use that address and proceed with the sign_tx action. If it matches multiple entries or you are unsure if they mean a contact or an internal vault, ask the user to clarify before proceeding.", + "Prefer using your knowledge and the provided JSON conversation context over calling tools.", + "Only call a tool when you are missing information that you cannot find in the context JSON.", + "Use markdown formatting for readability." + ].joined(separator: " ") + + // MARK: - Context Cache + + /// Cached address-book and vault slices for the light context path. + /// Both are stable across messages in a single conversation session, so we + /// hold them for up to `cacheMaxAge` seconds before re-fetching. + private static var cachedAddressBook: [AgentAddressBookEntry]? = nil + private static var cachedAllVaults: [AgentVaultInfo]? = nil + private static var cacheTimestamp: Date? = nil + private static let cacheMaxAge: TimeInterval = 5 * 60 // 5 minutes + + /// Bug fix (bug 4): using non-nil Optionals as the cache sentinel is wrong when + /// `cachedAddressBook` legitimately stays nil for users with no address-book entries — + /// the `cachedAddressBook != nil && cachedAllVaults != nil` guard would never match + /// and would re-fetch from SwiftData on every message for those users. + /// A separate boolean flag correctly distinguishes "never populated" from "populated but empty". + private static var cachePopulated: Bool = false + + private static var isCacheValid: Bool { + guard cachePopulated, let ts = cacheTimestamp else { return false } + return Date().timeIntervalSince(ts) < cacheMaxAge + } + + /// Invalidate the cache. Call this whenever local vault or address-book data changes — + /// e.g. after add/remove token, add/remove chain, or add/delete address-book entry. + @MainActor static func invalidateCache() { + cachedAddressBook = nil + cachedAllVaults = nil + cacheTimestamp = nil + cachePopulated = false // Bug fix (bug 3): reset sentinel so next call re-fetches + } + + // MARK: - Full Context (first message) + + /// Build the message context from the current vault state + @MainActor static func buildContext(vault: Vault, balances: [AgentBalanceInfo]? = nil) -> AgentMessageContext { + var addresses: [String: String] = [:] + var coins: [AgentCoinInfo] = [] + + for coin in vault.coins { + // One address per chain from native tokens + if coin.isNativeToken && !coin.address.isEmpty { + if addresses[coin.chain.rawValue] == nil { + addresses[coin.chain.rawValue] = coin.address + } + } + + coins.append(AgentCoinInfo( + chain: coin.chain.rawValue, + ticker: coin.ticker, + contractAddress: coin.contractAddress.isEmpty ? nil : coin.contractAddress, + isNativeToken: coin.isNativeToken, + decimals: coin.decimals + )) + } + + // Fetch + cache address book and all vaults + let (addressBook, allVaults) = fetchAndCacheStaticSlices() + + return AgentMessageContext( + vaultAddress: vault.pubKeyECDSA, + vaultName: vault.name, + balances: balances, + addresses: addresses, + coins: coins, + addressBook: addressBook, + allVaults: allVaults, + instructions: instructions + ) + } + + // MARK: - Light Context (subsequent messages) + + /// Build a lightweight context for subsequent messages. + /// Skips coin enumeration and returns cached address-book / vault slices + /// rather than re-fetching from SwiftData on every message. + @MainActor static func buildLightContext(vault: Vault) -> AgentMessageContext { + let (addressBook, allVaults) = fetchAndCacheStaticSlices() + + // Only include vault-specific coin list (cheap — it's already loaded in memory) + var addresses: [String: String] = [:] + for coin in vault.coins where coin.isNativeToken && !coin.address.isEmpty { + if addresses[coin.chain.rawValue] == nil { + addresses[coin.chain.rawValue] = coin.address + } + } + + return AgentMessageContext( + vaultAddress: vault.pubKeyECDSA, + vaultName: vault.name, + balances: nil, + addresses: addresses, + coins: nil, // Omit full coin list on light requests + addressBook: addressBook, + allVaults: allVaults, + instructions: instructions + ) + } + + // MARK: - Internal Cache Helper + + @MainActor + private static func fetchAndCacheStaticSlices() -> (addressBook: [AgentAddressBookEntry]?, allVaults: [AgentVaultInfo]?) { + // Return cached values if they are still fresh (uses cachePopulated sentinel — not Optional nil-check) + if isCacheValid { + return (cachedAddressBook, cachedAllVaults) + } + + guard let modelContext = Storage.shared.modelContext else { + return (nil, nil) + } + + // --- Address Book --- + var addressBookEntries: [AgentAddressBookEntry]? = nil + if let items = try? modelContext.fetch(FetchDescriptor()), + !items.isEmpty { + addressBookEntries = items.map { + AgentAddressBookEntry( + title: $0.title, + address: $0.address, + chain: $0.coinMeta.chain.name + ) + } + } + + // --- All Vaults --- + var allVaults: [AgentVaultInfo]? = nil + if let vaults = try? modelContext.fetch(FetchDescriptor()) { + allVaults = vaults.map { v in + let nativeCoins = v.coins.filter { $0.isNativeToken } + var vaultAddresses: [String: String] = [:] + for coin in nativeCoins { + if !coin.address.isEmpty { + vaultAddresses[coin.chain.name] = coin.address + } + } + return AgentVaultInfo( + name: v.name, + pubKeyECDSA: v.pubKeyECDSA, + pubKeyEdDSA: v.pubKeyEdDSA, + pubKeyMLDSA44: v.publicKeyMLDSA44, + addresses: vaultAddresses + ) + } + } + + // Store in cache — set sentinel LAST so isCacheValid stays false if we crash mid-fetch + cachedAddressBook = addressBookEntries + cachedAllVaults = allVaults + cacheTimestamp = Date() + cachePopulated = true // Bug fix: sentinel marks cache as valid regardless of nil arrays + + return (addressBookEntries, allVaults) + } +} diff --git a/VultisigApp/VultisigApp/Services/Agent/AgentModels.swift b/VultisigApp/VultisigApp/Services/Agent/AgentModels.swift new file mode 100644 index 0000000000..915d98f0e2 --- /dev/null +++ b/VultisigApp/VultisigApp/Services/Agent/AgentModels.swift @@ -0,0 +1,626 @@ +// +// AgentModels.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation + +// MARK: - Request Types + +struct AgentSendMessageRequest: Codable { + let publicKey: String + var content: String? + var model: String? + var context: AgentMessageContext? + var selectedSuggestionId: String? + var actionResult: AgentActionResult? + + enum CodingKeys: String, CodingKey { + case publicKey = "public_key" + case content + case model + case context + case selectedSuggestionId = "selected_suggestion_id" + case actionResult = "action_result" + } +} + +struct AgentMessageContext: Codable { + var vaultAddress: String? + var vaultName: String? + var balances: [AgentBalanceInfo]? + var addresses: [String: String]? + var coins: [AgentCoinInfo]? + var addressBook: [AgentAddressBookEntry]? + var allVaults: [AgentVaultInfo]? + var instructions: String? + + enum CodingKeys: String, CodingKey { + case vaultAddress = "vault_address" + case vaultName = "vault_name" + case balances, addresses, coins + case addressBook = "address_book" + case allVaults = "all_vaults" + case instructions + } +} + +struct AgentVaultInfo: Codable { + let name: String + let pubKeyECDSA: String + let pubKeyEdDSA: String + var pubKeyMLDSA44: String? + let addresses: [String: String] + + enum CodingKeys: String, CodingKey { + case name + case pubKeyECDSA = "pubkey_ecdsa" + case pubKeyEdDSA = "pubkey_eddsa" + case pubKeyMLDSA44 = "pubkey_mldsa44" + case addresses + } +} + +struct AgentBalanceInfo: Codable { + let chain: String + let asset: String + let symbol: String + let amount: String + let decimals: Int +} + +struct AgentCoinInfo: Codable { + let chain: String + let ticker: String + var contractAddress: String? + let isNativeToken: Bool + let decimals: Int + + enum CodingKeys: String, CodingKey { + case chain, ticker + case contractAddress = "contract_address" + case isNativeToken = "is_native_token" + case decimals + } +} + +struct AgentAddressBookEntry: Codable { + let title: String + let address: String + let chain: String +} + +struct AgentActionResult: Codable { + let action: String + var actionId: String? + let success: Bool + var data: [String: AnyCodable]? + var error: String? + + enum CodingKeys: String, CodingKey { + case action + case actionId = "action_id" + case success, data, error + } +} + +struct AgentGetStartersRequest: Codable { + let publicKey: String + var context: AgentMessageContext? + + enum CodingKeys: String, CodingKey { + case publicKey = "public_key" + case context + } +} + +// MARK: - Response Types + +struct AgentSendMessageResponse: Codable { + var message: AgentBackendMessage? + var title: String? + var suggestions: [AgentBackendSuggestion]? + var actions: [AgentBackendAction]? + var policyReady: AgentPolicyReady? + var installRequired: AgentInstallRequired? + var txReady: AgentTxReady? + var tokens: [AgentTokenSearchResult]? + + enum CodingKeys: String, CodingKey { + case message, title, suggestions, actions + case policyReady = "policy_ready" + case installRequired = "install_required" + case txReady = "tx_ready" + case tokens + } +} + +struct AgentBackendMessage: Codable { + let id: String + let conversationId: String + let role: String + let content: String + let contentType: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case conversationId = "conversation_id" + case role, content + case contentType = "content_type" + case createdAt = "created_at" + } +} + +struct AgentBackendAction: Codable { + let id: String + let type: String + let title: String + var description: String? + var params: [String: AnyCodable]? + let autoExecute: Bool + + enum CodingKeys: String, CodingKey { + case id, type, title, description, params + case autoExecute = "auto_execute" + } +} + +struct AgentBackendSuggestion: Codable { + let id: String + let pluginId: String + let title: String + let description: String + + enum CodingKeys: String, CodingKey { + case id + case pluginId = "plugin_id" + case title, description + } +} + +struct AgentTxReady: Codable { + var provider: String? + var expectedOutput: String? + var minimumOutput: String? + var needsApproval: Bool? + var keysignPayload: String? + let fromChain: String + let fromSymbol: String + var toChain: String? + var toSymbol: String? + let amount: String + let sender: String + let destination: String + var txType: String? + + enum CodingKeys: String, CodingKey { + case provider + case expectedOutput = "expected_output" + case minimumOutput = "minimum_output" + case needsApproval = "needs_approval" + case keysignPayload = "keysign_payload" + case fromChain = "from_chain" + case fromSymbol = "from_symbol" + case toChain = "to_chain" + case toSymbol = "to_symbol" + case amount, sender, destination + case txType = "tx_type" + } +} + +struct AgentPolicyReady: Codable { + let pluginId: String + let configuration: [String: AnyCodable] + + enum CodingKeys: String, CodingKey { + case pluginId = "plugin_id" + case configuration + } +} + +struct AgentInstallRequired: Codable { + let pluginId: String + let title: String + let description: String + + enum CodingKeys: String, CodingKey { + case pluginId = "plugin_id" + case title, description + } +} + +struct AgentGetStartersResponse: Codable { + let starters: [String] +} + +struct AgentListConversationsResponse: Codable { + let conversations: [AgentConversation] + let totalCount: Int + + enum CodingKeys: String, CodingKey { + case conversations + case totalCount = "total_count" + } +} + +struct AgentTokenSearchResult: Codable { + var id: String? + let name: String + let symbol: String + var logo: String? + var logoUrl: String? + var priceUsd: String? + var marketCapRank: Int? + var deployments: [AgentTokenDeployment]? + + enum CodingKeys: String, CodingKey { + case id, name, symbol, logo + case logoUrl = "logo_url" + case priceUsd = "price_usd" + case marketCapRank = "market_cap_rank" + case deployments + } +} + +struct AgentTokenDeployment: Codable { + let chain: String + let contractAddress: String + var decimals: Int? + + enum CodingKeys: String, CodingKey { + case chain + case contractAddress = "contract_address" + case decimals + } +} + +// MARK: - Tool Calls (MCP) + +struct AgentAddTokenParams: Codable { + let tokens: [AgentTokenParam] +} + +struct AgentTokenParam: Codable { + let chain: String + let ticker: String + var contractAddress: String? + var decimals: Int? + var logo: String? + var priceProviderId: String? + var isNative: Bool? + + enum CodingKeys: String, CodingKey { + case chain, ticker, decimals, logo + case contractAddress = "contract_address" + case priceProviderId = "price_provider_id" + case isNative = "is_native" + } +} + +struct AgentAddTokenResult: Codable { + let chain: String + let ticker: String + var address: String? + var contractAddress: String? + let success: Bool + var error: String? + var chainAdded: Bool? + + enum CodingKeys: String, CodingKey { + case chain, ticker, address, success, error + case contractAddress = "contract_address" + case chainAdded = "chain_added" + } +} + +struct AgentAddChainParams: Codable { + let chains: [AgentChainParam] +} + +struct AgentChainParam: Codable { + let chain: String +} + +struct AgentAddChainResult: Codable { + let chain: String + var ticker: String? + var address: String? + let success: Bool + var error: String? +} + +struct AgentRemoveChainParams: Codable { + let chains: [AgentChainParam] +} + +struct AgentRemoveChainResult: Codable { + let chain: String + let success: Bool + var error: String? +} + +struct AgentRemoveTokenParams: Codable { + let tokens: [AgentTokenParam] +} + +struct AgentRemoveTokenResult: Codable { + let chain: String + let ticker: String + let success: Bool + var error: String? +} + +struct AgentGetAddressBookParams: Codable { + var chain: String? + var query: String? +} + +struct AgentGetAddressBookResult: Codable { + let entries: [AgentAddressBookEntryResult] + let totalCount: Int + + enum CodingKeys: String, CodingKey { + case entries + case totalCount = "total_count" + } +} + +struct AgentAddressBookEntryResult: Codable { + let id: String + let title: String + let address: String + let chain: String + var chainKind: String? + + enum CodingKeys: String, CodingKey { + case id, title, address, chain + case chainKind = "chain_kind" + } +} + +struct AgentAddAddressBookParams: Codable { + let entries: [AgentAddAddressBookEntryParam] +} + +struct AgentAddAddressBookEntryParam: Codable { + var title: String? + var name: String? + let address: String + let chain: String + + /// The backend may send "name" instead of "title" + var resolvedTitle: String { + title ?? name ?? "Untitled" + } +} + +struct AgentAddAddressBookResult: Codable { + let id: String + let title: String + let address: String + let chain: String + let success: Bool + var error: String? +} + +struct AgentDeleteAddressBookParams: Codable { + let entries: [AgentDeleteAddressBookEntryParam] +} + +struct AgentDeleteAddressBookEntryParam: Codable { + var id: String? + var title: String? + var name: String? + var address: String? + var chain: String? + + /// The backend may send "name" instead of "title" + var resolvedTitle: String? { + title ?? name + } +} + +struct AgentDeleteAddressBookResult: Codable { + var id: String? + let title: String? + let chain: String? + let success: Bool + var error: String? +} + +// MARK: - Conversation + +struct AgentConversation: Codable, Identifiable, Hashable { + let id: String + let publicKey: String + var title: String? + let createdAt: String + let updatedAt: String + var archivedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case publicKey = "public_key" + case title + case createdAt = "created_at" + case updatedAt = "updated_at" + case archivedAt = "archived_at" + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: AgentConversation, rhs: AgentConversation) -> Bool { + lhs.id == rhs.id + } +} + +struct AgentConversationWithMessages: Codable { + let id: String + let publicKey: String + var title: String? + let createdAt: String + let updatedAt: String + let messages: [AgentBackendMessage] + + enum CodingKeys: String, CodingKey { + case id + case publicKey = "public_key" + case title + case createdAt = "created_at" + case updatedAt = "updated_at" + case messages + } +} + +// MARK: - Auth Token + +struct AgentAuthToken: Codable { + let token: String + let refreshToken: String + let expiresAt: Date +} + +struct AgentAuthData: Codable { + let accessToken: String + let refreshToken: String + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + } +} + +struct AgentAuthResponse: Codable { + let data: AgentAuthData +} + +// MARK: - SSE Event + +enum AgentSSEEvent { + case textDelta(String) + case title(String) + case actions([AgentBackendAction]) + case suggestions([AgentBackendSuggestion]) + case txReady(AgentTxReady) + case tokens([AgentTokenSearchResult]) + case message(AgentBackendMessage) + case error(String) + case done +} + +// MARK: - Chat UI Types + +struct AgentChatMessage: Identifiable { + let id: String + let role: AgentChatRole + var content: String + let timestamp: Date + var toolCall: AgentToolCallInfo? + var txStatus: AgentTxStatusInfo? + var tokenResults: [AgentTokenSearchResult]? + var txProposal: AgentTxReady? + /// True while the SSE stream is still delivering characters. + /// The view renders plain text (no Markdown parsing) while this is true. + var isStreaming: Bool = false +} + +enum AgentChatRole { + case user + case assistant +} + +struct AgentToolCallInfo { + let actionType: String + let title: String + var params: [String: AnyCodable]? + var status: AgentToolCallStatus + var resultData: [String: AnyCodable]? + var error: String? +} + +enum AgentToolCallStatus { + case running + case success + case error +} + +struct AgentTxStatusInfo { + let txHash: String + let chain: String + var status: AgentTxStatus + let label: String +} + +enum AgentTxStatus { + case pending + case confirmed + case failed +} + +// MARK: - AnyCodable Helper + +struct AnyCodable: Codable, Hashable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map(\.value) + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues(\.value) + } else { + value = NSNull() + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case is NSNull: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + default: + try container.encodeNil() + } + } + + static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + String(describing: lhs.value) == String(describing: rhs.value) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(String(describing: value)) + } +} diff --git a/VultisigApp/VultisigApp/Services/Agent/AgentToolExecutor.swift b/VultisigApp/VultisigApp/Services/Agent/AgentToolExecutor.swift new file mode 100644 index 0000000000..aa122680df --- /dev/null +++ b/VultisigApp/VultisigApp/Services/Agent/AgentToolExecutor.swift @@ -0,0 +1,721 @@ +// +// AgentToolExecutor.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation +import SwiftData + +@MainActor +final class AgentToolExecutor { + + static func execute(action: AgentBackendAction, vault: Vault) async -> AgentActionResult { + switch action.type { + + // MARK: Chain & Token Management (iOS-side) + case "add_token", "add_coin": + let result = await executeAddToken(action: action, vault: vault) + if result.success { AgentContextBuilder.invalidateCache() } + return result + case "add_chain": + let result = await executeAddChain(action: action, vault: vault) + if result.success { AgentContextBuilder.invalidateCache() } + return result + case "remove_coin": + let result = executeRemoveToken(action: action, vault: vault) + if result.success { AgentContextBuilder.invalidateCache() } + return result + case "remove_chain": + let result = executeRemoveChain(action: action, vault: vault) + if result.success { AgentContextBuilder.invalidateCache() } + return result + case "search_token": + return executeSearchToken(action: action, vault: vault) + + // MARK: Vault Info (iOS-side) + case "list_vaults": + return executeListVaults(action: action) + case "get_addresses": + return executeGetAddresses(action: action, vault: vault) + case "get_balances": + return executeGetBalances(action: action, vault: vault) + case "get_portfolio": + return executeGetPortfolio(action: action, vault: vault) + case "get_market_price": + return executeGetMarketPrice(action: action, vault: vault) + + // MARK: Address Book (iOS-side) + case "get_address_book": + return executeGetAddressBook(action: action) + case "add_address_book", "address_book_add": + let result = executeAddAddressBook(action: action) + if result.success { AgentContextBuilder.invalidateCache() } + return result + case "delete_address_book", "address_book_remove": + let result = executeDeleteAddressBook(action: action) + if result.success { AgentContextBuilder.invalidateCache() } + return result + + // MARK: Signing (requires iOS Keysign flow - not auto-executeable) + case "sign_tx", "sign_transaction_bundle": + return buildErrorResult(action: action, error: "sign_requires_keysign_flow") + + // MARK: Server-side only (handled by agent-backend/MCP, should not reach here) + case "build_swap_tx", "build_send_tx", "build_custom_tx", + "plugin_install", "create_policy", "delete_policy", + "read_evm_contract", "scan_tx", "thorchain_query", + "build_btc_send", "build_evm_tx", "build_utxo_tx", + "get_eth_balance", "get_token_balance", "get_utxo_balance", + "get_utxo_transactions", "list_utxos", "convert_amount", + "abi_encode", "abi_decode", "evm_call", "evm_tx_info", "btc_fee_rate": + return buildErrorResult(action: action, error: "handled_server_side") + + default: + return buildErrorResult(action: action, error: "unknown_action_type") + } + } + + // MARK: - List Vaults + + private static func executeListVaults(action: AgentBackendAction) -> AgentActionResult { + guard let context = Storage.shared.modelContext else { + return buildErrorResult(action: action, error: "storage_unavailable") + } + do { + var vaults = try context.fetch(FetchDescriptor()) + + // Support optional search/filter by partial vault name (case-insensitive) + if let paramsDict = action.params, + let searchValue = paramsDict["search"]?.value as? String, + !searchValue.isEmpty { + vaults = vaults.filter { $0.name.localizedCaseInsensitiveContains(searchValue) } + } + + let result = vaults.map { v in + let nativeCoins = v.coins.filter { $0.isNativeToken } + var addresses: [String: String] = [:] + for coin in nativeCoins { + addresses[coin.chain.name] = coin.address + } + var entry: [String: Any] = [ + "name": v.name, + "pubkey_ecdsa": v.pubKeyECDSA, + "pubkey_eddsa": v.pubKeyEdDSA, + "chains": nativeCoins.map { $0.chain.name }, + "addresses": addresses, + "is_fast_vault": v.isFastVault, + "created_at": ISO8601DateFormatter().string(from: v.createdAt) + ] + if let mldsa = v.publicKeyMLDSA44, !mldsa.isEmpty { + entry["pubkey_mldsa44"] = mldsa + } + return entry + } + return buildSuccessResult(action: action, data: ["vaults": result, "count": vaults.count]) + } catch { + return buildErrorResult(action: action, error: error.localizedDescription) + } + } + + // MARK: - Get Addresses + + private static func executeGetAddresses(action: AgentBackendAction, vault: Vault) -> AgentActionResult { + let chainParam: String? + let vaultNameParam: String? + + if let paramsDict = action.params, + let paramsData = try? JSONEncoder().encode(paramsDict), + let decoded = try? JSONDecoder().decode([String: String].self, from: paramsData) { + chainParam = decoded["chain"] + vaultNameParam = decoded["vault_name"] + } else { + chainParam = nil + vaultNameParam = nil + } + + // Resolve target vault: if vault_name is provided, look it up with wildcard matching + var targetVault = vault + if let searchName = vaultNameParam, !searchName.isEmpty { + guard let context = Storage.shared.modelContext else { + return buildErrorResult(action: action, error: "storage_unavailable") + } + do { + let allVaults = try context.fetch(FetchDescriptor()) + if let matched = allVaults.first(where: { $0.name.localizedCaseInsensitiveContains(searchName) }) { + targetVault = matched + } else { + return buildErrorResult(action: action, error: "vault_not_found: no vault matching '\(searchName)'") + } + } catch { + return buildErrorResult(action: action, error: error.localizedDescription) + } + } + + var coins = targetVault.coins.filter { $0.isNativeToken } + if let filter = chainParam, !filter.isEmpty { + coins = coins.filter { $0.chain.rawValue.lowercased() == filter.lowercased() || $0.chain.name.lowercased() == filter.lowercased() } + } + + let addresses = coins.map { coin -> [String: String] in + return [ + "chain": coin.chain.name, + "ticker": coin.ticker, + "address": coin.address + ] + } + + var resultData: [String: Any] = [ + "addresses": addresses, + "vault_name": targetVault.name, + "pubkey_ecdsa": targetVault.pubKeyECDSA, + "pubkey_eddsa": targetVault.pubKeyEdDSA + ] + if let mldsa = targetVault.publicKeyMLDSA44, !mldsa.isEmpty { + resultData["pubkey_mldsa44"] = mldsa + } + return buildSuccessResult(action: action, data: resultData) + } + + // MARK: - Search Token + + private static func executeSearchToken(action: AgentBackendAction, vault: Vault) -> AgentActionResult { + guard let paramsDict = action.params else { + return buildErrorResult(action: action, error: "missing_query_param") + } + + let query: String + if let q = paramsDict["query"]?.value as? String { + query = q + } else { + return buildErrorResult(action: action, error: "missing_query_param") + } + + let chainFilter = paramsDict["chain"]?.value as? String + let queryLower = query.lowercased() + + // Search across the full local TokensStore + var matches = TokensStore.TokenSelectionAssets.filter { asset in + asset.ticker.lowercased().contains(queryLower) || + asset.chain.name.lowercased().contains(queryLower) || + asset.contractAddress.lowercased().contains(queryLower) + } + + if let chainFilter = chainFilter, !chainFilter.isEmpty { + matches = matches.filter { $0.chain.rawValue.lowercased() == chainFilter.lowercased() || $0.chain.name.lowercased() == chainFilter.lowercased() } + } + + let results = matches.prefix(20).map { asset -> [String: Any] in + let alreadyInVault = vault.coin(for: asset) != nil + return [ + "chain": asset.chain.name, + "ticker": asset.ticker, + "contract_address": asset.contractAddress, + "decimals": asset.decimals, + "is_native": asset.isNativeToken, + "logo": asset.logo, + "already_in_vault": alreadyInVault + ] + } + + return buildSuccessResult(action: action, data: ["results": Array(results), "count": results.count]) + } + + // MARK: - Add Token + + private static func executeAddToken(action: AgentBackendAction, vault: Vault) async -> AgentActionResult { + guard let paramsDict = action.params, + let paramsData = try? JSONEncoder().encode(paramsDict) else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var tokensToProcess: [AgentTokenParam] = [] + if let multiParams = try? JSONDecoder().decode(AgentAddTokenParams.self, from: paramsData) { + tokensToProcess = multiParams.tokens + } else if let singleParam = try? JSONDecoder().decode(AgentTokenParam.self, from: paramsData) { + tokensToProcess = [singleParam] + } else { + return buildErrorResult(action: action, error: "invalid_params") + } + + // Process tokens concurrently + let tokenResults: [AgentAddTokenResult] = await withTaskGroup(of: AgentAddTokenResult.self) { group in + for tokenParam in tokensToProcess { + group.addTask { + guard let chainObj = Chain.allCases.first(where: { $0.rawValue.lowercased() == tokenParam.chain.lowercased() }) else { + return AgentAddTokenResult(chain: tokenParam.chain, ticker: tokenParam.ticker, address: nil, contractAddress: tokenParam.contractAddress, success: false, error: "unknown_chain") + } + + let isNative = tokenParam.isNative ?? (tokenParam.contractAddress == nil || tokenParam.contractAddress!.isEmpty) + // Use TokensStore to resolve native token decimals — MUST filter by isNativeToken + // to avoid picking a non-native coin (e.g. JUP decimals:6 instead of SOL decimals:9 on Solana) + let defaultDecimals = TokensStore.TokenSelectionAssets.first(where: { $0.chain == chainObj && $0.isNativeToken })?.decimals ?? 18 + let decimals = tokenParam.decimals ?? (isNative ? defaultDecimals : 18) + + let coinMeta = CoinMeta( + chain: chainObj, + ticker: tokenParam.ticker, + logo: tokenParam.logo ?? chainObj.logo, + decimals: decimals, + priceProviderId: tokenParam.priceProviderId ?? "", + contractAddress: tokenParam.contractAddress ?? "", + isNativeToken: isNative + ) + + let hasChain = vault.coins.contains(where: { $0.chain == chainObj && $0.isNativeToken }) + + do { + if let newCoin = try await CoinService.addIfNeeded(asset: coinMeta, to: vault, priceProviderId: coinMeta.priceProviderId) { + if isNative { + await CoinService.addDiscoveredTokens(nativeToken: newCoin, to: vault) + } + await MainActor.run { + if !vault.defiChains.contains(chainObj) && vault.availableDefiChains.contains(chainObj) { + vault.defiChains.append(chainObj) + } + } + return AgentAddTokenResult( + chain: chainObj.rawValue, + ticker: newCoin.ticker, + address: newCoin.address, + contractAddress: newCoin.contractAddress, + success: true, + error: nil, + chainAdded: !hasChain + ) + } else { + return AgentAddTokenResult(chain: chainObj.rawValue, ticker: tokenParam.ticker, contractAddress: tokenParam.contractAddress, success: false, error: "failed_to_add") + } + } catch { + return AgentAddTokenResult(chain: chainObj.rawValue, ticker: tokenParam.ticker, contractAddress: tokenParam.contractAddress, success: false, error: error.localizedDescription) + } + } + } + var collected: [AgentAddTokenResult] = [] + for await result in group { collected.append(result) } + return collected + } + + let anySuccess = tokenResults.contains(where: { $0.success }) + if anySuccess { + try? Storage.shared.save() + } + + return AgentActionResult(action: action.type, actionId: action.id, success: tokenResults.contains(where: { $0.success }), data: encodeToAnyCodableDict(["results": tokenResults])) + } + + // MARK: - Add Chain + + private static func executeAddChain(action: AgentBackendAction, vault: Vault) async -> AgentActionResult { + guard let paramsDict = action.params, + let paramsData = try? JSONEncoder().encode(paramsDict) else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var chainsToProcess: [AgentChainParam] = [] + if let multiParams = try? JSONDecoder().decode(AgentAddChainParams.self, from: paramsData) { + chainsToProcess = multiParams.chains + } else if let singleParam = try? JSONDecoder().decode(AgentChainParam.self, from: paramsData) { + chainsToProcess = [singleParam] + } else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var results: [AgentAddChainResult] = [] + var anySuccess = false + + for chainParam in chainsToProcess { + guard let chainObj = Chain.allCases.first(where: { $0.rawValue.lowercased() == chainParam.chain.lowercased() }) else { + results.append(AgentAddChainResult(chain: chainParam.chain, success: false, error: "unknown_chain")) + continue + } + + if vault.coins.contains(where: { $0.chain == chainObj && $0.isNativeToken }) { + results.append(AgentAddChainResult(chain: chainObj.rawValue, success: false, error: "already_exists")) + continue + } + + // Filter by isNativeToken to get correct chain decimals (e.g. SOL=9, not JUP=6) + let defaultDecimals = TokensStore.TokenSelectionAssets.first(where: { $0.chain == chainObj && $0.isNativeToken })?.decimals ?? 18 + // Native fee coin asset + let feeAsset = CoinMeta( + chain: chainObj, + ticker: chainObj.ticker, + logo: chainObj.logo, + decimals: defaultDecimals, + priceProviderId: "", + contractAddress: "", + isNativeToken: true + ) + + do { + if let newCoin = try CoinService.addIfNeeded(asset: feeAsset, to: vault, priceProviderId: nil) { + await CoinService.addDiscoveredTokens(nativeToken: newCoin, to: vault) + results.append(AgentAddChainResult( + chain: chainObj.rawValue, + ticker: newCoin.ticker, + address: newCoin.address, + success: true, + error: nil + )) + anySuccess = true + + if !vault.defiChains.contains(chainObj) && vault.availableDefiChains.contains(chainObj) { + vault.defiChains.append(chainObj) + } + } else { + results.append(AgentAddChainResult(chain: chainObj.rawValue, success: false, error: "failed_to_add")) + } + } catch { + results.append(AgentAddChainResult(chain: chainObj.rawValue, success: false, error: error.localizedDescription)) + } + } + + if anySuccess { + try? Storage.shared.save() + } + + return AgentActionResult(action: action.type, actionId: action.id, success: results.contains(where: { $0.success }), data: encodeToAnyCodableDict(["results": results])) + } + + // MARK: - Remove Token + + private static func executeRemoveToken(action: AgentBackendAction, vault: Vault) -> AgentActionResult { + guard let paramsDict = action.params, + let paramsData = try? JSONEncoder().encode(paramsDict) else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var tokensToProcess: [AgentTokenParam] = [] + if let multiParams = try? JSONDecoder().decode(AgentRemoveTokenParams.self, from: paramsData) { + tokensToProcess = multiParams.tokens + } else if let singleParam = try? JSONDecoder().decode(AgentTokenParam.self, from: paramsData) { + tokensToProcess = [singleParam] + } else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var results: [AgentRemoveTokenResult] = [] + var anySuccess = false + + for tokenParam in tokensToProcess { + guard let chainObj = Chain.allCases.first(where: { $0.rawValue.lowercased() == tokenParam.chain.lowercased() }) else { + results.append(AgentRemoveTokenResult(chain: tokenParam.chain, ticker: tokenParam.ticker, success: false, error: "unknown_chain")) + continue + } + + // Find the coins to remove + let coinsToRemove = vault.coins.filter { + $0.chain == chainObj && + $0.ticker.caseInsensitiveCompare(tokenParam.ticker) == .orderedSame && + ((tokenParam.contractAddress == nil || tokenParam.contractAddress!.isEmpty) || $0.contractAddress.caseInsensitiveCompare(tokenParam.contractAddress!) == .orderedSame) + } + + if coinsToRemove.isEmpty { + results.append(AgentRemoveTokenResult(chain: chainObj.rawValue, ticker: tokenParam.ticker, success: true, error: nil)) + continue + } + + do { + try CoinService.removeCoins(coins: coinsToRemove, vault: vault) + results.append(AgentRemoveTokenResult(chain: chainObj.rawValue, ticker: tokenParam.ticker, success: true, error: nil)) + anySuccess = true + } catch { + results.append(AgentRemoveTokenResult(chain: chainObj.rawValue, ticker: tokenParam.ticker, success: false, error: error.localizedDescription)) + } + } + + if anySuccess { + try? Storage.shared.save() + } + + return AgentActionResult(action: action.type, actionId: action.id, success: results.contains(where: { $0.success }), data: encodeToAnyCodableDict(["results": results])) + } + + // MARK: - Remove Chain + + private static func executeRemoveChain(action: AgentBackendAction, vault: Vault) -> AgentActionResult { + guard let paramsDict = action.params, + let paramsData = try? JSONEncoder().encode(paramsDict) else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var chainsToProcess: [AgentChainParam] = [] + if let multiParams = try? JSONDecoder().decode(AgentRemoveChainParams.self, from: paramsData) { + chainsToProcess = multiParams.chains + } else if let singleParam = try? JSONDecoder().decode(AgentChainParam.self, from: paramsData) { + chainsToProcess = [singleParam] + } else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var results: [AgentRemoveChainResult] = [] + var anySuccess = false + + for chainParam in chainsToProcess { + guard let chainObj = Chain.allCases.first(where: { $0.rawValue.lowercased() == chainParam.chain.lowercased() }) else { + results.append(AgentRemoveChainResult(chain: chainParam.chain, success: false, error: "unknown_chain")) + continue + } + + let vaultCoinsForChain = vault.coins.filter { $0.chain == chainObj } + + if vaultCoinsForChain.isEmpty { + results.append(AgentRemoveChainResult(chain: chainObj.rawValue, success: true, error: nil)) + continue + } + + do { + CoinService.clearHiddenTokensForChain(chainObj, vault: vault) + try CoinService.removeCoins(coins: vaultCoinsForChain, vault: vault) + vault.defiChains.removeAll(where: { $0 == chainObj }) + + results.append(AgentRemoveChainResult(chain: chainObj.rawValue, success: true, error: nil)) + anySuccess = true + } catch { + results.append(AgentRemoveChainResult(chain: chainObj.rawValue, success: false, error: error.localizedDescription)) + } + } + + if anySuccess { + try? Storage.shared.save() + } + + return AgentActionResult(action: action.type, actionId: action.id, success: results.contains(where: { $0.success }), data: encodeToAnyCodableDict(["results": results])) + } + + // MARK: - Get Address Book + + private static func executeGetAddressBook(action: AgentBackendAction) -> AgentActionResult { + guard let context = Storage.shared.modelContext else { + return buildErrorResult(action: action, error: "storage_unavailable") + } + + let params: AgentGetAddressBookParams? + if let paramsDict = action.params, let paramsData = try? JSONEncoder().encode(paramsDict) { + params = try? JSONDecoder().decode(AgentGetAddressBookParams.self, from: paramsData) + } else { + params = nil + } + + do { + let descriptor = FetchDescriptor() + var allItems = try context.fetch(descriptor) + + if let chainParam = params?.chain, !chainParam.isEmpty { + let param = chainParam.lowercased() + allItems = allItems.filter { + $0.coinMeta.chain.rawValue.lowercased() == param || + $0.coinMeta.chain.name.lowercased() == param + } + } + if let query = params?.query, !query.isEmpty { + let lowerQuery = query.lowercased() + allItems = allItems.filter { $0.title.lowercased().contains(lowerQuery) || $0.address.lowercased().contains(lowerQuery) } + } + + let results = allItems.map { item in + AgentAddressBookEntryResult( + id: item.id.uuidString, + title: item.title, + address: item.address, + chain: item.coinMeta.chain.rawValue, + chainKind: String(describing: item.coinMeta.chain.chainType) + ) + } + + let response = AgentGetAddressBookResult(entries: results, totalCount: results.count) + return AgentActionResult(action: action.type, actionId: action.id, success: true, data: encodeToAnyCodableDict(response)) + + } catch { + return buildErrorResult(action: action, error: error.localizedDescription) + } + } + + // MARK: - Add Address Book + + private static func executeAddAddressBook(action: AgentBackendAction) -> AgentActionResult { + guard let paramsDict = action.params, + let paramsData = try? JSONEncoder().encode(paramsDict) else { + return buildErrorResult(action: action, error: "invalid_params") + } + + // Support both {entries: [...]} and flat {name, address, chain} + var entriesToProcess: [AgentAddAddressBookEntryParam] = [] + if let multiParams = try? JSONDecoder().decode(AgentAddAddressBookParams.self, from: paramsData) { + entriesToProcess = multiParams.entries + } else if let singleParam = try? JSONDecoder().decode(AgentAddAddressBookEntryParam.self, from: paramsData) { + entriesToProcess = [singleParam] + } else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var results: [AgentAddAddressBookResult] = [] + var anySuccess = false + + for entryParam in entriesToProcess { + let chainName = entryParam.chain + guard let chainObj = Chain.allCases.first(where: { + $0.rawValue.lowercased() == chainName.lowercased() || + $0.name.lowercased() == chainName.lowercased() + }) else { + results.append(AgentAddAddressBookResult(id: "", title: entryParam.resolvedTitle, address: entryParam.address, chain: chainName, success: false, error: "unknown_chain")) + continue + } + + // Filter by isNativeToken to get correct chain decimals (e.g. SOL=9, not JUP=6) + let defaultDecimals = TokensStore.TokenSelectionAssets.first(where: { $0.chain == chainObj && $0.isNativeToken })?.decimals ?? 18 + let meta = CoinMeta(chain: chainObj, ticker: chainObj.ticker, logo: chainObj.logo, decimals: defaultDecimals, priceProviderId: "", contractAddress: "", isNativeToken: true) + let item = AddressBookItem(title: entryParam.resolvedTitle, address: entryParam.address, coinMeta: meta, order: 0) + + Storage.shared.insert(item) + results.append(AgentAddAddressBookResult( + id: item.id.uuidString, + title: entryParam.resolvedTitle, + address: entryParam.address, + chain: chainObj.rawValue, + success: true, + error: nil + )) + anySuccess = true + } + + if anySuccess { + try? Storage.shared.save() + } + + return AgentActionResult(action: action.type, actionId: action.id, success: results.contains(where: { $0.success }), data: encodeToAnyCodableDict(["results": results])) + } + + // MARK: - Delete Address Book + + private static func executeDeleteAddressBook(action: AgentBackendAction) -> AgentActionResult { + guard let context = Storage.shared.modelContext else { + return buildErrorResult(action: action, error: "storage_unavailable") + } + + guard let paramsDict = action.params, + let paramsData = try? JSONEncoder().encode(paramsDict) else { + return buildErrorResult(action: action, error: "invalid_params") + } + + // Support both {entries: [...]} and flat {name, address, chain} + var entriesToProcess: [AgentDeleteAddressBookEntryParam] = [] + if let multiParams = try? JSONDecoder().decode(AgentDeleteAddressBookParams.self, from: paramsData) { + entriesToProcess = multiParams.entries + } else if let singleParam = try? JSONDecoder().decode(AgentDeleteAddressBookEntryParam.self, from: paramsData) { + entriesToProcess = [singleParam] + } else { + return buildErrorResult(action: action, error: "invalid_params") + } + + var results: [AgentDeleteAddressBookResult] = [] + var anySuccess = false + + do { + let allItems = try context.fetch(FetchDescriptor()) + + for param in entriesToProcess { + var match: AddressBookItem? + + if let idParam = param.id, let uuid = UUID(uuidString: idParam) { + match = allItems.first(where: { $0.id == uuid }) + } else if let title = param.resolvedTitle, let chain = param.chain { + match = allItems.first(where: { + $0.title.lowercased() == title.lowercased() && + ($0.coinMeta.chain.rawValue.lowercased() == chain.lowercased() || + $0.coinMeta.chain.name.lowercased() == chain.lowercased()) + }) + } else if let address = param.address, let chain = param.chain { + match = allItems.first(where: { + $0.address.lowercased() == address.lowercased() && + ($0.coinMeta.chain.rawValue.lowercased() == chain.lowercased() || + $0.coinMeta.chain.name.lowercased() == chain.lowercased()) + }) + } else if let title = param.resolvedTitle { + match = allItems.first(where: { $0.title.lowercased() == title.lowercased() }) + } else if let address = param.address { + match = allItems.first(where: { $0.address.lowercased() == address.lowercased() }) + } + + if let found = match { + Storage.shared.delete(found) + results.append(AgentDeleteAddressBookResult(id: found.id.uuidString, title: found.title, chain: found.coinMeta.chain.rawValue, success: true, error: nil)) + anySuccess = true + } else { + results.append(AgentDeleteAddressBookResult(id: param.id, title: param.resolvedTitle, chain: param.chain, success: false, error: "not_found")) + } + } + + if anySuccess { + try? Storage.shared.save() + } + + } catch { + return buildErrorResult(action: action, error: error.localizedDescription) + } + + return AgentActionResult(action: action.type, actionId: action.id, success: results.contains(where: { $0.success }), data: encodeToAnyCodableDict(["results": results])) + } + + // MARK: - Read-only Context Builders + + private static func executeGetBalances(action: AgentBackendAction, vault: Vault) -> AgentActionResult { + let balances = vault.coins.map { coin in + return [ + "chain": coin.chain.name, + "ticker": coin.ticker, + "balance": coin.balanceString, + "fiatBalance": RateProvider.shared.fiatBalanceString(for: coin) + ] + } + return buildSuccessResult(action: action, data: ["balances": balances]) + } + + private static func executeGetPortfolio(action: AgentBackendAction, vault: Vault) -> AgentActionResult { + let totalFiat = vault.coins.reduce(Decimal.zero) { $0 + RateProvider.shared.fiatBalance(for: $1) } + return buildSuccessResult(action: action, data: ["totalFiatBalance": totalFiat.formatToFiat(includeCurrencySymbol: true)]) + } + + private static func executeGetMarketPrice(action: AgentBackendAction, vault: Vault) -> AgentActionResult { + guard let params = action.params, + let assetProvider = params["asset"]?.value as? String else { + return buildErrorResult(action: action, error: "missing_asset_param") + } + + let asset = assetProvider.lowercased() + + if let coin = vault.coins.first(where: { $0.ticker.lowercased() == asset }) { + if let rate = RateProvider.shared.rate(for: coin) { + return buildSuccessResult(action: action, data: [ + "asset": coin.ticker, + "price": rate.value, + "fiat": rate.fiat + ]) + } + } + + return buildErrorResult(action: action, error: "price_not_found_in_cache") + } + + // MARK: - Helpers + + private static func buildErrorResult(action: AgentBackendAction, error: String) -> AgentActionResult { + return AgentActionResult(action: action.type, actionId: action.id, success: false, data: nil, error: error) + } + + private static func buildSuccessResult(action: AgentBackendAction, data: [String: Any]) -> AgentActionResult { + let codableData = data.mapValues { AnyCodable($0) } + return AgentActionResult(action: action.type, actionId: action.id, success: true, data: codableData, error: nil) + } + + /// Encodes any Encodable value to [String: AnyCodable] via JSON round-trip. + private static func encodeToAnyCodableDict(_ value: T) -> [String: AnyCodable] { + guard let data = try? JSONEncoder().encode(value), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return dict.mapValues { AnyCodable($0) } + } +} diff --git a/VultisigApp/VultisigApp/Services/FastVault/FastVaultKeysignService.swift b/VultisigApp/VultisigApp/Services/FastVault/FastVaultKeysignService.swift new file mode 100644 index 0000000000..de512fe54c --- /dev/null +++ b/VultisigApp/VultisigApp/Services/FastVault/FastVaultKeysignService.swift @@ -0,0 +1,278 @@ +// +// FastVaultKeysignService.swift +// VultisigApp +// +// Headless FastVault keysign orchestrator — mirrors Windows' fastVaultKeysign.ts. +// Encapsulates the relay session + MPC ceremony so callers don't duplicate code. +// + +import Foundation +import OSLog +import Tss +import WalletCore + +/// Result of a headless FastVault keysign ceremony. +struct FastVaultKeysignResult { + let signatures: [String: TssKeysignResponse] +} + +/// Input parameters for the FastVault keysign ceremony. +struct FastVaultKeysignInput { + let vault: Vault + let keysignMessages: [String] + let derivePath: String + let isECDSA: Bool + let vaultPassword: String + let chain: String + /// Maximum number of retry attempts (default 2, matching Windows). + var maxAttempts: Int = 2 +} + +/// Shared headless FastVault keysign service. +/// Performs the full 6-step relay + MPC ceremony without any UI dependencies. +/// +/// Steps: +/// 1. Register session on relay +/// 2. Invite VultiServer via `FastVaultService.sign()` +/// 3. Poll relay until VultiServer joins +/// 4. Start the keysign session +/// 5. Run local DKLS keysign +/// 6. Return signatures +final class FastVaultKeysignService { + + static let shared = FastVaultKeysignService() + + private let logger = Logger(subsystem: "com.vultisig", category: "FastVaultKeysignService") + + /// Perform a headless FastVault keysign with retry (mirrors Windows' `fastVaultKeysign()`). + func keysign(input: FastVaultKeysignInput) async throws -> FastVaultKeysignResult { + var lastError: Error? + + for attempt in 1...input.maxAttempts { + do { + let result = try await keysignAttempt(input: input) + return result + } catch { + lastError = error + print("[FastVaultKeysign] ⚠️ Attempt \(attempt)/\(input.maxAttempts) failed: \(error.localizedDescription)") + if attempt < input.maxAttempts && isRetryable(error) { + try await Task.sleep(for: .seconds(2)) + continue + } + } + } + + throw lastError ?? FastVaultKeysignError.keysignFailed("Unknown error") + } + + // MARK: - Single Attempt + + private func keysignAttempt(input: FastVaultKeysignInput) async throws -> FastVaultKeysignResult { + let sessionID = UUID().uuidString + let serverAddr = Endpoint.vultisigRelay + let localPartyID = input.vault.localPartyID + + // Generate encryption key for relay message encryption + guard let encryptionKeyHex = Encryption.getEncryptionKey() else { + throw FastVaultKeysignError.keysignFailed("Failed to generate encryption key") + } + + // The server ALWAYS identifies vaults by the ECDSA public key. + // For the MPC ceremony, we use the correct signing key type. + let signingPublicKey = input.isECDSA ? input.vault.pubKeyECDSA : input.vault.pubKeyEdDSA + let vaultIdentifierKey = input.vault.pubKeyECDSA // Always ECDSA for server API + + print("[FastVaultKeysign] 🚀 Starting keysign ceremony") + print("[FastVaultKeysign] sessionID=\(sessionID)") + print("[FastVaultKeysign] localPartyID=\(localPartyID)") + print("[FastVaultKeysign] serverAddr=\(serverAddr)") + print("[FastVaultKeysign] derivePath=\(input.derivePath)") + print("[FastVaultKeysign] isECDSA=\(input.isECDSA)") + print("[FastVaultKeysign] chain=\(input.chain)") + print("[FastVaultKeysign] messages=\(input.keysignMessages.map { String($0.prefix(20)) })") + print("[FastVaultKeysign] signingPublicKey=\(signingPublicKey.prefix(20))...") + print("[FastVaultKeysign] vaultIdentifierKey=\(vaultIdentifierKey.prefix(20))...") + + // Step 1: Register session on relay + try await registerSession(serverAddr: serverAddr, sessionID: sessionID, localPartyID: localPartyID) + print("[FastVaultKeysign] ✅ Step 1: Session registered on relay") + + // Step 2: Invite VultiServer (uses ECDSA key for vault identification) + print("[FastVaultKeysign] 📡 Step 2: Inviting VultiServer via FastVaultService.sign()...") + try await inviteServer( + publicKey: vaultIdentifierKey, + keysignMessages: input.keysignMessages, + sessionID: sessionID, + encryptionKeyHex: encryptionKeyHex, + derivePath: input.derivePath, + isECDSA: input.isECDSA, + vaultPassword: input.vaultPassword, + chain: input.chain + ) + print("[FastVaultKeysign] ✅ Step 2: VultiServer invited") + + // Step 3: Wait for VultiServer to join (poll participants) + // IMPORTANT: Use ACTUAL discovered parties, not pre-computed IDs (matches Windows) + print("[FastVaultKeysign] 👥 Step 3: Waiting for peers...") + let parties = try await waitForParties(serverAddr: serverAddr, sessionID: sessionID, expected: 2) + print("[FastVaultKeysign] ✅ Step 3: Peers discovered: \(parties)") + + // Use actual discovered parties as the keysign committee (matching Windows) + // Windows: const peers = parties.filter(p => p !== vault.localPartyId) + let keysignCommittee = parties + let actualPeers = parties.filter { $0 != localPartyID } + print("[FastVaultKeysign] 📋 Keysign committee (from discovered parties): \(keysignCommittee)") + print("[FastVaultKeysign] 📋 Actual peers: \(actualPeers)") + + // Step 4: Start the session with actual parties + print("[FastVaultKeysign] 🔄 Step 4: Starting session...") + try await startSession(serverAddr: serverAddr, sessionID: sessionID, parties: parties) + print("[FastVaultKeysign] ✅ Step 4: Keysign started") + + // Step 5: Run local keysign (DKLS for ECDSA, Schnorr for EdDSA) + let chainPath = input.derivePath.replacingOccurrences(of: "'", with: "") + print("[FastVaultKeysign] ⚙️ Step 5: Starting \(input.isECDSA ? "DKLS (ECDSA)" : "Schnorr (EdDSA)") keysign...") + print("[FastVaultKeysign] chainPath=\(chainPath)") + print("[FastVaultKeysign] committee=\(keysignCommittee)") + print("[FastVaultKeysign] encryptionKeyHex=\(encryptionKeyHex.prefix(8))...") + print("[FastVaultKeysign] vault.localPartyID=\(input.vault.localPartyID)") + print("[FastVaultKeysign] vault.publicKeys=\(signingPublicKey.prefix(20))...") + + let signatures: [String: TssKeysignResponse] + if input.isECDSA { + let dklsKeysign = DKLSKeysign( + keysignCommittee: keysignCommittee, + mediatorURL: serverAddr, + sessionID: sessionID, + messsageToSign: input.keysignMessages, + vault: input.vault, + encryptionKeyHex: encryptionKeyHex, + chainPath: chainPath, + isInitiateDevice: true, + publicKeyECDSA: signingPublicKey + ) + try await dklsKeysign.DKLSKeysignWithRetry() + signatures = dklsKeysign.getSignatures() + } else { + let schnorrKeysign = SchnorrKeysign( + keysignCommittee: keysignCommittee, + mediatorURL: serverAddr, + sessionID: sessionID, + messsageToSign: input.keysignMessages, + vault: input.vault, + encryptionKeyHex: encryptionKeyHex, + isInitiateDevice: true, + publicKeyEdDSA: signingPublicKey + ) + try await schnorrKeysign.KeysignWithRetry() + signatures = schnorrKeysign.getSignatures() + } + + print("[FastVaultKeysign] ✅ Step 5: Keysign completed!") + + // Step 6: Return signatures + print("[FastVaultKeysign] 📝 Step 6: Got \(signatures.count) signature(s)") + for (hash, sig) in signatures { + print("[FastVaultKeysign] hash=\(hash.prefix(20))... key=\(sig.r.prefix(10))...\(sig.s.prefix(10))...") + } + guard !signatures.isEmpty else { + throw FastVaultKeysignError.keysignFailed("No signatures produced") + } + + return FastVaultKeysignResult(signatures: signatures) + } + + // MARK: - Relay Helpers (mirrors Windows relayClient.ts) + + /// POST /{sessionID} — register local party on relay + private func registerSession(serverAddr: String, sessionID: String, localPartyID: String) async throws { + let urlString = "\(serverAddr)/\(sessionID)" + let body = [localPartyID] + let bodyData = try JSONEncoder().encode(body) + _ = try await Utils.asyncPostRequest(urlString: urlString, headers: nil, body: bodyData) + } + + /// Call FastVaultService.sign to invite VultiServer + private func inviteServer( + publicKey: String, + keysignMessages: [String], + sessionID: String, + encryptionKeyHex: String, + derivePath: String, + isECDSA: Bool, + vaultPassword: String, + chain: String + ) async throws { + let success = await withCheckedContinuation { continuation in + FastVaultService.shared.sign( + publicKeyEcdsa: publicKey, + keysignMessages: keysignMessages, + sessionID: sessionID, + hexEncryptionKey: encryptionKeyHex, + derivePath: derivePath, + isECDSA: isECDSA, + vaultPassword: vaultPassword, + chain: chain + ) { success in + continuation.resume(returning: success) + } + } + + guard success else { + throw FastVaultKeysignError.keysignFailed("FastVaultService.sign failed") + } + } + + /// GET /{sessionID} — poll until expected number of parties join (mirrors Windows waitForParties) + private func waitForParties(serverAddr: String, sessionID: String, expected: Int, timeoutSeconds: TimeInterval = 120) async throws -> [String] { + let urlString = "\(serverAddr)/\(sessionID)" + let startTime = Date() + + while Date().timeIntervalSince(startTime) < timeoutSeconds { + do { + let data = try await Utils.asyncGetRequest(urlString: urlString, headers: [:]) + if let parties = try? JSONDecoder().decode([String].self, from: data) { + if parties.count >= expected { + return parties + } + print("[FastVaultKeysign] 👥 Waiting for peers: \(parties.count)/\(expected)") + } + } catch { + // Ignore polling errors (matches Windows behavior) + } + try await Task.sleep(for: .seconds(1)) + } + + throw FastVaultKeysignError.keysignFailed("Timeout waiting for \(expected) parties in session \(sessionID)") + } + + /// POST /start/{sessionID} — kick off (mirrors Windows startSession) + private func startSession(serverAddr: String, sessionID: String, parties: [String]) async throws { + let urlString = "\(serverAddr)/start/\(sessionID)" + let bodyData = try JSONEncoder().encode(parties) + _ = try await Utils.asyncPostRequest(urlString: urlString, headers: nil, body: bodyData) + } + + // MARK: - Retry Logic + + private func isRetryable(_ error: Error) -> Bool { + let msg = error.localizedDescription.lowercased() + return msg.contains("timeout") || + msg.contains("deadline exceeded") || + msg.contains("unreachable") || + msg.contains("keysign failed") + } +} + +// MARK: - Errors + +enum FastVaultKeysignError: Error, LocalizedError { + case keysignFailed(String) + + var errorDescription: String? { + switch self { + case .keysignFailed(let reason): + return "FastVault keysign failed: \(reason)" + } + } +} diff --git a/VultisigApp/VultisigApp/Utils/Endpoint.swift b/VultisigApp/VultisigApp/Utils/Endpoint.swift index 905243bb77..cf621bf572 100644 --- a/VultisigApp/VultisigApp/Utils/Endpoint.swift +++ b/VultisigApp/VultisigApp/Utils/Endpoint.swift @@ -1112,6 +1112,50 @@ class Endpoint { } } + // MARK: - Agent + + static let agentBackendUrl = "https://agent.vultisig.com" + static let verifierUrl = "https://verifier.vultisig.com" + + static func agentConversations() -> String { + "\(agentBackendUrl)/agent/conversations" + } + + static func agentConversationsList() -> String { + "\(agentBackendUrl)/agent/conversations/list" + } + + static func agentConversation(id: String) -> String { + let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return "\(agentBackendUrl)/agent/conversations/\(safeId)" + } + + static func agentConversationMessages(id: String) -> String { + let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return "\(agentBackendUrl)/agent/conversations/\(safeId)/messages" + } + + static func agentStarters() -> String { + "\(agentBackendUrl)/agent/starters" + } + + // Verifier auth + static func verifierAuth() -> String { + "\(verifierUrl)/auth" + } + + static func verifierAuthRefresh() -> String { + "\(verifierUrl)/auth/refresh" + } + + static func verifierAuthMe() -> String { + "\(verifierUrl)/auth/me" + } + + static func verifierAuthRevokeAll() -> String { + "\(verifierUrl)/auth/tokens/all" + } + // Referral static let ReferralBase = "https://thornode.ninerealms.com/thorchain" diff --git a/VultisigApp/VultisigApp/View Models/Agent/AgentChatViewModel.swift b/VultisigApp/VultisigApp/View Models/Agent/AgentChatViewModel.swift new file mode 100644 index 0000000000..7b8a23a915 --- /dev/null +++ b/VultisigApp/VultisigApp/View Models/Agent/AgentChatViewModel.swift @@ -0,0 +1,1181 @@ +// +// AgentChatViewModel.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation +import OSLog +import SwiftUI + +@MainActor +final class AgentChatViewModel: ObservableObject { + + // MARK: - Published State + + @Published var messages: [AgentChatMessage] = [] + @Published var starters: [String] = [] + @Published var isLoading = false + @Published var error: String? + @Published var conversationTitle: String? + @Published var passwordRequired = false + @Published var isConnected = false + + @Published var pendingSendTx: SendTransaction? + @Published var shouldShowPairingSheet = false + @Published var showFastVaultPasswordPrompt = false + @Published var activeKeysignPayload: KeysignPayload? + var activeSignTxCallId: String? + + // MARK: - Private + + private let backendClient = AgentBackendClient() + private let authService = AgentAuthService.shared + private let logger = Logger(subsystem: "com.vultisig", category: "AgentChatViewModel") + private var streamingMessageId: String? + private var currentTask: Task? + private var pendingMessage: String? + private var cachedFastVaultPassword: String? + + // SSE delta buffering — accumulate characters and flush at ~25 Hz + private var streamingBuffer: String = "" + private var flushTimer: Timer? + + var conversationId: String? + + // MARK: - Hardcoded Fallback Starters + + static let fallbackStarters = [ + "What's my portfolio value?", + "Show me my balances", + "What's the price of ETH?", + "Swap 0.01 ETH for USDC", + "Check my Bitcoin balance", + "List my vault chains", + "What tokens do I have?", + "Show my transaction history" + ] + + // MARK: - Send Message + + func sendMessage(_ text: String, vault: Vault) { + #if DEBUG + print("[AgentChat] 📤 sendMessage called") + #endif + + // Add user message to UI + let userMsg = AgentChatMessage( + id: "msg-\(Date().timeIntervalSince1970)", + role: .user, + content: text, + timestamp: Date() + ) + messages.append(userMsg) + isLoading = true + error = nil + streamingMessageId = nil + + currentTask = Task { + // Get token if available + let token = await getValidToken(vault: vault)?.token ?? "" + + // If token is empty, we must authenticate first + if token.isEmpty { + #if DEBUG + print("[AgentChat] 🔑 No valid token, prompting for password") + #endif + await MainActor.run { + self.pendingMessage = text + self.passwordRequired = true + self.isLoading = false + // Remove the user message from the UI since it hasn't sent yet, we'll append it when it actually sends + self.messages.removeAll { $0.id == userMsg.id } + } + return + } + + #if DEBUG + print("[AgentChat] 🔑 Using token: \(token.prefix(20).description)...") + #endif + + await executeSendMessage(text: text, vault: vault, token: token) + } + } + + private func executeSendMessage(text: String, vault: Vault, token: String) async { + do { + // Create conversation if needed + if conversationId == nil { + #if DEBUG + print("[AgentChat] 🆕 Creating new conversation...") + #endif + let conv = try await backendClient.createConversation( + publicKey: vault.pubKeyECDSA, + token: token + ) + conversationId = conv.id + #if DEBUG + print("[AgentChat] ✅ Conversation created: \(conv.id)") + #endif + + await primeMCPSession(vault: vault, token: token, convId: conv.id) + } + + // Fetch starters if needed + if messages.isEmpty && starters.isEmpty { + await loadStarters(vault: vault) + } + + guard let convId = conversationId else { + #if DEBUG + print("[AgentChat] ❌ No conversation ID") + #endif + throw AgentBackendClient.AgentBackendError.noBody + } + + // Build context (full context on first message, light on subsequent) + let context: AgentMessageContext + if messages.count <= 2 { + context = AgentContextBuilder.buildContext(vault: vault) // @MainActor, not async + } else { + context = AgentContextBuilder.buildLightContext(vault: vault) // @MainActor, not async + } + #if DEBUG + print("[AgentChat] 📋 Context built (\(messages.count <= 2 ? "full" : "light"))") + #endif + + let request = AgentSendMessageRequest( + publicKey: vault.pubKeyECDSA, + content: text, + model: "anthropic/claude-sonnet-4.5", + context: context + ) + + if let data = try? JSONEncoder().encode(request), let jsonString = String(data: data, encoding: .utf8) { + #if DEBUG + print("\n[AgentChat] 🐛 Outgoing Request Payload:") + #endif + #if DEBUG + print(jsonString) + #endif + #if DEBUG + print("-------------------------------------------\n") + #endif + } + + // Stream the response + #if DEBUG + print("[AgentChat] 🌊 Starting SSE stream for convId: \(convId)") + #endif + let stream = backendClient.sendMessageStream( + convId: convId, + request: request, + token: token + ) + + var eventCount = 0 + for try await event in stream { + if Task.isCancelled { + #if DEBUG + print("[AgentChat] ⚠️ Task cancelled, breaking stream") + #endif + break + } + eventCount += 1 + #if DEBUG + print("[AgentChat] 📨 SSE event #\(eventCount): \(event)") + #endif + handleSSEEvent(event, vault: vault) + } + #if DEBUG + print("[AgentChat] 🏁 Stream ended, total events: \(eventCount)") + #endif + + isLoading = false + + } catch let error as AgentBackendClient.AgentBackendError { + #if DEBUG + print("[AgentChat] ❌ Backend error: \(error.localizedDescription)") + #endif + handleError(error) + } catch { + #if DEBUG + print("[AgentChat] ❌ General error: \(error) — \(error.localizedDescription)") + #endif + handleError(error) + } + } + + // MARK: - Send Action Result + + func sendActionResult(_ result: AgentActionResult, vault: Vault) { + guard let convId = conversationId else { return } + + isLoading = true + + currentTask = Task { + do { + let token = await getValidToken(vault: vault)?.token ?? "" + + let request = AgentSendMessageRequest( + publicKey: vault.pubKeyECDSA, + model: "anthropic/claude-sonnet-4.5", + actionResult: result + ) + + let stream = backendClient.sendMessageStream( + convId: convId, + request: request, + token: token + ) + + for try await event in stream { + if Task.isCancelled { break } + handleSSEEvent(event, vault: vault) + } + + isLoading = false + + } catch { + handleError(error) + } + } + } + + // MARK: - Load Existing Conversation + + func loadConversation(id: String, vault: Vault) async { + conversationId = id + isLoading = true + + do { + let token = await getValidToken(vault: vault)?.token ?? "" + + let conv = try await backendClient.getConversation( + id: id, + publicKey: vault.pubKeyECDSA, + token: token + ) + + conversationTitle = conv.title + + await primeMCPSession(vault: vault, token: token, convId: id) + + // Convert backend messages to chat messages + messages = conv.messages.map { msg in + AgentChatMessage( + id: msg.id, + role: msg.role == "user" ? .user : .assistant, + content: msg.content, + timestamp: AgentBackendClient.parseISO8601(msg.createdAt) ?? Date() + ) + } + + isLoading = false + } catch { + logger.error("Failed to load conversation: \(error.localizedDescription)") + self.error = error.localizedDescription + isLoading = false + } + } + + // MARK: - Auth + + func signIn(vault: Vault, password: String) async { + #if DEBUG + print("[AgentChat] 🔐 signIn called with password length: \(password.count)") + #endif + do { + _ = try await authService.signIn(vault: vault, password: password) + #if DEBUG + print("[AgentChat] ✅ signIn succeeded") + #endif + isConnected = true + passwordRequired = false + cachedFastVaultPassword = password // Cache for headless keysign reuse + + if let pending = pendingMessage { + #if DEBUG + print("[AgentChat] 📤 Sending pending message after login") + #endif + let msgToSend = pending + pendingMessage = nil + self.sendMessage(msgToSend, vault: vault) + } + } catch { + #if DEBUG + print("[AgentChat] ❌ signIn failed: \(error)") + #endif + self.error = "Sign-in failed: \(error.localizedDescription)" + } + } + + func checkConnection(vault _: Vault) { + // Agent backend uses public_key for identity, no auth token needed + #if DEBUG + print("[AgentChat] 🔌 checkConnection: always connected (public_key auth)") + #endif + isConnected = true + } + + // MARK: - Load Starters + + func loadStarters(vault: Vault) async { + let token = await getValidToken(vault: vault) + + do { + let context = AgentContextBuilder.buildContext(vault: vault) // @MainActor, not async + let request = AgentGetStartersRequest( + publicKey: vault.pubKeyECDSA, + context: context + ) + + let response = try await backendClient.getStarters( + request: request, + token: token?.token ?? "" + ) + + if response.starters.isEmpty { + starters = Array(AgentChatViewModel.fallbackStarters.shuffled().prefix(4)) + } else { + starters = Array(response.starters.shuffled().prefix(4)) + } + } catch { + logger.warning("Failed to load starters, using fallback: \(error.localizedDescription)") + starters = Array(AgentChatViewModel.fallbackStarters.shuffled().prefix(4)) + } + } + + func disconnect(vault: Vault) async { + await authService.disconnect(vaultPubKey: vault.pubKeyECDSA) + cachedFastVaultPassword = nil + isConnected = false + } + + // MARK: - Cancel + + func cancelRequest() { + stopFlushTimer() + currentTask?.cancel() + currentTask = nil + isLoading = false + streamingMessageId = nil + streamingBuffer = "" + } + + func dismissError() { + error = nil + } + + // MARK: - Delete Conversation + + @Published var conversationDeleted = false + + func deleteCurrentConversation(vault: Vault) { + guard let convId = conversationId else { return } + Task { + let token = await getValidToken(vault: vault) + do { + try await backendClient.deleteConversation( + id: convId, + publicKey: vault.pubKeyECDSA, + token: token?.token ?? "" + ) + conversationDeleted = true + } catch { + self.error = error.localizedDescription + } + } + } + + // MARK: - SSE Event Handling + + private func handleSSEEvent(_ event: AgentSSEEvent, vault: Vault? = nil) { + switch event { + case .textDelta(let delta): + handleTextDelta(delta) + + case .title(let title): + conversationTitle = title + + case .actions(let actions): + let effectiveVault = vault ?? AppViewModel.shared.selectedVault + if let v = effectiveVault { + handleActions(actions, vault: v) + } + + case .suggestions: + // Suggestions are displayed in the conversation starters, not inline + break + + case .txReady(let txReady): + handleTxReady(txReady) + + case .tokens(let tokenResults): + // Attach token results to the current streaming message + if let streamId = streamingMessageId, + let idx = messages.firstIndex(where: { $0.id == streamId }) { + messages[idx].tokenResults = tokenResults + } + + case .message(let backendMsg): + finalizeStreamingMessage(with: backendMsg) + + case .error(let errorMsg): + // Bug fix: stop the flush timer so it doesn't keep running after the stream ends + stopFlushTimer() + streamingBuffer = "" + // Mark the in-flight message as finished so it renders Markdown + if let streamId = streamingMessageId, + let idx = messages.firstIndex(where: { $0.id == streamId }) { + messages[idx].isStreaming = false + } + streamingMessageId = nil + let normalized = normalizeErrorMessage(errorMsg) + if normalized == "agent stopped" { + appendAssistantMessage("Agent stopped. Send a new message when you're ready.") + } else { + error = normalized + } + isLoading = false + + case .done: + // Bug fix: stop the flush timer and finalize any buffered text + stopFlushTimer() + if !streamingBuffer.isEmpty, + let streamId = streamingMessageId, + let idx = messages.firstIndex(where: { $0.id == streamId }) { + messages[idx].content = streamingBuffer + messages[idx].isStreaming = false + } else if let streamId = streamingMessageId, + let idx = messages.firstIndex(where: { $0.id == streamId }) { + messages[idx].isStreaming = false + } + streamingBuffer = "" + streamingMessageId = nil + isLoading = false + } + } + + private func handleTextDelta(_ delta: String) { + streamingBuffer += delta + + if streamingMessageId == nil { + // No pre-seeded message: create one and start streaming + let msgId = "streaming-\(Date().timeIntervalSince1970)" + streamingMessageId = msgId + #if DEBUG + print("[AgentChat] 💬 Created new streaming message id: \(msgId)") + #endif + let streamMsg = AgentChatMessage( + id: msgId, + role: .assistant, + content: "", + timestamp: Date(), + isStreaming: true + ) + messages.append(streamMsg) + isLoading = false + } else if let streamId = streamingMessageId, + let idx = messages.firstIndex(where: { $0.id == streamId }), + !messages[idx].isStreaming { + // Bug fix: pre-seeded seed message (from auto-execute) was not marked isStreaming. + // Mark it now so the view switches to plain-text mode immediately. + messages[idx].isStreaming = true + } + + // Always ensure the timer is running (pre-seeded paths skip the block above) + startFlushTimer() + } + + private func startFlushTimer() { + guard flushTimer == nil else { return } + // ~25 Hz flush rate — smooth without hammering main thread. + // Use a DispatchQueue.main scheduled timer so the closure runs on MainActor + // without needing an explicit @MainActor annotation on the block. + flushTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 25.0, repeats: true) { [weak self] _ in + DispatchQueue.main.async { self?.flushStreamingBuffer() } + } + } + + private func stopFlushTimer() { + flushTimer?.invalidate() + flushTimer = nil + } + + @MainActor + private func flushStreamingBuffer() { + guard !streamingBuffer.isEmpty, + let streamId = streamingMessageId, + let idx = messages.firstIndex(where: { $0.id == streamId }) else { return } + messages[idx].content = streamingBuffer + } + + private func handleActions(_ actions: [AgentBackendAction], vault: Vault) { + var autoExecuteActions: [AgentBackendAction] = [] + + for action in actions { + // Add tool call status message + let toolCallMsg = AgentChatMessage( + id: "tool-call-\(action.id)", + role: .assistant, + content: "", + timestamp: Date(), + toolCall: AgentToolCallInfo( + actionType: action.type, + title: formatActionTitle(action.type, title: action.title), + params: action.params, + status: .running + ) + ) + messages.append(toolCallMsg) + + if action.type == "build_send_tx" { + self.createPendingSendTx(from: action.params, vault: vault) + } else if action.type == "sign_tx" { + self.activeSignTxCallId = "tool-call-\(action.id)" + self.confirmSignTx(vault: vault) + } + + if action.autoExecute { + autoExecuteActions.append(action) + } + } + + // Execute all auto-execute actions + if !autoExecuteActions.isEmpty { + if autoExecuteActions.count == 1 { + handleAutoExecuteAction(autoExecuteActions[0], vault: vault) + } else { + handleAutoExecuteActions(autoExecuteActions, vault: vault) + } + } + } + + private func handleAutoExecuteAction(_ action: AgentBackendAction, vault: Vault) { + let toolCallId = "tool-call-\(action.id)" + + Task { @MainActor in + let result = await AgentToolExecutor.execute(action: action, vault: vault) + + if let idx = self.messages.firstIndex(where: { $0.id == toolCallId }) { + self.messages[idx].toolCall?.status = result.success ? .success : .error + self.messages[idx].toolCall?.resultData = result.data + self.messages[idx].toolCall?.error = result.error + } + + // Plant a seed message marked isStreaming: true before streaming the result back + self.isLoading = true + let seedId = "streaming-\(Date().timeIntervalSince1970)" + self.streamingMessageId = seedId + self.messages.append(AgentChatMessage( + id: seedId, + role: .assistant, + content: "", + timestamp: Date(), + isStreaming: true // Bug fix: must be true so view and handleTextDelta handle this correctly + )) + + // Stream the result back + self.sendActionResult(result, vault: vault) + } + } + + private func handleAutoExecuteActions(_ actions: [AgentBackendAction], vault: Vault) { + // Execute all actions locally (no backend round-trip per action), then + // send a SINGLE aggregated action result. This prevents N×backend calls + // (e.g. "remove all 22 chains" becomes 1 request instead of 22). + Task { @MainActor in + var allSucceeded = true + var aggregatedData: [String: AnyCodable] = [:] + var errors: [String] = [] + let actionType = actions.first?.type ?? "batch" + + for action in actions { + if Task.isCancelled { break } + + let toolCallId = "tool-call-\(action.id)" + let result = await AgentToolExecutor.execute(action: action, vault: vault) + + if let idx = self.messages.firstIndex(where: { $0.id == toolCallId }) { + self.messages[idx].toolCall?.status = result.success ? .success : .error + self.messages[idx].toolCall?.resultData = result.data + self.messages[idx].toolCall?.error = result.error + } + + if !result.success { + allSucceeded = false + errors.append(result.error ?? "unknown error for action \(action.id)") + } + + if let outData = result.data { + for (k, v) in outData { + aggregatedData["\(action.id)_\(k)"] = v + } + } + } + + // Send ONE aggregated result back to the backend + var summaryData: [String: AnyCodable] = [ + "completed": AnyCodable(actions.count), + "succeeded": AnyCodable(allSucceeded ? actions.count : actions.count - errors.count), + "failed": AnyCodable(errors.count) + ] + if !aggregatedData.isEmpty { + summaryData["results"] = AnyCodable(aggregatedData) + } + if !errors.isEmpty { + summaryData["errors"] = AnyCodable(errors) + } + + let aggregatedResult = AgentActionResult( + action: actionType, + success: allSucceeded, + data: summaryData, + error: errors.isEmpty ? nil : errors.joined(separator: "; ") + ) + + // Plant an empty "seed" assistant message and register it as the + // current streaming message BEFORE calling sendActionResult. + // + // This achieves two things: + // 1. isLoading = true → spinner shows during the gap + // 2. streamingMessageId is set so the second SSE stream's + // text_delta events stream character-by-character into the + // seed message (real-time typing effect). If the backend + // sends a `message` event instead, finalizeStreamingMessage + // replaces the seed cleanly with the real content. + await MainActor.run { + self.isLoading = true + let seedId = "streaming-\(Date().timeIntervalSince1970)" + self.streamingMessageId = seedId + self.messages.append(AgentChatMessage( + id: seedId, + role: .assistant, + content: "", + timestamp: Date(), + isStreaming: true // Bug fix: must be true so view and handleTextDelta handle this correctly + )) + } + self.sendActionResult(aggregatedResult, vault: vault) + } + } + + private func finalizeStreamingMessage(with backendMsg: AgentBackendMessage) { + #if DEBUG + print("[AgentChat] 🏁 Finalizing streaming message id: \(backendMsg.id). Current streamingMessageId: \(streamingMessageId ?? "nil")") + #endif + // Stop timer and apply any remaining buffered text before replacing with final content + stopFlushTimer() + streamingBuffer = "" + + if let streamId = streamingMessageId, + let idx = messages.firstIndex(where: { $0.id == streamId }) { + let oldMsg = messages[idx] + // isStreaming = false → view will now render full Markdown + messages[idx] = AgentChatMessage( + id: backendMsg.id, + role: oldMsg.role, + content: backendMsg.content, + timestamp: oldMsg.timestamp, + toolCall: oldMsg.toolCall, + txStatus: oldMsg.txStatus, + tokenResults: oldMsg.tokenResults, + txProposal: oldMsg.txProposal, + isStreaming: false + ) + } else if !backendMsg.content.trimmingCharacters(in: .whitespaces).isEmpty { + let msg = AgentChatMessage( + id: backendMsg.id, + role: .assistant, + content: backendMsg.content, + timestamp: AgentBackendClient.parseISO8601(backendMsg.createdAt) ?? Date() + ) + messages.append(msg) + } + streamingMessageId = nil + isLoading = false + } + + private func handleTxReady(_ txReady: AgentTxReady) { + let msg = AgentChatMessage( + id: "tx-proposal-\(Date().timeIntervalSince1970)", + role: .assistant, + content: "", + timestamp: Date(), + txProposal: txReady + ) + messages.append(msg) + isLoading = false + } + + func acceptTxProposal(_ proposal: AgentTxReady, vault _: Vault) { + #if DEBUG + print("[AgentChat] 💳 User ACCEPTED transaction. Not fully implemented yet. keysignPayload length: \(proposal.keysignPayload?.count ?? 0)") + #endif + appendAssistantMessage("Transaction accepted. Launching keysign...") + + // TODO: This is where we will route the keysign payload to the Vultisig router in Phase 13. + isLoading = false + } + + func rejectTxProposal(_: AgentTxReady, vault: Vault) { + #if DEBUG + print("[AgentChat] ❌ User REJECTED transaction.") + #endif + sendMessage("Cancel the transaction. I do not want to execute it.", vault: vault) + } + + func handleTxBroadcasted(txid: String, vault: Vault) { + let callId = activeSignTxCallId + + // Send the result to AI + let result = AgentActionResult( + action: "sign_tx", + success: true, + data: ["txid": AnyCodable(txid)] + ) + + MainActor.assumeIsolated { + self.shouldShowPairingSheet = false + self.pendingSendTx = nil + self.activeSignTxCallId = nil + + // Update the tool-call message bubble if we have a matching ID + if let callId, let idx = self.messages.firstIndex(where: { $0.id == callId }) { + self.messages[idx].toolCall?.status = .success + self.messages[idx].toolCall?.resultData = ["txid": AnyCodable(txid)] + } + + // Always append the txid to chat so it's visible to the user + self.appendAssistantMessage("✅ Transaction broadcast!\nTxID: `\(txid)`") + + self.sendActionResult(result, vault: vault) + } + } + + func confirmSignTx(vault: Vault) { + #if DEBUG + print("[AgentChat] 🔐 confirmSignTx called. pendingSendTx=\(pendingSendTx != nil ? "SET (\(pendingSendTx!.coin.ticker) on \(pendingSendTx!.coin.chain.name))" : "NIL")") + #endif + guard let pendingSendTx else { + #if DEBUG + print("[AgentChat] ❌ confirmSignTx: pendingSendTx is nil, returning early") + #endif + return + } + + #if DEBUG + print("[AgentChat] 🔐 confirmSignTx: isFastVault=\(vault.isFastVault), cachedPassword=\(cachedFastVaultPassword != nil ? "SET" : "NIL")") + #endif + if vault.isFastVault { + // Fully headless: use cached password from signIn, no sheets at all + if let password = cachedFastVaultPassword, !password.isEmpty { + #if DEBUG + print("[AgentChat] 🔐 confirmSignTx: using cached password, calling executeFastVaultKeysign") + #endif + executeFastVaultKeysign(password: password, vault: vault) + } else { + // Fallback: prompt for password if not cached + #if DEBUG + print("[AgentChat] 🔐 confirmSignTx: no cached password, showing FastVaultPasswordPrompt") + #endif + self.showFastVaultPasswordPrompt = true + } + } else { + Task { + await MainActor.run { + self.isLoading = true + self.appendAssistantMessage("Generating keysign payload...") + } + + do { + let logic = SendCryptoVerifyLogic() + + await BalanceService.shared.updateBalance(for: pendingSendTx.coin) + let feeResult = try await logic.calculateFee(tx: pendingSendTx) + pendingSendTx.fee = feeResult.fee + pendingSendTx.gas = feeResult.gas + + let result = logic.validateBalanceWithFee(tx: pendingSendTx) + if !result.isValid { + throw HelperError.runtimeError(result.errorMessage ?? "Insufficient balance to cover fee.") + } + + try await logic.validateUtxosIfNeeded(tx: pendingSendTx) + let payload = try await logic.buildKeysignPayload(tx: pendingSendTx, vault: vault) + + await MainActor.run { + self.isLoading = false + self.activeKeysignPayload = payload + self.shouldShowPairingSheet = true + } + } catch { + await MainActor.run { + self.isLoading = false + self.error = error.localizedDescription + } + } + } + } + } + + func executeFastVaultKeysign(password: String, vault: Vault) { + guard let tx = pendingSendTx else { + #if DEBUG + print("[AgentChat] ❌ executeFastVaultKeysign: pendingSendTx is nil") + #endif + return + } + + // Cache password for future transactions in this session + if cachedFastVaultPassword == nil { + cachedFastVaultPassword = password + } + + Task { + await MainActor.run { + self.isLoading = true + self.appendAssistantMessage("Signing and broadcasting transaction...") + } + + do { + let logic = SendCryptoVerifyLogic() + + // 1. Fetch fees + await BalanceService.shared.updateBalance(for: tx.coin) + let feeResult = try await logic.calculateFee(tx: tx) + tx.fee = feeResult.fee + tx.gas = feeResult.gas + + #if DEBUG + print("[AgentChat] 💰 Balance check: rawBalance='\(tx.coin.rawBalance)', decimals=\(tx.coin.decimals)") + #endif + #if DEBUG + print("[AgentChat] 💰 amount=\(tx.amount), amountInRaw=\(tx.amountInRaw)") + #endif + #if DEBUG + print("[AgentChat] 💰 fee=\(tx.fee), gas=\(tx.gas)") + #endif + #if DEBUG + print("[AgentChat] 💰 isNativeToken=\(tx.coin.isNativeToken), sendMaxAmount=\(tx.sendMaxAmount)") + #endif + + let validationResult = logic.validateBalanceWithFee(tx: tx) + if !validationResult.isValid { + let errStr = validationResult.errorMessage ?? "Insufficient balance to cover fee." + #if DEBUG + print("[AgentChat] ❌ Balance validation FAILED: \(errStr)") + #endif + let localizedErr = NSLocalizedString(errStr, comment: "") + throw HelperError.runtimeError(localizedErr == errStr ? errStr : localizedErr) + } + + try await logic.validateUtxosIfNeeded(tx: tx) + + // 2. Validate form + let keysignPayload = try await logic.buildKeysignPayload(tx: tx, vault: vault) + + // 3. Generate Keysign Messages (Matches KeysignDiscoveryViewModel flow) + let finalPayload = keysignPayload.coin.chain == .solana ? + try await BlockChainService.shared.refreshSolanaBlockhash(for: keysignPayload) : keysignPayload + let keysignFactory = KeysignMessageFactory(payload: finalPayload) + let preSignedImageHash = try keysignFactory.getKeysignMessages() + let keysignMessages = preSignedImageHash.sorted() + + guard !keysignMessages.isEmpty else { + throw HelperError.runtimeError("No message need to be signed") + } + + // 4. FastVault Keysign execution + let input = FastVaultKeysignInput( + vault: vault, + keysignMessages: keysignMessages, + derivePath: finalPayload.coin.coinType.derivationPath(), + isECDSA: finalPayload.coin.chain.signingKeyType == .ECDSA, + vaultPassword: password, + chain: finalPayload.coin.chain.name + ) + + let result = try await FastVaultKeysignService.shared.keysign(input: input) + #if DEBUG + print("[AgentChat] ✅ Keysign returned \(result.signatures.count) signature(s)") + #endif + + // 5. Broadcast Transaction + await MainActor.run { + self.appendAssistantMessage("Transaction signed! Broadcasting to network...") + } + + let keysignViewModel = KeysignViewModel() + keysignViewModel.vault = vault + keysignViewModel.keysignPayload = finalPayload + keysignViewModel.signatures = result.signatures + + #if DEBUG + print("[AgentChat] 📡 Calling broadcastTransaction() for \(finalPayload.coin.ticker) on \(finalPayload.coin.chain.name)") + #endif + + // For UTXO chains (DOGE, BTC, LTC, DASH, ZEC, BCH), broadcastTransaction() + // uses a completion-handler internally, so the txid isn't set when the + // async function returns. We wait for the NotificationCenter post instead, + // with a 30-second timeout. + let isUTXO = finalPayload.coin.chain.chainType == .UTXO + + if isUTXO { + // Start an async waitUntil-style listener before calling broadcast + let txid = await withCheckedContinuation { (continuation: CheckedContinuation) in + var token: NSObjectProtocol? + var completed = false + token = NotificationCenter.default.addObserver( + forName: .agentDidBroadcastTx, + object: nil, + queue: .main + ) { notification in + guard !completed else { return } + completed = true + if let t = notification.userInfo?["txid"] as? String { + #if DEBUG + print("[AgentChat] 📬 NotificationCenter got txid: \(t)") + #endif + continuation.resume(returning: t) + } else { + continuation.resume(returning: "") + } + if let token { NotificationCenter.default.removeObserver(token) } + } + + // Kick off broadcast AFTER registering listener + Task { @MainActor in + await keysignViewModel.broadcastTransaction() + #if DEBUG + print("[AgentChat] 📡 broadcastTransaction() returned (UTXO). txid='\(keysignViewModel.txid)' error='\(keysignViewModel.keysignError)'") + #endif + + // If we already have a result (error path or immediate success), resume + if !completed { + if !keysignViewModel.keysignError.isEmpty { + completed = true + if let token { NotificationCenter.default.removeObserver(token) } + continuation.resume(returning: "") + } else { + // Schedule a 30-second timeout + Task { + try? await Task.sleep(for: .seconds(30)) + if !completed { + completed = true + #if DEBUG + print("[AgentChat] ⏰ UTXO broadcast timeout — no txid received after 30s") + #endif + if let token { NotificationCenter.default.removeObserver(token) } + continuation.resume(returning: "") + } + } + } + } + } + } + + if !txid.isEmpty { + #if DEBUG + print("[AgentChat] ✅ UTXO broadcast success. txid=\(txid)") + #endif + self.handleTxBroadcasted(txid: txid, vault: vault) + } else if !keysignViewModel.keysignError.isEmpty { + #if DEBUG + print("[AgentChat] ❌ Broadcast error: \(keysignViewModel.keysignError)") + #endif + throw HelperError.runtimeError(keysignViewModel.keysignError) + } else { + throw HelperError.runtimeError("Broadcast timed out or returned no txid. Check your balance and network, then try again.") + } + } else { + // Non-UTXO chains set txid synchronously inside broadcastTransaction() + await keysignViewModel.broadcastTransaction() + #if DEBUG + print("[AgentChat] 📡 broadcastTransaction() finished — txid='\(keysignViewModel.txid)' keysignError='\(keysignViewModel.keysignError)'") + #endif + + if !keysignViewModel.txid.isEmpty { + #if DEBUG + print("[AgentChat] ✅ Got txid: \(keysignViewModel.txid)") + #endif + self.handleTxBroadcasted(txid: keysignViewModel.txid, vault: vault) + } else if !keysignViewModel.keysignError.isEmpty { + #if DEBUG + print("[AgentChat] ❌ Broadcast error from KeysignViewModel: \(keysignViewModel.keysignError)") + #endif + throw HelperError.runtimeError(keysignViewModel.keysignError) + } else { + #if DEBUG + print("[AgentChat] ❌ Broadcast returned empty txid AND empty keysignError") + #endif + throw HelperError.runtimeError("Broadcast completed but no txid or error returned. Check your balance and try again.") + } + } + } catch { + #if DEBUG + print("[AgentChat] ❌ executeFastVaultKeysign caught error: \(error)") + #endif + #if DEBUG + print("[AgentChat] ❌ localizedDescription: \(error.localizedDescription)") + #endif + await MainActor.run { + self.isLoading = false + self.appendAssistantMessage("❌ Error: \(error.localizedDescription)") + self.error = error.localizedDescription + } + } + } + } + + private func createPendingSendTx(from params: [String: AnyCodable]?, vault: Vault) { + #if DEBUG + print("[AgentChat] 🏗️ createPendingSendTx called. params=\(params != nil ? "present" : "nil")") + #endif + guard let params = params, + let chainStr = params["chain"]?.value as? String, + let symbolStr = params["symbol"]?.value as? String, + let amountStr = params["amount"]?.value as? String, + let addressStr = params["address"]?.value as? String else { + #if DEBUG + print("[AgentChat] ❌ createPendingSendTx: missing required params. chain=\(params?["chain"]?.value ?? "nil"), symbol=\(params?["symbol"]?.value ?? "nil"), amount=\(params?["amount"]?.value ?? "nil"), address=\(params?["address"]?.value ?? "nil")") + #endif + return + } + + #if DEBUG + print("[AgentChat] 🏗️ createPendingSendTx: chain=\(chainStr), symbol=\(symbolStr), amount=\(amountStr), to=\(addressStr.prefix(10))...") + #endif + + // Find coin in vault + if let coin = vault.coins.first(where: { + $0.chain.name.lowercased() == chainStr.lowercased() && + $0.ticker.lowercased() == symbolStr.lowercased() + }) { + let tx = SendTransaction() + tx.coin = coin + tx.fromAddress = coin.address + tx.toAddress = addressStr + let localizedAmount = amountStr.replacingOccurrences(of: ".", with: Locale.current.decimalSeparator ?? ".") + tx.amount = localizedAmount + tx.vault = vault // Explicitly assign Vault to allow isFastVault detection + + if let memoStr = params["memo"]?.value as? String { + tx.memo = memoStr + } + + self.pendingSendTx = tx + #if DEBUG + print("[AgentChat] ✅ createPendingSendTx: SUCCESS — \(coin.ticker) on \(coin.chain.name), from=\(coin.address.prefix(10))..., to=\(addressStr.prefix(10))..., amount=\(amountStr)") + #endif + } else { + #if DEBUG + print("[AgentChat] ❌ createPendingSendTx: coin NOT FOUND in vault. Looking for chain=\(chainStr), symbol=\(symbolStr). Available coins: \(vault.coins.map { "\($0.chain.name)/\($0.ticker)" }.joined(separator: ", "))") + #endif + } + } + + private func appendAssistantMessage(_ content: String) { + guard !content.trimmingCharacters(in: .whitespaces).isEmpty else { return } + let msg = AgentChatMessage( + id: "msg-\(Date().timeIntervalSince1970)", + role: .assistant, + content: content, + timestamp: Date() + ) + messages.append(msg) + } + + // MARK: - Helpers + + private func primeMCPSession(vault: Vault, token: String, convId: String) async { + #if DEBUG + print("[AgentChat] 🔐 Priming backend MCP session with set_vault...") + #endif + let setVaultResult = AgentActionResult( + action: "set_vault", + success: true, + data: [ + "ecdsa_public_key": AnyCodable(vault.pubKeyECDSA), + "eddsa_public_key": AnyCodable(vault.pubKeyEdDSA), + "chain_code": AnyCodable(vault.hexChainCode) + ] + ) + let primeRequest = AgentSendMessageRequest( + publicKey: vault.pubKeyECDSA, + model: "anthropic/claude-sonnet-4.5", + actionResult: setVaultResult + ) + // Execute without streaming back to UI + _ = try? await backendClient.sendMessage(convId: convId, request: primeRequest, token: token) + #if DEBUG + print("[AgentChat] ✅ MCP session primed") + #endif + } + + private func getValidToken(vault: Vault) async -> AgentAuthToken? { + #if DEBUG + print("[AgentChat] 🔑 getValidToken: checking cached token for \(vault.pubKeyECDSA.prefix(20))...") + #endif + if let token = authService.getCachedToken(vaultPubKey: vault.pubKeyECDSA) { + #if DEBUG + print("[AgentChat] 🔑 getValidToken: found cached token, expires \(token.expiresAt)") + #endif + return token + } + #if DEBUG + print("[AgentChat] 🔑 getValidToken: no cached token, trying to refresh...") + #endif + let refreshed = await authService.refreshIfNeeded(vaultPubKey: vault.pubKeyECDSA) + #if DEBUG + print("[AgentChat] 🔑 getValidToken: refresh result = \(refreshed != nil ? "token found" : "nil")") + #endif + return refreshed + } + + private func handleError(_ error: Error) { + #if DEBUG + print("[AgentChat] ⚠️ handleError: \(error) — \(error.localizedDescription)") + #endif + streamingMessageId = nil + isLoading = false + + if case AgentBackendClient.AgentBackendError.unauthorized = error { + #if DEBUG + print("[AgentChat] ⚠️ handleError: unauthorized, showing password prompt") + #endif + passwordRequired = true + self.error = nil + } else { + self.error = error.localizedDescription + } + + logger.error("Agent error: \(error.localizedDescription)") + } + + private func formatActionTitle(_ type: String, title: String) -> String { + switch type { + case "get_balances": return "FETCHING BALANCES" + case "get_market_price": return "FETCHING PRICES" + case "build_swap_tx": return "PREPARING SWAP" + case "add_token": return "ADDING TOKEN" + case "add_chain": return "ADDING CHAIN" + case "get_address_book": return "CHECKING ADDRESS BOOK" + case "add_address_book": return "UPDATING ADDRESS BOOK" + default: return title.uppercased() + } + } + + private func normalizeErrorMessage(_ error: String) -> String { + let lower = error.lowercased() + let cancelPatterns = ["context canceled", "context cancelled", "user cancelled", "user canceled", "agent stopped"] + for pattern in cancelPatterns { + if lower.contains(pattern) { + return "agent stopped" + } + } + return error + } +} diff --git a/VultisigApp/VultisigApp/View Models/Agent/AgentConversationsViewModel.swift b/VultisigApp/VultisigApp/View Models/Agent/AgentConversationsViewModel.swift new file mode 100644 index 0000000000..d8d2494ed7 --- /dev/null +++ b/VultisigApp/VultisigApp/View Models/Agent/AgentConversationsViewModel.swift @@ -0,0 +1,240 @@ +// +// AgentConversationsViewModel.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import Foundation +import OSLog + +@MainActor +final class AgentConversationsViewModel: ObservableObject { + + // MARK: - Published State + + @Published var conversations: [AgentConversation] = [] + @Published var starters: [String] = [] + @Published var isLoading = false + @Published var isConnected = false + @Published var passwordRequired = false + @Published var error: String? + + // MARK: - Private + + private let backendClient = AgentBackendClient() + private let authService = AgentAuthService.shared + private let logger = Logger(subsystem: "com.vultisig", category: "AgentConversationsVM") + + private var startersRefreshTimer: Timer? + private var lastStartersRefresh: Date? + + // MARK: - Load Conversations + + func checkAuthAndLoad(vault: Vault) async { + isLoading = true + let token = await getValidToken(vault: vault) + + if token == nil { + isLoading = false + isConnected = false + passwordRequired = true + return + } + + isConnected = true + // Load data in parallel, passing the already-fetched token to avoid re-fetching + async let convos: () = loadConversations(vault: vault, prefetchedToken: token) + async let starts: () = loadStarters(vault: vault, prefetchedToken: token) + _ = await (convos, starts) + } + + func loadConversations(vault: Vault, prefetchedToken: AgentAuthToken? = nil) async { + let token: AgentAuthToken? + if let t = prefetchedToken { + token = t + } else { + token = await getValidToken(vault: vault) + } + #if DEBUG + print("[AgentConvos] 📝 loadConversations: token=\(token != nil ? "present" : "none")") + #endif + + guard let token else { + isLoading = false + passwordRequired = true + return + } + + isLoading = true + error = nil + + do { + let response = try await backendClient.listConversations( + publicKey: vault.pubKeyECDSA, + skip: 0, + take: 50, + token: token.token + ) + conversations = response.conversations + #if DEBUG + print("[AgentConvos] ✅ Loaded \(response.conversations.count) conversations") + #endif + logger.info("Loaded \(response.conversations.count) conversations") + self.isConnected = true + } catch let error as AgentBackendClient.AgentBackendError { + if case .unauthorized = error { + self.passwordRequired = true + } else { + logger.error("Failed to load conversations: \(error.localizedDescription)") + self.error = error.localizedDescription + } + } catch { + logger.error("Failed to load conversations: \(error.localizedDescription)") + self.error = error.localizedDescription + } + + isLoading = false + } + + // MARK: - Load Starters + + func loadStarters(vault: Vault, prefetchedToken: AgentAuthToken? = nil) async { + // Refresh every 30 minutes + if let lastRefresh = lastStartersRefresh, + Date().timeIntervalSince(lastRefresh) < 30 * 60, + !starters.isEmpty { + return + } + + let token: AgentAuthToken? + if let t = prefetchedToken { + token = t + } else { + token = await getValidToken(vault: vault) + } + guard let token else { + self.isConnected = false + self.passwordRequired = true + return + } + + do { + let context = AgentContextBuilder.buildContext(vault: vault) + let request = AgentGetStartersRequest( + publicKey: vault.pubKeyECDSA, + context: context + ) + + let response = try await backendClient.getStarters( + request: request, + token: token.token + ) + + if response.starters.isEmpty { + starters = Array(AgentChatViewModel.fallbackStarters.shuffled().prefix(4)) + } else { + starters = Array(response.starters.shuffled().prefix(4)) + } + + lastStartersRefresh = Date() + } catch let error as AgentBackendClient.AgentBackendError { + if case .unauthorized = error { + self.isConnected = false + self.passwordRequired = true + } else { + logger.warning("Failed to load starters, using fallback: \(error.localizedDescription)") + starters = Array(AgentChatViewModel.fallbackStarters.shuffled().prefix(4)) + } + } catch { + logger.warning("Failed to load starters, using fallback: \(error.localizedDescription)") + starters = Array(AgentChatViewModel.fallbackStarters.shuffled().prefix(4)) + } + } + + // MARK: - Create Conversation + + func createConversation(vault: Vault) async -> String? { + guard let token = await getValidToken(vault: vault) else { + passwordRequired = true + return nil + } + + do { + let conv = try await backendClient.createConversation( + publicKey: vault.pubKeyECDSA, + token: token.token + ) + return conv.id + } catch { + logger.error("Failed to create conversation: \(error.localizedDescription)") + self.error = error.localizedDescription + return nil + } + } + + // MARK: - Delete Conversation + + func deleteConversation(id: String, vault: Vault) async { + guard let token = await getValidToken(vault: vault) else { + passwordRequired = true + return + } + + do { + try await backendClient.deleteConversation( + id: id, + publicKey: vault.pubKeyECDSA, + token: token.token + ) + conversations.removeAll { $0.id == id } + } catch { + logger.error("Failed to delete conversation: \(error.localizedDescription)") + self.error = error.localizedDescription + } + } + + func deleteAllConversations(vault: Vault) async { + let all = conversations + await withTaskGroup(of: Void.self) { group in + for conv in all { + group.addTask { [weak self] in + guard let self else { return } + await self.deleteConversation(id: conv.id, vault: vault) + } + } + } + } + + // MARK: - Auth + + func signIn(vault: Vault, password: String) async { + isLoading = true + do { + _ = try await authService.signIn(vault: vault, password: password) + passwordRequired = false + isConnected = true + await checkAuthAndLoad(vault: vault) + } catch { + self.error = "Sign-in failed: \(error.localizedDescription)" + isLoading = false + } + } + + // MARK: - Helpers + + private func getValidToken(vault: Vault) async -> AgentAuthToken? { + if let token = authService.getCachedToken(vaultPubKey: vault.pubKeyECDSA) { + return token + } + return await authService.refreshIfNeeded(vaultPubKey: vault.pubKeyECDSA) + } + + func dismissError() { + error = nil + } + + deinit { + startersRefreshTimer?.invalidate() + } +} diff --git a/VultisigApp/VultisigApp/View Models/KeysignDiscoveryViewModel.swift b/VultisigApp/VultisigApp/View Models/KeysignDiscoveryViewModel.swift index 0fe50532b4..6ed0dbe02a 100644 --- a/VultisigApp/VultisigApp/View Models/KeysignDiscoveryViewModel.swift +++ b/VultisigApp/VultisigApp/View Models/KeysignDiscoveryViewModel.swift @@ -128,7 +128,7 @@ class KeysignDiscoveryViewModel: ObservableObject { } if let fastVaultPassword, let coin { - // when fast sign , always using relay server + // when fast sign, always using relay server serverAddr = Endpoint.vultisigRelay if vault.signers.count <= 3 { diff --git a/VultisigApp/VultisigApp/View Models/KeysignViewModel.swift b/VultisigApp/VultisigApp/View Models/KeysignViewModel.swift index d6ebed4a9d..9f5ba42afd 100644 --- a/VultisigApp/VultisigApp/View Models/KeysignViewModel.swift +++ b/VultisigApp/VultisigApp/View Models/KeysignViewModel.swift @@ -94,7 +94,7 @@ class KeysignViewModel: ObservableObject { self.keysignPayload = keysignPayload self.customMessagePayload = customMessagePayload self.encryptionKeyHex = encryptionKeyHex - let isEncryptGCM = await FeatureFlagService().isFeatureEnabled(feature: .EncryptGCM) + let isEncryptGCM = await FeatureFlagService().isFeatureEnabled(feature: .EncryptGCM) self.messagePuller = MessagePuller(encryptionKeyHex: encryptionKeyHex, pubKey: vault.pubKeyECDSA, encryptGCM: isEncryptGCM) self.isInitiateDevice = isInitiateDevice @@ -152,6 +152,7 @@ class KeysignViewModel: ObservableObject { } func startKeysign() async { + switch vault.libType { case .GG20, .none: await startKeysignGG20() @@ -549,6 +550,8 @@ class KeysignViewModel: ObservableObject { switch result { case .success(let transactionHash): self.txid = transactionHash + // Notify Agent chat of successful broadcast (callback fires after broadcastTransaction() returns) + NotificationCenter.default.post(name: .agentDidBroadcastTx, object: nil, userInfo: ["txid": transactionHash]) // Clear UTXO cache after successful broadcast to prevent using spent UTXOs Task { await BlockchairService.shared.clearUTXOCache(for: keysignPayload.coin) @@ -563,6 +566,8 @@ class KeysignViewModel: ObservableObject { switch result { case .success(let transactionHash): self.txid = transactionHash + // Notify Agent chat of successful broadcast (callback fires after broadcastTransaction() returns) + NotificationCenter.default.post(name: .agentDidBroadcastTx, object: nil, userInfo: ["txid": transactionHash]) // Clear UTXO cache after successful broadcast to prevent using spent UTXOs Task { await BlockchairService.shared.clearUTXOCache(for: keysignPayload.coin) @@ -644,6 +649,10 @@ class KeysignViewModel: ObservableObject { // Save to pending transactions for status tracking savePendingTransaction() + + if !txid.isEmpty && txid != "Transaction already broadcasted." { + NotificationCenter.default.post(name: .agentDidBroadcastTx, object: nil, userInfo: ["txid": txid]) + } } private func savePendingTransaction() { diff --git a/VultisigApp/VultisigApp/View Models/SettingsViewModel.swift b/VultisigApp/VultisigApp/View Models/SettingsViewModel.swift index ed8347e38e..c9163b5572 100644 --- a/VultisigApp/VultisigApp/View Models/SettingsViewModel.swift +++ b/VultisigApp/VultisigApp/View Models/SettingsViewModel.swift @@ -34,6 +34,7 @@ class SettingsViewModel: ObservableObject { @AppStorage("thorchainChainnet") var enableThorchainChainnet: Bool = false @AppStorage("SellEnabled") var sellEnabled: Bool = false @AppStorage("isMLDSAEnabled") var isMLDSAEnabled: Bool = false + @AppStorage("agentEnabled") var agentEnabled: Bool = false init() { self.selectedCurrency = SettingsCurrency.current diff --git a/VultisigApp/VultisigApp/Views/Agent/AgentChatMessageView.swift b/VultisigApp/VultisigApp/Views/Agent/AgentChatMessageView.swift new file mode 100644 index 0000000000..778a60f724 --- /dev/null +++ b/VultisigApp/VultisigApp/Views/Agent/AgentChatMessageView.swift @@ -0,0 +1,305 @@ +// +// AgentChatMessageView.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import SwiftUI + +struct AgentChatMessageView: View { + @Environment(\.openURL) var openURL + let message: AgentChatMessage + + var body: some View { + if message.toolCall != nil { + toolCallView + } else if message.txStatus != nil { + txStatusView + } else if message.txProposal != nil { + txProposalView + } else { + messageBubble + } + } + + // MARK: - Message Bubble + + private var messageBubble: some View { + HStack(alignment: .top, spacing: 0) { + if message.role == .user { Spacer(minLength: 60) } + + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) { + // Use verbatim text while streaming to skip Markdown re-parsing on every delta. + // After the stream finalizes (isStreaming = false), switch to full Markdown. + if message.isStreaming { + Text(verbatim: message.content) + .font(Theme.fonts.bodyMMedium) + .foregroundStyle(Theme.colors.textPrimary) + .textSelection(.enabled) + } else { + Text(.init(message.content)) // Renders markdown + .font(Theme.fonts.bodyMMedium) + .foregroundStyle(Theme.colors.textPrimary) + .textSelection(.enabled) + } + + // Token results + if let tokens = message.tokenResults, !tokens.isEmpty { + tokenResultsView(tokens) + } + } + .padding(12) + .background( + message.role == .user + ? Theme.colors.turquoise.opacity(0.2) + : Theme.colors.bgSurface1 + ) + .cornerRadius(16) + + if message.role == .assistant { Spacer(minLength: 60) } + } + } + + // MARK: - Tool Call Status + + @ViewBuilder + private var toolCallView: some View { + if let toolCall = message.toolCall { + HStack(spacing: 8) { + statusIcon(for: toolCall.status) + + Text(toolCall.title) + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + + Spacer() + + if let error = toolCall.error { + Text(error) + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.alertError) + .lineLimit(1) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + private func statusIcon(for status: AgentToolCallStatus) -> some View { + Group { + switch status { + case .running: + Image(systemName: "sun.max") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + case .success: + Image(systemName: "checkmark.circle.fill") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.alertSuccess) + case .error: + Image(systemName: "xmark.circle.fill") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.alertError) + } + } + } + + // MARK: - Transaction Proposal + + @ViewBuilder + private var txProposalView: some View { + if let tx = message.txProposal { + VStack(alignment: .leading, spacing: 12) { + + // Route header + HStack(alignment: .top, spacing: 8) { + Image(systemName: "arrow.left.arrow.right.circle") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + + VStack(alignment: .leading, spacing: 4) { + Text("\(tx.txType ?? "SWAP") \(tx.amount) \(tx.fromSymbol) → \(tx.toSymbol ?? "")") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + + if let provider = tx.provider { + Text("ROUTE: \(provider)".uppercased()) + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + } + + Text("EST. FEE: 0.001 \(tx.fromSymbol)") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + } + } + .padding(.bottom, 8) + + Text(tx.needsApproval == true ? "Should I execute the swap?" : "Transaction ready to sign.") + .font(Theme.fonts.bodyMMedium) + .foregroundStyle(Theme.colors.textPrimary) + + HStack(spacing: 12) { + Spacer() + if tx.needsApproval == true { + Button { + NotificationCenter.default.post(name: .agentDidRejectTx, object: tx) + } label: { + Text("No") + .font(Theme.fonts.buttonRegularSemibold) + .foregroundStyle(Theme.colors.textPrimary) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Theme.colors.bgSurface1) + .cornerRadius(20) + } + + Button { + NotificationCenter.default.post(name: .agentDidAcceptTx, object: tx) + } label: { + Text("Yes") + .font(Theme.fonts.buttonRegularSemibold) + .foregroundStyle(Theme.colors.bgPrimary) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Theme.colors.turquoise) + .cornerRadius(20) + } + } else { + Button { + NotificationCenter.default.post(name: .agentDidAcceptTx, object: tx) + } label: { + Text("Sign Transaction") + .font(Theme.fonts.buttonRegularSemibold) + .foregroundStyle(Theme.colors.bgPrimary) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Theme.colors.turquoise) + .cornerRadius(20) + } + } + } + } + .padding(16) + .background(Theme.colors.bgSurface1.opacity(0.3)) + .cornerRadius(16) + } + } + + // MARK: - Tx Status + + @ViewBuilder + private var txStatusView: some View { + if let txStatus = message.txStatus { + HStack(spacing: 8) { + txStatusIcon(for: txStatus.status) + + Text(txStatus.label) + .font(Theme.fonts.bodySMedium) + .foregroundStyle(Theme.colors.textPrimary) + + Spacer() + + if txStatus.status != .pending { + Button { + openExplorer(txHash: txStatus.txHash, chain: txStatus.chain) + } label: { + Text("View") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.turquoise) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Theme.colors.bgSurface1.opacity(0.5)) + .cornerRadius(8) + } + } + + private func txStatusIcon(for status: AgentTxStatus) -> some View { + Group { + switch status { + case .pending: + ProgressView() + .scaleEffect(0.6) + .frame(width: 16, height: 16) + case .confirmed: + Image(systemName: "checkmark.circle.fill") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.alertSuccess) + case .failed: + Image(systemName: "xmark.circle.fill") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.alertError) + } + } + } + + // MARK: - Token Results + + private func tokenResultsView(_ tokens: [AgentTokenSearchResult]) -> some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(tokens, id: \.symbol) { token in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(token.symbol) + .font(Theme.fonts.bodySMedium) + .foregroundStyle(Theme.colors.textPrimary) + Text(token.name) + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + } + + Spacer() + + if let price = token.priceUsd { + Text("$\(price)") + .font(Theme.fonts.bodySMedium) + .foregroundStyle(Theme.colors.textPrimary) + } + } + .padding(8) + .background(Theme.colors.bgPrimary.opacity(0.5)) + .cornerRadius(8) + } + } + .padding(.top, 4) + } + + // MARK: - Helpers + + private func openExplorer(txHash: String, chain: String) { + if let chainEnum = Chain(rawValue: chain) { + let urlString = Endpoint.getExplorerURL(chain: chainEnum, txid: txHash) + if let explorerUrl = URL(string: urlString) { + openURL(explorerUrl) + } + } + } +} + +#Preview { + VStack(spacing: 12) { + AgentChatMessageView(message: AgentChatMessage( + id: "1", role: .user, content: "What's my ETH balance?", timestamp: Date() + )) + + AgentChatMessageView(message: AgentChatMessage( + id: "2", role: .assistant, content: "Your ETH balance is **2.5 ETH** (~$4,500).", timestamp: Date() + )) + + AgentChatMessageView(message: AgentChatMessage( + id: "3", role: .assistant, content: "", + timestamp: Date(), + toolCall: AgentToolCallInfo( + actionType: "get_balances", title: "Getting balances", + status: .running + ) + )) + } + .padding() + .background(Theme.colors.bgPrimary) +} diff --git a/VultisigApp/VultisigApp/Views/Agent/AgentChatView.swift b/VultisigApp/VultisigApp/Views/Agent/AgentChatView.swift new file mode 100644 index 0000000000..b9c633da0b --- /dev/null +++ b/VultisigApp/VultisigApp/Views/Agent/AgentChatView.swift @@ -0,0 +1,356 @@ +// +// AgentChatView.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import SwiftUI + +struct AgentChatView: View { + let conversationId: String? + + @EnvironmentObject private var appViewModel: AppViewModel + @StateObject private var viewModel = AgentChatViewModel() + @State private var inputText = "" + @State private var showPasswordPrompt = false + @State private var showDeleteConfirm = false + @FocusState private var isInputFocused: Bool + @Environment(\.router) private var router + + var body: some View { + VStack(spacing: 0) { + inlineHeader + Separator(color: Theme.colors.borderLight, opacity: 1) + VStack(spacing: 0) { + messagesList + inputBar + } + } + .background(Theme.colors.bgPrimary.ignoresSafeArea()) + .navigationBarBackButtonHidden(true) + .confirmationDialog( + "Delete this conversation?", + isPresented: $showDeleteConfirm, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + guard let vault = appViewModel.selectedVault else { return } + viewModel.deleteCurrentConversation(vault: vault) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This action cannot be undone.") + } + .onChange(of: viewModel.conversationDeleted) { _, deleted in + if deleted { + router.navigateBack() + } + } + .onAppear { + setupChat() + } + .onChange(of: viewModel.passwordRequired) { _, required in + showPasswordPrompt = required + } + .sheet(isPresented: $showPasswordPrompt) { + AgentPasswordPromptScreen { password in + guard let vault = appViewModel.selectedVault else { return } + Task { + await viewModel.signIn(vault: vault, password: password) + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .agentDidAcceptTx)) { notif in + guard let tx = notif.object as? AgentTxReady, let vault = appViewModel.selectedVault else { return } + viewModel.acceptTxProposal(tx, vault: vault) + } + .onReceive(NotificationCenter.default.publisher(for: .agentDidRejectTx)) { notif in + guard let tx = notif.object as? AgentTxReady, let vault = appViewModel.selectedVault else { return } + viewModel.rejectTxProposal(tx, vault: vault) + } + .alert("Error", isPresented: .init( + get: { viewModel.error != nil }, + set: { if !$0 { viewModel.dismissError() } } + )) { + Button("OK") { viewModel.dismissError() } + } message: { + Text(viewModel.error ?? "") + } + .sheet(isPresented: $viewModel.shouldShowPairingSheet) { + if let tx = viewModel.pendingSendTx, let vault = appViewModel.selectedVault, let keysignPayload = viewModel.activeKeysignPayload { + NavigationStack { + SendPairScreen( + vault: vault, + tx: tx, + keysignPayload: keysignPayload, + fastVaultPassword: tx.fastVaultPassword.nilIfEmpty + ) + .navigationDestination(for: SendRoute.self) { route in + SendRouter().build(route) + } + .environmentObject(NavigationRouter()) + } + } + } + .sheet(isPresented: $viewModel.showFastVaultPasswordPrompt) { + if let tx = viewModel.pendingSendTx, let vault = appViewModel.selectedVault { + FastVaultEnterPasswordView( + password: Binding( + get: { tx.fastVaultPassword }, + set: { tx.fastVaultPassword = $0 } + ), + vault: vault, + onSubmit: { + viewModel.executeFastVaultKeysign(password: tx.fastVaultPassword, vault: vault) + } + ) + } + } + .onReceive(NotificationCenter.default.publisher(for: .agentDidBroadcastTx)) { notif in + if let txid = notif.userInfo?["txid"] as? String, let vault = appViewModel.selectedVault { + viewModel.handleTxBroadcasted(txid: txid, vault: vault) + } + } + } + + // MARK: - Inline Header + + private var inlineHeader: some View { + ZStack { + Text(viewModel.conversationTitle ?? "Vultisig") + .font(Theme.fonts.bodyMMedium) + .foregroundStyle(Theme.colors.textPrimary) + + HStack { + Button { + router.navigateBack() + } label: { + Image(systemName: "chevron.left") + .font(Theme.fonts.bodyMMedium) + .foregroundStyle(Theme.colors.textPrimary) + } + + Spacer() + + if conversationId != nil || viewModel.conversationId != nil { + Menu { + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Label("Delete Conversation", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundStyle(Theme.colors.textPrimary) + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Theme.colors.bgPrimary) + } + + // MARK: - Messages List + + private var messagesList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + if viewModel.messages.isEmpty && !viewModel.isLoading { + startersView + } + + ForEach(viewModel.messages) { message in + AgentChatMessageView(message: message) + .id(message.id) + } + + if viewModel.isLoading { + AgentThinkingIndicator() + .id("thinking") + } + } + .padding() + } + .onChange(of: viewModel.messages.count) { _, _ in + withAnimation(.easeOut(duration: 0.2)) { + if let lastId = viewModel.messages.last?.id { + proxy.scrollTo(lastId, anchor: .bottom) + } else if viewModel.isLoading { + proxy.scrollTo("thinking", anchor: .bottom) + } + } + } + .onChange(of: viewModel.isLoading) { _, isLoading in + if isLoading { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo("thinking", anchor: .bottom) + } + } + } + } + } + + // MARK: - Input Bar + + private var inputBar: some View { + HStack(spacing: 12) { + TextField("Message Vulti...", text: $inputText, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...5) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Theme.colors.bgSurface1) + .cornerRadius(20) + .foregroundStyle(Theme.colors.textPrimary) + .focused($isInputFocused) + + if viewModel.isLoading { + Button { + viewModel.cancelRequest() + } label: { + Image(systemName: "stop.circle.fill") + .font(Theme.fonts.title2) + .foregroundStyle(Theme.colors.alertError) + } + } else { + Button { + sendMessage() + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(Theme.fonts.title2) + .foregroundStyle( + inputText.trimmingCharacters(in: .whitespaces).isEmpty + ? Theme.colors.textTertiary + : Theme.colors.turquoise + ) + } + .disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Theme.colors.bgPrimary) + } + + // MARK: - Actions + + private func setupChat() { + guard let vault = appViewModel.selectedVault else { return } + viewModel.checkConnection(vault: vault) + + if let convId = conversationId { + Task { + await viewModel.loadConversation(id: convId, vault: vault) + } + } else { + Task { + await viewModel.loadStarters(vault: vault) + } + } + + // Check for pending starter message + if let starter = UserDefaults.standard.string(forKey: "agent_pending_starter") { + UserDefaults.standard.removeObject(forKey: "agent_pending_starter") + inputText = starter + Task { + try? await Task.sleep(nanoseconds: 500_000_000) + await MainActor.run { sendMessage() } + } + } + } + + private func sendMessage() { + let text = inputText.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty, let vault = appViewModel.selectedVault else { return } + + inputText = "" + isInputFocused = false + viewModel.sendMessage(text, vault: vault) + } + + // MARK: - Starters UI + + private var startersView: some View { + VStack(spacing: 24) { + Spacer().frame(height: 40) + + Image(systemName: "sparkles") + .font(.system(size: 48)) + .foregroundStyle( + LinearGradient( + colors: [Theme.colors.turquoise, Theme.colors.primaryAccent3], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Text("Ask Vulti Anything") + .font(Theme.fonts.title3) + .foregroundStyle(Theme.colors.textPrimary) + + Text("Try one of these suggestions below or type your own question.") + .font(Theme.fonts.bodySMedium) + .foregroundStyle(Theme.colors.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + // Starter suggestions + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], spacing: 12) { + ForEach(viewModel.starters, id: \.self) { starter in + starterCard(starter) + } + } + .padding(.top, 8) + + Spacer() + } + } + + private func starterCard(_ text: String) -> some View { + Button { + inputText = text + Task { + try? await Task.sleep(nanoseconds: 100_000_000) + await MainActor.run { sendMessage() } + } + } label: { + HStack { + Text(text) + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textPrimary) + .multilineTextAlignment(.leading) + .lineLimit(3) + + Spacer() + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 72, alignment: .topLeading) + .background(Theme.colors.bgSurface1) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Theme.colors.border, lineWidth: 1) + ) + } + } +} + +#Preview { + NavigationStack { + AgentChatView(conversationId: nil) + .environmentObject(AppViewModel()) + } +} + +extension Notification.Name { + static let agentDidAcceptTx = Notification.Name("agentDidAcceptTx") + static let agentDidRejectTx = Notification.Name("agentDidRejectTx") + static let agentDidBroadcastTx = Notification.Name("agentDidBroadcastTx") +} diff --git a/VultisigApp/VultisigApp/Views/Agent/AgentConversationsView.swift b/VultisigApp/VultisigApp/Views/Agent/AgentConversationsView.swift new file mode 100644 index 0000000000..c0a56ccb56 --- /dev/null +++ b/VultisigApp/VultisigApp/Views/Agent/AgentConversationsView.swift @@ -0,0 +1,254 @@ +// +// AgentConversationsView.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import SwiftUI + +struct AgentConversationsView: View { + @EnvironmentObject private var appViewModel: AppViewModel + @StateObject private var viewModel = AgentConversationsViewModel() + @Environment(\.router) private var router + + @State private var showDeleteAllConfirm = false + + var body: some View { + VStack(spacing: 0) { + inlineHeader + Separator(color: Theme.colors.borderLight, opacity: 1) + content + } + .background(Theme.colors.bgPrimary.ignoresSafeArea()) + .confirmationDialog( + "Delete all conversations?", + isPresented: $showDeleteAllConfirm, + titleVisibility: .visible + ) { + Button("Delete All", role: .destructive) { + guard let vault = appViewModel.selectedVault else { return } + Task { await viewModel.deleteAllConversations(vault: vault) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete all your conversations. This cannot be undone.") + } + .onAppear { + loadData() + } + .crossPlatformSheet(isPresented: $viewModel.passwordRequired) { + AgentPasswordPromptScreen { password in + guard let vault = appViewModel.selectedVault else { return } + Task { + await viewModel.signIn(vault: vault, password: password) + } + } + } + } + + // MARK: - Inline Header (root tab — no native NavBar) + + private var inlineHeader: some View { + ZStack { + Text("Vultisig") + .font(Theme.fonts.bodyMMedium) + .foregroundStyle(Theme.colors.textPrimary) + + HStack { + Spacer() + HStack(spacing: 12) { + Circle() + .fill(viewModel.isConnected ? Theme.colors.alertSuccess : Theme.colors.textTertiary) + .frame(width: 10, height: 10) + if !viewModel.conversations.isEmpty { + Menu { + Button(role: .destructive) { + showDeleteAllConfirm = true + } label: { + Label("Delete All Conversations", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundStyle(Theme.colors.textPrimary) + } + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Theme.colors.bgPrimary) + } + + // MARK: - Content + + private var content: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + if viewModel.isLoading && viewModel.conversations.isEmpty { + VStack { + Spacer().frame(height: 100) + ProgressView() + .controlSize(.large) + .tint(Theme.colors.turquoise) + Text("Loading conversations...") + .font(Theme.fonts.bodySMedium) + .foregroundStyle(Theme.colors.textTertiary) + .padding(.top, 8) + Spacer() + } + } else if viewModel.conversations.isEmpty { + VStack(spacing: 24) { + Spacer().frame(height: 40) + + Image(systemName: "bubble.left.and.bubble.right.fill") + .font(.system(size: 48)) + .foregroundStyle( + LinearGradient( + colors: [Theme.colors.turquoise, Theme.colors.primaryAccent3], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Text("No Past Conversations") + .font(Theme.fonts.title3) + .foregroundStyle(Theme.colors.textPrimary) + + Text("Start a new chat to begin interacting with Vulti.") + .font(Theme.fonts.bodySMedium) + .foregroundStyle(Theme.colors.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + // New chat button + PrimaryButton(title: "New Chat") { + navigateToChat(with: nil) + } + } + } else { + conversationList + } + } + .padding() + .padding(.bottom, 32) + } + } + + // MARK: - Conversation List + + private var conversationList: some View { + VStack(spacing: 0) { + // New chat button + PrimaryButton(title: "New Chat") { + navigateToChat(with: nil) + } + .padding(.bottom, 16) + + // Past conversations — LazyVStack for proper row virtualisation. + // (The outer ScrollView already handles scrolling; no inner List needed.) + LazyVStack(spacing: 0) { + ForEach(viewModel.conversations) { conv in + conversationRow(conv) + .padding(.bottom, 4) + // Swipe-to-delete (replaces List's .onDelete) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + guard let vault = appViewModel.selectedVault else { return } + if let idx = viewModel.conversations.firstIndex(where: { $0.id == conv.id }) { + deleteConversations(at: IndexSet(integer: idx)) + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + } + + private func conversationRow(_ conv: AgentConversation) -> some View { + Button { + router.navigate(to: AgentRoute.chat(conversationId: conv.id)) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(conv.title ?? "New Chat") + .font(Theme.fonts.bodyMMedium) + .foregroundStyle(Theme.colors.textPrimary) + .lineLimit(1) + + Text(formatDate(conv.updatedAt)) + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + } + Spacer() + Image(systemName: "chevron.right") + .font(Theme.fonts.caption12) + .foregroundStyle(Theme.colors.textTertiary) + } + .padding() + .background(Theme.colors.bgSurface1) + .cornerRadius(12) + } + } + + // MARK: - Actions + + private func loadData() { + guard let vault = appViewModel.selectedVault else { return } + + Task { + await viewModel.checkAuthAndLoad(vault: vault) + } + } + + private func navigateToChat(with starter: String?) { + if let starter { + UserDefaults.standard.set(starter, forKey: "agent_pending_starter") + } + router.navigate(to: AgentRoute.chat(conversationId: nil)) + } + + private func deleteConversations(at indexSet: IndexSet) { + guard let vault = appViewModel.selectedVault else { return } + for index in indexSet { + let conv = viewModel.conversations[index] + Task { + await viewModel.deleteConversation(id: conv.id, vault: vault) + } + } + } + + // MARK: - Shared formatters (one allocation per view lifetime, not per row) + + private static let iso8601Formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + private static let relativeDateFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + + private func formatDate(_ dateStr: String) -> String { + guard let date = Self.iso8601Formatter.date(from: dateStr) else { + // Fallback: try without fractional seconds + let plain = ISO8601DateFormatter() + guard let d = plain.date(from: dateStr) else { return dateStr } + return Self.relativeDateFormatter.localizedString(for: d, relativeTo: Date()) + } + return Self.relativeDateFormatter.localizedString(for: date, relativeTo: Date()) + } +} + +#Preview { + NavigationStack { + AgentConversationsView() + .environmentObject(AppViewModel()) + } +} diff --git a/VultisigApp/VultisigApp/Views/Agent/AgentPasswordPromptScreen.swift b/VultisigApp/VultisigApp/Views/Agent/AgentPasswordPromptScreen.swift new file mode 100644 index 0000000000..8b2eea1199 --- /dev/null +++ b/VultisigApp/VultisigApp/Views/Agent/AgentPasswordPromptScreen.swift @@ -0,0 +1,80 @@ +// +// AgentPasswordPromptView.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import SwiftUI + +struct AgentPasswordPromptScreen: View { + let onSubmit: (String) -> Void + + @State private var password = "" + @State private var isSubmitting = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + Screen(title: "") { + ZStack { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "lock.shield.fill") + .font(.system(size: 48)) + .foregroundStyle(Theme.colors.turquoise) + + Text("Enter Vault Password") + .font(.title3.bold()) + .foregroundStyle(Theme.colors.textPrimary) + + Text("Your password is needed to sign into the agent service using your vault.") + .font(.subheadline) + .foregroundStyle(Theme.colors.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + CommonTextField( + text: $password, + placeholder: "Password", + isSecure: .constant(true) + ) + .padding(.horizontal) + + PrimaryButton( + title: "Connect", + isLoading: isSubmitting + ) { + isSubmitting = true + onSubmit(password) + dismiss() + } + .disabled(password.isEmpty) + .padding(.horizontal) + + Spacer() + } + } + .toolbar { + #if os(iOS) + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + .foregroundStyle(Theme.colors.textTertiary) + } + #else + ToolbarItem(placement: .automatic) { + Button("Cancel") { dismiss() } + .foregroundStyle(Theme.colors.textTertiary) + } + #endif + } + } + .presentationDetents([.medium]) + } +} + +#Preview { + AgentPasswordPromptScreen { password in + print("Password: \(password)") + } +} diff --git a/VultisigApp/VultisigApp/Views/Agent/AgentThinkingIndicator.swift b/VultisigApp/VultisigApp/Views/Agent/AgentThinkingIndicator.swift new file mode 100644 index 0000000000..b066d6e025 --- /dev/null +++ b/VultisigApp/VultisigApp/Views/Agent/AgentThinkingIndicator.swift @@ -0,0 +1,47 @@ +// +// AgentThinkingIndicator.swift +// VultisigApp +// +// Created by Enrique Souza on 2026-02-25. +// + +import SwiftUI + +struct AgentThinkingIndicator: View { + @State private var animating = false + + var body: some View { + HStack(alignment: .top, spacing: 0) { + HStack(spacing: 6) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Theme.colors.textTertiary) + .frame(width: 8, height: 8) + .scaleEffect(animating ? 1.0 : 0.5) + .opacity(animating ? 1.0 : 0.3) + .animation( + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.2), + value: animating + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Theme.colors.bgSurface1) + .cornerRadius(16) + + Spacer() + } + .onAppear { + animating = true + } + } +} + +#Preview { + AgentThinkingIndicator() + .padding() + .background(Theme.colors.bgPrimary) +} diff --git a/VultisigApp/VultisigApp/Views/Components/TabBar/VultiTabBar.swift b/VultisigApp/VultisigApp/Views/Components/TabBar/VultiTabBar.swift index 8d927eb5c7..2ca337dcf9 100644 --- a/VultisigApp/VultisigApp/Views/Components/TabBar/VultiTabBar.swift +++ b/VultisigApp/VultisigApp/Views/Components/TabBar/VultiTabBar.swift @@ -227,6 +227,10 @@ private extension VultiTabBar { Color.green.ignoresSafeArea() .overlay(Text("Earn").foregroundColor(.white)) .tag(HomeTab.defi) + case .agent: + Color.purple.ignoresSafeArea() + .overlay(Text("Agent").foregroundColor(.white)) + .tag(HomeTab.agent) case .camera: EmptyView() } diff --git a/VultisigApp/VultisigApp/Views/Keysign/KeysignDiscoveryView.swift b/VultisigApp/VultisigApp/Views/Keysign/KeysignDiscoveryView.swift index 17f5f7b1db..a065f04f1a 100644 --- a/VultisigApp/VultisigApp/Views/Keysign/KeysignDiscoveryView.swift +++ b/VultisigApp/VultisigApp/Views/Keysign/KeysignDiscoveryView.swift @@ -36,14 +36,9 @@ struct KeysignDiscoveryView: View { @State var screenWidth: CGFloat = 0 @State var screenHeight: CGFloat = 0 @State var qrCodeImage: Image? = nil - @State var qrCodeString: String? = nil - @State var bannerText: String? = nil - @State private var resendCountdown: Int = 0 - @State private var resendCountdownTask: Task? @State var selectedNetwork = VultisigRelay.IsRelayEnabled ? NetworkPromptType.Internet : NetworkPromptType.Local @State var qrScannedAnimation: RiveViewModel? = nil - @State var dotsIndicatorVM: RiveViewModel? = nil #if os(iOS) @State var orientation = UIDevice.current.orientation @@ -55,6 +50,10 @@ struct KeysignDiscoveryView: View { GridItem(.adaptive(minimum: 150, maximum: 300), spacing: 16) ] + let adaptiveColumnsMac = [ + GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 8) + ] + var localModeAvailable: Bool { vault.libType != .KeyImport } var body: some View { @@ -70,7 +69,6 @@ struct KeysignDiscoveryView: View { loader } } - .withBanner(text: $bannerText) .onLoad { Task { @MainActor in qrScannedAnimation = RiveViewModel(fileName: "qrscanner", autoPlay: true) @@ -109,7 +107,6 @@ struct KeysignDiscoveryView: View { ZStack(alignment: .bottom) { orientedContent switchLink - .frame(maxWidth: .infinity) .background(Theme.colors.bgPrimary) .showIf(localModeAvailable) } @@ -118,46 +115,12 @@ struct KeysignDiscoveryView: View { var portraitContent: some View { ScrollView(showsIndicators: false) { paringQRCode - VStack(spacing: 10) { - waitingForDevicesText - disclaimer - resendNotificationButton - } + disclaimer list } .padding(.horizontal, contentPadding ?? 16) } - @ViewBuilder - var resendNotificationButton: some View { - let isDisabled = resendCountdown > 0 - - Button { - notifyVaultDevices() - } label: { - HStack(spacing: 6) { - Icon(named: "bell", color: Theme.colors.alertInfo, size: 16) - Text(isDisabled - ? String(format: "resendNotificationIn".localized, resendCountdownFormatted) - : "resendNotification".localized - ) - .font(Theme.fonts.caption12) - .foregroundStyle(Theme.colors.alertInfo) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .overlay( - RoundedRectangle(cornerRadius: 12) - .inset(by: 0.5) - .stroke(.white.opacity(0.03), lineWidth: 1) - .fill(Theme.colors.bgButtonDisabled.opacity(0.5)) - ) - } - .disabled(isDisabled) - .padding(.bottom, 8) - .showIf(!vault.isFastVault) - } - @ViewBuilder var paringQRCode: some View { ZStack { @@ -167,33 +130,24 @@ struct KeysignDiscoveryView: View { .padding(.bottom) } - @ViewBuilder var disclaimer: some View { - if selectedNetwork == .Local { - ZStack { + ZStack { + if selectedNetwork == .Local { LocalModeDisclaimer() } } } - @ViewBuilder - var waitingForDevicesText: some View { - let description = selectedNetwork == .Internet ? "waitingForDevicesToConnect" : "localModeWaitingOnDevices" - HStack(alignment: .bottom, spacing: 2) { - Text(description.localized) - .font(Theme.fonts.title3) - .foregroundStyle(Theme.colors.textPrimary) - - dotsIndicatorVM?.view() - .frame(width: 12, height: 12) - .offset(y: 2) - } - .onAppear { - dotsIndicatorVM = RiveViewModel(fileName: "dots_indicator", autoPlay: true) - } - .onDisappear { - dotsIndicatorVM?.stop() + var listTitle: some View { + HStack(spacing: 8) { + Text(NSLocalizedString("devices", comment: "")) + Text("(\(viewModel.selections.count)/\(vault.getThreshold()+1))") } + .frame(maxWidth: .infinity, alignment: .leading) + .font(Theme.fonts.title2) + .foregroundColor(Theme.colors.textPrimary) + .padding(.bottom, 8) + .padding(.horizontal, 8) } var lookingForDevices: some View { @@ -208,23 +162,6 @@ struct KeysignDiscoveryView: View { return fastVaultPassword == nil ? .secure : .fast } - private var resendCountdownFormatted: String { - String(format: "%d:%02d", resendCountdown / 60, resendCountdown % 60) - } - - private func startResendCountdown() { - resendCountdownTask?.cancel() - resendCountdown = 30 - resendCountdownTask = Task { @MainActor in - while resendCountdown > 0 && !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - if !Task.isCancelled && resendCountdown > 0 { - resendCountdown -= 1 - } - } - } - } - func setData() async { if VultisigRelay.IsRelayEnabled { self.selectedNetwork = .Internet @@ -245,11 +182,15 @@ struct KeysignDiscoveryView: View { return } - self.qrCodeString = qrCodeData self.qrCodeImage = qrCodeImage if !vault.isFastVault { - notifyVaultDevices() + Task { + await PushNotificationManager.shared.notifyVaultDevices( + vault: vault, + qrCodeData: qrCodeData + ) + } } shareSheetViewModel.render( @@ -265,15 +206,6 @@ struct KeysignDiscoveryView: View { ) } - func notifyVaultDevices() { - guard let qrCodeString else { return } - Task { - await PushNotificationManager.shared.notifyVaultDevices(vault: vault, qrCodeData: qrCodeString) - bannerText = "notificationSentSuccessfully".localized - startResendCountdown() - } - } - func getSwapFromAmount() -> String { let tx = swapTransaction diff --git a/VultisigApp/VultisigApp/Views/Settings/SettingsAdvancedView.swift b/VultisigApp/VultisigApp/Views/Settings/SettingsAdvancedView.swift index eb4f727e1d..ad5492fe07 100644 --- a/VultisigApp/VultisigApp/Views/Settings/SettingsAdvancedView.swift +++ b/VultisigApp/VultisigApp/Views/Settings/SettingsAdvancedView.swift @@ -59,6 +59,12 @@ struct SettingsAdvancedView: View { icon: "lock.shield", isEnabled: $settingsViewModel.isMLDSAEnabled ) + + SettingToggleCell( + title: "AI Agent (Vulti)", + icon: "sparkles", + isEnabled: $settingsViewModel.agentEnabled + ) } } }