From 220e8464069653972552c343883e92e9113ef4a0 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 13 Mar 2026 11:30:14 +0530 Subject: [PATCH 01/22] feat: update intent handling functionality in demo wallet --- apps/demo-wallet/e2e/qa/WalletApp.ts | 4 +- .../demo-wallet/e2e/ui-tests/UITestFixture.ts | 9 - .../e2e/ui-tests/importWallet.spec.ts | 11 +- .../e2e/ui-tests/newWallet.spec.ts | 7 +- .../src/components/IntentRequestModal.tsx | 527 ++++++++++++++++++ apps/demo-wallet/src/components/index.ts | 1 + apps/demo-wallet/src/pages/SetupPassword.tsx | 5 +- .../demo-wallet/src/pages/WalletDashboard.tsx | 83 ++- demo/wallet-core/src/hooks/useWalletStore.ts | 24 + demo/wallet-core/src/index.ts | 2 + .../src/store/createWalletStore.ts | 4 + .../src/store/slices/intentSlice.ts | 285 ++++++++++ .../src/store/slices/walletCoreSlice.ts | 1 + demo/wallet-core/src/types/store.ts | 40 ++ .../walletkit-android-bridge/src/api/index.ts | 4 +- .../src/api/intents.ts | 8 +- .../walletkit-android-bridge/src/types/api.ts | 10 +- .../src/types/walletkit.ts | 8 +- packages/walletkit/src/core/TonWalletKit.ts | 8 +- 19 files changed, 990 insertions(+), 51 deletions(-) create mode 100644 apps/demo-wallet/src/components/IntentRequestModal.tsx create mode 100644 demo/wallet-core/src/store/slices/intentSlice.ts diff --git a/apps/demo-wallet/e2e/qa/WalletApp.ts b/apps/demo-wallet/e2e/qa/WalletApp.ts index 472644d18..d6ef3f58a 100644 --- a/apps/demo-wallet/e2e/qa/WalletApp.ts +++ b/apps/demo-wallet/e2e/qa/WalletApp.ts @@ -9,8 +9,6 @@ import type { BrowserContext } from '@playwright/test'; import type { Page } from '@playwright/test'; -import { TEST_PASSWORD } from '../constants'; - export function isExtensionWalletSource(source: string): boolean { return !source.includes('http'); } @@ -28,7 +26,7 @@ export abstract class WalletApp { constructor( readonly context: BrowserContext, readonly source: string, - readonly password: string = TEST_PASSWORD, + readonly password: string = 'tester@1234', ) { this.context = context; this.source = source; diff --git a/apps/demo-wallet/e2e/ui-tests/UITestFixture.ts b/apps/demo-wallet/e2e/ui-tests/UITestFixture.ts index fb3f53609..19808859b 100644 --- a/apps/demo-wallet/e2e/ui-tests/UITestFixture.ts +++ b/apps/demo-wallet/e2e/ui-tests/UITestFixture.ts @@ -18,7 +18,6 @@ import { isExtensionWalletSource } from '../qa/WalletApp'; export interface UITestFixture { context: BrowserContext; page: Page; - webOnly: void; } export interface UITestConfig { @@ -42,14 +41,6 @@ export function uiTestFixture(config: UITestConfig = {}, slowMo = 0) { const isExtension = isExtensionWalletSource(walletSource); return test.extend({ - webOnly: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - test.skip(isExtension, 'web-only: not supported in extension mode'); - await use(); - }, - { auto: false }, - ], context: async ({ context: _ }, use) => { const extensionPath = isExtension ? walletSource : ''; const context = await launchPersistentContext(extensionPath, slowMo); diff --git a/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts b/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts index 34a7bdbc4..7893bd736 100644 --- a/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts +++ b/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts @@ -10,10 +10,11 @@ import { expect } from '@playwright/test'; import type { NetworkType } from '@demo/wallet-core'; import { testWithUIFixture } from './UITestFixture'; -import { TEST_PASSWORD } from '../constants'; const test = testWithUIFixture(); +const PASSWORD = 'tester@1234'; + // Test mnemonic - this should be a valid test mnemonic for e2e tests const TEST_MNEMONIC = process.env.WALLET_MNEMONIC ?? ''; @@ -48,8 +49,8 @@ test.describe('Import Wallet Flow', () => { // Setup password first await page.getByTestId('title').filter({ hasText: 'Setup Password' }).waitFor({ state: 'visible' }); await page.getByTestId('subtitle').filter({ hasText: 'Create Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('password').fill(TEST_PASSWORD); - await page.getByTestId('password-confirm').fill(TEST_PASSWORD); + await page.getByTestId('password').fill(PASSWORD); + await page.getByTestId('password-confirm').fill(PASSWORD); await page.getByTestId('password-submit').click(); // Wait for setup wallet page - Layout title is "Setup Wallet" @@ -107,8 +108,8 @@ test.describe('Import Wallet - Validation', () => { // Setup password first await page.getByTestId('title').filter({ hasText: 'Setup Password' }).waitFor({ state: 'visible' }); await page.getByTestId('subtitle').filter({ hasText: 'Create Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('password').fill(TEST_PASSWORD); - await page.getByTestId('password-confirm').fill(TEST_PASSWORD); + await page.getByTestId('password').fill(PASSWORD); + await page.getByTestId('password-confirm').fill(PASSWORD); await page.getByTestId('password-submit').click(); // Wait for setup wallet page - Layout title is "Setup Wallet" diff --git a/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts b/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts index cce119954..c09010d71 100644 --- a/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts +++ b/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts @@ -9,17 +9,18 @@ import { expect } from '@playwright/test'; import { testWithUIFixture } from './UITestFixture'; -import { TEST_PASSWORD } from '../constants'; const test = testWithUIFixture(); +const PASSWORD = 'tester@1234'; + test.describe('New Wallet Flow', () => { test.beforeEach(async ({ page }) => { // Setup password first - Layout title is "Setup Password" await page.getByTestId('title').filter({ hasText: 'Setup Password' }).waitFor({ state: 'visible' }); await page.getByTestId('subtitle').filter({ hasText: 'Create Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('password').fill(TEST_PASSWORD); - await page.getByTestId('password-confirm').fill(TEST_PASSWORD); + await page.getByTestId('password').fill(PASSWORD); + await page.getByTestId('password-confirm').fill(PASSWORD); await page.getByTestId('password-submit').click(); // Wait for setup wallet page - Layout title is "Setup Wallet" diff --git a/apps/demo-wallet/src/components/IntentRequestModal.tsx b/apps/demo-wallet/src/components/IntentRequestModal.tsx new file mode 100644 index 000000000..5aa8d89cb --- /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 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 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 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 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 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 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/SetupPassword.tsx b/apps/demo-wallet/src/pages/SetupPassword.tsx index 2dea693ce..a70158153 100644 --- a/apps/demo-wallet/src/pages/SetupPassword.tsx +++ b/apps/demo-wallet/src/pages/SetupPassword.tsx @@ -24,6 +24,9 @@ export const SetupPassword: React.FC = () => { const validatePassword = (pwd: string): string[] => { const errors = []; if (pwd.length < 4) errors.push('Password must be at least 4 characters long'); + // if (!/[A-Z]/.test(pwd)) errors.push('Password must contain at least one uppercase letter'); + // if (!/[a-z]/.test(pwd)) errors.push('Password must contain at least one lowercase letter'); + // if (!/[0-9]/.test(pwd)) errors.push('Password must contain at least one number'); return errors; }; @@ -77,7 +80,7 @@ export const SetupPassword: React.FC = () => { onChange={(e) => setPassword(e.target.value)} placeholder="Enter a strong password" required - helperText="At least 4 characters" + helperText="At least 8 characters with uppercase, lowercase, and numbers" /> { 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 */} - +