Skip to content

Commit dd2ab64

Browse files
committed
feat: wire wallet transaction signing in chat UI
1 parent 8ba33a4 commit dd2ab64

4 files changed

Lines changed: 223 additions & 26 deletions

File tree

app/src/components/ChatContainer.tsx

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useWallet } from '@solana/wallet-adapter-react'
33
import TextMessage from './TextMessage'
44
import ConfirmationPrompt, { type ConfirmationData, type ConfirmationStatus } from './ConfirmationPrompt'
55
import QuickActions from './QuickActions'
6+
import { useTransactionSigner, type SignStatus } from '../hooks/useTransactionSigner'
67

78
interface ChatMessage {
89
id: string
@@ -33,6 +34,7 @@ const API_URL = '/api/chat'
3334

3435
export default function ChatContainer() {
3536
const { connected, publicKey } = useWallet()
37+
const { signAndBroadcast } = useTransactionSigner()
3638
const [messages, setMessages] = useState<Message[]>([])
3739
const [input, setInput] = useState('')
3840
const [loading, setLoading] = useState(false)
@@ -56,6 +58,17 @@ export default function ChatContainer() {
5658
setMessages(prev => [...prev, msg])
5759
}, [])
5860

61+
const updateConfirmation = useCallback((confirmId: string, patch: Partial<ConfirmationData>) => {
62+
setMessages(prev =>
63+
prev.map(msg => {
64+
if (isConfirmation(msg) && msg.data.id === confirmId) {
65+
return { ...msg, data: { ...msg.data, ...patch } }
66+
}
67+
return msg
68+
})
69+
)
70+
}, [])
71+
5972
const sendToAgent = useCallback(async (userText: string) => {
6073
// Build conversation history for the API
6174
const chatHistory = messages
@@ -92,15 +105,17 @@ export default function ChatContainer() {
92105

93106
// If the response includes a confirmation request, render it
94107
if (data.confirmation) {
108+
const confirmId = generateId()
95109
addMessage({
96110
id: generateId(),
97111
type: 'confirmation',
98112
data: {
99-
id: generateId(),
113+
id: confirmId,
100114
action: data.confirmation.action ?? 'Transaction',
101115
amount: data.confirmation.amount,
102116
fee: data.confirmation.fee,
103117
recipient: data.confirmation.recipient,
118+
serializedTx: data.confirmation.serializedTx,
104119
status: 'pending',
105120
},
106121
timestamp: new Date(),
@@ -147,32 +162,51 @@ export default function ChatContainer() {
147162
}
148163

149164
const handleConfirm = (id: string) => {
150-
setMessages(prev =>
151-
prev.map(msg => {
152-
if (isConfirmation(msg) && msg.data.id === id) {
153-
return { ...msg, data: { ...msg.data, status: 'confirmed' as ConfirmationStatus } }
154-
}
155-
return msg
156-
})
157-
)
158-
// In a real implementation, this would trigger wallet signing via useWallet
165+
updateConfirmation(id, { status: 'confirmed' as ConfirmationStatus })
159166
addMessage({
160167
id: generateId(),
161168
role: 'agent',
162-
content: 'Transaction confirmed. Waiting for wallet signature...',
169+
content: 'Transaction confirmed (no on-chain transaction for this action).',
163170
timestamp: new Date(),
164171
})
165172
}
166173

167-
const handleCancel = (id: string) => {
168-
setMessages(prev =>
169-
prev.map(msg => {
170-
if (isConfirmation(msg) && msg.data.id === id) {
171-
return { ...msg, data: { ...msg.data, status: 'cancelled' as ConfirmationStatus } }
172-
}
173-
return msg
174+
const handleSign = useCallback(async (confirmId: string, serializedTx: string) => {
175+
updateConfirmation(confirmId, { signStatus: 'signing' as SignStatus })
176+
177+
const result = await signAndBroadcast(serializedTx)
178+
179+
if (result.signature) {
180+
updateConfirmation(confirmId, {
181+
status: 'confirmed',
182+
signStatus: 'confirmed',
183+
signature: result.signature,
174184
})
175-
)
185+
addMessage({
186+
id: generateId(),
187+
role: 'agent',
188+
content: `Transaction confirmed: ${result.signature}`,
189+
timestamp: new Date(),
190+
})
191+
} else {
192+
updateConfirmation(confirmId, {
193+
signStatus: 'error',
194+
txError: result.error ?? 'Transaction failed',
195+
})
196+
addMessage({
197+
id: generateId(),
198+
role: 'agent',
199+
content: `Transaction failed: ${result.error}`,
200+
timestamp: new Date(),
201+
error: true,
202+
})
203+
}
204+
205+
return result
206+
}, [signAndBroadcast, updateConfirmation, addMessage])
207+
208+
const handleCancel = (id: string) => {
209+
updateConfirmation(id, { status: 'cancelled' as ConfirmationStatus })
176210
addMessage({
177211
id: generateId(),
178212
role: 'agent',
@@ -205,6 +239,7 @@ export default function ChatContainer() {
205239
data={msg.data}
206240
onConfirm={handleConfirm}
207241
onCancel={handleCancel}
242+
onSign={handleSign}
208243
/>
209244
) : (
210245
<TextMessage

app/src/components/ConfirmationPrompt.tsx

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { SignResult, SignStatus } from '../hooks/useTransactionSigner'
2+
13
export type ConfirmationStatus = 'pending' | 'confirmed' | 'cancelled'
24

35
export interface ConfirmationData {
@@ -6,29 +8,57 @@ export interface ConfirmationData {
68
amount?: string
79
fee?: string
810
recipient?: string
11+
serializedTx?: string
912
status: ConfirmationStatus
13+
signature?: string
14+
txError?: string
15+
signStatus?: SignStatus
1016
}
1117

1218
interface ConfirmationPromptProps {
1319
data: ConfirmationData
1420
onConfirm: (id: string) => void
1521
onCancel: (id: string) => void
22+
onSign?: (id: string, serializedTx: string) => Promise<SignResult>
1623
}
1724

1825
function truncateAddress(addr: string): string {
1926
if (addr.length <= 12) return addr
2027
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
2128
}
2229

23-
export default function ConfirmationPrompt({ data, onConfirm, onCancel }: ConfirmationPromptProps) {
30+
function truncateSignature(sig: string): string {
31+
if (sig.length <= 16) return sig
32+
return `${sig.slice(0, 8)}...${sig.slice(-4)}`
33+
}
34+
35+
function explorerUrl(signature: string): string {
36+
return `https://solscan.io/tx/${signature}?cluster=devnet`
37+
}
38+
39+
export default function ConfirmationPrompt({ data, onConfirm, onCancel, onSign }: ConfirmationPromptProps) {
2440
const isPending = data.status === 'pending'
41+
const isSigning = data.signStatus === 'signing' || data.signStatus === 'broadcasting'
42+
43+
const handleConfirm = () => {
44+
if (data.serializedTx && onSign) {
45+
onSign(data.id, data.serializedTx)
46+
} else {
47+
onConfirm(data.id)
48+
}
49+
}
50+
51+
const statusIcon = isPending
52+
? '\u26a0'
53+
: data.status === 'confirmed'
54+
? '\u2713'
55+
: '\u2715'
2556

2657
return (
2758
<div className="confirmation">
2859
<div className="confirmation__card">
2960
<div className="confirmation__header">
30-
{isPending ? '\u26a0' : data.status === 'confirmed' ? '\u2713' : '\u2715'}
31-
{' '}{data.action}
61+
{statusIcon}{' '}{data.action}
3262
</div>
3363

3464
<div className="confirmation__body">
@@ -58,20 +88,50 @@ export default function ConfirmationPrompt({ data, onConfirm, onCancel }: Confir
5888
<div className="confirmation__actions">
5989
<button
6090
className="confirmation__btn confirmation__btn--confirm"
61-
onClick={() => onConfirm(data.id)}
91+
onClick={handleConfirm}
92+
disabled={isSigning}
6293
>
63-
Confirm & Sign
94+
{data.signStatus === 'signing'
95+
? 'Signing...'
96+
: data.signStatus === 'broadcasting'
97+
? 'Broadcasting...'
98+
: 'Confirm & Sign'}
6499
</button>
65100
<button
66101
className="confirmation__btn confirmation__btn--cancel"
67102
onClick={() => onCancel(data.id)}
103+
disabled={isSigning}
68104
>
69105
Cancel
70106
</button>
71107
</div>
72108
) : (
73-
<div className={`confirmation__status confirmation__status--${data.status}`}>
74-
{data.status === 'confirmed' ? 'Transaction signed' : 'Cancelled'}
109+
<div className="confirmation__footer">
110+
{data.status === 'confirmed' && data.signature ? (
111+
<div className="confirmation__status confirmation__status--confirmed">
112+
{'\u2713'} Confirmed:{' '}
113+
<a
114+
href={explorerUrl(data.signature)}
115+
target="_blank"
116+
rel="noopener noreferrer"
117+
className="confirmation__sig-link"
118+
>
119+
{truncateSignature(data.signature)}
120+
</a>
121+
</div>
122+
) : data.status === 'confirmed' ? (
123+
<div className="confirmation__status confirmation__status--confirmed">
124+
Transaction signed
125+
</div>
126+
) : data.txError ? (
127+
<div className="confirmation__status confirmation__status--error">
128+
{data.txError}
129+
</div>
130+
) : (
131+
<div className="confirmation__status confirmation__status--cancelled">
132+
Cancelled
133+
</div>
134+
)}
75135
</div>
76136
)}
77137
</div>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useConnection, useWallet } from '@solana/wallet-adapter-react'
2+
import { Transaction, VersionedTransaction } from '@solana/web3.js'
3+
import { useCallback, useState } from 'react'
4+
5+
export type SignStatus = 'idle' | 'signing' | 'broadcasting' | 'confirmed' | 'error'
6+
7+
export interface SignResult {
8+
signature?: string
9+
error?: string
10+
}
11+
12+
function deserializeTransaction(bytes: Uint8Array): Transaction | VersionedTransaction {
13+
try {
14+
return Transaction.from(bytes)
15+
} catch {
16+
return VersionedTransaction.deserialize(bytes)
17+
}
18+
}
19+
20+
function base64ToBytes(base64: string): Uint8Array {
21+
const binary = atob(base64)
22+
const bytes = new Uint8Array(binary.length)
23+
for (let i = 0; i < binary.length; i++) {
24+
bytes[i] = binary.charCodeAt(i)
25+
}
26+
return bytes
27+
}
28+
29+
export function useTransactionSigner() {
30+
const { connection } = useConnection()
31+
const { signTransaction, publicKey } = useWallet()
32+
const [status, setStatus] = useState<SignStatus>('idle')
33+
34+
const signAndBroadcast = useCallback(async (serializedTx: string): Promise<SignResult> => {
35+
if (!signTransaction || !publicKey) {
36+
setStatus('error')
37+
return { error: 'Wallet not connected' }
38+
}
39+
40+
try {
41+
setStatus('signing')
42+
43+
const bytes = base64ToBytes(serializedTx)
44+
const tx = deserializeTransaction(bytes)
45+
46+
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed')
47+
48+
if (tx instanceof Transaction) {
49+
tx.recentBlockhash = blockhash
50+
tx.feePayer = publicKey
51+
} else {
52+
// VersionedTransaction: update blockhash in the message
53+
tx.message.recentBlockhash = blockhash
54+
}
55+
56+
const signed = await signTransaction(tx)
57+
58+
setStatus('broadcasting')
59+
60+
const signature = await connection.sendRawTransaction(signed.serialize(), {
61+
skipPreflight: true,
62+
maxRetries: 3,
63+
})
64+
65+
await connection.confirmTransaction(
66+
{ signature, blockhash, lastValidBlockHeight },
67+
'confirmed',
68+
)
69+
70+
setStatus('confirmed')
71+
return { signature }
72+
} catch (err) {
73+
setStatus('error')
74+
const message = err instanceof Error ? err.message : String(err)
75+
return { error: message }
76+
}
77+
}, [connection, signTransaction, publicKey])
78+
79+
const reset = useCallback(() => setStatus('idle'), [])
80+
81+
return { signAndBroadcast, status, setStatus, reset }
82+
}

app/src/styles/theme.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,26 @@ body {
474474
color: var(--text-muted);
475475
}
476476

477+
.confirmation__status--error {
478+
color: var(--red);
479+
word-break: break-word;
480+
}
481+
482+
.confirmation__footer {
483+
border-top: 1px solid var(--border);
484+
}
485+
486+
.confirmation__sig-link {
487+
color: var(--cyan);
488+
text-decoration: none;
489+
font-family: 'SF Mono', 'Fira Code', monospace;
490+
font-size: 12px;
491+
}
492+
493+
.confirmation__sig-link:hover {
494+
text-decoration: underline;
495+
}
496+
477497
/* Quick Actions */
478498
.quick-actions {
479499
display: flex;

0 commit comments

Comments
 (0)