An introduction to off-chain permits

In the Tutorial chapter, we show how to transfer a NFT to a smart contract through the Gas Station. As the corresponding operation is ultimately going to get posted by the Gas Station account, there is an issue: how is NFT contract going to allow this transfer on behalf of the user? While FA2 contracts, which are used to implement NFTs, support the concept of operators accounts acting on the behalf of the user, only the original owner of the NFT can allow a new operator to do so. The simplest way would be to modify the NFT contract to

This, of course, creates a security (and, potentially, legal) issue: if the key of Marigold Gas Station account gets stolen, then several FA 2 contracts could be compromised as well. On the other hand, users whose operations get sponsored are not supposed to have any tez in their wallet, and thus cannot post the update_operator call on-chain themselves.

What is the solution, then?

Off-chain permits

To solve this problem in a secure way, the notion of off-chain permits was introduced by TZIP 17. It extends the FA 2 standard with a few new entrypoints. The most interesting one, itself called permit, can be called by anyone, and expects a list of authorizations for transfers signed by the owners. Those transfers are signed off-chain: this means that the application has to ask the users for their signature through the usual ways (e.g. a Beacon-compatible wallet), but this signature has then to be stored and/or sent to this entrypoint by another account.

Most of the time, however, these permits can be sent in the same transaction as the call to the other contract, as we did in the previous chapter. When a permit is registered by the contract, it acts as a one-time authorization for a transfer to a specific address, which can either be a contract or a implicit account. The transfer entrypoint has the same interface as an ordinary FA 2 contract and of course supports the same usage as before, including regular operators. This means that regular users, who don't need their transactions to be relayed by the gas station, can always use their assets in a normal, permissionless way.

Let's define permits: they are signed bytes, formed from 4 parameters:

  • the chain identifier, such that a permit signed for a given chain (such as Ghostnet) cannot be used on a different chain;
  • the address of the permit FA2 contract, such that a permit signed for a given NFT collection cannot be used on another one;
  • a counter (nonce) defined inside the contract, such as a permit can only be used once;
  • and, finally, a hash of the allowed operation, which is going to be checked when the transfer takes place.

If you recall the previous chapter, this byte string was computed by the library with the following call:

const permitData = await permitContract.generatePermit({
  from_: userAddress,
  txs: [{
    to_: RECIPIENT,
    token_id,
    amount: 1
  }]
});

Indeed, it can be a little bit complicated to form by hand, and the slightest error makes the permit fail silently.

Once it is signed by the user, the permit can be registered in the contract by calling the permit entrypoint, which expects a list of parameters of the form (public_key, signature, transfer_hash) where public_key is the user's public key, which is necessary to check the signature. This signature is computed from the whole byte string, not just the transfer_hash.

i If you choose to compute permits by hand, be mindful that they are actually computed by forming the following couple: ((chain_identifier, contract_address), (contract_counter, transfer_hash)). Check the documentation of the contract library that you are using to be sure.

How to deploy a permit contract

The most up-to-date implementation of TZIP 17-style permits is the permit-cameligo Ligo package, which is currently maintained outside of Ligo Package Registry website. To use it, it is recommended to clone the following repository and use the Ligo compiler to install the dependencies:

$ git clone https://github.com/aguillon/permit-cameligo
$ cd permit-cameligo/
$ make install
$ make compile

Note that the Makefile assumes that you run the dockerized version of Ligo. To use another one, for instance a local one, you can prefix the make commands with ligo_compiler=ligo . For instance:

$ ligo_compiler=ligo make install
$ ligo_compiler=ligo make compile

This installs the dependencies in .ligo/, and compiles the code to produce two files in compiled/. The first of those files is a JSONized version of the second, which is ready to be deployed by the scripts in deploy/. In addition to the compiled code, this script requires two files: deploy/metadata.json that contains the contract's metadata, and deploy/.env which contains the secret key and the RPC node.

Let's create a minimal deploy/metadata.json file:

{
  "name":"Example",
  "interfaces":[
    "TZIP-12"
  ]
}

Change this file according to your needs. If you just want to test the deployment script, you can also use the pre-generated deploy/metadata.json.dist file and rename it to deploy/metadata.json. In the same spirit, copy deploy/.env.dist to deploy/.env and edit the file to put your secret key:

# Required: Your private key
PK=edsk...
# Required: see https://tezostaquito.io/docs/rpc_nodes/
RPC_URL=https://ghostnet.tezos.marigold.dev/

Finally, you should be able to

$ cd deploy/
$ npm i
$ npm run start

This workflow assumes that you're going to mint each token individually by calling the create_token entrypoint. If you want to pre-mint some tokens, you need to edit the deploy/deploy.ts script to start with a non-empty token_metadata map. The script should print the address of the contract after origination.