Transaction Signing & Approval

Transaction Signing Callback Handler

POST /v2/tx_sign_request

This request is used for transaction signing and approval and expects a CallbackResponse object from the callback handler. If the callback handler does not respond within 30 seconds, Fireblocks fails the request.

If both approval and signing are requested for a transaction, one callback handles both requests.

To differentiate whether this request is for signing, approving, or both signing and approving a transaction, these parameters are included only with transaction signing requests:

Request parameters

ParameterTypeDescription
txIdstringThe transaction's internal ID in Fireblocks.
operationstringThe transaction operation type. The default is TRANSFER.
sourceTypestringThe source of the transaction.
sourceIdstringThe transaction’s source vault account ID or exchange account UUID.
destTypestringThe transaction's destination type. The type can be VAULT, EXCHANGE, or UNMANAGED.
destIdstringThe destination's vault account ID or exchange account UUID.
assetstringThe asset ID in Fireblocks.
amount (Deprecated)numberPlease use the amountStr field for accuracy.
(If the transfer is a withdrawal from an exchange, the actual transfer amount. Otherwise, the requested amount.)
amountStrstringThe amount of the transfer in string format.
requestedAmount (Deprecated)numberPlease use the requestedAmountStr field for accuracy. (The requested transfer amount. In gross transactions, transaction fees are deducted from this amount.)
requestedAmountStrstringThe requested transfer amount in string format. In gross transactions, transaction fees are deducted from this amount.
feestring(Optional) The transaction's estimated fee.
destAddressTypestringThe destination's address type. For one-time addresses on EVM-compatible blockchains, this is the smart contract address.
destAddressstringThe destination address of the transaction.
destinationsarrayAn array of TransactionRequestCallbackDestination objects with all details for all destination(s).
playersarray (string)A list of the Co-Signers that signed the transaction. (Two Fireblocks SaaS and at least one user device or one API Co-Signer). Each signer is represented by a Device ID.
requestIdstringA unique identifier of this request; is returned in the response.
signerIdstringThe user that signed the transaction.
extraParametersarray(Optional) Parameters that are specific to some transaction operation types and blockchain networks. Learn more here.
notestring(Optional) Custom note that describes this transaction in your Fireblocks workspace. The note isn’t sent to the blockchain.
rawTxarrayAn array of RawTX. Contains a list of the actual transactions sent to the blockchain. Note that some signing requests represent multiple transactions. When this occurs, the list contains more than one object. Additionally, this parameter is not included in the CallbackResponse object when using a Fireblocks EU cloud environment.
action (deprecated)objectIncludes information about the transaction authorization policy rule that matched this transaction. This field and its contents are not officially supported or maintained. Fireblocks may delete or change the contents of this field at any time.

TransactionOperation

ParameterTypeDescription
operationstring[TRANSFER, MINT, BURN, CONTRACT_CALL, TYPED_MESSAGE, RAW, ENABLE_ASSET, STAKE, UNSTAKE, WITHDRAW]
Not all operations are available in all workspaces. Contact your account manager to enable additional features.

TRANSFER - Default. Transfers funds from one account to another. UTXO blockchains allow multi-input and multi-output transfers. All other blockchains allow transfers with one source address and one destination address.

MINT - Mints new tokens. Supported for Stellar, Ripple, and EVM-based blockchains.

BURN - Burns tokens. Supported for Stellar, Ripple, and EVM-based blockchains.

CONTRACT_CALL - Calls a smart contract method for web3 operations on any EVM blockchain. The Fireblocks development libraries are recommended for building contract call transactions.

TYPED_MESSAGE - An off-chain message in either Ethereum Personal Message or EIP712 format. Use it to sign specific readable messages that are not actual transactions. Learn more about typed messages.

RAW - An off-chain message with no predefined format. Use it to sign any message with your private key, including protocols such as blockchains and custom transaction types that are not natively supported by Fireblocks. Learn more about raw signing transactions.

ENABLE_ASSET - Algorand, DigitalBits, Solana, and Stellar require an on-chain transaction to create an asset wallet and enable the deposit address. This transaction is automatically created when adding assets on these blockchains to a vault account.

STAKE - Assign assets to a staking pool managed by a staking validator. Learn more about staking transactions and supported blockchains. This transaction is automatically created when performing staking operations.

UNSTAKE - Remove assets from a staking pool managed by a staking validator. Learn more about staking transactions and supported blockchains. This transaction is automatically created when performing staking operations.

WITHDRAW - Transfer assets from a dedicated staking vault account to another address. Learn more about staking transactions and supported blockchains. This transaction is automatically created when performing staking operations.
Note: Fireblocks will rename this type from WITHDRAW to a different name soon. There will be at minimum a 7-day notice regarding the new type name.

TransactionRequestCallbackDestination

ParameterTypeDescription
amountNativestringDeprecated. The amount transferred to this destination as a number. Use the amountNativeStr parameter for accurate precision.
amountNativeStrnumberThe amount transferred to this destination represented as a string.
amountUSDnumberThe USD value of the transfer to this destination.
dstAddressTypeWHITELISTED or ONE_TIMEWHITELISTED or ONE_TIME
dstIdstringThe ID of the destination.
dstNamestringThe name of the destination.
dstSubTypestringThe specific exchange, fiat account, or unmanaged wallet.

For exchange accounts: (BINANCE, BINANCEUS, BITFINEX, BITHUMB, BITMEX, BITSO, BITSTAMP, BITTREX, BYBIT, CIRCLE, COINBASEEXCHANGE, COINBASEPRO, COINMETRO, COINSPRO, CRYPTOCOM, DERIBIT, GEMINI, HITBTC, HUOBI, INDEPENDENTRESERVE, KORBIT, KRAKEN, KRAKENINTL, KUCOIN, LIQUID, OKCOIN, OKEX, PAXOS, POLONIEX)

For fiat accounts: (BLINC)

For unmanaged wallets: (INTERNAL, EXTERNAL, or CONTRACT)
dstTypestringThe destination of the transaction (VAULT, EXCHANGE_ACCOUNT, FIAT_ACCOUNT, or UNMANAGED).
displayDstAddressstringThe address of this specific destination
actionstringIncludes information about the Transaction Authorization Policy rule that matched this transaction.

TransactionExtraParameters

ParameterTypeDescription
inputsSelectionInputsSelection objectFor UTXO-based blockchain multi-input selection, use the inputsSelection field with values set to the input selection structure. The inputs can be retrieved using the Retrieve Unspent Inputs endpoint.
rawMessageDataRawMessageData objectFor RAW operations, use the rawMessageData field with the values set to the raw message data structure. Only included with raw signing transactions on Bitcoin and Ethereum.
This is an opt-in feature. Please contact Fireblocks Support to include this feature in your workspace.
contractCallDatastringFor CONTRACT_CALL operations, use the contractCallData field with the value set to the Ethereum smart contract Application Binary Interface (ABI) payload. The Fireblocks development libraries are recommended for building contract call transactions

RawTX

ParameterTypeDescription
rawTxstringHex-encoded details of a transaction sent to the blockchain
keyDerivationPathArray of numbersLocation of the encryption key within the customer’s HD Wallet URL used to sign this transaction.

API Co-Signer callback code example

const express = require("express");
const bodyParser = require("body-parser");
const fs = require("fs");
const jwt = require("jsonwebtoken");
const privateKey = fs.readFileSync("private.pem");
const cosignerPubKey = fs.readFileSync("public.pem");
const app = express();

app.use(
  express.urlencoded({
    extended: true
  })
);
app.use(express.json());

app.use(function (req) {
  req.rawBody = "";
  req.setEncoding("utf8");
  req.on("data", function(chunk) {
    req.rawBody += chunk;
  });
  req.on("end", function () {
   req.next();
  });
});

app.post("/v2/tx_sign_request", async (req, res) => {
  let verified;
  try {
  const tx = jwt.decode(req.rawBody);
  const { requestId } = tx;
  verified = jwt.verify(req.rawBody, cosignerPubKey);
  if (verified) {
    let action = "REJECT";
    let rejectionReason = "Logic returned false";
    const signedRes = jwt.sign(
      {
        action,
        requestId,
        rejectionReason
      },
      privateKey,
      { algorithm: "RS256" }
    );
    res.send(signedRes);
  }
  } catch (e) {
    res.sendStatus(401);
  }
});
app.listen(3000);
from pathlib import Path
from wsgiref.simple_server import make_server
import falcon
import jwt
callback_handler_prikey = None
cosigner_pubkey = None

# Load keys.
f1 = Path("private.pem")
if f1.is_file(): callback_handler_prikey = f1.read_bytes()
f2 = Path("public.pem")
if f2.is_file(): cosigner_pubkey = f2.read_bytes()

class JWTTransferRequest(object):
    def on_post(self, req, resp):
        raw_req = req.bounded_stream.read()
        req = jwt.decode(raw_req, cosigner_pubkey, algorithms=["RS256"])
        resp.body = jwt.encode({'action': 'APPROVE', 'requestId': req['requestId']}, callback_handler_prikey, algorithm="RS256")
        resp.status = falcon.HTTP_200

# Create falcon app
app = falcon.App()
app.add_route('/v2/tx_sign_request', JWTTransferRequest())
app.add_route('/v2/config_change_sign_request', JWTTransferRequest())
if __name__ == '__main__':
    with make_server('', 80, app) as httpd:
        print('Serving on port 80...')
        # Serve until process is killed
        httpd.serve_forever()
JWTTransferRequest()

ETH Transaction callback handler validation

The API Co-Signer automates approving and signing transactions and approving workspace changes. The API Co-Signer replaces using a mobile device for manual approvals. This is ideal for any workspace that expects a high volume of transactions, frequent workspace activity, or 24-hour access.

The API Co-Signer can be configured to work with a callback handler. This is a web endpoint (HTTPS) that can approve or reject transactions and workspace changes based on custom logic that you can implement.

Fireblocks provides with the ability to verify ETH or BTC transactions before these are getting signed by the API Co-Signer.
It is possible to configure your workspace to receive the RAW data of the ETH or the BTC transaction as a part of the payload sent to the callback handler.

In this article we are going to cover how to verify Ethereum raw transactions.

ETH - Callback Handler Payload structure

First, let’s take a look on the payload that is sent from the Co-Signer to the Callback handler (a detailed spec can be found in here):

{
  "txId": "9c794cee-7e27-46c9-9e9a-ed68295ff06b",
  "operation": "TRANSFER",
  "sourceType": "VAULT",
  "sourceId": "0",
  "destType": "VAULT",
  "destId": "1",
  "asset": "ETH",
  "amount": 0.01,
  "amountStr": "0.010000000000000000",
  "requestedAmount": 0.01,
  "requestedAmountStr": "0.01",
  "fee": "0.000597803762241000",
  "destAddressType": "WHITELISTED",
  "destAddress": "0x5dC69B1Fbb13Bafd09af88a782F0F285772Ad5f8",
  "destinations": [
    {
      "amountNative": 0.01,
      "amountNativeStr": "0.01",
      "amountUSD": 18.74292937,
      "dstAddressType": "WHITELISTED",
      "dstId": "1",
      "dstWalletId": "",
      "dstName": "Network Deposits",
      "dstSubType": "",
      "dstType": "VAULT",
      "displayDstAddress": "0x5dC69B1Fbb13Bafd09af88a782F0F285772Ad5f8",
      "action": "ALLOW",
      "actionInfo": {
        "capturedRuleNum": 5,
        "rulesSnapshotId": 8164,
        "byGlobalPolicy": false,
        "byRule": true,
        "capturedRule": "{\"type\":\"TRANSFER\",\"transactionType\":\"TRANSFER\",\"asset\":\"*\",\"amount\":0,\"operators\":{\"wildcard\":\"*\"},\"applyForApprove\":true,\"action\":\"ALLOW\",\"src\":{\"ids\":[[\"*\"]]},\"dst\":{\"ids\":[[\"*\"]]},\"dstAddressType\":\"*\",\"amountCurrency\":\"USD\",\"amountScope\":\"SINGLE_TX\",\"periodSec\":0}"
      }
    }
  ],
  "rawTx": [
    {
      "keyDerivationPath": "[ 44, 60, 0, 0, 0 ]",
      "rawTx": "02ef0104843b9aca008506a0c1987d825208945dc69b1fbb13bafd09af88a782f0f285772ad5f8872386f26fc1000080c0",
      "payload": "77b4e74099ce90c08503c0e0bb6e672dbe1c5e3e127ce333bf22eb581cd3f6ce"
    }
  ],
  "players": [
    "21926ecc-4a8a-4614-bbac-7c591aa7efdd",
    "27900737-46f6-4097-a169-d0ff45649ed5",
    "f89cac50-c656-4e74-879f-041aff8d01b5"
  ],
  "requestId": "9c794cee-7e27-46c9-9e9a-ed68295ff06b"
}

The payload above contains a lot of information but we will be focusing only on some parts of it:

  1. Amount (destinations[0].amountNative)
  2. Destination Address (destinations[0].displayDstAddress)
  3. Raw Transaction array (rawTx)
    RLP encoded payload (rawTx.rawTx)
    The hash of the raw transaction (rawTx.payload)

Note that the RLP encoded payload (rawTx.rawTx) is the actual payload that you are signing on, or to be precise, the signature is done over the keccak256 hash of the RLP encoded payload, which is exactly the hash provided in the rawTx.payload property

Creating our callback application:

Before diving into the verification of the ETH transaction process, let’s start with spinning up our callback server. In this example I am going to use Express.js framework:

Install express:
npm i express

We will also need some additional packages to be installed:
npm i jsonwebtoken fs body-parser @ethereumjs/tx

Initiating the app:

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

Let’s set our middleware (we are using body-parser here in order to access the raw body sent over to our POST endpoint that we will add shortly):

const bodyParser = require("body-parser");

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json())
app.use( (req) => {
    req.rawBody = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      req.rawBody += chunk;
    });
    req.on("end", () => {
      req.next();
    });
  }
);

Defining our route for the /v2/tx_sign_request endpoint:

app.post("/v2/tx_sign_request", (req, res) => { 
	console.log("The raw body of the HTTP request:", req.rawBody)
}

Now, let’s understand how exactly we get this rawBody of the HTTP request.
In fact, Fireblocks API Co-Signer does not send this payload in clear text, there is some authentication process in place. This authentication process is done by performing mutual authentication using signed Json Web Tokens.
You can find more information about the auth process here: <link_to_callback_jwt_auth>
But just to cover it in a few words - each request from the Co-Signer to the callback carries a signed JWT in the request body signed with the private key of the Co-Signer application. Same goes for the response done by the callback to the Co-Signer - it should be signed by the callback private key.
We assume that you have gone through the authentication setup article and you have the callback private + public keys and also the Co-Signer public key.

Loading the callback’s private key + the cosigner public key:

const fs = require("fs");

// Read the callback handler private key
const privateKey = fs.readFileSync("private.pem");

// Read the cosigner public key (you can get it by running: ./cosigner print-public-key on the cosigner machine)
const cosignerPubKey = fs.readFileSync("cosigner_public.pem");

In our example the raw body of the HTTP request will actually look like this:

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ0eElkIjoiNDhmNjg3MWMtMDQ2Ny00YjU2LThjNDQtNDI2ZjNkNjQ4N2Y1Iiwib3BlcmF0aW9uIjoiVFJBTlNGRVIiLCJzb3VyY2VUeXBlIjoiVkFVTFQiLCJzb3VyY2VJZCI6IjAiLCJkZXN0VHlwZSI6IlZBVUxUIiwiZGVzdElkIjoiMSIsImFzc2V0IjoiRVRIIiwiYW1vdW50IjowLjAxMDAwMDAwLCJhbW91bnRTdHIiOiIwLjAxMDAwMDAwMDAwMDAwMDAwMCIsInJlcXVlc3RlZEFtb3VudCI6MC4wMTAwMDAwMCwicmVxdWVzdGVkQW1vdW50U3RyIjoiMC4wMSIsImZlZSI6IjAuMDAwNTg0MjYxNjA3NjU3MDAwIiwiZGVzdEFkZHJlc3NUeXBlIjoiV0hJVEVMSVNURUQiLCJkZXN0QWRkcmVzcyI6IjB4NWRDNjlCMUZiYjEzQmFmZDA5YWY4OGE3ODJGMEYyODU3NzJBZDVmOCIsImRlc3RpbmF0aW9ucyI6W3siYW1vdW50TmF0aXZlIjowLjAxMDAwMDAwLCJhbW91bnROYXRpdmVTdHIiOiIwLjAxIiwiYW1vdW50VVNEIjoxOC43NDUwMTkzNiwiZHN0QWRkcmVzc1R5cGUiOiJXSElURUxJU1RFRCIsImRzdElkIjoiMSIsImRzdFdhbGxldElkIjoiIiwiZHN0TmFtZSI6Ik5ldHdvcmsgRGVwb3NpdHMiLCJkc3RTdWJUeXBlIjoiIiwiZHN0VHlwZSI6IlZBVUxUIiwiZGlzcGxheURzdEFkZHJlc3MiOiIweDVkQzY5QjFGYmIxM0JhZmQwOWFmODhhNzgyRjBGMjg1NzcyQWQ1ZjgiLCJhY3Rpb24iOiJBTExPVyIsImFjdGlvbkluZm8iOnsiY2FwdHVyZWRSdWxlTnVtIjo1LCJydWxlc1NuYXBzaG90SWQiOjgxNjQsImJ5R2xvYmFsUG9saWN5IjpmYWxzZSwiYnlSdWxlIjp0cnVlLCJjYXB0dXJlZFJ1bGUiOiJ7XCJ0eXBlXCI6XCJUUkFOU0ZFUlwiLFwidHJhbnNhY3Rpb25UeXBlXCI6XCJUUkFOU0ZFUlwiLFwiYXNzZXRcIjpcIipcIixcImFtb3VudFwiOjAsXCJvcGVyYXRvcnNcIjp7XCJ3aWxkY2FyZFwiOlwiKlwifSxcImFwcGx5Rm9yQXBwcm92ZVwiOnRydWUsXCJhY3Rpb25cIjpcIkFMTE9XXCIsXCJzcmNcIjp7XCJpZHNcIjpbW1wiKlwiXV19LFwiZHN0XCI6e1wiaWRzXCI6W1tcIipcIl1dfSxcImRzdEFkZHJlc3NUeXBlXCI6XCIqXCIsXCJhbW91bnRDdXJyZW5jeVwiOlwiVVNEXCIsXCJhbW91bnRTY29wZVwiOlwiU0lOR0xFX1RYXCIsXCJwZXJpb2RTZWNcIjowfSJ9fV0sInJhd1R4IjpbeyJrZXlEZXJpdmF0aW9uUGF0aCI6IlsgNDQsIDYwLCAwLCAwLCAwIF0iLCJyYXdUeCI6IjAyZWYwMTA0ODQzYjlhY2EwMDg1MDY3YTUxYmU4NTgyNTIwODk0NWRjNjliMWZiYjEzYmFmZDA5YWY4OGE3ODJmMGYyODU3NzJhZDVmODg3MjM4NmYyNmZjMTAwMDA4MGMwIiwicGF5bG9hZCI6IjZjYTE4YzQ3Y2NkOGRkNDdiYjBiMGIwYzczY2M1MjM3YmJmZjlkMGVhYWU0NjljYTY1YTY3MjMyOTY0M2JmZGEifV0sInBsYXllcnMiOlsiMjE5MjZlY2MtNGE4YS00NjE0LWJiYWMtN2M1OTFhYTdlZmRkIiwiMjc5MDA3MzctNDZmNi00MDk3LWExNjktZDBmZjQ1NjQ5ZWQ1IiwiZjg5Y2FjNTAtYzY1Ni00ZTc0LTg3OWYtMDQxYWZmOGQwMWI1Il0sInJlcXVlc3RJZCI6IjQ4ZjY4NzFjLTA0NjctNGI1Ni04YzQ0LTQyNmYzZDY0ODdmNSJ9.00000000000000000000000000000000000000000000000
  • Kindly note that the signature part of the JWT is obfuscated as a security best practice.

If you take this JWT and parse it in jwt.io - you’ll get the same clear text json payload as at the beginning of this guide.

JWT Verification

So what should we do with this JWT? We need to verify it’s signature by using the Co-Signer public key and we need to decode it to JSON:

const jwt = require("jsonwebtoken");
const bodyParser = require("body-parser");

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json())
app.use( (req) => {
    req.rawBody = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      req.rawBody += chunk;
    });
    req.on("end", () => {
      req.next();
    });
  }
);

app.post("/v2/tx_sign_request", (req, res) => { 

	try {
		// jwt.verify returns the decoded payload on successful verification 
        const tx = jwt.verify(req.rawBody, cosignerPubKey);
    } catch(e) {
	    console.error(e)
        res.sendStatus(401).send();
    }
}

Building the ETH transaction object

So now we have set the authentication and if the token is verified we can actually move on to the transaction validation part. Let define a new function validateETHTransaction. It will get the plain text payload and will build an ETH transaction object from the RLP encoded hex:

const { FeeMarketEIP1559Transaction } = require('@ethereumjs/tx');

const validateETHTransaction = (payload) => {
   
    // Decode RLP
    const unsignedTx = FeeMarketEIP1559Transaction.fromSerializedTx(
        Buffer.from(
            payload.rawTx[0].rawTx, 
            "hex"
        )
    )
}

Basically, we are taking the RLP encoded hex and by using the 'FeeMarketEIP1559Transaction class from @ethereumjs/tx, we are building the transaction object.

If we will print the unsignedTx object, we’ll get:

{
  "chainId": "0x1",
  "nonce": "0x4",
  "maxPriorityFeePerGas": "0x4d4ea640",
  "maxFeePerGas": "0x7c4b19ac5",
  "gasLimit": "0x5208",
  "to": "0x5dc69b1fbb13bafd09af88a782f0f285772ad5f8",
  "value": "0x2386f26fc10000",
  "data": "0x",
  "accessList": []
}

Verifying the transaction parameters

So now we can actually check whether the destination and the amount in the decoded transaction match the JSON values of the payload:

const { FeeMarketEIP1559Transaction } = require('@ethereumjs/tx');

const validateETHTransaction = (payload) => {
    // Decode RLP
    const unsignedTx = FeeMarketEIP1559Transaction.fromSerializedTx(
        Buffer.from(
            payload.rawTx[0].rawTx, 
            "hex"
        )
    )

    return (
        parseFloat(unsignedTx.value, 16) / 1e18 === payload.destinations[0].amountNative 
        && 
        unsignedTx.to.toString("hex") === payload.destinations[0].displayDstAddress.toLowerCase() 
    )
}
  • Note that the address in the payload is in checksum format hence we need to lower case it

We can actually add one more check - verify that the hash of the unsignedTx matches the provided hash in the payload:

const { FeeMarketEIP1559Transaction } = require('@ethereumjs/tx');

const validateETHTransaction = (payload) => {
    const providedHash = payload.rawTx[0].payload;
    // Decode RLP
    const unsignedTx = FeeMarketEIP1559Transaction.fromSerializedTx(
        Buffer.from(
            payload.rawTx[0].rawTx, 
            "hex"
        )
    )
    
    return (
        parseFloat(unsignedTx.value, 16) / 1e18 === payload.destinations[0].amountNative 
        && 
        unsignedTx.to.toString("hex") === payload.destinations[0].displayDstAddress.toLowerCase() 
        && 
        unsignedTx.getMessageToSign(true).toString("hex") == providedHash
    )
}

What do we do here? We are taking the hash from the payload provided by the Co-Signer and comparing it with the result of unsignedTx.getMessageToSign(true) which returns the hash of the unsigned transaction that should be signed.

Callback Handler response

The response from the callback should be in the following format (signed with RS256 algorithm by using the callback private key):

{
	action: 'APPROVE' OR 'REJECT',
	requestId: 'The unique identifier of the call that was received in the approval request',
	rejectionReason: (Optional) 'Free text of the rejection reason for logging purposes'
}

Let’s just add one more function (my personal preference) that will generate the signed response that should be sent from the callback handler:

const generateSignedResponse = (action) => {
    
    const signedRes = jwt.sign(
        action,
        privateKey,
        { algorithm: "RS256" }
    );

    return signedRes
}

All parts together

const fs = require("fs");
const express = require("express");
const jwt = require("jsonwebtoken");
const bodyParser = require("body-parser");
const { FeeMarketEIP1559Transaction } = require('@ethereumjs/tx');

// Read the callback handler private key
const privateKey = fs.readFileSync("private.pem");

// Read the cosigner public key (you can get it by running: ./cosigner print-public-key on the cosigner machine)
const cosignerPubKey = fs.readFileSync("cosigner_public.pem");

// Start express app and set middleware 
const port = 8080; 
const app = express();

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json())
app.use( (req) => {
    req.rawBody = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      req.rawBody += chunk;
    });
    req.on("end", () => {
      req.next();
    });
  }
);

// Generate JWT response
const generateSignedResponse = (action) => {
    
    const signedRes = jwt.sign(
        action,
        privateKey,
        { algorithm: "RS256" }
    );
    
    return signedRes
}

// Verify that the params of the rawTx match the payload
const validateETHTransaction = (payload) => {    
    
    const providedHash = payload.rawTx[0].payload
    // Decode RLP
    const unsignedTx = FeeMarketEIP1559Transaction.fromSerializedTx(
        Buffer.from(payload.rawTx[0].rawTx, "hex")) 
    return (
        parseFloat(unsignedTx.value, 16) / 1e18 === payload.destinations[0].amountNative 
        && 
        unsignedTx.to.toString("hex") === payload.destinations[0].displayDstAddress.toLowerCase() 
        && 
        unsignedTx.getMessageToSign(true).toString("hex") == providedHash
    )
}

// Tx Sign Request endpoint 
app.post("/v2/tx_sign_request", (req, res) => { 
    
    try {
        let response; 
        const tx = jwt.verify(req.rawBody, cosignerPubKey);
        if (validateETHTransaction(tx)) {
            response = generateSignedResponse({
                action: "APPROVE",
                requestId: tx.requestId
            })
       } else {
            response = generateSignedResponse({
                action: "REJECT",
                requestId: tx.requestId,
                rejectionReason: `Failed to validate ETH transaction`
           	})
       }
        
        res.status(200).send(response);
    
    } catch(e) {
        console.error(e)
        res.sendStatus(401).send();
    }
});

console.log(`Callback is running on http://localhost:${port}`)
app.listen(port);