Advanced GSN Guide (Bouncers)

This guide shows you different strategies (Bouncers) to accept transactions via Gas Station Network.

First, we will explain the Bouncer concept, and then we will showcase how to use the two most common strategies.

Finally, we will cover how to create your own custom Bouncer.

If you’re still learning about the basics of the Gas Station Network, you should head over to our GSN Guide, which will help you get started from scratch.

GSN Bouncers

A GSN Bouncer decides which transaction gets approved and which transaction gets rejected. Bouncers are a key concept within GSN. Dapps need Bouncers to prevent malicious users from spending the subsidies for the transactions.

As we have seen in the Basic Guide, in order to use GSN, your contracts need to extend from GSN Recipient.

A GSN recipient contract needs the following to work:

  • It needs to have funds deposited on its RelayHub.

  • It needs to handle msg.sender and msg.data differently

  • It needs to decide how to approve and reject transactions.

The first can be done via the GSN Tools or programatically with our SDK.

The sender and data can be used safely when using GSNRecipient and _msgSender & _msgData.

The third is a bit more complex. The GSN Recipient, by default, will accept and pay for all transactions. Chances are you probably want to choose which users can use your contracts via the GSN and potentially charge them for it, like a bouncer at a nightclub. We call these contracts GSNBouncers.

We include two of them below, ready to use out of the box.

GSNBouncerSignature

This bouncer lets users call into your recipient contract via the GSN (charging you for it) if they can prove that an account you trust approved them to do so. The way they do this is via a signature.

The signature used to create the transaction must be added to the contract as a trusted signer. If it is not the same, this bouncer will not accept the transaction.

This means that you need to set up a system where your trusted account signs call requests, as long as they are valid users.

The definition of a valid user depends on your system, but an example is users that have completed their sign up via some kind of oauth and validation, e.g., gone through a captcha or validated their email address. You could restrict it further and let new users send a specific number of transactions (e.g., 5 requests via the GSN, at which point they need to create a wallet). Alternatively, you could charge them off-chain (e.g., via credit card) for credit on your system and let them run GSN calls until said credit runs out.

The great thing about this setup is that your contract doesn’t need to change. All you’re doing is changing the backend logic conditions under which users call into your contract for free. On the other hand, you need to have a backend server, microservice, or lambda function to accomplish this.

How does it work?

Here is the definition of acceptRelayedCall function.

It decides whether or not to accept the call based on the signature. No further gsn-actions need to be taken.

It only relies on the approvalData and does not use callData.

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) == _trustedSigner) {
          return _approveRelayedCall();
      } else {
          return _rejectRelayedCall(uint256(GSNRecipientSignedDataErrorCodes.INVALID_SIGNER));
      }
  }

Approve/Reject

When the signatures match, the function returns the following:

  return _approveRelayedCall();

  // Defined on base class GSNBouncerBase
  // uint256 constant private RELAYED_CALL_ACCEPTED = 0;
  // function _approveRelayedCall(bytes memory context) internal pure returns (uint256, bytes memory) {
  //    return (RELAYED_CALL_ACCEPTED, context);
  // }

On the other hand, when the signatures don’t match, the call gets rejected with the following:

  return _rejectRelayedCall(uint256(GSNBouncerSignatureErrorCodes.INVALID_SIGNER));

  // Defined on base class GSNBouncerBase
  // uint256 constant private RELAYED_CALL_REJECTED = 11;
  // function _rejectRelayedCall(uint256 errorCode) internal pure returns (uint256, bytes memory) {
  //    return (RELAYED_CALL_REJECTED + errorCode, "");
  // }

How to use it

Create your contract using the following:

  mycontract is GSNRecipient, GSNBouncerSignature {
    constructor(trusted_address) // that's it
  }

GSNBouncerERC20Fee

This bouncer is a bit more complex (but don’t worry, we’ve already written it for you!). Unlike GSNBouncerSignature, this Bouncer doesn’t require any off-chain services. Instead of manually approving each transaction, you will give tokens to your users. These tokens are then used to pay for GSN calls to your recipient contract. Any user that has enough tokens is automatically approved and the recipient contract will cover his transaction costs!

This bouncer charges users for the ether cost your recipient will incur. Each recipient contract has their own unique token, with a baked-in exchange rate of 1:1 to ether, since they act as an ether replacement when using the GSN.

The recipient has an internal mint function. Firstly, you need to setup a way to call it (e.g., add a public function with onlyOwner or some other form of access control). Then, issue tokens to users based on your business logic. For example, you could mint limited tokens to new users, mint tokens when they buy them off-chain, give tokens based on the user subscription, etc.

Users do not need call approve on their tokens for your recipient to use them. They are a modified ERC20 variant that lets the recipient contract retrieve them.

How does it work?

Let’s look at how this Bouncer decides to approve or reject transactions.

function acceptRelayedCall(
    address,
    address from,
    bytes calldata,
    uint256 transactionFee,
    uint256 gasPrice,
    uint256,
    uint256,
    bytes calldata,
    uint256 maxPossibleCharge
)
    external
    view
    returns (uint256, bytes memory)
{
    if (_token.balanceOf(from) < maxPossibleCharge) {
        return _rejectRelayedCall(uint256(GSNBouncerERC20FeeErrorCodes.INSUFFICIENT_BALANCE));
    } else if (_token.allowance(from, address(this)) < maxPossibleCharge) {
        return _rejectRelayedCall(uint256(GSNBouncerERC20FeeErrorCodes.INSUFFICIENT_ALLOWANCE));
    }

    return _approveRelayedCall(abi.encode(from, maxPossibleCharge, transactionFee, gasPrice));
}

The bouncer rejects the tx if the real sender doesn’t have enough tokens or they are not allowed to spend that amount. If the sender can spend the tokens, the bouncers approve the transaction and overrides _confirmRelayedCall to make that data available to pre and post.

Now, let’s see how we perform the token transfer inside the _preRelayedCall method.

function _preRelayedCall(bytes memory context) internal returns (bytes32) {
    (address from, uint256 maxPossibleCharge) = abi.decode(context, (address, uint256));

    // The maximum token charge is pre-charged from the user
    _token.safeTransferFrom(from, address(this), maxPossibleCharge);
}

We transfer the max amount of tokens assuming that the call will use all the gas available. Then, in the _postRelayedCall method, we calculate the actual amount - including the implementation and ERC transfers - and refund the difference.

function _postRelayedCall(bytes memory context, bool, uint256 actualCharge, bytes32) internal {
    (address from, uint256 maxPossibleCharge, uint256 transactionFee, uint256 gasPrice) =
        abi.decode(context, (address, uint256, uint256, uint256));

    // actualCharge is an _estimated_ charge, which assumes postRelayedCall will use all available gas.
    // This implementation's gas cost can be roughly estimated as 10k gas, for the two SSTORE operations in an
    // ERC20 transfer.
    uint256 overestimation = _computeCharge(POST_RELAYED_CALL_MAX_GAS.sub(10000), gasPrice, transactionFee);
    actualCharge = actualCharge.sub(overestimation);

    // After the relayed call has been executed and the actual charge estimated, the excess pre-charge is returned
    _token.safeTransfer(from, maxPossibleCharge.sub(actualCharge));
}

This is required to protect the contract from exploits (this is really similar to how ether is locked in Ethereum transactions).

Please note how the gas cost estimation is not 100% accurate, we may tweak it further down the road.

_preRelayedCall and _postRelayedCall are used instead of preRelayedCall and postRelayedCall. This prevents them from being called by non-relayhub. Always use _pre and _post methods.

How to use it

Create your contract using the following:

  mycontract is GSNRecipient, GSNBouncerERC20Fee {
    constructor(name symbol decimals)

    mint() {
      _mint()
    }
  }

Create your custom Bouncer [optional, for power users]

You can use 'GSNBouncerBase' as an example to guide your Bouncer implementation.

The only thing you must do is extend from GSNRecipient and implement the accept method.

Depending on your logic, you may need to implement _postRelayedCall and _preRelayedCall.