Skip to content

Conversation

@ntheile
Copy link
Contributor

@ntheile ntheile commented Feb 7, 2026

Summary by CodeRabbit

  • New Features
    • TypeScript frontend bindings: seven backend node integrations, LNURL/Lightning Address resolution, invoice polling, centralized node factory, standardized error type, encoding & HTTP utilities, and rich public types/APIs.
  • Documentation
    • Added README, thread-context, and agent guidance for TypeScript bindings and packaging.
  • Tests
    • Real integration test suites for all supported node implementations (env-gated).
  • Chores
    • Package manifest, build/tsconfig, .gitignore, and packaging/test scripts added.

@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

📝 Walkthrough

Walkthrough

Adds a TypeScript frontend binding for LNI: package scaffolding, public types/errors, HTTP/encoding/transform/polling utilities, LNURL resolution, seven node adapters (Phoenixd, CLN, LND, NWC, Strike, Speed, Blink), factory/index exports, environment-gated real integration tests, and supporting docs/agent guidance.

Changes

Cohort / File(s) Summary
Docs & Guidance
AGENTS.md, docs/THREAD_CONTEXT.md, bindings/typescript/README.md, readme.md
New agent guidance, thread-context handoff, TypeScript binding README, and updated project README describing the frontend TypeScript bindings and usage.
Package & TS Config
bindings/typescript/package.json, bindings/typescript/tsconfig.json, bindings/typescript/tsconfig.build.json, bindings/typescript/.gitignore
New npm manifest with scripts, publish config, strict TypeScript configs, build tsconfig, and .gitignore for node/dist.
Public Types & Exports
bindings/typescript/src/types.ts, bindings/typescript/src/index.ts, bindings/typescript/src/factory.ts
Introduces comprehensive public types (NodeInfo, Transaction, configs, LightningNode), central re-exports, and a createNode factory with overloads for each backend.
Errors
bindings/typescript/src/errors.ts
Adds LniErrorCode, LniError class (metadata: code/status/body/cause), and asLniError coercion helper.
HTTP & Encoding Utilities
bindings/typescript/src/internal/http.ts, bindings/typescript/src/internal/encoding.ts
Fetch resolution, URL builder, typed request helpers with timeouts/JSON/form handling, and base64/hex encode-decode helpers with environment fallbacks and LniError mapping.
Transform & Polling Helpers
bindings/typescript/src/internal/transform.ts, bindings/typescript/src/internal/polling.ts
Data-transform utilities (btc/sat/msat conversions, timestamps, empty models, search matching) and pollInvoiceEvents with delay/timeout and callbacks.
LNURL / Lightning Address
bindings/typescript/src/lnurl.ts
Payment destination detection, LNURL decoding/resolution, lightning-address → LNURL conversion, LNURL-Pay fetch/parse, amount validation, resolveToBolt11, and getPaymentInfo.
Node Adapters (implementations)
bindings/typescript/src/nodes/...
phoenixd.ts, cln.ts, lnd.ts, nwc.ts, strike.ts, speed.ts, blink.ts
Seven LightningNode implementations mapping backend APIs to unified types: getInfo, createInvoice, payInvoice, lookupInvoice, listTransactions, decode, onInvoiceEvents; Bolt12/Offer methods are stubbed or throw where unsupported.
Integration Tests & Helpers
bindings/typescript/src/__tests__/integration/helpers.ts, bindings/typescript/src/__tests__/integration/*.real.test.ts
Adds test utilities (env gating, conditional it, known-error handling) and environment-gated real integration tests per backend exercising getInfo/createInvoice/lookup/list flows.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Feature lightning address #27 — LNURL / lightning-address resolution APIs; directly related to lnurl.ts detection/resolution and getPaymentInfo/resolveToBolt11 flows.
  • On Invoice Events #9 — Invoice-event polling and OnInvoiceEvent types; directly related to pollInvoiceEvents and onInvoiceEvents integration across node adapters.
  • lnd #5 — LND support and payment/invoice surface; directly related to the new LndNode implementation and related types.

Poem

🐰 I hopped through types and HTTP streams,
stitched LNURLs, polls, and node-bound dreams,
invoices chimed, events took flight,
bindings sewn in TypeScript light,
a joyful rabbit cheers — bindings bright! ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title 'typescript' is overly generic and does not clearly convey the main change; it only indicates a technology/language rather than describing what was actually implemented. Consider a more descriptive title like 'Add TypeScript frontend bindings for LNI' or 'Implement TypeScript Lightning Node Interface bindings' to better reflect the substantial additions.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch typescript

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
bindings/typescript/src/nodes/strike.ts (2)

253-324: listTransactions could pass $paymentHash server-side for receives when filtering by payment hash.

When params.paymentHash is set, lookupInvoice already uses { '$paymentHash': params.paymentHash } to filter receives on the server. Here, both endpoints are fetched unfiltered and then filtered client-side (line 316). Passing $paymentHash to the receives call when available would reduce unnecessary data transfer, especially for accounts with many transactions.

♻️ Suggested improvement
-    const receives = await this.getJson<StrikeReceivesResponse>('/receive-requests/receives', {
-      '$skip': params.from,
-      '$top': params.limit,
-    });
+    const receivesQuery: Record<string, string | number | undefined> = {
+      '$skip': params.from,
+      '$top': params.limit,
+    };
+    if (params.paymentHash) {
+      receivesQuery['$paymentHash'] = params.paymentHash;
+    }
+    const receives = await this.getJson<StrikeReceivesResponse>('/receive-requests/receives', receivesQuery);

326-328: decode is a no-op — consider documenting intent or throwing.

Returning the input unchanged could silently mislead callers expecting actual bolt11/bolt12 decoding. If Strike's API doesn't expose a decode endpoint, a brief doc comment explaining why the passthrough is intentional would help maintainers. Alternatively, throwing LniError('Api', 'decode is not supported for StrikeNode') would make the limitation explicit, consistent with the Bolt12 stubs.

bindings/typescript/src/nodes/cln.ts (1)

334-347: payOffer hardcodes maxfeepercent: 1 and retry_for: 60 — consider accepting caller parameters.

payInvoice properly accepts feeLimitMsat, feeLimitPercentage, and timeoutSeconds from the caller, but payOffer ignores these and uses hardcoded values. This inconsistency means callers cannot control fee limits or timeouts when paying via offers.

Suggested approach

Add optional parameters to payOffer (or reuse the existing params from payInvoice):

-  async payOffer(offer: string, amountMsats: number, payerNote?: string): Promise<PayInvoiceResponse> {
+  async payOffer(offer: string, amountMsats: number, payerNote?: string, opts?: { feeLimitPercentage?: number; timeoutSeconds?: number }): Promise<PayInvoiceResponse> {
     const bolt11 = await this.fetchInvoiceFromOffer(offer, amountMsats, payerNote);
     const payload = await this.postJson<ClnPayResponse>('/v1/pay', {
       bolt11,
-      maxfeepercent: 1,
-      retry_for: 60,
+      maxfeepercent: opts?.feeLimitPercentage ?? 1,
+      retry_for: opts?.timeoutSeconds ?? 60,
     });
bindings/typescript/package.json (2)

51-58: Integration test scripts reference vitest by internal path — fragile.

Using ./node_modules/vitest/vitest.mjs directly couples the script to vitest's internal file layout, which may break on upgrades. Consider using npx vitest or the vitest bin entry instead.

Also, NODE_TLS_REJECT_UNAUTHORIZED=0 is understandable for local integration tests but consider adding a comment in the README warning contributors not to rely on this in CI or production-adjacent environments.


60-69: Update dependencies to current versions.

The specified versions are valid, but several have significant updates available:

  • websocket-polyfill@^0.0.3 is pinned to 0.0.3 due to its 0.0.x range; version 1.0.0 is now available
  • vitest@^2.1.8 is behind; version 4.0.18 is available (requires updating caret constraint to ^4.0.0 or reviewing breaking changes)
  • @types/node@^22.13.5 is behind; version 25.2.1 is available (update constraint to ^25.0.0)
  • typescript@^5.7.3 can be updated to 5.9.3 (or higher within ^5.x range)

Review these dependencies and upgrade to current versions, or explicitly document why older versions are required.

bindings/typescript/src/nodes/blink.ts (4)

419-447: lookupInvoice double-filters by paymentHash.

listTransactionsPage already filters results by args.paymentHash (Line 399), so the .find() on Line 434 is redundant. Not a bug, just unnecessary work.

♻️ Simplify by using the first result directly
-      const match = page.transactions.find((tx) => tx.paymentHash === params.paymentHash);
-      if (match) {
-        return match;
+      if (page.transactions.length > 0) {
+        return page.transactions[0];
       }

449-492: Client-side from offset over filtered, paginated results may skip more pages than necessary.

The skipped counter increments for every filtered-in transaction before from, so if the filter discards many items, the loop fetches many pages just to reach the offset. This is an inherent limitation of client-side offset with server cursor pagination and is acceptable for now, but worth documenting as a known trade-off for large offsets.


206-214: Redundant cache assignment in getBtcWalletId.

getBtcWallet() (Line 202) already sets this.cachedWalletId. Line 212 sets it again unnecessarily.

♻️ Remove the redundant assignment
   private async getBtcWalletId(): Promise<string> {
     if (this.cachedWalletId) {
       return this.cachedWalletId;
     }
 
     const wallet = await this.getBtcWallet();
-    this.cachedWalletId = wallet.id;
     return wallet.id;
   }

356-380: mapTransaction uses createdAt as settledAt for successful transactions.

When status === 'SUCCESS', settledAt is set to createdAt (Line 374). The Blink API may not expose a distinct settlement timestamp, so this is a reasonable approximation, but consumers should be aware it's not the true settlement time.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 19

🤖 Fix all issues with AI agents
In `@bindings/typescript/src/internal/encoding.ts`:
- Around line 3-6: The encodeBase64 function currently calls globalThis.btoa
which throws on non-Latin-1 input; update encodeBase64 to consistently UTF-8
encode the input first and then base64-encode the resulting bytes across
environments: use Buffer.from(input, 'utf-8').toString('base64') in Node when
Buffer exists, and in browsers use new TextEncoder().encode(input) to get a
Uint8Array then convert those bytes to a base64 string (e.g., binary string
chunking + globalThis.btoa) as the fallback; ensure you reference and replace
the current globalThis.btoa path and handle TextEncoder/Buffer presence within
encodeBase64 so non-ASCII API keys/passwords work consistently.

In `@bindings/typescript/src/internal/http.ts`:
- Around line 118-124: The call to response.text() is currently outside the
try-catch so any failure while reading the body will escape as a raw error; wrap
the await response.text() in the same error handling pattern used earlier:
surround it with a try-catch that throws a LniError('NetworkError', `Network
request failed: ${...}`, { cause: error }) (use the same message construction as
the existing catch) so all network/body-read failures are normalized to
LniError; update the code around response.text() and reuse the same error
variable naming to keep consistency.
- Around line 136-151: The function requestJson currently returns "{} as T" on
empty response which unsafely coerces callers into assuming a full object;
instead modify requestJson to throw an LniError when requestText(...) returns an
empty string (include a descriptive message and body/context), and mention in
docs/comments that callers expecting empty responses should use requestMaybeJson
or requestText; update any callers like listTransactions to handle or use
requestMaybeJson if they legitimately accept empty responses.

In `@bindings/typescript/src/lnurl.ts`:
- Around line 92-97: The parsed JSON is being unsafely cast to LnurlPayResponse
after checking for an LnurlError; add runtime validation to ensure required
fields (callback, minSendable, maxSendable, tag) exist and have the expected
types/values before returning as LnurlPayResponse. In the function that
currently uses maybeError and throws LniError, validate that callback is a
non-empty string, minSendable and maxSendable are numbers (or numeric strings
convertible to numbers) and tag equals the expected value, and throw a
descriptive LniError (or a new validation error) if any check fails; this
prevents downstream failures in requestInvoice and assertAmountRange when those
properties are undefined.

In `@bindings/typescript/src/nodes/blink.ts`:
- Around line 276-284: In payInvoice, the code currently returns empty
paymentHash and preimage after lnInvoicePaymentSend because that mutation
doesn't return those fields; update payInvoice to perform a follow-up payment
lookup (e.g., call Blink's payment lookup/get mutation using the
lnInvoicePaymentSend response or feeProbe identifiers) to retrieve and populate
paymentHash and preimage before returning, falling back to null/undefined if the
lookup still doesn't provide values; alternatively, make the return types
optional and add a brief comment documenting this Blink limitation. Ensure you
update the logic around lnInvoicePaymentSend, the returned object construction
(where paymentHash, preimage, feeMsats are set), and keep existing error
handling via LniError and satsToMsats intact.

In `@bindings/typescript/src/nodes/cln.ts`:
- Around line 66-68: ClnListOffersResponse and listOffers are returning CLN's
raw snake_case objects but typed as Offer (camelCase), so at runtime fields like
offer_id and single_use won't match Offer.offerId/singleUse; define a raw
response type (e.g., RawClnOffer { offer_id: string; single_use: boolean; ... }
and ClnListOffersResponse { offers: RawClnOffer[] }) and in listOffers map
payload.offers to convert snake_case to the Offer shape (offer_id -> offerId,
single_use -> singleUse, etc.) before returning; update any other functions
around lines ~300-306 that similarly return payload.offers to perform the same
mapping.
- Around line 341-363: ClnNode.listTransactions currently only queries
/v1/listinvoices so outgoing payments are omitted; update listTransactions to
also call /v1/listpays (or /v1/listsendpays as appropriate) via postJson, map
those pay entries to the same Transaction shape (add a helper like
payToTransaction or extend invoiceToTransaction logic to handle pay records),
merge the incoming (invoices) and outgoing (pays) transaction arrays, apply the
existing search filter to the combined list, and return the merged result
(optionally sorted by timestamp/created) so both incoming and outgoing
transactions are included; reference ClnNode.listTransactions,
invoiceToTransaction, and create/payToTransaction or similar.
- Around line 323-339: In lookupInvoice, don't fall back to using params.search
as a payment_hash; instead require params.paymentHash and return a clear error
if it's missing—remove the else-if that assigns query.payment_hash =
params.search and throw an LniError('Api', 'paymentHash is required') (or
similar) when params.paymentHash is undefined; keep using
postJson('/v1/listinvoices', query) with query.payment_hash set only from
params.paymentHash and then continue to call invoiceToTransaction on the
returned invoice.

In `@bindings/typescript/src/nodes/lnd.ts`:
- Around line 165-175: The expiresAt field in the createInvoice return uses a
relative duration (params.expiry ?? 86400) but must be an absolute Unix
timestamp; update the expiresAt assignment in the function that returns
emptyTransaction (the createInvoice/invoice construction in nodes/lnd.ts) to
compute Math.floor(Date.now() / 1000) + (params.expiry ?? 86400) so expiresAt is
a current-time + expiry-seconds Unix timestamp (ensure it stays an integer).
- Line 114: mapInvoice and createInvoice are setting expiresAt to a relative
expiry seconds value (parseOptionalNumber(invoice.expiry) and params.expiry)
instead of an absolute timestamp; change both to compute expiresAt as createdAt
+ expiry (convert expiry seconds to the same time unit as createdAt) — in
mapInvoice use the parsed createdAt (e.g.,
parseOptionalNumber(invoice.created_at) or the existing createdAt mapping) plus
parseOptionalNumber(invoice.expiry), and in createInvoice set expiresAt to
(createdAt + (params.expiry ?? 86400) * 1000 or seconds consistent with
Transaction.expiresAt) rather than directly assigning params.expiry; ensure you
use the same time unit everywhere and update references to parseOptionalNumber,
mapInvoice, and createInvoice accordingly.
- Around line 105-121: The mapInvoice function incorrectly sets feesPaid to
invoice.value_msat (the requested amount); change mapInvoice (in
bindings/typescript/src/nodes/lnd.ts) so feesPaid is the actual fees (for
incoming invoices usually 0) by replacing
parseOptionalNumber(invoice.value_msat) with 0 (or
parseOptionalNumber(invoice.fees_msat) if your LND response includes a fees
field), and keep amountMsats mapped from invoice.amt_paid_msat as currently
done; if you need to preserve the invoice requested amount, map
invoice.value_msat to a new field (e.g., requestedAmountMsats) instead of
feesPaid.
- Around line 185-190: The code currently builds a nested fee_limit object using
params.feeLimitPercentage and params.amountMsats; change this to set a flat
fee_limit_msat string (not an object) expected by /v2/router/send and compute it
from the payment amount when only a percentage is provided. Specifically, in the
block that references params.feeLimitPercentage and params.amountMsats, remove
creation of body.fee_limit object and instead set body.fee_limit_msat =
String(Math.floor(Number(params.amountMsats) * (params.feeLimitPercentage /
100))); ensure you use params.amountMsats and params.feeLimitPercentage to
compute the integer msat cap and store it as a string.

In `@bindings/typescript/src/nodes/nwc.ts`:
- Around line 102-115: The createInvoice method currently forces a zero amount
by using params.amountMsats ?? 0 which turns omitted amounts into literal 0;
change the call in createInvoice (the makeInvoice call) to pass
params.amountMsats directly so undefined is propagated for amountless invoices
(allowing payer-chosen amounts), matching behavior used by other backends like
Strike and Phoenixd and leaving the existing error handling and
nwcTransactionToLniTransaction conversion intact.

In `@bindings/typescript/src/nodes/phoenixd.ts`:
- Around line 249-254: In listTransactions, the expression params.from ?
params.from * 1000 : undefined treats 0 as falsy and drops a valid start offset;
change it to an explicit presence check (e.g., params.from !== undefined &&
params.from !== null) so that when params.from is 0 you still set query.from =
params.from * 1000, leaving other fields (limit, all) unchanged; update the
query construction in listTransactions to compute from using that explicit
check.
- Around line 110-154: In createInvoice, expiresAt is currently set to the
expiry duration (params.expiry ?? 3600) instead of an absolute Unix timestamp;
update the code paths that call emptyTransaction (both the InvoiceType.Bolt12
branch and the Bolt11 branch) to compute expiresAt as createdAt + (params.expiry
?? 3600) (use the same createdAt value you pass into emptyTransaction, e.g.,
Math.floor(Date.now()/1000) or the createdAt you already compute) so
Transaction.expiresAt is an absolute Unix timestamp rather than a duration.

In `@bindings/typescript/src/nodes/speed.ts`:
- Around line 150-170: The payInvoice implementation currently returns empty
paymentHash and preimage; update the payInvoice method to extract and return
actual identifiers from the Speed API response (the SpeedSendResponse returned
by postJson). Inspect fields on SpeedSendResponse such as payment_hash,
preimage, hash, speed_payment_hash, or nested objects like
withdrawal.id/withdrawal.payment_hash (or similar names used by the API) and map
them into the PayInvoiceResponse.paymentHash and PayInvoiceResponse.preimage; if
the API truly does not provide those values, return null/undefined in those
fields and update PayInvoiceResponse type (or add a comment) to document that
they may be missing so callers don’t assume non-empty values. Ensure you
reference payInvoice, PayInvoiceResponse, SpeedSendResponse, and postJson when
making the changes.
- Around line 93-107: The sendToTransaction function contains a dead ternary and
a hardcoded empty paymentHash: change the transaction type mapping so the
conditional reflects the real direction (e.g., map send.withdraw_method ===
'lightning' to 'outgoing' and the other branch to 'incoming' or whatever the
Speed API semantics require) instead of returning 'outgoing' for both branches,
and populate paymentHash from the actual Speed API field (check for properties
like send.payment_hash, send.paymentHash, send.pay_hash or similar) so
lookupInvoice can match by hash; if no payment-hash field exists in the API, set
paymentHash to null/undefined and document that hash-based lookup is unsupported
for SpeedNode (refer to sendToTransaction, send.withdraw_method, paymentHash and
lookupInvoice).
- Around line 125-127: The code currently computes sat amounts as
(params.amountMsats ?? 0) / 1000 which can produce fractional sats; update the
conversion in the Speed client to produce an integer sat value (e.g., use
Math.floor or Math.trunc on (params.amountMsats ?? 0) / 1000) before sending to
postJson so the API receives an integer sat amount; apply the same change in
both the createPayment path (where payload is created for '/payments' and typed
as SpeedCreatePaymentResponse) and the payInvoice path referenced in the review.

In `@readme.md`:
- Around line 296-314: The heading "#### typescript (frontend)" should be
demoted to the next level to preserve hierarchy—change that line to "###
typescript (frontend)" in the README so it follows the preceding h2 sections;
update the same heading token in the block that shows the TypeScript
install/build instructions (the markdown header string "#### typescript
(frontend)") to "### typescript (frontend)" to satisfy the static analysis rule.
🧹 Nitpick comments (26)
bindings/typescript/tsconfig.json (1)

13-13: Consider scoping vitest/globals types to test files only.

Including "vitest/globals" in the base tsconfig.json makes describe, it, expect, etc. available as globals in all source files, not just tests. This can mask missing imports in production code. You could move this to a separate tsconfig for tests or rely on explicit imports (which the test file already does on Line 1 of the LND test).

bindings/typescript/src/__tests__/integration/speed.real.test.ts (1)

5-5: itIf is duplicated across all seven integration test files.

Consider extracting this one-liner into helpers.ts alongside the other shared utilities. Every test file defines the same conditional runner.

bindings/typescript/src/__tests__/integration/blink.real.test.ts (1)

31-55: Consider wrapping in runOrSkipKnownError for consistency with other lookupInvoice tests.

The Speed and Strike lookupInvoice tests use runOrSkipKnownError to gracefully handle known API error patterns, but this Blink test does not. If the Blink API can return transient or expected errors (e.g., "not found"), those would cause a test failure here rather than a graceful skip.

bindings/typescript/src/errors.ts (1)

21-23: Consider passing cause to super() directly.

If your tsconfig targets ES2022+ (Node ≥20 supports it), you can pass { cause } to the Error constructor and drop the manual assignment + cast.

♻️ Suggested simplification
-  constructor(code: LniErrorCode, message: string, options?: { status?: number; body?: string; cause?: unknown }) {
-    super(message);
+  constructor(code: LniErrorCode, message: string, options?: { status?: number; body?: string; cause?: unknown }) {
+    super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
     this.name = 'LniError';
     this.code = code;
     this.status = options?.status;
     this.body = options?.body;
-
-    if (options?.cause !== undefined) {
-      (this as Error & { cause?: unknown }).cause = options.cause;
-    }
   }
bindings/typescript/package.json (2)

51-58: NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS certificate validation in integration tests.

This is common for local integration tests against Lightning nodes with self-signed certificates, but consider adding a comment in the README or contributing guide explaining why this is needed, so future contributors don't copy this pattern into production code.


60-68: Consider updating outdated dependencies.

Version verification shows:

  • @getalby/sdk: ^7.0.0 is current ✓
  • @scure/base: ^1.2.6 is outdated; v2.0.0 is available (note: already present as a transitive dependency via @scure/bip32, @scure/bip39, and nostr-tools in package-lock.json)
  • websocket-polyfill: ^0.0.3 is significantly outdated; v1.0.0 (stable release) is now available

No security advisories were found. websocket-polyfill is used only in test helpers and would be a good candidate for updating to v1.0.0. Verify compatibility before updating @scure/base to v2.0.0 despite it already being present in the dependency tree.

bindings/typescript/src/internal/polling.ts (2)

20-35: Final sleep before timeout adds unnecessary latency.

When the last iteration finds the invoice still pending, the loop sleeps for delayMs before re-checking the condition and emitting the final 'failure' callback. This means the timeout fires up to delayMs late.

Move the sleep to the top of the loop body (after the first iteration) or check the elapsed time before sleeping:

♻️ Suggested fix
   while (Date.now() - startedAt <= maxDurationMs) {
     try {
       const tx = await args.lookup();
       if (tx.settledAt > 0) {
         args.callback('success', tx);
         return;
       }
       args.callback('pending', tx);
     } catch {
       args.callback('failure');
     }
 
-    await sleep(delayMs);
+    if (Date.now() - startedAt + delayMs <= maxDurationMs) {
+      await sleep(delayMs);
+    } else {
+      break;
+    }
   }
 
   args.callback('failure');

28-29: Transient errors are silently swallowed with no diagnostic info.

When lookup() throws, callback('failure') is emitted but the error is discarded. The InvoiceEventCallback type doesn't carry an error parameter, so there's no way for consumers to distinguish a transient network blip from a permanent failure. Consider whether the callback type should be extended, or at minimum log/surface the error for debugging.

bindings/typescript/src/nodes/nwc.ts (2)

29-30: Unnecessary buffer copy before crypto.subtle.digest.

digest() does not mutate its input. The copy on lines 29–30 can be removed:

♻️ Simplification
 async function sha256Hex(bytes: Uint8Array): Promise<string> {
   if (!globalThis.crypto?.subtle) {
     throw new LniError('Api', 'Web Crypto API is required to hash NWC preimages.');
   }
 
-  const digestInput = new Uint8Array(bytes.length);
-  digestInput.set(bytes);
-  const digest = await globalThis.crypto.subtle.digest('SHA-256', digestInput);
+  const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
   return bytesToHex(new Uint8Array(digest));
 }

195-217: filterTransactions applies paymentHash as exact match but search as substring — document or align.

The filtering logic uses exact equality for paymentHash (line 202) but case-insensitive substring matching for search (lines 208-211). This is reasonable but the dual behavior could surprise callers. A brief JSDoc on the method or on the ListTransactionsParams fields would help clarify the contract.

bindings/typescript/src/lnurl.ts (3)

50-53: needsResolution has a subtle inconsistency with detectPaymentType for @ detection.

detectPaymentType guards against LNURL strings containing @ with !lower.startsWith('lnurl'), but needsResolution uses a bare destination.includes('@') without the same guard. If a bech32-encoded LNURL string somehow contains @, needsResolution would return true for the wrong reason. In practice this is extremely unlikely since bech32 doesn't encode @, but aligning the logic would be more robust:

♻️ Suggested alignment
 export function needsResolution(destination: string): boolean {
   const normalized = destination.trim().toLowerCase();
-  return destination.includes('@') || normalized.startsWith('lnurl1');
+  return (destination.includes('@') && !normalized.startsWith('lnurl')) || normalized.startsWith('lnurl1');
 }

76-98: fetchLnurlPay uses requestText + manual JSON.parse while requestInvoice uses requestJson.

This inconsistency in how JSON responses are handled between fetchLnurlPay (lines 77–90) and requestInvoice (line 104) makes the code harder to follow. Consider using requestJson in both places, or add a comment explaining why requestText is preferred here (e.g., to inspect raw payload on error).


142-173: LNURL and Lightning Address resolution paths share identical tail logic — consider extracting.

Lines 164–166 and 169–172 perform the same fetchLnurlPayassertAmountRangerequestInvoice sequence with only the URL derivation differing. A small helper would reduce duplication:

♻️ Suggested extraction
+async function resolveViaLnurlPay(url: string, amountMsats: number, fetchFn: FetchLike): Promise<string> {
+  const lnurlPay = await fetchLnurlPay(url, fetchFn);
+  assertAmountRange(amountMsats, lnurlPay.minSendable, lnurlPay.maxSendable);
+  return requestInvoice(lnurlPay.callback, amountMsats, fetchFn);
+}
+
 export async function resolveToBolt11(...) {
   ...
   if (destinationType === 'lightning_address') {
     const { user, domain } = parseLightningAddress(destination.trim());
-    const lnurlPay = await fetchLnurlPay(lightningAddressToUrl(user, domain), fetchFn);
-    assertAmountRange(amountMsats, lnurlPay.minSendable, lnurlPay.maxSendable);
-    return requestInvoice(lnurlPay.callback, amountMsats, fetchFn);
+    return resolveViaLnurlPay(lightningAddressToUrl(user, domain), amountMsats, fetchFn);
   }
 
   const lnurl = decodeLnurl(destination.trim());
-  const lnurlPay = await fetchLnurlPay(lnurl, fetchFn);
-  assertAmountRange(amountMsats, lnurlPay.minSendable, lnurlPay.maxSendable);
-  return requestInvoice(lnurlPay.callback, amountMsats, fetchFn);
+  return resolveViaLnurlPay(lnurl, amountMsats, fetchFn);
 }
bindings/typescript/src/nodes/phoenixd.ts (1)

330-348: Polling with limit: 2500 may be heavy for repeated poll iterations.

Each poll iteration fetches up to 2500 transactions via listTransactions (which itself makes two HTTP calls — incoming + outgoing). For long-running polls with short delays, this could place significant load on the Phoenixd instance.

Consider using lookupInvoice when paymentHash is not set but search is, or reduce the limit.

bindings/typescript/src/types.ts (1)

27-41: Consider narrowing Transaction.type to a string literal union.

All node implementations consistently use 'incoming' or 'outgoing'. A union type would catch typos at compile time and improve DX.

Proposed change
 export interface Transaction {
-  type: string;
+  type: 'incoming' | 'outgoing';
   invoice: string;
bindings/typescript/src/nodes/blink.ts (4)

123-146: getBtcWalletId() makes a network call on every invocation — consider caching.

getBtcWalletId is called from both createInvoice and payInvoice, each triggering a full GraphQL round-trip. The wallet ID is unlikely to change during the lifetime of a BlinkNode instance.

Proposed caching
 export class BlinkNode implements LightningNode {
   private readonly fetchFn;
   private readonly timeoutMs?: number;
   private readonly baseUrl: string;
+  private cachedWalletId?: string;

   // ...

   private async getBtcWalletId(): Promise<string> {
+    if (this.cachedWalletId) {
+      return this.cachedWalletId;
+    }
+
     const query = `...`;
     const response = await this.gql<BlinkMeQuery>(query);
     const wallet = response.me.defaultAccount.wallets.find((item) => item.walletCurrency === 'BTC');

     if (!wallet) {
       throw new LniError('Api', 'No BTC wallet found in Blink account.');
     }

+    this.cachedWalletId = wallet.id;
     return wallet.id;
   }

148-173: getInfo() duplicates the wallet query from getBtcWalletId().

The same GraphQL query and wallet lookup logic exists in both methods. getInfo could reuse getBtcWalletId (or a shared internal that returns the full wallet data) to reduce duplication.


387-402: Search filtering is inconsistent with matchesSearch used by other nodes.

PhoenixdNode uses the shared matchesSearch utility (which checks paymentHash, description, payerNote, invoice). BlinkNode rolls its own filter that checks description, paymentHash, and preimage — but omits invoice and payerNote. Consider reusing matchesSearch for consistency across node implementations.

Proposed fix

Import matchesSearch from '../internal/transform.js' (already partially imported) and replace the inline filter:

+import { emptyNodeInfo, emptyTransaction, matchesSearch, satsToMsats } from '../internal/transform.js';

     const filtered = transactions.filter((tx) => {
       if (params.paymentHash && tx.paymentHash !== params.paymentHash) {
         return false;
       }
-
-      if (!params.search) {
-        return true;
-      }
-
-      const search = params.search.toLowerCase();
-      return (
-        tx.description.toLowerCase().includes(search) ||
-        tx.paymentHash.toLowerCase().includes(search) ||
-        tx.preimage.toLowerCase().includes(search)
-      );
+      return matchesSearch(tx, params.search);
     });

350-359: Pagination fetches excess data — first: from + limit over-fetches then slices client-side.

first is set to params.from + params.limit, fetching all items from the start up to the desired page end. For large from values, this transfers significant unnecessary data. The Blink API supports cursor-based pagination (after/before) which would be more efficient but would require tracking cursors.

This is acceptable for now, but worth noting as a scalability limitation.

bindings/typescript/src/nodes/strike.ts (3)

132-171: Msats-to-BTC conversion is the inverse of btcToMsats but written inline.

Lines 142 and 180 use (params.amountMsats / 100_000_000_000).toFixed(8) to convert msats to BTC. This is the inverse of the btcToMsats helper in transform.ts. Consider extracting a shared msatsToBtc helper to reduce duplication and ensure the conversion factor stays consistent.


305-318: Search filtering is inconsistent with shared matchesSearch utility.

Same issue as in blink.ts: this inline filter checks paymentHash, description, and invoice but not payerNote, diverging from the shared matchesSearch utility used by PhoenixdNode. Reusing matchesSearch would ensure consistency across all node implementations.

Proposed fix

Import matchesSearch from '../internal/transform.js' and replace the inline filter:

+import { btcToMsats, emptyNodeInfo, emptyTransaction, matchesSearch, parseOptionalNumber, toUnixSeconds } from '../internal/transform.js';

     const filtered = txs.filter((tx) => {
       if (params.paymentHash && tx.paymentHash !== params.paymentHash) {
         return false;
       }
-      if (params.search) {
-        const search = params.search.toLowerCase();
-        return (
-          tx.paymentHash.toLowerCase().includes(search) ||
-          tx.description.toLowerCase().includes(search) ||
-          tx.invoice.toLowerCase().includes(search)
-        );
-      }
-      return true;
+      return matchesSearch(tx, params.search);
     });

244-260: Swallowing 404 for outgoing payments is reasonable but worth documenting.

The try/catch around the outgoing payments fetch (lines 251-260) silently returns an empty list on 404. This handles the case where Strike returns 404 when there are no payments. A brief inline comment explaining this would help future maintainers.

bindings/typescript/src/nodes/cln.ts (1)

222-227: Invoice label uses Math.random() which has non-trivial collision risk.

Math.floor(Math.random() * 1_000_000_000) generates ~30 bits of entropy. By the birthday paradox, collision probability reaches ~50% after ~31k invoices. CLN requires unique labels. Consider using crypto.randomUUID() or incorporating a timestamp.

Proposed fix
-      label: `lni.${Math.floor(Math.random() * 1_000_000_000)}`,
+      label: `lni.${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`,
bindings/typescript/src/nodes/lnd.ts (1)

199-201: Fragile error detection via string matching on response text.

Checking responseText.includes('"error"') can produce false positives if the string "error" appears within field values (e.g., an error description embedded in a result payload). Consider parsing the last JSON line first and then checking for an error property on the parsed object, or at minimum using a more targeted check.

bindings/typescript/src/nodes/speed.ts (1)

204-212: Redundant null check after length check.

After if (!txs.length) throws on Line 204, txs[0] is guaranteed to be defined — the if (!tx) check on Line 209 is unreachable dead code.

Proposed simplification
     if (!txs.length) {
       throw new LniError('Api', 'No transactions found matching lookup parameters.');
     }
 
-    const tx = txs[0];
-    if (!tx) {
-      throw new LniError('Api', 'No transactions found matching lookup parameters.');
-    }
-    return tx;
+    return txs[0]!;
bindings/typescript/src/internal/http.ts (1)

57-90: Timeout timer leaks when the request completes before the deadline.

withTimeout creates a setTimeout that is only cleared when the signal aborts (via external signal or timeout itself). If the request succeeds before the timeout fires, the timer remains live in the event loop until it eventually fires and calls controller.abort() on a completed request. This is benign in low-throughput scenarios but wasteful for long timeouts.

One approach: return both the signal and a cleanup function, and call it in requestText after the fetch completes.

Sketch
-function withTimeout(signal: AbortSignal | undefined, timeoutMs: number | undefined): AbortSignal | undefined {
+function withTimeout(signal: AbortSignal | undefined, timeoutMs: number | undefined): { signal: AbortSignal | undefined; clear: () => void } {
   if (!timeoutMs || timeoutMs <= 0) {
-    return signal;
+    return { signal, clear: () => {} };
   }
 
   const controller = new AbortController();
   const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+  const clear = () => clearTimeout(timeoutId);
 
   if (signal) {
     if (signal.aborted) {
       clearTimeout(timeoutId);
       controller.abort();
     } else {
       signal.addEventListener(
         'abort',
         () => {
           clearTimeout(timeoutId);
           controller.abort();
         },
         { once: true },
       );
     }
   }
 
-  controller.signal.addEventListener(
-    'abort',
-    () => {
-      clearTimeout(timeoutId);
-    },
-    { once: true },
-  );
-
-  return controller.signal;
+  return { signal: controller.signal, clear };
 }

Then in requestText, call clear() after fetch completes (in a finally block).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In `@bindings/typescript/src/__tests__/integration/cln.real.test.ts`:
- Line 27: Fix inconsistent indentation for the console.log statement in the
test by replacing the 3-space indent before console.log('CLN Invoice:', invoice)
with the same 4-space indentation used by the surrounding block (align with
other statements in the test). Locate the console.log in the cln.real.test.ts
integration test and adjust its leading whitespace so it matches the block's
indentation style.

In `@bindings/typescript/src/__tests__/integration/lnd.real.test.ts`:
- Line 3: The createInvoice test currently doesn't use the imported helper
runOrSkipKnownError, causing a partially unused import; wrap the createInvoice
test invocation (the test that calls createInvoice) with runOrSkipKnownError the
same way getInfo is wrapped so the test is skipped on known LND error
conditions, and remove the unused import only if you intentionally choose not to
wrap the test (reference the runOrSkipKnownError helper and the createInvoice
test name to find where to apply the wrapper).
- Around line 23-38: Wrap the test body for "createInvoice + lookupInvoice +
listTransactions" in runOrSkipKnownError so network/unreachable LND errors are
caught and the test is skipped instead of failing hard; locate the test that
calls makeNode() and then node.createInvoice, node.lookupInvoice, and
node.listTransactions and call runOrSkipKnownError(async () => { ...existing
test code... }) around that async block (keeping the existing itIf(enabled)(...)
and timeout) so any known network errors thrown by
createInvoice/lookupInvoice/listTransactions are handled gracefully.

In `@bindings/typescript/src/nodes/blink.ts`:
- Around line 162-172: In getInfo() (Blink Node) the code uses the custodial
wallet balance for both sendBalanceMsat and receiveBalanceMsat which is
misleading; update the emptyNodeInfo call in getInfo() (after getBtcWallet() /
satsToMsats(sats)) to use sendBalanceMsat: satsToMsats(sats) but set
receiveBalanceMsat to 0 (or remove the receiveBalanceMsat field entirely) so
consumers aren’t misled about receive capacity for this custodial wallet.
- Around line 349-395: The pagination mixes server fetch size and client offset:
the local variable first (set to params.from + params.limit) is used in the gql
call and then you apply a client-side skip/slice (skip = params.from;
filtered.slice(skip, end)), which breaks when filtering reduces results;
instead, implement proper cursor-based server pagination or fetch only
params.limit items per request and use an after cursor; specifically, stop
computing first = params.from + params.limit and/or stop relying on client skip
on the filtered array—use the GraphQL cursor (after) returned from this.gql and
the transactions edges cursors to request the correct page, or set first =
params.limit and iterate using after cursors when params.from > 0 before
mapping/filtering in the transactions -> filtered -> slice pipeline
(functions/variables to change: first, this.gql call, use of after,
transactions, filtered, skip, and slice).

In `@bindings/typescript/src/nodes/cln.ts`:
- Around line 128-144: In invoiceToTransaction, createdAt is hardcoded to 0;
compute a sensible creation timestamp instead by deriving createdAt from CLN
fields if available: if invoice.expires_at and an expiry duration field exist
(e.g., invoice.expiry or invoice.expiry_seconds), set createdAt = expires_at -
expiry (clamp to >=0); otherwise fall back to invoice.paid_at (invoice.paid_at
|| invoice.settledAt equivalent) or as a last resort use
Math.floor(Date.now()/1000) so consumers don't get epoch-zero; update the
createdAt assignment in invoiceToTransaction accordingly and ensure units are
seconds to match other timestamps.

In `@bindings/typescript/src/nodes/lnd.ts`:
- Around line 271-273: listTransactions currently ignores the incoming
ListTransactionsParams and always fetches all invoices; update the async
listTransactions(_params: ListTransactionsParams) method to apply filtering and
limiting after mapping invoices: use params.paymentHash to filter mapped results
by matching transaction.paymentHash, use params.search to case-insensitively
match against relevant fields (e.g., memo/description or paymentHash) and
enforce params.limit and params.from (offset) when slicing the sorted array;
keep using this.mapInvoice(invoice) for mapping and ensure the final array is
sorted by createdAt before applying from/limit.

In `@bindings/typescript/src/nodes/speed.ts`:
- Around line 211-227: listTransactions currently ignores params.from so
pagination is incorrect; update the post-sort slicing of txs to apply the offset
and limit together (use params.from as the slice start and params.limit as the
length) instead of always slicing from 0, handling cases where params.from is
missing/negative (treat as 0) and where params.limit <= 0 (treat as no limit).
Locate the logic in listTransactions (after mapping rows via sendToTransaction
and sorting) and replace the existing .slice(0, params.limit > 0 ? params.limit
: undefined) with a slice that uses params.from and params.limit (e.g., start =
Math.max(0, params.from || 0), end = params.limit > 0 ? start + params.limit :
undefined) so filtering by paymentHash and other flow remains unchanged; ensure
changes integrate with fetchSendTransactions and sendToTransaction usages.

In `@bindings/typescript/src/nodes/strike.ts`:
- Around line 234-236: The settledAt calculation uses Date.parse(item.completed
?? '') which yields NaN when item.completed is undefined; update the logic in
the strike mapping (field settledAt) to only call toUnixSeconds(Date.parse(...))
when item.completed is a non-empty string (e.g., item.completed ?
toUnixSeconds(Date.parse(item.completed)) : 0) to avoid NaN, and apply the same
guard/pattern to the analogous conversions in listTransactions (the two
occurrences referenced around lines 278 and 296) so all date conversions use a
presence check before parsing.
- Around line 244-314: listTransactions merges separately-paginated receives and
payments so it can return up to 2×params.limit; after filtering and sorting
(variables: txs, filtered) truncate the final result to params.limit before
returning. Modify listTransactions to sort the filtered array and then .slice(0,
params.limit) (using params.limit) so callers receive at most the requested
number of transactions.
🧹 Nitpick comments (6)
bindings/typescript/src/types.ts (1)

118-171: Consider extracting shared config fields into a base type.

All seven config interfaces repeat socks5Proxy, acceptInvalidCerts, and httpTimeout. Extracting these into a shared base interface would reduce duplication and make it easier to add future shared options.

Example
interface BaseNodeConfig {
  socks5Proxy?: string;
  acceptInvalidCerts?: boolean;
  httpTimeout?: number;
}

export interface PhoenixdConfig extends BaseNodeConfig {
  url: string;
  password: string;
}

// Similarly for ClnConfig, LndConfig, etc.
bindings/typescript/src/nodes/nwc.ts (1)

64-82: Silent fallback in getInfo may mask real errors.

When this.client.getInfo() fails (line 74), the error is silently caught and a default NodeInfo is returned. This is reasonable for NWC providers that don't support getInfo, but consider logging the error for debuggability.

bindings/typescript/src/nodes/phoenixd.ts (1)

265-283: listTransactions bypasses the private requestJson helper.

These two calls use the module-level requestJson + buildUrl with manual auth headers, while the rest of the class uses the private this.requestJson helper (lines 70–79). This inconsistency means changes to auth or timeout defaults must be synchronized in two places.

Proposed refactor
-    const incoming = await requestJson<PhoenixdInvoiceResponse[]>(
-      this.fetchFn,
-      buildUrl(this.config.url, '/payments/incoming', query),
-      {
-        method: 'GET',
-        timeoutMs: this.timeoutMs,
-        headers: { authorization: this.authHeader() },
-      },
-    );
-
-    const outgoing = await requestJson<PhoenixdOutgoingPaymentResponse[]>(
-      this.fetchFn,
-      buildUrl(this.config.url, '/payments/outgoing', query),
-      {
-        method: 'GET',
-        timeoutMs: this.timeoutMs,
-        headers: { authorization: this.authHeader() },
-      },
-    );
+    const incoming = await this.requestJson<PhoenixdInvoiceResponse[]>('/payments/incoming', {
+      method: 'GET',
+      query,
+    });
+
+    const outgoing = await this.requestJson<PhoenixdOutgoingPaymentResponse[]>('/payments/outgoing', {
+      method: 'GET',
+      query,
+    });

Note: This requires the private requestJson helper to support the query parameter, which it currently does not since it calls buildUrl without forwarding query params. You'd need to update the helper to accept and forward query from args:

 private async requestJson<T>(path: string, args: Parameters<typeof requestJson<T>>[2]): Promise<T> {
-    return requestJson<T>(this.fetchFn, buildUrl(this.config.url, path), {
+    return requestJson<T>(this.fetchFn, buildUrl(this.config.url, path, args?.query), {
       ...args,
bindings/typescript/src/__tests__/integration/cln.real.test.ts (1)

14-37: No runOrSkipKnownError wrapper — inconsistent with Phoenixd tests.

The CLN integration tests don't use runOrSkipKnownError to handle transient network failures, unlike the Phoenixd tests. If the CLN node is temporarily unreachable while env vars are set, these tests will fail instead of being gracefully skipped. Consider wrapping the test bodies for consistency, or importing only the helpers you actually use (line 3 imports runOrSkipKnownError from helpers but it's not used in the Phoenixd test — actually it's not imported here at all, which is fine).

bindings/typescript/src/factory.ts (1)

50-67: Add a default branch to guard against unhandled kind values.

If BackendNodeConfig is ever extended with a new kind but this switch isn't updated, the function silently returns undefined at runtime despite the LightningNode return type. A default branch with an exhaustiveness check prevents this.

♻️ Proposed fix
     case 'blink':
       return new BlinkNode(input.config, options);
+    default: {
+      const _exhaustive: never = input;
+      throw new Error(`Unknown node kind: ${(_exhaustive as any).kind}`);
+    }
   }
 }
bindings/typescript/src/nodes/strike.ts (1)

173-196: preimage is always empty in payInvoice response.

If the Strike API does not return the preimage, this is acceptable, but consumers relying on the PayInvoiceResponse.preimage field for proof-of-payment will get an empty string with no indication it's unavailable.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@bindings/typescript/src/nodes/blink.ts`:
- Around line 307-318: lookupInvoice currently searches only the first 100
transactions (limit: 100) and can miss older matches; update lookupInvoice to
page through listTransactions using its cursor-based pagination (call
listTransactions repeatedly with the returned nextCursor/after token until a
transaction with paymentHash is found or no more pages) rather than a fixed
small limit, and ensure onInvoiceEvents (which delegates to lookupInvoice)
continues to work with the updated behavior; reference the lookupInvoice method
and the listTransactions call to implement a loop that checks each page's
results for params.paymentHash and returns the match or throws the LniError if
exhausted.
- Around line 361-369: The loop uses limit = params.limit > 0 ? params.limit :
Number.POSITIVE_INFINITY which allows an unbounded fetch when params.limit ≤ 0;
change this to apply a sensible max cap (e.g. DEFAULT_MAX_FETCH) instead of
POSITIVE_INFINITY. Update the initialization of limit and possibly pageSize so
when params.limit ≤ 0 you set limit = Math.min(DEFAULT_MAX_FETCH, someFallback)
and keep pageSize computed from that bounded limit; modify the while loop that
checks transactions.length < limit to rely on the bounded limit. Use a clearly
named constant (e.g. DEFAULT_MAX_FETCH or MAX_TRANSACTION_FETCH) and adjust any
related logic around pageSize/after/skipped in the same function (in blink.ts)
to ensure the loop terminates within the cap.
- Around line 201-207: The code is passing amount as a string to Blink's GraphQL
API; change the amount field in the this.gql call (where
BlinkInvoiceCreateResponse is used) to pass a numeric Sat amount instead of
String(Math.floor((params.amountMsats ?? 0) / 1000)). Remove the String(...)
wrapper so amount is Math.floor((params.amountMsats ?? 0) / 1000) (a number) and
leave the rest (walletId, memo from params.description) intact to satisfy
LnInvoiceCreateInput.amount being a numeric SatAmount.

In `@bindings/typescript/src/nodes/cln.ts`:
- Around line 314-322: getOffer currently returns an empty partial Offer when
listOffers yields no results; change it to throw an LniError (matching
lookupInvoice's behavior) instead of returning a bogus object so callers can
detect "not found". Locate the getOffer function and, after awaiting
this.listOffers(search), if offers.length === 0 throw new LniError('Offer not
found', { search }) (or similar contextual message/metadata), otherwise return
offers[0]; preserve the Promise<Offer> signature and import/use the existing
LniError type used by lookupInvoice.
- Around line 246-263: The returned Transaction sets expiresAt to the expiry
duration instead of an absolute epoch time; update the code paths that call
postJson (both the Bolt11 branch using postJson('/v1/invoice') and the Bolt12
branch) so expiresAt is computed as current Unix time plus the expiry duration
(e.g. Math.floor(Date.now()/1000) + (params.expiry ?? 3600)) before calling
emptyTransaction; adjust any places using params.expiry to treat it as a
duration and ensure emptyTransaction receives an absolute timestamp for
expiresAt.

In `@bindings/typescript/src/nodes/speed.ts`:
- Around line 217-230: The pagination is applied before filtering by
params.paymentHash, causing matching transactions outside the sliced window to
be dropped; change the sequence so you apply the paymentHash filter prior to
slicing (and after mapping to transaction objects) — e.g., map rows via
this.sendToTransaction, then if params.paymentHash is present filter the mapped
txs by tx.paymentHash === params.paymentHash, then sort by createdAt and finally
apply the slice using params.from and params.limit so the returned txs respect
both the filter and pagination.

In `@bindings/typescript/src/nodes/strike.ts`:
- Around line 173-196: The payInvoice flow can lose the payment result if the
final getJson call fails after
patchJson(`/payment-quotes/${quote.paymentQuoteId}/execute`) succeeds; modify
payInvoice (which calls postJson, patchJson, getJson) to wrap the final fetch of
the payment (the getJson(`/payments/${execution.paymentId}`) call) in a
try/catch and, on failure, surface execution.paymentId (e.g., return a response
containing paymentId and best-effort fee/payment fields or throw an error that
includes execution.paymentId) so callers can independently recover the payment
state by calling getJson with execution.paymentId.
- Around line 244-255: In listTransactions, the payments call uses non-OData
params (skip/top) so pagination will fail; update the getJson call that fetches
StrikePaymentsResponse from '/payments' to pass OData-style parameters ('$skip'
and '$top') matching how receives uses '$skip'/'$top' and how the API expects
pagination; keep the same param sources (params.from and params.limit) when
replacing skip/top with '$skip'/'$top'.
🧹 Nitpick comments (4)
bindings/typescript/src/nodes/cln.ts (1)

332-345: Misleading variable name: bolt11 holds a BOLT12 invoice.

fetchInvoiceFromOffer returns a BOLT12 invoice string, but it's stored in a variable named bolt11 and passed as the bolt11 key. While the CLN pay API accepts either format via the bolt11 parameter, the local variable name is confusing.

Suggested rename
-    const bolt11 = await this.fetchInvoiceFromOffer(offer, amountMsats, payerNote);
+    const invoice = await this.fetchInvoiceFromOffer(offer, amountMsats, payerNote);
     const payload = await this.postJson<ClnPayResponse>('/v1/pay', {
-      bolt11,
+      bolt11: invoice,
       maxfeepercent: 1,
       retry_for: 60,
     });
bindings/typescript/src/nodes/blink.ts (1)

393-405: settledAt uses createdAt as a stand-in, which is inaccurate.

Line 400 sets settledAt to node.createdAt for successful transactions. Settlement can occur well after creation (e.g., multi-hop payments). If Blink's API provides a separate settlement timestamp, prefer that. Otherwise, a brief comment noting the approximation would help future maintainers.

bindings/typescript/src/nodes/strike.ts (2)

317-319: decode is a no-op — is this intentional?

Returning the input string unchanged means callers get no decoded invoice information (e.g., amount, destination, expiry). Other node adapters may provide richer decoding. If Strike doesn't offer a server-side decode endpoint, consider using a client-side bolt11 decoder so the interface contract is meaningful.


83-89: content-type: application/json header sent on GET requests.

headers() unconditionally sets content-type: application/json, which is unnecessary (and technically incorrect per HTTP semantics) for GET requests that have no body. While most servers ignore it, some proxies or strict servers may reject the request.

@ntheile
Copy link
Contributor Author

ntheile commented Feb 7, 2026

@CodeRabbit review

@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ntheile ntheile merged commit 631c811 into master Feb 7, 2026
1 of 9 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Feb 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant