Skip to content

Planning for SPV backend #3858

Open
Open
@ZmnSCPxj

Description

@ZmnSCPxj

First off: SPV is worse security than fullnodes. Please use fullnodes if you can.

On the other hand, if you trust a particular cloud service provider, you can run a fullnode on a cloud service, and thus not spend a lot of bandwidth in your own local connection. You might then think to run lightningd locally and point the --bitcoin parameters at the cloud service fullnode, but this actually downloads every block in full and gets you as much bandwidth use as a locally-run bitcoind with blocksonly option. You could instead run an SPV server of some kind on your remote cloud service fullnode, and as long as the cloud service does not secretly replace your bitcoind with minercoin2xd-unlimitedvision, you should be fine. Then we would want support for SPV in a locally-run lightningd to limit the bandwidth needed, and point it only at your trusted cloud service fullnode.

Here is my current plan:

When lightningd/bitcoind object is initialized, it checks for the following two sets of APIs:

  • getrawblockbyheight, getutxout, estimatefees, getchaininfo, sendrawtransaction - current 0.9.0 set.
  • gettxesbyheight, getutxobyscid, estimatefees, getchaininfo, sendpsbttomempool, checkspent - next version of the backend plugin API.

gettxesbyheight is given a height to fetch, plus a receive_scriptpubkeys an array of hex string scriptPubKeys to check, and a spend_utxos an array of objects with txid, vout, and scriptPubKey fields. It returns a field found as false if not yet found, or if the block is found it returns found field as true and in addition returns the blockid the hash of the block header, header the hex string dump of the header, and txes an array of hex string transactions in the block that match the given receive_scriptpubkeys or spend_utxos.

getutxobyscid is given an scid of BOLT-standard form BBBxTTxOO. It returns a field found, which is false if the given SCID does not match an unspent SegWit v0 32-byte witness (P2WSH), or a SegWit v1 32-byte witness (Taproot). If true it returns the scriptPubKey of the output as well as the amount.

sendpsbttomempool is given a Base64 PSBT that contains a completely signed transaction, and a allowhighfees parameter, and broadcasts it. We have to switch bitcoind_sendrawtx to use struct bitcoin_tx which conveniently contains a PSBT.

checkspent is given an array of objects. Each object represents a txo with at least txid and vout and scriptPubKey fields, and optional blockheight, which is the known confirmation height of the txo. For each txo, it checks if the txo is spent, and if so, adds it to the spent result, which is an array of objects with txid, vout, and spendheight fields, with 'spendheight': 0 for TXOs that are spent in unconfirmed transactions.

Internally, we also have these changes to lightningd/bitcoind functions:

  • bitcoind_can_getutxobyscid. This accepts a const struct bitcoind * and returns true if the bitcoin backend implements getutxobyscid.
    • If it returns true, it is safe to call a new bitcoind_getutxobyscid and bitcoind_checkspent, but not safe to call bitcoind_getfilteredblock or bitcoind_getutxout.
    • If it returns false, it is not safe to call bitcoind_getutxobyscid or bitcoind_checkspent but it is safe to call bitcoind_getfilteredblock and bitcoind_getutxout.
  • bitcoind_gettxesbyheight. This is always callable. chaintopology should use this API instead of bitcoind_getrawblockbyheight, since the latter is removed.
    • The only change in interface relative to bitcoind_getrawblockheight is that bitcoind_gettxesbyheight requires filter arguments. It still returns a struct block albeit one with a possibly-incomplete set of tx.
    • If the backend plugin is using the 0.9.0 interface this just calls into getrawblockbyheight and ignores the filter arguments.
  • As mentioned, bitcoind_sendrawtx should accept struct bitcoin_tx instead of const char *hextx.

Finally, we also add a new lightningd command, checknewblock, which triggers chaintopology into checking for a new block. The backend plugin should call this when the block tip height changes. Normal bitcoin-cli and BIP158 plugins will poll, though a BIP158 plugin could have a peer push a new block at it (not likely unless the peer is a miner). An Electrum plugin would subscribe to blockchain.headers.subscribe. For now we also do polling ourselves in lightningd to support older plugins that do not perform this call.

After we change lightningd, we can then update bcli to use the new interface. In order to check the new interface, gettxesbyheight will also apply filtering to transactions in the block, and will not provide txes that do not match the given filters. getutxobyscid could have a simple on-disk cache: a mapping of SCID to either a tuple of txid and vout, or Nothing. If an SCID exists in the on-disk cache, and it maps to nothing, it is known-invalid SCID, else if it maps to a txid vout the bcli will go query bitcoin-cli getutxout to see if it is still unspent. If the SCID does not exist in the on-disk cache, then we scan the block and update the cache, and we also iterate over all possible SCIDs (e.g. even absurd ones like BBBx16777215x65535) and map those that do not match a P2WSH or Taproot output to Nothing.


Original text:

First off: SPV is worse security than fullnodes. Please use fullnodes if you can.

Currently, the interface to bcli inherently assumes the backend is a fullnode of some kind. This fullnode is always fully trusted to not lie, and we always download every block from the backend.

When doing block synchronization, what we can do is to have a txfilter containing:

  • UTXOs which, if spent, we want to know about.
  • Addresses which, if spent from or received into, we want to know about.

Now, it is important that we keep track of UTXOs that we want to track spendedness of channels. However, each such UTXO has its own address as well.

Most SPV protocols (eg BIP158, Electrum) are address-centric. So, we should provide just addresses to the backend. The backend should inform us

So I propose for the bcli getrawblockbyheight:

  • Add a new required parameter, txfilter, which is an array of scriptPubKey hexdumps.
  • It may return a result without a block field (but must still return blockhash). This is used to inform lightningd that the block at the given height does not involve any of the addresses in the txfilter.
  • It has to return a separate blockheader field regardless. This is needed so that chaintopology can recognize reorgs.

Then:

  • For Electrum, we do blockchain.scripthash.get_history on every listed scriptPubKey, and if any of them has a transaction whose confirmed height equals the height being queried, then we should download that block (elsewhere) and provide it to the lightningd.
    • But we should be wary of reorgs. Thus, we should do a blockchain.block.header and save the header, then blockchain.scripthash.get_history for a single scriptpubkey, then a blockchain.block.header and compare if it is the same as previous; if not the same, we should restart the querying again, since the alternate block might now contain the previous scriptpubkey. This is not resilient against very fast ABA problems but hopefully those are rare.
  • For BIP158, the plugin maintains a header chain, and we download the version 0x00 Neutrino filter for the block header hash at the requested height. Then we check if anything in our txfilter matches the Neutrino filter.

Ideally, our fullnode-trusting bcli should also apply the txfilter to blocks it returns. This is to ensure that any bugs in maintaining the lightningd-side txfilter can be caught by our test suite without having to add SPV plugins of our own.

Unfortunately, to determine if an address is spent from, we would require a txindex, or have the plugin maintain its own transaction index. Both are obviously undesirable.

However, in practice:

  • We only care about addresses we receive from.
  • We only care about UTXOs that somebody spends from.

So let me propose instead passing in two arguments to getrawblockbyheight of bcli:

  • txfilter_receive_addr, an array of scriptpubkeys, which, if any of them receive funds in the given block, we should return the block.
  • txfilter_spend_utxo, an array of {'txid': 'xx', outnum: 42, scriptpubkey: 'xx'}, which, if any of the given UTXOs are spent, we should return the block.

Then:

  • The default fullnode-trusting bcli only needs to scan the requested block and every transaction:
    • If a transaction input has a prevout matching a txfilter_spend_utxo entry, return the block.
    • If a transaction output has a scriptPubKey matching a txfilter_receive_addr entry, return the block.
    • Otherwise do not return the block, just its header.
  • The SPV interfaces, which are all address-based, just get the scriptpubkey from the txfilter_spend_utxo and append them to the txfilter_receive_addr.

This allows us to check in our test suite that lightningd is maintaining its txfilter correctly, using only the bcli plugin, while allowing address-based SPV interfaces to be used.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions