Skip to content
Merged
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
39 changes: 36 additions & 3 deletions app/src/api/sse.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,44 @@
export type SSEHandler = (event: MessageEvent) => void

export function connectSSE(
const API_URL = import.meta.env.VITE_API_URL ?? ''

/**
* Exchange a JWT for a short-lived, one-time SSE ticket.
* Falls back to null if the endpoint is unavailable (legacy server).
*/
async function fetchSseTicket(jwt: string): Promise<string | null> {
try {
const res = await fetch(`${API_URL}/api/auth/sse-ticket`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
})
if (!res.ok) return null
const data = (await res.json()) as { ticket?: string }
return data.ticket ?? null
} catch {
return null
}
}

/**
* Create an SSE EventSource using a short-lived ticket (preferred)
* or falling back to raw JWT query param (legacy).
*/
export async function connectSSE(
token: string,
onEvent: SSEHandler,
onError?: (err: Event) => void
): EventSource {
const url = `${import.meta.env.VITE_API_URL ?? ''}/api/stream?token=${encodeURIComponent(token)}`
): Promise<EventSource> {
// Try ticket exchange first — keeps JWT out of URLs
const ticket = await fetchSseTicket(token)

const url = ticket
? `${API_URL}/api/stream?ticket=${encodeURIComponent(ticket)}`
: `${API_URL}/api/stream?token=${encodeURIComponent(token)}`

const source = new EventSource(url)
source.addEventListener('activity', onEvent)
source.addEventListener('confirm', onEvent)
Expand Down
23 changes: 17 additions & 6 deletions app/src/hooks/useSSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,27 @@ export function useSSE(token: string | null) {

useEffect(() => {
if (!token) { setConnected(false); return }
const source = connectSSE(token, (e) => {

let cancelled = false

connectSSE(token, (e) => {
const data = JSON.parse(e.data) as ActivityEvent
setEvents(prev => [data, ...prev].slice(0, 200))
}).then((source) => {
if (cancelled) { source.close(); return }
sourceRef.current = source
setConnected(true)
source.onerror = () => setConnected(false)
}).catch(() => {
setConnected(false)
})
sourceRef.current = source
setConnected(true)

source.onerror = () => setConnected(false)

return () => { source.close(); sourceRef.current = null; setConnected(false) }
return () => {
cancelled = true
sourceRef.current?.close()
sourceRef.current = null
setConnected(false)
}
}, [token])

return { events, connected }
Expand Down
80 changes: 76 additions & 4 deletions packages/agent/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,19 +197,84 @@ authRouter.post('/verify', (req: Request, res: Response) => {
res.json({ token, expiresIn: JWT_EXPIRY })
})

// ─── SSE Ticket Exchange ─────────────────────────────────────────────────────
// Short-lived one-time tickets for SSE connections (avoids JWT in URL)

const SSE_TICKET_TTL = 30_000 // 30 seconds
const sseTickets = new Map<string, { wallet: string; expires: number }>()

/**
* POST /auth/sse-ticket
* Exchanges a valid JWT for a short-lived, one-time SSE connection ticket.
* The ticket is a random string (not a JWT), safe to appear in URLs.
*/
authRouter.post('/sse-ticket', (req: Request, res: Response) => {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined

if (!token) {
res.status(401).json({ error: 'Bearer token required' })
return
}

try {
const decoded = jwt.verify(token, getSecret(), { algorithms: ['HS256'] }) as { wallet: string }
const ticket = crypto.randomBytes(32).toString('hex')
sseTickets.set(ticket, { wallet: decoded.wallet, expires: Date.now() + SSE_TICKET_TTL })

// Cap ticket store — evict oldest entry on overflow
if (sseTickets.size > 10_000) {
const oldest = sseTickets.keys().next().value
if (oldest) sseTickets.delete(oldest)
}

res.json({ ticket, expiresIn: SSE_TICKET_TTL / 1000 })
} catch {
res.status(401).json({ error: 'invalid or expired token' })
}
})

/**
* Validate and consume an SSE ticket. Returns the wallet if valid, null otherwise.
* Tickets are one-time use — deleted after first validation.
*/
export function consumeSseTicket(ticket: string): string | null {
const entry = sseTickets.get(ticket)
if (!entry || entry.expires < Date.now()) {
sseTickets.delete(ticket)
return null
}
sseTickets.delete(ticket) // one-time use
return entry.wallet
}

// ─────────────────────────────────────────────────────────────────────────────
// Middleware
// ─────────────────────────────────────────────────────────────────────────────

/**
* Express middleware that validates a JWT from:
* - ?token= query param (preferred for SSE — EventSource cannot set headers)
* - Authorization: Bearer <token> header
* Express middleware that validates auth from (in priority order):
* 1. ?ticket= query param (preferred for SSE — short-lived, one-time, no JWT in URL)
* 2. ?token= query param (legacy SSE fallback — discouraged, exposes JWT in URL)
* 3. Authorization: Bearer <token> header
*
* On success, attaches `wallet` to the request object and calls next().
*/
export function verifyJwt(req: Request, res: Response, next: NextFunction): void {
// Query param takes precedence (needed for SSE via EventSource)
// SSE ticket (preferred — no JWT in URL)
const ticket = req.query.ticket as string | undefined
if (ticket) {
const wallet = consumeSseTicket(ticket)
if (!wallet) {
res.status(401).json({ error: 'invalid or expired SSE ticket' })
return
}
;(req as unknown as Record<string, unknown>).wallet = wallet
next()
return
}

// JWT from query param (legacy SSE) or Authorization header
const authHeader = req.headers.authorization
const token =
(req.query.token as string | undefined) ??
Expand Down Expand Up @@ -268,3 +333,10 @@ setInterval(() => {
if (entry.resetAt < now) verifyAttempts.delete(ip)
}
}, 5 * 60 * 1000).unref()

setInterval(() => {
const now = Date.now()
for (const [ticket, data] of sseTickets) {
if (data.expires < now) sseTickets.delete(ticket)
}
}, 30_000).unref()
78 changes: 77 additions & 1 deletion packages/agent/src/routes/pay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from 'express'
import { Connection, LAMPORTS_PER_SOL } from '@solana/web3.js'
import { getPaymentLink, markPaymentLinkPaid } from '../db.js'
import {
renderPaymentPage,
Expand All @@ -9,6 +10,69 @@ import {

export const payRouter = Router()

// ─────────────────────────────────────────────────────────────────────────────
// On-chain transaction verification
// ─────────────────────────────────────────────────────────────────────────────

/**
* Verify a Solana transaction on-chain before accepting payment confirmation.
* Checks: tx exists, tx succeeded, recipient matches, amount matches.
* Fail-open on RPC errors — we don't want RPC downtime to block legitimate payments.
*/
export async function verifyTransaction(
txSignature: string,
expectedAddress: string,
expectedAmount: number | null,
): Promise<{ valid: boolean; error?: string }> {
const rpcUrl = process.env.SOLANA_RPC_URL ?? 'https://api.mainnet-beta.solana.com'
const connection = new Connection(rpcUrl, 'confirmed')

try {
const tx = await connection.getTransaction(txSignature, {
maxSupportedTransactionVersion: 0,
})

if (!tx) {
return { valid: false, error: 'transaction not found on-chain' }
}

if (tx.meta?.err) {
return { valid: false, error: 'transaction failed on-chain' }
}

// Check the stealth address received funds
const accountKeys = tx.transaction.message.getAccountKeys()
const targetIndex = accountKeys.staticAccountKeys.findIndex(
key => key.toBase58() === expectedAddress,
)

if (targetIndex === -1) {
return { valid: false, error: 'transaction does not involve the expected address' }
}

// Verify amount if specified (1% tolerance for rounding)
if (expectedAmount !== null && tx.meta) {
const preBalance = tx.meta.preBalances[targetIndex] ?? 0
const postBalance = tx.meta.postBalances[targetIndex] ?? 0
const receivedLamports = postBalance - preBalance
const receivedSol = receivedLamports / LAMPORTS_PER_SOL

if (receivedSol < expectedAmount * 0.99) {
return {
valid: false,
error: `insufficient amount: received ${receivedSol.toFixed(4)} SOL, expected ${expectedAmount} SOL`,
}
}
}

return { valid: true }
} catch (err) {
// RPC failures should not block payment — log and allow with warning
console.warn('[pay] on-chain verification failed, accepting signature:', err instanceof Error ? err.message : err)
return { valid: true }
}
}

payRouter.get('/:id', (req, res) => {
const link = getPaymentLink(req.params.id)

Expand All @@ -30,7 +94,7 @@ payRouter.get('/:id', (req, res) => {
res.type('html').send(renderPaymentPage(link))
})

payRouter.post('/:id/confirm', (req, res) => {
payRouter.post('/:id/confirm', async (req, res) => {
const { txSignature } = req.body

if (!txSignature || typeof txSignature !== 'string' || txSignature.length > 200) {
Expand All @@ -55,6 +119,18 @@ payRouter.post('/:id/confirm', (req, res) => {
return
}

// On-chain verification: tx exists, succeeded, correct recipient & amount
const verification = await verifyTransaction(
txSignature,
link.stealth_address,
link.amount,
)

if (!verification.valid) {
res.status(400).json({ error: verification.error ?? 'transaction verification failed' })
return
}

markPaymentLinkPaid(req.params.id, txSignature)
res.json({ success: true, message: 'Payment confirmed' })
})
Loading
Loading