Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bindings/lni_nodejs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ export declare class PhoenixdNode {
getInfo(): Promise<NodeInfo>
createInvoice(params: CreateInvoiceParams): Promise<Transaction>
payInvoice(params: PayInvoiceParams): Promise<PayInvoiceResponse>
prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise<OnchainTransaction>
payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions | undefined | null): Promise<PayOnchainResponse>
createOffer(params: CreateOfferParams): Promise<Offer>
getOffer(): Promise<Offer>
lookupInvoice(params: LookupInvoiceParams): Promise<Transaction>
Expand All @@ -444,6 +446,8 @@ export declare class ClnNode {
getInfo(): Promise<NodeInfo>
createInvoice(params: CreateInvoiceParams): Promise<Transaction>
payInvoice(params: PayInvoiceParams): Promise<PayInvoiceResponse>
prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise<OnchainTransaction>
payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions | undefined | null): Promise<PayOnchainResponse>
createOffer(params: CreateOfferParams): Promise<Offer>
getOffer(search?: string | undefined | null): Promise<Offer>
listOffers(search?: string | undefined | null): Promise<Array<Offer>>
Expand All @@ -465,6 +469,8 @@ export declare class LndNode {
getInfo(): Promise<NodeInfo>
createInvoice(params: CreateInvoiceParams): Promise<Transaction>
payInvoice(params: PayInvoiceParams): Promise<PayInvoiceResponse>
prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise<OnchainTransaction>
payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions | undefined | null): Promise<PayOnchainResponse>
lookupInvoice(params: LookupInvoiceParams): Promise<Transaction>
listTransactions(params: ListTransactionsParams): Promise<Array<Transaction>>
decode(invoiceStr: string): Promise<string>
Expand Down
30 changes: 29 additions & 1 deletion bindings/lni_nodejs/src/cln.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use lni::{cln::lib::ClnConfig, CreateInvoiceParams, CreateOfferParams, LookupInvoiceParams, PayInvoiceParams};
use lni::{
cln::lib::ClnConfig, CreateInvoiceParams, CreateOfferParams, LookupInvoiceParams,
OnchainTransaction, PayInvoiceParams, PayOnchainOptions, PrepareOnchainTransactionParams,
};
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
Expand Down Expand Up @@ -66,6 +69,31 @@ impl ClnNode {
Ok(invoice)
}

#[napi]
pub async fn prepare_onchain_transaction(
&self,
params: PrepareOnchainTransactionParams,
) -> Result<OnchainTransaction> {
lni::cln::api::prepare_onchain_transaction(self.inner.clone(), params)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}

#[napi]
pub async fn pay_onchain(
&self,
transaction: OnchainTransaction,
options: Option<PayOnchainOptions>,
) -> Result<lni::types::PayOnchainResponse> {
lni::cln::api::pay_onchain_with_options(
self.inner.clone(),
transaction,
options.unwrap_or_default(),
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}

#[napi]
pub async fn create_offer(&self, params: CreateOfferParams) -> Result<lni::Offer> {
let offer = lni::cln::api::create_offer(self.inner.clone(), params)
Expand Down
28 changes: 28 additions & 0 deletions bindings/lni_nodejs/src/lnd.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use lni::{
lnd::lib::LndConfig, CreateInvoiceParams, CreateOfferParams, LookupInvoiceParams, PayInvoiceParams,
OnchainTransaction, PayOnchainOptions, PrepareOnchainTransactionParams,
};
use napi::bindgen_prelude::*;
use napi_derive::napi;
Expand Down Expand Up @@ -106,6 +107,33 @@ impl LndNode {
Ok(invoice)
}

#[napi]
pub async fn prepare_onchain_transaction(
&self,
params: PrepareOnchainTransactionParams,
) -> napi::Result<lni::types::OnchainTransaction> {
let transaction = lni::lnd::api::prepare_onchain_transaction(self.inner.clone(), params)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(transaction)
}

#[napi]
pub async fn pay_onchain(
&self,
transaction: OnchainTransaction,
options: Option<PayOnchainOptions>,
) -> napi::Result<lni::types::PayOnchainResponse> {
let payment = lni::lnd::api::pay_onchain_with_options(
self.inner.clone(),
transaction,
options.unwrap_or_default(),
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(payment)
}

#[napi]
pub async fn lookup_invoice(
&self,
Expand Down
28 changes: 27 additions & 1 deletion bindings/lni_nodejs/src/phoenixd.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use lni::{
phoenixd::lib::PhoenixdConfig, CreateInvoiceParams, CreateOfferParams, LookupInvoiceParams, PayInvoiceParams,
phoenixd::lib::PhoenixdConfig, CreateInvoiceParams, CreateOfferParams, LookupInvoiceParams,
OnchainTransaction, PayInvoiceParams, PayOnchainOptions, PrepareOnchainTransactionParams,
};
use napi::bindgen_prelude::*;
use napi_derive::napi;
Expand Down Expand Up @@ -68,6 +69,31 @@ impl PhoenixdNode {
Ok(invoice)
}

#[napi]
pub async fn prepare_onchain_transaction(
&self,
params: PrepareOnchainTransactionParams,
) -> Result<OnchainTransaction> {
lni::phoenixd::api::prepare_onchain_transaction(self.inner.clone(), params)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}

#[napi]
pub async fn pay_onchain(
&self,
transaction: OnchainTransaction,
options: Option<PayOnchainOptions>,
) -> Result<lni::types::PayOnchainResponse> {
lni::phoenixd::api::pay_onchain_with_options(
self.inner.clone(),
transaction,
options.unwrap_or_default(),
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
Comment on lines +82 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The JS pay_onchain convenience path will fail for Phoenixd by default.

This wrapper defaults PayOnchainOptions, but Phoenixd prepared transactions intentionally omit fee_sats, so the backend guardrail rejects the payment unless dangerously_disable_fee_guardrail is set. That means the new JS-visible pay_onchain(transaction) API does not work for the normal prepare→pay flow.

Suggested fix
   pub async fn pay_onchain(
     &self,
     transaction: OnchainTransaction,
     options: Option<PayOnchainOptions>,
   ) -> Result<lni::types::PayOnchainResponse> {
-    lni::phoenixd::api::pay_onchain_with_options(
-      self.inner.clone(),
-      transaction,
-      options.unwrap_or_default(),
-    )
+    let options = options.unwrap_or_default();
+    if transaction.fee_sats.is_none() && !options.dangerously_disable_fee_guardrail {
+      return Err(napi::Error::from_reason(
+        "Phoenixd prepared on-chain transactions do not include feeSats; pass { dangerouslyDisableFeeGuardrail: true } explicitly or use an API that returns a fee quote.".to_string(),
+      ));
+    }
+
+    lni::phoenixd::api::pay_onchain_with_options(
+      self.inner.clone(),
+      transaction,
+      options,
+    )
     .await
     .map_err(|e| napi::Error::from_reason(e.to_string()))
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[napi]
pub async fn pay_onchain(
&self,
transaction: OnchainTransaction,
options: Option<PayOnchainOptions>,
) -> Result<lni::types::PayOnchainResponse> {
lni::phoenixd::api::pay_onchain_with_options(
self.inner.clone(),
transaction,
options.unwrap_or_default(),
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn pay_onchain(
&self,
transaction: OnchainTransaction,
options: Option<PayOnchainOptions>,
) -> Result<lni::types::PayOnchainResponse> {
let options = options.unwrap_or_default();
if transaction.fee_sats.is_none() && !options.dangerously_disable_fee_guardrail {
return Err(napi::Error::from_reason(
"Phoenixd prepared on-chain transactions do not include feeSats; pass { dangerouslyDisableFeeGuardrail: true } explicitly or use an API that returns a fee quote.".to_string(),
));
}
lni::phoenixd::api::pay_onchain_with_options(
self.inner.clone(),
transaction,
options,
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bindings/lni_nodejs/src/phoenixd.rs` around lines 82 - 95, The defaulting in
pay_onchain currently calls options.unwrap_or_default(), which leaves
Phoenixd-prepared transactions (that omit fee_sats) rejected by backend
guardrails; update the defaulting logic in the pay_onchain function so that when
options is None you construct a PayOnchainOptions with
dangerously_disable_fee_guardrail = true (instead of the plain Default), then
pass that to lni::phoenixd::api::pay_onchain_with_options; reference the
pay_onchain function, PayOnchainOptions type, and the call to
lni::phoenixd::api::pay_onchain_with_options to locate where to change the
default.


#[napi]
pub async fn create_offer(&self, params: CreateOfferParams) -> Result<lni::Offer> {
let offer = lni::phoenixd::api::create_offer(self.inner.clone(), params)
Expand Down
40 changes: 39 additions & 1 deletion bindings/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const txs = await node.listTransactions({ from: 0, limit: 10 });

### On-chain Bitcoin Payments

On-chain payments use a prepare-then-pay flow so apps can show fees before executing a payment. This is currently implemented for `StrikeNode` and `BlinkNode`.
On-chain payments use a prepare-then-pay flow so apps can show fees before executing a payment. This is currently implemented for `StrikeNode`, `BlinkNode`, `LndNode`, `ClnNode`, and `PhoenixdNode`.

```ts
import { StrikeNode } from '@sunnyln/lni';
Expand All @@ -90,6 +90,44 @@ On-chain amounts are expressed in sats. Lightning invoice and offer APIs continu

Blink maps `fast`, `normal`, and `slow` to Blink's `FAST`, `MEDIUM`, and `SLOW` payout speeds. Blink does not support `free`, target-confirmation, sats/vbyte, backend fee preferences, or recipient-paid fees for on-chain sends.

LND maps `fast`, `normal`, and `slow` to confirmation targets of `1`, `6`, and `12` blocks. LND also supports explicit target-confirmation and sats/vbyte fee preferences. LND can quote target-confirmation sends with `EstimateFee`, but LND cannot quote an explicit sats/vbyte send before broadcast; in that case `prepareOnchainTransaction` returns no `feeSats`, and `payOnchain` requires `dangerouslyDisableFeeGuardrail: true` after the caller has chosen and accepted the fee rate. LND does not support `free`, backend fee preferences, or recipient-paid fees.

CLN maps `fast`, `normal`, and `slow` to CLN's `urgent`, `normal`, and `slow` feerates. CLN also supports explicit sats/vbyte fee preferences and raw backend feerate strings such as `1000perkw` or `normal`, but not `free`, target-confirmation fee preferences, or recipient-paid fees. CLN prepares on-chain transactions with `txprepare`, which reserves wallet inputs until `txsend`, `txdiscard`, or lightningd restart.

Phoenixd requires an explicit sats/vbyte fee preference because its `sendtoaddress` endpoint requires `feerateSatByte`. Phoenixd does not support `default`, speed, target-confirmation, or recipient-paid fees for on-chain sends. Phoenixd does not expose a separate quote endpoint or final mining fee quote, so `payOnchain` requires `dangerouslyDisableFeeGuardrail: true` after the caller has chosen and accepted the feerate.

For LND payment flows, avoid using `admin.macaroon` in apps. Bake a narrower macaroon with the permissions LNI needs for Lightning sends and on-chain sends:

```bash
lncli bakemacaroon \
--save_to ./lni-payments.macaroon \
info:read \
offchain:read \
offchain:write \
onchain:read \
onchain:write
```

For on-chain-only testing, use a macaroon with just the LND wallet permissions:

```bash
lncli bakemacaroon \
--save_to ./lni-onchain.macaroon \
info:read \
onchain:read \
onchain:write
```

Plain `lncli bakemacaroon` macaroons do not enforce a max-spend budget; they only grant or restrict permissions. Enforce per-payment or rolling-window budgets in the app before calling `payInvoice` or `payOnchain`.

If you run `litd`, LND Accounts can create an account-restricted macaroon with an enforced off-chain balance:

```bash
litcli accounts create 50000 --save_to ./lni-account.macaroon
```

That account balance limits Lightning payments, including routing fees. It does not provide an on-chain send budget; account-restricted users cannot spend the node's on-chain wallet. LNI's on-chain guardrail limits unusually high fees, not total spend.

`payOnchain` enforces the shared `DEFAULT_ONCHAIN_FEE_GUARDRAIL`: `25_000` sats and `25%` of the send amount. It fails closed when `feeSats` is unknown, such as a recovered duplicate quote that only includes a quote id.

```ts
Expand Down
Loading