Offline Signing
Overview
Offline signing separates transaction construction from transaction authorization. The transaction is prepared on an internet-connected machine, then transferred to a secure, network-isolated device where the private keys reside and the actual signing takes place. The signed payload is then moved back to the online machine for broadcasting. This ensures that private keys never touch a networked environment at any point in the process. Data is exchanged between devices using a compact binary serialization format.
Signing Flow
Online Phase: Prepare Transaction Data
The first step occurs on the internet-connected machine. Simulate the contract call to validate the operation and obtain the calldata, then serialize the simulation result along with the transaction parameters into a binary payload. This payload contains everything the offline device needs to construct and sign the transaction without requiring network access.
// online-prepare.ts
import {
getContract,
IOP20Contract,
JSONRpcProvider,
OP_20_ABI
} from 'opnet';
import { Address } from '@btc-vision/transaction';
import { networks, toHex } from '@btc-vision/bitcoin';
import * as fs from 'fs';
async function prepareForOfflineSigning() {
const network = networks.regtest;
const provider = new JSONRpcProvider('https://regtest.opnet.org', network);
// Your PUBLIC address (no private key needed here)
const myAddress = Address.fromString('0x...');
const token = getContract<IOP20Contract>(
Address.fromString('0xTokenContract...'),
OP_20_ABI,
provider,
network,
myAddress
);
// Step 1: Simulate the contract call
const simulation = await token.transfer(
Address.fromString('0xRecipient...'),
100_00000000n, // 100 tokens
new Uint8Array(0),
);
if (simulation.revert) {
throw new Error(`Simulation failed: ${simulation.revert}`);
}
// Step 2: Serialize to binary buffer
// This fetches UTXOs, challenge, and all required data automatically
const offlineBuffer = await simulation.toOfflineBuffer(
'bcrt1p...', // Your p2tr address for UTXOs
50000n // Satoshis needed for transaction
);
// Step 3: Transfer buffer to offline device (your choice how)
// Example: write to file
fs.writeFileSync('offline-tx.bin', offlineBuffer);
// Example: encode as hex string
const hexData = toHex(offlineBuffer);
console.log('Data size:', offlineBuffer.length, 'bytes');
await provider.close();
}
prepareForOfflineSigning();Offline Phase: Sign Transaction
Transfer the binary payload to your network-isolated signing device. On the signing device, deserialize the payload to reconstruct the transaction data, inspect its contents to verify the operation matches your intent (recipient, amount, fees), and apply the private key signature. Once signed, serialize the completed transaction into a binary payload and transfer it back to the online machine for broadcasting.
// offline-sign.ts (run on offline device)
import { CallResult } from 'opnet';
import {
AddressTypes,
Mnemonic,
MLDSASecurityLevel,
Wallet,
} from '@btc-vision/transaction';
import { fromHex, networks, Network } from '@btc-vision/bitcoin';
import * as fs from 'fs';
// Initialize wallet from mnemonic - ONLY ON OFFLINE DEVICE
const network: Network = networks.regtest;
const mnemonic: Mnemonic = new Mnemonic(
'your twenty four word seed phrase goes here ...',
'',
network,
MLDSASecurityLevel.LEVEL2,
);
const wallet: Wallet = mnemonic.deriveOPWallet(AddressTypes.P2TR, 0);
async function signOffline() {
// Step 1: Load binary data (however you transferred it)
// Example: from file
const buffer = fs.readFileSync('offline-tx.bin');
// Example: from hex string
// const buffer = fromHex(hexString);
// Step 2: Reconstruct the CallResult
const simulation = CallResult.fromOfflineBuffer(buffer);
// Step 3: Verify transaction details before signing!
console.log('=== VERIFY TRANSACTION ===');
console.log('Contract:', simulation.to);
console.log('Estimated gas (sat):', simulation.estimatedSatGas.toString());
console.log('========================');
// Step 4: Sign the transaction
const signedTx = await simulation.signTransaction({
signer: wallet.keypair,
mldsaSigner: wallet.mldsaKeypair,
refundTo: wallet.p2tr,
maximumAllowedSatToSpend: 50000n,
feeRate: 10,
network: network,
});
// Step 5: Prepare signed transaction data for transfer back
// Include spent UTXOs so the online device can track them
const signedData = {
fundingTx: signedTx.fundingTransactionRaw,
interactionTx: signedTx.interactionTransactionRaw,
estimatedFees: signedTx.estimatedFees.toString(),
// Include spent UTXOs for UTXO manager tracking
spentUtxos: signedTx.fundingInputUtxos.map(u => ({
transactionId: u.transactionId,
outputIndex: u.outputIndex,
value: u.value.toString(),
})),
// Include new UTXOs created by the transaction
newUtxos: signedTx.nextUTXOs.map(u => ({
transactionId: u.transactionId,
outputIndex: u.outputIndex,
value: u.value.toString(),
scriptPubKey: u.scriptPubKey,
})),
};
// Example: write to file
fs.writeFileSync('signed-tx.json', JSON.stringify(signedData, null, 2));
console.log('Signed transaction ready for broadcast');
}
signOffline();Broadcast Phase: Send to Network
Back on the internet-connected machine, deserialize the signed transaction payload and broadcast it to the Bitcoin network through the provider. After a successful broadcast, store the returned receipt and update your local UTXO set with the new change outputs to ensure subsequent transactions reference the correct unspent outputs.
// online-broadcast.ts
import { JSONRpcProvider, UTXO } from 'opnet';
import { networks } from '@btc-vision/bitcoin';
import * as fs from 'fs';
// Type for serialized signed transaction data
interface SerializedSignedTx {
fundingTx: string | null;
interactionTx: string;
estimatedFees: string;
spentUtxos: SerializedUTXO[];
newUtxos: SerializedUTXOWithScript[];
}
interface SerializedUTXO {
transactionId: string;
outputIndex: number;
value: string;
}
interface SerializedUTXOWithScript extends SerializedUTXO {
scriptPubKey: { hex: string; address?: string };
}
async function broadcastTransaction() {
const network = networks.regtest;
const provider = new JSONRpcProvider('https://regtest.opnet.org', network);
// Your address (same as used in preparation)
const myP2TR = 'bcrt1p...';
// Load signed transaction
const signedData: SerializedSignedTx = JSON.parse(
fs.readFileSync('signed-tx.json', 'utf-8')
);
// Broadcast funding transaction first (if present)
if (signedData.fundingTx) {
const fundingResult = await provider.sendRawTransaction(
signedData.fundingTx,
false
);
if (!fundingResult.success) {
throw new Error(`Funding TX failed: ${fundingResult.error}`);
}
console.log('Funding TX broadcast');
}
// Broadcast interaction transaction
const result = await provider.sendRawTransaction(
signedData.interactionTx,
false
);
if (!result.success) {
throw new Error(`Transaction failed: ${result.error}`);
}
console.log('Transaction broadcast!');
console.log('TX ID:', result.result);
// IMPORTANT: Update UTXO manager to track spent/new UTXOs
// Option 1: Clean the cache to force refetch (simplest)
provider.utxoManager.clean();
// Option 2: Manually track spent and new UTXOs (more precise)
const spentUtxos: UTXO[] = signedData.spentUtxos.map((u) => ({
transactionId: u.transactionId,
outputIndex: u.outputIndex,
value: BigInt(u.value),
scriptPubKey: { hex: '', address: myP2TR },
}));
const newUtxos: UTXO[] = signedData.newUtxos.map((u) => ({
transactionId: u.transactionId,
outputIndex: u.outputIndex,
value: BigInt(u.value),
scriptPubKey: u.scriptPubKey,
}));
// Mark UTXOs as spent and register new ones
provider.utxoManager.spentUTXO(myP2TR, spentUtxos, newUtxos);
await provider.close();
}
broadcastTransaction();
Binary Serialization Format
The CallResultSerializer class converts simulation results into a compact binary format optimized for offline transfer. The serialized output is represented by the OfflineCallResultData interface, which encapsulates all the data necessary for the offline device to reconstruct and sign the transaction without any network access.
interface OfflineCallResultData {
readonly calldata: Uint8Array; // Contract calldata
readonly to: string; // Contract p2tr address
readonly contractAddress: string; // Contract hex address
readonly estimatedSatGas: bigint; // Estimated gas in satoshis
readonly estimatedRefundedGasInSat: bigint;
readonly revert?: string; // Revert message if any
readonly result: Uint8Array; // Simulation result
readonly accessList: IAccessList; // Storage access list
readonly bitcoinFees?: BitcoinFees; // Current fee rates
readonly network: NetworkName; // mainnet/testnet/regtest
readonly estimatedGas?: bigint; // Gas units
readonly refundedGas?: bigint; // Refunded gas
readonly challenge: RawChallenge; // PoW challenge data
readonly challengeOriginalPublicKey: Uint8Array; // 33-byte compressed public key
readonly utxos: UTXO[]; // UTXOs for signing
readonly csvAddress?: IP2WSHAddress; // CSV address if applicable
}import { CallResultSerializer, NetworkName } from 'opnet';
// Serialize
const buffer = CallResultSerializer.serialize(offlineData);
// Deserialize
const data = CallResultSerializer.deserialize(buffer);Security Considerations
Never Expose Private Keys
Private keys and mnemonic seed phrases must never appear in client-side code, version control, logs, or error messages. Store them in secure environments such as encrypted keystores or server-side secrets managers. If a private key is compromised, all funds controlled by that key are permanently at risk.
// Good: Mnemonic/private key stays offline
const mnemonic = new Mnemonic(seedPhrase, '', network, MLDSASecurityLevel.LEVEL2);
const wallet = mnemonic.deriveOPWallet(AddressTypes.P2TR, 0); // OPWallet-compatible
// Bad: Transmitting private key or mnemonic
const data = { mnemonic: '...' }; // NEVER do thisVerify Transaction Before Signing
Always inspect the transaction parameters including the recipient address, amount, fee rate and spending limit before applying a signature. Review the deserialized transaction data on the signing device to confirm it matches your intent. Once a transaction is signed and broadcast, it cannot be reversed.
// On offline device, verify transaction details
const simulation = CallResult.fromOfflineBuffer(buffer);
console.log('Signing transaction:');
console.log(' Contract:', simulation.to);
console.log(' Gas cost:', simulation.estimatedSatGas.toString(), 'sats');
// Confirm before signing