ElementsProject/lightning

`getroutes`: allocating more than `spendable_msat`

Closed this issue · 6 comments

On regtest with a few nodes and channels i call getroutes with a large amount and it will return paths that immediately fail sendpay with Capacity exceeded. I added up the amounts+fees of the first, local channel and compared them with the spendable_msat value.

Some channels are over-allocated by getroutes:

  • spendable_msat: 384718000
  • total amount_msat allocated by getroutes without fee: 384718000
    • with fees: 384721847

I expect getroutes to not go over spendable_msat or even be below that since iirc this value does not account for some things and the real value is even lower?

Could be related to #7550

OK, this is hard to reproduce, since I can't get getroutes() to even try to jam that much down a channel (so close to capacity).

Got a setup I can copy?

def test_mpp_pay2(node_factory, bitcoind):
    import logging

    logger = logging.getLogger(__name__)
    l1, l2, l3 = node_factory.get_nodes(
        3,
        opts=[
            {},
            {},
            {},
        ],
    )
    l1.fundwallet(10_000_000)
    l2.fundwallet(10_000_000)
    l1.rpc.fundchannel(
        l2.info["id"] + "@localhost:" + str(l2.port), 100_000, mindepth=1
    )
    l2.rpc.fundchannel(
        l3.info["id"] + "@localhost:" + str(l3.port), 100_000, mindepth=1
    )
    bitcoind.generate_block(1)
    sync_blockheight(bitcoind, [l1, l2])
    l1.rpc.fundchannel(
        l2.info["id"] + "@localhost:" + str(l2.port), 100_000, mindepth=1
    )
    l2.rpc.fundchannel(
        l3.info["id"] + "@localhost:" + str(l3.port), 100_000, mindepth=1
    )
    bitcoind.generate_block(1)
    sync_blockheight(bitcoind, [l1, l2])
    l1.rpc.fundchannel(
        l2.info["id"] + "@localhost:" + str(l2.port), 200_000, mindepth=1
    )
    l2.rpc.fundchannel(
        l3.info["id"] + "@localhost:" + str(l3.port), 200_000, mindepth=1
    )
    bitcoind.generate_block(1)
    sync_blockheight(bitcoind, [l1, l2])
    l1.rpc.fundchannel(
        l2.info["id"] + "@localhost:" + str(l2.port), 300_000, mindepth=1
    )
    l2.rpc.fundchannel(
        l3.info["id"] + "@localhost:" + str(l3.port), 300_000, mindepth=1
    )
    bitcoind.generate_block(1)
    sync_blockheight(bitcoind, [l1, l2])
    l1.rpc.fundchannel(
        l2.info["id"] + "@localhost:" + str(l2.port), 400_000, mindepth=1
    )
    l2.rpc.fundchannel(
        l3.info["id"] + "@localhost:" + str(l3.port), 400_000, mindepth=1
    )
    bitcoind.generate_block(6)
    sync_blockheight(bitcoind, [l1, l2, l3])

    wait_for(lambda: len(l1.rpc.listchannels()["channels"]) == 20)

    routes = l1.rpc.call(
        "getroutes",
        {
            "source": l1.info["id"],
            "destination": l3.info["id"],
            "amount_msat": 800_000_000,
            "layers": ["auto.localchans", "auto.sourcefree"],
            "maxfee_msat": 50_000_000,
            "finalcltv": 10,
        },
    )
    channels = l1.rpc.call("listpeerchannels")
    logger.info(routes)
    logger.info(channels)

Atleast one channel gets overallocated in this setup for me.

Thanks, IOU a beer. I'll take a look...

Seems to work for me:

+def test_mpp_pay2(node_factory, bitcoind):
+    l1, l2, l3 = node_factory.get_nodes(3)
+    l1.fundwallet(10_000_000)
+    l2.fundwallet(10_000_000)
+    l1.rpc.connect(l2.info['id'], 'localhost', port=l2.port)
+    l2.rpc.connect(l3.info['id'], 'localhost', port=l3.port)
+
+    capacities = (100_000, 100_000, 200_000, 300_000, 400_000)
+    for capacity in capacities:
+        l1.rpc.fundchannel(l2.info["id"], capacity, mindepth=1)
+        l2.rpc.fundchannel(l3.info["id"], capacity, mindepth=1)
+
+        bitcoind.generate_block(1, wait_for_mempool=2)
+        sync_blockheight(bitcoind, [l1, l2])
+
+    bitcoind.generate_block(5)
+    wait_for(lambda: len(l1.rpc.listchannels()["channels"]) == 2 * 2 * len(capacities))
+
+    routes = l1.rpc.getroutes(
+        source=l1.info["id"],
+        destination=l3.info["id"],
+        amount_msat=800_000_000,
+        layers=["auto.localchans", "auto.sourcefree"],
+        maxfee_msat=50_000_000,
+        final_cltv=10,
+    )
+
+    # Don't exceed spendable_msat
+    maxes = {}
+    for chan in l1.rpc.listpeerchannels()['channels']:
+        maxes["{}/{}".format(chan['short_channel_id'], chan['direction'])] = chan['spendable_msat']
+
+    for r in routes['routes']:
+        for p in r['path']:
+            scidd = "{}/{}".format(p['short_channel_id'], p['direction'])
+            if scidd in maxes:
+                assert p['amount_msat'] <= maxes[scidd]

Sorry, i wasn't precise enough: I meant that if you add up all the amounts+fee that getroutes returns per local channel, they might add up to more than spendable_msat. Maybe this will make it more clear:

def test_mpp_pay2(node_factory, bitcoind):
    l1, l2, l3 = node_factory.get_nodes(3)
    l1.fundwallet(10_000_000)
    l2.fundwallet(10_000_000)
    l1.rpc.connect(l2.info["id"], "localhost", port=l2.port)
    l2.rpc.connect(l3.info["id"], "localhost", port=l3.port)

    capacities = (100_000, 100_000, 200_000, 300_000, 400_000)
    for capacity in capacities:
        l1.rpc.fundchannel(l2.info["id"], capacity, mindepth=1)
        l2.rpc.fundchannel(l3.info["id"], capacity, mindepth=1)

        bitcoind.generate_block(1, wait_for_mempool=2)
        sync_blockheight(bitcoind, [l1, l2])

    bitcoind.generate_block(5)
    wait_for(lambda: len(l1.rpc.listchannels()["channels"]) == 2 * 2 * len(capacities))

    routes = l1.rpc.getroutes(
        source=l1.info["id"],
        destination=l3.info["id"],
        amount_msat=800_000_000,
        layers=["auto.localchans", "auto.sourcefree"],
        maxfee_msat=50_000_000,
        finalcltv=10,
    )

    # Don't exceed spendable_msat
    maxes = {}
    for chan in l1.rpc.listpeerchannels()["channels"]:
        maxes["{}/{}".format(chan["short_channel_id"], chan["direction"])] = chan[
            "spendable_msat"
        ]

    path_maxes = {}
    for r in routes["routes"]:
        key = "{}/{}".format(
            r["path"][0]["short_channel_id"], r["path"][0]["direction"]
        )
        path_maxes[key] = path_maxes.get(key, 0) + r["path"][0]["amount_msat"]

    for scidd in maxes.keys():
        if scidd in path_maxes:
            assert maxes[scidd] >= path_maxes[scidd]

This returns assert 285718000 >= 285720859 for me

This is my fault!

There is a sat precision in the MCF capacities to avoid large numbers.
The problem seems to derive from there, maybe we are letting the capacity constraint be
the ceil(msat/1000) instead of floor(msat/1000).

In the future I would like to set the smallest unit according to the payment amount...

UPD: fixed in renepay 5a68289, but it is not the same thing in askrene.