EVM: Add support for dynamic call intents#52
EVM: Add support for dynamic call intents#52facuspagnuolo wants to merge 18 commits intoevm/refactor-intents-with-operationsfrom
Conversation
| */ | ||
| struct DynamicCallOperation { | ||
| uint256 chainId; | ||
| bytes[] calls; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
| * @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) { |
There was a problem hiding this comment.
_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:
| /** | ||
| * @dev Detects the dynamic pre-encoding prefix used by abi.encode("", value) | ||
| */ | ||
| function _hasDynamicPrefix(bytes memory argument) private pure returns (bool) { |
There was a problem hiding this comment.
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))) | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
}
| 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)) | ||
| } | ||
| } |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
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:
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.