Background:
(Note: this tutorial assumes relative familiarity with the Gas Stations Network and OpenZeppelin SDK)
Introduction:
Using GSN allows developers to build (d)apps that don’t require end users to have ETH or even have their own wallet. It does this by allowing for a GSN enabled smart contract to pay for its users transactions itself. The question then of course is: how does a smart contract determine who is allowed to have their transactions subsidized? If there is no criteria, it would be trivial for a user in bad faith to exploit a (d)app and obtain unlimited free transactions.
OpenZeppelin provides two sample strategy contracts which can be used to help (d)app developers decide who their recipient contacts are willing to pay for. The focus of this tutorial is GSNRecipientSignature.sol, which provides an enhanced acceptRelayedCall()
function which requires that a trustedKey
sign the transaction request parameters in order for the relay request to be accepted. Before starting this tutorial, I recommend reading more about strategies and their purpose as well as how to developers can create meta-transaction powered (d)apps using the GSN.
What we are building:
This will be a multi-part tutorial explaining the process to:
- Incorporate the
GSNRecipientSignature.sol
contract into our GSN enabled smart contract. - Build an Express server as a backend that signs transactions in a format accepted by the
GSNRecipientSignature.sol
contract. - Modify our front end application so that our Express server is required to sign messages prior to the GSN accepting our users transactions.
To try and keep the length of this tutorial to a minimum, we will be using the OpenZeppelin GSN Starter Kit as a basis for starting our project. Starter Kits are a fantastic way to get up and running quickly with a complete (d)app in a standardized and convenient way. This tutorial will assume the reader has completed the GSN Starter Kit tutorial and has the application running.
Part 1: GSNRecipientSignature.sol
To use the GSNRecipientSignature.sol
we need only to inherit it into our contract. If we take a look at the GSNRecipientSignature.sol
code, we should take a close look at the acceptRelayedCall()
which is essentially “where the magic happens” as this function determines if our contract will accept or deny a relayed call (and pay the resulting cost).
function acceptRelayedCall(
address relay,
address from,
bytes calldata encodedFunction,
uint256 transactionFee,
uint256 gasPrice,
uint256 gasLimit,
uint256 nonce,
bytes calldata approvalData,
uint256
)
external
view
returns(uint256, bytes memory)
{
bytes memory blob = abi.encodePacked(
relay,
from,
encodedFunction,
transactionFee,
gasPrice,
gasLimit,
nonce, // Prevents replays on RelayHub
getHubAddr(), // Prevents replays in multiple RelayHubs
address(this) // Prevents replays in multiple recipients
);
if (keccak256(blob).toEthSignedMessageHash().recover(approvalData) == _getTrustedSigner()) {
return _approveRelayedCall();
} else {
return _rejectRelayedCall(uint256(GSNRecipientSignatureErrorCodes.INVALID_SIGNER));
}
}
Here the function takes in a number of parameters including:
bytes calldata approvalData
This approvalData
is a signed version of all of these parameters put together, signed by what is call the trustedSigner
. In the body of the function the parameters are abi.encodePacked()
and then hashed together. The result is then used to recover the signer of the approvalData
which is compared with the result of the call _getTrustedSigner()
. If the address that _getTrustedSigner()
returns equals the recovered signature from the encoded and hashed parameters, then the contract knows our trustedSigner
has signed off on this transaction and returns _approveRelayedCall()
likewise, if the signatures do not match, the function returns _rejectRelayedCall
with the failure of the signatures to match as the parameter.
Getting Started:
Be sure you have working the OpenZeppelin GSN Starter Kit, we will start from it as a base. We have a tutorial on the OpenZeppelin forum on how to get started with the GSN Starter Kit.
Add the GSNSignatureRecipient.sol
Once you have completed the tutorial you should see this screen where you have a deployed counter instance that you can interact with by clicking the Increase or Decrease Counter buttons.
Here we can start making some modifications. First, the tutorial is running in the GSN Developer mode, you can find this around line 33 of App.js
in the /client/src
folder.
The dev:true
enables a special developer mode for OpenZeppelin-network
that allows you to continue developing and testing a GSN powered experience without needing to run a full relayer. It’s great for testing and front end development, but for our project, we are going to need to run a full GSN relayer as well as deploy a relayHub
to our development blockchain Ganache.
If you have completed the tutorial we will need to start fresh. Restart your ganache so we have a fresh blockchain before running a Relayer.
Run a Relayer
At OpenZeppelin we make some fantastic tools to support the GSN Network, in this case we’re going to use the OpenZeppelin-gsn-helpers which allow us to do many different typical development tasks such as deploy a relayHub
, run a Relayer, register a Relayer and more.
First install the gsn-helpers
:
npm install @openzeppelin/gsn-helpers
Now we need to deploy a live relayHub
, run a Relayer, and then register our Relayer in the relayHub
. While we can do each of these steps individually with the gsn-helpers
running the command run-relayer
will take care of all these steps at once. In a new terminal window in your project folder, (with ganache running, after you restarted it, in another terminal) type:
npx oz-gsn run-relayer --quiet
You should see output similar to the following:
Starting relayer
/Users/dennison/Library/Caches/gsn-nodejs/gsn-relay-v0.1.4
-EthereumNodeUrl [http://localhost:8545](http://localhost:8545/)
-RelayHubAddress 0xd216153c06e857cd7f72665e0af1d7d82172f494
-Port 8090
-Url [http://localhost:8090](http://localhost:8090/)
-GasPricePercent 0
-Workdir /var/folders/pf/8knbxmfd6n3_cn5glssbp5580000gn/T/tmp-17513m3fru4uoIQW8
-DevMode
Funding GSN relay at [http://localhost:8090](http://localhost:8090/)
Will wait up to 30s for the relay to be ready
Relay is funded and ready!
Great. Now we need to redeploy the GSN Starter Kit contract counter.sol
remembering to call initialize()
at the end of the process.
oz create
And follow the cli
prompts:
If you still have your client server running from the tutorial, now is also a good time to restart it. Once you do, you should see this screen again which informs you that the recipient has no funds.
This time when we fund the recipient, we will be funding our smart contract counter.sol
on the relayHub
we deployed manually with the gsn-helpers
. Go ahead and fund your recipient.
npx oz-gsn fund-recipient --recipient <<Your contract address here>>
Once you’ve done this, refresh the tutorial webpage and ensure the buttons work again. This will be our demonstration setup.
Part 2: Alter the counter.sol
contract
Now that we’ve got our demo-setup running we can start with the next steps.
Update Contract
On our counter.sol
contract we need to alter the acceptRelayedCall()
function to use the GSNSignatureRecipient
provided acceptRelayedCall()
. This will enable our counter.sol
contract to check if transactions have been signed by our ‘yet to be built’ express server.
At the top of our contract we need to import the GSNSignatureRecipient
from the @openzeppelin/contracts-ethereum-package/
and inherit from it:
pragma solidity ^ 0.5.0;
import "@openzeppelin/contracts-ethereum-package/contracts/GSN/GSNRecipientSignature.sol";
import "@openzeppelin/upgrades/contracts/Initializable.sol";
contract Counter is Initializable, GSNRecipientSignature {
function initialize(uint num, address trustedSigner)
public initializer {
GSNRecipientSignature.initialize(trustedSigner);
_owner = _msgSender();
_owner = msg.sender;
count = num;
}
// omitted for brevity
We can also now comment out (or delete) the acceptRelayedCall()
listed in the Counter.sol
code because the acceptRelayedCall()
function we want is inherited from GSNRecipientSignature.sol
, but we also need to be sure we properly initialize our new GSNRecipientSignature.sol
with a trustedSigner
which will be the address our Express server uses to sign off on transactions.
contract Counter is Initializable, GSNRecipientSignature {
//it keeps a count to demonstrate stage changes
uint private count;
address private _owner;
function initialize(uint num, address trustedSigner) public initializer {
GSNRecipientSignature.initialize(trustedSigner);
_owner = _msgSender();
count = num;
}
// // accept all requests
// function acceptRelayedCall(
// address,
// address,
// bytes calldata,
// uint256,
// uint256,
// uint256,
// uint256,
// bytes calldata,
// uint256
// ) external view returns (uint256, bytes memory) {
// return _approveRelayedCall();
// }
Part 3: Build our Express Server
Now that we have our updated Counter.sol
contract ready to relay only signed transactions, we need to get to work on building our Express Server to sign them!
First, install Express
npm install express --save
Create a new folder in our project called signServer
mkdir signServer && cd signServer
The goal of our server will be to create an API that accepts a list of parameters, signs them, and then returns the signature back to the front end. When users interacting with a (d)app want to make a transaction, they will first need to send all the parameters to our Server for signing, and then submit the signed parameters to a Relayer.
While there are quite a number of steps for this, we will get to all of them in good time. Lets start by creating our Express Server:
create a new index.js
file and add the following code:
require("dotenv").config();
const port = process.env.PORT || 3000;
const express = require("express");
const asyncHandler = require("express-async-handler");
const app = express();
const { signMessage } = require("./utils/signUtils.js");
app.use(express.json());
app.post("/checkSig", asyncHandler(async (req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "localhost:3001");
const obj = req.body;
let result;
try {
result = await signMessage(obj);
} catch (error) {
console.log(error);
} if (result) {
return res.status(200).json(result);
} else {
return res.status(400).send();
}
}));
var server = app.listen(3001, function () {
var port = server.address().port;
console.log("Example app listening at port %s", port);
});
module.exports = server;
Lets install the dependencies for this file:
npm install dotenv express-async-handler
So lets walk through the code:
require("dotenv").config();
const port = process.env.PORT || 3000;
const express = require("express");
const asyncHandler = require("express-async-handler");
const app = express();
const { signMessage } = require("./utils/signUtils.js");
Here we are mostly just requiring in our dependencies. If you noticed the line const { signMessage } = require("./utils/signUtils.js");
it’s because to keep things clean, the signing code will be separate from our Server code. We will need to make this.
The next section we setup Express to natively handle JSON with app.us(express.json()
. Then app.post("/checkSign"...
handles our post route /checkSig.
This is the route we are going set for our API. When a user hits our /checkSig
route, we create an object from the request object which contains our parameters sent from the front end. We then pass it in our yet-to-be-create signMessage()
function which will returned a signed result. If the result is valid, we append a status 200
to our res
object and send it back with our result
appended as JSON. If we don’t get a valid signature back from our function, we append a status 400
to our res
object and send that back instead.
app.use(express.json());
app.post("/checkSig",asyncHandler(async (req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "localhost:3001");
const obj = req.body;
let result;
try {
result = await signMessage(obj);
} catch (error) {
console.log(error);
}
if (result) {
return res.status(200).json(result);
} else {
return res.status(400).send();
}
}));
Finally we start our server on port 3001 and export our server
. This export we don’t need to worry about here, we need it when running tests on our server.
var server = app.listen(3001, function() {
var port = server.address().port;
console.log("Example app listening at port %s", port);
});
module.exports = server;
Great, so now that we have our server, let’s build our signing utility.
Create a new folder in your signServer
folder:
mkdir utils && cd utils
Create a file inside utils
called signUtils.js
and add the following code:
const Web3 = require("web3");
const { utils: { toBN, soliditySha3 } } = require("web3");
const web3 = new Web3("ws://localhost:8545");
function fixSignature(signature) {
let v = parseInt(signature.slice(130, 132), 16);
if (v < 27) {
v += 27;
}
const vHex = v.toString(16);
return signature.slice(0, 130) + vHex;
}
const signMessage = async data => {
let accounts = await web3.eth.getAccounts();
return fixSignature(await web3.eth.sign(soliditySha3(
data.relayerAddress,
data.from,
data.encodedFunctionCall,
toBN(data.txFee),
toBN(data.gasPrice),
toBN(data.gas),
toBN(data.nonce),
data.relayHubAddress,
data.to),
accounts[0]));
};
module.exports = { signMessage };
First up, you’ll notice at the top we are requiring in web3
so lets go ahead and install it.
npm install web3
One we require in web3
we’re then creating an instance of it, connected to ganache (or in production- your ethereum node). We are also pealing out the functions toBN
, and soliditySha3
from the web3.utils
as a convenience.
const Web3 = require("web3");
const { utils: { toBN, soliditySha3 } } = require("web3");
const web3 = new Web3("ws://localhost:8545");
The next chunk of code basically fixes the signatures we get back when using Ganache as our ethereum node so they are not replay-able. To learn more why, read here.
function fixSignature(signature) {
let v = parseInt(signature.slice(130, 132), 16);
if (v < 27) {v += 27;}const vHex = v.toString(16);
return signature.slice(0, 130) + vHex;
}
The last part is where the magic happens. Here we are assuming that our trusted signer is the first account in our list of accounts from our Ganache instance. This is for convenience, in production you will want to make some decisions about who the signer is, and rather than instantiate your web3
provider via a connection to ganache, you will want to connect to a real ethereum node with an unlocked account.
Our function signMessage()
first hashes all of our parameters together at soliditySha3()
, the result of which is signed by web3.eth.sign()
. The resulting signature is then returned by signMessage()
. We then export our signMessage()
function so that we can require it in for our Express server.
const signMessage = async data => {
let accounts = await web3.eth.getAccounts();
return fixSignature(await web3.eth.sign(soliditySha3(
data.relayerAddress,
data.from,
data.encodedFunctionCall,
toBN(data.txFee),
toBN(data.gasPrice),
toBN(data.gas),
toBN(data.nonce),
data.relayHubAddress,
data.to),
accounts[0])
);
};
module.exports = { signMessage };
Great, so we have all the pieces we need, but at this point it’s hard to know if what we have built is actually working without creating the full front end. Before we do that, lets be responsible programmers and create some tests:
Part 4: Test our Express Server
Create a new folder test
in the project folder if there isn’t already one.
mkdir test && cd test
Here create a file called, unimaginatively, expressServer.test.js,
we will put our testing code here.
If you’re not so familiar with testing, then the following might seem like a lot of information to take in all at once. Basically, to run unit tests against an Express server we need to create a new server for each test, run our tests, and then shut down our server before creating a new one again for our next test. This is easier said than down, and in writing this tutorial I relied heavily on this tutorial by Gleb Bahmutov for reference. I highly recommend it.
Here is the code for our tests, copy and paste it into your code editor for much better formatting than I can get here on Medium.
const request = require("supertest");
const Web3 = require("web3");
const chai = require("chai");
const { utils: { toBN } } = require("web3");
const web3 = new Web3("ws://localhost:8545");
const { signMessage } = require("../signServer/utils/signUtils.js");
const expect = chai.expect;
const mochaAsync = fn => {
return done => {
fn.call().then(done, err => {
done(err);
});
};
};
describe("loading express", async () => {
let server;
let accounts;
let relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494";
let relayerAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494";
let to = "0xD216153c06E857cD7f72665E0aF1d7D82172F494";
let from;
let encodedFunctionCall = "0xe0b6fcfc";
let txFee = toBN(10);
let gasPrice = toBN(10);
let gas = toBN(10);
let nonce = toBN(10);
beforeEach(() => {
delete require.cache[require.resolve("../signServer/index.js")];
server = require("../signServer/index.js");
});
afterEach(done => {
server.close(done);
});
it("Signs a message", mochaAsync(async () => {
accounts = await web3.eth.getAccounts();
from = accounts[1];
//SIGN MESSAGE LOCALLY TO COMPARE SIGNATURES
let testSig = await signMessage({ relayerAddress, from, encodedFunctionCall, txFee, gasPrice, gas, nonce, relayHubAddress, to });
let res = await request(server).post("/checkSig").send({ relayerAddress, from, encodedFunctionCall, txFee, gasPrice, gas, nonce, relayHubAddress, to });
expect(res.status).to.equal(200); expect(res.body).to.equal(testSig);
}));
});
So lets take this bit by bit:
const request = require("supertest");
const Web3 = require("web3");
const chai = require("chai");
const { utils: { toBN } } = require("web3");
const web3 = new Web3("ws://localhost:8545");
const { signMessage } = require("../signServer/utils/signUtils.js");
const expect = chai.expect;
In our first chunk of code we are requiring in our dependencies, in this case: web3
, supertest
, and chai
. We should already have web3
installed from earlier, so lets get the others plus mocha
a testing framework.
npm install supertest chai mocha --dev
Our first dependency, supertest
will make testing against our express server easier, because it helps us to make calls to our express server over a HTTP connection while chai
is a common BDD / TDD assertion library that is often paired with the testing frame work mocha
.
Additionally in this chunk of code we create a new web3
instance and require in the signMessage()
function we created in our /utils
folder.
const mochaAsync = fn => {
return done => {
fn.call().then(done, err => {
done(err);
});
};
};
This mochaAsync()
function allows us to do async actions inside our tests while still properly calling the done()
function that Mocha needs to properly complete each test.
describe("loading express", async () => {
let server;
let accounts;
let relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494";
let relayerAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494";
let to = "0xD216153c06E857cD7f72665E0aF1d7D82172F494";
let from;
let encodedFunctionCall = "0xe0b6fcfc";
let txFee = toBN(10);
let gasPrice = toBN(10);
let gas = toBN(10);
let nonce = toBN(10);
Here at the top of our describe()
block we are just setting some variables. With the exception of server
, accounts
, and from
, the actual value is not so important as we are only using them to craft a message. We are going to be writing only a single test- to check that the express server sends us back a signed message and that this message matches our own signature, so the contents of the message, for now, are not so important.
beforeEach(() => {
delete require.cache[require.resolve("../signServer/index.js")];
server = require("../signServer/index.js");
});
afterEach(done => {
server.close(done);
});
In this chunk of code, we are doing something very interesting. In our beforeEach()
we are purging a reference to our index.js
file which is our express server. Otherwise, we won’t be able to destroy and recreate our express server for each test: Node will keep a cached reference to our server instead of creating a new one each time.
it("Signs a message", mochaAsync(async () => {
accounts = await web3.eth.getAccounts();
from = accounts[1];
//SIGN MESSAGE LOCALLY TO COMPARE SIGNATURES
let testSig = await signMessage({
relayerAddress, from, encodedFunctionCall, txFee, gasPrice, gas, nonce, relayHubAddress, to
});
let res = await request(server).post("/checkSig").send({
relayerAddress, from, encodedFunctionCall, txFee, gasPrice, gas, nonce, relayHubAddress, to
});
expect(res.status).to.equal(200);
expect(res.body).to.equal(testSig);
}));
});
Finally, we get to the test itself. As mentioned before, we are checking only that our express server actually signs a message and returns the signature. Ideally we would want to check that this signature is also valid in the context of the GSN, but this tutorial is getting too long as it is. Instead you should have a look at the tests used in the openzeppelin-contracts
package itself as an idea how to take your testing further.
In the test we first construct our own signature using our imported signMessage()
function and assign it’s value to testSig
. We then make a request to our server, posting to the /checkSig
route, the same parameters we just used to sign a message for testSig
. We check that the status returned equals 200
(this means, everything okay) and that the res.body
equals our testSig
.
Finally we will need to be sure that we have mocha
setup in our package.json
scripts. Open the package.json
file at the top level of your project and check for this line:
"test": "echo \"Error: no test specified\" && exit 1",
and change it to:
"test": "mocha",
If all has gone well, we can test our test! From the top level folder of our project:
npm run test test/expressServer.test.js
And we should see:
Great!
Part 5: Build our Front End
Building a Front end is no small task, luckily for us we are going to reuse the front end that is included with the GSN Starter kit to try and minimize the amount of work we need to do to get our Signing Server wired up to our React App.
Before we start writing code however, let’s talk a little bit more about how GSN works using OpenZeppelin tools on the front end.
If you’ve looked through the front end code for GSN Starter Kit you will see that we are using the OpenZeppelin toolset: openzeppelin-network.js
which is used to setup our web3
provider. Deeper in the stack openzeppelin-network.js
uses openzeppelin/gsn-provider
to allow us to instantiate a web3
network connection with GSN support. I recommend checking out both repos on GitHub for a deeper understanding of how they work.
Let’s take a look again at our GSNRecipientSignature.sol
file to understand a bit better about how the signature from our express server is used in the smart contract.
You can see that the approvalData
( approvalData
is our signature that our express server is creating for us) on line 9 is passed in when we call the acceptRelayedCall()
function. However we the user , never call this function, it is called as part of the transaction flow initiated by Relayers. The question then you might be wondering then is, “how do the Relayers get it?” if we look at the front end code (in this case, /client/src/components/Counter/index.js
line 74, the component that increments our counter) you can see that our contract call from the front end looks the same like a regular contract call.
Our contract call on line 6 looks normal- how do we send approval data?
What needs to be done is that we need to inject our approvalData
into the tx flow so that when our GSN enabled call is intercepted by openzeppelin-gsn-provider
and sent to a Relayer, the approvalData
is included.
Fortunately, openzeppelin-gsn-provider
provides the option to inject an approvalFunction
which then returns the injected approval data into the tx flow for us. In the documentation the approvalFunction()
works to sign the data locally, in our case, we will build an approvalFunction()
that calls out to our express server for the signed data.
Injecting an approvalFunction
can be done at the time we setup our web3
connection like this:
const gsnProvider = new GSNProvider("http://localhost:8545", { approveFunction });
Or it can be added to each function call that you want to have parameters signed like so:
const receipt = await instance.methods.increaseCounter(number).send({
from: accounts[0], approveFunction: approveFunction ,
});
So let’s build our approveFunction()
.
First install axios
which we will need to make our request to our express server.
npm install axios
And then include it somewhere at the top of our App.js
file in the /client/src
directory with the imports.
import axios from 'axios';
Next is our approveFunction()
const approveFunction = async ({
from, to, encodedFunctionCall, txFee, gasPrice, gas, nonce, relayerAddress, relayHubAddress,
}) => {
let response;
try {
response = await axios.post('http://localhost:3001/checkSig', {
from, to, encodedFunctionCall, txFee, gasPrice, gas, nonce, relayerAddress, relayHubAddress,
});
} catch (error) {
console.error(error);
}
console.log(response.data);
return response.data;
};
Here our approveFunction
takes in our parameters, then it uses axios
to post JSON data with these parameters to our express server, which in turn signs them and returns them on our response
object as data.
Add this approveFunction
above where we declare context
and add it to the gsn
object after signKey
.
Great!
Part 6: Tie it all Together (and make it work)
So now we should have everything together and theoretically working. Lets try it out!
First to give us a clean slate, lets kill our relay-server, our ganache, express server, our front end, etc.. to start fresh.
Starting over, lets first start ganache in it’s own terminal window:
ganache-cli --deterministic
Then deploy our relay-hub and start our relayer. In another terminal window (inside our project)
npx oz-gsn run-relayer --quiet
In another terminal window, inside our /client
folder, lets run our react server.
npm run start
In yet ANOTHER terminal window from your project folder run the express server.
node signServer/index.js
And in our FINAL terminal window, we deploy our counter.col
contract and initialize it with the address of our trustedSigner
. If you remember, our express server is signing messages using the accounts[0]
, the first account available from our ethereum node, which in this case, is ganache. If you don’t have that handy, it’s easy to use the OpenZeppelin CLI to get this.
oz accounts
Select development
as the network and copy the first account at the top of the list.
Now we need to deploy our contract:
oz create
Follow the prompts and at the point were it asks you if you want to call a function, select “yes” and choose the initialize()
function that takes both a number and an address for trustedSigner
, enter a number and the address you obtained from the top of the list from oz accounts
.
Moving to your web browser visit the tutorial website, localhost://3000 it should be loaded, recognize the contract has been deployed and be prompting you that the recipient has no funds in the relayHub
.
(you’re address might be different so copy/paste from your instance)
Fund the recipient, refresh your browser and test your counter.
Tada! It doesn’t work!
There is one more step here necessary in development. Your browsers CORS policy will most likely be blocking access to the Express server and you’ll see a message like this in your console:
To get around this, I use a Chrome extension that allows me to turn CORS off. The internet has plenty of opinions about what the best way is to deal with CORS when doing development, but for me this is the simplest.
So, thank you for coming with me on this voyage of a tutorial. It’s been long, but hopefully worth it. Lets recap what we have learned:
- Using the GSN Starter Kit, we have modified a stock GSN enabled application to require external signatures before accepting transactions.
- We have built an API using an Express server to sign transactions.
- We have created an
approvalFunction
which calls out to our API to get signed approvals which then get forwarded to our GSN Relayers for our transactions.
While this is just the basics, by building on our express server we can build complicated and secure methods for tracking our users, understanding who they are, and selectively allowing them to interact with (d)apps without needing to have ETH or even a wallet.