EOSIO - How to pay for users' CPU

Categories:

EOSIO 1.8 introduced the ONLY_BILL_FIRTH_AUTHORIZER feature which only bills CPU costs of a transaction to the first account authorizing it. Which in turn allows any account to pay for the CPU costs of a transaction of another user. For example, dapps can pay for their users’ CPU, or, if you have many EOS accounts yourself, you can designate one of your accounts as the CPU payer and make use it to pay for the transactions of all your other accounts.

The way it works is by prepending the actual transaction with a dummy action that is authorized/signed by the CPU payer account. The dummy action should itself consume minimal CPU, therefore a no-operation (noop) action is usually used.

Assuming your intention is to transfer some EOS tokens, the final transaction with a prepended CPU payer action authorized by a different account might look like this transaction:

ONLY_BILL_FIRTST_AUTHORIZER CPU paying action

As you can see the transaction now requires signatures from two accounts. Creating a single signature from one account is sometimes called cosigning.

It’s also possible to just authorize the actual action - in our case the eosio.token::transfer - by the CPU payer in addition to the transferring account. The ONLY_BILL_FIRTST_AUTHORIZER would still apply to the desired CPU payer. However, this can lead to security issues if you don’t know what you’re doing and blindly cosign any transaction. In our case, it would allow transferring the tokens also from the CPU paying account because the transfer action now has the CPU payer’s authorization. Prepending and signing a noop-action is more foolproof and therefore my recommended way.

How to use ONLY_BILL_FIRST_AUTHORIZER with eos-transit/scatter-js/eos-js

That just about wraps up the theory - let’s see how it’s possible to use this feature in practice. Surprisingly, there’s not a lot of example code yet, so I created an example repo demonstrating the ONLY_BILL_FIRST_AUTHORIZER feature.

It consists of a server and a frontend part which is a fork of EOS NY/EOS Titan’s eos-transit example.

  1. server: You won’t get around using a backend to accept/deny the transaction requests. Remember that we need to authorize and sign with the CPU payer’s keys, so we cannot store them in the frontend. Otherwise, they can be extracted and used to sign any transaction. What we’ll implement is a small function residing on the server that approves/disapproves a CPU paying request for a transaction. If approved, it prepends a noop action and cosigns the request. Serverless functions, like AWS Lambda or Google Cloud functions, are perfect for this task.
  2. client: The client uses eos-transit, a multi-wallet signature provider, to cosign the transaction from the user’s perspective. If you’re not using eos-transit, don’t worry, the example also works scatter-js or any other abstraction on top of eosjs’s Api object.

Client

Let’s start with the client code. The idea is to send the transaction to the server which signs it, then request a signature from the user, and finally merge both signatures, and send the serialized transaction with the combined signatures to a standard EOS node endpoint.

// this also affects the serialization
const transactionHeader = {
  blocksBehind: 3,
  expireSeconds: 60,
}

const sendTransaction = async actions => {
  const tx = {
    actions,
  }

  let pushTransactionArgs: PushTransactionArgs

  let serverTransactionPushArgs: PushTransactionArgs | undefined
  try {
    serverTransactionPushArgs = await serverSign(tx, transactionHeader)
  } catch (error) {
    console.error(`Error when requesting server signature: `, error.message)
  }

  if (serverTransactionPushArgs) {
    // just to initialize the ABIs and other structures on api
    // https://github.com/EOSIO/eosjs/blob/master/src/eosjs-api.ts#L214-L254
    await wallet.eosApi.transact(tx, {
      ...transactionHeader,
      sign: false,
      broadcast: false,
    })

    // fake requiredKeys to only be user's keys
    const requiredKeys = await wallet.eosApi.signatureProvider.getAvailableKeys()
    // must use server tx here because blocksBehind header might lead to different TAPOS tx header
    const serializedTx = serverTransactionPushArgs.serializedTransaction
    const signArgs = {
      chainId: wallet.eosApi.chainId,
      requiredKeys,
      serializedTransaction: serializedTx,
      abis: [],
    }
    pushTransactionArgs = await wallet.eosApi.signatureProvider.sign(signArgs)
    // add server signature
    pushTransactionArgs.signatures.unshift(
      serverTransactionPushArgs.signatures[0]
    )
  } else {
    // no server response => sign original tx
    pushTransactionArgs = await wallet.eosApi.transact(tx, {
      ...transactionHeader,
      sign: true,
      broadcast: false,
    })
  }

  return wallet.eosApi.pushSignedTransaction(pushTransactionArgs)
}

async function serverSign(
  transaction: any,
  txHeaders: any
): Promise<PushTransactionArgs> {
  // insert your server cosign endpoint here
  const rawResponse = await fetch('http://localhost:3031/api/eos/sign', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ tx: transaction, txHeaders }),
  })

  const content = await rawResponse.json()
  if (content.error) throw new Error(content.error)

  const pushTransactionArgs = {
    ...content,
    serializedTransaction: Buffer.from(content.serializedTransaction, `hex`),
  }

  return pushTransactionArgs
}

The code is not as straight-forward as just calling eosApi.transact(), because eosjs was never intended to do any cosigning. Instead, we use several calls to the eosApi methods to manually build and cosign the transaction. We fake the requiredKeys variable, which would by default be both the user and the CPU payer’s keys, to just be the user’s keys. This enables cosigning.

Prepending the CPU payer action and then checking if a free CPU request is granted all happens on the server.

If the server is down or does not grant the request, we fall back to signing the original transaction with the current wallet user as the payer.

Server

On the server-side, we check the actions in the transaction, prepend the no-operation action used for paying for CPU (named payforcpu), and sign it with the payforcpu permission of our dapp contract.

import { Request, Response, Router, Express } from 'express'
import { BAD_REQUEST, CREATED, OK } from 'http-status-codes'
import { api } from 'src/eos/api'
import { PushTransactionArgs } from 'eosjs/dist/eosjs-rpc-interfaces'
import { getNetwork } from 'src/eos/networks'

const router = Router()

const buffer2hex = (buffer: Uint8Array) =>
  Array.from(buffer, (x: number) => ('00' + x.toString(16)).slice(-2)).join('')

// we allow actions on this contract
const ALLOWED_CONTRACT = `dappcontract`
const checkAction = (action: any): void => {
  switch (action.account) {
    case `eosio.token`: {
      if (action.data.to !== ALLOWED_CONTRACT) {
        throw new Error(
          `Free CPU for transfers to other contracts is not granted.`
        )
      }
      return
    }
    case ALLOWED_CONTRACT: {
      // any internal action except payforcpu is fine
      // we don't want someone to DDOS by sending only payforcpu actions
      if (action.name === `payforcpu`) {
        throw new Error(`Don't include duplicate payforcpu actions.`)
      }
      return
    }
    default: {
      throw new Error(
        `Free CPU for actions on ${action.account} is not granted.`
      )
    }
  }
}

const checkTransaction = (tx: any): void => {
  tx.actions.forEach(checkAction)
}

router.post('/sign', async (req: Request, res: Response) => {
  try {
    const { tx, txHeaders = {} } = req.body
    if (!tx || !tx.actions) {
      return res.status(BAD_REQUEST).json({
        error: `No transaction passed`,
      })
    }

    checkTransaction(tx)

    // insert cpu payer's payforcpu action as first action to trigger ONLY_BILL_FIRST_AUTHORIZER
    tx.actions.unshift({
      account: ALLOWED_CONTRACT,
      name: 'payforcpu',
      authorization: [
        {
          actor: ALLOWED_CONTRACT,
          permission: `payforcpu`,
        },
      ],
      data: {},
    })

    // https://github.com/EOSIO/eosjs/blob/master/src/eosjs-api.ts#L214-L254
    // get the serialized transaction
    let pushTransactionArgs: PushTransactionArgs = await api.transact(tx, {
      blocksBehind: txHeaders.blocksBehind,
      expireSeconds: txHeaders.expireSeconds,
      // don't sign yet, as we don't have all keys and signing would fail
      sign: false,
      // don't broadcast yet, merge signatures first
      broadcast: false,
    })

    // JSSignatureProvider throws errors when encountering a key that it doesn't have a private key for
    // so we cannot use it for partial signing unless we change requiredKeys
    // https://github.com/EOSIO/eosjs/blob/849c03992e6ce3cb4b6a11bf18ab17b62136e5c9/src/eosjs-jssig.ts#L38
    const availableKeys = await api.signatureProvider.getAvailableKeys()
    const serializedTx = pushTransactionArgs.serializedTransaction
    const signArgs = {
      chainId: getNetwork().chainId,
      requiredKeys: availableKeys,
      serializedTransaction: serializedTx,
      abis: [],
    }
    pushTransactionArgs = await api.signatureProvider.sign(signArgs)

    const returnValue = {
      ...pushTransactionArgs,
      serializedTransaction: buffer2hex(
        pushTransactionArgs.serializedTransaction
      ),
    }
    return res.status(CREATED).json(returnValue)
  } catch (err) {
    console.error(err.message)
    return res.status(BAD_REQUEST).json({
      error: err.message,
    })
  }
})

export default router

The cosigning code is similar to the one we saw on the client-side. The new part is the code where the transaction is checked. Here, we only grant free CPU for actions on our dapp contract, or for eosio.token transfers to it.

This code can now be deployed as a serverless function and you can pay for your users’ transactions involving your dapp in a secure way, while still being compatible with all wallets. 🚀

The full example is available on Github.

Learn EOS Development Signup

Hi, I'm Christoph Michel 👋

I'm a , , and .

Currently, I mostly work in software security and do on an independent contractor basis.

I strive for efficiency and therefore track many aspects of my life.