Sign the Transaction Offline

This article covers some crucial details on the offline transaction signing flow, such as new extended request parameter with additional metadata.

For Polkadot transaction signing instructions, refer to the transaction signing section.

Extended metadata

All Polkadot endpoints, generating staking transactions, now support an optional boolean parameter extended. When it set to true, the response includes additional metadata, which can be required for offline transaction signing and external processing.

Examples

Example request with extended: true to /api/v1/polkadot/{network}/staking/bond:

curl --request POST \
--url https://api.p2p.org/api/v1/polkadot/westend/staking/bond \
--header 'accept: application/json' \
--header 'authorization: Bearer <token>' \
--header 'content-type: application/json' \
--data '
{
"stashAccountAddress": "5Eh1C69jwNgBPtp2oKxJ7Zy5dS6LJn3XZ6CJHPwcAB2iFpZQ",
"rewardDestinationType": "account",
"rewardDestination": "5Eh1C69jwNgBPtp2oKxJ7Zy5dS6LJn3XZ6CJHPwcAB2iFpZQ",
"amount": 1,
"extended": true
}'

Example response:

{
    "error": null,
    "result": {
        "unsignedTransaction": "0xa8040600070010a5d4e8037427a7dee592c47c6875266357ee7afd763ddfdf6bd1634e410b7335fc38331e",
        "unsignedTransactionSerialized": "7b2261646472657373223a22354568314336396a774e6742507470326f4b784a375a79356453364c4a6e33585a36434a485077634142326946705a51222c22626c6f636b48617368223a22307835333039616433373261623530396633363331666664666163633130633066386433396138303339616664666432353265393564653263336636653064306236222c22626c6f636b4e756d626572223a2230783031383734346262222c22657261223a22307862353033222c2267656e6573697348617368223a22307865313433663233383033616335306538663666386536323639356431636539653465316436386161333663316364326366643135333430323133663334323365222c226d65746164617461527063223a223078222c226d6574686f64223a22307830363030303730303130613564346538303337343237613764656535393263343763363837353236363335376565376166643736336464666466366264313633346534313062373333356663333833333165222c226e6f6e6365223a2230783030303030303136222c227369676e6564457874656e73696f6e73223a5b22436865636b4e6f6e5a65726f53656e646572222c22436865636b5370656356657273696f6e222c22436865636b547856657273696f6e222c22436865636b47656e65736973222c22436865636b4d6f7274616c697479222c22436865636b4e6f6e6365222c22436865636b576569676874222c224368617267655472616e73616374696f6e5061796d656e74222c22436865636b4d6574616461746148617368222c225765696768745265636c61696d225d2c227370656356657273696f6e223a2230783030306638383932222c22746970223a2230783030303030303030303030303030303030303030303030303030303030303030222c227472616e73616374696f6e56657273696f6e223a2230783030303030303162222c2276657273696f6e223a347d",
        "unsignedTransactionPayload": "0x0600070010a5d4e8037427a7dee592c47c6875266357ee7afd763ddfdf6bd1634e410b7335fc38331e",
        "unsignedTransactionObject": {
            "blockHash": "0x339985858981806fa80352e0ea533f94e5f92855296aa24b63b00fee7e40a448",
            "eraPeriod": 64,
            "currentEra": 9186,
            "genesisHash": "0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e",
            "metadataRpc": "0x",
            "method": {
                "args": {
                    "value": "1,000,000,000,000",
                    "payee": {
                        "Account": "5Eh1C69jwNgBPtp2oKxJ7Zy5dS6LJn3XZ6CJHPwcAB2iFpZQ"
                    }
                },
                "method": "bond",
                "section": "staking"
            },
            "nonce": 22,
            "specVersion": 1018002,
            "transactionVersion": 27,
            "tip": 0
        },
        "stashAccountAddress": "5Eh1C69jwNgBPtp2oKxJ7Zy5dS6LJn3XZ6CJHPwcAB2iFpZQ",
        "rewardDestinationType": "account",
        "rewardDestination": "5Eh1C69jwNgBPtp2oKxJ7Zy5dS6LJn3XZ6CJHPwcAB2iFpZQ",
        "amount": 1,
        "createdAt": "2025-04-17T13:25:14.508Z"
    }
}
  • unsignedTransaction — unsigned transaction in the hexadecimal format.
  • unsignedTransactionSerialized — unsigned serialized transaction containing all the data.
  • unsignedTransactionPayload — only raw payload of the unsigned transaction in the hexadecimal format.
  • unsignedTransactionObject — full decoded transaction structure with all the fields and metadata:
    • blockHash— hash of the checkpoint block in which the transaction was included.
    • eraPeriod — validity period of the transaction, representing the number of blocks following after the checkpoint for which the transaction is valid.
    • currentEra — current staking era of the transaction.
    • genesisHash — hash of the genesis block.
    • metadataRpc — serialized metadata used for offline decoding and transaction signing.
    • method is the list of data fields containing information on the method called to construct a transaction.
    • nonce — nonce of the transaction.
    • specVersion — current version of the chain specification for the runtime.
    • transactionVersion — current version of the transaction format.
    • tip — optional fee used to increase the transaction priority.
  • stashAccountAddress — main stash account address which keeps tokens for bonding.
  • rewardDestinationType — rewards destination type:
    • staked — rewards will be sent to the stash account and added to the current bond (compounding rewards).
    • stash — rewards will be sent to the stash account as a transferrable balance (not compounding rewards).
    • account — rewards will be sent to any account specified as a transferrable balance.
  • rewardDestination — rewards destination account address.
  • amount — amount of tokens to bond. DOT is used for the main network, KSM for Kusama, and WND for Westend.
  • createdAt — timestamp of the transaction in the ISO 8601 format.

Supported endpoints

The extended parameter is available for the following endpoints:

Offline transaction signing

To sign the transaction offline, follow these steps:

  1. Prepare the unsignedTransactionSerialized object.

  2. Sign the transaction in your preferable way of signing.

    Example (for westend network):

    /*
      Instructions:
      1. Install Sidecar and run with the environment variable for Substrate URL:
     yarn global add @substrate/api-sidecar
         SAS_SUBSTRATE_URL=wss://westend-ws-proxy.polka.p2p.world substrate-api-sidecar
      2. Install dependencies:
         yarn install
      3. Run the script:
         npx ts-node test.ts
    */
    const API_BASE_URL = 'https://api.p2p.org';
    const TOKEN = '*******';
    const WS_PROVIDER_URL = 'wss://westend-ws-proxy.polka.p2p.world';
    const SECRET_FILE_PATH = 'secret.json';
    const TEST_PASSWORD = 'testtest';
    const SIDECAR_URL = 'http://localhost:8080/transaction';
    const STASH_ACCOUNT_ADDRESS =
      '5GW6GmWBPhbpWPwRkria91Dn8EjTxEoqVorZxE9gkvTCHVN8';
    
    import { readFileSync } from 'fs';
    import { Keyring, WsProvider } from '@polkadot/api';
    import { cryptoWaitReady } from '@polkadot/util-crypto';
    import {
      construct,
      getRegistry,
      KeyringPair,
      UnsignedTransaction,
    } from '@substrate/txwrapper-polkadot';
    import { ApiPromise } from '@polkadot/api/cjs/bundle';
    
    interface ApiResponse {
      result: {
        unsignedTransactionSerialized: string;
      };
    }
    
    interface TransactionResponse {
      hash: string;
    }
    
    async function getBondTx(data: any): Promise<ApiResponse> {
      return fetchData(
        `${API_BASE_URL}/api/v1/polkadot/westend/staking/bond`,
        data
      );
    }
    
    async function getBondExtraTx(data: any): Promise<ApiResponse> {
      return fetchData(
        `${API_BASE_URL}/api/v1/polkadot/westend/staking/bond-extra`,
        data
      );
    }
    
    async function fetchData(url: string, data: any): Promise<ApiResponse> {
      try {
        const response = await fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
            Authorization: 'Bearer ' + TOKEN,
          },
          body: JSON.stringify(data),
        });
        return (await response.json()) as ApiResponse;
      } catch (error) {
        console.error('Error fetching data:', error);
        throw error;
      }
    }
    
    async function fetchMetadata(): Promise<string> {
      const provider = new WsProvider(WS_PROVIDER_URL);
      const api = await ApiPromise.create({ provider });
    
      const metadataRpc = await api.rpc.state.getMetadata();
      await api.disconnect();
    
      return metadataRpc.toHex();
    }
    
    function createKeypair(): KeyringPair {
      const keyring = new Keyring({ type: 'sr25519' });
      const keyInfo = JSON.parse(readFileSync(SECRET_FILE_PATH, 'utf8'));
      const sender = keyring.addFromJson(keyInfo);
      sender.decodePkcs8(TEST_PASSWORD);
    
      return sender;
    }
    
    function signTransaction(unsignTx: string, metadataRpc: `0x{string}`): string {
      const keypair = createKeypair();
      const registry = getRegistry({
        chainName: 'Polkadot',
        specName: 'westend',
        specVersion: 1018001,
        metadataRpc,
      });
    
      const serialized = unsignTx;
      const jsonString = Buffer.from(serialized, 'hex').toString('utf-8');
      const unsigned = JSON.parse(jsonString);
    
      const extrinsicPayload = registry.createType('ExtrinsicPayload', unsigned, {
        version: unsigned.version,
      });
    
      const signature = extrinsicPayload.sign(keypair).signature;
    
      const rawT = construct.signedTx(
        unsigned as unknown as UnsignedTransaction,
        signature,
        {
          metadataRpc,
          registry,
          userExtensions: { SetEvmOrigin: { payload: {}, extrinsic: {} } },
        }
      );
      return rawT;
    }
    
    async function sendTransaction(
      transactionData: string
    ): Promise<TransactionResponse> {
      try {
        const response = await fetch(SIDECAR_URL, {
          method: 'POST',
          headers: {
            Accept: 'application/json',
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ tx: transactionData }),
        });
        return (await response.json()) as TransactionResponse;
      } catch (error) {
        console.error('Error sending transaction:', error);
        throw error;
      }
    }
    
    void (async () => {
      await cryptoWaitReady();
    
      // Get Bond Transaction
      const unsignTx = await getBondTx({
        stashAccountAddress: STASH_ACCOUNT_ADDRESS,
        rewardDestinationType: 'account',
        rewardDestination: STASH_ACCOUNT_ADDRESS,
        amount: 1,
        extended: true,
      });
      /*
      // Get Bond Extra Transaction
      const unsignTx = await getBondExtraTx({
        stashAccountAddress: STASH_ACCOUNT_ADDRESS,
        amount: 0.001,
        extended: true,
      });
      */
      console.log('unsignTx', unsignTx);
    
      // Fetch Metadata
      const metadataRpc = await fetchMetadata();
    
      // Sign Transaction
      const signTx = signTransaction(
        unsignTx.result.unsignedTransactionSerialized,
        metadataRpc as `0x{string}`
      );
      console.log('signTx', signTx);
    
      // Send Signed Transaction
      const result = await sendTransaction(signTx);
      console.log('result', result);
    })();
    

What's Next?