Fee & Change Handling
Every BSV transaction requires a miner fee, and most transactions produce change outputs. The Runar SDK handles fee estimation and change management automatically, but understanding the mechanics helps you optimize costs.
How Transaction Fees Work on BSV
BSV transaction fees are calculated based on transaction size in bytes, not on the value transferred or the complexity of the script. The fee rate is expressed in satoshis per byte.
fee = transactionSizeInBytes * feeRate
As of early 2026, the standard fee rate on BSV mainnet is 0.1 satoshis per byte (100 satoshis per kilobyte). This is extremely low compared to other blockchains — a typical contract deployment transaction of 250 bytes costs approximately 25 satoshis (less than a fraction of a cent).
What Contributes to Transaction Size
| Component | Typical Size | Notes |
|---|---|---|
| Transaction overhead | ~10 bytes | Version, locktime, input/output counts |
| Each P2PKH input | ~148 bytes | Prev txid + signature + pubkey |
| Contract input (unlocking script) | Varies | Depends on method arguments |
| P2PKH output | ~34 bytes | Amount + script |
| Contract output (locking script) | Varies | Depends on contract + state size |
| Change output | ~34 bytes | Standard P2PKH output |
Automatic Fee Estimation
The SDK estimates fees before building transactions. The deploy() and call() methods both estimate the fee internally.
estimateDeployFee()
For deployment transactions, use estimateDeployFee() to calculate the expected fee before deploying:
import { estimateDeployFee } from 'runar-sdk';
const fee = estimateDeployFee(1, contract.getLockingScript().length / 2); // 1 input, script byte length
console.log('Estimated deploy fee:', fee, 'satoshis');
// Estimated deploy fee: 250 satoshis
The function estimates the transaction size by summing:
- Transaction overhead (10 bytes).
- One P2PKH funding input (148 bytes).
- The contract locking script output (variable, based on
contract.getLockingScript().length / 2). - One change output (34 bytes).
It then multiplies by the fee rate and rounds up.
Fee Estimation in call()
When calling a contract method, the fee depends on both the unlocking script (method arguments) and any outputs created by the transaction. The SDK estimates this internally:
// The SDK estimates the fee automatically
const result = await contract.call('unlock', [
null, // sig (auto-sign)
signer.publicKey,
], provider, signer);
console.log('Actual fee paid:', result.fee);
The fee is deducted from the contract UTXO’s satoshis. If the contract UTXO does not have enough satoshis to cover both the outputs and the fee, additional funding UTXOs are needed.
UTXO Selection
The selectUtxos() function picks UTXOs from a set to cover a target amount. It is used internally by deploy() and call(), but you can also use it directly.
How selectUtxos() Works
import { selectUtxos } from 'runar-sdk';
const allUtxos = await provider.getUtxos(signer.address);
// Select UTXOs to cover 10000 satoshis (contract amount) + ~300 (estimated fee)
const lockingScriptByteLen = contract.getLockingScript().length / 2;
const selected = selectUtxos(allUtxos, 10300, lockingScriptByteLen);
console.log('Selected:', selected.length, 'UTXOs');
console.log('Total value:', selected.reduce((sum, u) => sum + u.satoshis, 0));
Selection Algorithm
The SDK uses a simple and predictable selection algorithm:
- Sort UTXOs by value (largest first).
- Accumulate until the total meets or exceeds the target amount.
- Return the selected set.
This “largest first” strategy minimizes the number of inputs (reducing transaction size and fee), while ensuring the target is met with the fewest UTXOs possible.
// Example: allUtxos = [50000, 30000, 10000, 5000, 1000]
// Target: 35000
// Selected: [50000] (one UTXO covers it)
// Example: allUtxos = [20000, 15000, 10000, 5000]
// Target: 35000
// Selected: [20000, 15000] (two UTXOs needed)
Insufficient Funds
If the available UTXOs do not cover the target amount, selectUtxos() throws an error:
try {
const selected = selectUtxos(allUtxos, 1000000);
} catch (error) {
console.log(error.message);
// "Insufficient funds: available 50000, required 1000000"
}
Change Output Generation
When the selected UTXOs exceed the target amount plus fee, the excess is returned as a change output — a standard P2PKH output sent to the signer’s address (or a specified change address).
Change Calculation
change = totalInputValue - contractAmount - fee
For example:
- Selected UTXO: 50000 satoshis
- Contract amount: 10000 satoshis
- Fee: 250 satoshis
- Change: 50000 - 10000 - 250 = 39750 satoshis
Dust Threshold
BSV has a dust threshold of 546 satoshis. Outputs below this value are considered “dust” and will be rejected by nodes. If the calculated change is below the dust threshold, the SDK does not create a change output — the excess is donated to the miner as additional fee.
// If change would be 400 satoshis (below dust threshold):
// No change output is created
// Effective fee becomes: 250 + 400 = 650 satoshis
This is rare in practice, but it can happen when the UTXO value closely matches the target.
Custom Change Address
By default, change goes to the signer’s address. You can specify a different address:
const result = await contract.deploy({
satoshis: 10000,
changeAddress: 'mDifferentAddress...',
});
Fee Handling in Stateful Contract Chains
Stateful contracts require special attention to fees because each state transition costs a fee, and the satoshis locked in the continuation output decrease with each call.
Fee Deduction from Continuation Output
When you call a stateful contract method, the fee is deducted from the satoshis carried forward:
Deploy: counter (10000 sat, count=0)
Call #1: counter (9800 sat, count=1) -- fee ~200 sat
Call #2: counter (9600 sat, count=2) -- fee ~200 sat
Call #3: counter (9400 sat, count=3) -- fee ~200 sat
...
Call #47: counter (600 sat, count=47) -- approaching dust limit
Running Out of Satoshis
If the continuation output would fall below the dust threshold (546 satoshis), the call will fail. To prevent this, you have two options.
Option 1: Start with enough satoshis. Estimate the number of state transitions you expect and deploy with sufficient funds:
const expectedCalls = 100;
const feePerCall = 200; // estimate
const initialSatoshis = 546 + (expectedCalls * feePerCall);
// 546 + 20000 = 20546 satoshis
await counter.deploy({ satoshis: initialSatoshis });
Option 2: Add funding inputs. When the continuation output is getting low, add external funding to the call transaction:
const result = await counter.call('increment', [], provider, signer, {
additionalUtxos: [
{ txid: '...', outputIndex: 0, satoshis: 10000, script: '...' },
],
});
The additional funding UTXO provides extra satoshis to cover the fee without reducing the continuation output’s balance.
Manual Fee and Change Control
For full control, use the low-level transaction building functions:
import { buildDeployTransaction, selectUtxos } from 'runar-sdk';
const utxos = await provider.getUtxos(signer.address);
// Manually select UTXOs with extra headroom
const lockingScript = contract.getLockingScript();
const selected = selectUtxos(utxos, 15000, lockingScript.length / 2);
// Build transaction with explicit positional args
const tx = buildDeployTransaction(
lockingScript,
selected,
10000,
'mMyChange...',
signer.changeScript,
);
// Inspect the fee before broadcasting
const inputTotal = selected.reduce((s, u) => s + u.satoshis, 0);
const outputTotal = tx.outputs.reduce((s, o) => s + o.satoshis, 0);
const actualFee = inputTotal - outputTotal;
console.log('Fee:', actualFee, 'satoshis');
console.log('Fee rate:', actualFee / tx.toHex().length * 2, 'sat/byte');
Overriding Fee Rate
The default fee rate is 0.1 satoshis per byte (100 sats/KB). You can override it when using the low-level transaction building functions:
import { buildDeployTransaction } from 'runar-sdk';
// Higher fee for faster confirmation during congestion
const tx = buildDeployTransaction(
lockingScript, selected, 10000, changeAddress, changeScript, 2.0,
);
// Lower fee (if your node accepts it)
const tx2 = buildDeployTransaction(
lockingScript, selected, 10000, changeAddress, changeScript, 0.5,
);
Optimizing Transaction Size and Cost
Several strategies reduce transaction size and fees:
Minimize method arguments. Each argument adds bytes to the unlocking script. Use the smallest types possible (Ripemd160 is 20 bytes, PubKey is 33 bytes, a full ByteString can be arbitrary).
Use the constant folder. Enable constant folding during compilation to reduce script size:
runar compile contracts/MyContract.runar.ts --enable-constant-folding
Merge token UTXOs. Many small token UTXOs cost more to spend than one large one. Use TokenWallet.merge() to consolidate periodically.
Batch state transitions. If your contract design allows it, process multiple operations in a single state transition rather than one per transaction.
Fee Comparison Table
| Operation | Typical Size | Fee (at 0.1 sat/byte) |
|---|---|---|
| Deploy P2PKH | ~250 bytes | ~25 sat |
| Call P2PKH (unlock) | ~350 bytes | ~35 sat |
| Deploy Counter (stateful) | ~400 bytes | ~40 sat |
| Call Counter (increment) | ~450 bytes | ~45 sat |
| Token transfer (split) | ~600 bytes | ~60 sat |
| Token merge (2-to-1) | ~700 bytes | ~70 sat |
These are approximate. Actual sizes depend on the specific contract, number of state fields, and argument sizes.
What’s Next
- Deploying a Contract — Deployment workflow including fee handling
- Calling a Contract — How fees are handled during method calls
- Token Contracts — Fee considerations for token operations