Repository: bitcoin-dev-project/sim-ln
Pull request #225: Eclair node support
Author: login: alexzaitsev
Created: 2025-02-28T00:00:54Z | Merged: 2025-03-18T11:56:07Z

Added Eclair node support. Tracking issue: https://github.com/bitcoin-dev-project/sim-ln/issues/26

Tested on local Polar network with different use cases.


Diff:
diff --git a/.gitignore b/.gitignore
index 9a64cedc..2ebe5bb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ node_modules
 activity-generator/releases/*
 .DS_Store
 /results
+/.idea
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index e1d93ea4..7f9f4f3a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -461,6 +461,22 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
 
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
 [[package]]
 name = "crc32fast"
 version = "1.4.2"
@@ -581,6 +597,17 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.96",
+]
+
 [[package]]
 name = "downcast"
 version = "0.11.0"
@@ -599,6 +626,15 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
 
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
 [[package]]
 name = "equivalent"
 version = "1.0.1"
@@ -641,10 +677,10 @@ dependencies = [
  "hex",
  "http-body 0.4.6",
  "hyper 0.14.32",
- "hyper-rustls",
+ "hyper-rustls 0.24.2",
  "prost 0.12.6",
  "rustls 0.21.12",
- "rustls-pemfile",
+ "rustls-pemfile 1.0.4",
  "tokio",
  "tokio-stream",
  "tonic 0.10.2",
@@ -680,6 +716,30 @@ version = "0.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
 
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
 [[package]]
 name = "fragile"
 version = "2.0.0"
@@ -1047,6 +1107,23 @@ dependencies = [
  "tokio-rustls 0.24.1",
 ]
 
+[[package]]
+name = "hyper-rustls"
+version = "0.27.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
+dependencies = [
+ "futures-util",
+ "http 1.2.0",
+ "hyper 1.5.2",
+ "hyper-util",
+ "rustls 0.23.23",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls 0.26.1",
+ "tower-service",
+]
+
 [[package]]
 name = "hyper-timeout"
 version = "0.4.1"
@@ -1072,6 +1149,22 @@ dependencies = [
  "tower-service",
 ]
 
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper 1.5.2",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
 [[package]]
 name = "hyper-util"
 version = "0.1.10"
@@ -1091,6 +1184,145 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "icu_collections"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid_transform"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
+dependencies = [
+ "displaydoc",
+ "icu_locid",
+ "icu_locid_transform_data",
+ "icu_provider",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid_transform_data"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
+
+[[package]]
+name = "icu_normalizer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "utf16_iter",
+ "utf8_iter",
+ "write16",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
+
+[[package]]
+name = "icu_properties"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locid_transform",
+ "icu_properties_data",
+ "icu_provider",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
+
+[[package]]
+name = "icu_provider"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
+dependencies = [
+ "displaydoc",
+ "icu_locid",
+ "icu_provider_macros",
+ "stable_deref_trait",
+ "tinystr",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_provider_macros"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.96",
+]
+
+[[package]]
+name = "idna"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
 [[package]]
 name = "indexmap"
 version = "1.9.3"
@@ -1111,6 +1343,12 @@ dependencies = [
  "hashbrown 0.15.2",
 ]
 
+[[package]]
+name = "ipnet"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+
 [[package]]
 name = "itertools"
 version = "0.10.5"
@@ -1188,6 +1426,12 @@ version = "0.4.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
 
+[[package]]
+name = "litemap"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
+
 [[package]]
 name = "lock_api"
 version = "0.4.12"
@@ -1231,6 +1475,16 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "minimal-lexical"
 version = "0.2.1"
@@ -1313,6 +1567,23 @@ version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
 
+[[package]]
+name = "native-tls"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
 [[package]]
 name = "nix"
 version = "0.29.0"
@@ -1408,6 +1679,50 @@ version = "1.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
 
+[[package]]
+name = "openssl"
+version = "0.10.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd"
+dependencies = [
+ "bitflags 2.8.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.96",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
 [[package]]
 name = "parking_lot"
 version = "0.12.3"
@@ -1479,6 +1794,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
+[[package]]
+name = "pkg-config"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
+
 [[package]]
 name = "powerfmt"
 version = "0.2.0"
@@ -1838,6 +2159,51 @@ version = "0.8.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
 
+[[package]]
+name = "reqwest"
+version = "0.12.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2 0.4.7",
+ "http 1.2.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.5.2",
+ "hyper-rustls 0.27.5",
+ "hyper-tls",
+ "hyper-util",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "mime_guess",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pemfile 2.2.0",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper 1.0.2",
+ "system-configuration",
+ "tokio",
+ "tokio-native-tls",
+ "tower 0.5.2",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-registry",
+]
+
 [[package]]
 name = "ring"
 version = "0.16.20"
@@ -1919,10 +2285,23 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
 dependencies = [
  "log",
  "ring 0.17.8",
- "rustls-webpki",
+ "rustls-webpki 0.101.7",
  "sct",
 ]
 
+[[package]]
+name = "rustls"
+version = "0.23.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395"
+dependencies = [
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki 0.102.8",
+ "subtle",
+ "zeroize",
+]
+
 [[package]]
 name = "rustls-pemfile"
 version = "1.0.4"
@@ -1932,6 +2311,21 @@ dependencies = [
  "base64 0.21.7",
 ]
 
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
+
 [[package]]
 name = "rustls-webpki"
 version = "0.101.7"
@@ -1942,6 +2336,17 @@ dependencies = [
  "untrusted 0.9.0",
 ]
 
+[[package]]
+name = "rustls-webpki"
+version = "0.102.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
+dependencies = [
+ "ring 0.17.8",
+ "rustls-pki-types",
+ "untrusted 0.9.0",
+]
+
 [[package]]
 name = "rustversion"
 version = "1.0.19"
@@ -1954,6 +2359,15 @@ version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
 
+[[package]]
+name = "schannel"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "scopeguard"
 version = "1.2.0"
@@ -1990,6 +2404,29 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.8.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
 [[package]]
 name = "serde"
 version = "1.0.217"
@@ -2031,6 +2468,18 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -2103,6 +2552,7 @@ dependencies = [
  "rand",
  "rand_chacha",
  "rand_distr",
+ "reqwest",
  "serde",
  "serde_json",
  "serde_millis",
@@ -2162,6 +2612,12 @@ version = "0.9.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
 [[package]]
 name = "std-ext"
 version = "0.3.1"
@@ -2177,6 +2633,12 @@ version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
 [[package]]
 name = "syn"
 version = "1.0.109"
@@ -2210,6 +2672,41 @@ name = "sync_wrapper"
 version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.96",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
+dependencies = [
+ "bitflags 2.8.0",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
 
 [[package]]
 name = "tempfile"
@@ -2294,6 +2791,16 @@ dependencies = [
  "time-core",
 ]
 
+[[package]]
+name = "tinystr"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
 [[package]]
 name = "tokio"
 version = "1.43.0"
@@ -2334,6 +2841,16 @@ dependencies = [
  "syn 2.0.96",
 ]
 
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
 [[package]]
 name = "tokio-rustls"
 version = "0.23.4"
@@ -2355,6 +2872,16 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "tokio-rustls"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37"
+dependencies = [
+ "rustls 0.23.23",
+ "tokio",
+]
+
 [[package]]
 name = "tokio-stream"
 version = "0.1.17"
@@ -2421,7 +2948,7 @@ dependencies = [
  "pin-project",
  "prost 0.11.9",
  "prost-derive 0.11.9",
- "rustls-pemfile",
+ "rustls-pemfile 1.0.4",
  "tokio",
  "tokio-rustls 0.23.4",
  "tokio-stream",
@@ -2453,7 +2980,7 @@ dependencies = [
  "pin-project",
  "prost 0.12.6",
  "rustls 0.21.12",
- "rustls-pemfile",
+ "rustls-pemfile 1.0.4",
  "tokio",
  "tokio-rustls 0.24.1",
  "tokio-stream",
@@ -2549,6 +3076,7 @@ dependencies = [
  "futures-util",
  "pin-project-lite",
  "sync_wrapper 1.0.2",
+ "tokio",
  "tower-layer",
  "tower-service",
 ]
@@ -2635,6 +3163,12 @@ version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
 
+[[package]]
+name = "unicase"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.15"
@@ -2659,12 +3193,41 @@ version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
 
+[[package]]
+name = "url"
+version = "2.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf16_iter"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
 [[package]]
 name = "valuable"
 version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
 
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
 [[package]]
 name = "version_check"
 version = "0.9.5"
@@ -2700,6 +3263,7 @@ checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
 dependencies = [
  "cfg-if",
  "once_cell",
+ "rustversion",
  "wasm-bindgen-macro",
 ]
 
@@ -2717,6 +3281,19 @@ dependencies = [
  "wasm-bindgen-shared",
 ]
 
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "wasm-bindgen-macro"
 version = "0.2.100"
@@ -2803,6 +3380,36 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
+[[package]]
+name = "windows-registry"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0"
+dependencies = [
+ "windows-result",
+ "windows-strings",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.48.0"
@@ -2960,6 +3567,42 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "write16"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
+
+[[package]]
+name = "writeable"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
+
+[[package]]
+name = "yoke"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.96",
+ "synstructure",
+]
+
 [[package]]
 name = "zerocopy"
 version = "0.7.35"
@@ -2981,8 +3624,51 @@ dependencies = [
  "syn 2.0.96",
 ]
 
+[[package]]
+name = "zerofrom"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.96",
+ "synstructure",
+]
+
 [[package]]
 name = "zeroize"
 version = "1.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+
+[[package]]
+name = "zerovec"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.96",
+]
diff --git a/README.md b/README.md
index 6d8c6ad8..0d5f5877 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,15 @@ lightning network development. It may be useful to you if you are:
 * A signet operator interested in a hands-off way to run an active node. 
 * A researcher generating synthetic data for a target topology.
 
+## LN Implementation Support
+* LND ✅
+* CLN ✅
+* Eclair ✅️
+* LDK-node 🏗️
+
+See our [tracking issue](https://github.com/bitcoin-dev-project/sim-ln/issues/26)
+for updates on implementation support (contributions welcome!).
+
 ## Pre-Requisites
 SimLN requires you to "bring your own network" to generate activity 
 on. You will need:
@@ -21,14 +30,12 @@ on. You will need:
 * Rust compiler [installed](https://www.rust-lang.org/tools/install).
 * Protoc [installed](https://grpc.io/docs/protoc-installation).
 
-## LN Implementation Support
-* LND ✅ 
-* CLN ✅ 
-* Eclair 🏗️
-* LDK-node 🏗️
-
-See our [tracking issue](https://github.com/bitcoin-dev-project/sim-ln/issues/26)
-for updates on implementation support (contributions welcome!).
+The simulator requires access details for a set of `nodes` that you
+have permission to execute commands on. Note that the current version
+of the simulator uses keysend to execute payments, which must be enabled as follows:
+* LND: `--accept-keysend`
+* CLN: enabled by default
+* Eclair: `--features.keysend=optional`
 
 ## Getting Started
 
@@ -54,13 +61,8 @@ Run `sim-cli -h` for details on `--data-dir` and `--sim-file` options that allow
 Interested in contributing to the project? See [CONTRIBUTING](CONTRIBUTING.md) for more details.
 
 ### Simulation File Setup
-The simulator requires access details for a set of `nodes` that you 
-have permission to execute commands on. Note that the current version 
-of the simulator uses keysend to execute payments, which must be 
-enabled in LND using `--accept-keysend` (for CLN node it is enabled by default).
-
-The required access details will depend on the node implementation. For LND, the following
-information is required:
+The required access details will depend on the node implementation.  
+* LND:
 
 ```
 {
@@ -70,9 +72,7 @@ information is required:
   "cert": <path_to_tls_cert>
 }
 ```
-
-Whereas for CLN nodes, the following information is required:
-
+* CLN:
 ```
 { 
   "id": <node_id>,
@@ -82,12 +82,20 @@ Whereas for CLN nodes, the following information is required:
   "client_key": <path_to_client_key>
 }
 ```
-
+* Eclair:
+```
+{ 
+  "id": <node_id>,
+  "base_url": <ip:port or domain:port>,
+  "api_username": <username_to_authorize>,
+  "api_password": <password_to_authorize>
+}
+```
 
 Payment activity can be simulated in two different ways:
-* Random activity: generate random activity on the `nodes` provided, 
+* [Random activity](#setup---random-activity): generate random activity on the `nodes` provided, 
   using the graph topology to determine payment frequency and size.
-* Defined activity: provide descriptions of specific payments that 
+* [Defined activity](#setup---defined-activity): provide descriptions of specific payments that 
   you would like the generator to execute.
 
 ### Setup - Random Activity
@@ -113,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "127.0.0.1:8286",
+      "api_username": "",
+      "api_password": "eclairpw"
     }
   ]
 }
diff --git a/sim-cli/src/main.rs b/sim-cli/src/main.rs
index 1baba0da..76d6866b 100644
--- a/sim-cli/src/main.rs
+++ b/sim-cli/src/main.rs
@@ -4,8 +4,8 @@ use clap::builder::TypedValueParser;
 use clap::Parser;
 use log::LevelFilter;
 use simln_lib::{
-    cln::ClnNode, lnd::LndNode, ActivityDefinition, LightningError, LightningNode, NodeConnection,
-    NodeId, SimParams, Simulation, SimulationCfg, WriteResults,
+    cln::ClnNode, eclair::EclairNode, lnd::LndNode, ActivityDefinition, LightningError,
+    LightningNode, NodeConnection, NodeId, SimParams, Simulation, SimulationCfg, WriteResults,
 };
 use simple_logger::SimpleLogger;
 use std::collections::HashMap;
@@ -112,6 +112,7 @@ async fn main() -> anyhow::Result<()> {
         let node: Arc<Mutex<dyn LightningNode>> = match connection {
             NodeConnection::LND(c) => Arc::new(Mutex::new(LndNode::new(c).await?)),
             NodeConnection::CLN(c) => Arc::new(Mutex::new(ClnNode::new(c).await?)),
+            NodeConnection::ECLAIR(c) => Arc::new(Mutex::new(EclairNode::new(c).await?)),
         };
 
         let node_info = node.lock().await.get_info().clone();
diff --git a/simln-lib/Cargo.toml b/simln-lib/Cargo.toml
index e1a41c78..cc28116e 100644
--- a/simln-lib/Cargo.toml
+++ b/simln-lib/Cargo.toml
@@ -32,6 +32,7 @@ serde_millis = "0.1.1"
 rand_distr = "0.4.3"
 mockall = "0.12.1"
 rand_chacha = "0.3.1"
+reqwest = { version = "0.12", features = ["json", "multipart"] }
 tokio-util = { version = "0.7.13", features = ["rt"] }
 
 [dev-dependencies]
diff --git a/simln-lib/src/eclair.rs b/simln-lib/src/eclair.rs
new file mode 100644
index 00000000..c13a1b2c
--- /dev/null
+++ b/simln-lib/src/eclair.rs
@@ -0,0 +1,382 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::error::Error;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+struct EclairClient {
+    base_url: Url,
+    api_username: String,
+    api_password: String,
+    http_client: Client,
+}
+
+impl EclairClient {
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> Result<T, Box<dyn Error>> {
+        let url = self.base_url.join(endpoint)?;
+        let mut request = self
+            .http_client
+            .request(Method::POST, url)
+            .basic_auth(self.api_username.clone(), Some(self.api_password.clone()));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+}
+
+impl TryInto<EclairClient> for EclairConnection {
+    type Error = LightningError;
+
+    fn try_into(self) -> Result<EclairClient, LightningError> {
+        let base_url = Url::parse(&self.base_url)
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let http_client = Client::new();
+        Ok(EclairClient {
+            base_url,
+            api_username: self.api_username,
+            api_password: self.api_password,
+            http_client,
+        })
+    }
+}
+
+pub struct EclairNode {
+    client: EclairClient,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client: EclairClient = connection.try_into()?;
+        let info: GetInfoResponse = client
+            .request("getinfo", None)
+            .await
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            client,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .client
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .client
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self
+                    .client
+                    .request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // PaymentStatus::Failed means API call failed (can happen for various reasons).
+                            // Task to improve it: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
+                        };
+
+                        return Ok(PaymentResult {
+                            htlc_count,
+                            payment_outcome,
+                        });
+                    }
+                },
+            }
+        }
+    }
+
+    async fn get_node_info(&mut self, node_id: &PublicKey) -> Result<NodeInfo, LightningError> {
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(node_id.serialize()));
+
+        let node_info: NodeResponse = self
+            .client
+            .request("node", Some(params))
+            .await
+            .map_err(|err| LightningError::GetNodeInfoError(err.to_string()))?;
+        let features = parse_json_to_node_features(&node_info.announcement.features);
+
+        Ok(NodeInfo {
+            pubkey: *node_id,
+            alias: node_info.announcement.alias,
+            features,
+        })
+    }
+
+    async fn list_channels(&mut self) -> Result<Vec<u64>, LightningError> {
+        let channels: ChannelsResponse = self
+            .client
+            .request("channels", None)
+            .await
+            .map_err(|err| LightningError::ListChannelsError(err.to_string()))?;
+
+        let capacities_msat: Vec<u64> = channels
+            .iter()
+            .map(|channel| {
+                channel
+                    .data
+                    .commitments
+                    .active_channels
+                    .iter()
+                    .map(|ac| ac.local_commit.spec.to_local * 1_000)
+                    .sum()
+            })
+            .collect();
+
+        Ok(capacities_msat)
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct GetInfoResponse {
+    #[serde(rename = "nodeId")]
+    node_id: String,
+    alias: String,
+    network: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum PaymentStatus {
+    Pending,
+    Failed,
+    Sent,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentStatusDetails {
+    #[serde(rename = "type")]
+    r#type: PaymentStatus,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentPart {
+    status: PaymentStatusDetails,
+    #[serde(rename = "paymentHash")]
+    payment_hash: String,
+}
+
+type PaymentInfoResponse = Vec<PaymentPart>;
+
+#[derive(Debug, Deserialize)]
+struct Announcement {
+    alias: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct NodeResponse {
+    announcement: Announcement,
+}
+
+type ChannelsResponse = Vec<Channel>;
+
+#[derive(Debug, Deserialize)]
+struct Channel {
+    #[serde(rename = "data")]
+    data: ChannelData,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChannelData {
+    #[serde(rename = "commitments")]
+    commitments: Commitments,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commitments {
+    #[serde(rename = "active")]
+    active_channels: Vec<ActiveChannel>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ActiveChannel {
+    #[serde(rename = "localCommit")]
+    local_commit: LocalCommit,
+}
+
+#[derive(Debug, Deserialize)]
+struct LocalCommit {
+    #[serde(rename = "spec")]
+    spec: LocalCommitSpec,
+}
+
+#[derive(Debug, Deserialize)]
+struct LocalCommitSpec {
+    #[serde(rename = "toLocal")]
+    to_local: u64,
+}
+
+fn parse_json_to_node_features(json: &Value) -> NodeFeatures {
+    let mut flags = vec![0u8; 8];
+
+    let feature_mapping: HashMap<&str, usize> = [
+        ("option_data_loss_protect", 0),
+        ("option_upfront_shutdown_script", 4),
+        ("gossip_queries", 6),
+        ("var_onion_optin", 8),
+        ("gossip_queries_ex", 10),
+        ("option_static_remotekey", 12),
+        ("payment_secret", 14),
+        ("basic_mpp", 16),
+        ("option_support_large_channel", 18),
+        ("option_anchors_zero_fee_htlc_tx", 22),
+        ("option_route_blinding", 24),
+        ("option_shutdown_anysegwit", 26),
+        ("option_dual_fund", 28),
+        ("option_quiesce", 34),
+        ("option_onion_messages", 38),
+        ("option_provide_storage", 42),
+        ("option_channel_type", 44),
+        ("option_scid_alias", 46),
+        ("option_payment_metadata", 48),
+        ("option_zeroconf", 50),
+        ("keysend", 54),
+        ("option_simple_close", 60),
+    ]
+    .iter()
+    .cloned()
+    .collect();
+
+    if let Some(activated) = json["activated"].as_object() {
+        for (feature, level) in activated.iter() {
+            if let Some(&bit_position) = feature_mapping.get(feature.as_str()) {
+                // Determine if the feature is mandatory or optional.
+                let is_mandatory = level == "mandatory";
+                // Calculate the exact bit position.
+                let bit = if is_mandatory {
+                    bit_position // Even bit for mandatory
+                } else {
+                    bit_position + 1 // Odd bit for optional
+                };
+                // Calculate the byte index and bit offset.
+                let byte_index = bit / 8;
+                let bit_offset = bit % 8;
+
+                // Set the corresponding bit.
+                flags[byte_index] |= 1 << bit_offset;
+            }
+        }
+    }
+
+    NodeFeatures::from_le_bytes(flags)
+}
diff --git a/simln-lib/src/lib.rs b/simln-lib/src/lib.rs
index 7cb53ae1..f915940e 100644
--- a/simln-lib/src/lib.rs
+++ b/simln-lib/src/lib.rs
@@ -28,6 +28,7 @@ use self::random_activity::{NetworkGraphView, RandomPaymentActivity};
 
 pub mod cln;
 mod defined_activity;
+pub mod eclair;
 pub mod lnd;
 mod random_activity;
 mod serializers;
@@ -40,6 +41,7 @@ mod test_utils;
 pub enum NodeConnection {
     LND(lnd::LndConnection),
     CLN(cln::ClnConnection),
+    ECLAIR(eclair::EclairConnection),
 }
 
 #[derive(Serialize, Debug, Clone)]
diff --git a/simln-lib/src/serializers.rs b/simln-lib/src/serializers.rs
index 37bbb2c1..c15d5217 100644
--- a/simln-lib/src/serializers.rs
+++ b/simln-lib/src/serializers.rs
@@ -60,7 +60,7 @@ pub mod serde_address {
         D: serde::Deserializer<'de>,
     {
         let s = String::deserialize(deserializer)?;
-        if s.starts_with("https://") {
+        if s.starts_with("https://") || s.starts_with("http://") {
             Ok(s)
         } else {
             Ok(format!("https://{}", s))


Reviews:
user: login: f3r10
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-03T23:37:50Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T02:54:48Z
user: login: f3r10
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T15:45:21Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T16:41:10Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T16:42:40Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T16:49:38Z
user: login: f3r10
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T18:16:59Z
user: login: f3r10
body: Tested ACK: I tested it using Polar and it works great. 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T18:20:25Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T01:51:59Z
user: login: f3r10
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T16:20:54Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:39:24Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:41:16Z
user: login: carlaKC
body: Thanks for an **awesome** first PR! I've wanted this feature for a while, so it's great to finally have it on the way :) 

Just some low hanging fruit from me about how we're structuring the `EclairNode`, but looks great overall.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:43:15Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T19:09:23Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T01:35:20Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T05:18:57Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T05:22:45Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T05:39:40Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T05:42:52Z
user: login: carlaKC
body: close close close 🔥 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T16:31:01Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T19:20:10Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T19:23:11Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T19:30:09Z
user: login: f3r10
body: I did more tests.

1. A defined activity between two nodes where only one has all the liquidity.  :white_check_mark:
```txt
DEBUG [simln_lib] Send payment: bob(02c560...045557) -> alice(020717...b16e7c): (94c34420ce56e43289c4e761c30e76b5136b02da432d4f25efaf9725dd221bfb).
DEBUG [simln_lib] Tracking payment outcome for: 94c34420ce56e43289c4e761c30e76b5136b02da432d4f25efaf9725dd221bfb.
DEBUG [simln_lib] Track payment 94c34420ce56e43289c4e761c30e76b5136b02da432d4f25efaf9725dd221bfb result: UnexpectedError.
DEBUG [simln_lib] Generated payment: alice(020717...b16e7c) -> bob(02c560...045557): 1000 msat.
DEBUG [simln_lib] Next payment for alice(020717...b16e7c) in 3s.
```
The failure case: when the node without liquidity sends a payment is handled correctly.

2. Using the same two nodes I tried to run a random activity ❌
There is a problem here because it looks like `async fn list_channels` does not return the defined channels between the nodes. 
```txt
Node: 020717539a3ad606adb68107c7f9c5abf0fc91e37f13d461ca1949b71546b16e7c not eligible for activity generation: InsufficientCapacity: node needs at least 7600000 capacity (has: 0) to process expected payment amount: 3800000
```
@alexzaitsev could you check this case from your side, please? 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T22:58:46Z
user: login: carlaKC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T14:45:04Z
user: login: f3r10
body: I tested the random activity case again and it is working great
![image](https://github.com/user-attachments/assets/cf07d39b-e52a-40c9-a7e6-15427efeb5d1)

state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T14:57:52Z
user: login: alexzaitsev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T17:11:37Z
user: login: carlaKC
body: Very nice! Thanks for going a few rounds on this :)

You can go ahead and squash, then we're ready to hit the button.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T15:47:16Z
user: login: f3r10
body: LGTM!!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-17T18:40:23Z

Review comments:
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: f3r10
body: In this PR (https://github.com/bitcoin-dev-project/sim-ln/pull/212), we removed the requirement that URLs start with `https://` so something similar should be done with `base_url`. 
created_at: 2025-03-03T23:37:50Z
author_association: COLLABORATOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: alexzaitsev
body: `serde_address` adds `https://` and local Eclair node has `http://`
created_at: 2025-03-04T02:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: f3r10
body: For consistency, I think it would be necessary to do something similar with `base_ulr` detecting if it contains `http://` or not.

created_at: 2025-03-05T15:45:21Z
author_association: COLLABORATOR
diff_hunk: @@ -56,6 +56,7 @@ The simulator requires access details for a set of `nodes` that you
 have permission to execute commands on. Note that the current version 
 of the simulator uses keysend to execute payments, which must be 
 enabled in LND using `--accept-keysend` (for CLN node it is enabled by default).
+Use `--features.keysend=optional` to enable keysend in Eclair.
path: README.md
user: login: alexzaitsev
body: @f3r10
created_at: 2025-03-05T16:40:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: alexzaitsev
body: do you mean similar to `serde_address ` ?
seems like this parsing only complicates the things.
created_at: 2025-03-05T16:42:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: alexzaitsev
body: I would vote for removing `serde_address` at all. When I started to use `sim-ln` I had to go to the source code to figure out how exactly address is parsed. It's much better to use raw user values, in this way they will more likely get informative error that explains what went wrong.

Config changes can be achieved with the config versioning - something that is missed for now but likely will be needed in the future.
created_at: 2025-03-05T16:49:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,6 +56,7 @@ The simulator requires access details for a set of `nodes` that you
 have permission to execute commands on. Note that the current version 
 of the simulator uses keysend to execute payments, which must be 
 enabled in LND using `--accept-keysend` (for CLN node it is enabled by default).
+Use `--features.keysend=optional` to enable keysend in Eclair.
path: README.md
user: login: f3r10
body: awesome thanks, it works great!!.
created_at: 2025-03-05T18:16:59Z
author_association: COLLABORATOR
diff_hunk: @@ -56,6 +56,7 @@ The simulator requires access details for a set of `nodes` that you
 have permission to execute commands on. Note that the current version 
 of the simulator uses keysend to execute payments, which must be 
 enabled in LND using `--accept-keysend` (for CLN node it is enabled by default).
+Use `--features.keysend=optional` to enable keysend in Eclair.
path: README.md
user: login: carlaKC
body: nit: Now that we've got another node let's make this a list to improve readability, something like:
```
which must be enabed as follows:
* LND: `--accept-keysend`
* Eclair: ...
```
created_at: 2025-03-05T21:12:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: carlaKC
body: For now I would like to keep this consistent and require that this is `{IP/domain}:port`. I'd also like to call the `base_url` field `address` to remain consistent with the other nodes.

> I had to go to the source code to figure out how exactly address is parsed.

We should make a note in this readme that this is how we're doing things, I thought the examples would have been clear enough (my mistake).

Happy to discuss a wholesale change (open an issue to discuss?), but let's make that independent of this PR to keep things moving. 
created_at: 2025-03-05T21:17:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,364 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: Let's use a regular `Result` in this module? We're only using `anyhow` in `main` at the moment and I'd actually like to get rid of it in future.
created_at: 2025-03-05T21:18:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,364 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: Thoughts on adding a constructor that accepts the fields from `EclairConnection` and storing the `construct_url` and `basic_auth` values in `EclairNode` instead of saving the connection details to DRY things up a bit?

That way we don't have to keep a duplicate `NodeID` (`connection`+ `NodeInfo`) and re-construct `base_url`/
created_at: 2025-03-05T21:23:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: alexzaitsev
body: @f3r10 
would this be sufficient to keep consistency?

```
pub mod serde_address {
        // .....
        if s.starts_with("https://") || s.starts_with("http://") {
            Ok(s)
        } else {
            Ok(format!("https://{}", s))
        }
}
```
created_at: 2025-03-06T01:51:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: f3r10
body: yes that would work, thank you!!
The idea is not to force users to use `https` or , as in this case, `http` in the configuration file. https://github.com/bitcoin-dev-project/sim-ln/issues/154
created_at: 2025-03-06T16:20:54Z
author_association: COLLABORATOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: alexzaitsev
body: @f3r10 done: https://github.com/bitcoin-dev-project/sim-ln/pull/225/commits/b1901308d10d289b20f9eb6136ef5d5321385d70
created_at: 2025-03-06T16:39:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +121,12 @@ not "drain" from the simulation.
       "ca_cert": "/path/ca.pem",
       "client_cert": "/path/client.pem",
       "client_key": "/path/client-key.pem"
+    },
+    { 
+      "id": "carol",
+      "base_url": "http://127.0.0.1:8286",
path: README.md
user: login: alexzaitsev
body: > The idea is not to force users to use https or , as in this case, http in the configuration file.

yea, it makes sense. thank you for details!
created_at: 2025-03-06T16:41:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: Not following the comment linked to here with this line of code? 

Does this status from eclair mean that the API call failed?
created_at: 2025-03-06T17:20:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
+                        };
+
+                        return Ok(PaymentResult {
+                            htlc_count,
+                            payment_outcome,
+                        });
+                    }
+                },
+            }
+        }
+    }
+
+    async fn get_node_info(&mut self, node_id: &PublicKey) -> Result<NodeInfo, LightningError> {
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(node_id.serialize()));
+
+        let node_info: NodeResponse = self
+            .request("node", Some(params))
+            .await
+            .map_err(|err| LightningError::GetNodeInfoError(err.to_string()))?;
+        let features = parse_json_to_node_features(&node_info.announcement.features);
+
+        Ok(NodeInfo {
+            pubkey: *node_id,
+            alias: node_info.announcement.alias,
+            features,
+        })
+    }
+
+    async fn list_channels(&mut self) -> Result<Vec<u64>, LightningError> {
+        let mut params = HashMap::new();
+        params.insert(
+            "nodeId".to_string(),
+            hex::encode(self.info.pubkey.serialize()),
+        );
+
+        let channels: ChannelsResponse = self
+            .request("channels", Some(params))
+            .await
+            .map_err(|err| LightningError::ListChannelsError(err.to_string()))?;
+
+        let capacities_msat: Vec<u64> = channels
+            .iter()
+            .map(|channel| {
+                channel
+                    .data
+                    .commitments
+                    .active_channels
+                    .iter()
+                    .map(|ac| ac.funding_tx.amount_satoshis * 1_000)
+                    .sum()
+            })
+            .collect();
+
+        Ok(capacities_msat)
+    }
+}
+
+#[derive(Debug, Deserialize)]
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: Surprised that these aren't already defined in a rust library that we could pull in :(
created_at: 2025-03-06T17:20:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
+                        };
+
+                        return Ok(PaymentResult {
+                            htlc_count,
+                            payment_outcome,
+                        });
+                    }
+                },
+            }
+        }
+    }
+
+    async fn get_node_info(&mut self, node_id: &PublicKey) -> Result<NodeInfo, LightningError> {
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(node_id.serialize()));
+
+        let node_info: NodeResponse = self
+            .request("node", Some(params))
+            .await
+            .map_err(|err| LightningError::GetNodeInfoError(err.to_string()))?;
+        let features = parse_json_to_node_features(&node_info.announcement.features);
+
+        Ok(NodeInfo {
+            pubkey: *node_id,
+            alias: node_info.announcement.alias,
+            features,
+        })
+    }
+
+    async fn list_channels(&mut self) -> Result<Vec<u64>, LightningError> {
+        let mut params = HashMap::new();
+        params.insert(
+            "nodeId".to_string(),
+            hex::encode(self.info.pubkey.serialize()),
+        );
+
+        let channels: ChannelsResponse = self
+            .request("channels", Some(params))
+            .await
+            .map_err(|err| LightningError::ListChannelsError(err.to_string()))?;
+
+        let capacities_msat: Vec<u64> = channels
+            .iter()
+            .map(|channel| {
+                channel
+                    .data
+                    .commitments
+                    .active_channels
+                    .iter()
+                    .map(|ac| ac.funding_tx.amount_satoshis * 1_000)
+                    .sum()
+            })
+            .collect();
+
+        Ok(capacities_msat)
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct GetInfoResponse {
+    #[serde(rename = "nodeId")]
+    node_id: String,
+    alias: String,
+    network: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum PaymentStatus {
+    Pending,
+    Failed,
+    Sent,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentStatusDetails {
+    #[serde(rename = "type")]
+    r#type: PaymentStatus,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentPart {
+    status: PaymentStatusDetails,
+    #[serde(rename = "paymentHash")]
+    payment_hash: String,
+}
+
+type PaymentInfoResponse = Vec<PaymentPart>;
+
+#[derive(Debug, Deserialize)]
+struct Announcement {
+    alias: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct NodeResponse {
+    announcement: Announcement,
+}
+
+type ChannelsResponse = Vec<Channel>;
+
+#[derive(Debug, Deserialize)]
+struct Channel {
+    #[serde(rename = "data")]
+    data: ChannelData,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChannelData {
+    #[serde(rename = "commitments")]
+    commitments: Commitments,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commitments {
+    #[serde(rename = "active")]
+    active_channels: Vec<ActiveChannel>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ActiveChannel {
+    #[serde(rename = "fundingTx")]
+    funding_tx: FundingTx,
+}
+
+#[derive(Debug, Deserialize)]
+struct FundingTx {
+    #[serde(rename = "amountSatoshis")]
+    amount_satoshis: u64,
+}
+
+fn parse_json_to_node_features(json: &Value) -> NodeFeatures {
+    let mut flags = vec![0u8; 8];
+
+    let feature_mapping: HashMap<&str, usize> = [
+        ("option_data_loss_protect", 0),
+        ("option_upfront_shutdown_script", 4),
+        ("gossip_queries", 6),
+        ("var_onion_optin", 8),
+        ("gossip_queries_ex", 10),
+        ("option_static_remotekey", 12),
+        ("payment_secret", 14),
+        ("basic_mpp", 16),
+        ("option_support_large_channel", 18),
+        ("option_anchors_zero_fee_htlc_tx", 22),
+        ("option_route_blinding", 24),
+        ("option_shutdown_anysegwit", 26),
+        ("option_dual_fund", 28),
+        ("option_quiesce", 34),
+        ("option_onion_messages", 38),
+        ("option_provide_storage", 42),
+        ("option_channel_type", 44),
+        ("option_scid_alias", 46),
+        ("option_payment_metadata", 48),
+        ("option_zeroconf", 50),
+        ("keysend", 54),
+        ("option_simple_close", 60),
+    ]
+    .iter()
+    .cloned()
+    .collect();
+
+    if let Some(activated) = json["activated"].as_object() {
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: drive by comment, so don't spend too much time on this: is there a LDK utility we could pull in here? 
created_at: 2025-03-06T17:21:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: The division in duties of this wrapping function and `request_static` weren't immediately obvious to be, could we either add a comment to explain the need for this or move the connection details into its own `client` struct and implement `request` on it? 

Could work well with the request above, `EclairNode` then just holds a `client` / `info` / `network` - this is how we have it for `LndNode`, for example.
created_at: 2025-03-06T18:42:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,6 +56,7 @@ The simulator requires access details for a set of `nodes` that you
 have permission to execute commands on. Note that the current version 
 of the simulator uses keysend to execute payments, which must be 
 enabled in LND using `--accept-keysend` (for CLN node it is enabled by default).
+Use `--features.keysend=optional` to enable keysend in Eclair.
path: README.md
user: login: alexzaitsev
body: Done: https://github.com/bitcoin-dev-project/sim-ln/pull/225/commits/185d020f0d78dbbdfcfc861fa8f17548280005fd
I also moved this info to prerequisites section where it should be IMO. Let me know if you have any objections.
created_at: 2025-03-06T19:09:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,364 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: Done: https://github.com/bitcoin-dev-project/sim-ln/pull/225/commits/f0e1c2fe59dec2d36a3c3b9105c567192d578438
I used a trait object so I don't need to import `url` just to return its error from `construct_url`
created_at: 2025-03-07T01:35:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,364 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: Addressed: https://github.com/bitcoin-dev-project/sim-ln/pull/225/commits/5f2457c69a2a760a763daf94d5dd57dd4ea5a417
created_at: 2025-03-07T05:18:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: I followed other implementations in this codebase. In this particular case [here is](https://github.com/bitcoin-dev-project/sim-ln/blob/01ba08de78eaa78333868f49085ccab49b446543/simln-lib/src/cln.rs#L219) respective CLN code. Basically, as whole range of errors is not well documented (comparing to LND GRPC, [for example](https://github.com/bitcoin-dev-project/sim-ln/blob/01ba08de78eaa78333868f49085ccab49b446543/simln-lib/src/lnd.rs#L208)), I followed CLN node implementation approach and returning `UnexpectedError` here. For consistency, I copied this comment as well.
created_at: 2025-03-07T05:22:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
+                        };
+
+                        return Ok(PaymentResult {
+                            htlc_count,
+                            payment_outcome,
+                        });
+                    }
+                },
+            }
+        }
+    }
+
+    async fn get_node_info(&mut self, node_id: &PublicKey) -> Result<NodeInfo, LightningError> {
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(node_id.serialize()));
+
+        let node_info: NodeResponse = self
+            .request("node", Some(params))
+            .await
+            .map_err(|err| LightningError::GetNodeInfoError(err.to_string()))?;
+        let features = parse_json_to_node_features(&node_info.announcement.features);
+
+        Ok(NodeInfo {
+            pubkey: *node_id,
+            alias: node_info.announcement.alias,
+            features,
+        })
+    }
+
+    async fn list_channels(&mut self) -> Result<Vec<u64>, LightningError> {
+        let mut params = HashMap::new();
+        params.insert(
+            "nodeId".to_string(),
+            hex::encode(self.info.pubkey.serialize()),
+        );
+
+        let channels: ChannelsResponse = self
+            .request("channels", Some(params))
+            .await
+            .map_err(|err| LightningError::ListChannelsError(err.to_string()))?;
+
+        let capacities_msat: Vec<u64> = channels
+            .iter()
+            .map(|channel| {
+                channel
+                    .data
+                    .commitments
+                    .active_channels
+                    .iter()
+                    .map(|ac| ac.funding_tx.amount_satoshis * 1_000)
+                    .sum()
+            })
+            .collect();
+
+        Ok(capacities_msat)
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct GetInfoResponse {
+    #[serde(rename = "nodeId")]
+    node_id: String,
+    alias: String,
+    network: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum PaymentStatus {
+    Pending,
+    Failed,
+    Sent,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentStatusDetails {
+    #[serde(rename = "type")]
+    r#type: PaymentStatus,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentPart {
+    status: PaymentStatusDetails,
+    #[serde(rename = "paymentHash")]
+    payment_hash: String,
+}
+
+type PaymentInfoResponse = Vec<PaymentPart>;
+
+#[derive(Debug, Deserialize)]
+struct Announcement {
+    alias: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct NodeResponse {
+    announcement: Announcement,
+}
+
+type ChannelsResponse = Vec<Channel>;
+
+#[derive(Debug, Deserialize)]
+struct Channel {
+    #[serde(rename = "data")]
+    data: ChannelData,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChannelData {
+    #[serde(rename = "commitments")]
+    commitments: Commitments,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commitments {
+    #[serde(rename = "active")]
+    active_channels: Vec<ActiveChannel>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ActiveChannel {
+    #[serde(rename = "fundingTx")]
+    funding_tx: FundingTx,
+}
+
+#[derive(Debug, Deserialize)]
+struct FundingTx {
+    #[serde(rename = "amountSatoshis")]
+    amount_satoshis: u64,
+}
+
+fn parse_json_to_node_features(json: &Value) -> NodeFeatures {
+    let mut flags = vec![0u8; 8];
+
+    let feature_mapping: HashMap<&str, usize> = [
+        ("option_data_loss_protect", 0),
+        ("option_upfront_shutdown_script", 4),
+        ("gossip_queries", 6),
+        ("var_onion_optin", 8),
+        ("gossip_queries_ex", 10),
+        ("option_static_remotekey", 12),
+        ("payment_secret", 14),
+        ("basic_mpp", 16),
+        ("option_support_large_channel", 18),
+        ("option_anchors_zero_fee_htlc_tx", 22),
+        ("option_route_blinding", 24),
+        ("option_shutdown_anysegwit", 26),
+        ("option_dual_fund", 28),
+        ("option_quiesce", 34),
+        ("option_onion_messages", 38),
+        ("option_provide_storage", 42),
+        ("option_channel_type", 44),
+        ("option_scid_alias", 46),
+        ("option_payment_metadata", 48),
+        ("option_zeroconf", 50),
+        ("keysend", 54),
+        ("option_simple_close", 60),
+    ]
+    .iter()
+    .cloned()
+    .collect();
+
+    if let Some(activated) = json["activated"].as_object() {
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: I agree, this code looks like it should use some library. Unfortunately, I haven't found anything that can simplify it.
created_at: 2025-03-07T05:39:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: `request_static` is used in the constructor, this is the only reason why it exists. Otherwise, it will be only `request`. After all the refactoring it looks a bit cleaner so I won't go deeper for now and leave it as it is. If after second review you still find this can be improved, please let me know and I'll handle it.
created_at: 2025-03-07T05:42:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,10 +20,17 @@ on. You will need:
 * Access to execute commands on _at least_ one node in the network.
 * Rust compiler [installed](https://www.rust-lang.org/tools/install).
 
+The simulator requires access details for a set of `nodes` that you
+have permission to execute commands on. Note that the current version
+of the simulator uses keysend to execute payments, which must be enabled as follows:
+* CLN: enabled by default
path: README.md
user: login: carlaKC
body: nice 🧹 
created_at: 2025-03-07T16:13:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +83,15 @@ Whereas for CLN nodes, the following information is required:
 }
 ```
 
+For Eclair, the following information is required:
+```
+{ 
+  "id": <node_id>,
+  "base_url": <scheme://ip:port or scheme://domain:port>,
path: README.md
user: login: carlaKC
body: nit: remove `scheme://` here and `http:` below so that the readme has the simplest example possible
created_at: 2025-03-07T16:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: I was thinking something like [this patch](https://github.com/user-attachments/files/19129979/eclair_client.patch) to pull the request details out into their own struct, so that the `EclairNode`'s single responsibility is implementing `LightningNode` trait.
created_at: 2025-03-07T16:28:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: Cool got it, could you please expand the comment to just briefly say that `PaymentStatus::Failed` means that the API call failed and then link to the issue for additional context?
created_at: 2025-03-07T16:30:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
+                        };
+
+                        return Ok(PaymentResult {
+                            htlc_count,
+                            payment_outcome,
+                        });
+                    }
+                },
+            }
+        }
+    }
+
+    async fn get_node_info(&mut self, node_id: &PublicKey) -> Result<NodeInfo, LightningError> {
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(node_id.serialize()));
+
+        let node_info: NodeResponse = self
+            .request("node", Some(params))
+            .await
+            .map_err(|err| LightningError::GetNodeInfoError(err.to_string()))?;
+        let features = parse_json_to_node_features(&node_info.announcement.features);
+
+        Ok(NodeInfo {
+            pubkey: *node_id,
+            alias: node_info.announcement.alias,
+            features,
+        })
+    }
+
+    async fn list_channels(&mut self) -> Result<Vec<u64>, LightningError> {
+        let mut params = HashMap::new();
+        params.insert(
+            "nodeId".to_string(),
+            hex::encode(self.info.pubkey.serialize()),
+        );
+
+        let channels: ChannelsResponse = self
+            .request("channels", Some(params))
+            .await
+            .map_err(|err| LightningError::ListChannelsError(err.to_string()))?;
+
+        let capacities_msat: Vec<u64> = channels
+            .iter()
+            .map(|channel| {
+                channel
+                    .data
+                    .commitments
+                    .active_channels
+                    .iter()
+                    .map(|ac| ac.funding_tx.amount_satoshis * 1_000)
+                    .sum()
+            })
+            .collect();
+
+        Ok(capacities_msat)
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct GetInfoResponse {
+    #[serde(rename = "nodeId")]
+    node_id: String,
+    alias: String,
+    network: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum PaymentStatus {
+    Pending,
+    Failed,
+    Sent,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentStatusDetails {
+    #[serde(rename = "type")]
+    r#type: PaymentStatus,
+}
+
+#[derive(Debug, Deserialize)]
+struct PaymentPart {
+    status: PaymentStatusDetails,
+    #[serde(rename = "paymentHash")]
+    payment_hash: String,
+}
+
+type PaymentInfoResponse = Vec<PaymentPart>;
+
+#[derive(Debug, Deserialize)]
+struct Announcement {
+    alias: String,
+    features: Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct NodeResponse {
+    announcement: Announcement,
+}
+
+type ChannelsResponse = Vec<Channel>;
+
+#[derive(Debug, Deserialize)]
+struct Channel {
+    #[serde(rename = "data")]
+    data: ChannelData,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChannelData {
+    #[serde(rename = "commitments")]
+    commitments: Commitments,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commitments {
+    #[serde(rename = "active")]
+    active_channels: Vec<ActiveChannel>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ActiveChannel {
+    #[serde(rename = "fundingTx")]
+    funding_tx: FundingTx,
+}
+
+#[derive(Debug, Deserialize)]
+struct FundingTx {
+    #[serde(rename = "amountSatoshis")]
+    amount_satoshis: u64,
+}
+
+fn parse_json_to_node_features(json: &Value) -> NodeFeatures {
+    let mut flags = vec![0u8; 8];
+
+    let feature_mapping: HashMap<&str, usize> = [
+        ("option_data_loss_protect", 0),
+        ("option_upfront_shutdown_script", 4),
+        ("gossip_queries", 6),
+        ("var_onion_optin", 8),
+        ("gossip_queries_ex", 10),
+        ("option_static_remotekey", 12),
+        ("payment_secret", 14),
+        ("basic_mpp", 16),
+        ("option_support_large_channel", 18),
+        ("option_anchors_zero_fee_htlc_tx", 22),
+        ("option_route_blinding", 24),
+        ("option_shutdown_anysegwit", 26),
+        ("option_dual_fund", 28),
+        ("option_quiesce", 34),
+        ("option_onion_messages", 38),
+        ("option_provide_storage", 42),
+        ("option_channel_type", 44),
+        ("option_scid_alias", 46),
+        ("option_payment_metadata", 48),
+        ("option_zeroconf", 50),
+        ("keysend", 54),
+        ("option_simple_close", 60),
+    ]
+    .iter()
+    .cloned()
+    .collect();
+
+    if let Some(activated) = json["activated"].as_object() {
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: :( 
created_at: 2025-03-07T16:30:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +83,15 @@ Whereas for CLN nodes, the following information is required:
 }
 ```
 
+For Eclair, the following information is required:
+```
+{ 
+  "id": <node_id>,
+  "base_url": <scheme://ip:port or scheme://domain:port>,
path: README.md
user: login: alexzaitsev
body: Addressed: https://github.com/bitcoin-dev-project/sim-ln/pull/225/commits/be85f5b1fa6cacc54b1885a35d4445f963954323
I also did some light cleanup and moved list of implementations to be above the prerequisites (it's more important IMO). Let me know if any of these changes doesn't work for you.
created_at: 2025-03-07T19:20:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        EclairNode::request_static(&self.connection, &self.http_client, endpoint, params).await
+    }
+
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let client = Client::new();
+        let info: GetInfoResponse =
+            EclairNode::request_static(&connection, &client, "getinfo", None)
+                .await
+                .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let pubkey = PublicKey::from_str(info.node_id.as_str())
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let network = Network::from_str(match info.network.as_str() {
+            "mainnet" => "bitcoin",
+            "simnet" => {
+                return Err(LightningError::ValidationError(
+                    "simnet is not supported".to_string(),
+                ))
+            },
+            x => x,
+        })
+        .map_err(|err| LightningError::ValidationError(err.to_string()))?;
+        let features = parse_json_to_node_features(&info.features);
+
+        Ok(Self {
+            http_client: client,
+            connection,
+            info: NodeInfo {
+                pubkey,
+                alias: info.alias,
+                features,
+            },
+            network,
+        })
+    }
+}
+
+#[async_trait]
+impl LightningNode for EclairNode {
+    fn get_info(&self) -> &NodeInfo {
+        &self.info
+    }
+
+    async fn get_network(&mut self) -> Result<Network, LightningError> {
+        Ok(self.network)
+    }
+
+    async fn send_payment(
+        &mut self,
+        dest: PublicKey,
+        amount_msat: u64,
+    ) -> Result<PaymentHash, LightningError> {
+        let preimage = PaymentPreimage(rand::random()).0;
+        let mut params = HashMap::new();
+        params.insert("nodeId".to_string(), hex::encode(dest.serialize()));
+        params.insert("amountMsat".to_string(), amount_msat.to_string());
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        let uuid: String = self
+            .request("sendtonode", Some(params))
+            .await
+            .map_err(|err| LightningError::SendPaymentError(err.to_string()))?;
+
+        let mut params = HashMap::new();
+        params.insert("paymentHash".to_string(), hex::encode(preimage));
+        params.insert("id".to_string(), uuid);
+        let payment_parts: PaymentInfoResponse = self
+            .request("getsentinfo", Some(params))
+            .await
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+        let payment_hash: [u8; 32] = hex::decode(&payment_parts[0].payment_hash)
+            .map_err(|_| LightningError::InvalidPaymentHash)?
+            .try_into()
+            .map_err(|_| LightningError::InvalidPaymentHash)?;
+
+        Ok(PaymentHash(payment_hash))
+    }
+
+    async fn track_payment(
+        &mut self,
+        hash: &PaymentHash,
+        shutdown: Listener,
+    ) -> Result<PaymentResult, LightningError> {
+        loop {
+            tokio::select! {
+                biased;
+                _ = shutdown.clone() => {
+                    return Err(LightningError::TrackPaymentError("Shutdown before tracking results".to_string()));
+                },
+                _ = time::sleep(Duration::from_millis(500)) => {
+                    let mut params = HashMap::new();
+                    params.insert("paymentHash".to_string(), hex::encode(hash.0));
+
+                    let payment_parts: PaymentInfoResponse = self.request("getsentinfo", Some(params))
+                    .await
+                    .map_err(|err| LightningError::TrackPaymentError(err.to_string()))?;
+
+                    if let Some(payment) = payment_parts.first() {
+                        let htlc_count = payment_parts.len();
+                        let payment_outcome = match payment.status.r#type {
+                            PaymentStatus::Pending => continue,
+                            PaymentStatus::Sent => PaymentOutcome::Success,
+                            // Task: https://github.com/bitcoin-dev-project/sim-ln/issues/26#issuecomment-1691780018
+                            PaymentStatus::Failed => PaymentOutcome::UnexpectedError,
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: Sure https://github.com/bitcoin-dev-project/sim-ln/pull/225/commits/9c028fb6a2249abddfaf0ba512a99ff82a8db5f2
created_at: 2025-03-07T19:23:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: makes sense! Done https://github.com/bitcoin-dev-project/sim-ln/pull/225/commits/0157beb38b659f5afedc0e22ac79a39d87f36a57
created_at: 2025-03-07T19:30:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,6 +12,15 @@ lightning network development. It may be useful to you if you are:
 * A signet operator interested in a hands-off way to run an active node. 
 * A researcher generating synthetic data for a target topology.
 
+## LN Implementation Support
path: README.md
user: login: carlaKC
body: Very nice docs updates all around 🌟 
created_at: 2025-03-12T14:35:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,365 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+impl EclairConnection {
+    fn construct_url(&self, endpoint: &str) -> anyhow::Result<Url> {
+        Ok(Url::parse(&self.base_url)?.join(endpoint)?)
+    }
+}
+
+pub struct EclairNode {
+    connection: EclairConnection,
+    http_client: Client,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        connection: &EclairConnection,
+        client: &Client,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<T> {
+        let url = connection.construct_url(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(&connection.api_username, Some(&connection.api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: We can simplify this now that we've got a `EclairClient` type: 
```
impl EclairClient {
    async fn request<T: for<'de> Deserialize<'de>>(
		&self,
        endpoint: &str,
        params: Option<HashMap<String, String>>,
    ) -> Result<T, Box<dyn Error>> {
        let url = self.base_url.join(endpoint)?;
        let mut request = self.http_client
            .request(Method::POST, url)
            .basic_auth(self.api_username.clone(), Some(self.api_password.clone()));

        if let Some(params) = params {
            let mut form = Form::new();
            for (key, value) in params {
                form = form.text(key, value);
            }
            request = request.multipart(form);
        }

        let response = request
            .send()
            .await?
            .error_for_status()?
            .json::<T>()
            .await?;

        Ok(response)
    }
}
```
created_at: 2025-03-12T14:41:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,397 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::error::Error;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+struct EclairClient {
+    base_url: Url,
+    api_username: String,
+    api_password: String,
+    http_client: Client,
+}
+
+impl EclairClient {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        client: &Client,
+        base_url: &Url,
+        api_username: &str,
+        api_password: &str,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> Result<T, Box<dyn Error>> {
+        let url = base_url.join(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(api_username, Some(api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> Result<T, Box<dyn Error>> {
+        EclairClient::request_static(
+            &self.http_client,
+            &self.base_url,
+            &self.api_username,
+            &self.api_password,
+            endpoint,
+            params,
+        )
+        .await
+    }
+}
+
+pub struct EclairNode {
+    client: EclairClient,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let base_url = Url::parse(&connection.base_url)
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let client = Client::new();
+        let info: GetInfoResponse = EclairClient::request_static(
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: Rather than make a static request here and then create the `EclairClient` on 122, let's:
1. Create the `EclairClient` here
2. Use it to call `getinfo` here
3. Use the same client below to create `EclairNode`
created_at: 2025-03-12T14:42:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,397 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::error::Error;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+struct EclairClient {
+    base_url: Url,
+    api_username: String,
+    api_password: String,
+    http_client: Client,
+}
+
+impl EclairClient {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        client: &Client,
+        base_url: &Url,
+        api_username: &str,
+        api_password: &str,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> Result<T, Box<dyn Error>> {
+        let url = base_url.join(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(api_username, Some(api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> Result<T, Box<dyn Error>> {
+        EclairClient::request_static(
+            &self.http_client,
+            &self.base_url,
+            &self.api_username,
+            &self.api_password,
+            endpoint,
+            params,
+        )
+        .await
+    }
+}
+
+pub struct EclairNode {
+    client: EclairClient,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let base_url = Url::parse(&connection.base_url)
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let client = Client::new();
path: simln-lib/src/eclair.rs
user: login: carlaKC
body: Implement `impl TryInto<EclairClient> for EclairConnection` and use it here.
created_at: 2025-03-12T14:44:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,397 @@
+use crate::{
+    serializers, LightningError, LightningNode, NodeId, NodeInfo, PaymentOutcome, PaymentResult,
+};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use bitcoin::Network;
+use lightning::ln::features::NodeFeatures;
+use lightning::ln::{PaymentHash, PaymentPreimage};
+use reqwest::multipart::Form;
+use reqwest::{Client, Method, Url};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::error::Error;
+use std::str::FromStr;
+use std::time::Duration;
+use tokio::time;
+use triggered::Listener;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct EclairConnection {
+    #[serde(with = "serializers::serde_node_id")]
+    pub id: NodeId,
+    #[serde(with = "serializers::serde_address")]
+    pub base_url: String,
+    pub api_username: String,
+    pub api_password: String,
+}
+
+struct EclairClient {
+    base_url: Url,
+    api_username: String,
+    api_password: String,
+    http_client: Client,
+}
+
+impl EclairClient {
+    async fn request_static<T: for<'de> Deserialize<'de>>(
+        client: &Client,
+        base_url: &Url,
+        api_username: &str,
+        api_password: &str,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> Result<T, Box<dyn Error>> {
+        let url = base_url.join(endpoint)?;
+        let mut request = client
+            .request(Method::POST, url)
+            .basic_auth(api_username, Some(api_password));
+
+        if let Some(params) = params {
+            let mut form = Form::new();
+            for (key, value) in params {
+                form = form.text(key, value);
+            }
+            request = request.multipart(form);
+        }
+
+        let response = request
+            .send()
+            .await?
+            .error_for_status()?
+            .json::<T>()
+            .await?;
+
+        Ok(response)
+    }
+
+    async fn request<T: for<'de> Deserialize<'de>>(
+        &self,
+        endpoint: &str,
+        params: Option<HashMap<String, String>>,
+    ) -> Result<T, Box<dyn Error>> {
+        EclairClient::request_static(
+            &self.http_client,
+            &self.base_url,
+            &self.api_username,
+            &self.api_password,
+            endpoint,
+            params,
+        )
+        .await
+    }
+}
+
+pub struct EclairNode {
+    client: EclairClient,
+    info: NodeInfo,
+    network: Network,
+}
+
+impl EclairNode {
+    pub async fn new(connection: EclairConnection) -> Result<Self, LightningError> {
+        let base_url = Url::parse(&connection.base_url)
+            .map_err(|err| LightningError::GetInfoError(err.to_string()))?;
+        let client = Client::new();
+        let info: GetInfoResponse = EclairClient::request_static(
path: simln-lib/src/eclair.rs
user: login: alexzaitsev
body: indeed, this refactoring looks so logical! thank you for pointing out on it
created_at: 2025-03-15T17:11:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: carlaKC
created_at: 2025-03-03T13:25:07Z
body: Thanks for the PR, awesome stuff! On my list to review this week 🫡 

Small CI nitpick - `make check` failing, just needs a `cargo fmt`!
author_association: CONTRIBUTOR
user: login: alexzaitsev
created_at: 2025-03-03T20:14:59Z
body: @carlaKC should be good now. Should I squash every time I push some fix?
author_association: CONTRIBUTOR
user: login: carlaKC
created_at: 2025-03-03T20:43:30Z
body: > @carlaKC should be good now. Should I squash every time I push some fix?

My personal preference is to use `fixup` commits while review is ongoing and then squash once the PR is ready to merge. 
In this case, feel free to squash since it's just a formatting change and nobody has reviewed.
author_association: CONTRIBUTOR
user: login: alexzaitsev
created_at: 2025-03-03T20:47:20Z
body: I squshed. I'm not aware about fixup commits, will study this. Thank you
author_association: CONTRIBUTOR
user: login: f3r10
created_at: 2025-03-05T16:27:33Z
body: hi @alexzaitsev thanks for the PR. I am trying to run it with polar but I am getting this error: 
```Config validation failed: Destination node does not support keysend```
Do you know how to enable `keysend` on Eclair with polar :thinking: ? 
author_association: COLLABORATOR
user: login: alexzaitsev
created_at: 2025-03-10T03:50:47Z
body: @f3r10 here is `dave` - `heidi` channel where all the liquidity is on the dave's side:
<img width="716" alt="Screenshot 2025-03-09 at 21 48 45" src="https://github.com/user-attachments/assets/cf3fcfb8-a3ab-4a30-9f4d-101193ce78db" />

I'm getting next output:  
```
2025-03-10T03:48:04.857Z INFO  [sim_cli] Connected to dave - Node ID: 03e8f31de94e6aac1b8de359957e2d07bf89a180d91fb06c3af738bfc836715c56.
2025-03-10T03:48:04.887Z INFO  [sim_cli] Connected to heidi - Node ID: 0233853f612562ae7fd66b496c1ca9eb2e5949c6f3d262cf3a9ed6a22b1077b568.
2025-03-10T03:48:04.887Z INFO  [simln_lib] Running the simulation forever.
2025-03-10T03:48:04.887Z INFO  [simln_lib] Simulation is running on regtest.
2025-03-10T03:48:04.887Z INFO  [simln_lib] Simulating 0 activity on 2 nodes.
2025-03-10T03:48:04.887Z DEBUG [simln_lib] Setting up simulator data collection.
2025-03-10T03:48:04.888Z DEBUG [simln_lib] Simulator data collection set up.
2025-03-10T03:48:04.888Z DEBUG [simln_lib] Starting simulation results consumer.
2025-03-10T03:48:04.888Z DEBUG [simln_lib] Starting results logger.
2025-03-10T03:48:04.888Z DEBUG [simln_lib] Starting simulation results producer.
2025-03-10T03:48:04.888Z INFO  [simln_lib] Summary of results will be reported every 60s.
2025-03-10T03:48:04.903Z WARN  [simln_lib] Node: 03e8f31de94e6aac1b8de359957e2d07bf89a180d91fb06c3af738bfc836715c56 not eligible for activity generation: InsufficientCapacity: node needs at least 7600000 capacity (has: 0) to process expected payment amount: 3800000.
2025-03-10T03:48:04.935Z WARN  [simln_lib] Node: 0233853f612562ae7fd66b496c1ca9eb2e5949c6f3d262cf3a9ed6a22b1077b568 not eligible for activity generation: InsufficientCapacity: node needs at least 7600000 capacity (has: 0) to process expected payment amount: 3800000.
2025-03-10T03:48:04.935Z DEBUG [simln_lib] Simulation results producer exiting.
2025-03-10T03:48:04.935Z DEBUG [simln_lib] Produce simulation results received shutdown signal.
2025-03-10T03:48:04.935Z DEBUG [simln_lib] Exiting results logger.
2025-03-10T03:48:04.935Z DEBUG [simln_lib] Consume simulation result received shutdown signal.
Error: Value error: at least two nodes required for activity generation

Stack backtrace:
   0: std::backtrace_rs::backtrace::libunwind::trace
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/../../backtrace/src/backtrace/libunwind.rs:116:5
   1: std::backtrace_rs::backtrace::trace_unsynchronized
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/../../backtrace/src/backtrace/mod.rs:66:5
   2: std::backtrace::Backtrace::create
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/backtrace.rs:331:13
   3: anyhow::error::<impl core::convert::From<E> for anyhow::Error>::from
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/anyhow-1.0.95/src/backtrace.rs:27:14
   4: <core::result::Result<T,F> as core::ops::try_trait::FromResidual<core::result::Result<core::convert::Infallible,E>>>::from_residual
             at /Users/p3ngu1n/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/result.rs:2014:27
   5: sim_cli::main::{{closure}}
             at ./sim-cli/src/main.rs:231:5
   6: <core::pin::Pin<P> as core::future::future::Future>::poll
             at /Users/p3ngu1n/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/future/future.rs:124:9
   7: tokio::runtime::park::CachedParkThread::block_on::{{closure}}
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/park.rs:284:63
   8: tokio::runtime::coop::with_budget
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/coop.rs:107:5
   9: tokio::runtime::coop::budget
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/coop.rs:73:5
  10: tokio::runtime::park::CachedParkThread::block_on
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/park.rs:284:31
  11: tokio::runtime::context::blocking::BlockingRegionGuard::block_on
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/context/blocking.rs:66:9
  12: tokio::runtime::scheduler::multi_thread::MultiThread::block_on::{{closure}}
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/scheduler/multi_thread/mod.rs:87:13
  13: tokio::runtime::context::runtime::enter_runtime
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/context/runtime.rs:65:16
  14: tokio::runtime::scheduler::multi_thread::MultiThread::block_on
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/scheduler/multi_thread/mod.rs:86:9
  15: tokio::runtime::runtime::Runtime::block_on_inner
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/runtime.rs:370:45
  16: tokio::runtime::runtime::Runtime::block_on
             at /Users/p3ngu1n/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.43.0/src/runtime/runtime.rs:340:13
  17: sim_cli::main
             at ./sim-cli/src/main.rs:233:5
  18: core::ops::function::FnOnce::call_once
             at /Users/p3ngu1n/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
  19: std::sys::backtrace::__rust_begin_short_backtrace
             at /Users/p3ngu1n/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/std/src/sys/backtrace.rs:152:18
  20: std::rt::lang_start::{{closure}}
             at /Users/p3ngu1n/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/std/src/rt.rs:195:18
  21: core::ops::function::impls::<impl core::ops::function::FnOnce<A> for &F>::call_once
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/ops/function.rs:284:13
  22: std::panicking::try::do_call
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:584:40
  23: std::panicking::try
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:547:19
  24: std::panic::catch_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panic.rs:358:14
  25: std::rt::lang_start_internal::{{closure}}
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/rt.rs:174:48
  26: std::panicking::try::do_call
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:584:40
  27: std::panicking::try
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:547:19
  28: std::panic::catch_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panic.rs:358:14
  29: std::rt::lang_start_internal
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/rt.rs:174:20
  30: std::rt::lang_start
             at /Users/p3ngu1n/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/std/src/rt.rs:194:17
  31: _main

Process finished with exit code 1

```

sim.json:
```
{
    "nodes": [
        {
            "id": "dave",
            "base_url": "http://127.0.0.1:8284",
            "api_username": "",
            "api_password": "eclairpw"
        },
        {
            "id": "heidi",
            "base_url": "http://127.0.0.1:8288",
            "api_username": "",
            "api_password": "eclairpw"
        }
    ]
}
```

Seems like I have another output? I'm confused.
author_association: CONTRIBUTOR
user: login: alexzaitsev
created_at: 2025-03-10T04:03:17Z
body: Ok, I see the issue, I can confirm `list_channels` returns [] because channels list is empty. I'm working on the fix.
author_association: CONTRIBUTOR
user: login: alexzaitsev
created_at: 2025-03-11T04:22:16Z
body: @f3r10 I applied a few fixes and it should be good now, could you retest?  
```
2025-03-11T04:25:55.315Z INFO  [sim_cli] Connected to niaj - Node ID: 02be1f48019da2b41bb631fb8777208e9f9c2234a9729ba9b16d31b8020de5e0e5.
2025-03-11T04:25:55.332Z INFO  [sim_cli] Connected to mike - Node ID: 026e3bcb2a3daa1b93122ac97496484e01fb8d96715ef8564addc45fc34294a436.
2025-03-11T04:25:55.332Z INFO  [simln_lib] Running the simulation forever.
2025-03-11T04:25:55.332Z INFO  [simln_lib] Simulation is running on regtest.
2025-03-11T04:25:55.332Z INFO  [simln_lib] Simulating 0 activity on 2 nodes.
2025-03-11T04:25:55.332Z DEBUG [simln_lib] Setting up simulator data collection.
2025-03-11T04:25:55.333Z DEBUG [simln_lib] Starting results logger.
2025-03-11T04:25:55.333Z INFO  [simln_lib] Summary of results will be reported every 60s.
2025-03-11T04:25:55.333Z DEBUG [simln_lib] Simulator data collection set up.
2025-03-11T04:25:55.333Z DEBUG [simln_lib] Starting simulation results producer.
2025-03-11T04:25:55.333Z DEBUG [simln_lib] Starting simulation results consumer.
2025-03-11T04:25:55.363Z WARN  [simln_lib] Node: 026e3bcb2a3daa1b93122ac97496484e01fb8d96715ef8564addc45fc34294a436 not eligible for activity generation: InsufficientCapacity: node needs at least 7600000 capacity (has: 1427000) to process expected payment amount: 3800000.
2025-03-11T04:25:55.363Z DEBUG [simln_lib] Exiting results logger.
2025-03-11T04:25:55.363Z DEBUG [simln_lib] Simulation results producer exiting.
2025-03-11T04:25:55.363Z DEBUG [simln_lib] Produce simulation results received shutdown signal.
2025-03-11T04:25:55.363Z DEBUG [simln_lib] Consume simulation result received shutdown signal.
Error: Value error: at least two nodes required for activity generation
```

---

I found out that Eclair channels are unstable, especially if you restart those Docker nodes (or whole network). Maybe it's related to Polar, I don't know. It can result in some unexpected behaviour, especially considering the channels look OK in Polar UI. 

For example, here is investigation of `2025-03-11T03:46:02.912Z DEBUG [simln_lib] Track payment 514403ff3dea79163540bdb2ed6ff5516da3527957ebc06a8b185ef12b558884 result: UnexpectedError` log message:  
```
% curl -s -u :eclairpw -X POST -F paymentHash=514403ff3dea79163540bdb2ed6ff5516da3527957ebc06a8b185ef12b558884 "http://127.0.0.1:8283/getsentinfo"
```
[getsentinfo.json](https://github.com/user-attachments/files/19175337/getsentinfo.json)

As you see, it shows `"failureMessage": "channel is unavailable (offline or closing)"`. If I check this node's channels: 
 ```
% curl -s -u :eclairpw -X POST "http://127.0.0.1:8283/channels"
```
Indeed shows channels are offline:  
[channels.json](https://github.com/user-attachments/files/19175327/channels.json)

This is just something to consider as this can be an obstacle in the testing process. **For the best experience, I recommend spawning fresh nodes with the fresh channels each time.**

---

Another thing, not related to the current discussion. I noticed pretty often sim-ln tries to send zero amounts:  
```
2025-03-11T03:42:43.464Z DEBUG [simln_lib] Next payment for dave(03e8f3...715c56) in 0ns.
2025-03-11T03:42:43.465Z DEBUG [simln_lib] Skipping zero amount payment for dave(03e8f3...715c56) -> bob(0349e1...7a53e8).
2025-03-11T03:42:43.466Z DEBUG [simln_lib] Next payment for dave(03e8f3...715c56) in 0ns.
2025-03-11T03:42:43.467Z DEBUG [simln_lib] Skipping zero amount payment for dave(03e8f3...715c56) -> bob(0349e1...7a53e8).
```
Is it expected?
author_association: CONTRIBUTOR
user: login: carlaKC
created_at: 2025-03-11T19:27:15Z
body: >  I noticed pretty often sim-ln tries to send zero amounts:

This is okay! TL;DR on how we generate random activity:
- We expect each node in the network to send some multiplier of its capacity (`-c`) in a month; so if your channel is 20k and multiplier is 2, we'll expect your node to send 40k of payments in a month
- We set a payment amount (`-e`) which defines the mean payment amount, and then randomly sample around that amount

You can run into this zero-amount behavior when you have channels that are relatively close to the expected payment amount, because to meet the expected amount you don't need to send that many payments. Eg, if the expected payment amount is 10k, your channel will only need to send 4 payments to hit the total it needs to send.

The defaults are set based on mainnet values, so they can be a bit funky in test envs!
author_association: CONTRIBUTOR
user: login: carlaKC
created_at: 2025-03-17T18:39:06Z
body: @f3r10 any additional testing you'd like to do here or are we good to go?
author_association: CONTRIBUTOR
user: login: carlaKC
created_at: 2025-03-17T18:52:43Z
body: Ah sorry @alexzaitsev, gave you some minor conflicts merging #229 - could you rebase and then I'll merge?
author_association: CONTRIBUTOR