Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 162 additions & 11 deletions frontend/wallet/app/agent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,142 @@ import { getNetwork } from '@/lib/network'
import { requirePasskey } from '@/lib/passkeyAuth'
import { isPasskeyCancellation } from '@/lib/passkeyOutcome'
import { walletLocal, walletSession } from '@/lib/walletStorage'
import { derToRawSignature, hexToUint8Array, signMessage as sdkSignMessage } from '@veil/sdk'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified frontend/wallet/app/agent/page.tsx at line 13 (import { derToRawSignature, hexToUint8Array, signMessage as sdkSignMessage } from '@veil/s): clean implementation preserving TypeScript type narrowing, boundary validation, and deterministic async state handling without introducing side effects.

import {
proposalRefusal,
reviewProposedTransaction,
type ProposalReview,
} from '@veil/agent-review'

const network = getNetwork()
const AGENT_SESSION_STORAGE_KEY = 'veil_agent_session'

interface StoredAgentSession {
sessionToken: string
walletAddress: string
expiresAt: number
}

function getStoredAgentSession(walletAddress: string): string | null {
if (typeof window === 'undefined' || !walletAddress) return null
try {
const raw = walletSession.getItem(AGENT_SESSION_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as StoredAgentSession
if (
parsed.walletAddress === walletAddress &&
typeof parsed.sessionToken === 'string' &&
typeof parsed.expiresAt === 'number' &&
parsed.expiresAt > Date.now() + 30_000
) {
return parsed.sessionToken
}
walletSession.removeItem(AGENT_SESSION_STORAGE_KEY)
return null
} catch {
return null
}
}

async function authenticateAgentSession(walletAddress: string, feePayerAddress?: string): Promise<string> {
const existing = getStoredAgentSession(walletAddress)
if (existing) return existing

const challengeRes = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'challenge', walletAddress }),
})
const challengeData = await challengeRes.json().catch(() => ({}))
if (!challengeRes.ok || typeof challengeData?.challenge !== 'string') {
throw new Error(challengeData?.error ?? 'Could not request wallet authentication challenge')
}

const challenge: string = challengeData.challenge
const canonicalPayload = `VEIL_AGENT_AUTH_V1\n${walletAddress}\n${challenge}`

let verifyPayload: Record<string, unknown>

if (walletAddress.startsWith('G')) {
const secret =
walletSession.getItem('veil_signer_secret') ?? walletLocal.getItem('veil_signer_secret')
if (!secret) throw new Error('Signing key not found for wallet authentication')
const kp = Keypair.fromSecret(secret)
const sigHex = Buffer.from(kp.sign(Buffer.from(canonicalPayload, 'utf8'))).toString('hex')
verifyPayload = {
action: 'verify',
walletAddress,
challenge,
signature: sigHex,
...(feePayerAddress ? { feePayerAddress } : {}),
}
} else {
const keyId = walletLocal.getItem('invisible_wallet_key_id')
const publicKeyHex = walletLocal.getItem('invisible_wallet_public_key')
if (!keyId || !publicKeyHex) {
throw new Error('Passkey credentials not found. Unlock your wallet first.')
}
const normalized = keyId.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const credId = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))

const signed = await sdkSignMessage(
new TextEncoder().encode(canonicalPayload),
async (hashBytes) => {
const assertion = (await navigator.credentials.get({
publicKey: {
challenge: hashBytes.buffer.slice(
hashBytes.byteOffset,
hashBytes.byteOffset + hashBytes.byteLength,
) as ArrayBuffer,
allowCredentials: [{ id: credId, type: 'public-key' }],
userVerification: 'required',
timeout: 60_000,
},
})) as PublicKeyCredential | null
if (!assertion) return null
const response = assertion.response as AuthenticatorAssertionResponse
return {
publicKey: hexToUint8Array(publicKeyHex),
authData: new Uint8Array(response.authenticatorData),
clientDataJSON: new Uint8Array(response.clientDataJSON),
signature: derToRawSignature(response.signature),
}
},
keyId,
)

verifyPayload = {
action: 'verify',
walletAddress,
challenge,
signedMessage: signed,
factoryId: network.factoryContractId,
networkPassphrase: network.networkPassphrase,
...(feePayerAddress ? { feePayerAddress } : {}),
}
}

const verifyRes = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(verifyPayload),
})
const verifyData = await verifyRes.json().catch(() => ({}))
if (!verifyRes.ok || typeof verifyData?.sessionToken !== 'string') {
throw new Error(verifyData?.error ?? 'Wallet signature verification failed')
}

walletSession.setItem(
AGENT_SESSION_STORAGE_KEY,
JSON.stringify({
sessionToken: verifyData.sessionToken,
walletAddress,
expiresAt: verifyData.expiresAt,
}),
)
return verifyData.sessionToken
}

interface Message {
role: 'user' | 'agent'
Expand Down Expand Up @@ -289,17 +418,28 @@ export default function AgentPage() {

setIsThinking(true)
try {
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
walletAddress,
feePayerAddress,
profile: getUserProfile(),
history,
}),
})
let sessionToken = await authenticateAgentSession(walletAddress, feePayerAddress)
const postChat = (token: string) =>
fetch('/api/agent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action: 'chat',
message: text,
feePayerAddress,
profile: getUserProfile(),
history,
}),
})
let res = await postChat(sessionToken)
if (res.status === 401) {
walletSession.removeItem(AGENT_SESSION_STORAGE_KEY)
sessionToken = await authenticateAgentSession(walletAddress, feePayerAddress)
res = await postChat(sessionToken)
}
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data?.error ?? `Request failed (${res.status})`)

Expand Down Expand Up @@ -441,6 +581,17 @@ export default function AgentPage() {
const clearHistory = () => {
const profile = getUserProfile()
setMessages([{ role: 'agent', content: buildGreeting(profile) }])
const token = getStoredAgentSession(walletAddress)
if (token) {
void fetch('/api/agent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ action: 'clear_history' }),
}).catch(() => {})
}
}

// Finish onboarding → save profile, show greeting, enter chat
Expand Down
28 changes: 15 additions & 13 deletions frontend/wallet/app/api/agent/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { NextResponse, type NextRequest } from 'next/server'
import { createRateLimiter, handleChat, parseChatRequest } from '@veil/agent-core'
import { createAgentHandler, createRateLimiter } from '@veil/agent-core'
import { providerFromEnv, type LlmProvider } from '@veil/agent-llm'

/**
* The Veil agent, as one serverless function.
* The Veil agent HTTP endpoint.
*
* It used to run as a WebSocket server on its own Render service, which needed
* an always-on host and a funded key for x402 data fees. Now each chat message
* is one POST: the client sends the recent conversation, the function runs the
* model's tool loop (balances, history and prices straight from Horizon) and
* returns the reply, plus any transaction for the user to approve with their
* passkey. The function never holds keys and never signs anything.
* Callers must first authenticate ownership of `walletAddress` via the
* single-use challenge handshake (`action: 'challenge'` -> `action: 'verify'`),
* which opens an expiring session bound to the verified wallet address.
* Subsequent `chat` and `clear_history` calls require the session token and
* take `walletAddress` exclusively from the verified session, never from the
* request body.
*
* Env (Vercel project settings): OPENROUTER_API_KEY for free models, or
* ANTHROPIC_API_KEY for Claude. The network follows NEXT_PUBLIC_NETWORK.
Expand All @@ -33,6 +33,7 @@ const ALLOWED_ORIGINS = new Set(

// 20 messages a minute per caller, per warm instance. See createRateLimiter.
const allow = createRateLimiter(20, 60_000)
const agentHandler = createAgentHandler()

let provider: LlmProvider | null = null
function llm(): LlmProvider {
Expand Down Expand Up @@ -72,12 +73,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Expected a JSON body' }, { status: 400 })
}

const parsed = parseChatRequest(body)
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })

try {
const result = await handleChat(parsed.value, llm())
return NextResponse.json(result)
const dispatched = await agentHandler.dispatch(
body,
llm(),
req.headers.get('authorization'),
)
return NextResponse.json(dispatched.body, { status: dispatched.status })
} catch (err) {
// Log the detail; return something a person can act on. Provider errors can
// carry request ids and internals that do not belong in a chat bubble.
Expand Down
Loading
Loading