Sending Transaction

Overview

After a successful simulation, the transaction can be broadcast to the Bitcoin network. The CallResult object provides the sendTransaction() method, which constructs the Bitcoin transaction, applies the required signatures, and broadcasts it to the network. This section covers transaction parameters, signer configuration, fee management, and UTXO handling.

Transaction Flow

As previously stated, every OP_NET transaction follows a strict two-step lifecycle: simulate and broadcast. This pattern ensures that invalid operations are caught before any satoshis are spent on fees, and that the developer has full visibility into the expected outcome before committing to the network.

Transaction Broadcasting Sequence Your App Contract Provider Bitcoin Network Simulation method(args) App: CallResult (simulation) --> CallResult (simulation) Build & Sign sendTransaction(params) Build Bitcoin TX Sign TX Broadcast Broadcast Submit TX Response Provider: TX Hash --> TX Hash Contract: Receipt --> Receipt App: Transaction Result --> Transaction Result Your App Contract Provider Bitcoin Network synchronous call async return self-call

Step 1: Simulate the Call

Every transaction begins with a simulation to ensure the operation will succeed. The simulation executes the smart contract method against the current blockchain state without broadcasting anything. If the call would revert, the simulation returns a revert reason string describing the failure. Always check this field before proceeding.

typescript
Simulate the Call
const simulation = await token.transfer(recipient, amount, new Uint8Array(0));

// Verify the call would succeed
if (simulation.revert) {
    throw new Error(`Transfer would fail: ${simulation.revert}`);
}

Step 2: Build Transaction Parameters

Once the simulation confirms success, configure the transaction with signing keys, fee settings, and spending limits. The TransactionParameters object tells the library how to construct and sign the underlying Bitcoin transaction. At minimum, you must provide:

  • A signer (ECDSA or ML-DSA)
  • A change address
  • A spending cap
  • The target network
typescript
Build Transaction Parameters
import { TransactionParameters } from 'opnet';

const params: TransactionParameters = {
    signer: wallet.keypair,           // ECDSA keypair
    mldsaSigner: wallet.mldsaKeypair, // Quantum keypair (optional)
    refundTo: wallet.p2tr,            // Change address
    maximumAllowedSatToSpend: 10000n, // Maximum satoshis for fees
    feeRate: 10,                      // Satoshis per virtual byte
    network: network,
};

The maximumAllowedSatToSpend field acts as a safety mechanism. If the transaction would require more satoshis than this limit (due to high fees or large UTXO sets), the library will throw an error rather than overspending. Set this value to a reasonable upper bound for your use case, typically between 5000n and 50000n satoshis for standard token operations.

Step 3: Send the Transaction

Broadcast the transaction to the Bitcoin network by calling sendTransaction() on the simulation result. This method constructs the full Bitcoin transaction (including OP_RETURN outputs containing the contract calldata), signs all inputs with the provided keypair(s), and submits the raw transaction to connected peers via the provider.

typescript
Send the Transaction
const receipt = await simulation.sendTransaction(params);

console.log('Transaction ID:', receipt.transactionId);
console.log('Estimated fees:', receipt.estimatedFees);

The returned InteractionTransactionReceipt contains the Bitcoin transaction ID (txid), the actual fees paid, and the new UTXOs created as change outputs. The transaction ID can be used to track confirmation status on any Bitcoin block explorer or via the provider's RPC methods.

Transaction Parameters

The TransactionParameters interface provides comprehensive control over transaction construction. While only a few fields are strictly required, the full interface exposes fine-grained options for advanced use cases such as custom UTXO management, transaction versioning, embedded s, and quantum-resistant key linking.

typescript
TransactionParameters Interface - Simplified
interface TransactionParameters {
    // Signing keys (at least one required)
    readonly signer: Signer | UniversalSigner | null;
    readonly mldsaSigner: QuantumBIP32Interface | null;

    // Addresses
    readonly refundTo: string;        // Change address (required)
    readonly sender?: string;         // Override sender

    // Fees
    feeRate?: number;                 // Satoshis per virtual byte (0 = auto)
    readonly priorityFee?: bigint;    // Additional priority fee in satoshis

    // UTXOs
    readonly utxos?: UTXO[];          // Custom UTXOs
    readonly maximumAllowedSatToSpend: bigint;  // Maximum satoshis to use

    // Network
    readonly network: Network;

    // Advanced options
    readonly extraInputs?: UTXO[];
    readonly extraOutputs?: PsbtOutputExtended[];
    readonly from?: Address;
    readonly minGas?: bigint;
    readonly note?: string | Uint8Array;
    readonly txVersion?: SupportedTransactionVersion;
    readonly anchor?: boolean;

    // ML-DSA options
    readonly linkMLDSAPublicKeyToAddress?: boolean;
    readonly revealMLDSAPublicKey?: boolean;
}

Required Parameters

The following parameters must be provided for every transaction. Omitting any of these will result in a runtime error during transaction construction.

Property Type Description
signer or mldsaSigner Signer | QuantumBIP32Interface At least one signing key is required.
refundTo string Address to receive change and refunds.
maximumAllowedSatToSpend bigint Maximum satoshis allowed for the transaction.
network Network Bitcoin network configuration.

Signer Configuration

Transactions require cryptographic signatures to authorize spending of UTXOs. OP_NET supports two signature schemes:

  • Traditional ECDSA (Schnorr for P2TR addresses): For compatibility with standard Bitcoin transactions.
  • Quantum-resistant ML-DSA: For post-quantum security.

You must provide at least one signer, and you can use both simultaneously for maximum protection.

ECDSA Signer

The ECDSA signer is required for most operations and provides compatibility with standard Bitcoin transactions. On P2TR (Pay-to-Taproot) addresses, the ECDSA keypair produces Schnorr signatures, which are the native signature scheme for Taproot outputs. This is the minimum required signer for any OP_NET transaction.

typescript
ECDSA Signer
import {
    AddressTypes,
    Mnemonic,
    MLDSASecurityLevel,
} from '@btc-vision/transaction';

// Create wallet from mnemonic
const mnemonic = new Mnemonic(
    'your twenty four word seed phrase goes here ...',
    '',                            // BIP39 passphrase
    network,
    MLDSASecurityLevel.LEVEL2,
);
const wallet = mnemonic.deriveOPWallet(AddressTypes.P2TR, 0);

const params: TransactionParameters = {
    signer: wallet.keypair,
    mldsaSigner: null,  // Omit for non-quantum transactions
    // ... other params
};

ML-DSA Quantum Signer

For quantum-resistant transactions, the ML-DSA signer provides post-quantum security using lattice-based cryptography. The mnemonic-derived wallet automatically generates ML-DSA keys alongside the ECDSA keypair, so no additional key generation step is needed.

When using ML-DSA signing, the MLDSASecurityLevel.LEVEL2 parameter during mnemonic initialization determines the key size and security margin. Level 2 provides approximately 128 bits of post-quantum security and is the recommended default for most applications.

typescript
ML-DSA Quantum Signer
import { AddressTypes, Mnemonic, MLDSASecurityLevel } from '@btc-vision/transaction';

const mnemonic = new Mnemonic(
    'your twenty four word seed phrase goes here ...',
    '',
    network,
    MLDSASecurityLevel.LEVEL2,
);
const wallet = mnemonic.deriveOPWallet(AddressTypes.P2TR, 0);

const params: TransactionParameters = {
    signer: wallet.keypair,
    mldsaSigner: wallet.mldsaKeypair,  // ML-DSA key from mnemonic
    // ... other params
};

Using Both Signers

For maximum security, both signers can be used together. When combined with the linkMLDSAPublicKeyToAddress and revealMLDSAPublicKey options, this creates an on-chain binding between your Bitcoin address and your ML-DSA public key. This binding is required for the first quantum-signed transaction from an address and enables validators to verify your ML-DSA signatures in future transactions.

Once the ML-DSA public key has been linked and revealed on-chain, subsequent transactions from the same address can reference the previously published key without re-revealing it, reducing transaction size and fees.

typescript
Using Both Signers
const params: TransactionParameters = {
    signer: wallet.keypair,
    mldsaSigner: wallet.mldsaKeypair,
    linkMLDSAPublicKeyToAddress: true,
    revealMLDSAPublicKey: true,
    // ... other params
};

Multiple Signers

!!!!
typescript
import { ECPairFactory } from '@btc-vision/ecpair';

const ECPair = ECPairFactory();

// Create multiple keypairs
const keypair1 = ECPair.fromWIF('cKey1...', network);
const keypair2 = ECPair.fromWIF('cKey2...', network);

// Use primary signer
const params: TransactionParameters = {
    signer: keypair1,
    // For multisig, additional signing happens at PSBT level
    // ...
};

Fee Configuration

Transaction fees on Bitcoin are determined by the transaction's virtual size (in virtual bytes, or vBytes) multiplied by the fee rate (in satoshis per vByte). Larger transactions, those with more inputs, outputs, or witness data, cost more to broadcast. The library provides flexible options for fee management, ranging from fully automatic estimation to precise manual control.

OP_NET transactions are typically larger than standard Bitcoin transfers because they include additional OP_RETURN outputs containing the smart contract calldata and, when using ML-DSA signatures, significantly larger witness data. Keep this in mind when setting fee budgets, as a typical OP_NET interaction may be 2–5x the size of a simple Bitcoin payment.

Automatic Fee Rate

Setting the fee rate to zero enables automatic fee estimation. The provider queries the current mempool state and network conditions to determine an appropriate fee rate. This is the simplest option and is recommended for most applications where confirmation time flexibility is acceptable.

typescript
Automatic Fee Rate
const params: TransactionParameters = {
    // ... signers
    feeRate: 0,  // Automatic fee estimation
    network: network,
    // ... other params
};

Manual Fee Rate

A specific fee rate (in satoshis per virtual byte) can be provided for predictable fee costs. This is useful in applications where you want consistent fee behavior regardless of network congestion, or where you have already queried fee estimates externally and want to apply a specific value.

typescript
Manual Fee Rate
const params: TransactionParameters = {
    // ... signers
    feeRate: 10,  // 10 satoshis per virtual byte
    network: network,
    // ... other params
};

Priority Fee

An additional priority fee (a flat satoshi amount) can be added on top of the size-based fee to incentivize miners to include the transaction sooner. This is independent of the fee rate and is simply appended to the total fee. Use this during periods of high mempool congestion when you need faster confirmation.

typescript
Priority Fee
const params: TransactionParameters = {
    // ... signers
    feeRate: 10,
    priorityFee: 1000n,  // Additional 1000 satoshis
    network: network,
    // ... other params
};

Dynamic Fee Selection

For optimal fee selection based on real-time network conditions, query the provider's gasParameters() endpoint. This returns recommended fee rates across three tiers:

  • low: Economical rate with slower confirmation, suitable when timing is not critical.
  • medium: Balanced rate targeting confirmation within a few blocks.
  • high: Premium rate for faster confirmation, though not guaranteed for any specific block.

This approach is recommended for production applications that need to balance cost against confirmation speed.

typescript
Dynamic Fee Selection
const gasParams = await provider.gasParameters();

// Select fee based on urgency
const params: TransactionParameters = {
    // ... signers
    feeRate: gasParams.bitcoin.recommended.medium,  // Or .low, .high
    network: network,
    // ... other params
};

UTXO Selection

UTXOs (Unspent Transaction Outputs) are the fundamental building blocks of Bitcoin transactions. Every Bitcoin transaction consumes one or more UTXOs as inputs and creates new UTXOs as outputs. When sending an OP_NET transaction, the library must select UTXOs from your wallet that collectively provide enough satoshis to cover the transaction fees and any required outputs.

The library handles UTXO selection automatically in most cases, but also supports manual control for advanced scenarios such as batch transfers, coin selection optimization, or avoiding specific UTXOs.

Automatic Selection

By default, the provider queries your wallet's available UTXOs and automatically selects an appropriate set that satisfies the maximumAllowedSatToSpend limit. The selection algorithm prioritizes UTXOs that minimize the number of inputs (reducing transaction size and fees) while staying within the specified spending cap.

typescript
Automatic Selection
const params: TransactionParameters = {
    // ... signers
    refundTo: wallet.p2tr,
    maximumAllowedSatToSpend: 10000n,  // Provider selects UTXOs up to this limit
    network: network,
};

Custom UTXOs

For fine-grained control, you can fetch and provide specific UTXOs manually. When the utxos field is set, the library bypasses automatic selection entirely and uses only the provided UTXOs. This is particularly important for batch transactions, when sending multiple transactions in sequence, you must pass the newUTXOs from each receipt as inputs to the next transaction to avoid double-spend attempts on UTXOs that have already been consumed but not yet confirmed.

typescript
Custom UTXOs
// Example 1
// Fetch UTXOs manually
const utxos = await provider.utxoManager.getUTXOs({
    address: wallet.p2tr,
});

const params: TransactionParameters = {
    // ... signers
    utxos: utxos,  // Use specific UTXOs
    maximumAllowedSatToSpend: 10000n,
    network: network,
};

// Example 2
const allUtxos = await provider.utxoManager.getUTXOs({
    address: wallet.p2tr,
    optimize: true,
});

// Filter for specific UTXOs
const selectedUtxos = allUtxos.filter((utxo) => utxo.value >= 10000n);

const params: TransactionParameters = {
    utxos: selectedUtxos,
    // ...
};

Refund Address

The refundTo parameter specifies the Bitcoin address that receives any leftover satoshis (change) after the transaction fees and required outputs have been deducted. In Bitcoin's UTXO model, inputs are consumed in full, if a UTXO contains 50,000 satoshis but the transaction only needs 8,000 for fees and outputs, the remaining 42,000 satoshis are sent back to the refundTo address as a new change UTXO. If this parameter is missing or set to an invalid address, the excess satoshis are effectively lost as an overpayment to miners.

Basic Configuration

Set refundTo to a Bitcoin address you control. This is typically the same wallet address you are sending the transaction from.

typescript
const params: TransactionParameters = {
    refundTo: wallet.p2tr,  // Your own Taproot address receives the change
    // ... other params
};

Supported Address Types

The refund address accepts any standard Bitcoin address type. Keep in mind that each type produces a different output size, which directly impacts the transaction fee. P2TR (Taproot) is recommended because it generates the smallest change output, minimizing the fee overhead.

Extra Inputs and Outputs

By default, the library constructs a transaction with only the inputs needed to fund the contract interaction and the outputs required for the calldata and change. The extraInputs and extraOutputs parameters allow you to append additional inputs and outputs to the transaction, enabling more complex spending patterns within a single broadcast.

Extra Inputs

The extraInputs field accepts an array of UTXOs that are added as inputs alongside the automatically selected ones. Unlike the utxos parameter, which replaces automatic selection entirely, extraInputs supplements it. The combined value of all inputs (automatic and extra) must stay within the maximumAllowedSatToSpend cap.

Each extra input must reference a valid, unspent UTXO that you have the signing authority to spend. If the extra input belongs to a different keypair than your primary signer, you must use a UniversalSigner containing both keys.

typescript
Extra Inputs
// Define an additional UTXO to include as an input
const extraInput: UTXO = {
    transactionId: 'abc123...',   // Transaction ID that created this UTXO
    outputIndex: 0,               // Output index within that transaction
    value: 50000n,                // UTXO value in satoshis
    scriptPubKey: { /* ... */ },  // Locking script of the UTXO
};

const params: TransactionParameters = {
    extraInputs: [extraInput],    // Appended alongside auto-selected UTXOs
    // ... other params
};

Extra Outputs

The extraOutputs field accepts an array of PsbtOutputExtended objects that are appended to the transaction's output list. Each extra output specifies a destination address and a satoshi amount. The total value of all extra outputs is deducted from the available input funds before the change calculation, so ensure your inputs provide enough satoshis to cover both the extra outputs and the transaction fees.

typescript
Extra Outputs
import { PsbtOutputExtended } from '@btc-vision/bitcoin';

// Send an additional payment as part of the same transaction
const extraOutput: PsbtOutputExtended = {
    address: treasuryAddress,  // Destination address
    value: 1000,               // Amount in satoshis
};

const params: TransactionParameters = {
    extraOutputs: [extraOutput],  // Appended to the transaction's outputs
    // ... other params
};

Additional Options

Beyond the core signing, fee, and UTXO parameters, TransactionParameters exposes several optional fields for fine-tuning transaction behavior. These options cover gas allocation, on-chain metadata, UTXO selection constraints, transaction versioning, and fee-bumping strategies.

Minimum Gas

The minGas parameter sets a floor on the gas allocated to the smart contract execution.

typescript
Minimum Gas
const params: TransactionParameters = {
    minGas: 50000n,  // Allocate at least 50,000 gas units
    // ... other params
};

Transaction Note

The note parameter embeds arbitrary data into the transaction as an additional OP_RETURN output. This data is stored permanently on the Bitcoin blockchain and is visible to anyone inspecting the transaction. It accepts either a UTF-8 string or a raw byte array for binary payloads.

typescript
Transaction Note
// Attach a human-readable memo
const params: TransactionParameters = {
    note: 'My transaction note',
    // ... other params
};

// Attach raw binary data (e.g., a hash or application-specific identifier)
const params: TransactionParameters = {
    note: Buffer.from('a1b2c3d4...', 'hex'),
    // ... other params
};

UTXO Limits

These parameters control how the library's automatic UTXO selection behaves. They are useful for managing wallet fragmentation, avoiding problematic UTXOs, and enforcing predictable transaction sizes.

typescript
UTXO Limits
const params: TransactionParameters = {
    maxUTXOs: 10,                    // Select at most 10 UTXOs as inputs
    throwIfUTXOsLimitReached: true,  // Throw an error if the limit is insufficient
    dontUseCSVUtxos: false,          // Allow UTXOs locked by CSV timelocks
    // ... other params
};

Transaction Version

The txVersion parameter specifies the OP_NET transaction format version used when encoding the contract interaction data. The library defaults to the latest supported version, which includes the most recent protocol features and optimizations. Override this only when you need backward compatibility with nodes or indexers that have not yet upgraded to the latest version.

typescript
Transaction Version
import { SupportedTransactionVersion } from '@btc-vision/transaction';

const params: TransactionParameters = {
    txVersion: SupportedTransactionVersion.V1,
    // ... other params
};

Anchor Transactions

The anchor parameter creates an anchor output in the transaction, enabling CPFP (Child-Pays-For-Parent) fee bumping. An anchor output is a small, easily spendable output that a subsequent transaction can consume with a higher fee rate, effectively increasing the priority of the original (parent) transaction.

typescript
Anchor Transactions
const params: TransactionParameters = {
    anchor: true,  // Include an anchor output for CPFP fee bumping
    // ... other params
};

Transaction Result

The sendTransaction() method returns an InteractionTransactionReceipt containing comprehensive details about the broadcast transaction. This receipt is critical for tracking transaction status, chaining subsequent transactions, and verifying that the operation completed as expected.

typescript
InteractionTransactionReceipt Interface
interface InteractionTransactionReceipt {
    readonly transactionId: string;         // Transaction hash
    readonly newUTXOs: UTXO[];              // UTXOs created by this transaction
    readonly peerAcknowledgements: number;  // Network acknowledgements
    readonly estimatedFees: bigint;         // Actual fees paid
    readonly challengeSolution: RawChallenge;
    readonly rawTransaction: string;        // Raw transaction hex
    readonly interactionAddress: string | null;
    readonly fundingUTXOs: UTXO[];
    readonly fundingInputUtxos: UTXO[];
    readonly compiledTargetScript: string | null;
}

Handling the Result

typescript
Handling the Result
const receipt = await simulation.sendTransaction(params);

console.log('Transaction sent!');
console.log('TX ID:', receipt.transactionId);
console.log('Fees paid:', receipt.estimatedFees, 'satoshis');
console.log('New UTXOs:', receipt.newUTXOs.length);

// Track new UTXOs for subsequent transactions
const newUtxos = receipt.newUTXOs;

Examples

The following examples demonstrate common transaction patterns. Each example includes the complete import statements and follows the simulate-then-send pattern described above.

Basic Token Transfer

This example shows a complete end-to-end OP_20 token transfer, from provider initialization through transaction confirmation. It demonstrates wallet derivation from a mnemonic, contract instantiation via the getContract() factory, simulation, and broadcasting.

typescript
Basic Token Transfer
import {
    getContract,
    IOP20Contract,
    JSONRpcProvider,
    OP_20_ABI,
    TransactionParameters,
} from 'opnet';
import {
    Address,
    AddressTypes,
    Mnemonic,
    MLDSASecurityLevel,
} from '@btc-vision/transaction';
import { networks } from '@btc-vision/bitcoin';

async function transferTokens(): Promise<void> {
    const network = networks.regtest;
    const provider = new JSONRpcProvider('https://regtest.opnet.org', network);

    const mnemonic = new Mnemonic(
        'your seed phrase here ...',
        '',
        network,
        MLDSASecurityLevel.LEVEL2
    );
    const wallet = mnemonic.deriveOPWallet(AddressTypes.P2TR, 0);

    const token = getContract<IOP20Contract>(
        Address.fromString('0x...'),
        OP_20_ABI,
        provider,
        network,
        wallet.address
    );

    const recipient = Address.fromString('0x...');
    const amount = 100_00000000n;  // 100 tokens

    // Step 1: Simulate
    const simulation = await token.transfer(recipient, amount, new Uint8Array(0));

    if (simulation.revert) {
        throw new Error(`Transfer would fail: ${simulation.revert}`);
    }

    // Step 2: Build params
    const params: TransactionParameters = {
        signer: wallet.keypair,
        mldsaSigner: wallet.mldsaKeypair,
        refundTo: wallet.p2tr,
        maximumAllowedSatToSpend: 10000n,
        feeRate: 10,
        network: network,
    };

    // Step 3: Send
    const receipt = await simulation.sendTransaction(params);

    console.log('Transfer complete!');
    console.log('TX ID:', receipt.transactionId);

    await provider.close();
}

Approve and TransferFrom

The following example demonstrates the ERC-20-style approval pattern adapted for OP_20 tokens. This two-step process allows one address (the owner) to grant another address (the spender) permission to transfer tokens on their behalf. This pattern is essential for decentralized exchanges, lending protocols, and any contract that needs to move tokens from a user's balance.

Important

Note that the approve() and the subsequent transferFrom() are separate Bitcoin transactions that must be confirmed independently. In production, you should wait for the approval transaction to be confirmed before attempting the transferFrom().

typescript
Approve and TransferFrom
async function approveAndTransferFrom(): Promise<void> {
    // Approve spender
    const approveSimulation = await token.approve(spenderAddress, amount);

    if (approveSimulation.revert) {
        throw new Error('Approve failed');
    }

    const approveReceipt = await approveSimulation.sendTransaction(params);
    console.log('Approved:', approveReceipt.transactionId);

    // Wait for confirmation (in production, wait for actual confirmation)

    // TransferFrom (as spender)
    const spenderToken = getContract<IOP20Contract>(
        tokenAddress,
        OP_20_ABI,
        provider,
        network,
        spenderAddress  // Spender is the sender
    );

    const transferSimulation = await spenderToken.transferFrom(
        ownerAddress,
        recipientAddress,
        amount
    );

    if (transferSimulation.revert) {
        throw new Error('TransferFrom failed');
    }

    const transferReceipt = await transferSimulation.sendTransaction(spenderParams);
    console.log('Transferred:', transferReceipt.transactionId);
}

Batch Transfers

When sending multiple transactions in sequence without waiting for confirmations between them, you must track UTXO state across transactions. Each broadcast consumes UTXOs and creates new change outputs. If you attempt to use the same UTXOs for a second transaction, the Bitcoin network will reject it as a double-spend. The solution is to pass the newUTXOs from each receipt as inputs to the next transaction in the batch.

typescript
Batch Transfers
async function batchTransfer(
    token: IOP20Contract,
    recipients: { address: Address; amount: bigint }[],
    params: TransactionParameters
): Promise<void> {
    for (const { address, amount } of recipients) {
        const simulation = await token.transfer(address, amount, new Uint8Array(0));

        if (simulation.revert) {
            console.error(`Transfer to ${address.toHex()} would fail`);
            continue;
        }

        const receipt = await simulation.sendTransaction(params);
        console.log(`Sent to ${address.toHex()}: ${receipt.transactionId}`);

        // Update UTXOs for next transaction
        params = {
            ...params,
            utxos: receipt.newUTXOs,
        };
    }
}