Skip to content

Commit 774028b

Browse files
Merge pull request #5642 from BitGo/BTC-1826.utxo-core-feature-parity
feat(utxo-core): enhance descriptor and PSBT functionality
2 parents e08dfe0 + f1b5c2d commit 774028b

File tree

18 files changed

+367
-34
lines changed

18 files changed

+367
-34
lines changed

modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as coreDescriptors from '@bitgo/utxo-core/descriptor';
55
import { toExtendedAddressFormat } from '../recipient';
66
import { TransactionExplanation } from '../../abstractUtxoCoin';
77

8-
function toRecipient(output: coreDescriptors.psbt.ParsedOutput, network: utxolib.Network): ITransactionRecipient {
8+
function toRecipient(output: coreDescriptors.ParsedOutput, network: utxolib.Network): ITransactionRecipient {
99
return {
1010
address: toExtendedAddressFormat(output.script, network),
1111
amount: output.value.toString(),

modules/abstract-utxo/src/transaction/descriptor/parse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { IDescriptorWallet } from '../../descriptor/descriptorWallet';
1515
import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../recipient';
1616
import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference';
1717

18-
type ParsedOutput = coreDescriptors.psbt.ParsedOutput;
18+
type ParsedOutput = coreDescriptors.ParsedOutput;
1919

2020
export type RecipientOutput = Omit<ParsedOutput, 'value'> & {
2121
value: bigint | 'max';

modules/abstract-utxo/src/transaction/descriptor/signPsbt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as utxolib from '@bitgo/utxo-lib';
2-
import { DescriptorMap, psbt } from '@bitgo/utxo-core/descriptor';
2+
import { DescriptorMap, findDescriptorForInput } from '@bitgo/utxo-core/descriptor';
33

44
export class ErrorUnknownInput extends Error {
55
constructor(public vin: number) {
@@ -31,7 +31,7 @@ export function signPsbt(
3131
}
3232
): void {
3333
for (const [vin, input] of tx.data.inputs.entries()) {
34-
if (!psbt.findDescriptorForInput(input, descriptorMap)) {
34+
if (!findDescriptorForInput(input, descriptorMap)) {
3535
switch (params.onUnknownInput) {
3636
case 'skip':
3737
continue;

modules/utxo-core/src/bip65/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './locktime';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
3+
Modified version of https://github.com/bitcoinjs/bip65/blob/master/index.js
4+
5+
BIP0065: https://github.com/bitcoin/bips/blob/master/bip-0065.mediawiki
6+
7+
*/
8+
9+
// https://github.com/bitcoin/bitcoin/blob/v28.0/src/script/script.h#L44-L46
10+
const LOCKTIME_THRESHOLD = 500_000_000;
11+
12+
/**
13+
* @param obj
14+
* @return number
15+
*/
16+
export function encodeLocktime(obj: Date | { blocks: number }): number {
17+
if (obj instanceof Date) {
18+
if (!Number.isFinite(obj.getTime())) {
19+
throw new Error('invalid date');
20+
}
21+
const seconds = Math.floor(obj.getTime() / 1000);
22+
if (seconds < LOCKTIME_THRESHOLD) {
23+
throw new TypeError('Expected Number utc >= ' + LOCKTIME_THRESHOLD);
24+
}
25+
26+
return seconds;
27+
}
28+
29+
const { blocks } = obj;
30+
31+
if (!Number.isFinite(blocks) || !Number.isInteger(blocks)) {
32+
throw new TypeError('Expected Number blocks');
33+
}
34+
35+
if (blocks >= LOCKTIME_THRESHOLD) {
36+
throw new TypeError('Expected Number blocks < ' + LOCKTIME_THRESHOLD);
37+
}
38+
39+
return blocks;
40+
}

modules/utxo-core/src/descriptor/VirtualSize.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
12
import { Dimensions, VirtualSizes } from '@bitgo/unspents';
23
import { Descriptor } from '@bitgo/wasm-miniscript';
34

45
import { DescriptorMap } from './DescriptorMap';
6+
import { findDescriptorForInput } from './psbt';
57

68
function getScriptPubKeyLength(descType: string): number {
79
// See https://bitcoinops.org/en/tools/calc-size/
@@ -105,3 +107,15 @@ export function getVirtualSize(
105107
// we will just assume that we have at least one segwit input
106108
return inputVSize + outputVSize + VirtualSizes.txSegOverheadVSize;
107109
}
110+
111+
export function getVirtualSizeEstimateForPsbt(psbt: utxolib.Psbt, descriptorMap: DescriptorMap): number {
112+
const inputs = psbt.data.inputs.map((i) => {
113+
const result = findDescriptorForInput(i, descriptorMap);
114+
if (!result) {
115+
throw new Error('Could not find descriptor for input');
116+
}
117+
return result.descriptor;
118+
});
119+
const outputs = psbt.txOutputs.map((o) => ({ script: o.script }));
120+
return getVirtualSize({ inputs, outputs });
121+
}
Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
export type { Output, DescriptorWalletOutput, WithDescriptor, WithOptDescriptor } from './Output';
2-
export type { DescriptorMap } from './DescriptorMap';
3-
export * as psbt from './psbt';
4-
export type { PsbtParams } from './psbt';
5-
6-
export { createAddressFromDescriptor, createScriptPubKeyFromDescriptor } from './address';
7-
export { createPsbt, finalizePsbt, parse } from './psbt';
8-
export { toDescriptorMap } from './DescriptorMap';
9-
export { toDerivedDescriptorWalletOutput } from './Output';
1+
export * from './psbt';
2+
export * from './address';
3+
export * from './DescriptorMap';
4+
export * from './Output';
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
3+
/**
4+
* Checks if the output script is a witness script or not
5+
* @param script
6+
* @returns true if the script is a witness script
7+
*/
8+
function isWitnessOutputScript(script: Buffer): boolean {
9+
/**
10+
* Source: https://github.com/bitcoin/bitcoin/blob/v28.1/src/script/script.cpp#L241-L257
11+
* A witness program is any valid CScript that consists of a 1-byte push opcode
12+
* followed by a data push between 2 and 40 bytes.
13+
*/
14+
if (script.length < 4 || script.length > 42) {
15+
return false;
16+
}
17+
if (script[0] !== utxolib.opcodes.OP_0 && (script[0] < utxolib.opcodes.OP_1 || script[0] > utxolib.opcodes.OP_16)) {
18+
return false;
19+
}
20+
return script[1] + 2 === script.length;
21+
}
22+
23+
/*
24+
25+
The dust threshold for most UTXO coins is dependent on multiple factors:
26+
27+
(1) spendability of the output (OP_RETURNs are allowed to be 0 sized)
28+
(2) whether it is a witness or non-witness output
29+
(3) a particular fee rate (GetDiscardRate())
30+
31+
I will do the analysis mostly for bitcoin here and then generalize.
32+
33+
On the indexer we use `sendrawtransaction`, which calls `IsStandardTx` like this
34+
35+
https://github.com/bitcoin/bitcoin/blob/v28.0/src/kernel/mempool_options.h#L47
36+
37+
```
38+
if (
39+
m_pool.m_opts.require_standard &&
40+
!IsStandardTx(tx,
41+
m_pool.m_opts.max_datacarrier_bytes,
42+
m_pool.m_opts.permit_bare_multisig,
43+
m_pool.m_opts.dust_relay_feerate, reason))
44+
```
45+
46+
The `dust_relay_feerate` in this context is a hardcoded constant:
47+
https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/policy.h#L50-L55
48+
49+
(that can actually be overridden with a hidden command
50+
line parameter: https://bitcoin.stackexchange.com/a/41082/137601)
51+
52+
There we call `IsDust`
53+
54+
https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/policy.cpp#L144-L146
55+
56+
```
57+
if (IsDust(txout, dust_relay_fee)) {
58+
reason = "dust";
59+
return false;
60+
}
61+
```
62+
63+
Which calls `GetDustThreshold`,
64+
65+
https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/policy.cpp#L67
66+
67+
The implementation of `GetDustThreshold` computes the minimal transaction size that can spend the output, and computes
68+
a minimum fee for that transaction size based on the `dust_relay_fee` (FeeRate) parameter.
69+
70+
The different utxo implementations differ in these ways:
71+
72+
- some have a fixed, satoshi amount dust limit (doge, zec)
73+
- some have a different dust_relay_fee
74+
75+
*/
76+
77+
type DustLimit = { feeRateSatKB: number } | { satAmount: number };
78+
79+
function getDustRelayLimit(network: utxolib.Network): DustLimit {
80+
network = utxolib.getMainnet(network);
81+
switch (network) {
82+
case utxolib.networks.bitcoin:
83+
case utxolib.networks.bitcoingold:
84+
case utxolib.networks.dash:
85+
// btc: https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/policy.h#L50-L55
86+
// btg: https://github.com/BTCGPU/BTCGPU/blob/v0.17.3/src/policy/policy.h#L48
87+
// dash: https://github.com/dashpay/dash/blob/v22.0.0-beta.1/src/policy/policy.h#L41-L46
88+
return { feeRateSatKB: 3000 };
89+
case utxolib.networks.bitcoincash:
90+
// https://github.com/bitcoin-cash-node/bitcoin-cash-node/blob/v27.1.0/src/policy/policy.h#L76-L83
91+
// I actually haven't looked at BSV and am depressed that I still need to handle the case here
92+
return { feeRateSatKB: 1000 };
93+
case utxolib.networks.dogecoin:
94+
// https://github.com/dogecoin/dogecoin/blob/v1.14.8/src/policy/policy.h#L65-L81
95+
// (COIN / 100) / 10;
96+
return { satAmount: 1_000_000 };
97+
case utxolib.networks.litecoin:
98+
// https://github.com/litecoin-project/litecoin/blob/master/src/policy/policy.h#L47-L52
99+
return { feeRateSatKB: 30_000 };
100+
case utxolib.networks.zcash:
101+
// https://github.com/zcash/zcash/blob/master/src/primitives/transaction.h#L396-L399
102+
// https://github.com/zcash/zcash/blob/v6.0.0/src/policy/policy.h#L43-L89 (I don't quite get it)
103+
return { satAmount: 300 };
104+
case utxolib.networks.bitcoinsv:
105+
throw new Error('deprecated coin');
106+
default:
107+
throw new Error('unsupported network');
108+
}
109+
}
110+
111+
function getSpendSize(network: utxolib.Network, outputSize: number, isWitness: boolean): number {
112+
network = utxolib.getMainnet(network);
113+
switch (network) {
114+
case utxolib.networks.bitcoin:
115+
case utxolib.networks.bitcoincash:
116+
case utxolib.networks.bitcoingold:
117+
case utxolib.networks.litecoin:
118+
/*
119+
btc: https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/policy.cpp#L26-L68
120+
bch: https://github.com/bitcoin-cash-node/bitcoin-cash-node/blob/v27.1.0/src/policy/policy.cpp#L18-L36 (btc-ish)
121+
btg: https://github.com/BTCGPU/BTCGPU/blob/v0.17.3/src/policy/policy.cpp#L18-L50 (btc-ish)
122+
ltc: https://github.com/litecoin-project/litecoin/blob/v0.21.4/src/policy/policy.cpp#L15-L47 (btc-ish)
123+
124+
The fixed component here is 69.75 for isWitness=true and 150 for isWitness=false.
125+
*/
126+
return outputSize + 32 + 4 + 1 + 107 / (isWitness ? 4 : 1) + 4;
127+
case utxolib.networks.dash:
128+
// dash: https://github.com/dashpay/dash/blob/v21.1.1/src/policy/policy.cpp#L14-L30 (btc-ish)
129+
// how did they end up with 148? I don't know
130+
return outputSize + 148;
131+
case utxolib.networks.dogecoin:
132+
case utxolib.networks.zcash:
133+
// doge: https://github.com/dogecoin/dogecoin/blob/v1.14.8/src/policy/policy.h#L65-L81 (hardcoded)
134+
// zec: https://github.com/zcash/zcash/blob/v6.0.0/src/policy/policy.h#L43-L89 (some weird other thing, doge-ish)
135+
throw new Error('dust limit is size-independent');
136+
case utxolib.networks.bitcoinsv:
137+
throw new Error('deprecated coin');
138+
default:
139+
throw new Error('unsupported network');
140+
}
141+
}
142+
143+
export function getDustThresholdSat(network: utxolib.Network, outputSize: number, isWitness: boolean): number {
144+
const dustLimit = getDustRelayLimit(network);
145+
if ('satAmount' in dustLimit) {
146+
return dustLimit.satAmount;
147+
}
148+
if ('feeRateSatKB' in dustLimit) {
149+
const spendSize = getSpendSize(network, outputSize, isWitness);
150+
return Math.ceil((dustLimit.feeRateSatKB * spendSize) / 1000);
151+
}
152+
throw new Error('unexpected dustLimit');
153+
}
154+
155+
export function getDustThresholdSatForOutputScript(network: utxolib.Network, script: Buffer): number {
156+
return getDustThresholdSat(network, script.length, isWitnessOutputScript(script));
157+
}

modules/utxo-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * as bip65 from './bip65';
12
export * as descriptor from './descriptor';
23
export * as testutil from './testutil';

modules/utxo-core/src/testutil/descriptor/psbt.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as utxolib from '@bitgo/utxo-lib';
22

3-
import { matchPath, PathElement, toPlainObject } from '../../../src/testutil/toPlainObject.utils';
3+
import { matchPath, PathElement, toPlainObject } from '../../../src/testutil';
44

55
export function toPlainObjectFromPsbt(v: utxolib.Psbt): unknown {
66
return toPlainObject(

0 commit comments

Comments
 (0)