Subject: Re: [bitcoin-dev] One-Shot Replace-By-Fee-Rate

From: Peter Todd <[email-masked]>
Date: Sat, 27 Jan 2024 07:19:22 +0000

On Mon, Jan 22, 2024 at 01:12:45PM -0500, Murch via bitcoin-dev wrote:
> Hi Peter,
> 
> On 1/18/24 13:23, Peter Todd via bitcoin-dev wrote:
> > Reposting this blog post here for discussion:
> >
> > https://petertodd.org/2024/one-shot-replace-by-fee-rate
> 
> I saw your proposal mentioned on Stacker News and read it with interest. In
> response, I described a replacement cycle that can be used to broadcast the
> same five transactions repeatedly:
> 
> https://stacker.news/items/393182
> 
> The gist is that by using two confirmed inputs and five transactions, you
> can use RBFr to reduce the absolute fee while raising the feerate to top
> block levels, then immediately use the current RBF rules to introduce a
> high-feerate transaction that beats the RBFr transaction but is hampered by
> a low-feerate parent and not attractive for mining, then use RBF to replace
> its low-feerate parent, then use the RBFr transaction again to reduce the
> absolute feerate. Due to the asymmetric replacements, the same transactions
> can replace each other in that order in every cycle. Please refer to the
> linked write-up for details, I’ve included weights, fees, and a transaction
> graph to make my example comprehensible.
> 
> Among those five transactions, the only transaction attractive for block
> inclusion would be the small RBFr transaction with a
> bottom-of-the-next-block feerate. Today, if it were mined it would amount to
> fees of around 4000 sats every few blocks to make the entire network relay
> transactions of more than 205,000 vB every few seconds. Given that my
> example is minimal, it should be possible to further increase bandwidth
> cost.
> 
> Assuming that I did not make a mistake, i.e. all the replacements are viable
> and my scenario is compatible with your proposal, the described One-Shot
> Replace-By-Fee-Rate proposal would not be safe for deployment on the
> network.

I actually tried this attack out, and it fails at step #4 due to the Rule #6,
PaysMoreThanConflicts, check.

While on stacker.news you stated that:

    tx_HS has 5000 vB and pays 21 s/vB, but since it spends an output from a
    low-feerate parent, it’s mining score is only 1.95 s/vB.

and

    You RBF tx_LL and tx_HS with tx_LM that has 100,000 vB and pays 3.05 s/vB (fee:
    305,000 s) by spending the outputs C1 and C2. This is permitted, since only
    tx_LL is a direct conflict, so the feerate of tx_HS does not have to be beat
    directly.

tx_HS _is_ considered to be a direct conflict, and its raw fee-rate _does_ have
to be beat directly. While ts_HS does spend an unconfirmed output, it appears
that the fee-rate PaysMoreThanConflicts uses to calculate if ts_HS can be
beaten is ts_HS's raw fee-rate. So looks like your understanding was incorrect
on these two points.

FYI here is the actual test script I used to test this attack. You can run it
using Bitcoin v26.0 with the -acceptnonstdtxn -mempoolfullrbf=1 command line
arguments, with python-bitcoinlib v0.12.2 installed.

#!/usr/bin/env python3

import bitcoin
bitcoin.SelectParams('regtest')

import bitcoin.rpc
import sys

from bitcoin.core import *
from bitcoin.core.script import *
from bitcoin.wallet import *

proxy = bitcoin.rpc.Proxy()

my_addr = proxy.getnewaddress().to_scriptPubKey()

coins = proxy.listunspent(1)

print(coins[0:2])

txo1 = coins[0]['outpoint']
txo1_amount = coins[0]['amount']
txo2 = coins[1]['outpoint']
txo2_amount = coins[1]['amount']

print(txo1)
print(txo2)

for i in range(0, 1):
    # Step 2
    tx_ll = CTransaction(
        [CTxIn(txo1)],
        [CTxOut(txo1_amount - 100000, my_addr),
         CTxOut(0, CScript([OP_RETURN, b'x' * 90000]))])

    r = proxy.signrawtransactionwithwallet(tx_ll)
    assert(r['complete'])
    tx_ll_signed = r['tx']

    print('tx_ll = %s' % b2lx(proxy.sendrawtransaction(tx_ll_signed)))

    tx_ls = CTransaction(
        [CTxIn(COutPoint(tx_ll.GetTxid(), 0))],
        [CTxOut(txo1_amount - 100000 - 300, my_addr)])
    r = proxy.signrawtransactionwithwallet(tx_ls)
    assert(r['complete'])
    tx_ls_signed = r['tx']

    print('tx_ls = %s' % b2lx(proxy.sendrawtransaction(tx_ls_signed)))

    # Step 3
    tx_hs = CTransaction(
        [CTxIn(COutPoint(tx_ll.GetTxid(), 0)),
         CTxIn(txo2)],
        [CTxOut((txo1_amount - 100000) + txo2_amount - 4000, my_addr)])

    r = proxy.signrawtransactionwithwallet(tx_hs)
    assert(r['complete'])
    tx_hs_signed = r['tx']

    print('tx_hs = %s ' % b2lx(proxy.sendrawtransaction(tx_hs_signed)))

    # Step 4
    tx_lm = CTransaction(
        [CTxIn(txo1),
         CTxIn(txo2)],
        [CTxOut(txo1_amount + txo2_amount - 300000, my_addr),
         CTxOut(0, CScript([OP_RETURN, b'x' * 90000]))])

    r = proxy.signrawtransactionwithwallet(tx_lm)
    assert(r['complete'])
    tx_lm_signed = r['tx']

    print('tx_lm = %s' % b2lx(proxy.sendrawtransaction(tx_lm_signed)))