Anatomy of a Write Transaction

Overview

If you want to roll your own write transactions without the use of an SDK, there are four main steps, outlined below, you should follow:

  1. Create a transaction digest.
  2. Serialize transaction.
  3. Sign the serialized transaction.
  4. Push the transaction to an API node.
  5. Verify transaction.

Create transaction digest

Transaction digests are created within an application by instantiating a transaction object and pushing the related action instances into a list within the transaction instance. An action instance contains the actual details about the receiver account to whom the action is intended, the name of the action, the list of actors and permission levels that must authorize the transaction via signatures and delays, and the actual message to be sent, if any.

Below example uses addaddress action. For complete list of actions see FIO Chain Action API spec.

const fetch = require('node-fetch')
const httpEndpoint = 'https://fiotestnet.blockpane.com'

// Create keypair, fund from the faucet, and register a FIO Handle on the Testnet monitor (http://monitor.testnet.fioprotocol.io).
const user = {
    privateKey: '',
    publicKey: '',
    account: '',
    address: ''
}

// You can find these action parameters in the FIO Chain Action API spec
const contract = 'fio.address'
const action = 'addaddress'

// Example data for addaddress
const data = {
    fio_address: user.address,
    public_addresses: [
        {
        chain_code: 'BCH',
        token_code: 'BCH',
        public_address: 'bitcoincash:somebitcoincashpublicaddress'
        },
        {
        chain_code: 'DASH',
        token_code: 'DASH',
        public_address: 'somedashpublicaddress'
        }
    ],
    max_fee: 600000000,
    tpid: 'rewards@wallet',
    actor: user.account
}

info = await (await fetch(httpEndpoint + '/v1/chain/get_info')).json();
blockInfo = await (await fetch(httpEndpoint + '/v1/chain/get_block', { body: `{"block_num_or_id": ${info.last_irreversible_block_num}}`, method: 'POST' })).json()
chainId = info.chain_id;
currentDate = new Date();
timePlusTen = currentDate.getTime() + 10000;
timeInISOString = (new Date(timePlusTen)).toISOString();
expiration = timeInISOString.substr(0, timeInISOString.length - 1);

transaction = {
    expiration,
    ref_block_num: blockInfo.block_num & 0xffff,
    ref_block_prefix: blockInfo.ref_block_prefix,
    actions: [{
        account: contract,
        name: action,
        authorization: [{
        actor: user.account,
        permission: 'active'
        }],
        data: data
    }]
};

Transaction digest schema

ParameterTypeDescription
expirationstring-date-timeThe time the transaction must be confirmed by before it expires. The max this can be set at is 3600 seconds. The SDK default for this value is (FIO Chain head block time + 180 seconds).
ref_block_numintegerLower 16 bits of a block number.
ref_block_prefixintegerLower 32 bits of block id referred by ref_block_num.
context_free_actionsarrayArray of context-free actions if any
actionsarrayArray of actions
transaction_extensionsextensions_typeExtends fields to support additional features

Transaction digest example

{
  "expiration": "2023-07-25T21:22:30.415Z",
  "ref_block_num": 38096,
  "ref_block_prefix": 505360011,
  "actions": [
    {
      "account": "fio.token",
      "name": "trnsfiopubky",
      "authorization": [
        {
          "actor": "aftyershcu22",
          "permission": "active"
        }
      ],
      "data": {
        "payee_public_key": "FIO8PRe4WRZJj5mkem6qVGKyvNFgPsNnjNN6kPhh6EaCpzCVin5Jj",
        "amount": 1000000000,
        "max_fee": 250000000,
        "tpid": "rewards@wallet",
        "actor": "aftyershcu22"
      }
    }
  ],
  "transaction_extensions": null
}

Serialize Transaction

Next, the completed transaction is serialized. Serializing a transaction takes two steps:

Serialize the data in the actions field

For this example we use the fiojs serializer which is the same as that used by eosjs.

const { TextEncoder, TextDecoder } = require('text-encoding');
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
const { base64ToBinary, arrayToHex } = require('@fioprotocol/fiojs/dist/chain-numeric');
var ser = require("@fioprotocol/fiojs/dist/chain-serialize");

// Retrieve the fio.address ABI
abiFioAddress = await (await fetch(httpEndpoint + '/v1/chain/get_abi', { body: `{"account_name": "fio.address"}`, method: 'POST' })).json();
rawAbi = await (await fetch(httpEndpoint + '/v1/chain/get_raw_abi', { body: `{"account_name": "fio.address"}`, method: 'POST' })).json()
const abi = base64ToBinary(rawAbi.abi);

// Get a Map of all the types from fio.address
var typesFioAddress = ser.getTypesFromAbi(ser.createInitialTypes(), abiFioAddress.abi);

// Get the addaddress action type
const actionAddaddress = typesFioAddress.get('addaddress');

// Serialize the actions[] "data" field (This example assumes a single action, though transactions may hold an array of actions.)
const buffer = new ser.SerialBuffer({ textEncoder, textDecoder });
actionAddaddress.serialize(buffer, transaction.actions[0].data);
serializedData = arrayToHex(buffer.asUint8Array())

// Get the actions parameter from the transaction and replace the data field with the serialized data field
serializedAction = transaction.actions[0]
serializedAction = {
    ...serializedAction,
    data: serializedData
};

Serialize the entire transaction

abiMsig = await (await fetch(httpEndpoint + '/v1/chain/get_abi', { body: `{"account_name": "eosio.msig"}`, method: 'POST' })).json()

var typesTransaction = ser.getTypesFromAbi(ser.createInitialTypes(), abiMsig.abi)

// Get the transaction action type
const txnaction = typesTransaction.get('transaction');

rawTransaction = {
    ...transaction,
    max_net_usage_words: 0,
    max_cpu_usage_ms: 0,
    delay_sec: 0,
    context_free_actions: [],
    actions: [serializedAction],     //Actions have to be an array
    transaction_extensions: [],
}

// Serialize the transaction
const buffer2 = new ser.SerialBuffer({ textEncoder, textDecoder });
txnaction.serialize(buffer2, rawTransaction);
serializedTransaction = buffer2.asUint8Array()

Sign transaction

Next the transaction is signed with the private key associated with the signing account’s public key. The public-private key pair is usually stored within the local machine that connects to the local node. The signing process is performed within the wallet manager associated with the signing account, which is typically the same user that deploys the application. The wallet manager provides a virtual secure enclave to perform the digital signing, so a message signature is generated without the private key ever leaving the wallet.

The transaction must be signed by a set of keys sufficient to satisfy the accumulated set of explicit actor:permission pairs specified in all the actions enclosed within the transaction. This linkage is done through the authority table for the given permission (see Accounts & Permissions). The actual FIO Private key used for signing is obtained by querying the wallet on the client where the application is run.

The transaction signing process takes three parameters:

  1. The chain ID
  2. The transaction instance to sign
  3. The set of public keys from which the associated private keys within the application wallet are retrieved

For this example we use the fiojs signature provider which is the same as that used by eosjs.

const { JsSignatureProvider } = require('@fioprotocol/fiojs/dist/chain-jssig');
const signatureProvider = new JsSignatureProvider([user.privateKey]);

requiredKeys = [user.publicKey]
serializedContextFreeData = null;

signedTxn = await signatureProvider.sign({
    chainId: chainId,
    requiredKeys: requiredKeys,
    serializedTransaction: serializedTransaction,
    serializedContextFreeData: serializedContextFreeData,
    abis: abi,
});

Push transaction

After the transaction is signed, a packed transaction instance is created from the signed transaction instance and pushed from the application to a local FIO node, which in turn relays the transaction to the active producing nodes for signature verification, execution, and validation.

const txn = {
    signatures: signedTxn.signatures,
    compression: 0,
    packed_context_free_data: arrayToHex(serializedContextFreeData || new Uint8Array(0)),
    packed_trx: arrayToHex(serializedTransaction)
}

pushResult = await fetch(httpEndpoint + '/v1/chain/push_transaction', {
    body: JSON.stringify(txn),
    method: 'POST',
});

jsonResult = await pushResult.json()

if (jsonResult.transaction_id) {
    console.log('Success. \nTransaction: ', jsonResult);
} else if (jsonResult.code) {
    console.log('Error: ', jsonResult.error);
} else {
    console.log('Error: ', jsonResult)
}

Every producing node that receives a transaction will attempt to execute and validate it in their local context before relaying it to the next producing node. Hence, valid transactions are relayed while invalid ones are dropped. The idea behind this is to prevent bad actors from spamming the network with bogus transactions. The expectation is for bad transactions to get filtered and dropped before reaching the active producer on schedule. When a transaction is received, no assumption is made on its validity. All transactions are validated again by the next producing node, regardless of whether it is producing blocks. The only difference is that the producer on schedule attempts to produce blocks by pushing the transactions it validates into a pending block before pushing the finalized block to its own local chain and relaying it to other nodes.

See /push_transaction for schema and example.

Verify transaction

The process to verify a transaction is twofold. First, the public keys associated with the accounts that signed the transaction are recovered from the set of signatures provided in the transaction. Such a recovery is cryptographically possible for ECDSA, the elliptic curve digital signature algorithm used in FIO. Second, the public key of each actor specified in the list of action authorizations (actor:permission) from each action included in the transaction is checked against the set of recovered keys to see if it is satisfied. Third, each satisfied actor:permission is checked against the associated minimum permission required for that actor:contract::action pair to see if it meets or exceeds that minimum. This last check is performed at the action level before any action is executed.

Interpret transaction response

When a transaction is pushed the accepting node will provide a response. See /push_transaction for schema and example.

Meaningful response parameters:

ParameterDescription
transaction_idTransaction ID.
processed -> receipt -> statusTransaction status. Looking for executed
processed -> action_traces -> receipt -> responseAction-specific response as defined in FIO Chain Action API for specific action pushed.

What’s Next