Skip to content

feat(web3): #2399 add an optional caller address to read-contract - #2413

Open
bongbongcrypto wants to merge 2 commits into
KeeperHub:stagingfrom
bongbongcrypto:issue-2399
Open

feat(web3): #2399 add an optional caller address to read-contract#2413
bongbongcrypto wants to merge 2 commits into
KeeperHub:stagingfrom
bongbongcrypto:issue-2399

Conversation

@bongbongcrypto

Copy link
Copy Markdown

Closes #2399.

What this changes

web3/read-contract gains an optional callerAddress. Unset, the payload is byte-identical to today: no overrides object is constructed and the eth_call carries no from key. Set, it becomes { from } on the call, so a contract that answers by caller can be asked as the address that matters.

ReadContractRequest (lib/web3/chain-adapter/types.ts) gains callerAddress?: string. EvmChainAdapter.readContract (evm.ts) appends the overrides object when it is present and appends nothing when it is not, on both the view and the staticCall branch. read-contract-core.ts validates and forwards it, and plugins/web3/index.ts adds the config field between functionArgs and readFailOnError.

The three amendments from the issue

Absence rule. You were right that the two layers disagreed. validateFieldValue (lib/workflow/validation/action-config.ts) early-returns valid for undefined, null and "" only, and rejects a whitespace-only value through its isAddressField branch, so "empty or whitespace-only means absent" was unreachable for that input and contradicted save time about it. Absent is now that same set. A whitespace-only value fails isAddress here the way it fails validation there. null is mapped alongside undefined, because that is what an MCP-authored config carries and isMissingRequiredValue treats it as missing, so such a workflow persists and then has to run. The value is still trimmed before it is read - an address with whitespace around it is what a template actually produces - and that case is asserted rather than assumed.

Help text. It now says nothing is signed or sent from the address and that a write is never sent from it, with the warning pointed where you put it: gating a transfer on an answer obtained as someone else.

Scope. Stated rather than implied. readContractCore is also called from app/api/execute/contract-call/route.ts:99, check-and-execute/route.ts:175 and :436, and [...slug]/route.ts:206, each enumerating fields explicitly, so each drops this one. The Direct Execution API is deliberately out of scope; this reaches the workflow surface only. Say if you want it there and I will add it.

The design fork

Taking your reading: optional, defaulting to unset. Defaulting to the org wallet would change the answer every existing read gets, and a silent change of answer is the thing this issue is about. An explicit opt-in leaves address(0) as the default caller, which is not neutral, but it is the default that is already there rather than a new one. Happy to be argued out of it.

Out of scope, and worse than what I filed

Recording your find so it is not rediscovered: check-and-execute/route.ts:175-182 calls readContractCore with resolvedWriteAbi - the staticCall branch - to decide whether to broadcast. That is a write preflight with no caller, in a route that elsewhere calls simulateContractCall, which resolves the org wallet and sends a real from (lib/execute/simulate.ts:675,684). Two preflight styles in one file, one of them answering as address(0).

batch-read-contract stays excluded, and your Multicall3 run is the sharper reason: with from=0x00...01 aggregate3 returns success:false, returnData:0xab581036, so the portal's estimation branch keys on tx.origin. A caller field there would appear to work for this case and be wrong for every msg.sender-gated contract.

Checks

41 tests across read-contract-core and evm-chain-adapter-read; 346 across the 24 contract/adapter/web3 suites; biome clean on the four lintable files (plugins/ is excluded by biome.jsonc).

Five mutants were planted and each went red: the absence test trimming again, null no longer absent, the value no longer trimmed, the isAddress check dropped, and the caller never reaching the adapter.

Rebased onto staging. read-contract-core.ts is also touched by #2382, so whichever lands first the other rebases.

…tract

The eth_call this action makes carries no caller, so it goes out as
address(0). For a contract whose answer depends on who is asking that is not
a neutral read, and the answer can be the opposite of the true one.
OptimismPortal2 reveals a failing withdrawal target only to address(1), and
the note at protocols/chronicle.ts:15-17 records the same property from the
other direction: a toll-gated Scribe feed reads for address(0) but not for an
unauthed caller.

Read Contract gains one optional config field, callerAddress. It is threaded
through ReadContractRequest to the EVM adapter, which appends it as ethers
call overrides, the way the write path at evm.ts:159 already does for its
preflight staticCall.

Empty or whitespace-only means absent, the rule functionArgs already uses, so
a caller fed by a template that renders to nothing leaves the call unchanged
rather than failing it. A present value that is not an address is refused
before the chain call as a USER error, and it is not a destinationError: it is
payload, like the arguments, so failOnError softens it the way it softens an
unparseable argument list rather than hard-failing it the way it hard-fails an
invalid contract address.

With the field unset no overrides object is built and nothing is appended, so
the call is byte-identical to the one made before. Both branches of the isView
ternary take it. Wiring only one would be a silent half-fix, and the case that
motivated this is the staticCall branch.

Eight of the thirteen new tests fail against staging without this change,
including both branches of that ternary and the adapter case that asserts the
eth_call payload itself. The Solana adapter ignores its request and is
untouched; batch-read-contract is deliberately out of scope, because through
Multicall3 the inner call's msg.sender is Multicall3 no matter what caller is
set, so the field would answer wrongly there.
…ield is not

Three amendments from the issue review.

The absence rule had the two layers disagreeing. validateFieldValue
early-returns valid for undefined, null and "" only, and rejects a
whitespace-only value through its isAddressField branch, so the runtime
half of "empty or whitespace-only means absent" was unreachable for that
input and said the opposite of save time about it. Absent is now that
same set, a whitespace-only value fails isAddress here the way it fails
validation there, and null is mapped alongside undefined because that is
what an MCP-authored config carries and isMissingRequiredValue treats it
as missing. The value is still trimmed before it is read, which is the
case a template actually produces - an address with whitespace around
it - and that is now asserted rather than assumed.

The help text says plainly that nothing is signed or sent from the
address and that a write is never sent from it, with the warning
pointed at the real risk: gating a transfer on an answer obtained as
someone else.

Scope is stated rather than implied. readContractCore is also called
from app/api/execute/contract-call/route.ts, check-and-execute/route.ts
twice and [...slug]/route.ts, each enumerating fields explicitly, so
each will drop this one. The Direct Execution API is deliberately out of
scope here; this change reaches the workflow surface only.

Tests: 41 across the two suites this touches, 346 across the 24
contract/adapter/web3 suites, biome clean on the four lintable files.
Five mutants went red: the absence test trimming again, null no longer
absent, the value no longer trimmed, the address check dropped, and the
caller never reaching the adapter.

Rebased onto staging. read-contract-core.ts is also touched by KeeperHub#2382, so
whichever lands first the other rebases.
@github-actions

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.

@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

callerAddress?: string on ReadContractRequest (lib/web3/chain-adapter/types.ts:46) and on ReadContractCoreInput (plugins/web3/steps/read-contract-core.ts:46); a validation block at :147-161 that normalises empty to absent, trims, and refuses a malformed value as USER before any provider is resolved; conditional forwarding at :346; the overrides spread into both arms at lib/web3/chain-adapter/evm.ts:265-271; and a template-input field with isAddressField: true at plugins/web3/index.ts:895-903. batch-read-contract is untouched.

I verified the part most likely to be wrong against the repo's installed ethers 6.17.0 rather than reading it: from reaches the eth_call tx object on a zero-argument view, a one-argument view, a nonpayable staticCall, a zero-argument staticCall, and a call whose last real argument is a tuple object - the overrides are not folded into it. With the field unset the tx carries from: undefined and the call is argument-identical to staging. Both arms converge on one populateTransaction, and copyOverrides keeps from while rejecting to and data.

The validation divergence I raised is resolved the right way and for the right reason. template-input has no case in validateFieldValue's switch, so it reaches default: and the isAddressField branch at action-config.ts:463-468; isMissingRequiredValue covers exactly undefined | null | "", which is exactly what the runtime givenCaller === "" test covers. Whitespace-only is refused at both layers. And a malformed caller fails rather than silently falling back to a no-caller read, which is the correct direction for this feature.

Does it match the description

Matches.

Mechanical - actionable as-is

  • lib/abi/utils.ts:140-155 and lib/abi/function-key.ts:35-41 are now load-bearing in a way they were not before, and nothing pins that. With a bare overloaded name of arity N and N+1, a trailing overrides object makes both candidates match and ethers throws ambiguous function description - I reproduced it against 6.17.0 with deposit(uint256) / deposit(uint256,address). It is unreachable today because resolveAbiFunction rejects an ambiguous bare name and getAbiFunctionKey emits a qualified signature whenever a name is shared. Worth a comment at the overrides construction saying the qualified-key guarantee is what makes it safe.

  • read-contract-core.ts:148 trims after the emptiness test, so a template rendering to " " is a hard USER error while one rendering to "" means no caller. Deliberate and I agree with it; worth one line in the help tip, since incidental whitespace in a template decides between the two.

  • action-config.ts:463 validates with /^0x[0-9a-fA-F]{40}$/ while read-contract-core.ts:149 uses ethers.isAddress, which enforces EIP-55 on mixed case. An author who hand-edits one hex character's case saves clean and then fails every run. Pre-existing across several fields, not introduced here.

  • docs/plugins/web3.md:83 still lists the Read Contract inputs as "Network, Contract Address, ABI, Function, Function Arguments". Not covered by check:api-docs, which indexes docs/api only, so it will rot quietly.

  • Two assertions exist only against the mock: the staticCall branch with overrides, and the zero-argument case, which is where ethers' arity heuristic is actually doing work. I verified both by hand against real ethers, so the risk is negligible - but tests/unit/evm-chain-adapter-read.test.ts is the file that could pin them, and a future ethers bump is exactly what would break them silently.

With the team

Nothing.

Verdict

Approve - the field reaches the wire on both branches, the unset path is byte-identical to today, and the save-time and runtime rules agree.

Two notes rather than findings. The five enumerating call sites - contract-call/route.ts:99, check-and-execute/route.ts:175 and :436, [...slug]/route.ts:206, protocol-read.ts:142 - all drop the field, which matches the scope we agreed; none of them accepts it from a request body either, so nothing is accepted-then-ignored. And #2382 does conflict, in the ReadContractCoreInput type block: it widens functionArgs with a comment on the line your callerAddress follows. I reconstructed the merge - one textual conflict, no semantic one, and the resolution is to keep both lines. Whichever lands second takes it.

@suisuss suisuss added the approve Triage: reviewed and good - not a GitHub approval label Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approve Triage: reviewed and good - not a GitHub approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read Contract cannot choose the caller, so contracts that answer per caller answer wrong

2 participants