Skip to content

Commit

Permalink
py-client: Add trampoline pay and tests
Browse files Browse the repository at this point in the history
This commit adds trampoline pay to the python library to allow for
testing of the implementation. We also add some basic checks.

Signed-off-by: Peter Neuroth <[email protected]>
  • Loading branch information
nepet committed Jul 9, 2024
1 parent fe7d1a8 commit 3e10cc0
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 19 deletions.
9 changes: 9 additions & 0 deletions libs/gl-client-py/glclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,15 @@ def pay(
bytes(self.inner.call(uri, bytes(req)))
)

def trampoline_pay(self, bolt11: str, trmp_node_id: bytes, amount_msat: Optional[int] = None, label: Optional[str] = None):
res = self.inner.trampoline_pay(
bolt11=bolt11,
trmp_node_id=trmp_node_id,
amount_msat=amount_msat,
label=label,
)
return nodepb.TrampolinePayResponse.FromString(bytes(res))

def keysend(
self,
destination: bytes,
Expand Down
22 changes: 13 additions & 9 deletions libs/gl-client-py/glclient/glclient.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ package adds a pythonic facade on top of this to improve usability.
"""

from typing import Optional, List
import glclient.glclient as native;

import glclient.glclient as native

class TlsConfig:
def __init__(self) -> None: ...
def with_ca_certificate(self, ca: bytes) -> "TlsConfig": ...
def identity(self, cert_pem: bytes, key_pem: bytes) -> "TlsConfig": ...
def identity_from_path(self, path : str) -> "TlsConfig": ...
def identity_from_path(self, path: str) -> "TlsConfig": ...

class Credentials:
def __init__(self) -> None: ...
Expand All @@ -35,7 +34,6 @@ class Credentials:
class SignerHandle:
def shutdown(self) -> None: ...


class Signer:
def __init__(self, secret: bytes, network: str, creds: Credentials): ...
def sign_challenge(self, challenge: bytes) -> bytes: ...
Expand All @@ -45,8 +43,9 @@ class Signer:
def version(self) -> str: ...
def is_running(self) -> bool: ...
def shutdown(self) -> None: ...
def create_rune(self, restrictions: List[List[str]], rune: Optional[str] = None) -> str: ...

def create_rune(
self, restrictions: List[List[str]], rune: Optional[str] = None
) -> str: ...

class Scheduler:
def __init__(self, network: str, creds: Optional[Credentials]): ...
Expand All @@ -62,8 +61,7 @@ class Scheduler:
def list_outgoing_webhooks(self) -> bytes: ...
def delete_outgoing_webhook(self, webhook_id: int) -> bytes: ...
def delete_outgoing_webhooks(self, webhook_ids: List[int]) -> bytes: ...
def rotate_outgoing_webhook_secret(self, webhook_id: int) -> bytes: ...

def rotate_outgoing_webhook_secret(self, webhook_id: int) -> bytes: ...

class Node:
def __init__(
Expand All @@ -75,6 +73,13 @@ class Node:
def stop(self) -> None: ...
def call(self, method: str, request: bytes) -> bytes: ...
def get_lsp_client(self) -> LspClient: ...
def trampoline_pay(
self,
bolt11: str,
trmp_node_id: bytes,
amount_msat: Optional[int] = None,
label: Optional[str] = None,
) -> bytes: ...
def configure(self, payload: bytes) -> None: ...

class LspClient:
Expand All @@ -88,5 +93,4 @@ class LspClient:
) -> bytes: ...
def list_lsp_servers(self) -> List[str]: ...


def backup_decrypt_with_seed(encrypted: bytes, seed: bytes) -> bytes: ...
32 changes: 27 additions & 5 deletions libs/gl-client-py/src/node.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::runtime::exec;
use crate::scheduler::convert;
use crate::{credentials::Credentials, lsps::LspClient};
use gl_client as gl;
use gl_client::pb;
Expand All @@ -17,11 +18,7 @@ pub struct Node {
#[pymethods]
impl Node {
#[new]
fn new(
node_id: Vec<u8>,
grpc_uri: String,
creds: Credentials,
) -> PyResult<Self> {
fn new(node_id: Vec<u8>, grpc_uri: String, creds: Credentials) -> PyResult<Self> {
creds.ensure_device()?;
let inner = gl::node::Node::new(node_id, creds.inner)
.map_err(|s| PyValueError::new_err(s.to_string()))?;
Expand Down Expand Up @@ -60,6 +57,31 @@ impl Node {
Ok(CustommsgStream { inner: stream })
}

fn trampoline_pay(
&self,
bolt11: String,
trmp_node_id: Vec<u8>,
amount_msat: Option<u64>,
label: Option<String>,
maxfeepercent: Option<f32>,
maxdelay: Option<u32>,
description: Option<String>,
) -> PyResult<Vec<u8>> {
let req = pb::TrampolinePayRequest {
bolt11,
trmp_node_id,
amount_msat: amount_msat.unwrap_or_default(),
label: label.unwrap_or_default(),
maxfeepercent: maxfeepercent.unwrap_or_default(),
maxdelay: maxdelay.unwrap_or_default(),
description: description.unwrap_or_default(),
};
let res = exec(async { self.client.clone().trampoline_pay(req).await })
.map_err(error_calling_remote_method)?
.into_inner();
convert(Ok(res))
}

fn get_lsp_client(&self) -> LspClient {
LspClient::new(self.client.clone(), self.cln_client.clone())
}
Expand Down
20 changes: 20 additions & 0 deletions libs/gl-client-py/tests/plugins/trmp_htlc_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3

from pyln.client import Plugin

plugin = Plugin(
dynamic=False,
init_features=1 << 427,
)

@plugin.hook("htlc_accepted")
def on_htlc_accepted(htlc, onion, plugin, **kwargs):
plugin.log(f"Got onion {onion}")

# Stip off custom payload as we are the last hop.
new_payload = onion["payload"][6:102]
plugin.log(f"Replace onion payload with {new_payload}")
return {"result": "continue", "payload": new_payload}


plugin.run()
167 changes: 162 additions & 5 deletions libs/gl-client-py/tests/test_plugin.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
from gltesting.fixtures import Client
# from gltesting.fixtures import Client
from gltesting.fixtures import *
from pyln.testing.utils import wait_for
from pyln import grpc as clnpb
import pytest
import secrets


def test_big_size_requests(clients):
"""We want to test if we can pass through big size (up to ~4MB)
requests. These requests are handled by the gl-plugin to extract
the request context that is passed to the signer.
We need to test if the request is fully captured and passed to
We need to test if the request is fully captured and passed to
cln-grpc.
"""
c1: Client = clients.new()
c1.register()
n1 = c1.node()
# Size is roughly 4MB with some room for grpc overhead.
size = 3990000

# Write large data to the datastore.
n1.datastore("some-key", hex=bytes.fromhex(secrets.token_hex(size)))


def test_max_message_size(clients):
"""Tests that the maximum message size is ensured by the plugin.
This is currently hard-coded to 4194304bytes. The plugin should
Expand All @@ -27,7 +32,159 @@ def test_max_message_size(clients):
c1.register()
n1 = c1.node()
size = 4194304 + 1

# Send message too large.
with pytest.raises(ValueError):
n1.datastore("some-key", hex=bytes.fromhex(secrets.token_hex(size)))
n1.datastore("some-key", hex=bytes.fromhex(secrets.token_hex(size)))


def test_trampoline_pay(bitcoind, clients, node_factory):
c1 = clients.new()
c1.register()
s1 = c1.signer()
s1.run_in_thread()
n1 = c1.node()

# Fund greenlight node.
addr = n1.new_address().bech32
print(f"Send o address {addr}")
txid = bitcoind.rpc.sendtoaddress(addr, 0.1)
print(f"Generate a block to confirm {txid}")
bitcoind.generate_block(1, wait_for_mempool=[txid])

wait_for(lambda: txid in [o.txid.hex() for o in n1.list_funds().outputs])

# Fund channel between nodes.
l2 = node_factory.get_node(
options={
"plugin": "/repo/libs/gl-client-py/tests/plugins/trmp_htlc_hook.py",
}
)
n1.connect_peer(l2.info["id"], f"localhost:{l2.port}")
n1.fund_channel(
bytes.fromhex(l2.info["id"]),
clnpb.AmountOrAll(amount=clnpb.Amount(msat=1000000000)),
)
bitcoind.generate_block(6, wait_for_mempool=1)

wait_for(
lambda: l2.info["id"]
in [c.peer_id.hex() for c in n1.list_funds().channels if c.state == 2]
)

# create invoice and pay via trampoline. Trampoline is actually the
# same node as the destination but we don't care as we just want to
# test the business logic.
inv = l2.rpc.invoice(
amount_msat=50000000,
label="trampoline-pay-test",
description="trampoline-pay-test",
)
res = n1.trampoline_pay(inv["bolt11"], bytes.fromhex(l2.info["id"]))
assert res

# settle channel htlcs
bitcoind.generate_block(10)
wait_for(
lambda: len(
n1.list_peer_channels(bytes.fromhex(l2.info["id"])).channels[0].htlcs
)
== 0
)

# `trampoline_pay` is idempotent. A second invocation should return
# the same result but must not send any htlc.
res2 = n1.trampoline_pay(inv["bolt11"], bytes.fromhex(l2.info["id"]))
ch = n1.list_peer_channels(bytes.fromhex(l2.info["id"])).channels[0]
assert res2 == res

assert ch.to_us_msat.msat == (1000000000 - (50000000 + 0.005 * 50000000))
assert len(ch.htlcs) == 0

# new unknown unconnected node without the trampoline featurebit.
l3 = node_factory.get_node()
inv = l2.rpc.invoice(
amount_msat=1000000,
label="trampoline-pay-test-2",
description="trampoline-pay-test-2",
)

# calling `trampoline_pay` with an unkown tmrp_node_id must fail.
with pytest.raises(
expected_exception=ValueError, match=r"node with id [a-f0-9]{66} is unknown"
):
res = n1.trampoline_pay(inv["bolt11"], bytes.fromhex(l3.info["id"]))

n1.connect_peer(l3.info["id"], f"localhost:{l3.port}")

# calling `trampoline_pay` with a trmp_node that does not support
# trampoline payments must fail.
with pytest.raises(
expected_exception=ValueError,
match=r"Features \\\\\\\"[a-f0-9]+\\\\\\\" do not contain feature bit 427",
):
res = n1.trampoline_pay(inv["bolt11"], bytes.fromhex(l3.info["id"]))

res = n1.listpays()
print(f"LISTPAYS: {res}")


def test_trampoline_multi_htlc(bitcoind, clients, node_factory):
c1 = clients.new()
c1.register()
s1 = c1.signer()
s1.run_in_thread()
n1 = c1.node()

# Fund greenlight node.
addr = n1.new_address().bech32
print(f"Send o address {addr}")
txid = bitcoind.rpc.sendtoaddress(addr, 0.1)
print(f"Generate a block to confirm {txid}")
bitcoind.generate_block(1, wait_for_mempool=[txid])

wait_for(lambda: txid in [o.txid.hex() for o in n1.list_funds().outputs])

# Fund channel between nodes.
l2 = node_factory.get_node(
options={
"plugin": "/repo/libs/gl-client-py/tests/plugins/trmp_htlc_hook.py",
}
)
n1.connect_peer(l2.info["id"], f"localhost:{l2.port}")

# Fund first channel
n1.fund_channel(
bytes.fromhex(l2.info["id"]),
clnpb.AmountOrAll(amount=clnpb.Amount(msat=70000000)),
)
bitcoind.generate_block(6, wait_for_mempool=1)
wait_for(lambda: len([c for c in n1.list_funds().channels if c.state == 2]) == 1)

# Fund second channel
n1.fund_channel(
bytes.fromhex(l2.info["id"]),
clnpb.AmountOrAll(amount=clnpb.Amount(msat=30000000)),
)
bitcoind.generate_block(6, wait_for_mempool=1)
wait_for(lambda: len([c for c in n1.list_funds().channels if c.state == 2]) == 2)

spendable = max(
[
c.spendable_msat.msat
for c in n1.list_peer_channels(bytes.fromhex(l2.info["id"])).channels
]
)
print(f"spendable_msat: {spendable}")

# Create an invoice with an amount larger than the capacity of the
# bigger channel.
inv = l2.rpc.invoice(
amount_msat=spendable + 100000,
label="trampoline-multi-htlc-test",
description="trampoline-multi-htlc-test",
)

res = n1.trampoline_pay(inv["bolt11"], bytes.fromhex(l2.info["id"]))
assert res
assert res.parts == 2

0 comments on commit 3e10cc0

Please sign in to comment.