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
20 changes: 18 additions & 2 deletions eslint-rules/no-direct-submit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down Expand Up @@ -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',
Expand Down
11 changes: 5 additions & 6 deletions src/components/dashboard/AuditLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const FilterBar = ({ onFilterChange }: FilterBarProps) => {
color: 'var(--text-muted)',
fontSize: '14px',
}}>
🔍
{'\uD83D\uDD0D'}
</span>
</div>

Expand All @@ -195,7 +195,7 @@ const FilterBar = ({ onFilterChange }: FilterBarProps) => {
gap: '6px',
}}
>
<span>⚙</span>
<span>{'⚙'}</span>
Filters
{(selectedSeverities.length > 0 || selectedCategories.length > 0) && (
<span style={{
Expand Down Expand Up @@ -504,7 +504,7 @@ export default function AuditLog() {
)}
</EnhancedTable>

<div className="card" style={{
<div className="card" style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
Expand All @@ -527,7 +527,7 @@ export default function AuditLog() {
padding: '4px',
}}
>
✕
{'✕'}
</button>
</div>

Expand Down Expand Up @@ -587,12 +587,11 @@ export default function AuditLog() {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-muted)', textAlign: 'center' }}>
<div style={{ fontSize: '28px', marginBottom: '8px', opacity: 0.4 }}>📋</div>
<div style={{ fontSize: '28px', marginBottom: '8px', opacity: 0.4 }}>{'\uD83D\uDCCB'}</div>
<div style={{ fontSize: '13px' }}>Select an entry to view details</div>
</div>
)}
</div>
</div>
</div>
);
}
52 changes: 52 additions & 0 deletions src/components/dashboard/ContractInteraction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,11 @@ export default function ContractInteraction() {
});

const [contractFunctions, setContractFunctions] = useState([]);
const [contractSpec, setContractSpec] = useState<any>(null);
const [simulateLoading, setSimulateLoading] = useState(false);
const [invokeLoading, setInvokeLoading] = useState(false);
const [error, setError] = useState('');
const [specArgError, setSpecArgError] = useState('');
const [simulationResult, setSimulationResult] = useState(null);
const [previousFootprint, setPreviousFootprint] = useState(null);
const [invokeResult, setInvokeResult] = useState(null);
Expand Down Expand Up @@ -356,13 +358,15 @@ export default function ContractInteraction() {
useEffect(() => {
if (!form.contractId || !isValidContractId(form.contractId.trim())) {
setContractFunctions([]);
setContractSpec(null);
return;
}
let isCurrent = true;
parseContractWasm(form.contractId.trim(), network)
.then(res => {
if (isCurrent && res && res.functions) {
setContractFunctions(res.functions);
setContractSpec(res.spec ?? null);
}
})
.catch(err => {
Expand Down Expand Up @@ -454,11 +458,13 @@ export default function ContractInteraction() {
// diffs must only compare successive simulations of the same call (#849).
if (field === 'contractId' || field === 'functionName') {
setPreviousFootprint(null);
setSpecArgError('');
}
setForm((current) => ({ ...current, [field]: value }));
}

function updateArgument(index, field, value) {
setSpecArgError('');
setForm((current) => ({
...current,
args: current.args.map((arg, i) => (i === index ? { ...arg, [field]: value } : arg)),
Expand Down Expand Up @@ -512,8 +518,28 @@ export default function ContractInteraction() {

async function handleSimulate() {
setError('');
setSpecArgError('');
setInvokeResult(null);
setSimulationResult(null);

// Pre-validate args against the on-chain spec before hitting the RPC.
if (contractSpec && typeof contractSpec.funcArgsToScVals === 'function' && parameterDefinitions.length > 0) {
try {
const namedArgs: Record<string, unknown> = {};
for (const arg of form.args) {
if (!arg.name) continue;
const val = arg.value.trim();
if (arg.type === 'bool') namedArgs[arg.name] = val === 'true';
else if (arg.type === 'int') namedArgs[arg.name] = BigInt(val);
else namedArgs[arg.name] = val;
}
contractSpec.funcArgsToScVals(form.functionName, namedArgs);
} catch (validationErr: any) {
setSpecArgError(validationErr?.message || 'Arguments do not match the contract spec');
return;
}
}

setSimulateLoading(true);

try {
Expand All @@ -523,6 +549,7 @@ export default function ContractInteraction() {
args: form.args.filter((arg) => arg.value.trim() !== ''),
sourceAccount: form.sourceAccount || connectedAddress,
network,
spec: contractSpec,
});
setPreviousFootprint(simulationResult?.footprint ?? null);
setSimulationResult(result);
Expand Down Expand Up @@ -570,6 +597,7 @@ export default function ContractInteraction() {
sourceAccount: form.sourceAccount || connectedAddress,
secretKey: form.secretKey,
network,
spec: contractSpec,
onStatus: (status) => setInvokeStatus(status),
});
setInvokeResult(result);
Expand Down Expand Up @@ -901,6 +929,17 @@ export default function ContractInteraction() {
<option value="true">True</option>
<option value="false">False</option>
</select>
) : hasSpecName && parameterDefinitions[index]?.schema?.enum?.length > 0 ? (
<select
value={arg.value}
onChange={(e) => updateArgument(index, "value", e.target.value)}
style={textInputStyle(fieldAnomalies.some(a => a.severity === 'error'))}
>
<option value="">Select variant...</option>
{(parameterDefinitions[index].schema.enum as string[]).map((variant) => (
<option key={variant} value={variant}>{variant}</option>
))}
</select>
) : (
<input
value={arg.value}
Expand Down Expand Up @@ -1012,6 +1051,19 @@ export default function ContractInteraction() {
{error}
</div>
)}

{specArgError && (
<div
style={{
marginTop: "8px",
fontSize: "12px",
color: "var(--red)",
lineHeight: 1.5,
}}
>
Spec validation: {specArgError}
</div>
)}
</Panel>

{(form.contractId.trim() && form.functionName.trim()) && (
Expand Down
22 changes: 18 additions & 4 deletions src/components/dashboard/PortfolioRebalancer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +82,10 @@ export default function PortfolioRebalancer() {
const [simulationResult, setSimulationResult] = useState<any>(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<any[]>([]);
const [timeRange, setTimeRange] = useState(30);
Expand Down Expand Up @@ -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('');
Expand All @@ -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)}...`);
Expand Down Expand Up @@ -1144,6 +1156,8 @@ export default function PortfolioRebalancer() {
</div>
)}

<MainnetConfirmDialog {...dialogProps} />

</div>
);
}
56 changes: 42 additions & 14 deletions src/lib/contractInvoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ const TERMINAL_TRANSACTION_STATUSES = new Set(["SUCCESS", "FAILED"]);
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));

// Use spec.funcArgsToScVals when available — handles Vec, Map, enums, structs.
// Falls back to null so callers can use their own manual encoder.
function tryEncodeWithSpec(spec, functionName, args) {
if (!spec || typeof spec.funcArgsToScVals !== 'function') return null;
if (!args.every(a => a.name)) return null;
try {
const namedArgs = {};
for (const arg of args) {
const val = arg.value.trim();
if (arg.type === 'bool') namedArgs[arg.name] = val === 'true';
else if (arg.type === 'int') namedArgs[arg.name] = BigInt(val);
else namedArgs[arg.name] = val;
}
return spec.funcArgsToScVals(functionName, namedArgs);
} catch {
return null;
}
}

export async function waitForTransaction(
server,
hash,
Expand Down Expand Up @@ -624,6 +643,7 @@ export async function invokeContractFunction({
network = "testnet",
onStatus,
polling,
spec,
}) {
if (!isValidContractId(contractId)) {
throw new Error("Invalid contract ID");
Expand Down Expand Up @@ -654,20 +674,28 @@ export async function invokeContractFunction({
const account = await horizon.loadAccount(sourceAccount);
const contract = new StellarSdk.Contract(contractId);

const scArgs = args.map((arg) => {
switch (arg.type) {
case "string":
return StellarSdk.nativeToScVal(arg.value, { type: "string" });
case "int":
return StellarSdk.nativeToScVal(BigInt(arg.value), { type: "i128" });
case "address":
return StellarSdk.Address.fromString(arg.value).toScVal();
case "bool":
return StellarSdk.nativeToScVal(arg.value === "true", { type: "bool" });
default:
throw new Error(`Unsupported argument type: ${arg.type}`);
}
});
// Prefer spec-driven encoding when spec is available — handles Vec, Map, enums,
// structs and other complex types the manual switch can't cover.
let scArgs: StellarSdk.xdr.ScVal[];
const specArgs = tryEncodeWithSpec(spec, functionName, args);
if (specArgs) {
scArgs = specArgs;
} else {
scArgs = args.map((arg) => {
switch (arg.type) {
case "string":
return StellarSdk.nativeToScVal(arg.value, { type: "string" });
case "int":
return StellarSdk.nativeToScVal(BigInt(arg.value), { type: "i128" });
case "address":
return StellarSdk.Address.fromString(arg.value).toScVal();
case "bool":
return StellarSdk.nativeToScVal(arg.value === "true", { type: "bool" });
default:
throw new Error(`Unsupported argument type: ${arg.type}`);
}
});
}

const transaction = new StellarSdk.TransactionBuilder(account, {
fee: StellarSdk.BASE_FEE.toString(),
Expand Down
Loading
Loading