Skip to main content

Documentation Index

Fetch the complete documentation index at: https://developers.fireblocks.com/llms.txt

Use this file to discover all available pages before exploring further.

Collect signatures from multiple Fireblocks vaults on a single Solana transaction by chaining program call requests. The first vault signs in sign-only mode, the platform returns a partially signed payload, and each subsequent vault signs that same payload in turn. The transaction bytes accumulate each signature until the last request follows the normal broadcast path. This pattern fits workflows where two or more vault-owned keys must sign the same message — for example, multisig-style arrangements or instructions that list multiple signers. The walkthrough below uses TypeScript with Solana’s JavaScript client and the Fireblocks TypeScript SDK as a concrete illustration. The same sequence of API calls and transaction fields applies if you use another language, generated client, or raw HTTPS to Fireblocks and your Solana RPC.

What you need

  • Fireblocks API credentials, loaded from a secret manager. Never commit API keys or signing keys to source control.
  • A Solana library or RPC client that can build transactions, set the fee payer and recent blockhash (or nonce), serialize a partially signed message, and submit to the network.
  • REST or SDK access to the Fireblocks transactions endpoints, with support for Solana program call parameters.
  • Vault accounts whose on-chain addresses match every signer you attach to the transaction.

Resolve each signer’s Solana public key

For each vault that will sign, resolve its Solana public key through Fireblocks using the vault account identifier, asset ID, and derivation parameters your workspace uses in production, so the keys match what Policies and routing expect.
import type { FireblocksResponse, PublicKeyInformation } from "@fireblocks/ts-sdk";
import { PublicKey } from "@solana/web3.js";

const pubkeyA: FireblocksResponse<PublicKeyInformation> =
  await fireblocks.vaults.getPublicKeyInfoForAddress({
    vaultAccountId: vaultAccountIdA,
    assetId: "SOL",
    change: 0,
    addressIndex: 0,
  });
const pubkeyB: FireblocksResponse<PublicKeyInformation> =
  await fireblocks.vaults.getPublicKeyInfoForAddress({
    vaultAccountId: vaultAccountIdB,
    assetId: "SOL",
    change: 0,
    addressIndex: 0,
  });
const pubkeyASolana = new PublicKey(hexToBuffer(assertPublicKeyHex(pubkeyA.data, "vault A")));
const pubkeyBSolana = new PublicKey(hexToBuffer(assertPublicKeyHex(pubkeyB.data, "vault B")));
Decode the hex-encoded public key material from the API response into raw bytes before constructing on-chain public key objects. Reject empty or missing key fields from the response.
import type { PublicKeyInformation } from "@fireblocks/ts-sdk";

function hexToBuffer(hexString: string): Buffer {
  const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
  return Buffer.from(cleanHex, "hex");
}

function assertPublicKeyHex(info: PublicKeyInformation, label: string): string {
  const pk = info.publicKey;
  if (!pk) {
    throw new Error(`Fireblocks response missing publicKey (${label})`);
  }
  return pk;
}

Build the instruction so every Fireblocks signer appears in the message

A typical transfer-style instruction marks only the sender as a signer. When two different keys must sign the same instruction, each of their public keys must appear in the instruction’s account list with the signer flag set. If an address is not a signer in the compiled message, neither the chain nor Fireblocks treats it as required for that transaction. The snippet uses a simple system transfer and marks the second party as an additional signer for illustration only — replace accounts and program layout with your real instruction.
import { SystemProgram, Transaction } from "@solana/web3.js";

const transaction = new Transaction();
const transferInstruction = SystemProgram.transfer({
  fromPubkey: pubkeyASolana,
  toPubkey: pubkeyBSolana,
  lamports: 100_000,
});
transferInstruction.keys.push({
  pubkey: pubkeyBSolana,
  isSigner: true,
  isWritable: true,
});
transaction.add(transferInstruction);
Set the fee payer to whichever signing account should pay cluster fees. Here, that’s the first signer’s public key.
transaction.feePayer = pubkeyASolana;

Set the recent blockhash and serialize for partial signing

Open an RPC session to your Solana cluster and fetch a recent blockhash. If you use a durable nonce workflow, fetch the nonce instead.
const { blockhash } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
const serializedTx = transaction
  .serialize({ requireAllSignatures: false })
  .toString("base64");
The recent blockhash is required to serialize the transaction unless you rely entirely on a durable nonce flow. If the first Fireblocks sign-only request is configured to apply a durable nonce, the platform can inject the nonce instruction and align the message with nonce state as part of processing. Serialization must allow missing signatures at this stage; otherwise the library refuses to output bytes before any signature exists.

Durable nonce timing

A durable nonce replaces the ephemeral recent blockhash so the transaction can stay valid while signing and Policy approval finish. A standard recent blockhash on Solana typically expires after roughly 60–90 seconds, which is often too short for multi-vault signing unless you use a nonce.
Warning: When the first sign-only request opts into durable nonce handling, the partially signed payload is tied to that nonce account state. If the consuming transaction is not broadcast within roughly 10 minutes of the first request reaching a fully signed state, the platform may advance the nonce and the bytes you hold become invalid. Refresh nonce state and repeat signing from the first signer when that happens.

First Fireblocks request: Sign only with the first vault

Create a Fireblocks transaction whose operation is a Solana program call, attach the base64-encoded partial transaction as the program-call payload, and mark the request as sign-only so the first vault adds its signature without treating this step as the final on-chain send.
import {
  TransactionOperation,
  TransferPeerPathType,
  type TransactionRequest,
} from "@fireblocks/ts-sdk";

const signOnlyRequest: TransactionRequest = {
  operation: TransactionOperation.ProgramCall,
  assetId: "SOL",
  source: { type: TransferPeerPathType.VaultAccount, id: vaultAccountIdA },
  extraParameters: {
    programCallData: serializedTx,
    signOnly: true,
    useDurableNonce: true,
  },
};

const createRes = await fireblocks.transactions.createTransaction({
  transactionRequest: signOnlyRequest,
});
Poll the transaction until it reaches the SIGNED state. Exit with an error if the platform reports FAILED, BLOCKED, or the poll exceeds your configured retry budget before you submit the next request.

Second Fireblocks request: Reuse the partially signed payload from the first response

After the first request reaches the SIGNED state, read the returned partially signed program-call payload from the transaction details endpoint. That blob already includes platform-side adjustments (such as durable nonce handling) and the first signature. Submit it again as the program-call payload on a new request whose source is the vault that owns the next missing signer. Some client libraries omit this response field from generated models; treat it as opaque base64 from the API if your typings do not list it yet.
const signedData = signedPoll.data.signedProgramCallData;
if (!signedData) {
  throw new Error("Expected signedProgramCallData after SIGNED status");
}

const broadcastRequest: TransactionRequest = {
  operation: TransactionOperation.ProgramCall,
  assetId: "SOL",
  source: { type: TransferPeerPathType.VaultAccount, id: vaultAccountIdB },
  extraParameters: {
    programCallData: signedData,
  },
};

const tx2 = await fireblocks.transactions.createTransaction({
  transactionRequest: broadcastRequest,
});
This chains the first signature into the same transaction bytes before the second vault signs. After this second signing step, the platform follows the usual broadcast path for a completed program call. To chain three or more vaults, mark every request as sign-only except the last.

Checklist

DetailWhy it matters
Every co-signing address is marked as a signer in the compiled message.Otherwise only one signer appears on chain, and additional vaults have nothing valid to sign.
The fee payer is a signing account you control.The fee payer must sign the transaction. Keep routing consistent with which vault pays fees.
Client serialization allows missing signatures at intermediate steps.Otherwise the library refuses to output bytes before signing.
Intermediate Fireblocks requests are sign-only.This keeps middle steps as “add signature” rather than final submission.
Each follow-up request reuses the updated program-call payload field from the API response.This guarantees each vault signs the same message, including prior signatures.
The source vault on each Fireblocks request matches the next missing signer.Signing order must follow the message’s required signature slots.

End-to-end example

The listing below is a single-file TypeScript illustration you can copy into your own project layout. Load API credentials and signing material from your environment or secret manager — never embed them in source control. The console.log calls are included for development visibility; remove them when integrating into production code.
import { readFileSync } from "fs";

import {
  Fireblocks,
  TransactionOperation,
  TransactionStateEnum,
  TransferPeerPathType,
  type CreateTransactionResponse,
  type FireblocksResponse,
  type PublicKeyInformation,
  type TransactionRequest,
  type TransactionResponse,
} from "@fireblocks/ts-sdk";
import {
  Connection,
  PublicKey,
  SystemProgram,
  Transaction,
} from "@solana/web3.js";

type TransactionResponseWithProgramCall = TransactionResponse & {
  signedProgramCallData?: string;
};

const PRIMARY_TRANSACTION_STATUS_SIGNED = "SIGNED" as const;

function requiredEnv(name: string): string {
  const v = process.env[name];
  if (!v) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return v;
}

function hexToBuffer(hexString: string): Buffer {
  const cleanHex = hexString.startsWith("0x")
    ? hexString.slice(2)
    : hexString;
  return Buffer.from(cleanHex, "hex");
}

function assertPublicKeyHex(info: PublicKeyInformation, label: string): string {
  const pk = info.publicKey;
  if (!pk) {
    throw new Error(`Fireblocks response missing publicKey (${label})`);
  }
  return pk;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function main(): Promise<void> {
  const apiSecret = readFileSync(
    requiredEnv("FIREBLOCKS_SECRET_KEY_PATH"),
    "utf8"
  );
  const apiKey = requiredEnv("FIREBLOCKS_API_KEY");
  const basePath =
    process.env.FIREBLOCKS_BASE_PATH ?? "https://api.fireblocks.io/v1";

  const fireblocks = new Fireblocks({
    apiKey,
    basePath,
    secretKey: apiSecret,
  });

  const vaultAccountIdA = requiredEnv("FIREBLOCKS_VAULT_A");
  const vaultAccountIdB = requiredEnv("FIREBLOCKS_VAULT_B");
  const solanaRpc =
    process.env.SOLANA_RPC_URL ?? "https://api.mainnet-beta.solana.com";

  const pubkeyA: FireblocksResponse<PublicKeyInformation> =
    await fireblocks.vaults.getPublicKeyInfoForAddress({
      vaultAccountId: vaultAccountIdA,
      assetId: "SOL",
      change: 0,
      addressIndex: 0,
    });
  const pubkeyB: FireblocksResponse<PublicKeyInformation> =
    await fireblocks.vaults.getPublicKeyInfoForAddress({
      vaultAccountId: vaultAccountIdB,
      assetId: "SOL",
      change: 0,
      addressIndex: 0,
    });

  const pubkeyA_buffer = hexToBuffer(assertPublicKeyHex(pubkeyA.data, "vault A"));
  const pubkeyB_buffer = hexToBuffer(assertPublicKeyHex(pubkeyB.data, "vault B"));

  const connection = new Connection(solanaRpc, "confirmed");

  const pubkeyASolana = new PublicKey(pubkeyA_buffer);
  const pubkeyBSolana = new PublicKey(pubkeyB_buffer);

  const transaction = new Transaction();
  const transferInstruction = SystemProgram.transfer({
    fromPubkey: pubkeyASolana,
    toPubkey: pubkeyBSolana,
    lamports: 100_000,
  });
  transferInstruction.keys.push({
    pubkey: pubkeyBSolana,
    isSigner: true,
    isWritable: true,
  });
  transaction.add(transferInstruction);

  transaction.feePayer = pubkeyASolana;

  const { blockhash } = await connection.getLatestBlockhash();
  transaction.recentBlockhash = blockhash;

  console.log("Blockhash: ", blockhash);

  const serializedTx = transaction
    .serialize({ requireAllSignatures: false })
    .toString("base64");
  console.log("Serialized TX: ", serializedTx);

  const signOnlyRequest: TransactionRequest = {
    operation: TransactionOperation.ProgramCall,
    assetId: "SOL",
    source: { type: TransferPeerPathType.VaultAccount, id: vaultAccountIdA },
    extraParameters: {
      programCallData: serializedTx,
      signOnly: true,
      useDurableNonce: true,
    },
  };

  const createRes: FireblocksResponse<CreateTransactionResponse> =
    await fireblocks.transactions.createTransaction({
      transactionRequest: signOnlyRequest,
    });

  const initialTxId = createRes.data.id;
  if (!initialTxId) {
    throw new Error("createTransaction did not return a transaction id");
  }

  let signedPoll: FireblocksResponse<TransactionResponseWithProgramCall> | null =
    null;
  for (let i = 0; i < 20; i++) {
    await sleep(5000);
    signedPoll = await fireblocks.transactions.getTransaction({
      txId: initialTxId,
    });
    const status = signedPoll.data.status;
    console.log(`tx ${initialTxId} status: ${status}`);
    if (status === PRIMARY_TRANSACTION_STATUS_SIGNED) {
      break;
    }
    if (
      status === TransactionStateEnum.Failed ||
      status === TransactionStateEnum.Blocked ||
      i === 19
    ) {
      process.exit(1);
    }
  }

  if (!signedPoll) {
    throw new Error("Polling did not load transaction state");
  }

  console.log(JSON.stringify(signedPoll.data));

  const signedData = signedPoll.data.signedProgramCallData;
  if (!signedData) {
    throw new Error(
      "Expected signedProgramCallData on SIGNED PROGRAM_CALL transaction"
    );
  }

  const broadcastRequest: TransactionRequest = {
    operation: TransactionOperation.ProgramCall,
    assetId: "SOL",
    source: { type: TransferPeerPathType.VaultAccount, id: vaultAccountIdB },
    extraParameters: {
      programCallData: signedData,
    },
  };

  const tx2 = await fireblocks.transactions.createTransaction({
    transactionRequest: broadcastRequest,
  });

  console.log("Tx2: ", tx2);
}

main().catch((err: unknown) => {
  console.error(err);
  process.exit(1);
});