diff --git a/apps/demo-wallet/src/components/IntentRequestModal.tsx b/apps/demo-wallet/src/components/IntentRequestModal.tsx new file mode 100644 index 000000000..492ad9311 --- /dev/null +++ b/apps/demo-wallet/src/components/IntentRequestModal.tsx @@ -0,0 +1,527 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import type { + IntentRequestEvent, + BatchedIntentEvent, + TransactionIntentRequestEvent, + SignDataIntentRequestEvent, + ActionIntentRequestEvent, + IntentActionItem, + SendTonAction, + SendJettonAction, + SendNftAction, +} from '@ton/walletkit'; +import { useAuth } from '@demo/wallet-core'; +import type { SavedWallet } from '@demo/wallet-core'; +import { Address } from '@ton/core'; + +import { Button } from './Button'; +import { Card } from './Card'; +import { HoldToSignButton } from './HoldToSignButton'; +import { WalletPreview } from './WalletPreview'; +import { createComponentLogger } from '../utils/logger'; + +const log = createComponentLogger('IntentRequestModal'); + +// ==================== Shared Renderers ==================== + +function truncateAddress(address: string): string { + try { + const addr = Address.parse(address); + const friendly = addr.toString(); + return `${friendly.slice(0, 6)}...${friendly.slice(-4)}`; + } catch { + if (address.length > 16) { + return `${address.slice(0, 8)}...${address.slice(-4)}`; + } + return address; + } +} + +function formatNano(amount: string): string { + const n = BigInt(amount); + const whole = n / 1_000_000_000n; + const frac = n % 1_000_000_000n; + if (frac === 0n) return `${whole}`; + const fracStr = frac.toString().padStart(9, '0').replace(/0+$/, ''); + return `${whole}.${fracStr}`; +} + +const ActionItemCard: React.FC<{ item: IntentActionItem; index: number }> = ({ item, index }) => { + switch (item.type) { + case 'sendTon': { + const action = item.value as SendTonAction; + return ( +
+
+ + #{index + 1} Send TON + +
+
+

+ To:{' '} + {truncateAddress(action.address)} +

+

+ Amount: {formatNano(action.amount)} TON +

+ {action.payload && ( +

+ Payload: {action.payload} +

+ )} +
+
+ ); + } + case 'sendJetton': { + const action = item.value as SendJettonAction; + return ( +
+
+ + #{index + 1} Send Jetton + +
+
+

+ Master:{' '} + {truncateAddress(action.jettonMasterAddress)} +

+

+ Amount: {action.jettonAmount} +

+

+ To:{' '} + {truncateAddress(action.destination)} +

+ {action.forwardTonAmount && ( +

+ Forward TON: {formatNano(action.forwardTonAmount)} +

+ )} +
+
+ ); + } + case 'sendNft': { + const action = item.value as SendNftAction; + return ( +
+
+ + #{index + 1} Send NFT + +
+
+

+ NFT:{' '} + {truncateAddress(action.nftAddress)} +

+

+ New Owner:{' '} + {truncateAddress(action.newOwnerAddress)} +

+
+
+ ); + } + default: + return ( +
+

Unknown action type

+
+ ); + } +}; + +const IntentEventDetails: React.FC<{ event: IntentRequestEvent }> = ({ event }) => { + switch (event.type) { + case 'transaction': { + const tx = event.value as TransactionIntentRequestEvent; + return ( +
+
+ + Transaction + + + delivery: {tx.deliveryMode} + + {tx.network && network: {tx.network.chainId}} +
+ {tx.validUntil && ( +

+ Valid until: {new Date(tx.validUntil * 1000).toLocaleString()} +

+ )} +
+ {tx.items.map((item, i) => ( + + ))} +
+
+ ); + } + case 'signData': { + const sd = event.value as SignDataIntentRequestEvent; + return ( +
+
+ + Sign Data + + {sd.network && network: {sd.network.chainId}} +
+ {sd.manifestUrl &&

Manifest: {sd.manifestUrl}

} +
+

Payload

+
+                            {JSON.stringify(sd.payload, null, 2)}
+                        
+
+
+ ); + } + case 'action': { + const action = event.value as ActionIntentRequestEvent; + return ( +
+ + Action + +

+ URL: {action.actionUrl} +

+
+ ); + } + default: + return

Unknown intent type

; + } +}; + +// ==================== Single Intent Modal ==================== + +interface IntentRequestModalProps { + event: IntentRequestEvent; + savedWallets: SavedWallet[]; + isOpen: boolean; + onApprove: () => Promise; + onReject: (reason?: string) => Promise; +} + +export const IntentRequestModal: React.FC = ({ + event, + savedWallets, + isOpen, + onApprove, + onReject, +}) => { + const [isLoading, setIsLoading] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const [error, setError] = useState(null); + const { holdToSign } = useAuth(); + + const currentWallet = useMemo(() => { + return savedWallets[0] || null; + }, [savedWallets]); + + useEffect(() => { + if (!isOpen) { + setShowSuccess(false); + setIsLoading(false); + setError(null); + } + }, [isOpen]); + + const handleApprove = async () => { + setIsLoading(true); + setError(null); + try { + await onApprove(); + setIsLoading(false); + setShowSuccess(true); + } catch (err) { + log.error('Failed to approve intent:', err); + setIsLoading(false); + setError(err instanceof Error ? err.message : 'Failed to approve'); + } + }; + + const handleReject = async () => { + try { + await onReject('User declined'); + } catch (err) { + log.error('Failed to reject intent:', err); + } + }; + + if (!isOpen) return null; + + if (showSuccess) { + return ( +
+
+
+
+ + + +
+
+

Success!

+

Intent approved

+
+
+ ); + } + + return ( +
+
+ +
+ {/* Header */} +
+
+ + + +
+

Intent Request

+

+ Type: {event.type} +

+
+ + {/* Wallet Info */} + {currentWallet && } + + {/* Intent details */} + + + {/* Error */} + {error &&
{error}
} + + {/* Actions */} +
+ + {holdToSign ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +}; + +// ==================== Batched Intent Modal ==================== + +interface BatchedIntentRequestModalProps { + batch: BatchedIntentEvent; + savedWallets: SavedWallet[]; + isOpen: boolean; + onApprove: () => Promise; + onReject: (reason?: string) => Promise; +} + +export const BatchedIntentRequestModal: React.FC = ({ + batch, + savedWallets, + isOpen, + onApprove, + onReject, +}) => { + const [isLoading, setIsLoading] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const [error, setError] = useState(null); + const { holdToSign } = useAuth(); + + const currentWallet = useMemo(() => { + return savedWallets[0] || null; + }, [savedWallets]); + + useEffect(() => { + if (!isOpen) { + setShowSuccess(false); + setIsLoading(false); + setError(null); + } + }, [isOpen]); + + const handleApprove = async () => { + setIsLoading(true); + setError(null); + try { + await onApprove(); + setIsLoading(false); + setShowSuccess(true); + } catch (err) { + log.error('Failed to approve batched intent:', err); + setIsLoading(false); + setError(err instanceof Error ? err.message : 'Failed to approve'); + } + }; + + const handleReject = async () => { + try { + await onReject('User declined'); + } catch (err) { + log.error('Failed to reject batched intent:', err); + } + }; + + // Filter out connect intents for display (they're auto-handled) + const displayIntents = useMemo(() => { + return batch.intents.filter((i) => i.type !== 'connect'); + }, [batch.intents]); + + const connectIntents = useMemo(() => { + return batch.intents.filter((i) => i.type === 'connect'); + }, [batch.intents]); + + if (!isOpen) return null; + + if (showSuccess) { + return ( +
+
+
+
+ + + +
+
+

Success!

+

Batched intent approved

+
+
+ ); + } + + return ( +
+
+ +
+ {/* Header */} +
+
+ + + +
+

Batched Intent Request

+

+ Origin: {batch.origin} + {' · '} + {batch.intents.length} intent{batch.intents.length !== 1 ? 's' : ''} +

+
+ + {/* Wallet Info */} + {currentWallet && } + + {/* Connect info if present */} + {connectIntents.length > 0 && ( +
+

+ Includes {connectIntents.length} connect request + {connectIntents.length > 1 ? 's' : ''} +

+

+ A dApp connection will be established on approval. +

+
+ )} + + {/* Each intent */} +
+ {displayIntents.map((intent, i) => ( +
+

Intent {i + 1}

+ +
+ ))} +
+ + {/* Error */} + {error &&
{error}
} + + {/* Actions */} +
+ + {holdToSign ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +}; diff --git a/apps/demo-wallet/src/components/index.ts b/apps/demo-wallet/src/components/index.ts index d55b351f1..de110e895 100644 --- a/apps/demo-wallet/src/components/index.ts +++ b/apps/demo-wallet/src/components/index.ts @@ -28,6 +28,7 @@ export { SettingsDropdown } from './SettingsDropdown'; export { ProtectedRoute } from './ProtectedRoute'; export { RecentTransactions } from './RecentTransactions'; export { SignDataRequestModal } from './SignDataRequestModal'; +export { IntentRequestModal, BatchedIntentRequestModal } from './IntentRequestModal'; export { TraceRow } from './TraceRow'; export { TransactionRequestModal } from './TransactionRequestModal'; export { WalletPreview } from './WalletPreview'; diff --git a/apps/demo-wallet/src/pages/WalletDashboard.tsx b/apps/demo-wallet/src/pages/WalletDashboard.tsx index 8a3f1fd48..aca4844b4 100644 --- a/apps/demo-wallet/src/pages/WalletDashboard.tsx +++ b/apps/demo-wallet/src/pages/WalletDashboard.tsx @@ -8,7 +8,14 @@ import React, { useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useWallet, useWalletKit, useTonConnect, useTransactionRequests, useSignDataRequests } from '@demo/wallet-core'; +import { + useWallet, + useWalletKit, + useTonConnect, + useTransactionRequests, + useSignDataRequests, + useIntents, +} from '@demo/wallet-core'; import { Layout, @@ -17,6 +24,8 @@ import { ConnectRequestModal, TransactionRequestModal, SignDataRequestModal, + IntentRequestModal, + BatchedIntentRequestModal, DisconnectNotifications, NftsCard, RecentTransactions, @@ -62,10 +71,33 @@ export const WalletDashboard: React.FC = () => { const { pendingTransactionRequest, isTransactionModalOpen } = useTransactionRequests(); const { pendingSignDataRequest, isSignDataModalOpen, approveSignDataRequest, rejectSignDataRequest } = useSignDataRequests(); + const { + pendingIntentEvent, + pendingBatchedIntentEvent, + isIntentModalOpen, + isBatchedIntentModalOpen, + handleIntentUrl, + isIntentUrl, + approveIntent, + rejectIntent, + approveBatchedIntent, + rejectBatchedIntent, + } = useIntents(); const { error } = useTonWallet(); - // Use the paste handler hook - usePasteHandler(handleTonConnectUrl); + // Use the paste handler hook — route intent URLs to handleIntentUrl + const handlePastedUrl = useCallback( + async (url: string) => { + if (isIntentUrl(url)) { + log.info('Detected pasted intent URL, routing to intent handler'); + await handleIntentUrl(url); + } else { + await handleTonConnectUrl(url); + } + }, + [isIntentUrl, handleIntentUrl, handleTonConnectUrl], + ); + usePasteHandler(handlePastedUrl); const handleRefreshBalance = useCallback(async () => { setIsRefreshing(true); @@ -93,17 +125,22 @@ export const WalletDashboard: React.FC = () => { const handleConnectDApp = useCallback(async () => { if (!tonConnectUrl.trim()) return; + const url = tonConnectUrl.trim(); setIsConnecting(true); try { - await handleTonConnectUrl(tonConnectUrl.trim()); + if (isIntentUrl(url)) { + log.info('Detected intent URL, routing to intent handler'); + await handleIntentUrl(url); + } else { + await handleTonConnectUrl(url); + } setTonConnectUrl(''); } catch (err) { - log.error('Failed to connect to dApp:', err); - // TODO: Show error message to user + log.error('Failed to process URL:', err); } finally { setIsConnecting(false); } - }, [tonConnectUrl, handleTonConnectUrl]); + }, [tonConnectUrl, handleTonConnectUrl, isIntentUrl, handleIntentUrl]); const handleTestDisconnectAll = useCallback(async () => { if (!walletKit) return; @@ -286,18 +323,18 @@ export const WalletDashboard: React.FC = () => { {/* TON Connect URL Input */} - +