diff --git a/bindings/lni_nodejs/index.d.ts b/bindings/lni_nodejs/index.d.ts index f3316de..0a9af0e 100644 --- a/bindings/lni_nodejs/index.d.ts +++ b/bindings/lni_nodejs/index.d.ts @@ -429,6 +429,8 @@ export declare class PhoenixdNode { getInfo(): Promise createInvoice(params: CreateInvoiceParams): Promise payInvoice(params: PayInvoiceParams): Promise + prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise + payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions | undefined | null): Promise createOffer(params: CreateOfferParams): Promise getOffer(): Promise lookupInvoice(params: LookupInvoiceParams): Promise @@ -444,6 +446,8 @@ export declare class ClnNode { getInfo(): Promise createInvoice(params: CreateInvoiceParams): Promise payInvoice(params: PayInvoiceParams): Promise + prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise + payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions | undefined | null): Promise createOffer(params: CreateOfferParams): Promise getOffer(search?: string | undefined | null): Promise listOffers(search?: string | undefined | null): Promise> @@ -465,6 +469,8 @@ export declare class LndNode { getInfo(): Promise createInvoice(params: CreateInvoiceParams): Promise payInvoice(params: PayInvoiceParams): Promise + prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise + payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions | undefined | null): Promise lookupInvoice(params: LookupInvoiceParams): Promise listTransactions(params: ListTransactionsParams): Promise> decode(invoiceStr: string): Promise diff --git a/bindings/lni_nodejs/src/cln.rs b/bindings/lni_nodejs/src/cln.rs index b875e2d..ed7c788 100644 --- a/bindings/lni_nodejs/src/cln.rs +++ b/bindings/lni_nodejs/src/cln.rs @@ -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] @@ -66,6 +69,31 @@ impl ClnNode { Ok(invoice) } + #[napi] + pub async fn prepare_onchain_transaction( + &self, + params: PrepareOnchainTransactionParams, + ) -> Result { + 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, + ) -> Result { + 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 { let offer = lni::cln::api::create_offer(self.inner.clone(), params) diff --git a/bindings/lni_nodejs/src/lnd.rs b/bindings/lni_nodejs/src/lnd.rs index 0850f03..5c912de 100644 --- a/bindings/lni_nodejs/src/lnd.rs +++ b/bindings/lni_nodejs/src/lnd.rs @@ -1,5 +1,6 @@ use lni::{ lnd::lib::LndConfig, CreateInvoiceParams, CreateOfferParams, LookupInvoiceParams, PayInvoiceParams, + OnchainTransaction, PayOnchainOptions, PrepareOnchainTransactionParams, }; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -106,6 +107,33 @@ impl LndNode { Ok(invoice) } + #[napi] + pub async fn prepare_onchain_transaction( + &self, + params: PrepareOnchainTransactionParams, + ) -> napi::Result { + 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, + ) -> napi::Result { + 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, diff --git a/bindings/lni_nodejs/src/phoenixd.rs b/bindings/lni_nodejs/src/phoenixd.rs index 6852b2a..932342f 100644 --- a/bindings/lni_nodejs/src/phoenixd.rs +++ b/bindings/lni_nodejs/src/phoenixd.rs @@ -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; @@ -68,6 +69,31 @@ impl PhoenixdNode { Ok(invoice) } + #[napi] + pub async fn prepare_onchain_transaction( + &self, + params: PrepareOnchainTransactionParams, + ) -> Result { + 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, + ) -> Result { + 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 create_offer(&self, params: CreateOfferParams) -> Result { let offer = lni::phoenixd::api::create_offer(self.inner.clone(), params) diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md index 15e3750..703317b 100644 --- a/bindings/typescript/README.md +++ b/bindings/typescript/README.md @@ -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'; @@ -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 diff --git a/bindings/typescript/src/__tests__/cln.test.ts b/bindings/typescript/src/__tests__/cln.test.ts new file mode 100644 index 0000000..ef9aa4f --- /dev/null +++ b/bindings/typescript/src/__tests__/cln.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ClnNode } from '../nodes/cln.js'; +import type { FetchLike } from '../types.js'; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + 'content-type': 'application/json', + ...(init?.headers ?? {}), + }, + }); +} + +function compactSize(value: number): number[] { + if (value >= 0xfd) { + throw new Error('test helper only supports small compact sizes'); + } + return [value]; +} + +function u32le(value: number): number[] { + return [value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff, (value >> 24) & 0xff]; +} + +function u64le(value: number): number[] { + let remaining = BigInt(value); + const bytes: number[] = []; + for (let i = 0; i < 8; i += 1) { + bytes.push(Number(remaining & 0xffn)); + remaining >>= 8n; + } + return bytes; +} + +function psbtMap(entries: Array<{ key: number[]; value: number[] }>): number[] { + return [ + ...entries.flatMap(({ key, value }) => [ + ...compactSize(key.length), + ...key, + ...compactSize(value.length), + ...value, + ]), + 0, + ]; +} + +function testPsbtWithFee(feeSats: number): string { + const amountSats = 10_000; + const unsignedTx = [ + ...u32le(2), + 1, + ...Array.from({ length: 32 }, () => 1), + ...u32le(0), + 0, + ...u32le(0xffffffff), + 1, + ...u64le(amountSats), + 0, + ...u32le(0), + ]; + const witnessUtxo = [ + ...u64le(amountSats + feeSats), + 0, + ]; + const psbt = [ + 0x70, + 0x73, + 0x62, + 0x74, + 0xff, + ...psbtMap([{ key: [0x00], value: unsignedTx }]), + ...psbtMap([{ key: [0x01], value: witnessUtxo }]), + ...psbtMap([]), + ]; + + return Buffer.from(psbt).toString('base64'); +} + +describe('ClnNode on-chain payments', () => { + it('prepares an on-chain transaction using CLN txprepare', async () => { + const fetchMock = vi.fn(async (_input, init) => { + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + expect(body).toEqual({ + outputs: [{ bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh: '10000sat' }], + feerate: 'normal', + }); + + return jsonResponse({ + txid: 'txid-1', + unsigned_tx: '02000000', + psbt: testPsbtWithFee(1_000), + }); + }); + const node = new ClnNode( + { url: 'https://cln.test', rune: 'rune' }, + { fetch: fetchMock }, + ); + + const transaction = await node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + fee: { type: 'speed', speed: 'normal' }, + description: 'cold storage', + }); + + expect(transaction).toMatchObject({ + id: 'txid-1', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 1_000, + totalAmountSats: 11_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'speed', speed: 'normal' }, + }); + }); + + it('executes a prepared CLN on-chain transaction using txsend', async () => { + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + + if (url.pathname === '/v1/txsend') { + expect(body).toEqual({ txid: 'txid-1' }); + return jsonResponse({ txid: 'txid-1', tx: '02000000' }); + } + + return new Response('not found', { status: 404 }); + }); + const node = new ClnNode( + { url: 'https://cln.test', rune: 'rune' }, + { fetch: fetchMock }, + ); + + const payment = await node.payOnchain({ + id: 'txid-1', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 1_000, + totalAmountSats: 11_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'satsPerVbyte', satsPerVbyte: 5 }, + }); + + expect(payment).toMatchObject({ + paymentId: 'txid-1', + txid: 'txid-1', + state: 'pending', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 1_000, + totalAmountSats: 11_000, + }); + }); + + it('rejects unsupported CLN on-chain fee modes before network calls', async () => { + const fetchMock = vi.fn(); + const node = new ClnNode( + { url: 'https://cln.test', rune: 'rune' }, + { fetch: fetchMock }, + ); + + await expect( + node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feePayer: 'recipient', + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + + await expect( + node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + fee: { type: 'targetConf', blocks: 6 }, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('blocks CLN txsend when the quoted fee exceeds the default guardrail', async () => { + const fetchMock = vi.fn(); + const node = new ClnNode( + { url: 'https://cln.test', rune: 'rune' }, + { fetch: fetchMock }, + ); + + await expect( + node.payOnchain({ + id: 'txid-1', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 3_000, + feePayer: 'sender', + fee: { type: 'speed', speed: 'normal' }, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/bindings/typescript/src/__tests__/integration/cln.real.test.ts b/bindings/typescript/src/__tests__/integration/cln.real.test.ts index f70a681..fdfbd97 100644 --- a/bindings/typescript/src/__tests__/integration/cln.real.test.ts +++ b/bindings/typescript/src/__tests__/integration/cln.real.test.ts @@ -4,6 +4,11 @@ import { hasEnv, itIf, testInvoiceLabel, timeout } from './helpers.js'; describe('Real integration from crates/lni/.env > ClnNode', () => { const enabled = hasEnv('CLN_URL', 'CLN_RUNE'); + const onchainEnabled = enabled && hasEnv('CLN_ONCHAIN_TEST_ADDRESS', 'CLN_ONCHAIN_AMOUNT_SATS'); + const onchainSendConfirmation = 'I_UNDERSTAND_THIS_BROADCASTS_BITCOIN'; + const shouldBroadcastOnchain = + process.env.CLN_RUN_ONCHAIN_SEND === 'true' && + process.env.CLN_ONCHAIN_SEND_CONFIRM === onchainSendConfirmation; const makeNode = () => new ClnNode({ @@ -11,6 +16,21 @@ describe('Real integration from crates/lni/.env > ClnNode', () => { rune: process.env.CLN_RUNE!, }); + const clnPost = async (path: string, body: unknown): Promise => { + const url = new URL(path, process.env.CLN_URL!); + const response = await fetch(url, { + method: 'POST', + headers: { + rune: process.env.CLN_RUNE!, + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`CLN ${path} failed: ${response.status} ${await response.text()}`); + } + }; + itIf(enabled)('getInfo', async () => { const node = makeNode(); const info = await node.getInfo(); @@ -41,4 +61,30 @@ describe('Real integration from crates/lni/.env > ClnNode', () => { const decoded = await node.decode(process.env.CLN_TEST_PAYMENT_REQUEST!); expect(decoded.length).toBeGreaterThan(0); }, timeout); + + itIf(onchainEnabled)('prepareOnchainTransaction + optionally payOnchain', async () => { + const node = makeNode(); + const amountSats = Number(process.env.CLN_ONCHAIN_AMOUNT_SATS); + expect(Number.isSafeInteger(amountSats)).toBe(true); + expect(amountSats).toBeGreaterThan(0); + + const transaction = await node.prepareOnchainTransaction({ + address: process.env.CLN_ONCHAIN_TEST_ADDRESS!, + amountSats, + fee: { type: 'speed', speed: 'normal' }, + description: testInvoiceLabel('cln onchain'), + }); + + expect(transaction.id?.length).toBeGreaterThan(0); + expect(transaction.amountSats).toBe(amountSats); + expect(transaction.feeSats).toBeGreaterThanOrEqual(0); + + if (!shouldBroadcastOnchain) { + await clnPost('/v1/txdiscard', { txid: transaction.id }); + return; + } + + const payment = await node.payOnchain(transaction); + expect(payment.txid?.length).toBeGreaterThan(0); + }, timeout); }); diff --git a/bindings/typescript/src/__tests__/integration/lnd.real.test.ts b/bindings/typescript/src/__tests__/integration/lnd.real.test.ts index 1670dd1..3f545a7 100644 --- a/bindings/typescript/src/__tests__/integration/lnd.real.test.ts +++ b/bindings/typescript/src/__tests__/integration/lnd.real.test.ts @@ -2,8 +2,23 @@ import { describe, expect } from 'vitest'; import { LndNode } from '../../nodes/lnd.js'; import { hasEnv, itIf, runOrSkipKnownError, testInvoiceLabel, timeout } from './helpers.js'; +const ONCHAIN_SEND_CONFIRMATION = 'I_UNDERSTAND_THIS_BROADCASTS_BITCOIN'; +const DEFAULT_ONCHAIN_QUOTE_AMOUNT_SATS = 10_000; + describe('Real integration from crates/lni/.env > LndNode', () => { const enabled = hasEnv('LND_URL', 'LND_MACAROON'); + const onchainSendAmountSats = Number.parseInt(process.env.LND_ONCHAIN_AMOUNT_SATS ?? '', 10); + const quoteOnlyAmountSats = + Number.isSafeInteger(onchainSendAmountSats) && onchainSendAmountSats > 0 + ? onchainSendAmountSats + : DEFAULT_ONCHAIN_QUOTE_AMOUNT_SATS; + const runOnchainSend = + enabled + && process.env.LND_RUN_ONCHAIN_SEND === 'true' + && process.env.LND_ONCHAIN_SEND_CONFIRM === ONCHAIN_SEND_CONFIRMATION + && hasEnv('LND_ONCHAIN_TEST_ADDRESS', 'LND_ONCHAIN_AMOUNT_SATS') + && Number.isSafeInteger(onchainSendAmountSats) + && onchainSendAmountSats > 0; const makeNode = () => new LndNode({ @@ -42,4 +57,36 @@ describe('Real integration from crates/lni/.env > LndNode', () => { const decoded = await node.decode(process.env.LND_TEST_PAYMENT_REQUEST!); expect(decoded.length).toBeGreaterThan(0); }, timeout); + + itIf(enabled && hasEnv('LND_ONCHAIN_TEST_ADDRESS'))('prepareOnchainTransaction + optionally payOnchain', async () => { + const node = makeNode(); + const transaction = await node.prepareOnchainTransaction({ + address: process.env.LND_ONCHAIN_TEST_ADDRESS!, + amountSats: quoteOnlyAmountSats, + fee: { type: 'speed', speed: 'normal' }, + feePayer: 'sender', + description: testInvoiceLabel(runOnchainSend ? 'lnd onchain e2e' : 'lnd onchain quote'), + }); + + console.log('Prepared LND on-chain transaction:', transaction); + + expect(transaction.address).toBe(process.env.LND_ONCHAIN_TEST_ADDRESS); + expect(transaction.amountSats).toBe(quoteOnlyAmountSats); + expect(transaction.feePayer).toBe('sender'); + expect(transaction.feeSats).toBeGreaterThanOrEqual(0); + + if (!runOnchainSend) { + console.log('Prepared LND on-chain quote; skipping broadcast without explicit confirmation'); + return; + } + + const payment = await node.payOnchain(transaction); + + console.log('LND on-chain payment:', payment); + + expect(payment.state).toBe('pending'); + expect(payment.address).toBe(process.env.LND_ONCHAIN_TEST_ADDRESS); + expect(payment.amountSats).toBe(quoteOnlyAmountSats); + expect(payment.txid?.length).toBeGreaterThan(0); + }, timeout); }); diff --git a/bindings/typescript/src/__tests__/integration/phoenixd.real.test.ts b/bindings/typescript/src/__tests__/integration/phoenixd.real.test.ts index 36309b9..acfbe11 100644 --- a/bindings/typescript/src/__tests__/integration/phoenixd.real.test.ts +++ b/bindings/typescript/src/__tests__/integration/phoenixd.real.test.ts @@ -4,6 +4,13 @@ import { hasEnv, itIf, runOrSkipKnownError, testInvoiceLabel, timeout } from './ describe('Real integration from crates/lni/.env > PhoenixdNode', () => { const enabled = hasEnv('PHOENIXD_URL', 'PHOENIXD_PASSWORD'); + const onchainEnabled = + enabled && + hasEnv('PHOENIXD_ONCHAIN_TEST_ADDRESS', 'PHOENIXD_ONCHAIN_AMOUNT_SATS', 'PHOENIXD_ONCHAIN_FEERATE_SAT_BYTE'); + const onchainSendConfirmation = 'I_UNDERSTAND_THIS_BROADCASTS_BITCOIN'; + const shouldBroadcastOnchain = + process.env.PHOENIXD_RUN_ONCHAIN_SEND === 'true' && + process.env.PHOENIXD_ONCHAIN_SEND_CONFIRM === onchainSendConfirmation; const makeNode = () => new PhoenixdNode({ @@ -38,4 +45,35 @@ describe('Real integration from crates/lni/.env > PhoenixdNode', () => { expect(Array.isArray(txs)).toBe(true); }, ['fetch failed', 'econnrefused', 'enotfound', 'timed out']); }, timeout); + + itIf(onchainEnabled)('prepareOnchainTransaction + optionally payOnchain', async () => { + await runOrSkipKnownError(async () => { + const node = makeNode(); + const amountSats = Number(process.env.PHOENIXD_ONCHAIN_AMOUNT_SATS); + const feerateSatByte = Number(process.env.PHOENIXD_ONCHAIN_FEERATE_SAT_BYTE); + expect(Number.isSafeInteger(amountSats)).toBe(true); + expect(amountSats).toBeGreaterThan(0); + expect(Number.isSafeInteger(feerateSatByte)).toBe(true); + expect(feerateSatByte).toBeGreaterThan(0); + + const transaction = await node.prepareOnchainTransaction({ + address: process.env.PHOENIXD_ONCHAIN_TEST_ADDRESS!, + amountSats, + fee: { type: 'satsPerVbyte', satsPerVbyte: feerateSatByte }, + description: testInvoiceLabel('phoenixd onchain'), + }); + + expect(transaction.amountSats).toBe(amountSats); + expect(transaction.feeSats).toBeUndefined(); + + if (!shouldBroadcastOnchain) { + return; + } + + const payment = await node.payOnchain(transaction, { + dangerouslyDisableFeeGuardrail: true, + }); + expect(payment.txid?.length).toBeGreaterThan(0); + }, ['fetch failed', 'econnrefused', 'enotfound', 'timed out']); + }, timeout); }); diff --git a/bindings/typescript/src/__tests__/lnd.test.ts b/bindings/typescript/src/__tests__/lnd.test.ts new file mode 100644 index 0000000..d5e12a7 --- /dev/null +++ b/bindings/typescript/src/__tests__/lnd.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LndNode } from '../nodes/lnd.js'; +import type { FetchLike } from '../types.js'; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + 'content-type': 'application/json', + ...(init?.headers ?? {}), + }, + }); +} + +describe('LndNode on-chain payments', () => { + it('prepares an on-chain transaction using LND fee estimate', async () => { + const fetchMock = vi.fn(async (input) => { + const url = new URL(String(input)); + + if (url.pathname === '/v1/transactions/fee') { + expect(url.searchParams.get('AddrToAmount[bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh]')).toBe('10000'); + expect(url.searchParams.get('target_conf')).toBe('6'); + return jsonResponse({ + fee_sat: '1000', + sat_per_vbyte: '12', + }); + } + + return new Response('not found', { status: 404 }); + }); + const node = new LndNode( + { url: 'https://lnd.test', macaroon: '00' }, + { fetch: fetchMock }, + ); + + const transaction = await node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + fee: { type: 'speed', speed: 'normal' }, + description: 'cold storage', + }); + + expect(transaction).toMatchObject({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 1_000, + totalAmountSats: 11_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'speed', speed: 'normal' }, + }); + }); + + it('prepares a manual sat/vbyte send without calling LND fee estimate', async () => { + const fetchMock = vi.fn(); + const node = new LndNode( + { url: 'https://lnd.test', macaroon: '00' }, + { fetch: fetchMock }, + ); + + const transaction = await node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + fee: { type: 'satsPerVbyte', satsPerVbyte: 5 }, + description: 'cold storage', + }); + + expect(transaction).toMatchObject({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'satsPerVbyte', satsPerVbyte: 5 }, + raw: { + sendRequest: { sat_per_vbyte: '5' }, + label: 'cold storage', + }, + }); + expect(transaction.feeSats).toBeUndefined(); + expect(transaction.totalAmountSats).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('executes an on-chain transaction using LND sendcoins', async () => { + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + + if (url.pathname === '/v1/transactions') { + expect(body).toEqual({ + addr: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amount: 10_000, + sat_per_vbyte: '5', + label: 'cold storage', + }); + return jsonResponse({ txid: 'txid-1' }); + } + + return new Response('not found', { status: 404 }); + }); + const node = new LndNode( + { url: 'https://lnd.test', macaroon: '00' }, + { fetch: fetchMock }, + ); + + const payment = await node.payOnchain({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 1_000, + totalAmountSats: 11_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'satsPerVbyte', satsPerVbyte: 5 }, + raw: { label: 'cold storage' }, + }); + + expect(payment).toMatchObject({ + txid: 'txid-1', + state: 'pending', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 1_000, + totalAmountSats: 11_000, + }); + }); + + it('rejects unsupported LND on-chain fee modes before network calls', async () => { + const fetchMock = vi.fn(); + const node = new LndNode( + { url: 'https://lnd.test', macaroon: '00' }, + { fetch: fetchMock }, + ); + + await expect( + node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feePayer: 'recipient', + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + + await expect( + node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + fee: { type: 'speed', speed: 'free' }, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('blocks on-chain execution when the quoted fee exceeds the default guardrail', async () => { + const fetchMock = vi.fn(); + const node = new LndNode( + { url: 'https://lnd.test', macaroon: '00' }, + { fetch: fetchMock }, + ); + + await expect( + node.payOnchain({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + feeSats: 3_000, + feePayer: 'sender', + fee: { type: 'targetConf', blocks: 6 }, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('blocks manual sat/vbyte execution unless the fee guardrail is disabled', async () => { + const fetchMock = vi.fn(); + const node = new LndNode( + { url: 'https://lnd.test', macaroon: '00' }, + { fetch: fetchMock }, + ); + + await expect( + node.payOnchain({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'satsPerVbyte', satsPerVbyte: 5 }, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/bindings/typescript/src/__tests__/phoenixd.test.ts b/bindings/typescript/src/__tests__/phoenixd.test.ts new file mode 100644 index 0000000..be58c6d --- /dev/null +++ b/bindings/typescript/src/__tests__/phoenixd.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PhoenixdNode } from '../nodes/phoenixd.js'; +import type { FetchLike } from '../types.js'; + +describe('PhoenixdNode on-chain payments', () => { + it('prepares an on-chain transaction with an explicit feerate', async () => { + const fetchMock = vi.fn(); + const node = new PhoenixdNode( + { url: 'http://phoenixd.test', password: 'password' }, + { fetch: fetchMock }, + ); + + const transaction = await node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + fee: { type: 'satsPerVbyte', satsPerVbyte: 12 }, + description: 'cold storage', + }); + + expect(transaction).toMatchObject({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'satsPerVbyte', satsPerVbyte: 12 }, + raw: { + sendRequest: { + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSat: 10_000, + feerateSatByte: 12, + }, + description: 'cold storage', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('executes an on-chain transaction using Phoenixd sendtoaddress', async () => { + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + expect(url.pathname).toBe('/sendtoaddress'); + expect(init?.method).toBe('POST'); + expect(init?.body).toBe('address=bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh&amountSat=10000&feerateSatByte=12'); + return new Response('a'.repeat(64)); + }); + const node = new PhoenixdNode( + { url: 'http://phoenixd.test', password: 'password' }, + { fetch: fetchMock }, + ); + + const payment = await node.payOnchain( + { + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'satsPerVbyte', satsPerVbyte: 12 }, + }, + { dangerouslyDisableFeeGuardrail: true }, + ); + + expect(payment).toMatchObject({ + txid: 'a'.repeat(64), + state: 'pending', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + }); + }); + + it('requires an explicit Phoenixd on-chain feerate before network calls', async () => { + const fetchMock = vi.fn(); + const node = new PhoenixdNode( + { url: 'http://phoenixd.test', password: 'password' }, + { fetch: fetchMock }, + ); + + await expect( + node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + + await expect( + node.prepareOnchainTransaction({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + fee: { type: 'speed', speed: 'normal' }, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('blocks Phoenixd sendtoaddress unless the fee guardrail is explicitly disabled', async () => { + const fetchMock = vi.fn(); + const node = new PhoenixdNode( + { url: 'http://phoenixd.test', password: 'password' }, + { fetch: fetchMock }, + ); + + await expect( + node.payOnchain({ + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountSats: 10_000, + recipientAmountSats: 10_000, + feePayer: 'sender', + fee: { type: 'satsPerVbyte', satsPerVbyte: 12 }, + }), + ).rejects.toMatchObject({ code: 'InvalidInput' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/bindings/typescript/src/nodes/cln.ts b/bindings/typescript/src/nodes/cln.ts index c3ce614..3a8ceac 100644 --- a/bindings/typescript/src/nodes/cln.ts +++ b/bindings/typescript/src/nodes/cln.ts @@ -4,7 +4,7 @@ import { buildUrl, requestJson, requestText, resolveFetch, toTimeoutMs } from '. import { parseClnRunePermissions } from '../internal/permissions.js'; import { pollInvoiceEvents } from '../internal/polling.js'; import { emptyNodeInfo, emptyTransaction, parseOptionalNumber } from '../internal/transform.js'; -import { InvoiceType, type ClnConfig, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type Permissions, type Transaction } from '../types.js'; +import { DEFAULT_ONCHAIN_FEE_GUARDRAIL, InvoiceType, type ClnConfig, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type OnchainFeeGuardrail, type OnchainFeePayer, type OnchainFeePreference, type OnchainPayments, type OnchainTransaction, type PayInvoiceParams, type PayInvoiceResponse, type PayOnchainOptions, type PayOnchainResponse, type Permissions, type PrepareOnchainTransactionParams, type Transaction } from '../types.js'; interface ClnInfoResponse { id: string; @@ -71,6 +71,21 @@ interface ClnListOffersResponse { offers: Offer[]; } +interface ClnTxPrepareResponse { + psbt?: string; + unsigned_tx?: string; + txid?: string; +} + +interface ClnTxSendResponse { + txid?: string; + tx?: string; +} + +interface ClnOnchainFeeRequest { + feerate?: string; +} + function newInvoiceLabel(): string { if (globalThis.crypto?.randomUUID) { return `lni.${globalThis.crypto.randomUUID()}`; @@ -79,7 +94,243 @@ function newInvoiceLabel(): string { return `lni.${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`; } -export class ClnNode implements LightningNode { +function assertValidOnchainAmount(amountSats: number): void { + if (!Number.isSafeInteger(amountSats) || amountSats <= 0) { + throw new LniError('InvalidInput', 'payOnchain requires a positive integer amountSats.'); + } +} + +function defaultOnchainFee(): OnchainFeePreference { + return { type: 'speed', speed: 'normal' }; +} + +function resolveClnFeePayer(feePayer?: OnchainFeePayer): OnchainFeePayer { + if (feePayer === 'recipient') { + throw new LniError('InvalidInput', 'CLN payOnchain only supports sender-paid on-chain fees.'); + } + + return 'sender'; +} + +function resolveClnFeeRequest(fee: OnchainFeePreference): ClnOnchainFeeRequest { + switch (fee.type) { + case 'default': + return { feerate: 'normal' }; + case 'speed': + switch (fee.speed) { + case 'fast': + return { feerate: 'urgent' }; + case 'normal': + return { feerate: 'normal' }; + case 'slow': + return { feerate: 'slow' }; + case 'free': + throw new LniError('InvalidInput', 'CLN payOnchain does not support free on-chain fee speed.'); + } + case 'satsPerVbyte': + if (!Number.isFinite(fee.satsPerVbyte) || fee.satsPerVbyte <= 0) { + throw new LniError('InvalidInput', 'CLN satsPerVbyte fee preference requires a positive fee rate.'); + } + return { feerate: `${Math.ceil(fee.satsPerVbyte * 1000)}perkb` }; + case 'backend': + if (!fee.value.trim()) { + throw new LniError('InvalidInput', 'CLN backend fee preference requires a feerate value.'); + } + return { feerate: fee.value }; + case 'targetConf': + throw new LniError('InvalidInput', 'CLN payOnchain does not support target-confirmation fee preferences.'); + } +} + +function normalizeOnchainState(txid?: string): PayOnchainResponse['state'] { + return txid ? 'pending' : 'failed'; +} + +function assertOnchainFeeGuardrail(transaction: OnchainTransaction, options?: PayOnchainOptions): void { + if (options?.dangerouslyDisableFeeGuardrail) { + return; + } + + const guardrail: Required = { + maxFeeSats: options?.feeGuardrail?.maxFeeSats ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeeSats, + maxFeePercent: options?.feeGuardrail?.maxFeePercent ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeePercent, + }; + if (transaction.feeSats === undefined) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because feeSats is unknown. Re-prepare the transaction or pass dangerouslyDisableFeeGuardrail: true.'); + } + if (!Number.isFinite(transaction.feeSats) || transaction.feeSats < 0) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because feeSats is invalid.'); + } + if (!Number.isFinite(transaction.amountSats) || transaction.amountSats <= 0) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because amountSats is invalid.'); + } + + const maxFeeByPercent = Math.floor((transaction.amountSats * guardrail.maxFeePercent) / 100); + const maxAllowedFee = Math.min(guardrail.maxFeeSats, maxFeeByPercent); + if (transaction.feeSats > maxAllowedFee) { + throw new LniError('InvalidInput', `Cannot pay on-chain transaction because feeSats ${transaction.feeSats} exceeds guardrail ${maxAllowedFee} sats.`); + } +} + +function readUIntLE(bytes: Uint8Array, offset: number, length: number): number { + let value = 0; + for (let i = 0; i < length; i += 1) { + value += (bytes[offset + i] ?? 0) * 2 ** (8 * i); + } + return value; +} + +function readUInt64LE(bytes: Uint8Array, offset: number): bigint { + let value = 0n; + for (let i = 0; i < 8; i += 1) { + value += BigInt(bytes[offset + i] ?? 0) << BigInt(8 * i); + } + return value; +} + +function readCompactSize(bytes: Uint8Array, offset: number): { value: number; next: number } { + const first = bytes[offset]; + if (first === undefined) { + throw new Error('Unexpected end of compact size.'); + } + if (first < 0xfd) { + return { value: first, next: offset + 1 }; + } + if (first === 0xfd) { + return { value: readUIntLE(bytes, offset + 1, 2), next: offset + 3 }; + } + if (first === 0xfe) { + return { value: readUIntLE(bytes, offset + 1, 4), next: offset + 5 }; + } + const value = Number(readUInt64LE(bytes, offset + 1)); + if (!Number.isSafeInteger(value)) { + throw new Error('Compact size is too large.'); + } + return { value, next: offset + 9 }; +} + +function parseTransaction(bytes: Uint8Array): { + inputCount: number; + prevouts: Array<{ vout: number }>; + outputTotalSats: number; + outputs: Array<{ amountSats: number }>; +} { + let offset = 4; + let inputCountInfo = readCompactSize(bytes, offset); + if (inputCountInfo.value === 0 && bytes[inputCountInfo.next] !== undefined) { + offset = inputCountInfo.next + 1; + inputCountInfo = readCompactSize(bytes, offset); + } + + const inputCount = inputCountInfo.value; + offset = inputCountInfo.next; + const prevouts: Array<{ vout: number }> = []; + for (let i = 0; i < inputCount; i += 1) { + offset += 32; + const vout = readUIntLE(bytes, offset, 4); + offset += 4; + const script = readCompactSize(bytes, offset); + offset = script.next + script.value + 4; + prevouts.push({ vout }); + } + + const outputCount = readCompactSize(bytes, offset); + offset = outputCount.next; + const outputs: Array<{ amountSats: number }> = []; + let outputTotalSats = 0; + for (let i = 0; i < outputCount.value; i += 1) { + const amountSats = Number(readUInt64LE(bytes, offset)); + offset += 8; + const script = readCompactSize(bytes, offset); + offset = script.next + script.value; + outputs.push({ amountSats }); + outputTotalSats += amountSats; + } + + return { inputCount, prevouts, outputTotalSats, outputs }; +} + +function parsePsbtMap(bytes: Uint8Array, offset: number): { + entries: Array<{ key: Uint8Array; value: Uint8Array }>; + next: number; +} { + const entries: Array<{ key: Uint8Array; value: Uint8Array }> = []; + while (offset < bytes.length) { + const keyLen = readCompactSize(bytes, offset); + offset = keyLen.next; + if (keyLen.value === 0) { + return { entries, next: offset }; + } + const key = bytes.slice(offset, offset + keyLen.value); + offset += keyLen.value; + const valueLen = readCompactSize(bytes, offset); + offset = valueLen.next; + const value = bytes.slice(offset, offset + valueLen.value); + offset += valueLen.value; + entries.push({ key, value }); + } + + throw new Error('Unterminated PSBT map.'); +} + +function base64ToBytes(value: string): Uint8Array { + if (typeof globalThis.atob === 'function') { + const decoded = globalThis.atob(value); + return Uint8Array.from(decoded, (char) => char.charCodeAt(0)); + } + + return Uint8Array.from(Buffer.from(value, 'base64')); +} + +function parsePsbtFeeSats(psbt?: string): number | undefined { + if (!psbt) { + return undefined; + } + + try { + const bytes = base64ToBytes(psbt); + if (bytes.length < 5 || bytes[0] !== 0x70 || bytes[1] !== 0x73 || bytes[2] !== 0x62 || bytes[3] !== 0x74 || bytes[4] !== 0xff) { + return undefined; + } + + const globalMap = parsePsbtMap(bytes, 5); + const unsignedTx = globalMap.entries.find((entry) => entry.key[0] === 0x00)?.value; + if (!unsignedTx) { + return undefined; + } + + const tx = parseTransaction(unsignedTx); + let offset = globalMap.next; + let inputTotalSats = 0; + + for (let i = 0; i < tx.inputCount; i += 1) { + const inputMap = parsePsbtMap(bytes, offset); + offset = inputMap.next; + const witnessUtxo = inputMap.entries.find((entry) => entry.key[0] === 0x01)?.value; + if (witnessUtxo) { + inputTotalSats += Number(readUInt64LE(witnessUtxo, 0)); + continue; + } + + const nonWitnessUtxo = inputMap.entries.find((entry) => entry.key[0] === 0x00)?.value; + const prevout = tx.prevouts[i]; + if (nonWitnessUtxo && prevout) { + const prevTx = parseTransaction(nonWitnessUtxo); + const output = prevTx.outputs[prevout.vout]; + if (output) { + inputTotalSats += output.amountSats; + } + } + } + + const feeSats = inputTotalSats - tx.outputTotalSats; + return inputTotalSats > 0 && feeSats >= 0 ? feeSats : undefined; + } catch { + return undefined; + } +} + +export class ClnNode implements LightningNode, OnchainPayments { private readonly fetchFn; private readonly timeoutMs?: number; @@ -303,6 +554,62 @@ export class ClnNode implements LightningNode { }; } + async prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise { + assertValidOnchainAmount(params.amountSats); + + const fee = params.fee ?? defaultOnchainFee(); + const feePayer = resolveClnFeePayer(params.feePayer); + const feeRequest = resolveClnFeeRequest(fee); + const txPrepare = await this.postJson('/v1/txprepare', { + outputs: [{ [params.address]: `${params.amountSats}sat` }], + ...feeRequest, + }); + const feeSats = parsePsbtFeeSats(txPrepare.psbt); + + return { + id: txPrepare.txid, + address: params.address, + amountSats: params.amountSats, + feeSats, + totalAmountSats: feeSats === undefined ? undefined : params.amountSats + feeSats, + recipientAmountSats: params.amountSats, + feePayer, + fee, + raw: { + txPrepare, + txSendRequest: { txid: txPrepare.txid }, + feeRequest, + description: params.description, + }, + }; + } + + async payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions): Promise { + assertValidOnchainAmount(transaction.amountSats); + resolveClnFeePayer(transaction.feePayer); + resolveClnFeeRequest(transaction.fee); + assertOnchainFeeGuardrail(transaction, options); + if (!transaction.id) { + throw new LniError('InvalidInput', 'CLN payOnchain requires a transaction id from prepareOnchainTransaction.'); + } + + const response = await this.postJson('/v1/txsend', { + txid: transaction.id, + }); + + return { + paymentId: transaction.id, + txid: response.txid ?? transaction.id, + state: normalizeOnchainState(response.txid ?? transaction.id), + address: transaction.address, + amountSats: transaction.amountSats, + feeSats: transaction.feeSats, + totalAmountSats: transaction.totalAmountSats, + recipientAmountSats: transaction.recipientAmountSats ?? transaction.amountSats, + raw: response, + }; + } + async createOffer(params: CreateOfferParams): Promise { const payload = await this.postJson('/v1/offer', { amount: params.amountMsats !== undefined ? `${params.amountMsats}msat` : 'any', diff --git a/bindings/typescript/src/nodes/lnd.ts b/bindings/typescript/src/nodes/lnd.ts index f15677e..df4eced 100644 --- a/bindings/typescript/src/nodes/lnd.ts +++ b/bindings/typescript/src/nodes/lnd.ts @@ -5,7 +5,7 @@ import { buildUrl, requestJson, requestText, resolveFetch, toTimeoutMs } from '. import { isEmptyPermissions, normalizeLndPermissions, parseLndMacaroonPermissions } from '../internal/permissions.js'; import { pollInvoiceEvents } from '../internal/polling.js'; import { emptyNodeInfo, emptyTransaction, parseOptionalNumber, rHashToHex } from '../internal/transform.js'; -import { InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type LndConfig, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type Permissions, type Transaction } from '../types.js'; +import { DEFAULT_ONCHAIN_FEE_GUARDRAIL, InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type LndConfig, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type OnchainFeeGuardrail, type OnchainFeePayer, type OnchainFeePreference, type OnchainPayments, type OnchainTransaction, type PayInvoiceParams, type PayInvoiceResponse, type PayOnchainOptions, type PayOnchainResponse, type Permissions, type PrepareOnchainTransactionParams, type Transaction } from '../types.js'; interface LndGetInfoResponse { alias: string; @@ -79,7 +79,147 @@ interface LndCheckMacaroonPermissionsResponse { valid?: boolean; } -export class LndNode implements LightningNode { +interface LndEstimateFeeResponse { + fee_sat?: string; + feerate_sat_per_byte?: string; + sat_per_vbyte?: string; +} + +interface LndSendCoinsResponse { + txid?: string; +} + +interface LndOnchainFeeRequest { + target_conf?: number; + sat_per_vbyte?: string; +} + +function defaultOnchainFee(): OnchainFeePreference { + return { type: 'targetConf', blocks: 6 }; +} + +function resolveLndFeePayer(feePayer?: OnchainFeePayer): OnchainFeePayer { + if (feePayer === 'recipient') { + throw new LniError('InvalidInput', 'LND payOnchain only supports sender-paid on-chain fees.'); + } + + return 'sender'; +} + +function resolveLndFeeRequest(fee: OnchainFeePreference): LndOnchainFeeRequest { + switch (fee.type) { + case 'default': + return { target_conf: 6 }; + case 'speed': + switch (fee.speed) { + case 'fast': + return { target_conf: 1 }; + case 'normal': + return { target_conf: 6 }; + case 'slow': + return { target_conf: 12 }; + case 'free': + throw new LniError('InvalidInput', 'LND payOnchain does not support free on-chain fee speed.'); + } + case 'targetConf': + if (!Number.isSafeInteger(fee.blocks) || fee.blocks <= 0) { + throw new LniError('InvalidInput', 'LND targetConf fee preference requires a positive integer block target.'); + } + return { target_conf: fee.blocks }; + case 'satsPerVbyte': + if (!Number.isFinite(fee.satsPerVbyte) || fee.satsPerVbyte <= 0) { + throw new LniError('InvalidInput', 'LND satsPerVbyte fee preference requires a positive number.'); + } + return { sat_per_vbyte: String(Math.ceil(fee.satsPerVbyte)) }; + case 'backend': + throw new LniError('InvalidInput', 'LND payOnchain does not support backend fee preferences.'); + } +} + +function normalizeOnchainState(txid?: string): PayOnchainResponse['state'] { + return txid ? 'pending' : 'failed'; +} + +function parseOptionalFeeSats(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + + const parsed = parseOptionalNumber(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function assertValidOnchainAmount(amountSats: number): void { + if (!Number.isSafeInteger(amountSats) || amountSats <= 0) { + throw new LniError('InvalidInput', 'payOnchain requires a positive integer amountSats.'); + } +} + +function assertValidGuardrailLimit(value: number, name: string): void { + if (!Number.isFinite(value) || value < 0) { + throw new LniError('InvalidInput', `${name} must be a non-negative finite number.`); + } + + if (name.endsWith('maxFeeSats') && !Number.isSafeInteger(value)) { + throw new LniError('InvalidInput', `${name} must be a safe integer.`); + } +} + +function resolveOnchainFeeGuardrail(options?: PayOnchainOptions): Required | undefined { + if (options?.dangerouslyDisableFeeGuardrail) { + return undefined; + } + + const guardrail = { + maxFeeSats: options?.feeGuardrail?.maxFeeSats ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeeSats, + maxFeePercent: options?.feeGuardrail?.maxFeePercent ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeePercent, + }; + + assertValidGuardrailLimit(guardrail.maxFeeSats, 'feeGuardrail.maxFeeSats'); + assertValidGuardrailLimit(guardrail.maxFeePercent, 'feeGuardrail.maxFeePercent'); + + return guardrail; +} + +function assertOnchainFeeGuardrail(transaction: OnchainTransaction, options?: PayOnchainOptions): void { + const guardrail = resolveOnchainFeeGuardrail(options); + if (!guardrail) { + return; + } + + const { feeSats } = transaction; + if (feeSats === undefined) { + throw new LniError( + 'InvalidInput', + 'Cannot pay on-chain transaction because feeSats is unknown. Re-prepare the transaction or pass dangerouslyDisableFeeGuardrail: true.', + ); + } + + if (!Number.isSafeInteger(feeSats) || feeSats < 0) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because feeSats is invalid.'); + } + + if (!Number.isSafeInteger(transaction.amountSats) || transaction.amountSats <= 0) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because amountSats is invalid.'); + } + + if (feeSats > guardrail.maxFeeSats) { + throw new LniError( + 'InvalidInput', + `On-chain fee ${feeSats} sats exceeds guardrail maxFeeSats ${guardrail.maxFeeSats}.`, + ); + } + + const feePercent = (feeSats / transaction.amountSats) * 100; + if (feePercent > guardrail.maxFeePercent) { + throw new LniError( + 'InvalidInput', + `On-chain fee ${feePercent.toFixed(2)}% exceeds guardrail maxFeePercent ${guardrail.maxFeePercent}%.`, + ); + } +} + +export class LndNode implements LightningNode, OnchainPayments { private readonly fetchFn; private readonly timeoutMs?: number; @@ -103,6 +243,14 @@ export class LndNode implements LightningNode { }); } + private async getJsonWithQuery(path: string, query: Record): Promise { + return requestJson(this.fetchFn, buildUrl(this.config.url, path, query), { + method: 'GET', + headers: this.headers(), + timeoutMs: this.timeoutMs, + }); + } + private async postJson(path: string, json: unknown): Promise { return requestJson(this.fetchFn, buildUrl(this.config.url, path), { method: 'POST', @@ -295,6 +443,77 @@ export class LndNode implements LightningNode { }; } + async prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise { + const amountSats = params.amountSats; + assertValidOnchainAmount(amountSats); + + const fee = params.fee ?? defaultOnchainFee(); + const feePayer = resolveLndFeePayer(params.feePayer); + const feeRequest = resolveLndFeeRequest(fee); + + if (feeRequest.sat_per_vbyte !== undefined) { + return { + address: params.address, + amountSats, + recipientAmountSats: amountSats, + feePayer, + fee, + raw: { + sendRequest: feeRequest, + label: params.description, + }, + }; + } + + const estimate = await this.getJsonWithQuery('/v1/transactions/fee', { + [`AddrToAmount[${params.address}]`]: amountSats, + ...feeRequest, + }); + const feeSats = parseOptionalFeeSats(estimate.fee_sat); + + return { + address: params.address, + amountSats, + feeSats, + totalAmountSats: feeSats === undefined ? undefined : amountSats + feeSats, + recipientAmountSats: amountSats, + feePayer, + fee, + raw: { + estimate, + sendRequest: feeRequest, + label: params.description, + }, + }; + } + + async payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions): Promise { + assertValidOnchainAmount(transaction.amountSats); + resolveLndFeePayer(transaction.feePayer); + const feeRequest = resolveLndFeeRequest(transaction.fee); + assertOnchainFeeGuardrail(transaction, options); + + const response = await this.postJson('/v1/transactions', { + addr: transaction.address, + amount: transaction.amountSats, + ...feeRequest, + label: typeof transaction.raw === 'object' && transaction.raw !== null && !Array.isArray(transaction.raw) + ? (transaction.raw as { label?: unknown }).label + : undefined, + }); + + return { + txid: response.txid, + state: normalizeOnchainState(response.txid), + address: transaction.address, + amountSats: transaction.amountSats, + feeSats: transaction.feeSats, + totalAmountSats: transaction.totalAmountSats, + recipientAmountSats: transaction.recipientAmountSats ?? transaction.amountSats, + raw: response, + }; + } + async createOffer(_params: CreateOfferParams): Promise { throw new LniError('Api', 'Bolt12 is not implemented for LndNode.'); } diff --git a/bindings/typescript/src/nodes/phoenixd.ts b/bindings/typescript/src/nodes/phoenixd.ts index f334d40..eef8bd7 100644 --- a/bindings/typescript/src/nodes/phoenixd.ts +++ b/bindings/typescript/src/nodes/phoenixd.ts @@ -4,7 +4,7 @@ import { buildUrl, requestJson, requestText, resolveFetch, toTimeoutMs } from '. import { pollInvoiceEvents } from '../internal/polling.js'; import { emptyNodeInfo, emptyTransaction, matchesSearch, satsToMsats, toUnixSeconds } from '../internal/transform.js'; import { encodeBase64 } from '../internal/encoding.js'; -import { InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type Permissions, type PhoenixdConfig, type Transaction, type NodeInfo } from '../types.js'; +import { DEFAULT_ONCHAIN_FEE_GUARDRAIL, InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type OnchainFeeGuardrail, type OnchainFeePayer, type OnchainFeePreference, type OnchainPayments, type OnchainTransaction, type PayInvoiceParams, type PayInvoiceResponse, type PayOnchainOptions, type PayOnchainResponse, type Permissions, type PhoenixdConfig, type PrepareOnchainTransactionParams, type Transaction, type NodeInfo } from '../types.js'; interface PhoenixdInfoResponse { nodeId: string; @@ -55,7 +55,75 @@ interface PhoenixdOutgoingPaymentResponse { externalId?: string; } -export class PhoenixdNode implements LightningNode { +function assertValidOnchainAmount(amountSats: number): void { + if (!Number.isSafeInteger(amountSats) || amountSats <= 0) { + throw new LniError('InvalidInput', 'payOnchain requires a positive integer amountSats.'); + } +} + +function resolvePhoenixdFeePayer(feePayer?: OnchainFeePayer): OnchainFeePayer { + if (feePayer === 'recipient') { + throw new LniError('InvalidInput', 'Phoenixd payOnchain only supports sender-paid on-chain fees.'); + } + + return 'sender'; +} + +function resolvePhoenixdFeeRequest(fee?: OnchainFeePreference): { fee: OnchainFeePreference; feerateSatByte: number } { + const resolvedFee = fee ?? { type: 'default' }; + + switch (resolvedFee.type) { + case 'satsPerVbyte': + if (!Number.isFinite(resolvedFee.satsPerVbyte) || resolvedFee.satsPerVbyte <= 0) { + throw new LniError('InvalidInput', 'Phoenixd satsPerVbyte fee preference requires a positive fee rate.'); + } + return { fee: resolvedFee, feerateSatByte: Math.ceil(resolvedFee.satsPerVbyte) }; + case 'backend': { + const feerateSatByte = Number(resolvedFee.value); + if (!Number.isSafeInteger(feerateSatByte) || feerateSatByte <= 0) { + throw new LniError('InvalidInput', 'Phoenixd backend fee preference must be a positive integer feerateSatByte value.'); + } + return { fee: resolvedFee, feerateSatByte }; + } + case 'default': + case 'speed': + case 'targetConf': + throw new LniError('InvalidInput', 'Phoenixd payOnchain requires an explicit satsPerVbyte fee preference.'); + } +} + +function assertOnchainFeeGuardrail(transaction: OnchainTransaction, options?: PayOnchainOptions): void { + if (options?.dangerouslyDisableFeeGuardrail) { + return; + } + + const guardrail: Required = { + maxFeeSats: options?.feeGuardrail?.maxFeeSats ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeeSats, + maxFeePercent: options?.feeGuardrail?.maxFeePercent ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeePercent, + }; + if (transaction.feeSats === undefined) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because feeSats is unknown. Re-prepare the transaction or pass dangerouslyDisableFeeGuardrail: true.'); + } + if (!Number.isFinite(transaction.feeSats) || transaction.feeSats < 0) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because feeSats is invalid.'); + } + if (!Number.isFinite(transaction.amountSats) || transaction.amountSats <= 0) { + throw new LniError('InvalidInput', 'Cannot pay on-chain transaction because amountSats is invalid.'); + } + + const maxFeeByPercent = Math.floor((transaction.amountSats * guardrail.maxFeePercent) / 100); + const maxAllowedFee = Math.min(guardrail.maxFeeSats, maxFeeByPercent); + if (transaction.feeSats > maxAllowedFee) { + throw new LniError('InvalidInput', `Cannot pay on-chain transaction because feeSats ${transaction.feeSats} exceeds guardrail ${maxAllowedFee} sats.`); + } +} + +function txidFromText(value: string): string | undefined { + const trimmed = value.trim(); + return /^[0-9a-fA-F]{64}$/.test(trimmed) ? trimmed : undefined; +} + +export class PhoenixdNode implements LightningNode, OnchainPayments { private readonly fetchFn; private readonly timeoutMs?: number; @@ -177,6 +245,59 @@ export class PhoenixdNode implements LightningNode { }; } + async prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise { + assertValidOnchainAmount(params.amountSats); + const feePayer = resolvePhoenixdFeePayer(params.feePayer); + const { fee, feerateSatByte } = resolvePhoenixdFeeRequest(params.fee); + + return { + address: params.address, + amountSats: params.amountSats, + recipientAmountSats: params.amountSats, + feePayer, + fee, + raw: { + sendRequest: { + address: params.address, + amountSat: params.amountSats, + feerateSatByte, + }, + description: params.description, + }, + }; + } + + async payOnchain(transaction: OnchainTransaction, options?: PayOnchainOptions): Promise { + assertValidOnchainAmount(transaction.amountSats); + resolvePhoenixdFeePayer(transaction.feePayer); + const { feerateSatByte } = resolvePhoenixdFeeRequest(transaction.fee); + assertOnchainFeeGuardrail(transaction, options); + + const response = await this.requestText('/sendtoaddress', { + method: 'POST', + form: { + address: transaction.address, + amountSat: transaction.amountSats, + feerateSatByte, + }, + }); + const txid = txidFromText(response); + if (!txid) { + throw new LniError('Api', response.trim() || 'Phoenixd sendtoaddress did not return a txid.'); + } + + return { + txid, + state: 'pending', + address: transaction.address, + amountSats: transaction.amountSats, + feeSats: transaction.feeSats, + totalAmountSats: transaction.totalAmountSats, + recipientAmountSats: transaction.recipientAmountSats ?? transaction.amountSats, + raw: response, + }; + } + async createOffer(params: CreateOfferParams): Promise { const bolt12 = await this.requestText('/createoffer', { method: 'POST', diff --git a/crates/lni/cln/api.rs b/crates/lni/cln/api.rs index d9ca176..aca3666 100644 --- a/crates/lni/cln/api.rs +++ b/crates/lni/cln/api.rs @@ -7,14 +7,35 @@ use crate::cln::types::Invoice; use crate::types::NodeInfo; use crate::{ calculate_fee_msats, ApiError, CreateOfferParams, InvoiceType, Offer, OnInvoiceEventCallback, OnInvoiceEventParams, - PayInvoiceParams, PayInvoiceResponse, Transaction, + OnchainFeePayer, OnchainFeePreference, OnchainFeePreferenceType, OnchainFeeSpeed, + OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, + PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, }; use reqwest::header; +use serde::Deserialize; +use serde_json::json; use std::time::Duration; use tokio::time::sleep; // https://docs.corelightning.org/reference/get_list_methods_resource +#[derive(Debug, Deserialize)] +struct TxPrepareResponse { + psbt: Option, + unsigned_tx: Option, + txid: Option, +} + +#[derive(Debug, Deserialize)] +struct TxSendResponse { + txid: Option, +} + +#[derive(Clone)] +struct ClnOnchainFeeRequest { + feerate: Option, +} + fn clnrest_client(config: &ClnConfig) -> reqwest::Client { let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Rune", header::HeaderValue::from_str(&config.rune).unwrap()); @@ -56,6 +77,322 @@ fn clnrest_client(config: &ClnConfig) -> reqwest::Client { .unwrap_or_else(|_| reqwest::Client::new()) } +fn assert_valid_onchain_amount(amount_sats: i64) -> Result<(), ApiError> { + if amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "pay_onchain requires a positive amount_sats".to_string(), + )); + } + + Ok(()) +} + +fn default_onchain_fee() -> OnchainFeePreference { + OnchainFeePreference { + preference_type: OnchainFeePreferenceType::Speed, + speed: Some(OnchainFeeSpeed::Normal), + target_conf: None, + sats_per_vbyte: None, + backend: None, + } +} + +fn resolve_cln_fee_payer(fee_payer: Option) -> Result { + match fee_payer.unwrap_or(OnchainFeePayer::Sender) { + OnchainFeePayer::Sender => Ok(OnchainFeePayer::Sender), + OnchainFeePayer::Recipient => Err(ApiError::InvalidInput( + "CLN pay_onchain only supports sender-paid on-chain fees".to_string(), + )), + } +} + +fn resolve_cln_fee_request(fee: &OnchainFeePreference) -> Result { + match fee.preference_type { + OnchainFeePreferenceType::Default => Ok(ClnOnchainFeeRequest { + feerate: Some("normal".to_string()), + }), + OnchainFeePreferenceType::Speed => match fee.speed.clone().unwrap_or(OnchainFeeSpeed::Normal) { + OnchainFeeSpeed::Fast => Ok(ClnOnchainFeeRequest { + feerate: Some("urgent".to_string()), + }), + OnchainFeeSpeed::Normal => Ok(ClnOnchainFeeRequest { + feerate: Some("normal".to_string()), + }), + OnchainFeeSpeed::Slow => Ok(ClnOnchainFeeRequest { + feerate: Some("slow".to_string()), + }), + OnchainFeeSpeed::Free => Err(ApiError::InvalidInput( + "CLN pay_onchain does not support free on-chain fee speed".to_string(), + )), + }, + OnchainFeePreferenceType::SatsPerVbyte => { + let sats_per_vbyte = fee.sats_per_vbyte.ok_or_else(|| { + ApiError::InvalidInput( + "CLN sats_per_vbyte fee preference requires a fee rate".to_string(), + ) + })?; + if sats_per_vbyte <= 0.0 { + return Err(ApiError::InvalidInput( + "CLN sats_per_vbyte fee preference requires a positive fee rate".to_string(), + )); + } + + Ok(ClnOnchainFeeRequest { + feerate: Some(format!("{}perkb", (sats_per_vbyte * 1000.0).ceil() as i64)), + }) + } + OnchainFeePreferenceType::Backend => { + let feerate = fee.backend.clone().unwrap_or_default(); + if feerate.trim().is_empty() { + return Err(ApiError::InvalidInput( + "CLN backend fee preference requires a feerate value".to_string(), + )); + } + + Ok(ClnOnchainFeeRequest { + feerate: Some(feerate), + }) + } + OnchainFeePreferenceType::TargetConf => Err(ApiError::InvalidInput( + "CLN pay_onchain does not support target-confirmation fee preferences".to_string(), + )), + } +} + +fn assert_onchain_fee_guardrail( + transaction: &OnchainTransaction, + options: PayOnchainOptions, +) -> Result<(), ApiError> { + if options.dangerously_disable_fee_guardrail { + return Ok(()); + } + + let guardrail = options.fee_guardrail.unwrap_or_default(); + let max_fee_sats = guardrail + .max_fee_sats + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_SATS); + let max_fee_percent = guardrail + .max_fee_percent + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_PERCENT); + let fee_sats = transaction.fee_sats.ok_or_else(|| { + ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is unknown. Re-prepare the transaction or pass dangerously_disable_fee_guardrail: true.".to_string(), + ) + })?; + if fee_sats < 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is invalid".to_string(), + )); + } + if transaction.amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because amount_sats is invalid".to_string(), + )); + } + + let max_fee_by_percent = + ((transaction.amount_sats as f64) * max_fee_percent / 100.0).floor() as i64; + let max_allowed_fee = std::cmp::min(max_fee_sats, max_fee_by_percent); + if fee_sats > max_allowed_fee { + return Err(ApiError::InvalidInput(format!( + "Cannot pay on-chain transaction because fee_sats {} exceeds guardrail {} sats", + fee_sats, max_allowed_fee + ))); + } + + Ok(()) +} + +fn read_u64_le(bytes: &[u8], offset: usize) -> Result { + if offset + 8 > bytes.len() { + return Err(ApiError::Json { + reason: "Unexpected end of uint64".to_string(), + }); + } + + Ok(u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap())) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> Result { + if offset + 4 > bytes.len() { + return Err(ApiError::Json { + reason: "Unexpected end of uint32".to_string(), + }); + } + + Ok(u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())) +} + +fn read_compact_size(bytes: &[u8], offset: usize) -> Result<(usize, usize), ApiError> { + let first = *bytes.get(offset).ok_or_else(|| ApiError::Json { + reason: "Unexpected end of compact size".to_string(), + })?; + if first < 0xfd { + return Ok((first as usize, offset + 1)); + } + if first == 0xfd { + if offset + 3 > bytes.len() { + return Err(ApiError::Json { + reason: "Unexpected end of compact size".to_string(), + }); + } + return Ok(( + u16::from_le_bytes(bytes[offset + 1..offset + 3].try_into().unwrap()) as usize, + offset + 3, + )); + } + if first == 0xfe { + return Ok((read_u32_le(bytes, offset + 1)? as usize, offset + 5)); + } + + let value = read_u64_le(bytes, offset + 1)?; + if value > usize::MAX as u64 { + return Err(ApiError::Json { + reason: "Compact size is too large".to_string(), + }); + } + Ok((value as usize, offset + 9)) +} + +#[derive(Default)] +struct ParsedTransaction { + input_count: usize, + prevout_vouts: Vec, + output_total_sats: i64, + outputs: Vec, +} + +fn parse_transaction(bytes: &[u8]) -> Result { + let mut offset = 4; + let (mut input_count, next) = read_compact_size(bytes, offset)?; + offset = next; + if input_count == 0 && offset < bytes.len() { + offset += 1; + let input_count_info = read_compact_size(bytes, offset)?; + input_count = input_count_info.0; + offset = input_count_info.1; + } + + let mut prevout_vouts = Vec::new(); + for _ in 0..input_count { + offset += 32; + let vout = read_u32_le(bytes, offset)? as usize; + offset += 4; + let (script_len, next) = read_compact_size(bytes, offset)?; + offset = next + script_len + 4; + if offset > bytes.len() { + return Err(ApiError::Json { + reason: "Unexpected end of transaction input".to_string(), + }); + } + prevout_vouts.push(vout); + } + + let (output_count, next) = read_compact_size(bytes, offset)?; + offset = next; + let mut outputs = Vec::new(); + let mut output_total_sats = 0; + for _ in 0..output_count { + let amount_sats = read_u64_le(bytes, offset)? as i64; + offset += 8; + let (script_len, next) = read_compact_size(bytes, offset)?; + offset = next + script_len; + if offset > bytes.len() { + return Err(ApiError::Json { + reason: "Unexpected end of transaction output".to_string(), + }); + } + outputs.push(amount_sats); + output_total_sats += amount_sats; + } + + Ok(ParsedTransaction { + input_count, + prevout_vouts, + output_total_sats, + outputs, + }) +} + +fn parse_psbt_map(bytes: &[u8], mut offset: usize) -> Result<(Vec<(Vec, Vec)>, usize), ApiError> { + let mut entries = Vec::new(); + while offset < bytes.len() { + let (key_len, next) = read_compact_size(bytes, offset)?; + offset = next; + if key_len == 0 { + return Ok((entries, offset)); + } + if offset + key_len > bytes.len() { + return Err(ApiError::Json { + reason: "Unexpected end of PSBT key".to_string(), + }); + } + let key = bytes[offset..offset + key_len].to_vec(); + offset += key_len; + let (value_len, next) = read_compact_size(bytes, offset)?; + offset = next; + if offset + value_len > bytes.len() { + return Err(ApiError::Json { + reason: "Unexpected end of PSBT value".to_string(), + }); + } + let value = bytes[offset..offset + value_len].to_vec(); + offset += value_len; + entries.push((key, value)); + } + + Err(ApiError::Json { + reason: "Unterminated PSBT map".to_string(), + }) +} + +fn parse_psbt_fee_sats(psbt: Option<&String>) -> Option { + let psbt = psbt?; + let bytes = base64::decode(psbt).ok()?; + if bytes.len() < 5 || &bytes[0..5] != b"psbt\xff" { + return None; + } + + let (global_entries, mut offset) = parse_psbt_map(&bytes, 5).ok()?; + let unsigned_tx = global_entries + .iter() + .find(|(key, _)| key.first() == Some(&0x00)) + .map(|(_, value)| value.clone())?; + let tx = parse_transaction(&unsigned_tx).ok()?; + let mut input_total_sats = 0; + + for index in 0..tx.input_count { + let (input_entries, next) = parse_psbt_map(&bytes, offset).ok()?; + offset = next; + + if let Some((_, witness_utxo)) = input_entries + .iter() + .find(|(key, _)| key.first() == Some(&0x01)) + { + input_total_sats += read_u64_le(witness_utxo, 0).ok()? as i64; + continue; + } + + if let Some((_, non_witness_utxo)) = input_entries + .iter() + .find(|(key, _)| key.first() == Some(&0x00)) + { + let prev_tx = parse_transaction(non_witness_utxo).ok()?; + let vout = *tx.prevout_vouts.get(index)?; + if let Some(amount_sats) = prev_tx.outputs.get(vout) { + input_total_sats += amount_sats; + } + } + } + + let fee_sats = input_total_sats - tx.output_total_sats; + if input_total_sats > 0 && fee_sats >= 0 { + Some(fee_sats) + } else { + None + } +} + pub async fn get_info(config: ClnConfig) -> Result { let req_url = format!("{}/v1/getinfo", config.url); let client = clnrest_client(&config); @@ -314,6 +651,151 @@ pub async fn pay_invoice( }) } +pub async fn prepare_onchain_transaction( + config: ClnConfig, + params: PrepareOnchainTransactionParams, +) -> Result { + assert_valid_onchain_amount(params.amount_sats)?; + + let fee = params.fee.clone().unwrap_or_else(default_onchain_fee); + let fee_payer = resolve_cln_fee_payer(params.fee_payer.clone())?; + let fee_request = resolve_cln_fee_request(&fee)?; + let client = clnrest_client(&config); + let txprepare_url = format!("{}/v1/txprepare", config.url); + let mut output = serde_json::Map::new(); + output.insert( + params.address.clone(), + json!(format!("{}sat", params.amount_sats)), + ); + let mut body = json!({ + "outputs": [output], + }); + if let Some(feerate) = fee_request.feerate.clone() { + body["feerate"] = json!(feerate); + } + + let response = client + .post(&txprepare_url) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| ApiError::Http { + reason: format!("Failed to prepare CLN on-chain transaction: {}", e), + })?; + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!( + "Failed to prepare CLN on-chain transaction: {} - {}", + status, error_text + ), + }); + } + + let response_text = response.text().await.unwrap_or_default(); + let txprepare: TxPrepareResponse = serde_json::from_str(&response_text)?; + let fee_sats = parse_psbt_fee_sats(txprepare.psbt.as_ref()); + + Ok(OnchainTransaction { + id: txprepare.txid.clone(), + address: params.address, + amount_sats: params.amount_sats, + fee_sats, + total_amount_sats: fee_sats.map(|fee_sats| params.amount_sats + fee_sats), + recipient_amount_sats: Some(params.amount_sats), + fee_payer, + fee, + expires_at: None, + estimated_delivery_seconds: None, + raw: Some( + json!({ + "txPrepare": { + "psbt": txprepare.psbt, + "unsigned_tx": txprepare.unsigned_tx, + "txid": txprepare.txid, + }, + "txSendRequest": { + "txid": txprepare.txid, + }, + "feeRequest": { + "feerate": fee_request.feerate, + }, + "description": params.description, + }) + .to_string(), + ), + }) +} + +pub async fn pay_onchain( + config: ClnConfig, + transaction: OnchainTransaction, +) -> Result { + pay_onchain_with_options(config, transaction, PayOnchainOptions::default()).await +} + +pub async fn pay_onchain_with_options( + config: ClnConfig, + transaction: OnchainTransaction, + options: PayOnchainOptions, +) -> Result { + assert_valid_onchain_amount(transaction.amount_sats)?; + let _fee_payer = resolve_cln_fee_payer(Some(transaction.fee_payer.clone()))?; + let _fee_request = resolve_cln_fee_request(&transaction.fee)?; + assert_onchain_fee_guardrail(&transaction, options)?; + let txid = transaction.id.clone().ok_or_else(|| { + ApiError::InvalidInput( + "CLN pay_onchain requires a transaction id from prepare_onchain_transaction" + .to_string(), + ) + })?; + + let client = clnrest_client(&config); + let txsend_url = format!("{}/v1/txsend", config.url); + let response = client + .post(&txsend_url) + .header("Content-Type", "application/json") + .json(&json!({ "txid": txid })) + .send() + .await + .map_err(|e| ApiError::Http { + reason: format!("Failed to broadcast CLN on-chain transaction: {}", e), + })?; + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!( + "Failed to broadcast CLN on-chain transaction: {} - {}", + status, error_text + ), + }); + } + + let response_text = response.text().await.unwrap_or_default(); + let txsend: TxSendResponse = serde_json::from_str(&response_text)?; + let response_txid = txsend.txid.clone().or(transaction.id.clone()); + + Ok(PayOnchainResponse { + payment_id: transaction.id, + txid: response_txid.clone(), + state: if response_txid.is_some() { + "pending".to_string() + } else { + "failed".to_string() + }, + address: transaction.address, + amount_sats: transaction.amount_sats, + fee_sats: transaction.fee_sats, + total_amount_sats: transaction.total_amount_sats, + recipient_amount_sats: transaction.recipient_amount_sats.or(Some(transaction.amount_sats)), + created_at: None, + raw: Some(response_text), + }) +} + pub fn decode(str: String) -> Result { crate::utils::decode_bolt11(str) } diff --git a/crates/lni/cln/lib.rs b/crates/lni/cln/lib.rs index a64cb77..67a9620 100644 --- a/crates/lni/cln/lib.rs +++ b/crates/lni/cln/lib.rs @@ -4,7 +4,8 @@ use napi_derive::napi; use crate::types::NodeInfo; use crate::{ ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, Offer, - PayInvoiceParams, PayInvoiceResponse, Transaction, + OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, + PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, }; #[cfg(not(feature = "uniffi"))] use crate::LightningNode; @@ -97,6 +98,28 @@ impl ClnNode { crate::cln::api::pay_invoice(self.config.clone(), params).await } + pub async fn prepare_onchain_transaction( + &self, + params: PrepareOnchainTransactionParams, + ) -> Result { + crate::cln::api::prepare_onchain_transaction(self.config.clone(), params).await + } + + pub async fn pay_onchain( + &self, + transaction: OnchainTransaction, + ) -> Result { + crate::cln::api::pay_onchain(self.config.clone(), transaction).await + } + + pub async fn pay_onchain_with_options( + &self, + transaction: OnchainTransaction, + options: PayOnchainOptions, + ) -> Result { + crate::cln::api::pay_onchain_with_options(self.config.clone(), transaction, options).await + } + pub async fn create_offer(&self, params: CreateOfferParams) -> Result { crate::cln::api::create_offer(self.config.clone(), params).await } @@ -168,7 +191,10 @@ crate::impl_lightning_node!(ClnNode); #[cfg(test)] mod tests { - use crate::InvoiceType; + use crate::{ + InvoiceType, OnchainFeePreference, OnchainFeePreferenceType, OnchainFeeSpeed, + PrepareOnchainTransactionParams, + }; use super::*; use dotenv::dotenv; @@ -228,6 +254,78 @@ mod tests { } } + #[tokio::test] + async fn test_pay_onchain_e2e() { + let address = env::var("CLN_ONCHAIN_TEST_ADDRESS") + .expect("CLN_ONCHAIN_TEST_ADDRESS must be set"); + let amount_sats = env::var("CLN_ONCHAIN_AMOUNT_SATS") + .expect("CLN_ONCHAIN_AMOUNT_SATS must be set") + .parse::() + .expect("CLN_ONCHAIN_AMOUNT_SATS must be a positive integer"); + assert!(amount_sats > 0, "CLN_ONCHAIN_AMOUNT_SATS must be positive"); + + let transaction = NODE + .prepare_onchain_transaction(PrepareOnchainTransactionParams { + address, + amount_sats, + fee: Some(OnchainFeePreference { + preference_type: OnchainFeePreferenceType::Speed, + speed: Some(OnchainFeeSpeed::Normal), + target_conf: None, + sats_per_vbyte: None, + backend: None, + }), + fee_payer: None, + description: Some("cln rust onchain e2e".to_string()), + idempotency_key: None, + }) + .await + .expect("prepare_onchain_transaction should create a CLN on-chain transaction"); + + assert!( + transaction.id.as_ref().map(|id| !id.is_empty()).unwrap_or(false), + "CLN prepared on-chain transaction should include a txid" + ); + assert!( + transaction.fee_sats.unwrap_or(-1) >= 0, + "CLN prepared on-chain transaction should include a non-negative fee quote" + ); + + if env::var("CLN_RUN_ONCHAIN_SEND").ok().as_deref() != Some("true") + || env::var("CLN_ONCHAIN_SEND_CONFIRM").ok().as_deref() + != Some("I_UNDERSTAND_THIS_BROADCASTS_BITCOIN") + { + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("test client should build"); + let discard_url = format!("{}/v1/txdiscard", URL.as_str()); + let response = client + .post(discard_url) + .header("Rune", RUNE.as_str()) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ "txid": transaction.id.clone() })) + .send() + .await + .expect("txdiscard should send"); + assert!( + response.status().is_success(), + "txdiscard should release prepared CLN inputs" + ); + println!("Prepared CLN on-chain transaction; discarded without explicit broadcast confirmation"); + return; + } + + let payment = NODE + .pay_onchain(transaction) + .await + .expect("pay_onchain should execute CLN txsend"); + assert!( + payment.txid.as_ref().map(|txid| !txid.is_empty()).unwrap_or(false), + "CLN on-chain payment should include a txid" + ); + } + #[tokio::test] async fn test_create_invoice() { let amount_msats = 3000; diff --git a/crates/lni/lnd/api.rs b/crates/lni/lnd/api.rs index 62d3fbe..bbf3342 100644 --- a/crates/lni/lnd/api.rs +++ b/crates/lni/lnd/api.rs @@ -8,7 +8,9 @@ use super::LndConfig; use crate::types::NodeInfo; use crate::{ ApiError, CreateInvoiceParams, Offer, OnInvoiceEventCallback, - OnInvoiceEventParams, PayInvoiceParams, PayInvoiceResponse, Transaction, + OnInvoiceEventParams, OnchainFeePayer, OnchainFeePreference, OnchainFeePreferenceType, + OnchainFeeSpeed, OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, + PayOnchainOptions, PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, DEFAULT_INVOICE_EXPIRY, }; use reqwest::header; @@ -18,6 +20,23 @@ use serde_json::json; // Docs // https://lightning.engineering/api-docs/api/lnd/rest-endpoints/ +#[derive(Debug, Deserialize)] +struct EstimateFeeResponse { + fee_sat: Option, + sat_per_vbyte: Option, +} + +#[derive(Debug, Deserialize)] +struct SendCoinsResponse { + txid: Option, +} + +#[derive(Clone)] +struct LndOnchainFeeRequest { + target_conf: Option, + sat_per_vbyte: Option, +} + fn async_client(config: &LndConfig) -> reqwest::Client { let mut headers = reqwest::header::HeaderMap::new(); headers.insert( @@ -56,6 +75,183 @@ fn async_client(config: &LndConfig) -> reqwest::Client { client_builder.build().unwrap_or_else(|_| reqwest::Client::new()) } +fn assert_valid_onchain_amount(amount_sats: i64) -> Result<(), ApiError> { + if amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "pay_onchain requires a positive amount_sats".to_string(), + )); + } + + Ok(()) +} + +fn default_onchain_fee() -> OnchainFeePreference { + OnchainFeePreference { + preference_type: OnchainFeePreferenceType::TargetConf, + speed: None, + target_conf: Some(6), + sats_per_vbyte: None, + backend: None, + } +} + +fn resolve_lnd_fee_payer(fee_payer: Option) -> Result { + match fee_payer.unwrap_or(OnchainFeePayer::Sender) { + OnchainFeePayer::Sender => Ok(OnchainFeePayer::Sender), + OnchainFeePayer::Recipient => Err(ApiError::InvalidInput( + "LND pay_onchain only supports sender-paid on-chain fees".to_string(), + )), + } +} + +fn resolve_lnd_fee_request(fee: &OnchainFeePreference) -> Result { + match fee.preference_type { + OnchainFeePreferenceType::Default => Ok(LndOnchainFeeRequest { + target_conf: Some(6), + sat_per_vbyte: None, + }), + OnchainFeePreferenceType::Speed => match fee.speed.clone().unwrap_or(OnchainFeeSpeed::Normal) { + OnchainFeeSpeed::Fast => Ok(LndOnchainFeeRequest { + target_conf: Some(1), + sat_per_vbyte: None, + }), + OnchainFeeSpeed::Normal => Ok(LndOnchainFeeRequest { + target_conf: Some(6), + sat_per_vbyte: None, + }), + OnchainFeeSpeed::Slow => Ok(LndOnchainFeeRequest { + target_conf: Some(12), + sat_per_vbyte: None, + }), + OnchainFeeSpeed::Free => Err(ApiError::InvalidInput( + "LND pay_onchain does not support free on-chain fee speed".to_string(), + )), + }, + OnchainFeePreferenceType::TargetConf => { + let blocks = fee.target_conf.ok_or_else(|| { + ApiError::InvalidInput( + "LND target_conf fee preference requires a block target".to_string(), + ) + })?; + if blocks <= 0 { + return Err(ApiError::InvalidInput( + "LND target_conf fee preference requires a positive block target".to_string(), + )); + } + Ok(LndOnchainFeeRequest { + target_conf: Some(blocks), + sat_per_vbyte: None, + }) + } + OnchainFeePreferenceType::SatsPerVbyte => { + let sats_per_vbyte = fee.sats_per_vbyte.ok_or_else(|| { + ApiError::InvalidInput( + "LND sats_per_vbyte fee preference requires a fee rate".to_string(), + ) + })?; + if !sats_per_vbyte.is_finite() || sats_per_vbyte <= 0.0 { + return Err(ApiError::InvalidInput( + "LND sats_per_vbyte fee preference requires a positive fee rate".to_string(), + )); + } + Ok(LndOnchainFeeRequest { + target_conf: None, + sat_per_vbyte: Some(sats_per_vbyte.ceil() as i64), + }) + } + OnchainFeePreferenceType::Backend => Err(ApiError::InvalidInput( + "LND pay_onchain does not support backend fee preferences".to_string(), + )), + } +} + +fn assert_valid_guardrail_limit(value: f64, name: &str) -> Result<(), ApiError> { + if !value.is_finite() || value < 0.0 { + return Err(ApiError::InvalidInput(format!( + "{} must be a non-negative finite number", + name + ))); + } + + Ok(()) +} + +fn assert_onchain_fee_guardrail( + transaction: &OnchainTransaction, + options: PayOnchainOptions, +) -> Result<(), ApiError> { + if options.dangerously_disable_fee_guardrail { + return Ok(()); + } + + let default_guardrail = crate::types::OnchainFeeGuardrail::default(); + let guardrail = options.fee_guardrail.unwrap_or_default(); + let max_fee_sats = guardrail + .max_fee_sats + .or(default_guardrail.max_fee_sats) + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_SATS); + let max_fee_percent = guardrail + .max_fee_percent + .or(default_guardrail.max_fee_percent) + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_PERCENT); + + if max_fee_sats < 0 { + return Err(ApiError::InvalidInput( + "fee_guardrail.max_fee_sats must be non-negative".to_string(), + )); + } + assert_valid_guardrail_limit(max_fee_percent, "fee_guardrail.max_fee_percent")?; + + let fee_sats = transaction.fee_sats.ok_or_else(|| { + ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is unknown. Re-prepare the transaction or pass dangerously_disable_fee_guardrail: true.".to_string(), + ) + })?; + + if fee_sats < 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is invalid".to_string(), + )); + } + + if transaction.amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because amount_sats is invalid".to_string(), + )); + } + + if fee_sats > max_fee_sats { + return Err(ApiError::InvalidInput(format!( + "On-chain fee {} sats exceeds guardrail max_fee_sats {}", + fee_sats, max_fee_sats + ))); + } + + let fee_percent = (fee_sats as f64 / transaction.amount_sats as f64) * 100.0; + if fee_percent > max_fee_percent { + return Err(ApiError::InvalidInput(format!( + "On-chain fee {:.2}% exceeds guardrail max_fee_percent {}%", + fee_percent, max_fee_percent + ))); + } + + Ok(()) +} + +fn parse_optional_fee_sats(value: Option) -> Option { + value.and_then(|value| value.parse::().ok()).filter(|fee| *fee >= 0) +} + +fn raw_label(transaction: &OnchainTransaction) -> Option { + let raw = transaction.raw.as_deref()?; + let value: serde_json::Value = serde_json::from_str(raw).ok()?; + value + .get("label") + .and_then(|label| label.as_str()) + .filter(|label| !label.is_empty()) + .map(|label| label.to_string()) +} + // Core shared logic for processing LND node info and balance responses fn process_node_info_responses( info: GetInfoResponse, @@ -590,6 +786,164 @@ pub async fn pay_invoice( Ok(pay_response) } +pub async fn prepare_onchain_transaction( + config: LndConfig, + params: PrepareOnchainTransactionParams, +) -> Result { + assert_valid_onchain_amount(params.amount_sats)?; + + let fee = params.fee.clone().unwrap_or_else(default_onchain_fee); + let fee_payer = resolve_lnd_fee_payer(params.fee_payer.clone())?; + let fee_request = resolve_lnd_fee_request(&fee)?; + + if fee_request.sat_per_vbyte.is_some() { + return Ok(OnchainTransaction { + id: None, + address: params.address, + amount_sats: params.amount_sats, + fee_sats: None, + total_amount_sats: None, + recipient_amount_sats: Some(params.amount_sats), + fee_payer, + fee, + expires_at: None, + estimated_delivery_seconds: None, + raw: Some( + serde_json::json!({ + "send_request": { + "sat_per_vbyte": fee_request + .sat_per_vbyte + .map(|sat_per_vbyte| sat_per_vbyte.to_string()), + }, + "label": params.description, + }) + .to_string(), + ), + }); + } + + let client = async_client(&config); + let fee_url = format!("{}/v1/transactions/fee", config.url); + let addr_amount_key = format!("AddrToAmount[{}]", params.address); + let mut query = vec![(addr_amount_key, params.amount_sats.to_string())]; + if let Some(target_conf) = fee_request.target_conf { + query.push(("target_conf".to_string(), target_conf.to_string())); + } + + let response = client + .get(&fee_url) + .query(&query) + .send() + .await + .map_err(|e| ApiError::Http { + reason: format!("Failed to estimate LND on-chain fee: {}", e), + })?; + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!("Failed to estimate LND on-chain fee: {} - {}", status, error_text), + }); + } + + let response_text = response.text().await.unwrap_or_default(); + let estimate: EstimateFeeResponse = serde_json::from_str(&response_text)?; + let fee_sats = parse_optional_fee_sats(estimate.fee_sat.clone()); + + Ok(OnchainTransaction { + id: None, + address: params.address, + amount_sats: params.amount_sats, + fee_sats, + total_amount_sats: fee_sats.map(|fee_sats| params.amount_sats + fee_sats), + recipient_amount_sats: Some(params.amount_sats), + fee_payer, + fee, + expires_at: None, + estimated_delivery_seconds: fee_request.target_conf.map(|blocks| blocks * 10 * 60), + raw: Some( + serde_json::json!({ + "estimate": { + "fee_sat": estimate.fee_sat, + "sat_per_vbyte": estimate.sat_per_vbyte, + }, + "label": params.description, + }) + .to_string(), + ), + }) +} + +pub async fn pay_onchain( + config: LndConfig, + transaction: OnchainTransaction, +) -> Result { + pay_onchain_with_options(config, transaction, PayOnchainOptions::default()).await +} + +pub async fn pay_onchain_with_options( + config: LndConfig, + transaction: OnchainTransaction, + options: PayOnchainOptions, +) -> Result { + assert_valid_onchain_amount(transaction.amount_sats)?; + let _fee_payer = resolve_lnd_fee_payer(Some(transaction.fee_payer.clone()))?; + let fee_request = resolve_lnd_fee_request(&transaction.fee)?; + assert_onchain_fee_guardrail(&transaction, options)?; + + let client = async_client(&config); + let send_url = format!("{}/v1/transactions", config.url); + let mut body = json!({ + "addr": transaction.address.clone(), + "amount": transaction.amount_sats, + }); + if let Some(target_conf) = fee_request.target_conf { + body["target_conf"] = json!(target_conf); + } + if let Some(sat_per_vbyte) = fee_request.sat_per_vbyte { + body["sat_per_vbyte"] = json!(sat_per_vbyte.to_string()); + } + if let Some(label) = raw_label(&transaction) { + body["label"] = json!(label); + } + + let response = client + .post(&send_url) + .json(&body) + .send() + .await + .map_err(|e| ApiError::Http { + reason: format!("Failed to broadcast LND on-chain transaction: {}", e), + })?; + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!("Failed to broadcast LND on-chain transaction: {} - {}", status, error_text), + }); + } + + let response_text = response.text().await.unwrap_or_default(); + let send_response: SendCoinsResponse = serde_json::from_str(&response_text)?; + + Ok(PayOnchainResponse { + payment_id: None, + txid: send_response.txid.clone(), + state: if send_response.txid.is_some() { + "pending".to_string() + } else { + "failed".to_string() + }, + address: transaction.address, + amount_sats: transaction.amount_sats, + fee_sats: transaction.fee_sats, + total_amount_sats: transaction.total_amount_sats, + recipient_amount_sats: transaction.recipient_amount_sats.or(Some(transaction.amount_sats)), + created_at: None, + raw: Some(response_text), + }) +} + #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn decode(invoice_str: String) -> Result { crate::utils::decode_bolt11(invoice_str) @@ -691,3 +1045,47 @@ fn parse_r_preimage(r_preimage_str: &str) -> String { } } } + +#[cfg(test)] +mod onchain_tests { + use super::*; + + #[tokio::test] + async fn prepare_manual_sat_per_vbyte_without_fee_estimate() { + let transaction = prepare_onchain_transaction( + LndConfig { + url: "https://lnd.test".to_string(), + macaroon: "00".to_string(), + socks5_proxy: None, + accept_invalid_certs: Some(true), + http_timeout: Some(1), + }, + PrepareOnchainTransactionParams { + address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), + amount_sats: 10_000, + fee: Some(OnchainFeePreference { + preference_type: OnchainFeePreferenceType::SatsPerVbyte, + speed: None, + target_conf: None, + sats_per_vbyte: Some(5.0), + backend: None, + }), + fee_payer: Some(OnchainFeePayer::Sender), + description: Some("cold storage".to_string()), + idempotency_key: None, + }, + ) + .await + .expect("manual sats/vbyte prepare should not call LND fee estimate"); + + assert_eq!(transaction.amount_sats, 10_000); + assert_eq!(transaction.recipient_amount_sats, Some(10_000)); + assert_eq!(transaction.fee_sats, None); + assert_eq!(transaction.total_amount_sats, None); + + let raw: serde_json::Value = + serde_json::from_str(transaction.raw.as_deref().unwrap_or_default()).unwrap(); + assert_eq!(raw["send_request"]["sat_per_vbyte"], "5"); + assert_eq!(raw["label"], "cold storage"); + } +} diff --git a/crates/lni/lnd/lib.rs b/crates/lni/lnd/lib.rs index 6fe86f9..8dca207 100644 --- a/crates/lni/lnd/lib.rs +++ b/crates/lni/lnd/lib.rs @@ -4,7 +4,8 @@ use napi_derive::napi; use crate::types::NodeInfo; use crate::{ ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, - Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, + Offer, OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, + PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, }; #[cfg(not(feature = "uniffi"))] use crate::LightningNode; @@ -82,6 +83,28 @@ impl LndNode { crate::lnd::api::pay_invoice(self.config.clone(), params).await } + pub async fn prepare_onchain_transaction( + &self, + params: PrepareOnchainTransactionParams, + ) -> Result { + crate::lnd::api::prepare_onchain_transaction(self.config.clone(), params).await + } + + pub async fn pay_onchain( + &self, + transaction: OnchainTransaction, + ) -> Result { + crate::lnd::api::pay_onchain(self.config.clone(), transaction).await + } + + pub async fn pay_onchain_with_options( + &self, + transaction: OnchainTransaction, + options: PayOnchainOptions, + ) -> Result { + crate::lnd::api::pay_onchain_with_options(self.config.clone(), transaction, options).await + } + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { Err(ApiError::Api { reason: "create_offer not implemented for LndNode".to_string() }) } @@ -153,7 +176,10 @@ crate::impl_lightning_node!(LndNode); #[cfg(test)] mod tests { - use crate::{InvoiceType, PayInvoiceParams}; + use crate::{ + InvoiceType, OnchainFeePayer, OnchainFeePreference, OnchainFeePreferenceType, + OnchainFeeSpeed, PayInvoiceParams, PrepareOnchainTransactionParams, + }; use super::*; use dotenv::dotenv; @@ -163,6 +189,8 @@ mod tests { use std::env; use std::sync::{Arc, Mutex}; + const ONCHAIN_SEND_CONFIRMATION: &str = "I_UNDERSTAND_THIS_BROADCASTS_BITCOIN"; + lazy_static! { static ref URL: String = { dotenv().ok(); @@ -309,6 +337,69 @@ mod tests { } } + #[tokio::test] + async fn test_pay_onchain_e2e() { + dotenv().ok(); + + let address = env::var("LND_ONCHAIN_TEST_ADDRESS") + .expect("LND_ONCHAIN_TEST_ADDRESS must be set"); + let amount_sats = env::var("LND_ONCHAIN_AMOUNT_SATS") + .unwrap_or_else(|_| "10000".to_string()) + .parse::() + .expect("LND_ONCHAIN_AMOUNT_SATS must be a positive integer"); + assert!( + amount_sats > 0, + "LND_ONCHAIN_AMOUNT_SATS must be positive" + ); + + let transaction = NODE + .prepare_onchain_transaction(PrepareOnchainTransactionParams { + address: address.clone(), + amount_sats, + fee: Some(OnchainFeePreference { + preference_type: OnchainFeePreferenceType::Speed, + speed: Some(OnchainFeeSpeed::Normal), + target_conf: None, + sats_per_vbyte: None, + backend: None, + }), + fee_payer: Some(OnchainFeePayer::Sender), + description: Some("lnd rust onchain e2e".to_string()), + idempotency_key: None, + }) + .await + .expect("prepare_onchain_transaction should create an LND on-chain quote"); + + assert_eq!(transaction.address, address); + assert_eq!(transaction.amount_sats, amount_sats); + assert_eq!(transaction.fee_payer, OnchainFeePayer::Sender); + assert!( + transaction.fee_sats.map_or(false, |fee_sats| fee_sats >= 0), + "on-chain transaction should include a non-negative fee quote" + ); + + if env::var("LND_RUN_ONCHAIN_SEND").ok().as_deref() != Some("true") + || env::var("LND_ONCHAIN_SEND_CONFIRM").ok().as_deref() + != Some(ONCHAIN_SEND_CONFIRMATION) + { + println!("Prepared LND on-chain quote; skipping broadcast without explicit confirmation"); + return; + } + + let payment = NODE + .pay_onchain(transaction) + .await + .expect("pay_onchain should execute LND on-chain send"); + + assert_eq!(payment.address, address); + assert_eq!(payment.amount_sats, amount_sats); + assert_eq!(payment.state, "pending"); + assert!( + payment.txid.as_ref().map_or(false, |txid| !txid.is_empty()), + "LND on-chain payment should include a txid" + ); + } + #[tokio::test] async fn test_pay_invoice() { match NODE diff --git a/crates/lni/nwc/lib.rs b/crates/lni/nwc/lib.rs index 3c67b21..a0a42bc 100644 --- a/crates/lni/nwc/lib.rs +++ b/crates/lni/nwc/lib.rs @@ -164,6 +164,35 @@ mod tests { }; } + #[tokio::test] + async fn test_get_permissions() { + dotenv().ok(); + let nwc_uri = match env::var("NWC_URI") { + Ok(nwc_uri) => nwc_uri, + Err(_) => { + eprintln!("Skipping NWC permissions test because NWC_URI is not set"); + return; + } + }; + let node = NwcNode::new(NwcConfig { + nwc_uri, + ..Default::default() + }); + + match node.get_permissions().await { + Ok(permissions) => { + dbg!(&permissions); + assert!( + !permissions.is_empty(), + "NWC permissions should include at least one supported method" + ); + } + Err(e) => { + panic!("Failed to get NWC permissions: {:?}", e); + } + } + } + #[tokio::test] async fn test_get_info() { match NODE.get_info().await { diff --git a/crates/lni/phoenixd/api.rs b/crates/lni/phoenixd/api.rs index 8225d07..872f2cc 100644 --- a/crates/lni/phoenixd/api.rs +++ b/crates/lni/phoenixd/api.rs @@ -6,7 +6,9 @@ use super::PhoenixdConfig; use crate::ListTransactionsParams; use crate::{ phoenixd::types::GetBalanceResponse, ApiError, CreateOfferParams, InvoiceType, NodeInfo, Offer, OnInvoiceEventCallback, - OnInvoiceEventParams, PayInvoiceParams, PayInvoiceResponse, Transaction, + OnInvoiceEventParams, OnchainFeePayer, OnchainFeePreference, OnchainFeePreferenceType, + OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, + PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, }; use lightning_invoice::Bolt11Invoice; use serde_urlencoded; @@ -20,6 +22,10 @@ use tokio::time::sleep; // https://phoenix.acinq.co/server/api +struct PhoenixdOnchainFeeRequest { + feerate_sat_byte: i64, +} + fn client(config: &PhoenixdConfig) -> reqwest::Client { // Create HTTP client with optional SOCKS5 proxy following LND pattern if let Some(proxy_url) = config.socks5_proxy.clone() { @@ -55,6 +61,125 @@ fn client(config: &PhoenixdConfig) -> reqwest::Client { client_builder.build().unwrap_or_else(|_| reqwest::Client::new()) } +fn assert_valid_onchain_amount(amount_sats: i64) -> Result<(), ApiError> { + if amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "pay_onchain requires a positive amount_sats".to_string(), + )); + } + + Ok(()) +} + +fn resolve_phoenixd_fee_payer( + fee_payer: Option, +) -> Result { + match fee_payer.unwrap_or(OnchainFeePayer::Sender) { + OnchainFeePayer::Sender => Ok(OnchainFeePayer::Sender), + OnchainFeePayer::Recipient => Err(ApiError::InvalidInput( + "Phoenixd pay_onchain only supports sender-paid on-chain fees".to_string(), + )), + } +} + +fn resolve_phoenixd_fee_request( + fee: &OnchainFeePreference, +) -> Result { + match fee.preference_type { + OnchainFeePreferenceType::SatsPerVbyte => { + let sats_per_vbyte = fee.sats_per_vbyte.ok_or_else(|| { + ApiError::InvalidInput( + "Phoenixd sats_per_vbyte fee preference requires a fee rate".to_string(), + ) + })?; + if sats_per_vbyte <= 0.0 { + return Err(ApiError::InvalidInput( + "Phoenixd sats_per_vbyte fee preference requires a positive fee rate".to_string(), + )); + } + + Ok(PhoenixdOnchainFeeRequest { + feerate_sat_byte: sats_per_vbyte.ceil() as i64, + }) + } + OnchainFeePreferenceType::Backend => { + let feerate = fee + .backend + .clone() + .unwrap_or_default() + .parse::() + .map_err(|_| { + ApiError::InvalidInput( + "Phoenixd backend fee preference must be a positive integer feerateSatByte value".to_string(), + ) + })?; + if feerate <= 0 { + return Err(ApiError::InvalidInput( + "Phoenixd backend fee preference must be a positive integer feerateSatByte value".to_string(), + )); + } + + Ok(PhoenixdOnchainFeeRequest { + feerate_sat_byte: feerate, + }) + } + OnchainFeePreferenceType::Default + | OnchainFeePreferenceType::Speed + | OnchainFeePreferenceType::TargetConf => Err(ApiError::InvalidInput( + "Phoenixd pay_onchain requires an explicit sats_per_vbyte fee preference".to_string(), + )), + } +} + +fn assert_onchain_fee_guardrail( + transaction: &OnchainTransaction, + options: PayOnchainOptions, +) -> Result<(), ApiError> { + if options.dangerously_disable_fee_guardrail { + return Ok(()); + } + + let guardrail = options.fee_guardrail.unwrap_or_default(); + let max_fee_sats = guardrail + .max_fee_sats + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_SATS); + let max_fee_percent = guardrail + .max_fee_percent + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_PERCENT); + let fee_sats = transaction.fee_sats.ok_or_else(|| { + ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is unknown. Re-prepare the transaction or pass dangerously_disable_fee_guardrail: true.".to_string(), + ) + })?; + if fee_sats < 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is invalid".to_string(), + )); + } + if transaction.amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because amount_sats is invalid".to_string(), + )); + } + + let max_fee_by_percent = + ((transaction.amount_sats as f64) * max_fee_percent / 100.0).floor() as i64; + let max_allowed_fee = std::cmp::min(max_fee_sats, max_fee_by_percent); + if fee_sats > max_allowed_fee { + return Err(ApiError::InvalidInput(format!( + "Cannot pay on-chain transaction because fee_sats {} exceeds guardrail {} sats", + fee_sats, max_allowed_fee + ))); + } + + Ok(()) +} + +fn is_txid(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) +} + pub async fn get_info(config: PhoenixdConfig) -> Result { let info_url = format!("{}/getinfo", config.url); let client = client(&config); @@ -255,6 +380,110 @@ pub async fn pay_invoice( }) } +pub async fn prepare_onchain_transaction( + _config: PhoenixdConfig, + params: PrepareOnchainTransactionParams, +) -> Result { + assert_valid_onchain_amount(params.amount_sats)?; + + let fee = params.fee.clone().unwrap_or_default(); + let fee_payer = resolve_phoenixd_fee_payer(params.fee_payer.clone())?; + let fee_request = resolve_phoenixd_fee_request(&fee)?; + + Ok(OnchainTransaction { + id: None, + address: params.address.clone(), + amount_sats: params.amount_sats, + fee_sats: None, + total_amount_sats: None, + recipient_amount_sats: Some(params.amount_sats), + fee_payer, + fee, + expires_at: None, + estimated_delivery_seconds: None, + raw: Some( + serde_json::json!({ + "sendRequest": { + "address": params.address, + "amountSat": params.amount_sats, + "feerateSatByte": fee_request.feerate_sat_byte, + }, + "description": params.description, + }) + .to_string(), + ), + }) +} + +pub async fn pay_onchain( + config: PhoenixdConfig, + transaction: OnchainTransaction, +) -> Result { + pay_onchain_with_options(config, transaction, PayOnchainOptions::default()).await +} + +pub async fn pay_onchain_with_options( + config: PhoenixdConfig, + transaction: OnchainTransaction, + options: PayOnchainOptions, +) -> Result { + assert_valid_onchain_amount(transaction.amount_sats)?; + let _fee_payer = resolve_phoenixd_fee_payer(Some(transaction.fee_payer.clone()))?; + let fee_request = resolve_phoenixd_fee_request(&transaction.fee)?; + assert_onchain_fee_guardrail(&transaction, options)?; + + let client = client(&config); + let req_url = format!("{}/sendtoaddress", config.url); + let response = client + .post(&req_url) + .basic_auth("", Some(config.password.clone())) + .form(&[ + ("address", transaction.address.clone()), + ("amountSat", transaction.amount_sats.to_string()), + ("feerateSatByte", fee_request.feerate_sat_byte.to_string()), + ]) + .send() + .await + .map_err(|e| ApiError::Http { + reason: format!("Failed to broadcast Phoenixd on-chain transaction: {}", e), + })?; + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!( + "Failed to broadcast Phoenixd on-chain transaction: {} - {}", + status, error_text + ), + }); + } + + let response_text = response.text().await.unwrap_or_default(); + let txid = response_text.trim().to_string(); + if !is_txid(&txid) { + return Err(ApiError::Api { + reason: if txid.is_empty() { + "Phoenixd sendtoaddress did not return a txid".to_string() + } else { + txid + }, + }); + } + + Ok(PayOnchainResponse { + payment_id: None, + txid: Some(txid), + state: "pending".to_string(), + address: transaction.address, + amount_sats: transaction.amount_sats, + fee_sats: transaction.fee_sats, + total_amount_sats: transaction.total_amount_sats, + recipient_amount_sats: transaction.recipient_amount_sats.or(Some(transaction.amount_sats)), + created_at: None, + raw: Some(response_text), + }) +} + pub fn decode(str: String) -> Result { crate::utils::decode_bolt11(str) } diff --git a/crates/lni/phoenixd/lib.rs b/crates/lni/phoenixd/lib.rs index 6a74012..7526bef 100644 --- a/crates/lni/phoenixd/lib.rs +++ b/crates/lni/phoenixd/lib.rs @@ -3,6 +3,7 @@ use napi_derive::napi; use crate::{ phoenixd::api::*, ApiError, ListTransactionsParams, PayInvoiceParams, PayInvoiceResponse, + OnchainTransaction, PayOnchainOptions, PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, CreateOfferParams }; #[cfg(not(feature = "uniffi"))] @@ -92,6 +93,28 @@ impl PhoenixdNode { pay_invoice(self.config.clone(), params).await } + pub async fn prepare_onchain_transaction( + &self, + params: PrepareOnchainTransactionParams, + ) -> Result { + crate::phoenixd::api::prepare_onchain_transaction(self.config.clone(), params).await + } + + pub async fn pay_onchain( + &self, + transaction: OnchainTransaction, + ) -> Result { + crate::phoenixd::api::pay_onchain(self.config.clone(), transaction).await + } + + pub async fn pay_onchain_with_options( + &self, + transaction: OnchainTransaction, + options: PayOnchainOptions, + ) -> Result { + crate::phoenixd::api::pay_onchain_with_options(self.config.clone(), transaction, options).await + } + pub async fn create_offer(&self, params: CreateOfferParams) -> Result { crate::phoenixd::api::create_offer(self.config.clone(), params).await } @@ -153,7 +176,10 @@ crate::impl_lightning_node!(PhoenixdNode); #[cfg(test)] mod tests { - use crate::InvoiceType; + use crate::{ + InvoiceType, OnchainFeeGuardrail, OnchainFeePreference, OnchainFeePreferenceType, + PayOnchainOptions, PrepareOnchainTransactionParams, + }; use super::*; use dotenv::dotenv; @@ -201,6 +227,72 @@ mod tests { } } + #[tokio::test] + async fn test_pay_onchain_e2e() { + let address = env::var("PHOENIXD_ONCHAIN_TEST_ADDRESS") + .expect("PHOENIXD_ONCHAIN_TEST_ADDRESS must be set"); + let amount_sats = env::var("PHOENIXD_ONCHAIN_AMOUNT_SATS") + .expect("PHOENIXD_ONCHAIN_AMOUNT_SATS must be set") + .parse::() + .expect("PHOENIXD_ONCHAIN_AMOUNT_SATS must be a positive integer"); + let feerate_sat_byte = env::var("PHOENIXD_ONCHAIN_FEERATE_SAT_BYTE") + .expect("PHOENIXD_ONCHAIN_FEERATE_SAT_BYTE must be set") + .parse::() + .expect("PHOENIXD_ONCHAIN_FEERATE_SAT_BYTE must be a positive number"); + assert!(amount_sats > 0, "PHOENIXD_ONCHAIN_AMOUNT_SATS must be positive"); + assert!( + feerate_sat_byte > 0.0, + "PHOENIXD_ONCHAIN_FEERATE_SAT_BYTE must be positive" + ); + + let transaction = NODE + .prepare_onchain_transaction(PrepareOnchainTransactionParams { + address, + amount_sats, + fee: Some(OnchainFeePreference { + preference_type: OnchainFeePreferenceType::SatsPerVbyte, + speed: None, + target_conf: None, + sats_per_vbyte: Some(feerate_sat_byte), + backend: None, + }), + fee_payer: None, + description: Some("phoenixd rust onchain e2e".to_string()), + idempotency_key: None, + }) + .await + .expect("prepare_onchain_transaction should create a Phoenixd on-chain transaction"); + + assert_eq!(transaction.amount_sats, amount_sats); + assert!( + transaction.fee_sats.is_none(), + "Phoenixd does not quote final on-chain fee_sats" + ); + + if env::var("PHOENIXD_RUN_ONCHAIN_SEND").ok().as_deref() != Some("true") + || env::var("PHOENIXD_ONCHAIN_SEND_CONFIRM").ok().as_deref() + != Some("I_UNDERSTAND_THIS_BROADCASTS_BITCOIN") + { + println!("Prepared Phoenixd on-chain transaction; skipping broadcast without explicit confirmation"); + return; + } + + let payment = NODE + .pay_onchain_with_options( + transaction, + PayOnchainOptions { + fee_guardrail: Some(OnchainFeeGuardrail::default()), + dangerously_disable_fee_guardrail: true, + }, + ) + .await + .expect("pay_onchain should execute Phoenixd sendtoaddress"); + assert!( + payment.txid.as_ref().map(|txid| !txid.is_empty()).unwrap_or(false), + "Phoenixd on-chain payment should include a txid" + ); + } + #[tokio::test] async fn test_create_invoice() { let amount_msats = 1000; diff --git a/readme.md b/readme.md index 98f9d16..2186a20 100644 --- a/readme.md +++ b/readme.md @@ -175,7 +175,7 @@ lnurl::get_payment_info(destination, amount_msats) -> Result PaymentDestination // Auto-detect: bolt11|bolt12|lnurl|lightning_address lnurl::needs_resolution(destination) -> bool // Check if LNURL resolution needed -// On-chain Bitcoin payments (currently implemented for Strike and Blink) +// On-chain Bitcoin payments (currently implemented for Strike, Blink, LND, CLN, and Phoenixd) node.prepare_onchain_transaction(PrepareOnchainTransactionParams) -> Result node.pay_onchain(OnchainTransaction) -> Result node.pay_onchain_with_options(OnchainTransaction, PayOnchainOptions) -> Result @@ -190,7 +190,7 @@ node.list_transactions(ListTransactionsParams) -> Result 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 Strike and Blink. `fee_payer` answers who pays the mining/provider fee: +On-chain payments use a prepare-then-pay flow so apps can show fees before executing a payment. This is currently implemented for Strike, Blink, LND, CLN, and Phoenixd. `fee_payer` answers who pays the mining/provider fee: - `OnchainFeePayer::Sender` means the recipient receives the full requested amount and the sender pays fees on top. - `OnchainFeePayer::Recipient` means fees are deducted from the requested amount. @@ -292,6 +292,47 @@ For Strike, LNI maps `fast` to `tier_fast`, `normal` to `tier_standard`, and `sl For Blink, LNI 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. +For LND, LNI 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 `prepare_onchain_transaction` / `prepareOnchainTransaction` returns no `fee_sats` / `feeSats`, and `pay_onchain` / `payOnchain` requires `dangerously_disable_fee_guardrail: true` after the caller has chosen and accepted the fee rate. LND does not support `free`, backend fee preferences, or recipient-paid fees for on-chain sends. + +For CLN, LNI 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`. CLN does not support `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. + +For Phoenixd, LNI requires an explicit sats/vbyte fee preference because Phoenixd's `sendtoaddress` endpoint requires `feerateSatByte`. Use `fee: { type: "satsPerVbyte", satsPerVbyte: 12 }` in TypeScript, or `OnchainFeePreferenceType::SatsPerVbyte` in Rust. 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 `pay_onchain` / `payOnchain` requires `dangerously_disable_fee_guardrail: true` after the caller has chosen and accepted the feerate. + +LND payment macaroons +--------------------- + +For LNI 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` / `pay_invoice` or `payOnchain` / `pay_onchain`. + +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 fee guardrail limits unusually high fees; it is not a total spend budget. + Testing on-chain payments ------------------------- @@ -314,6 +355,25 @@ BLINK_API_KEY=... BLINK_BASE_URL=https://api.blink.sv/graphql BLINK_ONCHAIN_TEST_ADDRESS=bc1q... BLINK_ONCHAIN_AMOUNT_SATS=10000 + +# LND +LND_URL=https://127.0.0.1:8080 +LND_MACAROON=... +LND_ONCHAIN_TEST_ADDRESS=bc1q... +LND_ONCHAIN_AMOUNT_SATS=10000 + +# CLN +CLN_URL=https://127.0.0.1:3010 +CLN_RUNE=... +CLN_ONCHAIN_TEST_ADDRESS=bc1q... +CLN_ONCHAIN_AMOUNT_SATS=10000 + +# Phoenixd +PHOENIXD_URL=http://127.0.0.1:9740 +PHOENIXD_PASSWORD=... +PHOENIXD_ONCHAIN_TEST_ADDRESS=bc1q... +PHOENIXD_ONCHAIN_AMOUNT_SATS=10000 +PHOENIXD_ONCHAIN_FEERATE_SAT_BYTE=12 ``` Run TypeScript integration tests: @@ -322,13 +382,19 @@ Run TypeScript integration tests: cd bindings/typescript npm run test:integration:strike npm run test:integration:blink +npm run test:integration:lnd +npm run test:integration:cln +npm run test:integration:phoenixd ``` -Blink's TypeScript and Rust tests prepare a quote first, then skip the broadcast unless the confirmation variables are set. Run Rust tests from `crates/lni` so `dotenv` loads `crates/lni/.env`: +Blink, LND, CLN, and Phoenixd TypeScript and Rust tests prepare a quote first, then skip the broadcast unless the confirmation variables are set. CLN prepare-only tests call `txdiscard` to release reserved inputs. Run Rust tests from `crates/lni` so `dotenv` loads `crates/lni/.env`: ```bash cd crates/lni cargo test blink::lib::tests::test_pay_onchain_e2e -- --nocapture +cargo test lnd::lib::tests::test_pay_onchain_e2e -- --nocapture +cargo test cln::lib::tests::test_pay_onchain_e2e -- --nocapture +cargo test phoenixd::lib::tests::test_pay_onchain_e2e -- --nocapture ``` Broadcast tests require a second pair of env vars for the provider being tested: @@ -339,6 +405,15 @@ STRIKE_ONCHAIN_SEND_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_BITCOIN BLINK_RUN_ONCHAIN_SEND=true BLINK_ONCHAIN_SEND_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_BITCOIN + +LND_RUN_ONCHAIN_SEND=true +LND_ONCHAIN_SEND_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_BITCOIN + +CLN_RUN_ONCHAIN_SEND=true +CLN_ONCHAIN_SEND_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_BITCOIN + +PHOENIXD_RUN_ONCHAIN_SEND=true +PHOENIXD_ONCHAIN_SEND_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_BITCOIN ``` Run broadcast tests only when you intentionally want to send real bitcoin: @@ -347,13 +422,19 @@ Run broadcast tests only when you intentionally want to send real bitcoin: cd bindings/typescript npm run test:integration:strike npm run test:integration:blink +npm run test:integration:lnd +npm run test:integration:cln +npm run test:integration:phoenixd cd crates/lni cargo test strike::lib::tests::test_pay_onchain_e2e -- --ignored --nocapture cargo test blink::lib::tests::test_pay_onchain_e2e -- --nocapture +cargo test lnd::lib::tests::test_pay_onchain_e2e -- --nocapture +cargo test cln::lib::tests::test_pay_onchain_e2e -- --nocapture +cargo test phoenixd::lib::tests::test_pay_onchain_e2e -- --nocapture ``` -Without `-- --ignored`, Strike's Rust broadcast test is discovered but skipped. Blink's Rust test is not ignored; it prepares a quote and returns before broadcasting unless confirmation is present. TypeScript broadcast tests are skipped unless the provider-specific confirmation variables are present. Without the provider-specific `*_RUN_ONCHAIN_SEND=true` and `*_ONCHAIN_SEND_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_BITCOIN`, broadcast tests refuse to broadcast. +Without `-- --ignored`, Strike's Rust broadcast test is discovered but skipped. Blink, LND, CLN, and Phoenixd Rust tests are not ignored; they prepare a quote and return before broadcasting unless confirmation is present. TypeScript broadcast tests are skipped unless the provider-specific confirmation variables are present. Without the provider-specific `*_RUN_ONCHAIN_SEND=true` and `*_ONCHAIN_SEND_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_BITCOIN`, broadcast tests refuse to broadcast. #### Node Management ```rust