feat: add Alby JS SDK client interop improvements

- Add NwcBudgetRenewal constants (daily/weekly/monthly/yearly/never)
- Add case-insensitive NwcTransactionState helpers (isSettled, isPending,
  isFailed, isAccepted) for JS SDK lowercase state interop
- Add URI test using Alby JS SDK test vector (69effe7b... pubkey)
- Add JS SDK interop tests: lowercase transaction states in responses,
  notifications, and list_transactions; empty get_budget response;
  all budget renewal periods; structured transaction metadata;
  full 13-method get_info response

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
This commit is contained in:
Claude
2026-03-14 03:28:17 +00:00
parent 4ee937ff0c
commit 83a6feeca4
4 changed files with 155 additions and 2 deletions
@@ -30,6 +30,22 @@ object NwcTransactionState {
const val SETTLED = "SETTLED"
const val FAILED = "FAILED"
const val ACCEPTED = "ACCEPTED"
fun isSettled(state: String?) = state.equals(SETTLED, ignoreCase = true)
fun isPending(state: String?) = state.equals(PENDING, ignoreCase = true)
fun isFailed(state: String?) = state.equals(FAILED, ignoreCase = true)
fun isAccepted(state: String?) = state.equals(ACCEPTED, ignoreCase = true)
}
object NwcBudgetRenewal {
const val DAILY = "daily"
const val WEEKLY = "weekly"
const val MONTHLY = "monthly"
const val YEARLY = "yearly"
const val NEVER = "never"
}
class NwcTransaction(
@@ -26,10 +26,12 @@ import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Interoperability tests using JSON structures matching Alby Hub's NIP-47 implementation.
* These tests verify that Amethyst can correctly parse responses from Alby Hub wallets.
* Interoperability tests using JSON structures matching Alby Hub (server)
* and Alby JS SDK (client) NIP-47 implementations.
* These tests verify that Amethyst can correctly parse responses from Alby wallets.
*/
class AlbyInteropTest {
// --- Alby Hub pay_invoice response format ---
@@ -404,4 +406,99 @@ class AlbyInteropTest {
assertEquals(144L, txn.settle_deadline)
assertEquals(NwcTransactionState.PENDING, txn.state)
}
// ===================================================================
// Alby JS SDK (client) interop tests
// The JS SDK uses lowercase transaction states while Hub uses uppercase.
// Both formats must be handled correctly.
// ===================================================================
@Test
fun testJsSdkLowercaseSettledState() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
assertEquals("settled", response.result?.state)
assertTrue(NwcTransactionState.isSettled(response.result?.state))
}
@Test
fun testJsSdkLowercasePendingState() {
val json =
"""{"result_type":"make_invoice","result":{"type":"incoming","state":"pending","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<MakeInvoiceSuccessResponse>(response)
assertEquals("pending", response.result?.state)
assertTrue(NwcTransactionState.isPending(response.result?.state))
}
@Test
fun testJsSdkLowercaseStatesInListTransactions() {
val json =
"""{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"settled","amount":1000,"created_at":1000},{"type":"outgoing","state":"failed","amount":2000,"created_at":2000},{"type":"incoming","state":"accepted","amount":3000,"created_at":3000}],"total_count":3}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<ListTransactionsSuccessResponse>(response)
val txns = response.result?.transactions
assertNotNull(txns)
assertEquals(3, txns.size)
assertTrue(NwcTransactionState.isSettled(txns[0].state))
assertTrue(NwcTransactionState.isFailed(txns[1].state))
assertTrue(NwcTransactionState.isAccepted(txns[2].state))
}
@Test
fun testJsSdkLowercaseStatesInNotification() {
val json =
"""{"notification_type":"payment_received","notification":{"type":"incoming","state":"settled","invoice":"lnbc...","amount":5000,"created_at":1000,"settled_at":2000}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<PaymentReceivedNotification>(notification)
assertTrue(NwcTransactionState.isSettled(notification.notification?.state))
}
@Test
fun testJsSdkEmptyGetBudgetResponse() {
// JS SDK allows get_budget to return empty object when no budget is set
val json = """{"result_type":"get_budget","result":{}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
assertNull(response.result?.used_budget)
assertNull(response.result?.total_budget)
assertNull(response.result?.renews_at)
assertNull(response.result?.renewal_period)
}
@Test
fun testJsSdkGetBudgetWithAllRenewalPeriods() {
for (period in listOf("daily", "weekly", "monthly", "yearly", "never")) {
val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":100000,"renewal_period":"$period"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
assertEquals(period, response.result?.renewal_period)
}
}
@Test
fun testJsSdkTransactionWithMetadata() {
// JS SDK supports structured metadata with comment, payer_data, nostr fields
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Thanks!","payer_data":{"name":"Alice","pubkey":"abc123"},"nostr":{"pubkey":"npub1...","tags":[["p","def456"]]}}}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
assertNotNull(response.result?.metadata)
}
@Test
fun testJsSdkGetInfoWithAllMethods() {
// JS SDK advertises all 13 single methods + notifications
val json =
"""{"result_type":"get_info","result":{"alias":"TestNode","methods":["get_info","get_balance","get_budget","make_invoice","pay_invoice","pay_keysend","lookup_invoice","list_transactions","sign_message","create_connection","make_hold_invoice","settle_hold_invoice","cancel_hold_invoice"],"notifications":["payment_received","payment_sent","hold_invoice_accepted"]}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetInfoSuccessResponse>(response)
assertEquals(13, response.result?.methods?.size)
assertEquals(3, response.result?.notifications?.size)
assertTrue(response.result?.methods?.contains(NwcMethod.GET_BUDGET) == true)
assertTrue(response.result?.methods?.contains(NwcMethod.SIGN_MESSAGE) == true)
assertTrue(response.result?.methods?.contains(NwcMethod.CREATE_CONNECTION) == true)
}
}
@@ -93,6 +93,20 @@ class Nip47WalletConnectTest {
}
}
// --- Alby JS SDK URI test vector ---
@Test
fun testParseAlbyJsSdkUri() {
// Test vector from @getalby/js-sdk NWCClient.test.ts
val uri =
"nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss%3A%2F%2Frelay.getalby.com%2Fv1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b&lud16=hello%40getalby.com"
val parsed = Nip47WalletConnect.parse(uri)
assertEquals("69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", parsed.pubKeyHex)
assertEquals("e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", parsed.secret)
assertEquals("hello@getalby.com", parsed.lud16)
}
// --- Nip47URI serialization ---
@Test
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip47WalletConnect
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NwcMethodTest {
@Test
@@ -81,6 +83,30 @@ class NwcMethodTest {
assertEquals("ACCEPTED", NwcTransactionState.ACCEPTED)
}
@Test
fun testTransactionStateCaseInsensitive() {
// Alby Hub uses uppercase, Alby JS SDK uses lowercase
assertTrue(NwcTransactionState.isSettled("SETTLED"))
assertTrue(NwcTransactionState.isSettled("settled"))
assertTrue(NwcTransactionState.isPending("PENDING"))
assertTrue(NwcTransactionState.isPending("pending"))
assertTrue(NwcTransactionState.isFailed("FAILED"))
assertTrue(NwcTransactionState.isFailed("failed"))
assertTrue(NwcTransactionState.isAccepted("ACCEPTED"))
assertTrue(NwcTransactionState.isAccepted("accepted"))
assertFalse(NwcTransactionState.isSettled("pending"))
assertFalse(NwcTransactionState.isSettled(null))
}
@Test
fun testBudgetRenewalConstants() {
assertEquals("daily", NwcBudgetRenewal.DAILY)
assertEquals("weekly", NwcBudgetRenewal.WEEKLY)
assertEquals("monthly", NwcBudgetRenewal.MONTHLY)
assertEquals("yearly", NwcBudgetRenewal.YEARLY)
assertEquals("never", NwcBudgetRenewal.NEVER)
}
@Test
fun testNwcError() {
val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed")