diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1f9c0bb5..fb2fd402 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +5,7 @@ on: branches: [ "master" ] pull_request: branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml index 39cc8e63..a67be11a 100644 --- a/.github/workflows/format.yaml +++ b/.github/workflows/format.yaml @@ -2,9 +2,8 @@ name: Format code on: push: - branches: [ "master" ] pull_request: - branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2282f9d2..621b348f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -5,6 +5,7 @@ on: branches: [ "master" ] pull_request: branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fec6aff2..8755bb67 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,6 +5,7 @@ on: branches: [ "master" ] pull_request: branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/Cargo.lock b/Cargo.lock index a4a2436f..c71c56d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,17 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -961,6 +972,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake3" version = "1.8.2" @@ -1020,6 +1043,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "bp-consensus" version = "0.11.1-alpha.2+unreviewed" @@ -1174,6 +1220,28 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -2056,6 +2124,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -2272,6 +2346,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2285,7 +2362,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", "serde", ] @@ -3681,6 +3758,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.4", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3775,6 +3861,26 @@ dependencies = [ "strict_encoding", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quinn" version = "0.11.8" @@ -3845,6 +3951,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -3988,6 +4100,15 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.22" @@ -4183,9 +4304,12 @@ dependencies = [ "reqwest", "rgb-lib", "scrypt", + "sea-orm", + "sea-orm-migration", "serde", "serde_json", "serial_test", + "sqlx", "tempfile", "thiserror 2.0.12", "time", @@ -4283,6 +4407,35 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rsa" version = "0.9.8" @@ -4310,8 +4463,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" dependencies = [ "arrayvec", + "borsh", + "bytes", "num-traits", + "rand 0.8.5", + "rkyv", "serde", + "serde_json", ] [[package]] @@ -4643,10 +4801,15 @@ version = "0.32.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a" dependencies = [ + "bigdecimal", + "chrono", "inherent", "ordered-float", + "rust_decimal", "sea-query-derive", "serde_json", + "time", + "uuid", ] [[package]] @@ -4655,9 +4818,14 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" dependencies = [ + "bigdecimal", + "chrono", + "rust_decimal", "sea-query", "serde_json", "sqlx", + "time", + "uuid", ] [[package]] @@ -4699,6 +4867,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sec1" version = "0.7.3" @@ -4999,6 +5173,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "single_use_seals" version = "0.11.1-alpha.2" @@ -5134,7 +5314,9 @@ dependencies = [ "async-io 1.13.0", "async-std", "base64 0.22.1", + "bigdecimal", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -5150,14 +5332,19 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", + "rust_decimal", "rustls 0.23.29", "serde", "serde_json", "sha2 0.10.9", "smallvec", "thiserror 2.0.12", + "time", + "tokio", + "tokio-stream", "tracing", "url", + "uuid", "webpki-roots 0.26.11", ] @@ -5196,6 +5383,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.104", + "tokio", "url", ] @@ -5207,9 +5395,11 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags 2.9.1", "byteorder", "bytes", + "chrono", "crc", "digest 0.10.7", "dotenvy", @@ -5230,6 +5420,7 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rsa", + "rust_decimal", "serde", "sha1", "sha2 0.10.9", @@ -5237,7 +5428,9 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.12", + "time", "tracing", + "uuid", "whoami", ] @@ -5249,8 +5442,10 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags 2.9.1", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -5265,8 +5460,10 @@ dependencies = [ "log", "md-5", "memchr", + "num-bigint", "once_cell", "rand 0.8.5", + "rust_decimal", "serde", "serde_json", "sha2 0.10.9", @@ -5274,7 +5471,9 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.12", + "time", "tracing", + "uuid", "whoami", ] @@ -5285,6 +5484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -5298,8 +5498,10 @@ dependencies = [ "serde_urlencoded", "sqlx-core", "thiserror 2.0.12", + "time", "tracing", "url", + "uuid", ] [[package]] @@ -5446,6 +5648,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.20.0" @@ -5634,6 +5842,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -5655,8 +5874,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -5668,6 +5887,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -5677,11 +5905,32 @@ dependencies = [ "indexmap 2.10.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +dependencies = [ + "indexmap 2.10.0", + "toml_datetime 0.7.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" version = "0.1.2" @@ -5928,7 +6177,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ "getrandom 0.3.3", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] @@ -6497,6 +6748,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index d62f8055..d79c2084 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,8 +41,11 @@ rgb-lib = { version = "0.3.0-beta.4", features = [ "esplora", ] } scrypt = "0.11.0" +sea-orm = { version = "1.1", features = ["sqlx-sqlite", "sqlx-mysql", "sqlx-postgres", "runtime-tokio-rustls", "macros"] } +sea-orm-migration = "1.1" serde = { version = "^1.0", features = ["derive"] } serde_json = "1.0" +sqlx = { version = "0.8", features = ["sqlite", "mysql", "postgres", "runtime-tokio-rustls"] } tempfile = "3.14.0" thiserror = "2.0" time = { version = "0.3.36", features = ["std"] } diff --git a/openapi.yaml b/openapi.yaml index cda48b80..0ccbf4dd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -649,6 +649,60 @@ paths: application/json: schema: $ref: '#/components/schemas/LNInvoiceResponse' + /invoice/hodl: + post: + tags: + - Invoices + summary: Create a HODL LN invoice + description: Create a BOLT11 invoice with a caller-provided payment hash; settlement is deferred until settle/cancel. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceHodlRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceHodlResponse' + /invoice/settle: + post: + tags: + - Invoices + summary: Settle a HODL invoice + description: Claim a held HTLC for a HODL invoice + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceSettleRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + /invoice/cancel: + post: + tags: + - Invoices + summary: Cancel a HODL invoice + description: Cancel a held HTLC for a HODL invoice + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceCancelRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' /makerexecute: post: tags: @@ -1561,6 +1615,7 @@ components: enum: - Pending - Succeeded + - Cancelled - Failed - Expired InvoiceStatusRequest: @@ -1790,6 +1845,59 @@ components: invoice: type: string example: lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 + InvoiceHodlRequest: + type: object + required: + - payment_hash + - expiry_sec + properties: + amt_msat: + type: integer + example: 3000000 + expiry_sec: + type: integer + example: 86400 + asset_id: + type: string + example: rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 + asset_amount: + type: integer + example: 42 + payment_hash: + type: string + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd + external_ref: + type: string + example: swap-123 + InvoiceHodlResponse: + type: object + properties: + invoice: + type: string + example: lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 + payment_secret: + type: string + example: 777a7756c620868199ed5fdc35bee4095b5709d543e5c2bf0494396bf27d2ea2 + InvoiceSettleRequest: + type: object + required: + - payment_hash + - payment_preimage + properties: + payment_hash: + type: string + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd + payment_preimage: + type: string + example: 777a7756c620868199ed5fdc35bee4095b5709d543e5c2bf0494396bf27d2ea2 + InvoiceCancelRequest: + type: object + required: + - payment_hash + properties: + payment_hash: + type: string + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd MakerExecuteRequest: type: object properties: diff --git a/regtest.sh b/regtest.sh index b3452f01..a61f944a 100755 --- a/regtest.sh +++ b/regtest.sh @@ -49,6 +49,20 @@ _wait_for_bitcoind() { done } +_wait_for_bitcoind_rpc() { + # wait for RPC to accept requests + start_time=$(date +%s) + until $BITCOIN_CLI getblockcount >/dev/null 2>&1; do + current_time=$(date +%s) + if [ $((current_time - start_time)) -gt $TIMEOUT ]; then + echo "Timeout waiting for bitcoind RPC to start" + $COMPOSE logs bitcoind + exit 1 + fi + sleep 1 + done +} + _wait_for_electrs() { # wait for electrs to have completed startup start_time=$(date +%s) @@ -74,9 +88,11 @@ _start_services() { _die "port $port is already bound, services can't be started" fi done - $COMPOSE up -d + $COMPOSE up -d bitcoind echo && echo "preparing bitcoind wallet" _wait_for_bitcoind + _wait_for_bitcoind_rpc + $COMPOSE up -d electrs proxy $BITCOIN_CLI createwallet miner >/dev/null $BITCOIN_CLI -rpcwallet=miner -generate $INITIAL_BLOCKS >/dev/null echo "waiting for electrs to have completed startup" diff --git a/src/args.rs b/src/args.rs index 496bbbeb..d70f5bb3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -6,6 +6,23 @@ use crate::auth::check_auth_args; use crate::error::AppError; use crate::utils::check_port_is_available; +#[derive(clap::ValueEnum, Clone, Debug)] +pub(crate) enum DatabaseType { + Sqlite, + Mysql, + Postgresql, +} + +impl std::fmt::Display for DatabaseType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DatabaseType::Sqlite => write!(f, "sqlite"), + DatabaseType::Mysql => write!(f, "mysql"), + DatabaseType::Postgresql => write!(f, "postgresql"), + } + } +} + #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { @@ -35,6 +52,14 @@ struct Args { /// Disable authentication #[arg(long, default_value_t = false)] disable_authentication: bool, + + /// Database type: sqlite, mysql, postgresql + #[arg(long, default_value_t = DatabaseType::Sqlite)] + database_type: DatabaseType, + + /// Database URL (required for mysql/postgresql) + #[arg(long)] + database_url: Option, } pub(crate) struct UserArgs { @@ -44,6 +69,8 @@ pub(crate) struct UserArgs { pub(crate) network: BitcoinNetwork, pub(crate) max_media_upload_size_mb: u16, pub(crate) root_public_key: Option, + pub(crate) database_type: DatabaseType, + pub(crate) database_url: Option, } pub(crate) fn parse_startup_args() -> Result { @@ -65,5 +92,7 @@ pub(crate) fn parse_startup_args() -> Result { network, max_media_upload_size_mb: args.max_media_upload_size_mb, root_public_key, + database_type: args.database_type, + database_url: args.database_url, }) } diff --git a/src/disk.rs b/src/disk.rs index 6c6f104e..99fca75c 100644 --- a/src/disk.rs +++ b/src/disk.rs @@ -15,8 +15,8 @@ use std::sync::Arc; use crate::error::APIError; use crate::ldk::{ - ChannelIdsMap, InboundPaymentInfoStorage, NetworkGraph, OutboundPaymentInfoStorage, - OutputSpenderTxes, SwapMap, + ChannelIdsMap, ClaimablePaymentStorage, InboundPaymentInfoStorage, InvoiceMetadataStorage, + NetworkGraph, OutboundPaymentInfoStorage, OutputSpenderTxes, SwapMap, }; use crate::utils::{parse_peer_info, LOGS_DIR}; @@ -24,6 +24,8 @@ pub(crate) const LDK_LOGS_FILE: &str = "logs.txt"; pub(crate) const INBOUND_PAYMENTS_FNAME: &str = "inbound_payments"; pub(crate) const OUTBOUND_PAYMENTS_FNAME: &str = "outbound_payments"; +pub(crate) const INVOICE_METADATA_FNAME: &str = "invoice_metadata"; +pub(crate) const CLAIMABLE_HTLCS_FNAME: &str = "claimable_htlcs"; pub(crate) const CHANNEL_PEER_DATA: &str = "channel_peer_data"; @@ -178,6 +180,28 @@ pub(crate) fn read_outbound_payment_info(path: &Path) -> OutboundPaymentInfoStor } } +pub(crate) fn read_invoice_metadata(path: &Path) -> InvoiceMetadataStorage { + if let Ok(file) = File::open(path) { + if let Ok(info) = InvoiceMetadataStorage::read(&mut BufReader::new(file)) { + return info; + } + } + InvoiceMetadataStorage { + invoices: HashMap::new(), + } +} + +pub(crate) fn read_claimable_htlcs(path: &Path) -> ClaimablePaymentStorage { + if let Ok(file) = File::open(path) { + if let Ok(info) = ClaimablePaymentStorage::read(&mut BufReader::new(file)) { + return info; + } + } + ClaimablePaymentStorage { + payments: HashMap::new(), + } +} + pub(crate) fn read_output_spender_txes(path: &Path) -> OutputSpenderTxes { if let Ok(file) = File::open(path) { if let Ok(info) = OutputSpenderTxes::read(&mut BufReader::new(file)) { diff --git a/src/error.rs b/src/error.rs index 52e04778..ba69af51 100644 --- a/src/error.rs +++ b/src/error.rs @@ -75,6 +75,9 @@ pub enum APIError { #[error("Unable to create keys seed file {0}: {1}")] FailedKeysCreation(String, String), + #[error("Database error: {0}")] + DatabaseError(String), + #[error("Failed to open channel: {0}")] FailedOpenChannel(String), @@ -162,6 +165,9 @@ pub enum APIError { #[error("Invalid payment hash: {0}")] InvalidPaymentHash(String), + #[error("Payment hash already used")] + PaymentHashAlreadyUsed, + #[error("Invalid payment secret")] InvalidPaymentSecret, @@ -210,6 +216,18 @@ pub enum APIError { #[error("Invalid transport endpoints: {0}")] InvalidTransportEndpoints(String), + #[error("Invoice is expired")] + InvoiceExpired, + + #[error("HTLC claim deadline exceeded")] + ClaimDeadlineExceeded, + + #[error("Invoice is not marked as HODL")] + InvoiceNotHodl, + + #[error("No claimable HTLC found for this invoice")] + InvoiceNotClaimable, + #[error("IO error: {0}")] IO(#[from] std::io::Error), @@ -234,6 +252,9 @@ pub enum APIError { #[error("Unable to find payment preimage, be sure you've provided the correct swap info")] MissingSwapPaymentPreimage, + #[error("Invalid payment preimage")] + InvalidPaymentPreimage, + #[error("Network error: {0}")] Network(String), @@ -408,6 +429,7 @@ impl IntoResponse for APIError { | APIError::FailedPayment(_) | APIError::FailedPeerDisconnection(_) | APIError::FailedSendingOnionMessage(_) + | APIError::DatabaseError(_) | APIError::IO(_) | APIError::Unexpected(_) => ( StatusCode::INTERNAL_SERVER_ERROR, @@ -437,13 +459,16 @@ impl IntoResponse for APIError { | APIError::InvalidOnionData(_) | APIError::InvalidPassword(_) | APIError::InvalidPaymentHash(_) + | APIError::PaymentHashAlreadyUsed | APIError::InvalidPaymentSecret + | APIError::InvalidPaymentPreimage | APIError::InvalidPeerInfo(_) | APIError::InvalidPrecision(_) | APIError::InvalidPubkey | APIError::InvalidRecipientData(_) | APIError::InvalidRecipientID | APIError::InvalidRecipientNetwork + | APIError::InvoiceExpired | APIError::InvalidSwap(_) | APIError::InvalidSwapString(_, _) | APIError::InvalidTicker(_) @@ -454,6 +479,7 @@ impl IntoResponse for APIError { | APIError::MediaFileNotProvided | APIError::MissingSwapPaymentPreimage | APIError::OutputBelowDustLimit + | APIError::ClaimDeadlineExceeded | APIError::UnsupportedBackupVersion { .. } => { (StatusCode::BAD_REQUEST, self.to_string(), self.name()) } @@ -499,6 +525,9 @@ impl IntoResponse for APIError { | APIError::UnsupportedTransportType => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } + APIError::InvoiceNotHodl | APIError::InvoiceNotClaimable => { + (StatusCode::FORBIDDEN, self.to_string(), self.name()) + } APIError::Network(_) | APIError::NoValidTransportEndpoint => ( StatusCode::SERVICE_UNAVAILABLE, self.to_string(), @@ -526,6 +555,9 @@ impl IntoResponse for APIError { /// The error variants returned by the app #[derive(Debug, thiserror::Error)] pub enum AppError { + #[error("Configuration error: {0}")] + ConfigError(String), + #[error("The provided authentication args are invalid")] InvalidAuthenticationArgs, @@ -540,4 +572,7 @@ pub enum AppError { #[error("Port {0} is unavailable")] UnavailablePort(u16), + + #[error("Database connection error: {0}")] + DatabaseConnection(String), } diff --git a/src/ldk.rs b/src/ldk.rs index 32608d42..1f0eae42 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -45,7 +45,7 @@ use lightning::util::persist::{ }; use lightning::util::ser::{ReadableArgs, Writeable}; use lightning::util::sweep as ldk_sweep; -use lightning::{chain, impl_writeable_tlv_based}; +use lightning::{chain, impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_background_processor::{process_events_async, GossipSync, NO_LIQUIDITY_MANAGER}; use lightning_block_sync::gossip::TokioSpawner; use lightning_block_sync::init; @@ -68,7 +68,7 @@ use rgb_lib::{ utils::{get_account_data, recipient_id_from_script_buf, script_buf_from_recipient_id}, wallet::{ rust_only::{check_indexer_url, AssetColoringInfo, ColoringInfo}, - DatabaseType, Recipient, TransportEndpoint, Wallet as RgbLibWallet, WalletData, + DatabaseType, Online, Recipient, TransportEndpoint, Wallet as RgbLibWallet, WalletData, WitnessData, }, AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, FileContent, RgbTransfer, @@ -93,8 +93,9 @@ use tokio::task::JoinHandle; use crate::bitcoind::BitcoindClient; use crate::disk::{ - self, FilesystemLogger, CHANNEL_IDS_FNAME, CHANNEL_PEER_DATA, INBOUND_PAYMENTS_FNAME, - MAKER_SWAPS_FNAME, OUTBOUND_PAYMENTS_FNAME, OUTPUT_SPENDER_TXES, TAKER_SWAPS_FNAME, + self, FilesystemLogger, CHANNEL_IDS_FNAME, CHANNEL_PEER_DATA, CLAIMABLE_HTLCS_FNAME, + INBOUND_PAYMENTS_FNAME, INVOICE_METADATA_FNAME, MAKER_SWAPS_FNAME, OUTBOUND_PAYMENTS_FNAME, + OUTPUT_SPENDER_TXES, TAKER_SWAPS_FNAME, }; use crate::error::APIError; use crate::rgb::{check_rgb_proxy_endpoint, get_rgb_channel_info_optional, RgbLibWalletWrapper}; @@ -116,6 +117,7 @@ pub(crate) struct LdkBackgroundServices { peer_manager: Arc, bp_exit: Sender<()>, background_processor: Option>>, + claimable_expiry_task: Option>, } #[derive(Clone, Debug)] @@ -139,6 +141,69 @@ impl_writeable_tlv_based!(PaymentInfo, { (12, payee_pubkey, required), }); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum InvoiceMode { + AutoClaim, + Hodl, +} + +impl_writeable_tlv_based_enum!(InvoiceMode, + (0, AutoClaim) => {}, + (1, Hodl) => {}, +); + +/// Invoice-level metadata persisted by payment_hash. +/// Holds static expectations from invoice creation. +#[derive(Clone, Debug)] +pub(crate) struct InvoiceMetadata { + /// Invoice mode: AutoClaim or Hodl. + pub(crate) mode: InvoiceMode, + /// Expected amount from the invoice (msat). Used for under/over checks. + pub(crate) expected_amt_msat: Option, + /// Invoice expiry (seconds since epoch). + pub(crate) expiry: Option, + /// Optional external reference (swap/order id, etc.). + pub(crate) external_ref: Option, +} + +impl_writeable_tlv_based!(InvoiceMetadata, { + (0, mode, required), + (2, expected_amt_msat, required), + (4, expiry, required), + (6, external_ref, option), +}); + +/// Persisted HTLC claimable state for HODL invoices. +/// Stored when we receive `Event::PaymentClaimable` in HODL mode and used by +/// settle/cancel and the auto-expiry sweep. +#[derive(Clone, Debug)] +pub(crate) struct ClaimablePayment { + /// Payment hash for this inbound HTLC. + pub(crate) payment_hash: PaymentHash, + /// HTLC amount in millisatoshis (received amount). + pub(crate) amount_msat: u64, + /// Invoice expiry timestamp (seconds since epoch). + pub(crate) invoice_expiry: Option, + /// Optional absolute deadline as block height from PaymentClaimable. + pub(crate) claim_deadline_height: Option, + /// When we stored this claimable (seconds since epoch). + pub(crate) created_at: u64, + /// Whether a settle is currently in-flight (prevents expiry task from failing it). + pub(crate) settling: Option, + /// When settlement was initiated (seconds since epoch), used to time out stalled settlements. + pub(crate) settling_since: Option, +} + +impl_writeable_tlv_based!(ClaimablePayment, { + (0, payment_hash, required), + (2, amount_msat, required), + (4, invoice_expiry, required), + (6, claim_deadline_height, required), + (8, created_at, required), + (10, settling, option), + (12, settling_since, option), +}); + pub(crate) struct InboundPaymentInfoStorage { pub(crate) payments: LdkHashMap, } @@ -147,6 +212,14 @@ impl_writeable_tlv_based!(InboundPaymentInfoStorage, { (0, payments, required), }); +pub(crate) struct InvoiceMetadataStorage { + pub(crate) invoices: HashMap, +} + +impl_writeable_tlv_based!(InvoiceMetadataStorage, { + (0, invoices, required), +}); + pub(crate) struct OutboundPaymentInfoStorage { pub(crate) payments: LdkHashMap, } @@ -155,6 +228,14 @@ impl_writeable_tlv_based!(OutboundPaymentInfoStorage, { (0, payments, required), }); +pub(crate) struct ClaimablePaymentStorage { + pub(crate) payments: HashMap, +} + +impl_writeable_tlv_based!(ClaimablePaymentStorage, { + (0, payments, required), +}); + pub(crate) struct SwapMap { pub(crate) swaps: LdkHashMap, } @@ -172,6 +253,54 @@ impl_writeable_tlv_based!(ChannelIdsMap, { }); impl UnlockedAppState { + /// Remove and return claimables that are expired or past deadline. + pub(crate) fn expire_claimables( + &self, + now_ts: u64, + current_height: u32, + ) -> Vec { + let mut claimables = self.get_claimable_htlcs(); + let mut expired = vec![]; + let to_remove: Vec = claimables + .payments + .iter() + .filter_map(|(hash, c)| { + if c.settling.unwrap_or(false) { + // Settlement in-flight; allow a timeout (24h) to avoid stuck entries. + if let Some(since) = c.settling_since { + if now_ts.saturating_sub(since) > 86_400 { + // Timeout, let it expire. + } else { + return None; + } + } else { + return None; + } + } + let deadline_passed = c + .claim_deadline_height + .map(|h| current_height >= h) + .unwrap_or(false); + let invoice_expired = c.invoice_expiry.map(|e| now_ts >= e).unwrap_or(false); + if deadline_passed || invoice_expired { + Some(*hash) + } else { + None + } + }) + .collect(); + + for hash in to_remove.iter() { + if let Some(c) = claimables.payments.remove(hash) { + expired.push(c); + } + } + if !to_remove.is_empty() { + self.save_claimable_htlcs(claimables); + } + expired + } + pub(crate) fn add_maker_swap(&self, payment_hash: PaymentHash, swap: SwapData) { let mut maker_swaps = self.get_maker_swaps(); maker_swaps.swaps.insert(payment_hash, swap); @@ -246,6 +375,83 @@ impl UnlockedAppState { self.save_inbound_payments(inbound); } + pub(crate) fn add_invoice_metadata( + &self, + payment_hash: PaymentHash, + metadata: InvoiceMetadata, + ) { + let mut invoices = self.get_invoice_metadata(); + invoices.invoices.insert(payment_hash, metadata); + self.save_invoice_metadata(invoices); + } + + pub(crate) fn invoice_metadata(&self) -> HashMap { + self.get_invoice_metadata().invoices.clone() + } + + pub(crate) fn upsert_claimable_payment(&self, claimable: ClaimablePayment) { + let mut claimables = self.get_claimable_htlcs(); + claimables + .payments + .insert(claimable.payment_hash, claimable); + self.save_claimable_htlcs(claimables); + } + + pub(crate) fn take_claimable_payment( + &self, + payment_hash: &PaymentHash, + ) -> Option { + let mut claimables = self.get_claimable_htlcs(); + let res = claimables.payments.remove(payment_hash); + if res.is_some() { + self.save_claimable_htlcs(claimables); + } + res + } + + /// Mark a claimable HTLC as settling after revalidating expiry/deadline. + /// Keeps the entry so PaymentClaimed can remove it; expiry task will skip while settling=true. + pub(crate) fn mark_claimable_settling( + &self, + payment_hash: &PaymentHash, + invoice_expiry: Option, + ) -> Result { + let mut claimables = self.get_claimable_htlcs(); + let Some(claimable) = claimables.payments.get_mut(payment_hash) else { + return Err(APIError::InvoiceNotClaimable); + }; + + let current_height = self.channel_manager.current_best_block().height; + let now_ts = get_current_timestamp(); + + if let Some(deadline_height) = claimable.claim_deadline_height { + if current_height >= deadline_height { + return Err(APIError::ClaimDeadlineExceeded); + } + } + + if let Some(expiry) = invoice_expiry { + if now_ts >= expiry { + return Err(APIError::InvoiceExpired); + } + } + + // Persist the settling flag so the expiry task won't race and fail it backwards. + claimable.settling = Some(true); + claimable.settling_since = Some(now_ts); + let claimable_clone = claimable.clone(); + self.save_claimable_htlcs(claimables); + + Ok(claimable_clone) + } + + pub(crate) fn claimable_payment(&self, payment_hash: &PaymentHash) -> Option { + self.get_claimable_htlcs() + .payments + .get(payment_hash) + .cloned() + } + pub(crate) fn add_outbound_payment( &self, payment_id: PaymentId, @@ -297,13 +503,25 @@ impl UnlockedAppState { .unwrap(); } + fn save_invoice_metadata(&self, invoices: MutexGuard) { + self.fs_store + .write("", "", INVOICE_METADATA_FNAME, &invoices.encode()) + .unwrap(); + } + fn save_outbound_payments(&self, outbound: MutexGuard) { self.fs_store .write("", "", OUTBOUND_PAYMENTS_FNAME, outbound.encode()) .unwrap(); } - fn upsert_inbound_payment( + fn save_claimable_htlcs(&self, claimables: MutexGuard) { + self.fs_store + .write("", "", CLAIMABLE_HTLCS_FNAME, &claimables.encode()) + .unwrap(); + } + + pub fn upsert_inbound_payment( &self, payment_hash: PaymentHash, status: HTLCStatus, @@ -671,7 +889,9 @@ async fn handle_ldk_events( purpose, amount_msat, receiver_node_id: _, - claim_deadline: _, + via_channel_id: _, + via_user_channel_id: _, + claim_deadline, onion_fields: _, counterparty_skimmed_fee_msat: _, receiving_channel_ids: _, @@ -682,21 +902,198 @@ async fn handle_ldk_events( payment_hash, amount_msat, ); - let payment_preimage = match purpose { + + let (payment_preimage, payment_secret) = match purpose { PaymentPurpose::Bolt11InvoicePayment { - payment_preimage, .. - } => payment_preimage, + payment_preimage, + payment_secret, + .. + } => (payment_preimage, Some(payment_secret)), PaymentPurpose::Bolt12OfferPayment { - payment_preimage, .. - } => payment_preimage, + payment_preimage, + payment_secret, + .. + } => (payment_preimage, Some(payment_secret)), PaymentPurpose::Bolt12RefundPayment { - payment_preimage, .. - } => payment_preimage, - PaymentPurpose::SpontaneousPayment(preimage) => Some(preimage), + payment_preimage, + payment_secret, + .. + } => (payment_preimage, Some(payment_secret)), + PaymentPurpose::SpontaneousPayment(preimage) => (Some(preimage), None), }; - unlocked_state - .channel_manager - .claim_funds(payment_preimage.unwrap()); + + // Invoice metadata is optional - if missing, default to auto-claim behavior + let invoice_metadata = unlocked_state + .invoice_metadata() + .get(&payment_hash) + .cloned(); + + // If no metadata exists, check if this is a legacy invoice in inbound_payments + let Some(metadata) = invoice_metadata else { + // Check if this payment_hash exists in inbound_payments (legacy invoice) + let inbound_payments = unlocked_state.inbound_payments(); + let legacy_invoice = inbound_payments.get(&payment_hash); + + if let Some(legacy_payment_info) = legacy_invoice { + // This is a legacy invoice (created before invoice_metadata feature) + // Treat it as auto-claim invoice with basic validation + tracing::info!( + "Legacy invoice detected (no metadata) for payment {:?}, treating as auto-claim", + payment_hash + ); + + // For legacy invoices, LDK should provide the preimage for standard Bolt11 invoices + let Some(preimage) = payment_preimage else { + // If preimage is missing, check if it was stored in inbound_payment + // (unlikely for standard invoices, but possible for edge cases) + if let Some(stored_preimage) = legacy_payment_info.preimage { + tracing::info!( + "Using stored preimage from inbound_payment for legacy invoice {:?}", + payment_hash + ); + unlocked_state.channel_manager.claim_funds(stored_preimage); + return Ok(()); + } + + tracing::error!( + "Missing payment preimage for legacy invoice {:?}, cannot claim. \ + This may indicate a corrupted state or LDK version issue.", + payment_hash + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + return Ok(()); + }; + + // Basic validation: check amount if specified in legacy invoice + if let Some(expected_amt) = legacy_payment_info.amt_msat { + if amount_msat < expected_amt { + tracing::warn!( + "Received {} msat for legacy invoice {} but expected at least {} msat", + amount_msat, + payment_hash, + expected_amt + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Failed, + payment_preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + return Ok(()); + } + } + + // Auto-claim legacy invoice + tracing::info!("Auto-claiming legacy invoice {:?}", payment_hash); + unlocked_state.channel_manager.claim_funds(preimage); + return Ok(()); + } + + // No metadata and not in inbound_payments - likely spontaneous/keysend payment + let Some(preimage) = payment_preimage else { + tracing::error!( + "Missing payment preimage for payment {:?}, cannot claim", + payment_hash + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + return Ok(()); + }; + tracing::info!("Auto-claiming payment without metadata {:?}", payment_hash); + unlocked_state.channel_manager.claim_funds(preimage); + return Ok(()); + }; + + let now_ts = get_current_timestamp(); + // Metadata exists - apply expiry and amount checks + if let Some(expiry) = metadata.expiry { + if now_ts >= expiry { + tracing::warn!( + "Received HTLC for expired invoice {payment_hash:?} (expiry {expiry})" + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Failed, + payment_preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + return Ok(()); + } + } + + if let Some(expected) = metadata.expected_amt_msat { + if amount_msat < expected { + tracing::warn!( + "Received {} msat for invoice {} but expected at least {} msat", + amount_msat, + payment_hash, + expected + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Failed, + payment_preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + return Ok(()); + } + } + + match metadata.mode { + InvoiceMode::AutoClaim => { + let Some(preimage) = payment_preimage else { + tracing::error!( + "Missing payment preimage for standard invoice {:?}, cannot claim", + payment_hash + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + return Ok(()); + }; + unlocked_state.channel_manager.claim_funds(preimage); + } + InvoiceMode::Hodl => { + let claim_deadline_height = claim_deadline.map(|h| h); + + let claimable = ClaimablePayment { + payment_hash, + amount_msat, + invoice_expiry: metadata.expiry, + claim_deadline_height, + created_at: now_ts, + settling: Some(false), + settling_since: None, + }; + unlocked_state.upsert_claimable_payment(claimable); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Pending, + None, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + } + } } Event::PaymentClaimed { payment_hash, @@ -765,6 +1162,11 @@ async fn handle_ldk_events( receiver_node_id.unwrap(), ); } + + // Only HODL invoices create claimable entries; auto-claim payments won't have one. + if unlocked_state.claimable_payment(&payment_hash).is_some() { + let _ = unlocked_state.take_claimable_payment(&payment_hash); + } } Event::PaymentSent { payment_preimage, @@ -1747,8 +2149,9 @@ pub(crate) async fn start_ldk( .clone() .to_string_lossy() .to_string(); - let mut rgb_wallet = tokio::task::spawn_blocking(move || { - RgbLibWallet::new(WalletData { + let indexer_url_str = indexer_url.to_string(); + let (rgb_wallet, rgb_online) = tokio::task::spawn_blocking(move || { + let mut wallet = RgbLibWallet::new(WalletData { data_dir, bitcoin_network, database_type: DatabaseType::Sqlite, @@ -1760,11 +2163,16 @@ pub(crate) async fn start_ldk( vanilla_keychain: None, supported_schemas: vec![AssetSchema::Nia, AssetSchema::Cfa, AssetSchema::Uda], }) - .expect("valid rgb-lib wallet") + .expect("valid rgb-lib wallet"); + + let online = wallet.go_online(false, indexer_url_str)?; + + Ok::<(RgbLibWallet, Online), rgb_lib::Error>((wallet, online)) }) .await - .unwrap(); - let rgb_online = rgb_wallet.go_online(false, indexer_url.to_string())?; + .unwrap() + .map_err(|e| APIError::Unexpected(format!("Unmapped rgb-lib error: {:?}", e)))?; + fs::write( static_state.storage_dir_path.join(WALLET_FINGERPRINT_FNAME), account_xpub_colored.fingerprint().to_string(), @@ -2032,6 +2440,12 @@ pub(crate) async fn start_ldk( let inbound_payments = Arc::new(Mutex::new(disk::read_inbound_payment_info( &ldk_data_dir.join(INBOUND_PAYMENTS_FNAME), ))); + let invoice_metadata = Arc::new(Mutex::new(disk::read_invoice_metadata( + &ldk_data_dir.join(INVOICE_METADATA_FNAME), + ))); + let claimable_htlcs = Arc::new(Mutex::new(disk::read_claimable_htlcs( + &ldk_data_dir.join(CLAIMABLE_HTLCS_FNAME), + ))); let outbound_payments = Arc::new(Mutex::new(disk::read_outbound_payment_info( &ldk_data_dir.join(OUTBOUND_PAYMENTS_FNAME), ))); @@ -2062,6 +2476,8 @@ pub(crate) async fn start_ldk( let unlocked_state = Arc::new(UnlockedAppState { channel_manager: Arc::clone(&channel_manager), inbound_payments, + invoice_metadata, + claimable_htlcs, keys_manager, network_graph, chain_monitor: chain_monitor.clone(), @@ -2101,6 +2517,57 @@ pub(crate) async fn start_ldk( async move { handle_ldk_events(event, unlocked_state_copy, static_state_copy).await } }; + // Background task: monitor claimable HTLCs for expiry/deadline and fail them. + let stop_claimable_expiry = Arc::clone(&stop_processing); + let unlocked_state_claimable = Arc::clone(&unlocked_state); + let claimable_expiry_task = tokio::spawn(async move { + loop { + if stop_claimable_expiry.load(Ordering::Acquire) { + return; + } + tokio::time::sleep(Duration::from_secs(30)).await; + if stop_claimable_expiry.load(Ordering::Acquire) { + return; + } + let now = get_current_timestamp(); + let height = unlocked_state_claimable + .channel_manager + .current_best_block() + .height; + let expired = unlocked_state_claimable.expire_claimables(now, height); + for claimable in expired { + // expire_claimables() already removed it atomically, so we own it now + // Note: There's a potential race where user might call invoice_settle()/invoice_cancel() + // at the same time, but the mutex in get_claimable_htlcs() protects against this. + // If user already took it via take_claimable_payment(), expire_claimables() won't + // return it, so this is safe. + + tracing::info!( + "Expiring claimable payment {:?} (deadline: {:?}, expiry: {:?})", + claimable.payment_hash, + claimable.claim_deadline_height, + claimable.invoice_expiry + ); + + // Fail the HTLC backwards - this may be a no-op if already claimed/failed, + // but LDK should handle that gracefully + unlocked_state_claimable + .channel_manager + .fail_htlc_backwards(&claimable.payment_hash); + + // Update payment status to Failed + unlocked_state_claimable.upsert_inbound_payment( + claimable.payment_hash, + HTLCStatus::Failed, + None, + None, + Some(claimable.amount_msat), + unlocked_state_claimable.channel_manager.get_our_node_id(), + ); + } + } + }); + // Background Processing let (bp_exit, bp_exit_check) = tokio::sync::watch::channel(()); let background_processor = tokio::spawn(process_events_async( @@ -2236,6 +2703,7 @@ pub(crate) async fn start_ldk( peer_manager: peer_manager.clone(), bp_exit, background_processor: Some(background_processor), + claimable_expiry_task: Some(claimable_expiry_task), }, unlocked_state, )) @@ -2260,6 +2728,12 @@ impl AppState { .store(true, Ordering::Release); ldk_background_services.peer_manager.disconnect_all_peers(); + // Stop the claimable expiry task - abort it for immediate shutdown + // (it would exit gracefully via stop_processing flag, but aborting ensures immediate stop) + if let Some(claimable_task) = ldk_background_services.claimable_expiry_task.take() { + claimable_task.abort(); + } + // Stop the background processor. if !ldk_background_services.bp_exit.is_closed() { ldk_background_services.bp_exit.send(()).unwrap(); diff --git a/src/main.rs b/src/main.rs index aaa98872..55ce94da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,12 +45,13 @@ use crate::routes::{ address, asset_balance, asset_metadata, backup, btc_balance, change_password, check_indexer_url, check_proxy_endpoint, close_channel, connect_peer, create_utxos, decode_ln_invoice, decode_rgb_invoice, disconnect_peer, estimate_fee, fail_transfers, - get_asset_media, get_channel_id, get_payment, get_swap, init, invoice_status, issue_asset_cfa, - issue_asset_nia, issue_asset_uda, keysend, list_assets, list_channels, list_payments, - list_peers, list_swaps, list_transactions, list_transfers, list_unspents, ln_invoice, lock, - maker_execute, maker_init, network_info, node_info, open_channel, post_asset_media, - refresh_transfers, restore, revoke_token, rgb_invoice, send_asset, send_btc, - send_onion_message, send_payment, shutdown, sign_message, sync, taker, unlock, + get_asset_media, get_channel_id, get_payment, get_swap, init, invoice_cancel, invoice_hodl, + invoice_settle, invoice_status, issue_asset_cfa, issue_asset_nia, issue_asset_uda, keysend, + list_assets, list_channels, list_payments, list_peers, list_swaps, list_transactions, + list_transfers, list_unspents, ln_invoice, lock, maker_execute, maker_init, network_info, + node_info, open_channel, post_asset_media, refresh_transfers, restore, revoke_token, + rgb_invoice, send_asset, send_btc, send_onion_message, send_payment, shutdown, sign_message, + sync, taker, unlock, }; use crate::utils::{start_daemon, AppState, LOGS_DIR}; @@ -139,6 +140,9 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr .route("/listtransfers", post(list_transfers)) .route("/listunspents", post(list_unspents)) .route("/lninvoice", post(ln_invoice)) + .route("/invoice/hodl", post(invoice_hodl)) + .route("/invoice/settle", post(invoice_settle)) + .route("/invoice/cancel", post(invoice_cancel)) .route("/lock", post(lock)) .route("/makerexecute", post(maker_execute)) .route("/makerinit", post(maker_init)) diff --git a/src/routes.rs b/src/routes.rs index 2db37678..4932116b 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -10,7 +10,15 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use bitcoin::{Network, ScriptBuf}; use hex::DisplayHex; -use lightning::ln::{channelmanager::OptionalOfferPaymentParams, types::ChannelId}; +use lightning::ln::bolt11_payment::{ + payment_parameters_from_invoice, payment_parameters_from_zero_amount_invoice, +}; +use lightning::ln::channelmanager::OptionalOfferPaymentParams; +use lightning::ln::invoice_utils::{ + create_invoice_from_channelmanager, + create_invoice_from_channelmanager_and_duration_since_epoch_with_payment_hash, +}; +use lightning::ln::types::ChannelId; use lightning::offers::offer::{self, Offer}; use lightning::onion_message::messenger::Destination; use lightning::rgb_utils::{ @@ -66,7 +74,7 @@ use std::{ path::{Path, PathBuf}, str::FromStr, sync::Arc, - time::Duration, + time::{Duration, SystemTime}, }; use tokio::{ fs::File, @@ -78,8 +86,9 @@ use crate::ldk::{start_ldk, stop_ldk, LdkBackgroundServices, MIN_CHANNEL_CONFIRM use crate::swap::{SwapData, SwapInfo, SwapString}; use crate::utils::{ check_already_initialized, check_channel_id, check_password_strength, check_password_validity, - encrypt_and_save_mnemonic, get_max_local_rgb_amount, get_mnemonic_path, get_route, hex_str, - hex_str_to_compressed_pubkey, hex_str_to_vec, UnlockedAppState, UserOnionMessageContents, + encrypt_and_save_mnemonic, get_max_local_rgb_amount, get_route, hex_str, + hex_str_to_compressed_pubkey, hex_str_to_vec, validate_and_parse_payment_hash, + validate_and_parse_payment_preimage, UnlockedAppState, UserOnionMessageContents, }; use crate::{ backup::{do_backup, restore_backup}, @@ -88,7 +97,7 @@ use crate::{ use crate::{ disk::{self, CHANNEL_PEER_DATA}, error::APIError, - ldk::{PaymentInfo, FEE_RATE, UTXO_SIZE_SAT}, + ldk::{InvoiceMetadata, InvoiceMode, PaymentInfo, FEE_RATE, UTXO_SIZE_SAT}, utils::{ connect_peer_if_necessary, get_current_timestamp, no_cancel, parse_peer_info, AppState, }, @@ -573,6 +582,7 @@ pub(crate) struct GetSwapResponse { pub(crate) enum HTLCStatus { Pending, Succeeded, + Cancelled, Failed, } @@ -580,6 +590,7 @@ impl_writeable_tlv_based_enum!(HTLCStatus, (0, Pending) => {}, (1, Succeeded) => {}, (2, Failed) => {}, + (3, Cancelled) => {}, ); #[derive(Debug, Deserialize, Serialize)] @@ -611,6 +622,7 @@ pub(crate) struct InitResponse { pub(crate) enum InvoiceStatus { Pending, Succeeded, + Cancelled, Failed, Expired, } @@ -625,6 +637,17 @@ pub(crate) struct InvoiceStatusResponse { pub(crate) status: InvoiceStatus, } +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceSettleRequest { + pub(crate) payment_hash: String, + pub(crate) payment_preimage: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceCancelRequest { + pub(crate) payment_hash: String, +} + #[derive(Deserialize, Serialize)] pub(crate) struct IssueAssetCFARequest { pub(crate) amounts: Vec, @@ -758,6 +781,22 @@ pub(crate) struct LNInvoiceResponse { pub(crate) invoice: String, } +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceHodlRequest { + pub(crate) amt_msat: Option, + pub(crate) expiry_sec: u32, + pub(crate) asset_id: Option, + pub(crate) asset_amount: Option, + pub(crate) payment_hash: String, + pub(crate) external_ref: Option, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceHodlResponse { + pub(crate) invoice: String, + pub(crate) payment_secret: String, +} + #[derive(Deserialize, Serialize)] pub(crate) struct MakerExecuteRequest { pub(crate) swapstring: String, @@ -1340,8 +1379,7 @@ pub(crate) async fn backup( no_cancel(async move { let _guard = state.check_locked().await?; - let _mnemonic = - check_password_validity(&payload.password, &state.static_state.storage_dir_path)?; + let _mnemonic = check_password_validity(&payload.password, &state.static_state.db).await?; do_backup( &state.static_state.storage_dir_path, @@ -1388,13 +1426,14 @@ pub(crate) async fn change_password( check_password_strength(payload.new_password.clone())?; let mnemonic = - check_password_validity(&payload.old_password, &state.static_state.storage_dir_path)?; + check_password_validity(&payload.old_password, &state.static_state.db).await?; encrypt_and_save_mnemonic( payload.new_password, mnemonic.to_string(), - &get_mnemonic_path(&state.static_state.storage_dir_path), - )?; + &state.static_state.db, + ) + .await?; Ok(Json(EmptyResponse {})) }) @@ -1724,14 +1763,14 @@ pub(crate) async fn init( check_password_strength(payload.password.clone())?; - let mnemonic_path = get_mnemonic_path(&state.static_state.storage_dir_path); - check_already_initialized(&mnemonic_path)?; + check_already_initialized(&state.static_state.db).await?; let keys = generate_keys(state.static_state.network); let mnemonic = keys.mnemonic; - encrypt_and_save_mnemonic(payload.password, mnemonic.clone(), &mnemonic_path)?; + encrypt_and_save_mnemonic(payload.password, mnemonic.clone(), &state.static_state.db) + .await?; Ok(Json(InitResponse { mnemonic })) }) @@ -1757,6 +1796,7 @@ pub(crate) async fn invoice_status( HTLCStatus::Pending => InvoiceStatus::Pending, HTLCStatus::Succeeded => InvoiceStatus::Succeeded, HTLCStatus::Failed => InvoiceStatus::Failed, + HTLCStatus::Cancelled => InvoiceStatus::Cancelled, }, None => return Err(APIError::UnknownLNInvoice), }; @@ -2552,6 +2592,20 @@ pub(crate) async fn ln_invoice( }, ); + let expiry_ts = invoice + .duration_since_epoch() + .as_secs() + .saturating_add(invoice.expiry_time().as_secs()); + unlocked_state.add_invoice_metadata( + payment_hash, + InvoiceMetadata { + mode: InvoiceMode::AutoClaim, + expected_amt_msat: payload.amt_msat.or_else(|| invoice.amount_milli_satoshis()), + expiry: Some(expiry_ts), + external_ref: None, + }, + ); + Ok(Json(LNInvoiceResponse { invoice: invoice.to_string(), })) @@ -2559,6 +2613,203 @@ pub(crate) async fn ln_invoice( .await } +pub(crate) async fn invoice_hodl( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let contract_id = if let Some(asset_id) = payload.asset_id { + Some(ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?) + } else { + None + }; + + if contract_id.is_some() && payload.amt_msat.unwrap_or(0) < INVOICE_MIN_MSAT { + return Err(APIError::InvalidAmount(format!( + "amt_msat cannot be less than {INVOICE_MIN_MSAT} when transferring an RGB asset" + ))); + } + + let currency = match state.static_state.network { + RgbLibNetwork::Mainnet => Currency::Bitcoin, + RgbLibNetwork::Testnet | RgbLibNetwork::Testnet4 => Currency::BitcoinTestnet, + RgbLibNetwork::Regtest => Currency::Regtest, + RgbLibNetwork::Signet => Currency::Signet, + }; + + let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + + // Reject reusing a payment hash that already exists in any of the known stores. + let hash_already_used = unlocked_state + .invoice_metadata() + .contains_key(&payment_hash) + || unlocked_state + .inbound_payments() + .contains_key(&payment_hash) + || unlocked_state.claimable_payment(&payment_hash).is_some(); + if hash_already_used { + return Err(APIError::PaymentHashAlreadyUsed); + } + + let duration_since_epoch = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| APIError::FailedInvoiceCreation("system time before UNIX_EPOCH".into()))?; + + let invoice = + create_invoice_from_channelmanager_and_duration_since_epoch_with_payment_hash( + &unlocked_state.channel_manager, + unlocked_state.keys_manager.clone(), + state.static_state.logger.clone(), + currency, + payload.amt_msat, + "ldk-tutorial-node".to_string(), + duration_since_epoch, + payload.expiry_sec, + payment_hash, + None, + contract_id, + payload.asset_amount, + ) + .map_err(|e| APIError::FailedInvoiceCreation(e.to_string()))?; + + let created_at = get_current_timestamp(); + unlocked_state.add_inbound_payment( + payment_hash, + PaymentInfo { + preimage: None, + secret: Some(*invoice.payment_secret()), + status: HTLCStatus::Pending, + amt_msat: payload.amt_msat, + created_at, + updated_at: created_at, + payee_pubkey: unlocked_state.channel_manager.get_our_node_id(), + }, + ); + + let expiry_ts = invoice + .duration_since_epoch() + .as_secs() + .saturating_add(invoice.expiry_time().as_secs()); + unlocked_state.add_invoice_metadata( + payment_hash, + InvoiceMetadata { + mode: InvoiceMode::Hodl, + expected_amt_msat: payload.amt_msat.or_else(|| invoice.amount_milli_satoshis()), + expiry: Some(expiry_ts), + external_ref: payload.external_ref.clone(), + }, + ); + + Ok(Json(InvoiceHodlResponse { + invoice: invoice.to_string(), + payment_secret: hex_str(&invoice.payment_secret().0), + })) + }) + .await +} + +/// Settle a HODL invoice that currently has a held HTLC. Requires the invoice +/// `payment_hash` and the matching 32-byte `payment_preimage`. Fails if the +/// invoice is not HODL, there is no claimable HTLC (already cancelled, expired +/// or failed), the HTLC has timed out, or the preimage doesn't match. +/// If the invoice is already settled, this call succeeds (idempotent). +pub(crate) async fn invoice_settle( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + let preimage = + validate_and_parse_payment_preimage(&payload.payment_preimage, &payment_hash)?; + + let metadata = unlocked_state + .invoice_metadata() + .get(&payment_hash) + .cloned() + .ok_or(APIError::UnknownLNInvoice)?; + + if metadata.mode != InvoiceMode::Hodl { + return Err(APIError::InvoiceNotHodl); + } + + // Idempotent path: if this payment already succeeded, validate preimage and return OK. + // This avoids failing when the claimable entry has already been cleaned up by PaymentClaimed. + if let Some(existing) = unlocked_state.inbound_payments().get(&payment_hash) { + if matches!(existing.status, HTLCStatus::Succeeded) { + if let Some(stored_preimage) = existing.preimage { + if stored_preimage != preimage { + return Err(APIError::InvalidPaymentPreimage); + } + } + // Already settled with matching preimage; idempotent success. + return Ok(Json(EmptyResponse {})); + } + } + + // Atomically take the claimable entry so the expiry task cannot fail it between + // validation and claim_funds. + let _claimable = unlocked_state.mark_claimable_settling(&payment_hash, metadata.expiry)?; + + // All validations passed; now claim the funds. + unlocked_state.channel_manager.claim_funds(preimage); + + Ok(Json(EmptyResponse {})) + }) + .await +} + +pub(crate) async fn invoice_cancel( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + + let metadata = unlocked_state + .invoice_metadata() + .get(&payment_hash) + .cloned() + .ok_or(APIError::UnknownLNInvoice)?; + + if metadata.mode != InvoiceMode::Hodl { + return Err(APIError::InvoiceNotHodl); + } + + let claimable = unlocked_state + .claimable_payment(&payment_hash) + .ok_or(APIError::InvoiceNotClaimable)?; + + // Best-effort cancel: LDK doesn't report sync success here, so just clear the + // claimable entry and let later events update status if it was already claimed. + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + // Best-effort cleanup; ignore if already removed. + let _ = unlocked_state.take_claimable_payment(&payment_hash); + + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Cancelled, + None, + None, + Some(claimable.amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + + Ok(Json(EmptyResponse {})) + }) + .await +} + pub(crate) async fn lock( State(state): State>, ) -> Result, APIError> { @@ -2569,6 +2820,11 @@ pub(crate) async fn lock( state.update_changing_state(true); drop(unlocked_state); } + Err(APIError::LockedNode) => { + // Node is already locked, which is the desired state + tracing::info!("Node is already locked"); + return Ok(Json(EmptyResponse {})); + } Err(e) => { state.update_changing_state(false); return Err(e); @@ -3283,8 +3539,7 @@ pub(crate) async fn restore( no_cancel(async move { let _unlocked_state = state.check_locked().await?; - let mnemonic_path = get_mnemonic_path(&state.static_state.storage_dir_path); - check_already_initialized(&mnemonic_path)?; + check_already_initialized(&state.static_state.db).await?; restore_backup( Path::new(&payload.backup_path), @@ -3292,8 +3547,7 @@ pub(crate) async fn restore( &state.static_state.storage_dir_path, )?; - let _mnemonic = - check_password_validity(&payload.password, &state.static_state.storage_dir_path)?; + let _mnemonic = check_password_validity(&payload.password, &state.static_state.db).await?; Ok(Json(EmptyResponse {})) }) @@ -3752,16 +4006,14 @@ pub(crate) async fn unlock( } } - let mnemonic = match check_password_validity( - &payload.password, - &state.static_state.storage_dir_path, - ) { - Ok(mnemonic) => mnemonic, - Err(e) => { - state.update_changing_state(false); - return Err(e); - } - }; + let mnemonic = + match check_password_validity(&payload.password, &state.static_state.db).await { + Ok(mnemonic) => mnemonic, + Err(e) => { + state.update_changing_state(false); + return Err(e); + } + }; tracing::debug!("Starting LDK..."); let (new_ldk_background_services, new_unlocked_app_state) = diff --git a/src/test/database_connection.rs b/src/test/database_connection.rs new file mode 100644 index 00000000..1bb985f3 --- /dev/null +++ b/src/test/database_connection.rs @@ -0,0 +1,93 @@ +use super::*; +use crate::args::DatabaseType; +use tempfile::TempDir; +use tracing_test::traced_test; + +#[traced_test] +#[tokio::test] +async fn test_database_connection_sqlite() { + let temp_dir = TempDir::new().unwrap(); + let args = UserArgs { + storage_dir_path: temp_dir.path().to_path_buf(), + daemon_listening_port: 3001, + ldk_peer_listening_port: 9735, + network: rgb_lib::BitcoinNetwork::Testnet, + max_media_upload_size_mb: 5, + root_public_key: None, + database_type: DatabaseType::Sqlite, + database_url: None, + }; + + let app_state = crate::utils::start_daemon(&args).await.unwrap(); + + // test ping + app_state.static_state.db.ping().await.unwrap(); + + // check db file created + let db_path = temp_dir.path().join("db.sqlite"); + assert!(db_path.exists(), "SQLite database file should be created"); +} + +#[tokio::test] +async fn test_database_connection_invalid_mysql() { + let temp_dir = TempDir::new().unwrap(); + let args = UserArgs { + storage_dir_path: temp_dir.path().to_path_buf(), + daemon_listening_port: 3001, + ldk_peer_listening_port: 9735, + network: rgb_lib::BitcoinNetwork::Testnet, + max_media_upload_size_mb: 5, + root_public_key: None, + database_type: DatabaseType::Mysql, + database_url: None, // missing url should cause error + }; + + let result = crate::utils::start_daemon(&args).await; + assert!( + result.is_err(), + "Should fail without database URL for MySQL" + ); + if let Err(err) = result { + match err { + crate::error::AppError::ConfigError(msg) => { + assert!( + msg.contains("Database URL required"), + "Error should mention missing URL" + ); + } + _ => panic!("Expected ConfigError, got {:?}", err), + } + } +} + +#[tokio::test] +async fn test_database_connection_invalid_postgresql() { + let temp_dir = TempDir::new().unwrap(); + let args = UserArgs { + storage_dir_path: temp_dir.path().to_path_buf(), + daemon_listening_port: 3001, + ldk_peer_listening_port: 9735, + network: rgb_lib::BitcoinNetwork::Testnet, + max_media_upload_size_mb: 5, + root_public_key: None, + database_type: DatabaseType::Postgresql, + database_url: None, // missing url should cause error + }; + + let result = crate::utils::start_daemon(&args).await; + assert!( + result.is_err(), + "Should fail without database URL for PostgreSQL" + ); + if let Err(err) = result { + match err { + crate::error::AppError::ConfigError(msg) => { + assert!( + msg.contains("Database URL required"), + "Error should mention missing URL" + ); + } + _ => panic!("Expected ConfigError, got {:?}", err), + } + } +} diff --git a/src/test/database_lock_unlock.rs b/src/test/database_lock_unlock.rs new file mode 100644 index 00000000..2d292634 --- /dev/null +++ b/src/test/database_lock_unlock.rs @@ -0,0 +1,103 @@ +use super::*; +use crate::error::APIError; +use crate::utils::{check_already_initialized, check_password_validity, encrypt_and_save_mnemonic}; +use sea_orm::{Database, DatabaseConnection}; +use tempfile::TempDir; + +#[traced_test] +#[tokio::test] +async fn test_encrypt_and_save_mnemonic() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let password = "test_password_123"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let result = encrypt_and_save_mnemonic(password.to_string(), mnemonic.to_string(), &db).await; + assert!( + result.is_ok(), + "Failed to encrypt and save mnemonic: {:?}", + result + ); + + let retrieved_mnemonic = check_password_validity(password, &db).await; + assert!( + retrieved_mnemonic.is_ok(), + "Failed to retrieve mnemonic: {:?}", + retrieved_mnemonic + ); + assert_eq!(retrieved_mnemonic.unwrap().to_string(), mnemonic); +} + +#[traced_test] +#[tokio::test] +async fn test_check_password_validity_wrong_password() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let password = "correct_password"; + let wrong_password = "wrong_password"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + encrypt_and_save_mnemonic(password.to_string(), mnemonic.to_string(), &db) + .await + .unwrap(); + + let result = check_password_validity(wrong_password, &db).await; + assert!( + matches!(result, Err(APIError::WrongPassword)), + "Expected WrongPassword error, got {:?}", + result + ); +} + +#[traced_test] +#[tokio::test] +async fn test_check_password_validity_uninitialized() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let password = "some_password"; + + let result = check_password_validity(password, &db).await; + assert!( + matches!(result, Err(APIError::NotInitialized)), + "Expected NotInitialized error, got {:?}", + result + ); +} + +#[traced_test] +#[tokio::test] +async fn test_check_already_initialized() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let result = check_already_initialized(&db).await; + assert!( + result.is_ok(), + "Expected OK for uninitialized, got {:?}", + result + ); + + let password = "test_password"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + encrypt_and_save_mnemonic(password.to_string(), mnemonic.to_string(), &db) + .await + .unwrap(); + + let result = check_already_initialized(&db).await; + assert!( + matches!(result, Err(APIError::AlreadyInitialized)), + "Expected AlreadyInitialized error, got {:?}", + result + ); +} diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs new file mode 100644 index 00000000..cce7c0b5 --- /dev/null +++ b/src/test/hodl_invoice.rs @@ -0,0 +1,684 @@ +use bitcoin::hashes::{sha256::Hash as Sha256, Hash}; +use rand::RngCore; +use reqwest::StatusCode; +use serde::Serialize; +use std::net::SocketAddr; +use std::path::Path; +use time::OffsetDateTime; + +use crate::{ + disk::{read_claimable_htlcs, CLAIMABLE_HTLCS_FNAME}, + error::APIError, + utils::{hex_str, validate_and_parse_payment_hash, LDK_DIR}, +}; + +use super::*; + +const TEST_DIR_BASE: &str = "tmp/hodl_invoice/"; + +/// Generate a random preimage and its corresponding payment hash. +fn random_preimage_and_hash() -> (String, String) { + let mut preimage = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut preimage); + let preimage_hex = hex_str(&preimage); + let payment_hash = hex_str(&Sha256::hash(&preimage).to_byte_array()); + (preimage_hex, payment_hash) +} + +async fn setup_two_nodes_with_channel( + test_dir_suffix: &str, + port_offset: u16, +) -> (SocketAddr, SocketAddr, String, String) { + let test_dir_base = format!("{TEST_DIR_BASE}{test_dir_suffix}/"); + let test_dir_node1 = format!("{test_dir_base}node1"); + let test_dir_node2 = format!("{test_dir_base}node2"); + let node1_port = NODE1_PEER_PORT + port_offset; + let node2_port = NODE2_PEER_PORT + port_offset; + let (node1_addr, _) = start_node(&test_dir_node1, node1_port, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, node2_port, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + let _channel = open_channel( + node1_addr, + &node2_pubkey, + Some(node2_port), + Some(500000), + Some(0), + None, + None, + ) + .await; + + (node1_addr, node2_addr, test_dir_node1, test_dir_node2) +} + +async fn setup_single_node(test_dir_suffix: &str, port_offset: u16) -> (SocketAddr, String) { + let test_dir_base = format!("{TEST_DIR_BASE}{test_dir_suffix}/"); + let test_dir_node1 = format!("{test_dir_base}node1"); + let node1_port = NODE1_PEER_PORT + port_offset; + let (node1_addr, _) = start_node(&test_dir_node1, node1_port, false).await; + fund_and_create_utxos(node1_addr, None).await; + (node1_addr, test_dir_node1) +} + +async fn invoice_post_expect_error( + node_address: SocketAddr, + path: &str, + payload: &T, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + let res = reqwest::Client::new() + .post(format!("http://{node_address}{path}")) + .json(payload) + .send() + .await + .unwrap(); + check_response_is_nok(res, expected_status, expected_message, expected_name).await; +} + +async fn invoice_hodl_expect_error( + node_address: SocketAddr, + amt_msat: Option, + expiry_sec: u32, + payment_hash: String, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + println!("creating HODL invoice on node {node_address}"); + let payload = InvoiceHodlRequest { + amt_msat, + expiry_sec, + asset_id: None, + asset_amount: None, + payment_hash, + external_ref: None, + }; + invoice_post_expect_error( + node_address, + "/invoice/hodl", + &payload, + expected_status, + expected_message, + expected_name, + ) + .await; +} + +async fn invoice_settle_expect_error( + node_address: SocketAddr, + payment_hash: String, + payment_preimage: String, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + println!("settling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceSettleRequest { + payment_hash, + payment_preimage, + }; + invoice_post_expect_error( + node_address, + "/invoice/settle", + &payload, + expected_status, + expected_message, + expected_name, + ) + .await; +} + +async fn invoice_cancel_expect_error( + node_address: SocketAddr, + payment_hash: String, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + println!("cancelling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceCancelRequest { payment_hash }; + invoice_post_expect_error( + node_address, + "/invoice/cancel", + &payload, + expected_status, + expected_message, + expected_name, + ) + .await; +} + +fn expect_api_ok(result: Result, context: &str) -> T { + match result { + Ok(value) => value, + Err(err) => panic!("{context}: {err}"), + } +} + +/// Check if the claimable HTLC entry exists in the node's on-disk store. +fn claimable_exists(node_test_dir: &str, payment_hash_hex: &str) -> Result { + let claimable_path = Path::new(node_test_dir) + .join(LDK_DIR) + .join(CLAIMABLE_HTLCS_FNAME); + let storage = read_claimable_htlcs(&claimable_path); + let hash = validate_and_parse_payment_hash(payment_hash_hex)?; + Ok(storage.payments.contains_key(&hash)) +} + +/// Get the claim_deadline_height for a claimable, if present on disk. +fn claimable_deadline_height( + node_test_dir: &str, + payment_hash_hex: &str, +) -> Result, APIError> { + let claimable_path = Path::new(node_test_dir) + .join(LDK_DIR) + .join(CLAIMABLE_HTLCS_FNAME); + let storage = read_claimable_htlcs(&claimable_path); + let hash = validate_and_parse_payment_hash(payment_hash_hex)?; + Ok(storage + .payments + .get(&hash) + .and_then(|c| c.claim_deadline_height)) +} + +/// Poll until the claimable entry appears or disappears (bounded by timeout). +async fn wait_for_claimable_state( + node_test_dir: &str, + payment_hash_hex: &str, + expected: bool, +) -> Result<(), APIError> { + let t_0 = OffsetDateTime::now_utc(); + loop { + if claimable_exists(node_test_dir, payment_hash_hex)? == expected { + return Ok(()); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 20.0 { + return Err(APIError::Unexpected(format!( + "claimable entry for {payment_hash_hex} did not reach state {expected}" + ))); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_hodl_invoice() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle", 0).await; + + // Arrange: create a HODL invoice with a fixed payment hash. + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(50_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Act: pay the invoice; HODL keeps it pending and claimable. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Pending + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Act: settle with the chosen preimage. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + + // Assert: payer/payee succeed and claimable entry is removed. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payer_payment.status, HTLCStatus::Succeeded); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); +} + +/// Idempotency: settling twice should both succeed (LDK/LND behavior). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_twice_succeeds() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle-twice", 5).await; + + // Arrange: create a HODL invoice with a fixed payment hash. + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(45_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Act: pay the invoice; HODL keeps it pending and claimable. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Act: first settle with the chosen preimage. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + + // Assert: payer/payee succeed and claimable entry may be cleaned up later. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payer_payment.status, HTLCStatus::Succeeded); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); + + // Act: settle again with the same preimage; should be idempotent success. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; +} + +/// Cancel and then try to cancel again (the second call fails). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_hodl_invoice() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("cancel", 10).await; + + // Arrange: create a HODL invoice with a fixed payment hash. + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(40_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Act: pay the invoice; it should be pending and claimable. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Pending + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Act: cancel and fail back the HTLC. + invoice_cancel(node2_addr, payment_hash_hex.clone()).await; + + // Assert: payer fails, payee cancels, and claimable entry is removed. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payer_payment.status, HTLCStatus::Failed); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Cancelled).await; + assert_eq!(payee_payment.status, HTLCStatus::Cancelled); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Cancelled + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); + + // Duplicate cancel should fail. + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +/// Cacelling first must make a later settle fail (already cancelled). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_then_settle_fails() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("cancel-settle", 11).await; + + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(40_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "claimable entry should appear", + ); + + invoice_cancel(node2_addr, payment_hash_hex.clone()).await; + + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + preimage_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Cancelled).await; + assert_eq!(payee_payment.status, HTLCStatus::Cancelled); +} + +/// Settling first must make a later cancel fail (already settled). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_then_cancel_fails() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle-cancel", 12).await; + + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(42_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "claimable entry should appear", + ); + + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex).await; + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex.clone(), + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +/// Expiry via short invoice timeout: ensure settle/cancel fail after expiry. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn expire_hodl_invoice() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("expiry", 20).await; + + // Arrange: create a short-expiry HODL invoice (20s). + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + // Use a small-but-not-too-small expiry to let the payment reach Pending + // before the background expiry task fails it. + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(30_000), 20, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Act: pay and wait for the background expiry task to fail the HTLC. + // Timing note: expiry is 20s, the expiry task runs every 30s, and the payment wait timeout + // is 40s, so this should succeed on the next expiry tick. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Assert: both sides see Failed and claimable entry is removed. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payer_payment.status, HTLCStatus::Failed); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payee_payment.status, HTLCStatus::Failed); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Failed + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); + + // After expiry, settle/cancel should fail. + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + _preimage_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +/// Expiry driven by CLTV/blocks: mine past deadline, then settle/cancel must fail. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn expire_hodl_invoice_by_blocks() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("expiry-blocks", 25).await; + + // Arrange: create a HODL invoice with standard expiry. + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(30_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Pay and wait for claimable. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Mine past the claim deadline height (reported by LDK) to force timeout, then + // give the 30s expiry task a chance to sweep it. + let deadline_height = claimable_deadline_height(&test_dir_node2, &payment_hash_hex) + .expect("read deadline height") + .expect("expected claim_deadline_height for claimable HTLC"); + let current_height = super::get_block_count(); + let blocks_to_mine = deadline_height.saturating_sub(current_height) + 2; + super::mine_n_blocks(false, blocks_to_mine as u16); + tokio::time::sleep(std::time::Duration::from_secs(35)).await; + + // Assert: both sides see Failed and claimable entry is removed. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payer_payment.status, HTLCStatus::Failed); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payee_payment.status, HTLCStatus::Failed); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Failed + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); + + // After expiry, settle/cancel should fail. + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + _preimage_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn reject_wrong_preimage_settle() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("wrong_preimage", 30).await; + + // Arrange: create a HODL invoice and pay it (pending). + let (good_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(35_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Act: try to settle with a mismatching preimage. + let (wrong_preimage_hex, _) = random_preimage_and_hash(); + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + wrong_preimage_hex, + StatusCode::BAD_REQUEST, + "Invalid payment preimage", + "InvalidPaymentPreimage", + ) + .await; + + // Assert: invoice stays pending and claimable entry remains. + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Pending + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to remain", + ); + + // Now settle with the correct preimage; should succeed and clean up. + invoice_settle(node2_addr, payment_hash_hex.clone(), good_preimage_hex).await; + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn reject_duplicate_hodl_payment_hash() { + initialize(); + + // Arrange: start a node and fund it. + let (node1_addr, _test_dir_node1) = setup_single_node("duplicate_hash", 40).await; + + // Arrange: create the first HODL invoice. + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node1_addr, Some(20_000), 900, payment_hash_hex.clone()).await; + + // Act: attempt to create another HODL invoice with the same hash. + invoice_hodl_expect_error( + node1_addr, + Some(20_000), + 900, + payment_hash_hex.clone(), + StatusCode::BAD_REQUEST, + "Payment hash already used", + "PaymentHashAlreadyUsed", + ) + .await; + + // Assert: the original invoice remains pending. + assert!(matches!( + invoice_status(node1_addr, &invoice).await, + InvoiceStatus::Pending + )); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn auto_claim_invoice_regression() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, _test_dir_node2) = + setup_two_nodes_with_channel("autoclaim", 50).await; + + // Act: create and pay a normal (auto-claim) invoice. + let LNInvoiceResponse { invoice } = ln_invoice(node2_addr, Some(25_000), None, None, 900).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Succeeded).await; + // Assert: both sides succeed and invoice status updates. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payer_payment.status, HTLCStatus::Succeeded); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); +} diff --git a/src/test/mod.rs b/src/test/mod.rs index 06b9a120..0b01d4e4 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -17,6 +17,7 @@ use tokio::io::AsyncReadExt; use tokio::net::TcpListener; use tracing_test::traced_test; +use crate::args::DatabaseType; use crate::error::APIErrorResponse; use crate::ldk::FEE_RATE; use crate::routes::{ @@ -27,7 +28,8 @@ use crate::routes::{ DisconnectPeerRequest, EmptyResponse, FailTransfersRequest, FailTransfersResponse, GetAssetMediaRequest, GetAssetMediaResponse, GetChannelIdRequest, GetChannelIdResponse, GetPaymentRequest, GetPaymentResponse, GetSwapRequest, GetSwapResponse, HTLCStatus, - InitRequest, InitResponse, InvoiceStatus, InvoiceStatusRequest, InvoiceStatusResponse, + InitRequest, InitResponse, InvoiceCancelRequest, InvoiceHodlRequest, InvoiceHodlResponse, + InvoiceSettleRequest, InvoiceStatus, InvoiceStatusRequest, InvoiceStatusResponse, IssueAssetCFARequest, IssueAssetCFAResponse, IssueAssetNIARequest, IssueAssetNIAResponse, IssueAssetUDARequest, IssueAssetUDAResponse, KeysendRequest, KeysendResponse, LNInvoiceRequest, LNInvoiceResponse, ListAssetsRequest, ListAssetsResponse, ListChannelsResponse, @@ -66,6 +68,8 @@ impl Default for UserArgs { ldk_peer_listening_port: 9735, max_media_upload_size_mb: 3, root_public_key: None, + database_type: DatabaseType::Sqlite, + database_url: None, } } } @@ -549,6 +553,61 @@ async fn invoice_status(node_address: SocketAddr, invoice: &str) -> InvoiceStatu .status } +async fn invoice_hodl( + node_address: SocketAddr, + amt_msat: Option, + expiry_sec: u32, + payment_hash: String, +) -> InvoiceHodlResponse { + println!("creating HODL invoice on node {node_address}"); + let payload = InvoiceHodlRequest { + amt_msat, + expiry_sec, + asset_id: None, + asset_amount: None, + payment_hash, + external_ref: None, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/invoice/hodl")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res) + .await + .json::() + .await + .unwrap() +} + +async fn invoice_settle(node_address: SocketAddr, payment_hash: String, payment_preimage: String) { + println!("settling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceSettleRequest { + payment_hash, + payment_preimage, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/invoice/settle")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res).await; +} + +async fn invoice_cancel(node_address: SocketAddr, payment_hash: String) { + println!("cancelling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceCancelRequest { payment_hash }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/invoice/cancel")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res).await; +} + async fn issue_asset_cfa(node_address: SocketAddr, file_path: Option<&str>) -> AssetCFA { println!("issuing CFA asset on node {node_address}"); let mut file_digest = None; @@ -1804,8 +1863,11 @@ mod close_force_other_side; mod close_force_standard; mod concurrent_btc_payments; mod concurrent_openchannel; +mod database_connection; +mod database_lock_unlock; mod fail_transfers; mod getchannelid; +mod hodl_invoice; mod htlc_amount_checks; mod invoice; mod issue; diff --git a/src/utils.rs b/src/utils.rs index c214f086..03a8615f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,6 @@ use amplify::s; +use bitcoin::hashes::sha256::Hash as Sha256; +use bitcoin::hashes::Hash; use bitcoin::io; use bitcoin::secp256k1::PublicKey; use futures::Future; @@ -9,6 +11,7 @@ use lightning::routing::router::{ DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, MAX_PATH_LENGTH_ESTIMATE, }; use lightning::{ + ln::{PaymentHash, PaymentPreimage}, onion_message::packet::OnionMessageContents, sign::KeysManager, util::ser::{Writeable, Writer}, @@ -16,10 +19,10 @@ use lightning::{ use lightning_persister::fs_store::FilesystemStore; use magic_crypt::{new_magic_crypt, MagicCryptTrait}; use rgb_lib::{bdk_wallet::keys::bip39::Mnemonic, BitcoinNetwork, ContractId}; +use sea_orm::{ConnectionTrait, DatabaseConnection, Statement}; use std::{ collections::HashSet, fmt::Write, - fs, net::{SocketAddr, TcpStream, ToSocketAddrs}, path::Path, path::PathBuf, @@ -34,13 +37,13 @@ use crate::ldk::{ChannelIdsMap, Router}; use crate::rgb::{get_rgb_channel_info_optional, RgbLibWalletWrapper}; use crate::routes::{DEFAULT_FINAL_CLTV_EXPIRY_DELTA, HTLC_MIN_MSAT}; use crate::{ - args::UserArgs, + args::{DatabaseType, UserArgs}, disk::FilesystemLogger, error::{APIError, AppError}, ldk::{ - BumpTxEventHandler, ChainMonitor, ChannelManager, InboundPaymentInfoStorage, - LdkBackgroundServices, NetworkGraph, OnionMessenger, OutboundPaymentInfoStorage, - OutputSweeper, PeerManager, SwapMap, + BumpTxEventHandler, ChainMonitor, ChannelManager, ClaimablePaymentStorage, + InboundPaymentInfoStorage, InvoiceMetadataStorage, LdkBackgroundServices, NetworkGraph, + OnionMessenger, OutboundPaymentInfoStorage, OutputSweeper, PeerManager, SwapMap, }, }; @@ -90,11 +93,14 @@ pub(crate) struct StaticState { pub(crate) ldk_data_dir: PathBuf, pub(crate) logger: Arc, pub(crate) max_media_upload_size_mb: u16, + pub(crate) db: DatabaseConnection, } pub(crate) struct UnlockedAppState { pub(crate) channel_manager: Arc, pub(crate) inbound_payments: Arc>, + pub(crate) invoice_metadata: Arc>, + pub(crate) claimable_htlcs: Arc>, pub(crate) keys_manager: Arc, pub(crate) network_graph: Arc, pub(crate) chain_monitor: Arc, @@ -118,6 +124,14 @@ impl UnlockedAppState { self.inbound_payments.lock().unwrap() } + pub(crate) fn get_invoice_metadata(&self) -> MutexGuard<'_, InvoiceMetadataStorage> { + self.invoice_metadata.lock().unwrap() + } + + pub(crate) fn get_claimable_htlcs(&self) -> MutexGuard<'_, ClaimablePaymentStorage> { + self.claimable_htlcs.lock().unwrap() + } + pub(crate) fn get_outbound_payments(&self) -> MutexGuard<'_, OutboundPaymentInfoStorage> { self.outbound_payments.lock().unwrap() } @@ -156,8 +170,24 @@ impl Writeable for UserOnionMessageContents { } } -pub(crate) fn check_already_initialized(mnemonic_path: &Path) -> Result<(), APIError> { - if mnemonic_path.exists() { +pub(crate) async fn check_already_initialized(db: &DatabaseConnection) -> Result<(), APIError> { + let create_table_stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "CREATE TABLE IF NOT EXISTS mnemonic (id INTEGER PRIMARY KEY, encrypted_mnemonic TEXT NOT NULL)".to_string(), + ); + db.execute(create_table_stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to create mnemonic table: {}", e)))?; + + let stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT id FROM mnemonic WHERE id = 1".to_string(), + ); + let result = db.query_one(stmt).await.map_err(|e| { + APIError::DatabaseError(format!("Failed to check mnemonic existence: {}", e)) + })?; + + if result.is_some() { return Err(APIError::AlreadyInitialized); } Ok(()) @@ -172,12 +202,31 @@ pub(crate) fn check_password_strength(password: String) -> Result<(), APIError> Ok(()) } -pub(crate) fn check_password_validity( +pub(crate) async fn check_password_validity( password: &str, - storage_dir_path: &Path, + db: &DatabaseConnection, ) -> Result { - let mnemonic_path = get_mnemonic_path(storage_dir_path); - if let Ok(encrypted_mnemonic) = fs::read_to_string(mnemonic_path) { + let create_table_stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "CREATE TABLE IF NOT EXISTS mnemonic (id INTEGER PRIMARY KEY, encrypted_mnemonic TEXT NOT NULL)".to_string(), + ); + db.execute(create_table_stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to create mnemonic table: {}", e)))?; + + let stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT encrypted_mnemonic FROM mnemonic WHERE id = 1".to_string(), + ); + let result = db + .query_one(stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to read mnemonic: {}", e)))?; + + if let Some(row) = result { + let encrypted_mnemonic: String = row.try_get_by_index(0).map_err(|e| { + APIError::DatabaseError(format!("Failed to get mnemonic from row: {}", e)) + })?; let mcrypt = new_magic_crypt!(password, 256); let mnemonic_str = mcrypt .decrypt_base64_to_string(encrypted_mnemonic) @@ -206,27 +255,27 @@ pub(crate) fn check_port_is_available(port: u16) -> Result<(), AppError> { Ok(()) } -pub(crate) fn get_mnemonic_path(storage_dir_path: &Path) -> PathBuf { - storage_dir_path.join("mnemonic") -} - -pub(crate) fn encrypt_and_save_mnemonic( +pub(crate) async fn encrypt_and_save_mnemonic( password: String, mnemonic: String, - mnemonic_path: &Path, + db: &DatabaseConnection, ) -> Result<(), APIError> { let mcrypt = new_magic_crypt!(password, 256); let encrypted_mnemonic = mcrypt.encrypt_str_to_base64(mnemonic); - match fs::write(mnemonic_path, encrypted_mnemonic) { - Ok(()) => { - tracing::info!("Created a new wallet"); - Ok(()) - } - Err(e) => Err(APIError::FailedKeysCreation( - mnemonic_path.to_string_lossy().to_string(), - e.to_string(), - )), - } + + let create_table_stmt = Statement::from_string(sea_orm::DatabaseBackend::Sqlite, "CREATE TABLE IF NOT EXISTS mnemonic (id INTEGER PRIMARY KEY, encrypted_mnemonic TEXT NOT NULL)".to_string()); + db.execute(create_table_stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to create mnemonic table: {}", e)))?; + + let sql = format!("INSERT INTO mnemonic (id, encrypted_mnemonic) VALUES (1, '{}') ON CONFLICT(id) DO UPDATE SET encrypted_mnemonic = excluded.encrypted_mnemonic", encrypted_mnemonic.replace("'", "''")); + let stmt = Statement::from_string(sea_orm::DatabaseBackend::Sqlite, sql); + db.execute(stmt) + .await + .map_err(|e| APIError::FailedKeysCreation("database".to_string(), e.to_string()))?; + + tracing::info!("Created a new wallet"); + Ok(()) } pub(crate) async fn connect_peer_if_necessary( @@ -345,6 +394,24 @@ pub(crate) fn parse_peer_info( Ok((pubkey.unwrap(), peer_addr)) } +fn get_database_url( + db_type: &DatabaseType, + db_url: Option<&str>, + storage_dir: &Path, +) -> Result { + match db_type { + DatabaseType::Sqlite => { + let db_path = storage_dir.join("db.sqlite"); + Ok(format!("sqlite://{}?mode=rwc", db_path.display())) + } + DatabaseType::Mysql | DatabaseType::Postgresql => db_url + .ok_or(AppError::ConfigError( + "Database URL required for mysql/postgresql".to_string(), + )) + .map(|s| s.to_string()), + } +} + pub(crate) async fn start_daemon(args: &UserArgs) -> Result, AppError> { // Initialize the Logger (creates ldk_data_dir and its logs directory) let ldk_data_dir = args.storage_dir_path.join(LDK_DIR); @@ -352,6 +419,15 @@ pub(crate) async fn start_daemon(args: &UserArgs) -> Result, AppEr let cancel_token = CancellationToken::new(); + let database_url = get_database_url( + &args.database_type, + args.database_url.as_deref(), + &args.storage_dir_path, + )?; + let db = sea_orm::Database::connect(&database_url) + .await + .map_err(|e| AppError::DatabaseConnection(format!("Failed to connect: {}", e)))?; + let static_state = Arc::new(StaticState { ldk_peer_listening_port: args.ldk_peer_listening_port, network: args.network, @@ -359,6 +435,7 @@ pub(crate) async fn start_daemon(args: &UserArgs) -> Result, AppEr ldk_data_dir, logger, max_media_upload_size_mb: args.max_media_upload_size_mb, + db, }); let app_state = Arc::new(AppState { @@ -445,3 +522,46 @@ pub(crate) fn get_route( route.ok() } + +/// Validates a hex-encoded payment hash string and converts it to a PaymentHash. +/// Returns an error if the string is invalid, empty, or not exactly 32 bytes. +pub(crate) fn validate_and_parse_payment_hash( + payment_hash_str: &str, +) -> Result { + if payment_hash_str.is_empty() { + return Err(APIError::InvalidPaymentHash("missing payment_hash".into())); + } + let hash_vec = hex_str_to_vec(payment_hash_str) + .ok_or_else(|| APIError::InvalidPaymentHash(payment_hash_str.to_string()))?; + if hash_vec.len() != 32 { + return Err(APIError::InvalidPaymentHash(payment_hash_str.to_string())); + } + let hash_bytes: [u8; 32] = hash_vec + .try_into() + .map_err(|_| APIError::InvalidPaymentHash(payment_hash_str.to_string()))?; + Ok(PaymentHash(hash_bytes)) +} + +/// Validates a hex-encoded payment preimage string, converts it to a PaymentPreimage, +/// and verifies that it matches the provided payment hash. +/// Returns an error if the string is invalid, not exactly 32 bytes, or doesn't match the hash. +pub(crate) fn validate_and_parse_payment_preimage( + payment_preimage_str: &str, + payment_hash: &PaymentHash, +) -> Result { + let preimage_vec = + hex_str_to_vec(payment_preimage_str).ok_or_else(|| APIError::InvalidPaymentPreimage)?; + if preimage_vec.len() != 32 { + return Err(APIError::InvalidPaymentPreimage); + } + let preimage = PaymentPreimage( + preimage_vec + .try_into() + .map_err(|_| APIError::InvalidPaymentPreimage)?, + ); + let computed_hash = PaymentHash(Sha256::hash(&preimage.0).to_byte_array()); + if computed_hash != *payment_hash { + return Err(APIError::InvalidPaymentPreimage); + } + Ok(preimage) +}