@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
288 lines (218 loc) • 11.1 kB
Markdown
# Cancel Transactions
Recover funds from stuck or failed transactions using `CancelTransaction`.
## Overview
A `CancelTransaction` recovers funds that are locked in a script address (P2TR or P2MR) from a previous interaction or deployment that failed to confirm or got stuck. It spends the script UTXO via the lock leaf (the second leaf in the Taproot script tree) and sends the funds back to the sender's address.
```mermaid
flowchart LR
subgraph Stuck["Stuck Transaction"]
SA["Script Address<br/>(P2TR with embedded data)"]
end
subgraph Cancel["CancelTransaction"]
direction TB
LLS["Spend via Lock Leaf Script<br/>(owner's key path)"]
Fee["Higher fee rate<br/>to outbid original"]
end
subgraph Recovery["Recovered Funds"]
RF["Refund Output<br/>(back to sender)"]
end
SA --> Cancel
Cancel --> RF
```
## When to Use
Cancel transactions are needed in the following scenarios:
| Scenario | Description |
|----------|-------------|
| **Stuck interaction** | The funding transaction confirmed but the interaction transaction did not |
| **Failed broadcast** | The interaction transaction was rejected by the network |
| **Fee too low** | The interaction transaction is stuck in the mempool due to low fees |
| **Network congestion** | Transactions are not confirming in a reasonable time |
| **Application error** | The application crashed after broadcasting the funding transaction but before the interaction |
In all these cases, funds are locked at the Taproot script address created by the funding transaction. The `CancelTransaction` spends the lock leaf of that script tree to recover the funds.
## Factory Method
Cancel transactions are created through `TransactionFactory.createCancellableTransaction()`:
```typescript
import { TransactionFactory } from '@btc-vision/transaction';
const factory = new TransactionFactory();
const result = await factory.createCancellableTransaction(parameters);
```
## Parameters
`ICancelTransactionParameters` extends `ITransactionParameters` (with `priorityFee` and `gasSatFee` omitted):
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `signer` | `Signer \| UniversalSigner` | Yes | - | The same key pair that created the original transaction |
| `network` | `Network` | Yes | - | Bitcoin network |
| `utxos` | `UTXO[]` | Yes | - | The stuck UTXO(s) at the script address, plus additional UTXOs for fees |
| `from` | `string` | Yes | - | Sender address (must match the original transaction's sender) |
| `to` | `string` | Yes | - | Recovery address (typically the same as `from`) |
| `feeRate` | `number` | Yes | - | Fee rate in sat/vB (should be higher than the stuck transaction) |
| `compiledTargetScript` | `string \| Uint8Array` | Yes | - | The compiled target script from the original interaction/deployment |
| `useP2MR` | `boolean` | No | `false` | Must match the original transaction's output type. Set `true` if the stuck UTXO is a P2MR output. |
The `compiledTargetScript` is the script that was embedded in the first leaf of the original Taproot tree. It is available from:
- `InteractionResponse.compiledTargetScript` (after `signInteraction()`)
- `DeploymentTransaction.exportCompiledTargetScript()` (from the deployment instance)
- Saved application state
Note: The `priorityFee` and `gasSatFee` are set internally to `1n` (minimal) since cancel transactions do not perform OPNet operations.
## Response Type
`CancelledTransaction`:
| Field | Type | Description |
|-------|------|-------------|
| `transaction` | `string` | Signed cancel transaction hex, ready to broadcast |
| `nextUTXOs` | `UTXO[]` | Recovered UTXOs for subsequent transactions |
| `inputUtxos` | `UTXO[]` | The original UTXOs that were consumed |
## How It Works
The cancel transaction leverages the Taproot script tree structure used by all OPNet transactions. Every interaction and deployment creates a two-leaf tree:
```mermaid
flowchart TB
Root["Taproot Script Tree"]
Root --> L1["Leaf 1: Target Script<br/>(interaction/deployment data)"]
Root --> L2["Leaf 2: Lock Leaf Script<br/>(owner's pubkey + OP_CHECKSIG)"]
style L1 fill:#ff9999
style L2 fill:#99ff99
Note1["Original transaction<br/>spends Leaf 1"] -.- L1
Note2["Cancel transaction<br/>spends Leaf 2"] -.- L2
```
The cancel transaction reconstructs the same script tree (using the `compiledTargetScript` for Leaf 1 and the lock leaf for Leaf 2), then spends via Leaf 2 using only the owner's signature.
### Script Tree Reconstruction
```mermaid
flowchart LR
CTS["compiledTargetScript<br/>(from original tx)"] --> L1["Leaf 1"]
LLS["Lock Leaf Script<br/>(derived from signer)"] --> L2["Leaf 2"]
L1 --> Tree["Same Taproot Tree<br/>as original"]
L2 --> Tree
Tree --> Addr["Verifies to same<br/>script address"]
```
The cancel transaction must produce the exact same Taproot output key as the original transaction. This is why the `compiledTargetScript` is required -- without it, the script tree hash would differ and the UTXO could not be spent.
## Fee Considerations
Cancel transactions must include **additional UTXOs** beyond the stuck script UTXO to pay for miner fees. The stuck UTXO itself is fully refunded to the sender.
```mermaid
flowchart LR
subgraph Inputs
Stuck["Stuck UTXO<br/>(at script address)"]
Extra["Extra UTXO(s)<br/>(for fees)"]
end
subgraph Outputs
Refund["Refund Output<br/>(stuck amount + extra - fees)"]
end
Stuck --> Refund
Extra --> Refund
```
If no extra UTXOs are provided to cover fees, the transaction will fail with:
```
Error: Must add extra UTXOs to cancel this transaction
```
To outbid a stuck transaction in the mempool, use a higher `feeRate` than the original.
## Complete Recovery Example
```typescript
import {
TransactionFactory,
EcKeyPair,
UTXO,
} from '-vision/transaction';
import { networks } from '@btc-vision/bitcoin';
async function recoverStuckFunds() {
const network = networks.bitcoin;
const factory = new TransactionFactory();
// Use the same signer as the original transaction
const signer = EcKeyPair.fromWIF(process.env.PRIVATE_KEY!, network);
const address = EcKeyPair.getTaprootAddress(signer, network);
// The stuck UTXO at the script address (from the funding transaction)
const stuckUtxo: UTXO = {
transactionId: 'abcd...'.padEnd(64, '0'),
outputIndex: 0,
value: 50000n,
scriptPubKey: {
hex: '5120...',
address: 'bc1p...scriptAddress',
},
};
// Additional UTXOs to cover the cancel transaction's fees
const feeUtxo: UTXO = {
transactionId: 'efgh...'.padEnd(64, '0'),
outputIndex: 1,
value: 10000n,
scriptPubKey: {
hex: '5120...',
address: address,
},
};
// The compiled target script saved from the original interaction
const compiledTargetScript = '0a1b2c3d...'; // hex string or Uint8Array
// Create the cancel transaction with a higher fee rate
const result = await factory.createCancellableTransaction({
signer,
mldsaSigner: null,
network,
utxos: [stuckUtxo, feeUtxo],
from: address,
to: address, // Recover to own address
feeRate: 25, // Higher than original to ensure confirmation
compiledTargetScript,
});
// Broadcast
await broadcastTransaction(result.transaction);
console.log('Funds recovered!');
console.log('Recovered UTXOs:', result.nextUTXOs);
}
```
## Signing Process
The cancel transaction signs input 0 via the lock leaf script path, then signs remaining inputs (fee UTXOs) via standard key path:
```mermaid
flowchart TB
subgraph Input0["Input 0 (Script Path - Lock Leaf)"]
WS["Wallet Signer"]
WS --> CF["Custom Finalizer<br/>[signature, script,<br/>controlBlock]"]
end
subgraph InputN["Inputs 1+ (Key Path - Fee UTXOs)"]
WS2["Wallet Signer"] --> SF["Standard Finalizer"]
end
```
Unlike interaction and deployment transactions, the cancel transaction only needs the wallet signer's signature for the lock leaf (no script signer needed).
## Saving the Compiled Target Script
Always save the `compiledTargetScript` after creating an interaction or deployment. Without it, recovery is not possible.
```typescript
// After signInteraction()
const interactionResult = await factory.signInteraction(params);
const savedScript = interactionResult.compiledTargetScript;
// Store savedScript persistently (database, file, etc.)
await saveToDatabase('pending_interaction', {
txId: interactionResult.interactionTransaction,
compiledTargetScript: savedScript,
fundingUtxos: interactionResult.fundingUTXOs,
});
// If recovery is needed later:
const saved = await loadFromDatabase('pending_interaction', txId);
const cancelResult = await factory.createCancellableTransaction({
// ...
compiledTargetScript: saved.compiledTargetScript,
utxos: [...saved.fundingUtxos, ...feeUtxos],
});
```
## Error Handling
```typescript
try {
const result = await factory.createCancellableTransaction(params);
} catch (error) {
const message = (error as Error).message;
if (message.includes('Must add extra UTXOs to cancel this transaction')) {
// Not enough UTXOs to cover fees -- add more fee UTXOs
} else if (message.includes('From address is required')) {
// The 'from' field is missing
} else if (message.includes('Field "to" not provided')) {
// The 'to' field is missing
} else if (message.includes('Missing at least one UTXO')) {
// No UTXOs provided
} else if (message.includes('Left over funds script redeem is required')) {
// Internal error reconstructing the script tree
}
}
```
## Best Practices
1. **Always save `compiledTargetScript`.** Store it alongside pending transaction records so you can recover funds if anything goes wrong.
2. **Use a higher fee rate.** If the original transaction is stuck in the mempool, the cancel transaction needs a higher fee to be prioritized.
3. **Include fee UTXOs.** The stuck UTXO alone cannot cover fees -- you must provide additional UTXOs.
4. **Recover to your own address.** Set `to` to the same address as `from` for straightforward fund recovery.
5. **Use the same signer.** The cancel transaction must use the same key pair that created the original funding transaction, since the lock leaf requires a signature from the original owner.
6. **Monitor pending transactions.** Implement a system that tracks pending two-transaction flows and automatically triggers cancellation if the second transaction fails.
7. **Match the output type.** If the original transaction used P2MR (`useP2MR: true`), the cancel transaction must also use `useP2MR: true` to reconstruct the same script tree.
---
[< Custom Script Transactions](./custom-script-transactions.md) | [Back to README](../README.md)