Skip to content

EVM: Add support for dynamic call intents#52

Open
facuspagnuolo wants to merge 18 commits intoevm/refactor-intents-with-operationsfrom
evm/dynamic_call_intents
Open

EVM: Add support for dynamic call intents#52
facuspagnuolo wants to merge 18 commits intoevm/refactor-intents-with-operationsfrom
evm/dynamic_call_intents

Conversation

@facuspagnuolo
Copy link
Copy Markdown
Member

@facuspagnuolo facuspagnuolo commented Jan 19, 2026

This PR introduces support for dynamic call intents, enabling calldata to be built on-chain from structured arguments instead of precomputed calldata.

Dynamic calls support:

  • Literal ABI-encoded arguments
  • Variable references resolved from previous intent results
  • Nested static calls whose return values can be used as arguments

The encoder reconstructs ABI calldata at execution time following the Aggregatable ABI Encoding approach proposed by OpenZeppelin, allowing intents to be composed, parameterized, and chained without relying on off-chain calldata generation. This unlocks more flexible and expressive intent definitions while preserving standard ABI semantics.

@facuspagnuolo facuspagnuolo self-assigned this Jan 19, 2026
@lgalende lgalende changed the base branch from main to evm/refactor-intents-with-operations April 10, 2026 18:08
@lgalende lgalende requested a review from alavarello April 10, 2026 19:36
@lgalende lgalende self-assigned this Apr 10, 2026
*/
struct DynamicCallOperation {
uint256 chainId;
bytes[] calls;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note: bytes[] instead of DynamicCall[] to avoid stack-too-deep error

_transferFrom(transfer.token, operation.user, transfer.recipient, transfer.amount, isSmartAccount);
}

outputs = new bytes[](0);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We could include the balance diff instead (as we do in swaps). If we decide to do so, I'd do it in a separate PR.

@lgalende lgalende marked this pull request as ready for review April 17, 2026 18:34
@lgalende lgalende added the do not merge Do not merge label Apr 20, 2026
* @dev Interprets ABI-like bytes as either a static or dynamic value
* Used for variable resolution
*/
function _encodeFromAbiLikeBytes(bytes memory value) internal pure returns (EncodedArg memory out) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_encodeFromAbiLikeBytes currently treats every non-dynamic variable as a single 32-byte word, so outputs like uint256[2] or (uint256,address) get truncated when they are reused in a later dynamic call. This works for single-word returns such as uint256, but any multi-word static return from a prior call will be re-encoded incorrectly.
Moreover, any static multi-word value whose first 32-byte word happens to equal 0x20 will be misclassified as dynamic.

Here are some test cases that show these failures:

 const iface = new ethers.Interface([
   'function bar(uint256[2])',
   'function baz((uint256,address))',
   'function qux(uint256,uint256[2])',
 ])

   context('with variable arguments', () => {
     context('when the variable spec is correct', () => {
       const var3 = [11n, 22n]
       const var4 = [33n, randomEvmAddress()]
       const var5 = [32n, 99n]

       // variables[opIndex][subIndex]
       const variables = [
         [ethers.AbiCoder.defaultAbiCoder().encode(['uint256[2]'], [var3])],
         [ethers.AbiCoder.defaultAbiCoder().encode(['tuple(uint256,address)'], [var4])],
         [ethers.AbiCoder.defaultAbiCoder().encode(['uint256[2]'], [var5])],
       ]

       context('with multi-word static arguments', () => {
         const call = dynamicCall('bar', [variable(2, 0)])

         it('encodes arguments properly', async () => {
           const encoded = await encoder.encode(call, variables, variables.length)
           expect(encoded).to.equal(iface.encodeFunctionData('bar', [var3]))
         })
       })

       context('with static tuple arguments', () => {
         const call = dynamicCall('baz', [variable(3, 0)])

         it('encodes arguments properly', async () => {
           const encoded = await encoder.encode(call, variables, variables.length)
           expect(encoded).to.equal(iface.encodeFunctionData('baz', [var4]))
         })
       })

       context('when a static value starts with 0x20', () => {
         const val = 7n
         const call = dynamicCall('qux', [literal(['uint256'], [val]), variable(4, 0)])

         it('encodes arguments properly', async () => {
           const encoded = await encoder.encode(call, variables, variables.length)
           expect(encoded).to.equal(iface.encodeFunctionData('qux', [val, var5]))
         })
       })
     })
   })

And produce the following output:

Image

/**
* @dev Detects the dynamic pre-encoding prefix used by abi.encode("", value)
*/
function _hasDynamicPrefix(bytes memory argument) private pure returns (bool) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This function has a false positive for single-word static literals whose ABI-encoded value is 0x60. For example, literal(['uint256'], [96n]) is misclassified as a dynamic literal by _hasDynamicPrefix, which causes the encoder to revert with DynamicCallEncoderEmptyDynamic instead of treating it as a static value.
Another example is literal(['address'], ['0x0000000000000000000000000000000000000060']).

} {
mstore(add(dst, i), mload(add(src, i)))
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can use mcopy now, we have to bump the solidity version though

I think something like this will work

assembly {
    let src := add(add(data, 32), start)
    let dst := add(out, 32)
    mcopy(dst, src, len)
}

Comment on lines +32 to +47
function readWord0(bytes memory data) internal pure returns (uint256 result) {
assembly {
result := mload(add(data, 32))
}
}

/**
* @dev Reads the second 32-byte word of a bytes array
* @param data Bytes array to read from
* @return result Second ABI word of `data`
*/
function readWord1(bytes memory data) internal pure returns (uint256 result) {
assembly {
result := mload(add(data, 64))
}
}
Copy link
Copy Markdown
Member

@alavarello alavarello Apr 23, 2026

Choose a reason for hiding this comment

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

NIT: Maybe we can create a readWord(bytes memory data, uint256 index)?

function readWord(bytes memory data, uint256 index) internal pure returns (uint256 result) {
    assembly {
        result := mload(add(data, mul(add(index, 1), 32)))
    }
}

* Commonly used to validate ABI-encoded static values, which must
* end with a zero padding word.
*/
function lastWordIsZero(bytes memory data) internal pure returns (bool) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

NIT: I'm not sure but then maybe we can use readWord(data, data.length) === bytes32(0) directly in the code instead of having a specific function for this.

But it might be better to use this function directly

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do not merge Do not merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants