Simple Integration Walkthrough

Ori Pomerantz — qbzzt1@gmail.com

Introduction

Ethereum transactions cost gas, which means your dapp’s users cannot initiate them unless they already have some ether. Onboarding new users into Ethereum is a problem. GSNv2 provides a solution by allowing a different entity, either you or a third party, to pay for transactions.

In this article you learn how to accept transactions that are paid for by somebody other than the sender, how to sponsor transactions, and how to write a user interface that uses GSNv2.

Converting a Contract to Support GSNv2

To accept transactions that are paid for by a separate entity you have to do several things:

  1. If necessary modify your configuration file (in truffle, truffle.js or truffle-config.js) to require Solidity version 0.6.10 or higher:

    module.exports = {
      networks: {
        .
        .
        .
      },
      compilers: {
        solc: {
          version: "^0.6.10"
       }
     }
    };
  2. Add @opengsn/gsn in the dependencies, version 2.0.0-beta.1.3 or higher.

    npm install @opengsn/gsn@"^2.0.0-beta.1.3" --save
  3. Import two solidity files:

    • @opengsn/gsn/contracts/BaseRelayRecipient.sol

    • @opengsn/gsn/contracts/interfaces/IKnowForwarderAddress.sol

  4. Inherit from BaseRelayRecipient and IKnowForwarderAddress.

  5. Create a constructor that sets trustedForwarder to the address of a trusted forwarder. The purpose is to have a tiny (and therefore easily audited) contract that proxies the relayed messages so a security audit of the GSN aware contract doesn’t require a security audit of the full RelayHub contract. You can look here to see the addresses to use on the Kovan and Ropsten test networks.

  6. Create a versionRecipient() function to return the current version of the contract.

  7. Create a getTrustedForwarder() function to return the value of trustedForwarder.

  8. Create a setTrustedForwarder(address) function to set the value of trustedForwader.

  9. Replace msg.sender in your code, and in any libraries your code uses, with _msgSender(). If you receive a normal Ethereum transaction from an entity that pays for its own gas, this value is identical to msg.sender. If you receive an etherless transaction, _msgSender() gives you the correct sender whereas msg.sender would be the above forwarder.

Example: CaptureTheFlag

As a demonstration, here is an extremely simple capture the flag game that, when called, captures the flag and emits an event with the old and new holders.

pragma solidity ^0.6.10;

// SPDX-License-Identifier: MIT OR Apache-2.0

import "@opengsn/gsn/contracts/BaseRelayRecipient.sol";
import "@opengsn/gsn/contracts/interfaces/IKnowForwarderAddress.sol";

contract CaptureTheFlag is BaseRelayRecipient, IKnowForwarderAddress {

	event FlagCaptured(address _from, address _to);

	address flagHolder = address(0);

	constructor(address forwarder) public {
		trustedForwarder = forwarder;
	}

	function getTrustedForwarder() public override view returns(address) {
		return trustedForwarder;
	}

	function setTrustedForwarder(address forwarder) internal {
		trustedForwarder = forwarder;
	}

	function captureFlag() external {
		address previous = flagHolder;

                // The real sender. If you are using GSNv2, this
                // is not the same as msg.sender.
		flagHolder = _msgSender();

		emit FlagCaptured(previous, flagHolder);
	}

	function versionRecipient() external virtual view
	override returns (string memory) {
		return "1.0";
	}
}

How does it Work?

Obviously, blockchain access is still not free. Your smart contract gets these GSNv2 transactions with the help of two entities. The user’s application talks to a relay server, one of a number of servers that offer to send messages into the chain. The relay then talks to a paymaster, a contract that decides which transactions to sponsor based on the sender, the target contract, and possibly additional information.

Paymasters are contracts, so they are always available, similar to any other Ethereum contract. Relays are internet sites which get paid by paymasters for their services. Running a new relay is cheap and easy (see directions here). We expect that anybody who opens a dapp for relayed calls will also set up a relay or two, so there will be enough that they can’t all be censored.

Note that everything the relays do is verified. They cannot cheat, and if a relay attempts to censor a client at most it can delay the message by a few seconds before the client selects to go through a different relay.

paymaster needs gas

To know what relays are available the dapp consults a special contract called RelayHub. This hub also checks up on relays and paymasters to ensure nobody is cheating. You can read more about it here.

Creating a Paymaster

You might want to create a paymaster to sponsor transactions that users pay for by other means, or that are in your interest to accept (for example, user onboarding). In this section you learn how to create a paymaster to accomplish this.

Complete documentation of how to write a paymaster is beyond the scope of this tutorial, you can read about it here. For the purpose of this tutorial, I am going to present a simple paymaster that accepts all requests to a specific contract, and nothing else. This can be an onboarding contract, which calls other contracts.

You can see the complete code here. Look below for a line by line explanation.

pragma solidity ^0.6.10;
pragma experimental ABIEncoderV2;

import "@opengsn/gsn/contracts/BasePaymaster.sol";

All paymasters inherit from BasePaymaster. That contract handles getting deposits, ensuring functions are only called by the relay hub, and so on.

This paymaster is naive because it is not a secure implementation. It can be blocked by sending enough requests to drain the account. A more sophisticated paymaster would use captcha or maybe hashcash.
contract NaivePaymaster is BasePaymaster {

This variable holds the one target contract we are willing to pay for.

    address public ourTarget;

When the owner sets a new target, we want to emit an event to inform the world about it.

    event TargetSet(address target);

This function modifies the target contract we are willing to be a paymaster for. We can use onlyOwner because BasePaymaster inherits from Ownable.

    function setTarget(address target) external onlyOwner {
        ourTarget = target;
        emit TargetSet(target);
    }

These events are emitted before and after the relayed call.

    event PreRelayed(uint);
    event PostRelayed(uint);

This function decides whether to pay for a transaction or not. The GNSType.RelayRequest type is defined here and here. It includes multiple fields - we’ll use the .to, which is the target contract. The signature can be used to validate the relayRequest value.

    function preRelayedCall(
        GsnTypes.RelayRequest calldata relayRequest,
        bytes calldata signature,

The approval data is sent by the web client through the relay. It can include any data from the dapp that the paymaster needs to decide whether to approve a request or not.

        bytes calldata approvalData,

This parameter can be used, in conjunction with relayHub.calculateCharge(), to calculate the cost a transaction would incur. Using it is beyond the scope of this basic tutorial.

        uint256 maxPossibleGas

The context that the function returns is shared with postRelayedCall. The revertOnChain variable identifies the behavior in case the recipient contract reverts. If it is true any revert is done on chain and the paymaster still pays for it. Otherwise reverts happen off chain, and are costless.

    ) external override returns (bytes memory context,
                                 bool revertOnChain) {

First, the paymaster verifies that the message came from the approved forwarder. The next line just prevents some warning messages from the compiler.

        _verifyForwarder(relayRequest);
        (signature, approvalData, maxPossibleGas);

This paymaster is naive, but not a complete sucker. It only accepts requests going to our service. This is the way that preRelayedCall returns a rejection - either by failing a require or by explicitly calling revert. If we return from this function normally it means that the paymaster is committed to paying for the transaction.

        require(relayRequest.request.to == ourTarget);

We can return anything here, but for now we’ll just return the time. We want something that the post-processing can emit and we’ll be able to match with the pre-processing.

This is not necessary. The pre and post processing are part of the same transaction, so we could match the pre- and post-processing using the txid. However, I wanted to have a trivial example of using the context here.
        emit PreRelayed(now);
        return (abi.encode(now), false);

This function is called after the relayed call. At this point the cost of the request is almost known (with the exception of the gas cost of postRelayedCall itself), and we can do any accounting we need, charge entities, etc.

    function postRelayedCall(
        bytes calldata context,
        bool success,

The gasUseWithoutPost parameter provides the gas used for the transaction so far. It includes all the gas of the transaction, except for the unknown amount we are going to use in the postRelayedCall itself.

        uint256 gasUseWithoutPost,
        GSNTypes.RelayData calldata relayData
    ) external override virtual {
        (success, gasUseWithoutPost, relayData);
        emit PostRelayed(abi.decode(context, (uint)));
    }

This function returns the version of the paymaster contract.

    function versionPaymaster() external virtual view
    override returns (string memory) {
        return "1.0";
    }
}

Initializing the Paymaster

It is not enough to deploy the paymaster contract. Any paymaster contract needs to attach to a RelayHub, and if you use NaivePaymaster you also need to specify the target for which you are willing to pay. Additionally, the paymaster is not going to help anybody unless you actually fund it to be able to pay.

The directions below assume you are using truffle and that it is already configured for the network you are deploying into (either the real network or a test network).

  1. Run the truffle command line interface:

    truffle console --network <the network you are using>
  2. Deploy the paymaster contract, and then display the address so you can store it somewhere for future use:

    paymaster = await <paymaster contract>.new()
    paymaster.address

    If you have already deployed the contract and know the address, do this:

    paymaster = await <paymaster contract>.at(<address>)
  3. Specify the address of RelayHub on the network you’re using. You can get that information here.

    paymaster.setRelayHub(<RelayHub address>)
  4. Configure your paymaster. In the case of NaivePaymaster, this means to set the target.

    paymaster.setTarget(<target contract address>)
  5. Transfer ether to the paymaster’s address.

The User Interface

Your contract is not going to be very useful if users can’t use it. The way you interact with a contract using GSNv2 is a bit different from the way you do it for a normal dApp, because users need to go through a relay and may not have any ether.

This tutorial only explains the basics of using GSNv2 from a webapp. For more detailed documentation, look here.

Using npm Packages

GSNv2 is available as an npm package. This means that to use it in a webapp you need to import it as a package, as if you were using Node.js (the server version of JavaScript) and then use a tool such as browserify to make your code browser-compatible. This article teaches only the basics of using these tools, for more information see here.

These are the steps to start the development:

  1. Install browserify so it will be available as a script.

    sudo npm install -g browserify
  2. Create and change to a directory.

  3. Run this command to create the initial package definition file:

    npm init -y
  4. Install the GSNv2 package and its dependencies:

    npm install @opengsn/gsn@">=0.9.0" ethers
  5. Write your code in a file, for example index.js. You can use require just as you would with Node.js.

  6. To compile the application into browser-compatible JavaScript, use this command:

    browserify index.js -o bundle.js
    At writing there is a bug that causes the output to have some junk characters. Under Linux you can use the tr command to solve this:
    browserify index.js | tr -dc '\0-\177' > bundle.js

The user interface code

You can see the user interface code here. Here are the important parts (first in the JavaScript file and then on the HTML page).

This is the configuration with the addresses of the relevant contracts (on the test network where they are deployed, Kovan) and the maximum acceptable gas price for our messages. The address of the contract we wish to contact is conf.ourContract.

const conf = {
	ourContract: '0x9576f163350b33Bb75CFB5A4E6B123E5c2AbdaD8',
	notOurs:     '0x6969Bc71C8f631f6ECE03CE16FdaBE51ae4d66B1',
	paymaster:   '0x9940c8e12Ca14Fe4f82646D6d00030f4fC3C7ad1',
	relayhub:    '0xcfcb6017e8ac4a063504b9d31b4AbD618565a276',
        forwarder:   '0x663946D7Ea17FEd07BF1420559F9FB73d85B5B03',
	gasPrice:  20000000000   // 20 Gwei
}

This program uses the ethers.js package to communicate with the Ethereum network.

const ethers = require("ethers")

The program uses the variable provider as the entry point to ethers. However, it has to be initialized asynchronously. So we start with false and then initialize it. While creating the provider we also get the address of the current user.

var provider = false
var userAddr   // The user's address

This is the function that gets called to get ready to use GSN.

const startGsn = async () => {

For the sake of simplicity we call this function every time that we use Ethereum. If there is already a provider, it just returns. Otherwise, it starts Ethereum.

	if (provider)
		return;

	await window.ethereum.enable()

This is how we create the GSN configuration. We read information from the wallet (that is the reason the function is asynchronous), and combine it with the fields we specify.

	const gsnConfig =
		await gsn.resolveConfigurationGSN(window.ethereum, {
//			verbose: true,
			chainId: window.ethereum.chainId,

The relevant addresses on the network we use.

			paymasterAddress: conf.paymaster,
			forwarderAddress: conf.forwarder,

These fields are required for MetaMask to work. They will be added to the default GSN configuration in the future.

			methodSuffix: '_v4',
                        jsonStringifyRequest: true
		})

For debugging purposes we save some variables into window.app so they’ll be available at the developer’s tools console in the browser.

	window.app.gsnConfig = gsnConfig

The standard is to have the wallet manager in the browser (for example, MetaMask) expose a Web3 compatible provider in window.ethereum. This provider is then wrapped by GSN, which creates a RelayProvider that is also compatible with Web3. Transactions are processed by GSN to allow them to go through a relay at zero cost, and then sent to the original wallet manager to be signed by the user.

The ethers.js package uses its own provider class. So to create the provider we’ll use, we take that GSN provider and use it as the parameter to the ethers provider constructor.

	const gsnProvider = new gsn.RelayProvider(window.ethereum, gsnConfig)
        window.app.gsnProvider = gsnProvider

	provider = new ethers.providers.Web3Provider(gsnProvider)

This code gets the user address from the wallet. The other option, gsnProvider.newAccount().address, generates a random address for true anonymity.

//	userAddr = gsnProvider.newAccount().address
	userAddr = gsnProvider.origProvider.selectedAddress
	window.app.provider = provider
	window.app.userAddr = userAddr
}

The ABI (Application Binary Interface) is produced as an artifact by the Solidity compiler. It specifies the inputs of the various public functions, the arguments of events, and so on. The ABI is the only field we need, but if it is easier the data can be the entire build/contracts/CaptureTheFlag.json file (which includes many additional fields).

data = {
  "abi": [
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "_forwarder",
          "type": "address"
        }
      ],
     .
     .
     .
   }]}

This function calls the contract using GSN. It is standard ethers.js, provided here for completeness. First we create a provider and wait until it is ready.

const gsnContractCall = async () => {

	await startGsn()
	await provider.ready

The only network for which we have contact numbers is Kovan, whose chainId is 42. If the wallet is using any other network, we cannot call the contract.

	if (provider._network.chainId != 42) {
		alert("I only know the addresses for Kovan");
		raise("Unknown network");
	}

	const contract = await new ethers.Contract(
		conf.ourContract, data.abi, provider.getSigner(userAddr));
	const transaction = await contract.captureFlag();
	const hash = transaction.hash;
	console.log(`Transaction ${hash} sent`);

	const receipt = await provider.waitForTransaction(hash);
	console.log(`Mined in block: ${receipt.blockNumber}`);
};   // gsnContractCall

This function is almost identical to gnsContractCall. The difference is that it attempts to trick NaivePaymaster by using it to pay for access to a different contract.

const gsnPaymasterRejection = async () => {
	.
	.
	.
	const contract = await new ethers.Contract(
		conf.notOurs, data.abi, provider.getSigner(userAddr) );
	.
	.
	.
};   // gsPaymasterRejection

This function listens for events. Users don’t need an identity for that, so we can use the provider rather than a signer. It is also standard ethers.js, and provided here for completeness.

const listenToEvents = async () => {
	await startGsn()

	const contract = await new ethers.Contract(
		conf.ourContract, data.abi, provider);

This is an easy way to listen to events in ethers.js, and to parse them once they are received

	contract.on(contract.interface.events.FlagCaptured,
		evt => console.log(`Event: ${
			JSON.stringify(contract.interface.parseLog(evt))}`)
	);

};  // listenToEvents

The namespace within a file that is going to pass through browserify is inaccessible for JavaScript written on the HTML page. By adding fields to the global variable window, we can provide that JavaScript with a link to our functions and parameters. We don’t need all of these parameters for our program, but those we don’t are useful for debugging.

window.app = {
	gsnContractCall: gsnContractCall,
              .
              .
              .
	abi: data.abi
};

The HTML code can be very simple. There are two points to remember.

First, it is necessary to load the bundle.js script (or any other name) browserify creates out of our JavaScript code and the required libraries.

<script src="bundle.js">
</script>

Second, to access the functions in our JavaScript we need to use the window.app field we created in the JavaScript.

<script>
window.app.listenToEvents();
</script>

<button onClick="window.app.gsnContractCall()">
Capture the Flag (for free)
</button>

Local Tests

If you just want to run a couple of transactions to see that the dapp works you can use a test network such as Kovan, but such a network is too slow for serious testing. To do that, you run the tests locally.

You can see a complete automated test here. Here is a line by line explanation of the new parts.

The definitions required to use GSN:

const { RelayProvider, resolveConfigurationGSN } = require('@opengsn/gsn')
const { GsnTestEnvironment } = require('@opengsn/gsn/dist/GsnTestEnvironment' )
const ethers = require('ethers')

const Web3HttpProvider = require( 'web3-providers-http')

Import the contracts:

const CaptureTheFlag = artifacts.require('CaptureTheFlag')
const NaivePaymaster = artifacts.require('NaivePaymaster')

The function that calls the contract is similar to what we did with the user interface. To get the last caller from the event requires several steps:

  1. Get the logs using receipt.logs.

  2. Parse the log using the contract. The function <contract>.interface.parseLog parses a single log entry (provided it is an event emitted by the contract). If the function encounters an event that came from elsewhere, it returns null. Because it only handles a single event, we map this function on receipt.logs. The output looks like this:

    [
      null,
      _LogDescription {
        decode: [Function],
        name: 'FlagCaptured',
        signature: 'FlagCaptured(address,address)',
        topic: '0xacc718a11fbc93a22905740808767480f9efd07b1c0b0128095790cd1440048d',
        values: Result {
          '0': '0x0000000000000000000000000000000000000000',
          '1': '0xAcFEFc98e8977CBA2b12a467097a4267D9C4D5F0',
          length: 2
        }
      },
      null,
      null
    ]
  3. Use filter to remove the null values.

  4. There should be just one entry left, for the FlagCaptured event. The event parameters are stored in values as an associative array, one with keys and values. To get the first value, the previous holder, use values['0']. For the second one, the current holder, use values['1'].

Putting it all together gives us this function which returns the previous holder of the flag.

const callThroughGsn = async (contract, provider) => {
	.
	.
	.
	const result = receipt.logs.
		map(entry => contract.interface.parseLog(entry)).
		filter(entry => entry != null)[0];

	return result.values['0']

};  // callThroughGsn
contract("CaptureTheFlag", async accounts => {
	.
	.
	.

	it ('Runs with GSN', async () => {

The gsnTestEnvironment.startGsn command starts GSN on the provided blockchain. The return value includes the forwarder address, as well as the address for an extremely generous paymaster that accepts everything.

                let env = await GsnTestEnvironment.startGsn('localhost')

The various addresses GSN uses on the blockchain are stored in gsnInstance.deploymentResult. Use them to initialize our contracts.

This definition fills the two constants with the values with the same keys in env.deploymentResult.

		const { naivePaymasterAddress, forwarderAddress } = env.deploymentResult
		const web3provider = new Web3HttpProvider('http://localhost:8545')
		const deploymentProvider = new ethers.providers.Web3Provider(web3provider)

        	const factory = new ethers.ContractFactory(
			CaptureTheFlag.abi,
			CaptureTheFlag.bytecode,
			deploymentProvider.getSigner())

		const flag = await factory.deploy(forwarderAddress)
		await flag.deployed()

        	const config = await resolveConfigurationGSN(web3provider, {
            		verbose: false,
            		forwarderAddress,
            		paymasterAddress: naivePaymasterAddress,
        	})

The first time we call CaptureTheFlag we expect to get zero.

		var result = await callThroughGsn(contract, provider);
		assert.equal(result, 0, "Wrong initial last caller");

The second time we expect it to be the account that we used for the previous call. The rest of the tests just create a second account and switch between them, verifying that we get the correct account each time. The addresses need to be changed in lowercase because Ethereum uses the case of the letters a-f as a checksum, and acct.address does not use that.

		var result = await callThroughGsn(contract, provider);
		assert.equal(result.toLowerCase(), acct.address.toLowerCase(),
			"Wrong second last caller (should be acct)");

		.
		.
		.

	});   // it 'Runs with GSN'

});   // contract("CaptureTheFlag", ...)

Conclusion

In this article you learned how to accept transactions from entities that can’t (or won’t) pay for them with their own gas. You also learned how to create a simple paymaster to pay for transactions that you want to sponsor, for example because the users pay you by other means.

You should now be able to write your own GSNv2 compatible dapps that users could use without having to purchase ether. Hopefully, this will make user acquisition and onboarding much less painful for the users, and therefore much more effective for you.