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.
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, and 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 notes, 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.
| 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.
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
};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 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.
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.
// 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.
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
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 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().
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,
};
}
}