diff --git a/CHANGELOG.md b/CHANGELOG.md index 57da83f..f88cdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2.1.0] - 2026-04-08 + +### Features +- **Request Payment**: Users can now request Bitcoin payments from other identities via the Payments page + - New "Request Payment" tab with recipient search, amount, description, and configurable expiry + - New "Incoming Requests" tab showing payment request cards with Pay/Decline actions + - Editable payment amount — payer can modify the amount before fulfilling + - Optional notes on payments and declines + - Outgoing request tracker with live status updates (Pending/Paid/Declined/Expired/Cancelled) + - Cancel pending outgoing requests +- **Request Security Settings** (in Incoming Requests tab): + - Identity whitelist with on/off toggle — only whitelisted identities can send requests when enabled + - Identity search integration for adding to whitelist + - Configurable min/max amount limits to filter dust and oversized requests +- Payments page reorganized into 4 full-width tabs: Send Payment, Request Payment, Incoming Requests, Pending Payments + +### Dependencies +- `@bsv/message-box-client` ^2.0.6 (requires >=2.0.7 with payment request methods once published) ## [2.0.9] - 2026-03-18 ### Features diff --git a/package-lock.json b/package-lock.json index 74bfd6d..5204a53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@bsv/btms-permission-module": "^1.0.1", "@bsv/btms-permission-module-ui": "^1.0.0", "@bsv/identity-react": "^1.1.14", - "@bsv/message-box-client": "^2.0.4", + "@bsv/message-box-client": "^2.0.6", "@bsv/sdk": "^2.0.7", "@bsv/uhrp-react": "^1.0.6", "@bsv/wallet-toolbox": "^2.1.5", @@ -750,13 +750,13 @@ } }, "node_modules/@bsv/message-box-client": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@bsv/message-box-client/-/message-box-client-2.0.4.tgz", - "integrity": "sha512-XvnNrchW6gZB1U3pskIld8rie8ds2ifxrOhfO4Ot+/kHaMm8osYky7KK6cxCnIdPtOLc8G1yiV41M13p+BzY/A==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@bsv/message-box-client/-/message-box-client-2.0.6.tgz", + "integrity": "sha512-8dkQ7Qo/DkdQmJOg8XdenvdBRmkcm5lDIMpT8ap4V9mQ1kDvWgkUcD6P5SIOcvfv8r0P2fDdOYW8kH8OnASJnA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@bsv/authsocket-client": "^2.0.2", - "@bsv/sdk": "^2.0.4" + "@bsv/sdk": "^2.0.11" } }, "node_modules/@bsv/payment-express-middleware": { @@ -1076,9 +1076,9 @@ } }, "node_modules/@bsv/sdk": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.0.7.tgz", - "integrity": "sha512-zh3DtbGIAeojf/TYlhVTIOIFeUWboq7jyWSEmiPmKxp8MnprZM+jino1Lm0KbjEnwn56pAPCZt8eb+5tTWtHaA==", + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.0.13.tgz", + "integrity": "sha512-VV25sV6oB1Piu7tTEMta+B29IRcq7Xpj1ohuk8RZRT656onop1YbA3oTnYf6stleWnbvsZeOciwH7Xppvs1QWw==", "license": "SEE LICENSE IN LICENSE.txt" }, "node_modules/@bsv/uhrp-react": { @@ -10159,9 +10159,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", diff --git a/package.json b/package.json index 6adc0fc..78874ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsv-desktop-electron", - "version": "2.0.9", + "version": "2.1.0", "description": "BSV Desktop Wallet - Electron Edition", "main": "dist-electron/main.js", "type": "module", @@ -28,7 +28,7 @@ "@bsv/btms-permission-module": "^1.0.1", "@bsv/btms-permission-module-ui": "^1.0.0", "@bsv/identity-react": "^1.1.14", - "@bsv/message-box-client": "^2.0.4", + "@bsv/message-box-client": "^2.0.6", "@bsv/sdk": "^2.0.7", "@bsv/uhrp-react": "^1.0.6", "@bsv/wallet-toolbox": "^2.1.5", diff --git a/src/lib/WalletContext.tsx b/src/lib/WalletContext.tsx index a7ab668..dc357fe 100644 --- a/src/lib/WalletContext.tsx +++ b/src/lib/WalletContext.tsx @@ -2209,12 +2209,26 @@ export const WalletContextProvider: React.FC = ({ }, [peerPayClient, checkAnointmentStatus]); const logout = useCallback(() => { + // Preserve payment request data across logout (keyed by identity). + const preservedKeys: Record = {} + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith('payReq_')) { + preservedKeys[key] = localStorage.getItem(key) ?? '' + } + } + // Clear localStorage to prevent auto-login localStorage.clear(); if (localStorage.snap) { localStorage.removeItem('snap'); } + // Restore preserved payment request data + for (const [key, value] of Object.entries(preservedKeys)) { + localStorage.setItem(key, value) + } + // Reset manager state setManagers({}); diff --git a/src/lib/pages/Dashboard/Payments/IncomingRequestList.tsx b/src/lib/pages/Dashboard/Payments/IncomingRequestList.tsx new file mode 100644 index 0000000..17f755a --- /dev/null +++ b/src/lib/pages/Dashboard/Payments/IncomingRequestList.tsx @@ -0,0 +1,561 @@ +// src/lib/pages/Dashboard/Payments/IncomingRequestList.tsx +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { + Alert, + Autocomplete, + Avatar, + Box, + Button, + Chip, + CircularProgress, + Collapse, + Divider, + IconButton, + List, + ListItem, + ListItemText, + Paper, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material' +import DeleteIcon from '@mui/icons-material/Delete' +import { PublicKey, WalletInterface } from '@bsv/sdk' +import { WalletContext } from '../../../WalletContext' +import { IncomingPaymentRequest } from '@bsv/message-box-client' +import { toast } from 'react-toastify' +import { useIdentitySearch } from '@bsv/identity-react' + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function getInitials(identityKey: string): string { + return identityKey.slice(0, 2).toUpperCase() +} + +function truncateKey(key: string, chars = 14): string { + if (key.length <= chars + 3) return key + return `${key.slice(0, chars)}…` +} + +function formatTimeRemaining(expiresAt: number): string { + const diff = expiresAt - Date.now() + if (diff <= 0) return 'Expired' + const hours = Math.floor(diff / (1000 * 60 * 60)) + const days = Math.floor(hours / 24) + if (days > 0) return `${days}d ${hours % 24}h remaining` + const mins = Math.floor(diff / (1000 * 60)) + if (hours > 0) return `${hours}h ${mins % 60}m remaining` + return `${mins}m remaining` +} + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type Props = { + requests: IncomingPaymentRequest[] + onRefresh: () => void + wallet: WalletInterface +} + +type LoadingMap = Record + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export default function IncomingRequestList({ requests, onRefresh, wallet }: Props) { + const { peerPayClient, messageBoxUrl, adminOriginator, activeProfile } = useContext(WalletContext) + + // Storage keys scoped to the current user's identity to prevent cross-account overwrites. + const idSuffix = activeProfile?.identityKey ? `_${activeProfile.identityKey}` : '' + const whitelistKey = `payReq_whitelist${idSuffix}` + const minAmountKey = `payReq_minAmount${idSuffix}` + const maxAmountKey = `payReq_maxAmount${idSuffix}` + const whitelistEnabledKey = `payReq_whitelistEnabled${idSuffix}` + + // Per-card state + const [notes, setNotes] = useState>({}) + const [paying, setPaying] = useState({}) + const [declining, setDeclining] = useState({}) + + // Settings panel + const [settingsOpen, setSettingsOpen] = useState(false) + const [permissions, setPermissions] = useState(() => { + try { + return JSON.parse(localStorage.getItem(whitelistKey) ?? '[]') + } catch { return [] } + }) + const [whitelistKeyInput, setWhitelistKeyInput] = useState('') + const [allowError, setAllowError] = useState('') + const [minAmount, setMinAmount] = useState(() => + localStorage.getItem(minAmountKey) ?? '1000' + ) + const [maxAmount, setMaxAmount] = useState(() => + localStorage.getItem(maxAmountKey) ?? '10000000' + ) + const [limitsSaved, setLimitsSaved] = useState(false) + + // Whitelist toggle (on/off) + const [whitelistEnabled, setWhitelistEnabled] = useState(() => + localStorage.getItem(whitelistEnabledKey) !== 'false' + ) + + // Identity search for whitelist + const whitelistIdentitySearch = useIdentitySearch({ + originator: adminOriginator, + wallet, + onIdentitySelected: (identity) => { + if (identity) { + setWhitelistKeyInput(identity.identityKey) + } + } + }) + + /* ---------------------------------------------------------------- */ + /* Settings helpers */ + /* ---------------------------------------------------------------- */ + + /** Persist the whitelist to localStorage. */ + const saveWhitelist = useCallback((list: string[]) => { + setPermissions(list) + localStorage.setItem(whitelistKey, JSON.stringify(list)) + }, [whitelistKey]) + + const handleToggleSettings = () => { + setSettingsOpen(prev => !prev) + } + + const handleToggleWhitelist = (enabled: boolean) => { + setWhitelistEnabled(enabled) + localStorage.setItem(whitelistEnabledKey, String(enabled)) + } + + const handleAllow = async () => { + const key = whitelistKeyInput.trim() + if (!key) { setAllowError('Identity key is required'); return } + if (permissions.includes(key)) { setAllowError('Identity already whitelisted'); return } + setAllowError('') + + // Best-effort server-side permission set (may not persist on all servers). + if (peerPayClient) { + try { + await peerPayClient.allowPaymentRequestsFrom({ identityKey: key }) + } catch (e) { + console.warn('[IncomingRequestList] Server-side allow failed (non-blocking):', e) + } + } + + // Authoritative: persist in localStorage. + saveWhitelist([...permissions, key]) + setWhitelistKeyInput('') + whitelistIdentitySearch.handleSelect(null, null) + toast.success('Identity whitelisted') + } + + const handleBlock = async (identityKey: string) => { + // Best-effort server-side block. + if (peerPayClient) { + try { + await peerPayClient.blockPaymentRequestsFrom({ identityKey }) + } catch (e) { + console.warn('[IncomingRequestList] Server-side block failed (non-blocking):', e) + } + } + + // Authoritative: remove from localStorage. + saveWhitelist(permissions.filter(k => k !== identityKey)) + toast.success('Identity removed from whitelist') + } + + const saveLimits = () => { + localStorage.setItem(minAmountKey, minAmount) + localStorage.setItem(maxAmountKey, maxAmount) + setLimitsSaved(true) + setTimeout(() => setLimitsSaved(false), 2000) + toast.success('Amount limits saved') + onRefresh() + } + + /* ---------------------------------------------------------------- */ + /* Pay / Decline */ + /* ---------------------------------------------------------------- */ + + const handlePay = async (req: IncomingPaymentRequest) => { + if (!peerPayClient) return + const id = req.messageId + setPaying(prev => ({ ...prev, [id]: true })) + try { + const note = notes[id] || undefined + await peerPayClient.fulfillPaymentRequest( + { request: req, note }, + messageBoxUrl || undefined + ) + window.dispatchEvent(new CustomEvent('balance-changed')) + toast.success('Payment sent!') + onRefresh() + } catch (e) { + toast.error((e as Error)?.message ?? 'Failed to send payment') + } finally { + setPaying(prev => { const n = { ...prev }; delete n[id]; return n }) + } + } + + const handleDecline = async (req: IncomingPaymentRequest) => { + if (!peerPayClient) return + const id = req.messageId + setDeclining(prev => ({ ...prev, [id]: true })) + try { + const note = notes[id] || undefined + await peerPayClient.declinePaymentRequest( + { request: req, note }, + messageBoxUrl || undefined + ) + toast.info('Request declined') + onRefresh() + } catch (e) { + toast.error((e as Error)?.message ?? 'Failed to decline request') + } finally { + setDeclining(prev => { const n = { ...prev }; delete n[id]; return n }) + } + } + + /* ---------------------------------------------------------------- */ + /* Render */ + /* ---------------------------------------------------------------- */ + + return ( + + {/* ---- Settings panel ---- */} + + + Incoming Requests + + + + + + + + + + + {/* Whitelist toggle */} + + + + Identity Whitelist + + + When enabled, only whitelisted identities can send you requests + + + + + handleToggleWhitelist(e.target.checked)} + /> + + + + {/* Whitelist management — only shown when enabled */} + + + {/* Identity search for adding */} + { + whitelistIdentitySearch.handleSelect(event, value as any) + if (value && typeof value !== 'string') { + setWhitelistKeyInput(value.identityKey) + } else { + setWhitelistKeyInput('') + } + }} + getOptionLabel={(option) => + typeof option === 'string' ? option : option.name || option.identityKey.slice(0, 16) + } + isOptionEqualToValue={(option, value) => { + if (typeof option === 'string' || typeof value === 'string') return false + return option.identityKey === value.identityKey + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + if (typeof option === 'string') return null + const { key, ...otherProps } = props + return ( +
  • + + + {getInitials(option.identityKey)} + + + {option.name || 'Unknown'} + + {option.identityKey.slice(0, 20)}... + + + +
  • + ) + }} + noOptionsText={whitelistIdentitySearch.inputValue ? 'No identities found' : 'Start typing to search'} + fullWidth + size="small" + /> + + {/* Direct key input fallback */} + + { + const val = e.target.value.trim() + setWhitelistKeyInput(val) + setAllowError('') + if (!val) return + try { + PublicKey.fromString(val) + whitelistIdentitySearch.handleSelect(null, null) + } catch { + // Not a valid key yet — allow typing + } + }} + disabled={!!whitelistIdentitySearch.selectedIdentity} + error={!!allowError} + helperText={allowError} + /> + + + + {/* Whitelisted identities list */} + {permissions.length === 0 ? ( + + No whitelisted identities yet. Add identities above to receive payment requests. + + ) : ( + + {permissions.map(key => ( + handleBlock(key)} + color="error" + > + + + } + > + + {truncateKey(key, 24)} + + } + secondary={ + + {key} + + } + /> + + ))} + + )} +
    +
    + + + + {/* Amount limits */} + + Amount Limits (satoshis) + + + setMinAmount(e.target.value)} + fullWidth + /> + setMaxAmount(e.target.value)} + fullWidth + /> + + +
    +
    +
    + + {/* ---- Request cards ---- */} + {requests.length === 0 ? ( + + No incoming payment requests. + + ) : ( + requests.map(req => { + const id = req.messageId + const isExpired = req.expiresAt < Date.now() + const isPaying = !!paying[id] + const isDeclining = !!declining[id] + const isBusy = isPaying || isDeclining + + return ( + + + {/* Header row */} + + + {getInitials(req.sender)} + + + + {truncateKey(req.sender)} + + + {formatTimeRemaining(req.expiresAt)} + + + + + + {/* Amount — prominent */} + + {req.amount.toLocaleString()} sats + + + {/* Description quote block */} + + + "{req.description}" + + + + {!isExpired && ( + <> + {/* Optional note */} + setNotes(prev => ({ ...prev, [id]: e.target.value }))} + disabled={isBusy} + /> + + {/* Actions */} + + + + + + )} + + {isExpired && ( + + This request has expired and can no longer be fulfilled. + + )} + + + ) + }) + )} +
    + ) +} diff --git a/src/lib/pages/Dashboard/Payments/RequestPaymentForm.tsx b/src/lib/pages/Dashboard/Payments/RequestPaymentForm.tsx new file mode 100644 index 0000000..9c6a547 --- /dev/null +++ b/src/lib/pages/Dashboard/Payments/RequestPaymentForm.tsx @@ -0,0 +1,585 @@ +// src/lib/pages/Dashboard/Payments/RequestPaymentForm.tsx +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' +import { + Autocomplete, + Avatar, + Box, + Button, + Chip, + CircularProgress, + FormControl, + InputAdornment, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from '@mui/material' +import { PublicKey, WalletInterface } from '@bsv/sdk' +import { WalletContext } from '../../../WalletContext' +import { CurrencyConverter } from '@bsv/amountinator' +import { toast } from 'react-toastify' +import useAsyncEffect from 'use-async-effect' +import { useIdentitySearch } from '@bsv/identity-react' +import { PaymentRequestResponse, IncomingPayment } from '@bsv/message-box-client' + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type OutgoingRequest = { + requestId: string + requestProof: string + recipient: string + recipientLabel: string + amount: number + description: string + expiresAt: number + status: 'pending' | 'paid' | 'declined' | 'expired' | 'cancelled' | 'received' + amountPaid?: number + note?: string + /** The incoming payment associated with a fulfilled request, used for accepting funds. */ + incomingPayment?: IncomingPayment +} + +type Props = { + wallet: WalletInterface + onRequestSent?: () => void +} + +type IdentityOption = { + identityKey: string + name?: string + avatarURL?: string + badgeLabel?: string +} + +/* ------------------------------------------------------------------ */ +/* Expiry helpers */ +/* ------------------------------------------------------------------ */ + +const EXPIRY_OPTIONS = [ + { label: '1 hour', ms: 60 * 60 * 1000 }, + { label: '1 day', ms: 24 * 60 * 60 * 1000 }, + { label: '3 days', ms: 3 * 24 * 60 * 60 * 1000 }, + { label: '7 days', ms: 7 * 24 * 60 * 60 * 1000 }, + { label: '30 days', ms: 30 * 24 * 60 * 60 * 1000 }, +] as const + +function getInitials(name: string, identityKey: string): string { + if (!name || name.trim() === '') return identityKey.slice(0, 2).toUpperCase() + const words = name.trim().split(/\s+/) + if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase() + return name.slice(0, 2).toUpperCase() +} + +function statusColor(status: OutgoingRequest['status']): 'success' | 'info' | 'error' | 'default' { + switch (status) { + case 'pending': return 'success' + case 'paid': return 'info' + case 'received': return 'success' + case 'declined': return 'error' + default: return 'default' + } +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export default function RequestPaymentForm({ wallet, onRequestSent }: Props) { + const { managers, adminOriginator, peerPayClient, messageBoxUrl, activeProfile } = useContext(WalletContext) + + // Storage key scoped to the current user's identity to prevent cross-account overwrites. + // Null until identity is available — prevents saving/loading with the wrong key. + const identityKey = activeProfile?.identityKey + const storageKey = identityKey ? `payReq_outgoing_${identityKey}` : null + + // Form state + const [recipient, setRecipient] = useState('') + const [publicKeyInput, setPublicKeyInput] = useState('') + const [amount, setAmount] = useState(0) + const [amountInput, setAmountInput] = useState('') + const [description, setDescription] = useState('') + const [expiryMs, setExpiryMs] = useState(EXPIRY_OPTIONS[2].ms) // 3 days default + const [sending, setSending] = useState(false) + const [currencySymbol, setCurrencySymbol] = useState('$') + + // Outgoing tracker — loaded from localStorage once identity is known. + const [outgoing, setOutgoing] = useState([]) + const [cancellingId, setCancellingId] = useState(null) + const [receivingId, setReceivingId] = useState(null) + + // Load outgoing requests from localStorage when identity becomes available or changes. + useEffect(() => { + if (!storageKey) return + try { + const raw = localStorage.getItem(storageKey) + setOutgoing(raw ? JSON.parse(raw) : []) + } catch { + setOutgoing([]) + } + }, [storageKey]) + + /** + * Persist-aware setter for outgoing requests. + * Writes to localStorage immediately when called, avoiding the + * race condition where a useEffect-based save overwrites data + * before the load effect's setOutgoing takes effect. + */ + const updateOutgoing = useCallback((updater: OutgoingRequest[] | ((prev: OutgoingRequest[]) => OutgoingRequest[])) => { + setOutgoing(prev => { + const next = typeof updater === 'function' ? updater(prev) : updater + if (storageKey) { + const serializable = next.map(({ incomingPayment, ...rest }) => rest) + localStorage.setItem(storageKey, JSON.stringify(serializable)) + } + return next + }) + }, [storageKey]) + + const currencyConverter = new CurrencyConverter(undefined, managers?.settingsManager as any) + + const identitySearch = useIdentitySearch({ + originator: adminOriginator, + wallet, + onIdentitySelected: (identity) => { + if (identity) setRecipient(identity.identityKey) + } + }) + + useAsyncEffect(async () => { + await currencyConverter.initialize() + setCurrencySymbol(currencyConverter.getCurrencySymbol()) + }, []) + + // Keep a ref to outgoing so pollResponses doesn't depend on it (avoids re-render loops). + const outgoingRef = useRef(outgoing) + useEffect(() => { outgoingRef.current = outgoing }, [outgoing]) + + // Poll for responses when there are pending or paid (unreceived) requests. + const pollResponses = useCallback(async () => { + if (!peerPayClient || !messageBoxUrl) return + const current = outgoingRef.current + const hasPending = current.some(r => r.status === 'pending' && r.expiresAt > Date.now()) + const hasPaidUnreceived = current.some(r => r.status === 'paid' && !r.incomingPayment) + if (!hasPending && !hasPaidUnreceived) return + try { + const responses: PaymentRequestResponse[] = await peerPayClient.listPaymentRequestResponses(messageBoxUrl) + + // Also fetch incoming payments to match with fulfilled requests + let incomingPayments: IncomingPayment[] = [] + if (responses.some(r => r.status === 'paid') || hasPaidUnreceived) { + incomingPayments = await peerPayClient.listIncomingPayments(messageBoxUrl) + } + + updateOutgoing(prev => prev.map(req => { + // Don't update already received requests + if (req.status === 'received') return req + + const match = responses.find(r => r.requestId === req.requestId) + if (!match) { + // Try to find incoming payment for already-paid requests missing their payment ref + if (req.status === 'paid' && !req.incomingPayment) { + const payment = incomingPayments.find(p => p.sender === req.recipient) + if (payment) return { ...req, incomingPayment: payment } + } + return req + } + + if (match.status === 'paid') { + // Find the matching incoming payment from this sender + const payment = incomingPayments.find(p => p.sender === req.recipient) + return { + ...req, + status: 'paid' as const, + amountPaid: match.amountPaid, + note: match.note, + incomingPayment: payment, + } + } + return { + ...req, + status: 'declined' as const, + note: match.note, + } + })) + } catch { + // Silently ignore poll errors + } + }, [peerPayClient, messageBoxUrl]) + + // Mark expired requests + useEffect(() => { + const tick = setInterval(() => { + updateOutgoing(prev => + prev.map(r => + r.status === 'pending' && r.expiresAt < Date.now() + ? { ...r, status: 'expired' } + : r + ) + ) + }, 30_000) + return () => clearInterval(tick) + }, []) + + // Immediate poll once on mount if there are actionable requests. + const hasMountPolled = useRef(false) + useEffect(() => { + if (hasMountPolled.current) return + const current = outgoingRef.current + const needsPoll = current.some(r => + r.status === 'pending' || (r.status === 'paid' && !r.incomingPayment) + ) + if (needsPoll && peerPayClient && messageBoxUrl) { + hasMountPolled.current = true + pollResponses() + } + }, [peerPayClient, messageBoxUrl, pollResponses]) + + // Set up 15-second polling interval (stable — doesn't re-create on outgoing changes). + useEffect(() => { + const id = setInterval(pollResponses, 15_000) + return () => clearInterval(id) + }, [pollResponses]) + + const handleAmountChange = useCallback(async (e: React.ChangeEvent) => { + const val = e.target.value.replace(/[^0-9.]/g, '') + setAmountInput(val) + const sats = await currencyConverter.convertToSatoshis(parseFloat(val) || 0) + setAmount(sats ?? 0) + }, []) + + const canRequest = + recipient.trim().length > 0 && + amount > 0 && + description.trim().length > 0 && + !sending + + const handleSubmit = async () => { + if (!canRequest || !peerPayClient) return + try { + setSending(true) + const expiresAt = Date.now() + expiryMs + const { requestId, requestProof } = await peerPayClient.requestPayment( + { recipient: recipient.trim(), amount, description: description.trim(), expiresAt }, + messageBoxUrl || undefined + ) + const label = + identitySearch.selectedIdentity + ? (identitySearch.selectedIdentity as IdentityOption).name || + (identitySearch.selectedIdentity as IdentityOption).identityKey.slice(0, 14) + : recipient.slice(0, 14) + updateOutgoing(prev => [ + { + requestId, + requestProof, + recipient: recipient.trim(), + recipientLabel: label, + amount, + description: description.trim(), + expiresAt, + status: 'pending', + }, + ...prev, + ]) + toast.success('Payment request sent!') + setAmountInput('') + setAmount(0) + setDescription('') + setRecipient('') + setPublicKeyInput('') + identitySearch.handleSelect(null, null) + onRequestSent?.() + } catch (e) { + const msg = (e as Error)?.message ?? 'Failed to send payment request' + toast.error(msg) + } finally { + setSending(false) + } + } + + const handleCancel = async (req: OutgoingRequest) => { + if (!peerPayClient) return + try { + setCancellingId(req.requestId) + await peerPayClient.cancelPaymentRequest( + { recipient: req.recipient, requestId: req.requestId, requestProof: req.requestProof }, + messageBoxUrl || undefined + ) + updateOutgoing(prev => + prev.map(r => r.requestId === req.requestId ? { ...r, status: 'cancelled' } : r) + ) + toast.success('Request cancelled') + } catch (e) { + toast.error((e as Error)?.message ?? 'Failed to cancel request') + } finally { + setCancellingId(null) + } + } + + /** Accept/internalize the payment associated with a fulfilled request. */ + const handleReceive = async (req: OutgoingRequest) => { + if (!peerPayClient || !req.incomingPayment) return + try { + setReceivingId(req.requestId) + await peerPayClient.acceptPayment(req.incomingPayment) + updateOutgoing(prev => + prev.map(r => r.requestId === req.requestId ? { ...r, status: 'received' as const } : r) + ) + window.dispatchEvent(new CustomEvent('balance-changed')) + toast.success('Payment received!') + } catch (e) { + toast.error((e as Error)?.message ?? 'Failed to receive payment') + } finally { + setReceivingId(null) + } + } + + return ( + + {/* ---- Form ---- */} + + + Request Payment + + + {/* Recipient autocomplete */} + { + identitySearch.handleSelect(event, value as any) + if (value && typeof value !== 'string') { + setRecipient((value as IdentityOption).identityKey) + setPublicKeyInput((value as IdentityOption).identityKey) + } else { + setRecipient('') + setPublicKeyInput('') + } + }} + filterOptions={(options: IdentityOption[]) => + options.filter( + (identity, index, array) => + array.findIndex(i => i.identityKey === identity.identityKey) === index + ) + } + getOptionLabel={(option) => { + if (typeof option === 'string') return option + return (option as IdentityOption).name || (option as IdentityOption).identityKey.slice(0, 16) + }} + isOptionEqualToValue={(option, value) => { + if (typeof option === 'string' || typeof value === 'string') return false + return (option as IdentityOption).identityKey === (value as IdentityOption).identityKey + }} + renderInput={(params) => ( + + {identitySearch.isLoading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + renderOption={(props, option) => { + if (typeof option === 'string') return null + const opt = option as IdentityOption + const { key, ...otherProps } = props as any + return ( +
  • + + {opt.avatarURL ? ( + + ) : ( + + {getInitials(opt.name || '', opt.identityKey)} + + )} + + {opt.name || 'Unknown'} + + {opt.identityKey.slice(0, 20)}... + + + {opt.badgeLabel && } + +
  • + ) + }} + noOptionsText={identitySearch.inputValue ? 'No identities found' : 'Start typing to search'} + fullWidth + /> + + {/* Direct public key fallback */} + { + const val = e.target.value.trim() + setPublicKeyInput(val) + if (val) { + try { + PublicKey.fromString(val) + setRecipient(val) + identitySearch.handleSelect(null, null) + } catch { + setRecipient('') + } + } else { + setRecipient('') + } + }} + disabled={!!identitySearch.selectedIdentity} + error={Boolean(publicKeyInput && !recipient && !identitySearch.selectedIdentity)} + helperText={publicKeyInput && !recipient && !identitySearch.selectedIdentity ? 'Invalid public key' : ''} + /> + + {/* Amount */} + {currencySymbol} + }} + /> + + {/* Description */} + setDescription(e.target.value)} + required + /> + + {/* Expiry */} + + Expires In + + + + + + +
    +
    + + {/* ---- Outgoing tracker ---- */} + {outgoing.length > 0 && ( + + + Sent Requests + + + + {outgoing.map(req => ( + + + + + To: {req.recipientLabel}… + + + {req.amount.toLocaleString()} sats + + + {req.description} + + {req.status === 'paid' && req.amountPaid != null && ( + + Paid: {req.amountPaid.toLocaleString()} sats + + )} + {req.note && ( + + + "{req.note}" + + + )} + + + + {req.status === 'pending' && ( + + )} + {req.status === 'paid' && req.incomingPayment && ( + + )} + {req.status === 'paid' && !req.incomingPayment && ( + + Waiting for payment… + + )} + + + + ))} + + + )} +
    + ) +} diff --git a/src/lib/pages/Dashboard/Payments/index.tsx b/src/lib/pages/Dashboard/Payments/index.tsx index c870dd3..26ca6a6 100644 --- a/src/lib/pages/Dashboard/Payments/index.tsx +++ b/src/lib/pages/Dashboard/Payments/index.tsx @@ -27,7 +27,9 @@ import { Link } from '@mui/material' import InputAdornment from '@mui/material/InputAdornment' -import { IncomingPayment } from '@bsv/message-box-client' +import { IncomingPayment, IncomingPaymentRequest } from '@bsv/message-box-client' +import RequestPaymentForm from './RequestPaymentForm' +import IncomingRequestList from './IncomingRequestList' import { Utils, Script, PublicKey, WalletInterface } from '@bsv/sdk' import { WalletContext } from '../../../WalletContext' import { toast } from 'react-toastify' @@ -475,11 +477,15 @@ export default function PeerPayRoute() { isHostAnointed, anointCurrentHost, anointmentLoading, - adminOriginator + adminOriginator, + activeProfile } = useContext(WalletContext) const wallet = managers?.permissionsManager || null + const idSuffix = activeProfile?.identityKey ? `_${activeProfile.identityKey}` : '' const [payments, setPayments] = useState([]) + const [incomingRequests, setIncomingRequests] = useState([]) + const [activeTab, setActiveTab] = useState(0) const [loading, setLoading] = useState(false) const [transactions, setTransactions] = useState([]) const [snack, setSnack] = useState<{ open: boolean; msg: string; severity: 'success' | 'info' | 'warning' | 'error' }>({ @@ -501,6 +507,21 @@ export default function PeerPayRoute() { } }, [peerPayClient, messageBoxUrl]) + const fetchRequests = useCallback(async () => { + try { + if (!peerPayClient || !messageBoxUrl) return + const min = parseInt(localStorage.getItem(`payReq_minAmount${idSuffix}`) ?? '1000', 10) + const max = parseInt(localStorage.getItem(`payReq_maxAmount${idSuffix}`) ?? '10000000', 10) + const list = await peerPayClient.listIncomingPaymentRequests(messageBoxUrl, { + minAmount: isNaN(min) ? 1000 : min, + maxAmount: isNaN(max) ? 10000000 : max + }) + setIncomingRequests(list) + } catch (e) { + setSnack({ open: true, msg: (e as Error)?.message ?? 'Failed to load requests', severity: 'error' }) + } + }, [peerPayClient, messageBoxUrl, idSuffix]) + const getPastTransactions = async () => { if (!wallet) return @@ -567,65 +588,107 @@ export default function PeerPayRoute() { Payments - - {!isHostAnointed && ( - - Your message box host is not yet anointed. You can send payments, but others cannot find you to send payments to you until you anoint the host in Settings. - - )} + {!isHostAnointed && ( + + Your message box host is not yet anointed. You can send payments, but others cannot find you to send payments to you until you anoint the host in Settings. + + )} - + { + setActiveTab(v) + if (v === 2) fetchRequests() + if (v === 3) fetchPayments() + }} + variant="fullWidth" + sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} + > + + + + + + + {/* Tab 0: Send Payment */} + {activeTab === 0 && ( + + - {loading && } + {loading && } - + {/* Transaction History Section */} + + + Transaction History + + + + + + {transactions.length === 0 ? ( + + No transactions yet... + + ) : ( + + {transactions.map((tx, index) => ( + + + + TXID:{' '} + + {tx.txid} + + + + To: {tx.to || 'N/A'} + + + Amount: {tx.amount || 'N/A'} BSV + + + + ))} + + )} + + + )} - {/* Transaction History Section */} - - - Transaction History - - + {/* Tab 1: Request Payment */} + {activeTab === 1 && ( + + )} - + {/* Tab 2: Incoming Requests */} + {activeTab === 2 && ( + + )} - {transactions.length === 0 ? ( - - No transactions yet... - - ) : ( - - {transactions.map((tx, index) => ( - - - - TXID:{' '} - - {tx.txid} - - - - To: {tx.to || 'N/A'} - - - Amount: {tx.amount || 'N/A'} BSV - - - - ))} - - )} - - + {/* Tab 3: Pending Payments */} + {activeTab === 3 && ( + + {loading && } + + + )}