Sign and broadcast transactions
Build transactions locally, request signatures from io.sign, and broadcast through your own RPC
These recipes use the standalone io.sign launch API to sign native-asset transfers. Your code builds the transaction, io.sign coordinates your signers, and your code sends the signed transaction to your own remote procedure call (RPC) endpoint. No io.vault transaction-building or broadcasting service is involved.
Choose a recipe
| Network | Recipe | Supported transaction |
|---|---|---|
| Bitcoin | Sign and broadcast BTC | One confirmed native P2WPKH input, a P2WPKH recipient, and change back to the same key. |
| EVM | Sign and broadcast EVM | EIP-1559 native-asset transfer on a configured chain. |
| Solana | Sign and broadcast SOL | Legacy SOL transfer with one Ed25519 signer and a recent blockhash. |
| XRP Ledger | Sign and broadcast XRP | Native XRP Payment from an Ed25519 master key, with an optional destination tag. |
Token transfers, Bitcoin Taproot/multiple inputs, Solana durable nonces, and XRP multisigning need different transaction construction. Do not apply these examples to those formats unchanged.
Prepare the vault and runtime
- Complete Get started with io.sign. The vault must have completed key generation, an appropriate curve, and enough eligible signers online to meet its threshold.
- Obtain the public key and address for the exact vault and, if used, derivation path. Confirm them through your environment's management interface or vault API. These examples never require a private key.
- Use Node.js 22 or later. Save the shared helper below as
io-sign.mjsand a recipe's script beside it. - Install the tested SDK versions in that directory:
npm install --save-exact [email protected] [email protected] [email protected] \
@solana/[email protected] [email protected] [email protected]The repository also contains the same scripts and a lockfile under examples/transactions; run npm ci there to use the locked dependency tree.
Create a local .env file with these shared settings and the chain-specific settings from your recipe:
IO_SIGN_API_URL=https://sign.example.com
IO_SIGN_TOKEN=<bearer-token-for-this-environment>
VAULT_ID=<vault-id>
# Optional: omit to sign with the root vault key.
# DERIVATION_PATH=m/0/1IO_SIGN_API_URL is the base origin, without /v2. Use a token authorised to create and read signature requests in the vault's organisation. Obtain it through your environment's configured authentication flow; these examples do not assume the older /v1/auth/accessToken endpoint exists on a standalone backend. Keep the token valid for the approval period. Do not commit populated .env files.
If DERIVATION_PATH is set, supply the matching derived public key/address in the chain settings. The launch API accepts non-hardened paths such as m/0/1. A public key/address for another path causes signature verification to fail.
Use test funds and test networks first. Running a recipe submits a signing request and broadcasts the resulting transaction after verification. Use network identifiers obtained independently from your deployment configuration so the script can detect an unintended RPC network.
What the signature endpoint signs
The launch endpoint does not treat every dataToSign value as a precomputed digest. Its algorithm determines how the supplied bytes are processed:
| Recipe | coseAlgorithm | Bytes in dataToSign | Signer processing |
|---|---|---|---|
| EVM | eskec256 | Unsigned typed transaction envelope | Keccak-256, then secp256k1 signing. |
| Bitcoin | es256k | SHA-256 of the BIP143 preimage | One further SHA-256, then secp256k1 signing. |
| Solana | eddsa | Compiled transaction message | Ed25519 signs the message. |
| XRP | eddsa | encodeForSigning bytes, including the signing prefix | Ed25519 signs the message. |
Submitting an already-computed EVM or Bitcoin final digest would hash it again and produce a signature the network cannot use. Each example verifies the final signature locally before broadcast.
The request contains exactly one entry in signatures. That entry has a dataToSign array; the completed response returns the corresponding array at signatures[0].signature. All examples use hexadecimal input without 0x, through application/octet-stream+hex.
The request's metadata.contextType and metadata.serializedData carry the transaction for signer review. They are distinct from the bytes to sign. In particular, Solana's application/solana-solwiretx-v1+base58 metadata contains a base58-encoded compiled message, despite the media type's name. Keep the metadata and signing input derived from the same immutable transaction.
Shared signature helper
The helper submits once, prints the request ID, and polls for completion for up to 10 minutes. The requested API expiry is 11 minutes; the launch API requires an expiry at least 10 minutes away. A blockchain transaction may expire sooner. The Solana and XRP recipes check their independent chain deadlines while waiting and before broadcast.
The current Virtual Signer returns hex signatures with a recovery suffix, including a zero suffix for Ed25519. The helper extracts the 64-byte Ed25519 signature and handles EVM recovery values that may occupy more than one byte when a chain ID is supplied.
import { createPublicKey, verify } from 'node:crypto';
import { pathToFileURL } from 'node:url';
export function required(name) {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}`);
return value;
}
export function positiveInteger(value, name) {
if (!/^[1-9][0-9]*$/.test(String(value))) throw new Error(`${name} must be a positive integer`);
return BigInt(value);
}
export function hexBytes(value) {
const hex = value.replace(/^0x/, '');
if (!/^(?:[0-9a-fA-F]{2})+$/.test(hex)) throw new Error('Invalid hexadecimal bytes');
return Buffer.from(hex, 'hex');
}
export function signatureBytes(value, algorithm) {
const bytes = hexBytes(value);
// The current Virtual Signer appends a zero recovery byte even for Ed25519.
if (algorithm === 'eddsa' && bytes.length === 64) return bytes;
if (bytes.length === 65 && (algorithm !== 'eddsa' || bytes[64] === 0)) return bytes.subarray(0, 64);
throw new Error(`Unexpected ${algorithm} signature encoding`);
}
export function ecdsaParts(value, chainId) {
const bytes = hexBytes(value);
if (bytes.length < 65) throw new Error('Expected r, s, and recovery suffix');
const v = BigInt('0x' + bytes.subarray(64).toString('hex'));
const parity = v <= 1n ? v : v === 27n || v === 28n ? v - 27n : v - 35n - 2n * BigInt(chainId);
if (parity !== 0n && parity !== 1n) throw new Error('Invalid recovery suffix or chain ID');
return { r: '0x' + bytes.subarray(0, 32).toString('hex'),
s: '0x' + bytes.subarray(32, 64).toString('hex'), yParity: Number(parity) };
}
export function verifyEd25519(message, publicKey, signature) {
if (publicKey.length !== 32 || signature.length !== 64) throw new Error('Invalid Ed25519 key or signature');
const key = createPublicKey({ format: 'der', type: 'spki',
key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(publicKey)]) });
if (!verify(null, message, key, signature)) throw new Error('Signature does not match the message and public key');
}
export async function requestSignature(config, item, checkFresh = async () => {}) {
const { apiUrl, token, vaultId, fetchImpl = fetch, intervalMs = 2000, maxWaitMs = 600_000 } = config;
const endpoint = `${apiUrl.replace(/\/$/, '')}/v2/vaults/${encodeURIComponent(vaultId)}/signatures`;
const deadline = Date.now() + maxWaitMs;
async function api(url, method, body) {
const response = await fetchImpl(url, {
method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`io.sign HTTP ${response.status}; inspect the existing request before retrying`);
return response.json();
}
await checkFresh();
// No automatic POST retry: a lost response does not prove creation failed.
const created = await api(endpoint, 'POST', {
expiresAt: new Date(Date.now() + 11 * 60_000).toISOString(), signatures: [
{ contentType: 'application/octet-stream+hex', ...item },
] });
if (!created.id) throw new Error('io.sign returned no request ID');
console.log(`io.sign request: ${created.id}`);
while (Date.now() < deadline) {
await checkFresh();
const result = await api(`${endpoint}/${encodeURIComponent(created.id)}`, 'GET');
if (result.status === 'completed') {
const signatures = result.signatures?.[0]?.signature;
if (!Array.isArray(signatures) || signatures.length !== item.dataToSign.length ||
!signatures.every((s) => typeof s === 'string')) throw new Error('Missing signature results');
await checkFresh();
return signatures;
}
if (['rejected', 'expired', 'failed', 'cancelled'].includes(result.status)) {
throw new Error(`io.sign request ${created.id}: ${result.status}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Stopped waiting for ${created.id}; this does not cancel it. Inspect its status before retrying.`);
}
export function signerFromEnvironment() {
const config = { apiUrl: required('IO_SIGN_API_URL'), token: required('IO_SIGN_TOKEN'), vaultId: required('VAULT_ID') };
return (item, checkFresh) => requestSignature(config, {
...item, ...(process.env.DERIVATION_PATH ? { derivationPath: process.env.DERIVATION_PATH } : {}),
}, checkFresh);
}
export function jsonRpc(url, authorization) {
let id = 0;
return async (method, params = []) => {
const response = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...(authorization ? { Authorization: authorization } : {}) },
body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), signal: AbortSignal.timeout(20_000),
});
if (!response.ok) throw new Error(`RPC HTTP ${response.status}`);
const body = await response.json();
if (body.error) throw new Error(`RPC ${method}: ${body.error.message}`);
return body.result;
};
}
export function runMain(url, main) {
if (process.argv[1] && pathToFileURL(process.argv[1]).href === url) {
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
}
}Request statuses
The helper polls the signature request until it reaches a final status:
| Status | Meaning |
|---|---|
| voting | In progress. The helper keeps polling. |
| presigning | In progress. The helper keeps polling. |
| signing | In progress. The helper keeps polling. |
| completed | Final. The response contains the signatures. |
| rejected | Final. No signature is produced, and the helper throws an error. |
| failed | Final. No signature is produced, and the helper throws an error. |
| expired | Final. No signature is produced, and the helper throws an error. |
| cancelled | Final. No signature is produced, and the helper throws an error. |
If a request or broadcast fails
- A polling timeout or blockchain-expiry check stops the script; it does not cancel the io.sign request. Use the printed ID to inspect
GET /v2/vaults/{vaultId}/signatures/{id}. - If creation returns a network error, inspect existing requests before submitting again. The server may have accepted the original POST.
- If broadcast or confirmation returns a network error, query your RPC using the printed signed transaction ID. The network may already have accepted it. Retry the same signed bytes only after checking its status; do not automatically build another transfer.
- Changed nonce, UTXO, fees, blockhash, destination, amount, or XRP sequence means a different signed payload. Rebuild and obtain a fresh approval when a new transaction is required.
- Allocate EVM nonces and XRP sequences, and reserve Bitcoin UTXOs, in your application's transaction queue. These single-transfer examples do not coordinate concurrent executions.
Verification scope
The checked-in tests use real SDK encoding, generated test signatures, and mocked RPC responses. They verify signature attachment and failure paths without transferring assets. These recipes have not been exercised against a live io.sign environment or a public testnet. Validate your signer release and RPC behaviour in your own test environment before operational use.
Updated about 10 hours ago