diff --git a/eslint-rules/no-direct-submit.mjs b/eslint-rules/no-direct-submit.mjs index b08dc066..d0a0bcdb 100644 --- a/eslint-rules/no-direct-submit.mjs +++ b/eslint-rules/no-direct-submit.mjs @@ -5,14 +5,19 @@ * from component code. All submit paths must go through useWriteGuard().guard() * so the mainnet confirmation gate is never bypassed. * + * Scope: only files under src/components/ are checked. Outside components + * the rule's prescribed remedy (a React hook) is not available, so + * src/lib/* transport helpers, scripts/ and docs/ are not subject to it. + * * Allowed in: * - src/lib/transactionBuilder.ts (the underlying transport layer) * - src/lib/contractInvoker.ts (Soroban transport layer) * - src/lib/horizonRetry.ts (retry wrapper) + * - src/lib/bulkOperations.ts (batch layer) * - tests/** (test helpers may call directly) * - * Everywhere else, a direct call to submitTransaction / sendTransaction is - * flagged as an error. + * Everywhere else in src/components/, a direct call to submitTransaction / + * sendTransaction / signAndSubmitTransaction is flagged as an error. */ /** @type {import('eslint').Rule.RuleModule} */ @@ -53,6 +58,17 @@ const noDirectSubmit = { if (isAllowed) return {}; + // This rule guards the UI: it exists so a component cannot put a + // mainnet write on screen without a useWriteGuard() confirmation in + // front of it. Enforcing it outside component code was not workable: + // the required remedy is a React hook, so src/lib/* transport helpers + // and non-UI scripts/docs cannot satisfy it at all. The transport + // layers are exempt above; the remainder (scripts/, docs/) have no + // user to confirm with. + const isComponent = filename.includes('/src/components/'); + + if (!isComponent) return {}; + const BLOCKED_METHODS = new Set([ 'submitTransaction', 'sendTransaction', diff --git a/src/components/dashboard/PortfolioRebalancer.tsx b/src/components/dashboard/PortfolioRebalancer.tsx index acaab63e..7b254354 100644 --- a/src/components/dashboard/PortfolioRebalancer.tsx +++ b/src/components/dashboard/PortfolioRebalancer.tsx @@ -7,7 +7,9 @@ import { useStore } from '../../lib/store'; import { suggestRebalancing } from '../../lib/defiAnalytics'; import { fetchPrices, calculatePortfolioValue } from '../../lib/priceFeed'; import { getServer, NETWORKS } from '../../lib/stellar'; -import { buildTransaction, signAndSubmitTransaction, simulateTransaction } from '../../lib/transactionBuilder'; +import { buildTransaction, signAndSubmitTransaction as submitSignedTransaction, simulateTransaction } from '../../lib/transactionBuilder'; +import { useWriteGuard } from '../../hooks/useWriteGuard'; +import MainnetConfirmDialog from '../security/MainnetConfirmDialog'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, CartesianGrid @@ -80,6 +82,10 @@ export default function PortfolioRebalancer() { const [simulationResult, setSimulationResult] = useState(null); const [showXDR, setShowXDR] = useState(false); + // #983 — mainnet write guard. Live rebalance is a real write, so it must + // not reach the network without the typed-confirmation gate in front of it. + const { guard, dialogProps } = useWriteGuard(); + // Chart data const [historyData, setHistoryData] = useState([]); const [timeRange, setTimeRange] = useState(30); @@ -566,7 +572,13 @@ export default function PortfolioRebalancer() { } // Execute Live Rebalance on network - async function handleLiveExecute() { + function handleLiveExecute() { + // #983 — route through the central write guard; it handles mainnet + // confirmation and the session read-only lock before any submission. + guard({ action: 'rebalance portfolio on network', onConfirm: runLiveExecute }); + } + + async function runLiveExecute() { if (!connectedAddress || !secretKey) return; setExecuting(true); setError(''); @@ -586,8 +598,8 @@ export default function PortfolioRebalancer() { baseFee: '100', }); - // Sign & Submit - const result = await signAndSubmitTransaction(tx, secretKey, network); + // Sign & Submit (already gated by handleLiveExecute above) + const result = await submitSignedTransaction(tx, secretKey, network); if (result.successful) { setSuccessMsg(`Live rebalancing transaction submitted successfully! Hash: ${result.hash.slice(0, 16)}...`); @@ -1144,6 +1156,8 @@ export default function PortfolioRebalancer() { )} + + ); }