The question is a little how custom do you want to have it. In general I would start from the contract side.
It is not really required that you use Solidity function selectors. This is primarily a standard that allow easy reusability with different libraries and contracts.
You can use the fallback method to have custom method handling with Solidity:
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;
contract Bytecode {
fallback(bytes calldata data) external returns (bytes memory) {
if (bytes4(data) == 0x0000dead) {
return "Dead";
}
return "Alive";
}
}
This can be extended to also use custom parameter encoding via array slices. Lets assume we want to use packed encoding to optimize on calldata size:
contract Custom {
fallback(bytes calldata data) external returns (bytes memory) {
if (bytes4(data) == 0x0000dead) {
uint8 hasAddress = uint8(bytes1(data[4:5]));
address someAddress = address(bytes20(data[5:25]));
if (hasAddress > 0) {
return abi.encode(someAddress);
}
return "Dead";
}
return "Alive";
}
}
This contract can now be called using the Solidity packed encoding.
Example how to call it in Solidity:
(bool success, bytes memory data) = custom.call(abi.encodePacked(bytes4(0x0000dead), true, this));
Example how to call it via Ethers provider:
const data = await provider.call({
to: "<your_contract_address>",
data: ethers.utils.solidityPack(["bytes4", "boolean", "address"], ["0x0000dead", true, "0xE5f2A565Ee0Aa9836B4c80a07C8b32aAd7978e22"])
})
These calls return you raw data and to handle the response you still need to decode it.
Example how to decode it in Solidity:
(address response) = abi.decode(data, (address));
Example how to call it via Ethers AbiCoder:
const [response] = ethers.utils.defaultAbiCoder.decode([ "address" ], data);
You can do all kinds of magic with this approach. BUT this is very low level and in many cases will only lower your calldata gas costs. A lot of gas costs is not caused by the calldata encoding, but by checks enforced by Solidity on the calldata and general variables.
Some of these checks are:
- Bounds checks for dynamic length types such as
arrays
and bytes
- Masking of elementary types that don't use the full 32 bytes, such as
address
or boolean
- Overflow checks for mathematical operations.
These checks improve the security of your contracts and prevent that unexpected flows can be exploited.
Some of these checks can be disabled with an unchecked
block. But if you want to fully optimize it you have to use YUL/assembly.
For an example of custom parameter encoding using YUL you can take a look at the MultiSend contract from the Safe team. There it also uses packed encoding and you can see the example code how to perform the encoding for the data with Ethers in the multisend utility module
eth.sendTransaction
oreth.call
. Unfortunately you won't be able to use the wrapperweb3.eth.Contract
since it assumes the contract accepts and returns ABI encoded values.