Discover Signatory

Claude Barde
ECAD Labs Inc
Published in
11 min readDec 21, 2022

--

Learn how to use Signatory, a remote signing service that provides convenience and safety for the Tezos blockchain

If you have ever used a dapp on Tezos, you are probably familiar with the most common way of signing a transaction: through your dedicated wallet, whether it be Temple, Kukai, Naan, Umami or another wallet available on Tezos.

It generally works like that: you click a button in the interface of the dapp to interact with a smart contract, the wallet opens, you click “Confirm” to sign the transaction and it is sent to the blockchain to be included in a block.

What if I told you there are different ways of signing a transaction, some of them being totally automated? 🤯

This article is about one of the ways to sign a transaction in a convenient, reliable and safe manner. It’s Signatory!

In this article, you will learn how to use Signatory by creating a smart-contract-based oracle on Tezos that will provide cryptocurrency exchange rates and that will be updated at regular intervals through a JavaScript back-end that uses Signatory to sign operations.

This tutorial requires a good knowledge of TypeScript/JavaScript and a basic understanding of programming concepts to follow the description of the smart contract.

If you want to see the entire project, the full code for the Signatory setup and the Express server is available in this repository: https://github.com/claudebarde/signatory-tutorial

What is Signatory?

Signatory is a remote signer. It means that you can set up Signatory once and use its API to send transactions that will be received and signed with the account you chose.

This way of configuring your application has many benefits:

  • You can use different ways of signing transactions with Signatory, in this article, we will use a file-based solution, but you can also use an HSM
  • You can select the type of operations you want to sign: Signatory has been mainly used by bakers who choose to sign only blocks and endorsements, but any service can use it to sign any type of operation
  • You can get stats about your Signatory instance through the different metrics exposed by the running instance
  • Signatory can be easily set up using a Docker image (which is the way we will follow in this article) in a few minutes.

Writing and testing the underlying smart contract

The first step of our application is the creation of the smart contract. To write it, we are going to use CameLigo. It’s an easy, but powerful language to develop smart contracts on Tezos. In order to set up a development environment to make our lives easier, we will use Taqueria, a development suite for Tezos that includes all the tools we are going to need.

As this article is not a tutorial on how to write smart contracts, we will just go through the general structure of the contract to give you a better understanding of what it does.

The contract will expose 4 entrypoints:

  • update_prices : this is the entrypoint where we will send an operation through Signatory to update the exchange rates of the supported cryptocurrencies
  • add_coins_pair : the cryptocurrency exchange rates are organized by pair, for example, BTC-USD is a pair that holds the current exchange rate from BTC to USD
  • remove_coins_pair : as it is possible to add a new cryptocurrency pair, it is also possible to remove it by calling this entrypoint
  • update_admin : all the operations sent to the contract must come from the admin account, this entrypoint allows the admin to set up a new address as the admin

In addition to these 4 entrypoints, the oracle exposes 2 views:

  • get_exchange_rate : this view allows other contracts on-chain to consume the exchange rates held by the oracle
  • get_valid_pairs : this view returns a set containing all the cryptocurrency pairs currently supported by the oracle

The general behaviour of the contract involves getting the exchange rates in our back-end server and saving them into a big_map so they can be accessible to other contracts on-chain, while providing a reliable way of knowing which cryptocurrency pairs are available in the oracle.

Setting up Signatory

In order to keep this tutorial easy to follow and implement, we will use the Docker image provided by Signatory (digest: c5dce35f5232).

Note: in the package.json file, you will notice that the image chosen for this tutorial is latest:arm64, as the code was written on an M1 Apple computer. Choose the image that you need according to the computer you develop on.

Before launching Signatory, there are a couple of things we have to set up.

First, create a signatory.yaml file and put the following code in it:

server:
address: :6732
utility_address: :9583

vaults:
local_secret:
driver: file
config:
file: /etc/secret.json

tezos:
tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb:
log_payloads: true
allow:
generic:
- transaction
- origination

This file is going to tell Signatory how you want it to run. It will be listening to your requests on port 6732 and will use a file to keep your private key named secret.json located in the etc folder. The public key hash of the signer is tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb and the operations that are accepted are transaction and origination.

Next, we have to create the secret.json file we just talked about:

[ 
{
"name": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb",
"value": "edskRpm2mUhvoUjHjXgMoDRxMKhtKfww1ixmWiHCWhHuMEEbGzdnz8Ks4vgarKDtxok7HmrEo1JzkXkdkvyw7Rtw6BNtSd7MJ7"
}
]

Note: we are using Alice’s account here, a popular account on Tezos to use for tests, so exposing the private key in this article is fine.

The content of the file is just an array holding an object with 2 properties: name for the public key hash and value for the private key.

Now, it’s time to check that everything works. After launching Docker desktop, you just have to run the following command:

docker run -it --rm \
-v \"$(pwd)/signatory.yaml:/signatory.yaml\" \
-v \"$(pwd)/etc/secret.json:/etc/secret.json\" \
-p 6732:6732 \
-p 9583:9583 \
ecadlabs/signatory:latest-arm64 \
serve -c /signatory.yaml

This will start Signatory with the settings you input in the signatory.yaml file. If the Docker image is not available on your computer, it will be downloaded.

In order to check if Signatory is working correctly, you can type this command in your terminal:

curl localhost:6732/keys/tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb

It should return the following response:

{"public_key":"edpkvGfYw3LyB1UcCahKQk4rF2tvbMUk8GFiTuMjL75uGXrpvKXhjn"}

If you don’t know your public key hash and only have your private key, it is also possible to use Signatory in order to get it.

First, in the secret.json file, replace the actual public key hash with anything you want:

[ 
{
"name": "I_LOVE_TEZOS",
"value": "edskRpm2mUhvoUjHjXgMoDRxMKhtKfww1ixmWiHCWhHuMEEbGzdnz8Ks4vgarKDtxok7HmrEo1JzkXkdkvyw7Rtw6BNtSd7MJ7"
}
]

Next, run this command to get your public key hash:

./signatory-cli list -c ./file.yaml --base-dir ./

This should output something like this:

INFO[0000] Initializing vault         vault=file vault_name=local_file_keys
Public Key Hash: tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb
Vault: File
ID: I_LOVE_TEZOS
Active: false

Creating the server

The server will be a typical Express server. Make sure you have NodeJS and NPM installed on your computer first. Then, there are a few dependencies we have to install before we can start:

npm install express axios coingecko-api node-cron /
@taquito/remote-signer @taquito/taquito
  • express : this NPM package provides an easy way to set up a server quickly (version 4.18.2)
  • axios : because of working in a NodeJS environment, axios is required to send HTTP requests from the server to the Signatory server (version 1.1.3)
  • coingecko-api : it will make it easier for us to fetch the exchange rates that we will then forward to the oracle (version 1.0.10)
  • node-cron : as the exchange rates will be updated regularly, this package will allow us to set up a recurring time to fetch the exchange rates and send them to the contract through a CRON job (version 3.0.2)
  • @taquito/remote-signer : this package will allow Taquito to use the Signatory server as a signer (version 15.0.1)
  • @taquito/taquito : your favourite Tezos library, required here to forge transactions and make contract calls (version 15.0.1)

Note: as we are using TypeScript in this project, you may have to install different NPM packages to remove typing errors in addition to the previously mentioned ones:

npm install --save-dev @types/node @types/express /
@types/node-cron @types/coingecko-api

Now, the fun part begins, writing the code for the server 😄

Create a new index.ts file to set up the server. The code starts like your typical Express server:

import express from "express";
import axios from "axios";

const app = express();
const port = 3456;

app.get("/", (_, res) => {
res.send("Hello Signatory!");
});

app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});

We don’t really need to have endpoints for this project, but it’s nice to have one, at least to check if the server is running properly. If you type http://localhost:3456 in your browser, you should see Hello Signatory when running the server.

We can also add an endpoint that returns the public key of the current signer on the Signatory server if that’s the kind of information that can be useful to our users:

app.get("/signer-pk", async (_, res) => {
try {
const axiosRes = await axios.get(
`http://localhost:${signatoryPort}/keys/${signatorySigner}`
);
if (axiosRes) {
const { data } = axiosRes;
if (data.hasOwnProperty("public_key")) {
res.status(200).send({ signerPk: data.public_key });
} else {
throw "Signatory didn't return the signer's public key";
}
} else {
throw "Error fetching signer's public key";
}
} catch (error) {
res.status(500).send(error);
}
});

This endpoint uses axios to query the public key of the signer from Signatory and returns it. If anything goes wrong, a 500 response is sent back with an error message.

Creating the CRON job

After setting up the server, we can start writing our application logic.

First, we are going to write a function to run a CRON job. The CRON job will be executed every minute and will fetch the exchange rates we need from the CoinGecko API before forging a contract call transaction, signing it with Signatory and emitting it to the network.

export const updateOracle = async (contractAddress: string) => {
...
};

Let’s start by setting up the different tools we will need for the function:

import { TezosToolkit } from "@taquito/taquito";
import { RemoteSigner } from "@taquito/remote-signer";
import { CoinGecko } from "coingecko-api";

const Tezos = new TezosToolkit(RPC_URL);
const signer = new RemoteSigner(
signatorySigner,
`http://localhost:${signatoryPort}`
);
Tezos.setSignerProvider(signer);
const contract = await Tezos.contract.at(contractAddress);
const CoinGeckoClient = new CoinGecko();
  • An instance of the TezosToolkit created with the endpoint URL for Flextesa (to mock the real Tezos blockchain locally)
  • An instance of the RemoteSigner that takes 2 parameters: the address of the account set up in Signatory and the endpoint URL for Signatory, the instance is then passed to the TezosToolkit to be used as the main signer
  • A ContractAbstraction of the oracle in order to interact with it
  • An instance of the CoinGecko client to interact with their API

Now, we can write the function that we will call to start the CRON job:

const availableCoins = ["bitcoin", "ethereum", "tezos"];

const scheduledJobFunction = CronJob.schedule("*/1 * * * *", async () => {
const res = await CoinGeckoClient.simple.price({
ids: availableCoins,
vs_currencies: "usd"
});
...
});

The scheduledJobFunction will start the CRON job by calling the schedule method on the CronJob class. This method takes 2 parameters: the first one is a string that describes the interval for the CRON job and the second one is a function to be executed at each interval specified in the first parameter.

The first thing to do every time the CRON job is executed is to fetch the exchange rate for the coins in the availableCoins array. After the exchange rates have been fetched, we are ready to send them to the oracle:

if (res.success && res.code === 200) {
try {
const op = await contract.methods
.update_prices(
availableCoins
.map(coin => {
if (res.data.hasOwnProperty(coin)) {
const price = res.data[coin].usd;
if (!isNaN(price)) {
return {
coins_pair: coinToCoinpair(coin),
exchange_rate: Math.floor(price * 10 ** 6)
};
} else {
return undefined;
}
} else {
return undefined;
}
})
.filter(el => el)
.filter(el => el?.coins_pair === "unknown")
)
.send();

await op.confirmation();
console.log("Update prices confirmed!");
} catch (error) {
console.log("Unable to update prices '-_-");
console.error(error);
}
}

The oracle has an entrypoint called update_prices that expects a list of update that we can easily create by mapping the array of available coins to verify, first, that the rates we received from CoinGecko are for the coins we want, and second, that the rates are indeed numbers.

We use the following function to match the coin name provided by CoinGecko with the coin pairs we accept:

const coinToCoinpair = (coin: string): string => {
switch (coin) {
case "bitcoin":
return "BTC-USD";
case "ethereum":
return "ETH-USD";
case "tezos":
return "XTZ-USD";
default:
return "unknown";
}
};

If one of the above conditions is not met, undefined is returned instead of the update object and the array is then filtered to remove all the undefined values.

As usual, once the operation has been forged and injected, we call .confirmation() on the operation object to wait for one confirmation.

Note: notice how there are no additional steps to use Signatory as a signer. You just create an instance of the RemoteSigner, pass it to the TezosToolkit and Taquito does its magic under the hood, so your experience as a Tezos developer remains the same, whatever signer you may want to use!

Once the function is complete and the CRON job has been scheduled, you can launch it:

scheduledJobFunction.start();

Running the project

Now that the scheduling function is ready, it’s time to plug it into our server. For convenience, we will create an endpoint that will originate a new oracle and start the CRON job:

app.get("/originate-oracle", async (_, res) => {
const Tezos = new TezosToolkit(flextesaUrl);
const signer = new RemoteSigner(
signatorySigner,
`http://localhost:${signatoryPort}`
);
Tezos.setSignerProvider(signer);

...
});

The endpoint is simply called originate-oracle, and once called, it will create a new instance of the TezosToolkit and use Signatory as a remote signer.

Once again, the amazing thing about using Signatory is that you don’t need to do anything different from your current Taquito workflow: you just have to use the RemoteSigner package instead of the InMemorySigner or the BeaconWallet package that you have probably been using so far 🤯

After this is done, we can originate the oracle:

try {
// checks if no contract address exists
if (contractAddress)
throw `Contract already exists at address "${contractAddress}"`;

// prepares the initial storage for the contract
const initialStorage: Omit<Storage, "exchange_rates"> & {
exchange_rates: MichelsonMap<
string,
{ exchange_rate: BigNumber; last_update: string }
>;
} = {
valid_pairs: ["BTC-USD", "ETH-USD", "XTZ-USD"],
exchange_rates: new MichelsonMap(),
admin: signatorySigner
};
// originates the contract
const originationOp = await Tezos.contract.originate({
code: oracleCode,
storage: initialStorage
});
await originationOp.confirmation();
contractAddress = originationOp.contractAddress || "";

// launches the CRON job
await updateOracle(contractAddress);

res.status(200).send({ contractAddress });
} catch (error) {
console.log(error);
res.status(500).send(JSON.stringify(error));
}

These steps are very simple and there is nothing new here, it’s just the standard way of originating a contract with Taquito:

  1. We verify first if a contract hasn’t been originated yet
  2. We create the initial storage for the oracle by setting up the available pairs of coins, an empty map for the exchange rates and the address of the admin
  3. The oracle is originated by calling Tezos.contract.originate() and passing it the Michelson code for the contract and the initial storage we’ve just created above
  4. We then wait for the confirmation of the origination and retrieve the oracle’s address from originationOp.contractAddress
  5. After these steps are complete, we can launch the CRON job with await updateOracle(contractAddress)
  6. If everything worked as expected, we return a 200 status code with the address of the new contract. If the origination failed, we return a 500 status code with the stringified error.

And that’s it! After these steps, the CRON job is running and new exchange rates will be fetched from the CoinGecko API every minute before being sent to the oracle through Signatory to be saved in the contract storage.

Conclusion

Signatory is one of these tools on Tezos that are less known to developers while they have been used for years by bakers. It is a great way to set up a signer in the back-end in order to process and sign transactions according to your dapp use case. Signatory provides an extra level of security and convenience and can plug into the Taquito ecosystem via the RemoteSigner package as easily as any other Taquito library you may be familiar with.

Useful links

--

--

Self-taught developer interested in web3 and functional programming