diff --git a/cspell/project-words.txt b/cspell/project-words.txt index c1a539d9..36fe905f 100644 --- a/cspell/project-words.txt +++ b/cspell/project-words.txt @@ -147,3 +147,7 @@ Hypernative Hypernative's predeterministic sfrx +GaslessForwarder +ecrecover +signTypedData +ForwardRequest diff --git a/src/pages/build/tutorials/_meta.json b/src/pages/build/tutorials/_meta.json index abdb45ac..3f0e0ec9 100644 --- a/src/pages/build/tutorials/_meta.json +++ b/src/pages/build/tutorials/_meta.json @@ -2,5 +2,6 @@ "deploying-a-smart-contract": "Deploying a Smart Contract", "verify-smart-contract": "Verifying a Smart Contract", "shipping-on-the-superchain": "Shipping on the Superchain", - "deploying-a-superchainerc20": "Deploying a SuperchainERC20" + "deploying-a-superchainerc20": "Deploying a SuperchainERC20", + "gasless-relayer": "Gasless transactions with EIP-712" } diff --git a/src/pages/build/tutorials/gasless-relayer.mdx b/src/pages/build/tutorials/gasless-relayer.mdx new file mode 100644 index 00000000..f8cc512a --- /dev/null +++ b/src/pages/build/tutorials/gasless-relayer.mdx @@ -0,0 +1,538 @@ +# Gasless transactions with a custom EIP-712 relayer + +Most DeFi applications require users to hold ETH before they can do anything. +A gasless relayer removes that requirement: a backend service submits +transactions on behalf of users while the application stays non-custodial. The +user signs a typed message; the relayer pays the gas; the contract verifies the +signature and executes the intent. + +This guide builds a production-ready gasless relayer on Ink using +[EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed structured data +signatures. It requires no smart account infrastructure, and the relayer never +holds user funds. + +import { Callout } from 'nextra/components' + + + Change `chainId` and `RPC_URL` in the `.env` examples to switch between Ink + Sepolia and Ink mainnet. + + +## How it works + +Three participants are involved: the user, the relayer server, and the on-chain +contract. + +1. The user constructs a typed payload describing their intent — for example, + "transfer 100 USDC to address X" — and signs it with `eth_signTypedData_v4`. + No ETH is spent at this point. +2. The signed payload is sent to the relayer's HTTP endpoint. +3. The relayer validates the signature off-chain, then submits the transaction + and pays the gas fee in ETH. +4. The contract recovers the signer from the EIP-712 signature, checks the + nonce to prevent replays, and executes the intent. + +The user never loses custody of their assets. The relayer cannot alter the +payload because the signature covers the full typed struct. + +## Why not ERC-4337 + +ERC-4337 is the right choice when you need full account programmability: session +keys, multi-sig recovery, and so on. It introduces a `UserOperation` mempool, a +global `EntryPoint` contract, and requires users to own a smart contract wallet. + +A custom EIP-712 relayer is narrower. It works with any EOA, has no dependency +on the `EntryPoint`, and is straightforward to audit — the trust surface is one +verifier contract and a nonce mapping. On Ink, where gas is already cheap, the +relayer's per-transaction cost is low enough that this model is financially +viable without bundling. + +## Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) +- Node.js 20.11.0 or higher +- An Ink Sepolia RPC URL (see [RPC](/tools/rpc)) +- A funded relayer wallet (see [Faucets](/tools/faucets)) + +## Step 1: The verifier contract + +Create a new Foundry project: + +```bash +forge init gasless-ink +cd gasless-ink +rm -rf src/Counter.sol test/Counter.t.sol script/Counter.s.sol +``` + +Create `src/GaslessForwarder.sol`: + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +/// @title GaslessForwarder +/// @notice Accepts EIP-712-signed requests and executes them on behalf of users. +contract GaslessForwarder { + bytes32 public constant DOMAIN_TYPEHASH = + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + bytes32 public constant REQUEST_TYPEHASH = + keccak256( + "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)" + ); + + bytes32 public immutable DOMAIN_SEPARATOR; + + mapping(address => uint256) public nonces; + + event RequestExecuted(address indexed from, address indexed to, bool success); + + constructor() { + DOMAIN_SEPARATOR = keccak256( + abi.encode( + DOMAIN_TYPEHASH, + keccak256(bytes("GaslessForwarder")), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } + + struct ForwardRequest { + address from; + address to; + uint256 value; + uint256 gas; + uint256 nonce; + bytes data; + } + + /// @notice Verifies the EIP-712 signature and executes the call. + function execute( + ForwardRequest calldata req, + bytes calldata signature + ) external payable returns (bool success, bytes memory returndata) { + require(nonces[req.from] == req.nonce, "GaslessForwarder: invalid nonce"); + require(verify(req, signature), "GaslessForwarder: invalid signature"); + + nonces[req.from]++; + + (success, returndata) = req.to.call{value: req.value, gas: req.gas}( + abi.encodePacked(req.data, req.from) + ); + + emit RequestExecuted(req.from, req.to, success); + } + + /// @notice Returns true if the signature is a valid EIP-712 signature over `req` by `req.from`. + function verify( + ForwardRequest calldata req, + bytes calldata signature + ) public view returns (bool) { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256( + abi.encode( + REQUEST_TYPEHASH, + req.from, + req.to, + req.value, + req.gas, + req.nonce, + keccak256(req.data) + ) + ) + ) + ); + + (bytes32 r, bytes32 s, uint8 v) = splitSignature(signature); + return ecrecover(digest, v, r, s) == req.from; + } + + function splitSignature( + bytes memory sig + ) internal pure returns (bytes32 r, bytes32 s, uint8 v) { + require(sig.length == 65, "GaslessForwarder: bad signature length"); + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + } +} +``` + +The contract appends `req.from` to calldata before forwarding. Target contracts +that need to identify the original signer should read the last 20 bytes of +`msg.data` rather than using `msg.sender` directly. + +## Step 2: Tests + +Create `test/GaslessForwarder.t.sol`: + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {GaslessForwarder} from "../src/GaslessForwarder.sol"; + +contract MockTarget { + address public lastSender; + uint256 public value; + + function store(uint256 _value) external { + address originalSender; + assembly { + originalSender := shr(96, calldataload(sub(calldatasize(), 20))) + } + lastSender = originalSender; + value = _value; + } +} + +contract GaslessForwarderTest is Test { + GaslessForwarder public forwarder; + MockTarget public target; + + uint256 internal signerKey = 0xA11CE; + address internal signer; + + function setUp() public { + forwarder = new GaslessForwarder(); + target = new MockTarget(); + signer = vm.addr(signerKey); + } + + function _buildRequest(uint256 nonce) + internal + view + returns (GaslessForwarder.ForwardRequest memory req) + { + req = GaslessForwarder.ForwardRequest({ + from: signer, + to: address(target), + value: 0, + gas: 100_000, + nonce: nonce, + data: abi.encodeWithSelector(MockTarget.store.selector, 42) + }); + } + + function _sign(GaslessForwarder.ForwardRequest memory req) + internal + view + returns (bytes memory) + { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + forwarder.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + forwarder.REQUEST_TYPEHASH(), + req.from, + req.to, + req.value, + req.gas, + req.nonce, + keccak256(req.data) + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest); + return abi.encodePacked(r, s, v); + } + + function test_ExecutesAndRecordsOriginalSender() public { + GaslessForwarder.ForwardRequest memory req = _buildRequest(0); + bytes memory sig = _sign(req); + + (bool success, ) = forwarder.execute(req, sig); + assertTrue(success); + assertEq(target.lastSender(), signer); + assertEq(target.value(), 42); + } + + function test_IncrementsNonce() public { + GaslessForwarder.ForwardRequest memory req = _buildRequest(0); + forwarder.execute(req, _sign(req)); + assertEq(forwarder.nonces(signer), 1); + } + + function test_RejectsReplay() public { + GaslessForwarder.ForwardRequest memory req = _buildRequest(0); + bytes memory sig = _sign(req); + forwarder.execute(req, sig); + + vm.expectRevert("GaslessForwarder: invalid nonce"); + forwarder.execute(req, sig); + } + + function test_RejectsTamperedPayload() public { + GaslessForwarder.ForwardRequest memory req = _buildRequest(0); + bytes memory sig = _sign(req); + + req.to = address(0xDEAD); + + vm.expectRevert("GaslessForwarder: invalid signature"); + forwarder.execute(req, sig); + } +} +``` + +Run: + +```bash +forge test -vv +``` + +## Step 3: Deploy + +Create a `.env` file: + +```bash +PRIVATE_KEY=your_relayer_private_key +RPC_URL=https://rpc-gel-sepolia.inkonchain.com/ +BLOCKSCOUT_API_KEY=your_blockscout_api_key +``` + +Create `script/Deploy.s.sol`: + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "../src/GaslessForwarder.sol"; + +contract DeployScript is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + new GaslessForwarder(); + vm.stopBroadcast(); + } +} +``` + +Deploy and verify: + +```bash +source .env + +forge script script/Deploy.s.sol:DeployScript \ + --rpc-url $RPC_URL \ + --broadcast \ + --verify +``` + +Note the deployed contract address — you will need it in the relayer server. + +## Step 4: The relayer server + +The relayer is a Node.js HTTP server. It receives signed requests, validates the +signature off-chain, and submits the transaction to Ink. + +```bash +mkdir relayer && cd relayer +npm init -y +npm install ethers express dotenv +``` + +Create `.env` inside the `relayer` directory: + +```bash +RELAYER_PRIVATE_KEY=your_relayer_private_key +RPC_URL=https://rpc-gel-sepolia.inkonchain.com/ +FORWARDER_ADDRESS=0xYourDeployedForwarderAddress +``` + +Create `relayer/index.js`: + +```js +import "dotenv/config"; +import express from "express"; +import { ethers } from "ethers"; + +const app = express(); +app.use(express.json()); + +const provider = new ethers.JsonRpcProvider(process.env.RPC_URL); +const relayerWallet = new ethers.Wallet(process.env.RELAYER_PRIVATE_KEY, provider); + +const FORWARDER_ABI = [ + "function nonces(address) view returns (uint256)", + "function verify((address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data) req, bytes signature) view returns (bool)", + "function execute((address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data) req, bytes signature) payable returns (bool success, bytes returndata)", +]; + +const forwarder = new ethers.Contract( + process.env.FORWARDER_ADDRESS, + FORWARDER_ABI, + relayerWallet +); + +app.post("/relay", async (req, res) => { + const { request, signature } = req.body; + + if (!request || !signature) { + return res.status(400).json({ error: "Missing request or signature" }); + } + + try { + const valid = await forwarder.verify(request, signature); + if (!valid) { + return res.status(400).json({ error: "Invalid signature" }); + } + + const tx = await forwarder.execute(request, signature, { + gasLimit: BigInt(request.gas) + 50_000n, + }); + + const receipt = await tx.wait(); + return res.json({ txHash: receipt.hash }); + } catch (err) { + console.error(err); + return res.status(500).json({ error: err.message }); + } +}); + +app.listen(3000, () => console.log("Relayer listening on port 3000")); +``` + +Start the server: + +```bash +node index.js +``` + + + The relayer wallet pays all gas fees. Monitor its ETH balance and set up + alerts before deploying to mainnet. On Ink Sepolia you can refill from the + [faucet](/tools/faucets). + + +## Step 5: The client + +The user signs the request with `eth_signTypedData_v4` and posts it to the +relayer. This example uses ethers.js v6. + +```js +import { ethers } from "ethers"; + +const FORWARDER_ADDRESS = "0xYourDeployedForwarderAddress"; +const CHAIN_ID = 763373; // Ink Sepolia; use 57073 for mainnet + +async function sendGasless(provider, targetAddress, encodedData) { + const signer = await provider.getSigner(); + const userAddress = await signer.getAddress(); + + const forwarderAbi = ["function nonces(address) view returns (uint256)"]; + const forwarder = new ethers.Contract(FORWARDER_ADDRESS, forwarderAbi, provider); + const nonce = await forwarder.nonces(userAddress); + + const request = { + from: userAddress, + to: targetAddress, + value: 0, + gas: 100000, + nonce: nonce.toString(), + data: encodedData, + }; + + const domain = { + name: "GaslessForwarder", + version: "1", + chainId: CHAIN_ID, + verifyingContract: FORWARDER_ADDRESS, + }; + + const types = { + 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" }, + ], + }; + + const signature = await signer.signTypedData(domain, types, request); + + const response = await fetch("http://localhost:3000/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request, signature }), + }); + + const { txHash, error } = await response.json(); + if (error) throw new Error(error); + return txHash; +} +``` + +## Recovering the original sender in target contracts + +Because `GaslessForwarder` appends `req.from` to calldata, target contracts +cannot use `msg.sender` to identify users — `msg.sender` is the forwarder's +address. Read the appended address from the tail of `msg.data` instead: + +```solidity +function _msgSender() internal view returns (address sender) { + if (msg.sender == address(forwarder) && msg.data.length >= 20) { + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + sender = msg.sender; + } +} +``` + +This pattern is compatible with OpenZeppelin's `ERC2771Context`. + +## Security + +**Nonce ordering.** The implementation uses a sequential nonce, so a second +transaction cannot execute until the first is confirmed. For concurrent requests +from the same user, consider a 2D nonce scheme similar to +[EIP-2612](https://eips.ethereum.org/EIPS/eip-2612). + +**Gas limit.** The user sets `req.gas` in the signed payload. The relayer adds +50,000 gas to cover forwarder overhead. If the inner call runs out of gas, +`execute` returns `success = false` rather than reverting — handle this in your +application. + +**Relayer key management.** Store `RELAYER_PRIVATE_KEY` in a secrets manager +(AWS Secrets Manager, HashiCorp Vault, or similar) before going to mainnet. +Never commit it to a repository. + +**Rate limiting.** The example server has no rate limiting. Add a per-IP or +per-user limit before exposing the endpoint publicly. + +**Signature malleability.** `ecrecover` is not protected against signature +malleability. If your application requires strict uniqueness of signatures +rather than nonces, use +[OpenZeppelin's `ECDSA` library](https://docs.openzeppelin.com/contracts/5.x/api/utils#ECDSA). + + + This guide uses Ink Sepolia (chain ID 763373). For mainnet, use chain ID + 57073 and RPC URL `https://rpc-gel.inkonchain.com/`. + + +## Next steps + +- [Transaction fees](/build/transaction-fees) — understand how L1 data fees + affect your relayer's cost model +- [Account abstraction](/tools/account-abstraction) — ZeroDev and Safe both + support Ink mainnet for full smart account features +- [Verifying a smart contract](/build/tutorials/verify-smart-contract) — + verify `GaslessForwarder` on Blockscout after deployment