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