Skip to content
16 changes: 12 additions & 4 deletions app/api/execute/contract-call/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import "server-only";

import { NextResponse } from "next/server";
import { resolveAbi } from "@/lib/abi/cache";
import { type AbiItem, findAbiFunction } from "@/lib/abi/utils";
import {
type AbiItem,
describeAmbiguousKey,
resolveAbiFunction,
} from "@/lib/abi/utils";
import { enforceExecutionLimit } from "@/lib/billing/execution-guard";
import { enterApiExecuteErrorContext } from "@/lib/db/org-helpers";
import { simulateContractCall } from "@/lib/execute/simulate";
Expand Down Expand Up @@ -54,13 +58,17 @@ function findFunctionInAbi(
return { error: "ABI must be a JSON array" };
}

const entry = findAbiFunction(parsed, functionName);
const resolution = resolveAbiFunction(parsed, functionName);

if (resolution.status === "ambiguous") {
return { error: describeAmbiguousKey(functionName, resolution.candidates) };
}

if (!entry) {
if (resolution.status !== "found") {
return { error: `Function '${functionName}' not found in ABI` };
}

return { entry };
return { entry: resolution.entry };
}

async function resolveAbiForRequest(
Expand Down
29 changes: 27 additions & 2 deletions app/api/gas/estimate/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { ethers } from "ethers";
import { NextResponse } from "next/server";
import { normalizeAbiEntries } from "@/lib/abi/normalize";
import {
type AbiItem,
describeAmbiguousKey,
resolveAbiFunction,
} from "@/lib/abi/utils";
import { apiError } from "@/lib/api-error";
import ERC20_ABI from "@/lib/contracts/abis/erc20.json";
import { MULTICALL3_ABI, MULTICALL3_ADDRESS } from "@/lib/contracts/multicall3";
Expand Down Expand Up @@ -153,6 +159,25 @@ function estimateWriteContract(
return badRequest("Invalid ABI JSON");
}

if (!Array.isArray(parsedAbi)) {
return badRequest("ABI must be a JSON array");
}
// ethers also accepts human-readable fragments in the array; the resolver
// needs them as objects.
parsedAbi = normalizeAbiEntries(parsedAbi) as ethers.InterfaceAbi;
const resolution = resolveAbiFunction(
parsedAbi as AbiItem[],
config.abiFunction
);
if (resolution.status === "ambiguous") {
return badRequest(
describeAmbiguousKey(config.abiFunction, resolution.candidates)
);
}
if (resolution.status !== "found") {
return badRequest(`Function '${config.abiFunction}' not found in ABI`);
}

let args: unknown[] = [];
if (config.functionArgs && config.functionArgs.trim() !== "") {
try {
Expand All @@ -173,9 +198,9 @@ function estimateWriteContract(
// instead of the inherited method (which lacks `.estimateGas`).
let fn: ethers.BaseContractMethod;
try {
fn = contract.getFunction(config.abiFunction);
fn = contract.getFunction(resolution.canonicalKey);
} catch {
return badRequest(`Function '${config.abiFunction}' not found in ABI`);
return badRequest(`Invalid ABI function '${config.abiFunction}'`);
}

return fn.estimateGas(...args, { from: walletAddress });
Expand Down
93 changes: 1 addition & 92 deletions app/api/web3/fetch-abi/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { ethers } from "ethers";
import { NextResponse } from "next/server";
import { combineAbis } from "@/lib/abi/combine-abis";
import { toChecksumAddress } from "@/lib/address-utils";
import { apiError } from "@/lib/api-error";
import { db } from "@/lib/db";
Expand Down Expand Up @@ -311,98 +312,6 @@ async function getDiamondFacets(
});
}

/**
* Get function selector for an ABI item
*/
function getFunctionSelector(abiItem: {
type: string;
name?: string;
inputs?: Array<{ type: string; name?: string }>;
}): string | null {
if (abiItem.type !== "function" || !abiItem.name || !abiItem.inputs) {
return null;
}
const signature = `${abiItem.name}(${abiItem.inputs.map((i) => i.type).join(",")})`;
return ethers.id(signature).slice(0, 10); // First 4 bytes
}

/**
* Parse and process a single ABI string
*/
function processAbiString(
abiStr: string,
seenSelectors: Set<string>
): unknown[] {
try {
const abi = JSON.parse(abiStr) as unknown[];
const items: unknown[] = [];
let functionCount = 0;
let duplicateCount = 0;

for (const item of abi) {
const abiItem = item as {
type: string;
name?: string;
inputs?: Array<{ type: string; name?: string }>;
};

// For functions, check for duplicates by selector
const selector = getFunctionSelector(abiItem);
if (selector) {
functionCount += 1;
if (seenSelectors.has(selector)) {
duplicateCount += 1;
console.log(
`[Diamond] Skipping duplicate function: ${abiItem.name} (selector: ${selector})`
);
continue;
}
seenSelectors.add(selector);
}

// Include all items (functions, events, errors, etc.)
items.push(item);
}

if (functionCount > 0) {
const uniqueFunctions = items.filter(
(i) => (i as { type?: string }).type === "function"
).length;
console.log(
`[Diamond] Processed ${functionCount} functions (${duplicateCount} duplicates skipped, ${uniqueFunctions} unique)`
);
}

return items;
} catch (error) {
logUserError(
ErrorCategory.EXTERNAL_SERVICE,
"[Diamond] Failed to parse facet ABI from Etherscan",
error instanceof Error ? error : new Error(String(error)),
{
service: "etherscan",
component: "diamond-proxy",
}
);
return [];
}
}

/**
* Combine multiple ABIs into one, removing duplicates
*/
function combineAbis(abis: string[]): string {
const allItems: unknown[] = [];
const seenSelectors = new Set<string>();

for (const abiStr of abis) {
const items = processAbiString(abiStr, seenSelectors);
allItems.push(...items);
}

return JSON.stringify(allItems);
}

type DiamondFacetResult = {
address: string;
name: string | null;
Expand Down
63 changes: 46 additions & 17 deletions components/workflow/config/action-config-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ import { ArrayInputField } from "@/components/workflow/config/array-input-field"
import { MalformedAbiArgsNotice } from "@/components/workflow/config/malformed-abi-notice";
import { TupleInputField } from "@/components/workflow/config/tuple-input-field";
import {
type AbiFunctionInput,
isValidAbiInput,
resolveFunctionInputs,
} from "@/lib/abi/function-inputs";
import { parseAbiFunctionArgs } from "@/lib/abi/parse-args";
import { computeSelector } from "@/lib/abi/utils";
import {
canonicalType,
computeSelector,
resolveAbiFunction,
} from "@/lib/abi/utils";
import { evaluateShowWhen } from "@/lib/workflow/editor/show-when";
import { parseAddressBookSelection } from "@/lib/address-book-selection";
import { toChecksumAddress } from "@/lib/address-utils";
Expand Down Expand Up @@ -471,18 +476,18 @@ export function AbiFunctionSelectField({
abiValue,
functionFilter = "read",
}: AbiFunctionSelectProps) {
// Parse ABI and extract functions
const functions = React.useMemo(() => {
if (!abiValue || abiValue.trim() === "") {
const abi = React.useMemo(() => {
try {
const parsed = JSON.parse(abiValue);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}, [abiValue]);

// Parse ABI and extract functions
const functions = React.useMemo(() => {
try {
const abi = JSON.parse(abiValue);
if (!Array.isArray(abi)) {
return [];
}

// Filter functions based on functionFilter prop
const filterFn =
functionFilter === "write"
Expand All @@ -497,16 +502,20 @@ export function AbiFunctionSelectField({

const filtered = abi.filter(filterFn);

// Count how many times each function name appears to detect overloads
// Count overloads across the whole ABI, not the filtered list: a read
// overload hidden here still shares the name, and a bare key would be
// ambiguous to every lookup that resolves against the full ABI.
const nameCounts = new Map<string, number>();
for (const func of filtered) {
nameCounts.set(func.name, (nameCounts.get(func.name) ?? 0) + 1);
for (const func of abi) {
if (func?.type === "function") {
nameCounts.set(func.name, (nameCounts.get(func.name) ?? 0) + 1);
}
}

return filtered.map((func) => {
const inputs = Array.isArray(func.inputs) ? func.inputs : [];
// A parameter with no type cannot be encoded, so the function is still
// listed (selecting it explains the problem) but gets no selector.
// Missing types or tuple components cannot be encoded. Keep the entry
// visible, but withhold its selector and show the malformed ABI notice.
const complete = inputs.every(isValidAbiInput);
const inputTypes = inputs.map((input: { type?: unknown }) =>
typeof input?.type === "string" ? input.type : "?"
Expand All @@ -518,11 +527,21 @@ export function AbiFunctionSelectField({
.join(", ");
const selector = complete ? computeSelector(func.name, inputs) : null;
const isOverloaded = (nameCounts.get(func.name) ?? 0) > 1;
// The stored key must expand tuples the same way the selector above
// does: a raw `input.type` renders a struct as the literal "tuple",
// which ethers rejects as a fragment and which cannot tell two
// overloads apart when they differ only inside the struct. Falls back
// to the raw types when the ABI is too malformed to canonicalise --
// the same condition that already suppresses the selector.
const keyTypes = complete
? inputs.map((input: AbiFunctionInput) => canonicalType(input))
: inputTypes;
const key = isOverloaded
? `${func.name}(${inputTypes.join(",")})`
? `${func.name}(${keyTypes.join(",")})`
: func.name;
return {
key,
entry: func,
label: `${func.name}(${params})`,
stateMutability: func.stateMutability || "nonpayable",
selector,
Expand All @@ -531,7 +550,17 @@ export function AbiFunctionSelectField({
} catch {
return [];
}
}, [abiValue, functionFilter]);
}, [abi, functionFilter]);

// Resolve only for display. Opening a saved workflow must not mutate its
// config, and an ambiguous legacy key must not select the first overload.
// Resolve against the entire ABI: a hidden read/write overload can also
// make the stored key ambiguous.
const resolution = resolveAbiFunction(abi, value);
const displayValue =
resolution.status === "found"
? functions.find((func) => func.entry === resolution.entry)?.key ?? ""
: "";

if (functions.length === 0) {
return (
Expand All @@ -544,7 +573,7 @@ export function AbiFunctionSelectField({
}

return (
<Select disabled={disabled} onValueChange={onChange} value={value}>
<Select disabled={disabled} onValueChange={onChange} value={displayValue}>
<SelectTrigger className="w-full" id={field.key}>
<SelectValue placeholder={field.placeholder || "Select a function"} />
</SelectTrigger>
Expand Down
3 changes: 2 additions & 1 deletion components/workflow/config/malformed-abi-notice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export function MalformedAbiArgsNotice(): React.ReactNode {
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
The parameters for this function could not be read from the ABI. Check
that the ABI above is valid JSON and that every parameter has a type.
that the ABI above is valid JSON, every parameter has a type, and every
tuple has its components.
</div>
);
}
Loading
Loading