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

Every OP_NET transaction follows a strict three-step lifecycle: simulate, configure, 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
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
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, and the target network.

typescript
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
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 notes, and quantum-resistant key linking.

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

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

For detailed information about all available configuration options, refer to the Transaction Configuration section.

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, and 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
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
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
const params: TransactionParameters = {
    signer: wallet.keypair,
    mldsaSigner: wallet.mldsaKeypair,
    linkMLDSAPublicKeyToAddress: true,
    revealMLDSAPublicKey: true,
    // ... other params
};

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
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
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
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 gas parameters endpoint. This returns recommended fee rates at three tiers: low, medium, and high, corresponding to slower, normal, and faster expected confirmation times respectively. This approach is recommended for production applications that need to balance cost against confirmation speed.

typescript
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
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
// 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,
};

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
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 fundingUTXOs: UTXO[];          // UTXOs consumed
}

Handling the Result

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

Complete Token Transfer Example
typescript
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.

Note that the approval 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
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
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,
        };
    }
}