@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
373 lines (290 loc) • 12.1 kB
Markdown
Create M-of-N multi-signature transactions using `MultiSignTransaction`.
`MultiSignTransaction` creates Taproot-based multi-signature transactions where M out of N key holders must sign before the transaction is valid. It uses a PSBT (Partially Signed Bitcoin Transaction) workflow to collect signatures from multiple parties. Supports both P2TR and P2MR (BIP 360) output types.
```mermaid
flowchart TB
subgraph Keys["Public Keys (N=3)"]
A["PubKey A"]
B["PubKey B"]
C["PubKey C"]
end
Keys --> Script["Taproot MultiSig Script<br/>M-of-N (e.g., 2-of-3)"]
subgraph PSBT["PSBT Workflow"]
Create["Create PSBT<br/>(Signer 1)"]
Sign1["Sign partial<br/>(Signer 2)"]
Finalize["Finalize & broadcast"]
Create --> Sign1 --> Finalize
end
Script --> PSBT
subgraph Outputs
Receiver["Receiver<br/>(requestedAmount)"]
Refund["Refund Vault<br/>(remaining)"]
end
PSBT --> Outputs
```
## Direct Construction
Unlike other transaction types, `MultiSignTransaction` is used directly rather than through `TransactionFactory`:
```typescript
import { MultiSignTransaction } from '@btc-vision/transaction';
const multiSigTx = new MultiSignTransaction(parameters);
const psbt = await multiSigTx.signPSBT();
```
`MultiSignParameters`:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `network` | `Network` | Yes | - | Bitcoin network |
| `utxos` | `UTXO[]` | Yes | - | UTXOs locked in the multisig address |
| `feeRate` | `number` | Yes | - | Fee rate in sat/vB |
| `pubkeys` | `Uint8Array[]` | Yes | - | Array of N public keys for the multisig |
| `minimumSignatures` | `number` | Yes | - | Minimum signatures required (M) |
| `receiver` | `string` | Yes | - | Address to receive the requested amount |
| `requestedAmount` | `bigint` | Yes | - | Amount to send to the receiver in satoshis |
| `refundVault` | `string` | Yes | - | Address to receive remaining funds |
| `from` | `string` | No | - | Source address |
| `to` | `string` | No | - | Target address |
| `psbt` | `Psbt` | No | - | Existing PSBT to add signatures to |
| `useP2MR` | `boolean` | No | `false` | Use P2MR (BIP 360) instead of P2TR. Eliminates the quantum-vulnerable key-path spend (no NUMS point needed). |
Note: The `signer`, `priorityFee`, `gasSatFee`, `from`, and `to` fields from `ITransactionParameters` are omitted. `from` and `to` are re-declared as optional above. A dummy signer is used internally since actual signing happens via the PSBT workflow. The `mldsaSigner` field is inherited from `ITransactionParameters` (type `QuantumBIP32Interface | null`).
## Output Structure
The multisig transaction creates two outputs:
```mermaid
flowchart LR
subgraph Inputs["Multisig UTXOs"]
V1["Vault UTXO 1"]
V2["Vault UTXO 2"]
end
subgraph Outputs
R["Receiver<br/>(requestedAmount)"]
RV["Refund Vault<br/>(total - requestedAmount)"]
end
V1 --> R
V1 --> RV
V2 --> R
V2 --> RV
```
The refund amount is calculated as:
```
refundAmount = totalInputValue - requestedAmount
```
Multi-signature transactions use a PSBT-based workflow for collecting signatures from multiple parties:
```mermaid
sequenceDiagram
participant S1 as Signer 1
participant S2 as Signer 2
participant S3 as Signer 3
participant BTC as Bitcoin Network
S1->>S1: Create MultiSignTransaction
S1->>S1: signPSBT() -> PSBT
S1->>S1: Export PSBT as base64
S1->>S2: Send base64 PSBT
S2->>S2: Import PSBT from base64
S2->>S2: signPartial()
Note over S2: Check if final (2/3)
S2->>S2: attemptFinalizeInputs()
alt 2-of-3 reached
S2->>BTC: Broadcast finalized tx
else Need more signatures
S2->>S3: Forward PSBT
S3->>S3: signPartial()
S3->>S3: attemptFinalizeInputs()
S3->>BTC: Broadcast finalized tx
end
```
## Key Static Methods
### `MultiSignTransaction.fromBase64()`
Reconstruct a `MultiSignTransaction` from a base64-encoded PSBT:
```typescript
const multiSigTx = MultiSignTransaction.fromBase64({
psbt: base64String,
network,
utxos: vaultUtxos,
feeRate: 10,
pubkeys: [pubkeyA, pubkeyB, pubkeyC],
minimumSignatures: 2,
receiver: recipientAddress,
requestedAmount: 100000n,
refundVault: vaultAddress,
});
```
Add a signature from an additional signer:
```typescript
const result = MultiSignTransaction.signPartial(
psbt, // The PSBT to sign
signer, // The signer's key pair
originalInputCount, // Number of inputs before multisig inputs
minimums, // Array of required signatures per input
);
// result.signed - true if this signer added a signature
// result.final - true if enough signatures have been collected
```
Check whether a specific public key has already signed:
```typescript
const alreadySigned = MultiSignTransaction.verifyIfSigned(psbt, signerPubKey);
```
Attempt to finalize all multisig inputs after collecting signatures:
```typescript
const success = MultiSignTransaction.attemptFinalizeInputs(
psbt,
startIndex, // Index of first multisig input
orderedPubKeys, // Array of ordered public key arrays per input
isFinal, // Whether all required signatures are present
);
```
Merge and deduplicate signature arrays (used when combining PSBTs from different signers):
```typescript
const merged = MultiSignTransaction.dedupeSignatures(
originalSignatures,
newPartialSignatures,
);
```
The multisig uses a Taproot script tree with two leaves:
```mermaid
flowchart TB
Root["Taproot Output<br/>(NUMS point internal key)"]
Root --> L1["Leaf 1: MultiSig Script<br/>OP_CHECKSIG per key<br/>OP_ADD to count<br/>M OP_NUMEQUAL"]
Root --> L2["Leaf 2: Lock Script<br/>OP_XOR OP_NOP<br/>OP_CODESEPARATOR"]
```
The internal public key is the NUMS (Nothing Up My Sleeve) point, which ensures that only the script path can be used -- there is no key-path spend.
```typescript
// NUMS point used as internal key (no key-path spend possible)
public static readonly numsPoint: PublicKey = fromHex(
'50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0',
);
```
When `useP2MR: true` is set, the multisig uses P2MR (BIP 360) instead of P2TR. P2MR eliminates the internal pubkey entirely -- the output commits directly to the Merkle root of the script tree. No NUMS point is needed.
For convenience, the `P2MR_MS` utility class generates P2MR multisig addresses:
```typescript
import { P2MR_MS } from '@btc-vision/transaction';
const p2mrMultisigAddress = P2MR_MS.generateMultiSigAddress(
[],
2, // 2-of-3
networks.bitcoin,
);
// Returns bc1z... address
```
Compare with the P2TR equivalent:
```typescript
import { P2TR_MS } from '@btc-vision/transaction';
const p2trMultisigAddress = P2TR_MS.generateMultiSigAddress(
[],
2, // 2-of-3
networks.bitcoin,
);
// Returns bc1p... address
```
```typescript
import {
MultiSignTransaction,
EcKeyPair,
UTXO,
} from '@btc-vision/transaction';
import { networks, Psbt } from '@btc-vision/bitcoin';
const network = networks.bitcoin;
// Three participants
const signerA = EcKeyPair.fromWIF(process.env.KEY_A!, network);
const signerB = EcKeyPair.fromWIF(process.env.KEY_B!, network);
const signerC = EcKeyPair.fromWIF(process.env.KEY_C!, network);
const pubkeys = [signerA.publicKey, signerB.publicKey, signerC.publicKey];
// UTXOs locked in the multisig address
const vaultUtxos: UTXO[] = [
{
transactionId: 'abcd...'.padEnd(64, '0'),
outputIndex: 0,
value: 200000n,
scriptPubKey: {
hex: '5120...',
address: 'bc1p...multisigAddress',
},
},
];
// --- Step 1: First signer creates the PSBT ---
const multiSigTx = new MultiSignTransaction({
network,
utxos: vaultUtxos,
feeRate: 10,
pubkeys,
minimumSignatures: 2,
receiver: 'bc1p...recipientAddress',
requestedAmount: 100000n,
refundVault: 'bc1p...vaultAddress',
});
const psbt = await multiSigTx.signPSBT();
const psbtBase64 = psbt.toBase64();
// --- Step 2: Send psbtBase64 to the second signer ---
// Second signer reconstructs and signs
const psbt2 = Psbt.fromBase64(psbtBase64, { network });
// Check if this signer already signed
const alreadySigned = MultiSignTransaction.verifyIfSigned(
psbt2,
signerB.publicKey,
);
if (!alreadySigned) {
const result = MultiSignTransaction.signPartial(
psbt2,
signerB,
0, // originalInputCount
[], // minimums per input (need 2 sigs)
);
if (result.final) {
// 2-of-3 threshold reached - finalize
const finalized = MultiSignTransaction.attemptFinalizeInputs(
psbt2,
0, // startIndex
[], // orderedPubKeys per input
true, // isFinal
);
if (finalized) {
const tx = psbt2.extractTransaction();
const txHex = tx.toHex();
await broadcastTransaction(txHex);
console.log('Multi-sig transaction broadcast!');
}
} else {
// Need more signatures - forward to next signer
const updatedPsbt = psbt2.toBase64();
// Send updatedPsbt to signer C...
}
}
```
```typescript
try {
const multiSigTx = new MultiSignTransaction(params);
const psbt = await multiSigTx.signPSBT();
} catch (error) {
const message = (error as Error).message;
if (message.includes('Refund vault is required')) {
// refundVault parameter is missing
} else if (message.includes('Requested amount is required')) {
// requestedAmount is missing or zero
} else if (message.includes('Receiver is required')) {
// receiver address is missing
} else if (message.includes('Pubkeys are required')) {
// pubkeys array is missing
} else if (message.includes('Output value left is negative')) {
// requestedAmount exceeds total UTXO value
} else if (message.includes('Could not sign transaction')) {
// PSBT signing failed
}
}
```
1. **Verify signatures before forwarding.** Use `verifyIfSigned()` to prevent duplicate signing.
2. **Use base64 for PSBT transport.** The `toBase64()` / `fromBase64()` methods provide a compact, safe encoding for transmitting PSBTs between signers.
3. **Order public keys consistently.** The same `pubkeys` array order must be used by all signers when constructing or reconstructing the `MultiSignTransaction`.
4. **Validate the refund amount.** Ensure `requestedAmount` does not exceed the total UTXO value to avoid a negative refund.
5. **Check `result.final` after each signature.** If the threshold is met, finalize and broadcast immediately rather than collecting unnecessary extra signatures.
6. **Keep the NUMS point.** The internal key is a standard NUMS point that ensures only script-path spending. Do not change this.
7. **Consider P2MR for quantum safety.** Set `useP2MR: true` to use P2MR outputs (BIP 360) instead of P2TR. P2MR commits directly to a Merkle root without a key-path spend, eliminating quantum-vulnerable internal pubkey exposure. Use `P2MR_MS.generateMultiSigAddress()` for P2MR multisig address generation.
---
[< Interaction Transactions](./interaction-transactions.md) | [Custom Script Transactions >](./custom-script-transactions.md)