Meta transactions are a popular way to enable users to transact on a blockchain without directly paying the gas fees. Instead, they sign a message off-chain, which is then relayed by a relayer who pays the gas fees.
Interaction Diagram
The actors of this scheme are:
The basic mechanism is given by the OpenZeppelin’s MinimalForwarder implementation, to be used together with an ERC2771 compatible contract as the Recipient contract.
We will be using the below recipient Contract for our sample project which is an implementation of ERC2771 contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts/metatx/ERC2771Context.sol";
contract Recipient is ERC2771Context {
event FlagCaptured(address previousHolder, address currentHolder, string color);
address public currentHolder = address(0);
string public color = "white";
constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {}
function setFlagOwner(string memory _color) external {
address previousHolder = currentHolder;
currentHolder = _msgSender();
color = _color;
emit FlagCaptured(previousHolder, currentHolder, color);
}
function getFlagOwner() external view returns (address, string memory) {
return (currentHolder, color);
}
}
In order to be able to sign the meta transaction request, JSON structured data has to be created and it should look like:
{
"types": {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}
],
"ForwardRequest": [
{"name": "from", "type": "address"},
{"name": "to", "type": "address"},
{"name": "value", "type": "uint256"},
{"name": "gas", "type": "uint256"},
{"name": "nonce", "type": "uint256"},
{"name": "data", "type": "bytes"}
]
},
"primaryType": "ForwardRequest",
"domain": {
"name": "MinimalForwarder",
"version": "0.0.1",
"chainId": <network_chain_id>,
"verifyingContract": "<forwarder_contract_address>"
},
"message": {
"from": "<user_address>",
"to": "<recipient_contract_address>",
"value": 0,
"gas": 210000,
"nonce": "",
"data": ""
}
}
The above JSON data specifies the EIP712 domain separator and message types for a Meta transaction using the ForwardRequest struct, which has the following fields:
Create a Web3j instance and connect to a node:
Web3j web3j = Web3j.build(new HttpService("<network_http_rpc_endpoint>"));
The minimalForwarder and recipient smart contracts can be compiled using web3j-sokt.
Java wrappers from Solidity smart contracts can be generated using web3j-maven-plugin or web3j-gradle-plugin and these Java classes will be used in the remaining steps.
Load the MinimalForwarder and Recipient contract using respective contract addresses, assuming you have already deployed the MinimalForwarder and recipient contract.
MinimalForwarder minimalForwarder = MinimalForwarder.load("<minimalForwarder_contract_address>", web3j, credentials, new StaticGasProvider(BigInteger.valueOf(4_100_000_000L),BigInteger.valueOf(6_721_975L)));
Recipient recipient = Recipient.load("<recipient_contract_address>", web3j, credentials, new StaticGasProvider(BigInteger.valueOf(4_100_000_000L),BigInteger.valueOf(6_721_975L)));
Define the recipient function and encode it:
final Function recipientFunction = new Function(
"setFlagOwner",
List.of(new org.web3j.abi.datatypes.Utf8String("blue")),
Collections.emptyList()) {
};
String encodedFunction = FunctionEncoder.encode(recipientFunction);
Get the current nonce for the MinimalForwarder contract:
BigInteger nonce = minimalForwarder.getNonce(credentials.getAddress()).send();
Create a ForwardRequest object
MinimalForwarder.ForwardRequest forwardRequest = new MinimalForwarder.ForwardRequest(
credentials.getAddress(),
recipient.getContractAddress(),
BigInteger.ZERO,
BigInteger.valueOf(210000),
nonce,
Numeric.hexStringToByteArray(encodedFunction));
Now read the EIP712 typed structured data json, add the empty values and sign it using Sign.signTypedData()
String jsonMessageString = Files.readString(Paths.get("src/main/resources/data.json").toAbsolutePath());
JSONObject jsonObject = new JSONObject(jsonMessageString);
jsonObject.getJSONObject("message").put("from", credentials.getAddress());
jsonObject.getJSONObject("message").put("nonce", nonce);
jsonObject.getJSONObject("message").put("data", encodedFunction);
String modifiedJsonString = jsonObject.toString();
Sign.SignatureData signature = Sign.signTypedData(modifiedJsonString, credentials.getEcKeyPair());
Get signature in Bytes
byte[] retval = new byte[65];
System.arraycopy(signature.getR(), 0, retval, 0, 32);
System.arraycopy(signature.getS(), 0, retval, 32, 32);
System.arraycopy(signature.getV(), 0, retval, 64, 1);
Now execute the meta transaction and check if output is correct
minimalForwarder.execute(forwardRequest, getSignatureBytes(retval, BigInteger.valueOf(210000)).send();
System.out.println(recipient.color().send()); // returns "blue" color
If you will see the output to the recipient.color().send() it will give “blue” which was the parameter passed through the meta transaction and hence it confirms everything is working as expected.
Meta transactions offer a convenient way for users to interact with a blockchain without worrying about gas fees. By utilising Web3j and the provided example contracts, developers can test meta transaction functionality and implement it in their own dApps.
If you're interested in implementing and experimenting with meta transactions using Web3j, we recommend diving deeper into the provided tutorial and exploring the example contracts. Also familiarise yourself with the Web3j documentation to understand how to interact with Ethereum smart contracts using Java.
References -