Skip to content

Commit f1b5c2d

Browse files
feat(utxo-core): add dust threshold calculations for UTXO coins
Add functions to calculate minimum output amounts (dust thresholds) for UTXO-based coins based on witness type and output size Issue: BTC-1826
1 parent bf51d1d commit f1b5c2d

File tree

2 files changed

+233
-0
lines changed

2 files changed

+233
-0
lines changed
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+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from 'assert';
2+
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
5+
import { getDustThresholdSat } from '../src/dustThreshold';
6+
7+
describe('getDustThresholdSat', function () {
8+
it('has expected values', function () {
9+
assert.deepStrictEqual(
10+
utxolib.getNetworkList().flatMap((n): [unknown, unknown][] => {
11+
if (n === utxolib.networks.bitcoin) {
12+
return [
13+
['bitcoin', getDustThresholdSat(n, 34, false)],
14+
['bitcoin (segwit)', getDustThresholdSat(n, 31, true)],
15+
];
16+
}
17+
try {
18+
return [[utxolib.getNetworkName(n), getDustThresholdSat(n, 34, false)]];
19+
} catch (e) {
20+
assert(e instanceof Error);
21+
return [[utxolib.getNetworkName(n), e.message]];
22+
}
23+
}),
24+
[
25+
/*
26+
27+
https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/policy.cpp#L28
28+
29+
>> "Dust" is defined in terms of dustRelayFee,
30+
>> which has units satoshis-per-kilobyte.
31+
>> If you'd pay more in fees than the value of the output
32+
>> to spend something, then we consider it dust.
33+
>> A typical spendable non-segwit txout is 34 bytes big, and will
34+
>> need a CTxIn of at least 148 bytes to spend:
35+
>> so dust is a spendable txout less than
36+
>> 182*dustRelayFee/1000 (in satoshis).
37+
>> 546 satoshis at the default rate of 3000 sat/kvB.
38+
39+
*/
40+
['bitcoin', 546],
41+
/*
42+
43+
>> A typical spendable segwit P2WPKH txout is 31 bytes big, and will
44+
>> need a CTxIn of at least 67 bytes to spend:
45+
>> so dust is a spendable txout less than
46+
>> 98*dustRelayFee/1000 (in satoshis).
47+
>> 294 satoshis at the default rate of 3000 sat/kvB.
48+
49+
for us it is 297 because we round up
50+
51+
*/
52+
['bitcoin (segwit)', 297],
53+
['testnet', 546],
54+
['bitcoinPublicSignet', 546],
55+
['bitcoinTestnet4', 546],
56+
['bitcoinBitGoSignet', 546],
57+
['bitcoincash', 182],
58+
['bitcoincashTestnet', 182],
59+
['bitcoingold', 546],
60+
['bitcoingoldTestnet', 546],
61+
['bitcoinsv', 'deprecated coin'],
62+
['bitcoinsvTestnet', 'deprecated coin'],
63+
['dash', 546],
64+
['dashTest', 546],
65+
['dogecoin', 1000000],
66+
['dogecoinTest', 1000000],
67+
['ecash', 'unsupported network'],
68+
['ecashTest', 'unsupported network'],
69+
['litecoin', 5460],
70+
['litecoinTest', 5460],
71+
['zcash', 300],
72+
['zcashTest', 300],
73+
]
74+
);
75+
});
76+
});

0 commit comments

Comments
 (0)