Skip to content
Prev Previous commit
Next Next commit
Fix contract verification regressions
  • Loading branch information
pthmas committed Apr 10, 2026
commit ad62686f13392bbafd8931da83a12c9ca5d7dea5
53 changes: 43 additions & 10 deletions backend/crates/atlas-server/src/api/handlers/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,19 +479,32 @@ fn build_standard_json_input(
contract_outputs.push(serde_json::json!("evm.deployedBytecode"));
}

Ok(serde_json::json!({
"language": "Solidity",
"sources": sources,
"settings": {
"optimizer": {
let mut settings = serde_json::Map::from_iter([
(
"optimizer".to_string(),
serde_json::json!({
"enabled": req.optimization_enabled,
"runs": runs,
},
"evmVersion": req.evm_version.as_deref().unwrap_or("default"),
"outputSelection": {
}),
),
(
"outputSelection".to_string(),
serde_json::json!({
"*": { "*": contract_outputs }
}
}
}),
),
]);
if let Some(evm_version) = req.evm_version.as_deref() {
settings.insert(
"evmVersion".to_string(),
serde_json::Value::String(evm_version.to_string()),
);
}

Ok(serde_json::json!({
"language": "Solidity",
"sources": sources,
"settings": settings,
}))
}

Expand Down Expand Up @@ -840,4 +853,24 @@ mod tests {
]
);
}

#[test]
fn build_standard_json_input_omits_evm_version_when_not_provided() {
let req = VerifyRequest {
source_code: Some("pragma solidity ^0.8.20; contract C {}".to_string()),
source_files: None,
compiler_version: "v0.8.20+commit.a1b79de6".to_string(),
optimization_enabled: false,
optimization_runs: None,
contract_name: "C".to_string(),
constructor_args: None,
evm_version: None,
license_type: None,
};

let input = build_standard_json_input(&req, true).unwrap();
let settings = input.get("settings").and_then(|v| v.as_object()).unwrap();

assert!(!settings.contains_key("evmVersion"));
}
}
101 changes: 82 additions & 19 deletions frontend/src/components/ContractTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEvent, FormEvent, InputHTMLAttributes } from 'react';
import type { ContractDetail, AbiItem, VerifyContractRequest } from '../types';
import { verifyContract } from '../api/contracts';
import {
Expand Down Expand Up @@ -41,13 +42,32 @@ interface VerifyFormProps {
onVerified: () => void;
}

interface SourceFileEntry {
path: string;
name: string;
content: string;
}

type DirectoryInputAttributes = InputHTMLAttributes<HTMLInputElement> & {
directory?: string;
mozdirectory?: string;
webkitdirectory?: string;
};

const directoryInputAttributes: DirectoryInputAttributes = {
directory: '',
mozdirectory: '',
webkitdirectory: '',
};

function VerifyForm({ address, onVerified }: VerifyFormProps) {
const [compilerVersion, setCompilerVersion] = useState('');
const [contractName, setContractName] = useState('');
const [mode, setMode] = useState<'single' | 'multi'>('single');
const [sourceCode, setSourceCode] = useState('');
const [sourceFiles, setSourceFiles] = useState<{ name: string; content: string }[]>([]);
const [sourceFiles, setSourceFiles] = useState<SourceFileEntry[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const [optimizationEnabled, setOptimizationEnabled] = useState(false);
const [optimizationRunsPreset, setOptimizationRunsPreset] = useState<string>('200');
const [customOptimizationRuns, setCustomOptimizationRuns] = useState('');
Expand All @@ -73,29 +93,44 @@ function VerifyForm({ address, onVerified }: VerifyFormProps) {
setSourceFiles(prev => prev.filter((_, i) => i !== index));
}

async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(e.target.files ?? []);
if (picked.length === 0) return;
function sourcePathForFile(file: File): string {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
return (relativePath && relativePath.trim()) || file.name;
}

async function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
const picked = Array.from(e.target.files ?? []).filter(file => file.name.endsWith('.sol'));
if (picked.length === 0) {
setError('No Solidity source files were found in the selected upload.');
e.target.value = '';
return;
}
const loaded = await Promise.all(
picked.map(file =>
new Promise<{ name: string; content: string }>((resolve, reject) => {
new Promise<SourceFileEntry>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, content: reader.result as string });
reader.onload = () =>
resolve({
path: sourcePathForFile(file),
name: file.name,
content: reader.result as string,
});
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
reader.readAsText(file);
}),
),
);
setSourceFiles(prev => {
const existing = new Map(prev.map(f => [f.name, f]));
for (const f of loaded) existing.set(f.name, f);
const existing = new Map(prev.map(f => [f.path, f]));
for (const f of loaded) existing.set(f.path, f);
return Array.from(existing.values());
});
setError(null);
// Reset input so the same files can be re-added after removal
e.target.value = '';
}

async function handleSubmit(e: React.FormEvent) {
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!compilerVersion.trim()) {
setError('Select a compiler version.');
Expand Down Expand Up @@ -129,7 +164,7 @@ function VerifyForm({ address, onVerified }: VerifyFormProps) {
license_type: licenseType.trim() || undefined,
}
: {
source_files: Object.fromEntries(sourceFiles.map(f => [f.name.trim(), f.content])),
source_files: Object.fromEntries(sourceFiles.map(f => [f.path.trim(), f.content])),
compiler_version: compilerVersion.trim(),
optimization_enabled: optimizationEnabled,
optimization_runs: optimizationEnabled ? parseInt(optimizationRunsValue, 10) || 200 : undefined,
Expand Down Expand Up @@ -240,21 +275,49 @@ function VerifyForm({ address, onVerified }: VerifyFormProps) {
className="hidden"
onChange={handleFileChange}
/>
<input
ref={directoryInputRef}
type="file"
accept=".sol"
multiple
className="hidden"
onChange={handleFileChange}
{...directoryInputAttributes}
/>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => directoryInputRef.current?.click()}
className="flex items-center gap-2 self-start px-4 py-2 text-sm border border-accent-primary/40 rounded-xl text-accent-primary hover:border-accent-primary hover:text-fg bg-accent-primary/10 backdrop-blur shadow-md shadow-black/20"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7h5l2 2h11v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
{sourceFiles.length === 0 ? 'Select project folder' : 'Replace or add folder'}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 self-start px-4 py-2 text-sm border border-dark-500 rounded-xl text-gray-300 hover:border-gray-400 hover:text-fg bg-dark-700/80 backdrop-blur shadow-md shadow-black/20"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{sourceFiles.length === 0 ? 'Add flat files' : 'Add more flat files'}
</button>
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 self-start px-4 py-2 text-sm border border-dark-500 rounded-xl text-gray-300 hover:border-gray-400 hover:text-fg bg-dark-700/80 backdrop-blur shadow-md shadow-black/20"
onClick={() => setSourceFiles([])}
className="text-xs text-gray-500 hover:text-gray-300 self-start"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{sourceFiles.length === 0 ? 'Select .sol files' : 'Add more files'}
Clear file list
</button>
{sourceFiles.length > 0 && (
<ul className="flex flex-col gap-1">
{sourceFiles.map((file, index) => (
<li key={file.name} className="flex items-center justify-between px-3 py-2 bg-dark-700/60 border border-dark-500 rounded-lg text-sm font-mono text-gray-300">
<span>{file.name}</span>
<li key={file.path} className="flex items-center justify-between px-3 py-2 bg-dark-700/60 border border-dark-500 rounded-lg text-sm font-mono text-gray-300 gap-4">
<span className="break-all">{file.path}</span>
<button
type="button"
onClick={() => removeFile(index)}
Expand All @@ -266,7 +329,7 @@ function VerifyForm({ address, onVerified }: VerifyFormProps) {
))}
</ul>
)}
<span className="text-xs text-gray-500">Select all .sol files that make up the contract (imports included).</span>
<span className="text-xs text-gray-500">Use the project folder picker to preserve import paths. Flat file upload only works when imports do not rely on directories.</span>
</div>
)}
</div>
Expand Down
86 changes: 51 additions & 35 deletions frontend/src/utils/abiDecode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,9 @@ function keccak256Hex(input: string): string {
return bytesToHex(keccak256(bytes));
}

// Keccak-256 implementation (Ethereum's variant of SHA-3)
// Based on the NIST Keccak reference implementation, adapted for JS.
// Keccak-256 implementation (Ethereum's variant of SHA-3).
function keccak256(data: Uint8Array): Uint8Array {
const state = new BigInt64Array(25);
const state = Array<bigint>(25).fill(0n);
const rate = 136; // 1088 / 8 for keccak256
const output = 32;

Expand All @@ -138,7 +137,7 @@ function keccak256(data: Uint8Array): Uint8Array {
for (let i = 0; i < padded.length; i += rate) {
for (let j = 0; j < rate; j += 8) {
const lane = readLane(padded, i + j);
state[j / 8] ^= lane;
state[j / 8] = mask64(state[j / 8] ^ lane);
}
keccakF(state);
}
Expand All @@ -156,26 +155,25 @@ function readLane(buf: Uint8Array, offset: number): bigint {
for (let i = 0; i < 8; i++) {
val |= BigInt(buf[offset + i] ?? 0) << BigInt(8 * i);
}
return BigInt.asIntN(64, val);
return mask64(val);
}

function writeLane(buf: Uint8Array, offset: number, val: bigint) {
const u = BigInt.asUintN(64, val);
const u = mask64(val);
for (let i = 0; i < 8; i++) {
buf[offset + i] = Number((u >> BigInt(8 * i)) & 0xffn);
}
}

// Rotation constants
const ROT: number[] = [
1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44,
];
// Pi indices
const PI: number[] = [
10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1,
];
// Round constants
const RC: bigint[] = [
const ROTATION_OFFSETS = [
[0, 36, 3, 41, 18],
[1, 44, 10, 45, 2],
[62, 6, 43, 15, 61],
[28, 55, 25, 21, 56],
[27, 20, 39, 8, 14],
] as const;

const ROUND_CONSTANTS: bigint[] = [
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
Expand All @@ -184,42 +182,56 @@ const RC: bigint[] = [
0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
];

function mask64(x: bigint): bigint {
return BigInt.asUintN(64, x);
}

function rol64(x: bigint, n: number): bigint {
n = ((n % 64) + 64) % 64;
if (n === 0) return x;
const u = BigInt.asUintN(64, x);
return BigInt.asIntN(64, (u << BigInt(n)) | (u >> BigInt(64 - n)));
const u = mask64(x);
return mask64((u << BigInt(n)) | (u >> BigInt(64 - n)));
}

function keccakF(A: BigInt64Array) {
function keccakF(state: bigint[]) {
for (let round = 0; round < 24; round++) {
// θ
const C = new BigInt64Array(5);
const c = Array<bigint>(5).fill(0n);
for (let x = 0; x < 5; x++) {
c[x] = state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20];
}

const d = Array<bigint>(5).fill(0n);
for (let x = 0; x < 5; x++) {
C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20];
d[x] = c[(x + 4) % 5] ^ rol64(c[(x + 1) % 5], 1);
}
const D = new BigInt64Array(5);
for (let x = 0; x < 5; x++) {
D[x] = C[(x + 4) % 5] ^ rol64(C[(x + 1) % 5], 1);
for (let y = 0; y < 5; y++) {
const index = x + 5 * y;
state[index] = mask64(state[index] ^ d[x]);
}
}
for (let i = 0; i < 25; i++) A[i] ^= D[i % 5];

// ρ and π
const B = new BigInt64Array(25);
B[0] = A[0];
for (let t = 0; t < 24; t++) {
B[PI[t]] = rol64(A[t === 0 ? 0 : PI[t - 1]], ROT[t]);
const b = Array<bigint>(25).fill(0n);
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const index = x + 5 * y;
const newX = y;
const newY = (2 * x + 3 * y) % 5;
b[newX + 5 * newY] = rol64(state[index], ROTATION_OFFSETS[x][y]);
}
}

// χ
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
A[x + 5 * y] = B[x + 5 * y] ^ (~B[(x + 1) % 5 + 5 * y] & B[(x + 2) % 5 + 5 * y]);
const index = x + 5 * y;
const current = b[index];
const next = b[(x + 1) % 5 + 5 * y];
const nextNext = b[(x + 2) % 5 + 5 * y];
state[index] = mask64(current ^ (mask64(~next) & nextNext));
}
}

// ι
A[0] ^= RC[round];
state[0] = mask64(state[0] ^ ROUND_CONSTANTS[round]);
}
}

Expand All @@ -230,7 +242,11 @@ function hexToBytes(hex: string): Uint8Array {
const padded = clean.length % 2 === 0 ? clean : '0' + clean;
const bytes = new Uint8Array(padded.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16);
const value = parseInt(padded.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(value)) {
throw new Error(`Invalid hex byte at index ${i}`);
}
bytes[i] = value;
}
return bytes;
}
Expand Down
Loading