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

OP_NET Offline Signing Flow Online Device Offline Device OP_NET Network Simulate & serialize (toOfflineBuffer) Transfer binary data Reconstruct (fromOfflineBuffer) Sign transaction Transfer signed TX Broadcast Online Device Offline Device OP_NET Network call / transfer return / response self-call

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.

Prepare Transaction Datatypescript
// 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.

Sign Transactiontypescript
// 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.

Send to Networktypescript
// 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.

OfflineCallResultData Interfacetypescript
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
}
Using the CallResultSerializertypescript
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.

Exposing Private Keystypescript
// 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 this

Verify 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.

Verify Transaction Before Signingtypescript
// 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