Skip to content

Commit 61a46c6

Browse files
committed
feat(gl-sdk): add list_invoices, list_pays, and unified list_payments API
Three new Node methods for querying payment history: - list_invoices(): wraps CLN ListInvoices with full native filtering (label, invstring, payment_hash, offer_id) and pagination (index, start, limit) - list_pays(): wraps CLN ListPays with native filtering (bolt11, payment_hash, status) and pagination - list_payments(): merges both into a unified Payment timeline sorted newest-first, defaults to COMPLETE status filter New types: Invoice, InvoiceStatus, Pay, ListInvoicesResponse, ListPaysResponse, Payment, PaymentDirection, PaymentStatus, ListPaymentsResponse, ListIndex. Payment includes optional invoice/pay fields for drill-down access to full CLN-specific details. Kotlin extensions provide default null parameters for idiomatic API. Includes Python integration tests and Android instrumented tests.
1 parent 5cb9e0a commit 61a46c6

12 files changed

Lines changed: 2326 additions & 42 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/superpowers/plans/2026-04-01-list-payments-api.md

Whitespace-only changes.

docs/superpowers/specs/2026-04-01-list-payments-api-design.md

Whitespace-only changes.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Instrumented tests for list_invoices, list_pays, and list_payments.
2+
// Tests type construction, empty lists on fresh nodes, invoice creation
3+
// visibility, and status filtering.
4+
5+
package com.blockstream.glsdk
6+
7+
import android.system.Os
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import org.junit.Assert.*
10+
import org.junit.Before
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import kotlin.uuid.ExperimentalUuidApi
14+
import kotlin.uuid.Uuid
15+
16+
@RunWith(AndroidJUnit4::class)
17+
class ListPaymentTest {
18+
19+
@Before
20+
fun setup() {
21+
Os.setenv("RUST_LOG", "trace", true)
22+
}
23+
24+
// BIP39 test vector — not a real wallet
25+
private val testMnemonic =
26+
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
27+
28+
// ============================================================
29+
// Type construction
30+
// ============================================================
31+
32+
@Test
33+
fun invoice_status_enum_values() {
34+
assertNotNull(InvoiceStatus.UNPAID)
35+
assertNotNull(InvoiceStatus.PAID)
36+
assertNotNull(InvoiceStatus.EXPIRED)
37+
}
38+
39+
@Test
40+
fun payment_direction_enum_values() {
41+
assertNotNull(PaymentDirection.SENT)
42+
assertNotNull(PaymentDirection.RECEIVED)
43+
}
44+
45+
@Test
46+
fun payment_status_enum_values() {
47+
assertNotNull(PaymentStatus.PENDING)
48+
assertNotNull(PaymentStatus.COMPLETE)
49+
assertNotNull(PaymentStatus.FAILED)
50+
assertNotNull(PaymentStatus.EXPIRED)
51+
}
52+
53+
// ============================================================
54+
// Invoice appears in list_invoices and list_payments
55+
// ============================================================
56+
57+
@OptIn(ExperimentalUuidApi::class)
58+
@Test
59+
fun created_invoice_appears_in_list_invoices() {
60+
val config = Config()
61+
registerOrRecover(testMnemonic, null, config).use { node ->
62+
val label = Uuid.random().toString()
63+
node.receive(label = label, description = "Coffee", amountMsat = 10_000_000uL)
64+
65+
val result = node.listInvoices()
66+
val matching = result.invoices.filter { it.label == label }
67+
assertEquals("Should find exactly one invoice", 1, matching.size)
68+
assertEquals(InvoiceStatus.UNPAID, matching[0].status)
69+
assertEquals("Coffee", matching[0].description)
70+
assertNotNull(matching[0].bolt11)
71+
// Unpaid invoices have no preimage yet
72+
assertNull(matching[0].paymentPreimage)
73+
// Destination pubkey is parsed from the bolt11 invoice
74+
assertNotNull(matching[0].destinationPubkey)
75+
}
76+
}
77+
78+
@OptIn(ExperimentalUuidApi::class)
79+
@Test
80+
fun default_filter_excludes_unpaid() {
81+
val config = Config()
82+
registerOrRecover(testMnemonic, null, config).use { node ->
83+
val label = Uuid.random().toString()
84+
node.receive(label = label, description = "Tea", amountMsat = 5_000_000uL)
85+
86+
// Default (null) filters to COMPLETE, so unpaid invoice should not appear
87+
val result = node.listPayments()
88+
val matching = result.payments.filter { it.label == label }
89+
assertTrue("Default filter should exclude UNPAID invoice", matching.isEmpty())
90+
}
91+
}
92+
93+
@OptIn(ExperimentalUuidApi::class)
94+
@Test
95+
fun pending_filter_includes_unpaid_invoice() {
96+
val config = Config()
97+
registerOrRecover(testMnemonic, null, config).use { node ->
98+
val label = Uuid.random().toString()
99+
node.receive(label = label, description = "Tea", amountMsat = 5_000_000uL)
100+
101+
val result = node.listPayments(PaymentStatus.PENDING)
102+
val matching = result.payments.filter { it.label == label }
103+
assertEquals("PENDING filter should include UNPAID invoice", 1, matching.size)
104+
assertEquals(PaymentDirection.RECEIVED, matching[0].direction)
105+
assertEquals(PaymentStatus.PENDING, matching[0].status)
106+
assertEquals(InvoiceStatus.UNPAID, matching[0].invoiceStatus)
107+
assertNull(matching[0].payStatus)
108+
// Received payments have no fee
109+
assertNull(matching[0].feeMsat)
110+
// For received, amount_total_msat equals amount_msat
111+
assertEquals(matching[0].amountMsat, matching[0].amountTotalMsat)
112+
// Destination pubkey is available on the unified Payment
113+
assertNotNull(matching[0].destinationPubkey)
114+
assertNotNull(matching[0].invoice)
115+
assertEquals(label, matching[0].invoice?.label)
116+
assertNull(matching[0].pay)
117+
}
118+
}
119+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Kotlin extension functions for Node that provide default parameter values.
2+
// The UniFFI-generated bindings require all parameters explicitly;
3+
// these extensions give Kotlin consumers idiomatic optional-parameter APIs.
4+
5+
package com.blockstream.glsdk
6+
7+
@Throws(Exception::class)
8+
public fun Node.listInvoices(
9+
label: String? = null,
10+
invstring: String? = null,
11+
paymentHash: ByteArray? = null,
12+
offerId: String? = null,
13+
index: ListIndex? = null,
14+
start: ULong? = null,
15+
limit: UInt? = null,
16+
): ListInvoicesResponse = listInvoices(label, invstring, paymentHash, offerId, index, start, limit)
17+
18+
@Throws(Exception::class)
19+
public fun Node.listPays(
20+
bolt11: String? = null,
21+
paymentHash: ByteArray? = null,
22+
status: PayStatus? = null,
23+
index: ListIndex? = null,
24+
start: ULong? = null,
25+
limit: UInt? = null,
26+
): ListPaysResponse = listPays(bolt11, paymentHash, status, index, start, limit)
27+
28+
@Throws(Exception::class)
29+
public fun Node.listPayments(
30+
status: PaymentStatus? = null,
31+
): ListPaymentsResponse = listPayments(status)

libs/gl-sdk-napi/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ pub struct ReceiveResponse {
3232
pub struct SendResponse {
3333
pub status: u32,
3434
pub preimage: Buffer,
35+
pub payment_hash: Buffer,
36+
pub destination_pubkey: Option<Buffer>,
3537
/// Amount in millisatoshis (as i64 for JS compatibility)
3638
pub amount_msat: i64,
3739
/// Amount sent in millisatoshis (as i64 for JS compatibility)
@@ -512,6 +514,8 @@ impl Node {
512514
Ok(SendResponse {
513515
status: response.status as u32,
514516
preimage: Buffer::from(response.preimage),
517+
payment_hash: Buffer::from(response.payment_hash),
518+
destination_pubkey: response.destination_pubkey.map(Buffer::from),
515519
amount_msat: response.amount_msat as i64,
516520
amount_sent_msat: response.amount_sent_msat as i64,
517521
parts: response.parts,

libs/gl-sdk/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ anyhow = "1"
1212
bip39 = "2.2.0"
1313
gl-client = { version = "0.3.3", path = "../gl-client" }
1414
hex = "0.4"
15+
lightning-invoice = "0.33"
1516
once_cell = "1.21.3"
1617
thiserror = "2.0.17"
1718
tokio = { version = "1", features = ["sync"] }

0 commit comments

Comments
 (0)