Skip to content

fix: #2330 expand tuples in overloaded ABI function keys - #2349

Open
Vastargazing wants to merge 4 commits into
KeeperHub:stagingfrom
Vastargazing:fix/issue-2330-tuple-overload-keys
Open

fix: #2330 expand tuples in overloaded ABI function keys#2349
Vastargazing wants to merge 4 commits into
KeeperHub:stagingfrom
Vastargazing:fix/issue-2330-tuple-overload-keys

Conversation

@Vastargazing

@Vastargazing Vastargazing commented Sep 7, 2026

Copy link
Copy Markdown

Closes #2330

Selecting a tuple overload could store a key such as permit(address,tuple,bytes): lookup accepted it, but ethers could not encode it. Different overloads could also produce the same stored key.

This follows the accepted plan, including the Diamond ABI merge fix.

  • The dropdown and getAbiFunctionKey now build canonical signatures with expanded tuple types.
  • Lookup accepts canonical signatures and unambiguous legacy keys. When a legacy key matches different overloads, execution returns an error listing the signatures to choose from. Duplicate entries for the same signature are treated as one function.
  • canonicalType stays strict. Lookup catches canonicalisation errors per entry so malformed tuple components do not crash the config renderer or prevent lookup of healthy entries.
  • Simulation uses the resolved canonical signature for both calldata encoding and return-value decoding.
  • Diamond ABI merging now computes canonical selectors, so tuple overloads survive deduplication. Entries whose selectors cannot be computed are preserved and excluded from deduplication. The merge helpers were moved into lib/abi/combine-abis.ts so the merge itself could be tested.

Bare-name lookup keeps its existing first-match behaviour. Saved keys such as send(tuple,address) still work when they identify one overload. Keys that collapsed multiple overloads require the user to select the function again.

Breaking change

Saved workflows with a legacy function key that matches multiple overloads, such as permit(address,tuple,bytes), now return an error listing the full signatures instead of selecting the first match. Users need to re-select the function once to save its full signature.

This only affects ambiguous legacy keys. Bare-name lookup, overloads without tuple parameters, and legacy keys that identify a single overload, such as send(tuple,address), keep their existing behaviour.

Tests

Tests cover canonical and legacy keys through encoding, decoded simulation results, duplicate ABI entries, malformed tuple components, and ambiguous-key errors before the API's read/write branch. Diamond merge tests cover overloads within one facet and across facets, genuine duplicates, ordering, and malformed entries.

Validation on 294294f, using Node 24.19.0 to match .node-version:

  • Vitest 4.1.11: 242 passed across 15 files in the regression set for this change.
  • pnpm discover-plugins && pnpm type-check: passed.
  • pnpm check: no errors across 2168 files; one pre-existing warning about an oversized JSON fixture.

Function keys for overloaded ABI functions were built from the raw ABI
types, so a struct parameter became the literal "tuple". The resulting key
is not a fragment ethers accepts, and two overloads differing only inside
the struct produced the same key, so the user's choice was not recoverable.
Selecting either Permit2 `permit` overload from the published ABI produced
`permit(address,tuple,bytes)` and failed at encode with "invalid function
fragment", while the canonical spelling was not found by the lookup.

Both key writers now expand tuples through canonicalType, which the
selector shown next to the same dropdown entry already used. The reader
matches canonical signatures and keeps accepting the legacy raw spelling
wherever it still identifies one overload, so saved workflows and API or
MCP callers that supply a key themselves keep working.

canonicalType stays strict. Canonicalising an entry is guarded at the
lookup instead, so an entry that cannot be canonicalised is simply not a
canonical match: findAbiFunction stays total, the UI helpers that call it
outside a try/catch keep failing closed, and one malformed entry cannot
hide the healthy functions beside it.

Where a legacy key matches several overloads the choice was never stored
and cannot be recovered. The execution paths now report that as its own
error naming the signatures to pick from, rather than resolving to
whichever overload came first and returning its inputs, outputs and
stateMutability to the caller.

simulate encodes with the signature derived from the resolved entry rather
than the key as supplied, since resolving a legacy key is not enough to
make it encodable.

The Diamond facet merge in fetch-abi deduplicates on a computed selector.
Building that selector from raw types made two tuple overloads collide, so
one was dropped from the merged ABI at fetch time; it now goes through
computeSelector, and an entry that cannot be canonicalised is left out of
deduplication instead of discarded.
…he encoder

Three cases the first pass left open.

A legacy key was reported ambiguous by counting matching entries rather
than distinct signatures, so an ABI that lists one function twice -- merged
facet ABIs and some explorer responses do -- turned a scalar call that
resolved on staging into an ambiguity error, and a duplicated tuple
function into not found under its canonical key. Ambiguity now means two
different overloads share the raw spelling; repeats of one signature
collapse to their first occurrence, and the message lists each overload
once.

simulate encoded with the resolved signature but still decoded the return
data with the key as supplied, so a legacy tuple key produced correct call
data and then handed the caller raw hex instead of the decoded value, the
decode failure having fallen through silently. Both directions now use
the canonical key.

The contract-call route resolves the key itself before choosing the read
or write path, and its own lookup collapsed an ambiguous key to not found,
so the message naming the signatures to choose from was unreachable
through the API. The route now distinguishes the two.

Tests cover each: duplicated scalar and tuple entries, ambiguity counted
across distinct overloads only, the legacy-key return value through
simulateContractCall, and the 400 body from the route.
…ew found

combineAbis, processAbiString and getFunctionSelector move unchanged from
the fetch-abi route into lib/abi/combine-abis.ts, with combineAbis as the
only export and the route importing it. The merge is what deduplicates
facet ABIs on the computed selector, and it could not be tested where it
lived: reaching it meant standing up loupe detection and the explorer
fetch. Its tests now pin that two overloads differing only inside a
struct survive the merge, within one facet and across facets, that a
genuine duplicate is still dropped, that order and non-function entries
are preserved, and that an entry with no computable selector is neither
dropped nor allowed to drop its neighbours.

The execute-simulate route test mocks the ABI helpers and provided only
findAbiFunction, so the routes' switch to resolveAbiFunction failed it on
a missing export. The mock now builds resolveAbiFunction on the same
lookup.

BigInt literals in the new key test are replaced with BigInt(), which is
what the project's ES2017 target allows.
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

About the build check on this pull request

This pull request comes from a fork, so GitHub does not pass it the credentials build normally uses for our image registry cache and staging build configuration. The build still runs and still compiles the image, so a red build here is real; it just takes longer than on team branches.

Every workflow run on a pull request from a fork also waits for a maintainer to approve it, so checks can sit at "awaiting approval" for a while after each push. Nothing is needed from you for either of these.

@joelorzet joelorzet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design is careful and the documentation on it is unusually good. resolveAbiFunction reporting ambiguous separately from not_found, distinctBySignature stopping repeated facet entries from looking like overloads, and findAbiFunction staying total so the UI helpers keep failing closed, are all the right calls.

One change, and one smaller one, left inline.

Worth adding to the description rather than fixing: a saved workflow whose key collapsed two overloads now returns an error instead of silently calling the first match. That is the correct behaviour and your body does say it, but it changes workflows that run today, so it should be called out as a breaking change rather than sitting inside a bullet list.

Comment thread lib/abi/utils.ts
* and a tuple[] becomes "(uint32,bytes32)[]"
*/
function canonicalType(input: AbiInput): string {
export function canonicalType(input: AbiInput): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function can still return the literal string tuple, which is the exact spelling the PR exists to eliminate.

The guard below reads:

if (!(input.type.startsWith("tuple") && input.components)) {
  return input.type;
}

A tuple with no components fails that condition and falls through to return input.type. I ran it:

tuple, no components     -> "tuple"
tuple[], no components   -> "tuple[]"
tuple, components []     -> "()"
good tuple               -> "(address,uint256)"

Three consequences, all in the new code:

  1. canonicalSignature can produce send(tuple,address). In resolveAbiFunction that matches on the canonical branch, ahead of the legacy branch, and returns status: "found" with canonicalKey: "send(tuple,address)". The guarantee that canonicalKey is the spelling ethers accepts does not hold for that entry, and encoding fails exactly as it does today.
  2. getAbiFunctionKey stores that same unencodable key, so the original bug is still reachable from a different input.
  3. computeSelector hashes send(tuple,address) and returns a selector that is not the real on-chain one. getFunctionSelector therefore returns a value rather than null, so the entry is not excluded from deduplication the way its comment promises. Two distinct tuple overloads that both lack components collide on that wrong selector and combineAbis drops one, which is the failure that comment says this change prevents.

Your malformed-tuple tests cover components: [{ name: "a" }] and components: { a: "uint256" }. Both throw and are handled correctly. A tuple with components absent is the one shape that produces a wrong answer silently instead of throwing, and it is untested.

The fix is one line: treat a tuple-prefixed type without usable components the same as a missing type, and throw.

@Vastargazing Vastargazing Sep 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right, i missed the case where components is absent (:

fixed in 294294f: canonicalType now throws for tuple-prefixed types unless components is an array
an empty components array still gives ()

this prevents the entry from matching through the canonical branch or producing a selector for deduplication. the legacy lookup fallback still works, but it cannot make a malformed ABI entry encodable

added regression tests for tuple and tuple[] without components, the legacy lookup fallback, and keeping both entries in combineAbis when neither has components

Comment thread lib/abi/combine-abis.ts Outdated
components?: AbiItemComponent[];
}>;
}): string | null {
if (abiItem.type !== "function" || !abiItem.name || !abiItem.inputs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!abiItem.inputs excludes an entry that omits inputs entirely. An empty array passes, so zero-argument functions are fine, but explorer-fetched ABIs sometimes omit the key rather than emitting [].

Such an entry returns null, drops out of deduplication, and can then appear twice in a merged Diamond ABI. Treating a missing inputs as [] covers it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 294294f

missing inputs now defaults to [], so both forms of a zero-argument function produce the same selector

added a test with inputs omitted in one facet and inputs: [] in another, checking that the merged ABI contains the function only once

@joelorzet joelorzet added the changes-requested Triage: reviewed, changes needed from the contributor label Sep 7, 2026
…ling it "tuple"

canonicalType returned the raw type for a tuple that carried no
components, so the literal "tuple" this change exists to eliminate could
still reach every consumer: resolveAbiFunction reported such an entry as a
canonical match and handed back an unencodable canonicalKey,
getAbiFunctionKey stored that key, and computeSelector hashed it into a
selector that is not the on-chain one -- which combineAbis then
deduplicated on, dropping one of two distinct tuple overloads that both
lacked components. A tuple without an array of components has no
canonical form and now throws like a missing type does, so it is not a
canonical match, produces no key and no selector, and stays out of
deduplication.

getFunctionSelector also treated an absent inputs key as "no selector",
which let a zero-argument function that an explorer emitted without the
key appear twice in a merged Diamond ABI. Absent inputs are an empty
list.

Tests cover both shapes and the merge behaviour they affect.
@Vastargazing

Copy link
Copy Markdown
Author

thanks for the review!

addressed both inline comments in 294294f and added regression tests for the missing cases

i've also added a separate Breaking change section to the description and updated the validation results after rerunning on Node 24.19.0

@suisuss suisuss left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What this changes

Four files, two of them source. lib/abi/utils.ts:25-33 splits canonicalType's single guard in two: a non-tuple returns input.type, and a tuple whose components is not an array now throws where it previously returned the literal string "tuple". lib/abi/combine-abis.ts:27,33 drops the !abiItem.inputs early return in favour of computeSelector(name, abiItem.inputs ?? []), so a function entry that omits inputs participates in selector dedup instead of bypassing it. Two test files gain two cases each.

The expansion itself is right, and I checked it rather than reading it: I ported canonicalType and computeSelector verbatim and diffed them against ethers v6 FunctionFragment.format("sighash") and .selector for a flat tuple, a nested tuple, tuple[], tuple[2], a tuple[] nested inside a tuple, tuple[][2] of nested tuples, the empty tuple and a tuple-plus-scalar. All eight match on both the string and the four-byte selector, and new Interface([entry]).getFunction(key) resolves in every case. The suffix slice is correct for arbitrary array dimensions because Solidity puts every dimension after the tuple. Legacy stored keys also still resolve on all five paths I traced, and getAbiFunctionKey upgrades a legacy spelling to the canonical one before ethers sees it.

Does it match the description

Undersells. The diff also changes what a components-less tuple does everywhere canonicalType is reached, and one of those call sites has no guard - see the first blocker. That is a behaviour change well outside "expand tuples in overloaded function keys", and it is a regression against both staging and the previously reviewed head.

Blocking

  • components/workflow/config/action-config-renderer.tsx:520,529 - the new throw empties the entire function dropdown. isValidAbiInput (lib/abi/function-inputs.ts:28-30) returns true when components === undefined, so for {name:"p", type:"tuple"} the complete test at :511 is true, computeSelector at :520 and inputs.map(canonicalType) at :529 both throw, and the catch at :541 returns []. -> Paste an ABI with forty good functions and one hand-written {"type":"function","name":"f","inputs":[{"name":"p","type":"tuple"}]} and the selector renders "No functions found in ABI" for the whole ABI. At c9ee6a18 and on staging all forty-one listed. -> Guard the two calls, or make the : inputTypes fallback reachable - the comment at :523-527 says it is "the same condition that already suppresses the selector", and it is not, because complete is true for this input. The two notions of validity have diverged and nothing makes them agree.

  • lib/execute/simulate.ts:637 - a bare function name against an overloaded ABI now silently encodes against the first overload. resolveAbiFunction keeps first-match for a bare name and hands back canonicalKey = canonicalSignature(named[0]), which simulate.ts then encodes with. On staging this reached iface.encodeFunctionData("swap", args), which throws ambiguous function description. -> An API or MCP caller sending swap for a contract with swap(uint256) and swap((address,uint256)) used to get a clean failure and now gets an arbitrary pick, with the decode at :703 matching. -> The PR adds an ambiguous status for a legacy qualified key matching more than one overload; parenIdx === -1 && named.length > 1 is the case that most deserves it and is the one that got quieter.

  • components/workflow/config/action-config-renderer.tsx:532,557 - option values changed format, so an existing workflow's stored key no longer matches any SelectItem. staging emitted send(tuple,address); head emits send((uint32,bytes32),address). The controlled <Select value={value}> falls back to the placeholder, while AbiFunctionArgsField below it resolves the same legacy key through findAbiFunction and renders the arguments fully populated. -> The user sees "Select a function" above a filled-in argument form on a workflow that still executes correctly. -> Normalise the stored key when the panel loads, or emit the legacy spelling as an additional matching value.

Mechanical - actionable as-is

  • plugins/web3/steps/query-transactions-core.ts:294 - iface.getFunction(abiFunction) with no try, and neither queryTransactionsInner:330 nor queryTransactionsCore:317 wraps it. A legacy tuple key throws out of the step instead of returning a USER error. Pre-existing, but it is exactly the key format this change teaches the rest of the system to accept.

  • app/api/gas/estimate/route.ts:176 - contract.getFunction(config.abiFunction) fails a stored legacy key with "Function not found in ABI" while the same workflow executes fine, so the message names the wrong cause.

  • lib/abi/function-key.ts:44-47 - the comment says the malformed-key path "still fails, but at the encoder, naming the fragment". In readContract that getFunction sits inside rpcManager.executeWithFailover (lib/web3/chain-adapter/evm.ts:258), so a deterministic invalid function fragment is retried across every configured provider first, and read-contract-core.ts logs it under ErrorCategory.NETWORK_RPC.

  • tests/unit/abi-utils.test.ts:266-285 - canonicalType has two positive cases, a flat tuple and a one-component tuple[], and abi-function-key.test.ts uses only a flat two-field struct. Nested tuples, tuple[2], arrays of nested tuples and the empty tuple are all unpinned. I verified every one of them empirically and they are correct - the recursion and the suffix slice are precisely the two things that could regress, and neither has a test.

  • tests/unit/abi-combine-abis.test.ts - the new case is titled "keeps two distinct tuple overloads", but the two entries differ only in parameter name, which is not part of any signature. They are two unencodable entries rather than distinguishable overloads. The behaviour is fine; the title and comment are not.

  • One screenshot: an existing workflow whose stored abiFunction is a legacy tuple key, showing the function selector and the argument fields below it together. That state is the third blocker and only a render settles what it looks like.

Verdict

Changes requested - one components-less tuple anywhere in an ABI now blanks the entire function dropdown, and a bare overloaded name silently encodes against an arbitrary overload where it used to fail.

@suisuss suisuss added the requested-evidence Screenshots or video requested from the contributor label Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Triage: reviewed, changes needed from the contributor requested-evidence Screenshots or video requested from the contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

selecting an overload that takes a tuple parameter produces a function key that cannot be encoded

3 participants