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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions bindings/typescript-arkade/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions bindings/typescript-arkade/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sunnyln/lni-arkade",
"version": "0.2.11",
"version": "0.2.12",
"private": false,
"description": "Optional Arkade Boltz adapter for @sunnyln/lni.",
"type": "module",
Expand Down Expand Up @@ -52,7 +52,7 @@
"test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/arkade-boltz.real.test.ts"
},
"peerDependencies": {
"@sunnyln/lni": "^0.2.11"
"@sunnyln/lni": "^0.2.12"
},
"dependencies": {
"@arkade-os/boltz-swap": "^0.3.3",
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions bindings/typescript-spark/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions bindings/typescript-spark/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sunnyln/lni-spark",
"version": "0.2.11",
"version": "0.2.12",
"private": false,
"description": "Optional Spark adapter for @sunnyln/lni with browser and Expo compatible pure TypeScript signer patching.",
"type": "module",
Expand Down Expand Up @@ -56,7 +56,7 @@
"test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/spark.real.test.ts"
},
"peerDependencies": {
"@sunnyln/lni": "^0.2.11"
"@sunnyln/lni": "^0.2.12"
},
"dependencies": {
"@buildonspark/spark-sdk": "^0.6.3",
Expand Down
4 changes: 2 additions & 2 deletions bindings/typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bindings/typescript/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sunnyln/lni",
"version": "0.2.11",
"version": "0.2.12",
"private": false,
"description": "Lightning Node Interface. Connect to CLN, LND, Phoenixd, NWC, Strike, Speed and Blink. Supports BOLT11, BOLT12, LNURL and Lightning Address.",
"type": "module",
Expand Down
97 changes: 97 additions & 0 deletions bindings/typescript/src/__tests__/nwc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const nwcMocks = vi.hoisted(() => ({
Nip47Error: class Nip47Error extends Error {
code: string;

constructor(message: string, code: string) {
super(message);
this.code = code;
}
},
payInvoice: vi.fn(),
lookupInvoice: vi.fn(),
listTransactions: vi.fn(),
Expand All @@ -12,6 +20,7 @@ const bolt11Mocks = vi.hoisted(() => ({
}));

vi.mock('@getalby/sdk/nwc', () => ({
Nip47Error: nwcMocks.Nip47Error,
NWCClient: Object.assign(
vi.fn().mockImplementation(() => ({
payInvoice: nwcMocks.payInvoice,
Expand All @@ -32,6 +41,7 @@ vi.mock('../decode.js', () => ({
}));

import { NwcNode } from '../nodes/nwc.js';
import { NwcError } from '../errors.js';
import { registerSha256DigestFallback } from '../internal/sha256.js';

const PAYMENT_HASH = '31b06bf9be4c938914030eb23d583a4fe6f6e2f3374293170f027be248ed6370';
Expand All @@ -40,6 +50,7 @@ const ZERO_PREIMAGE = '000000000000000000000000000000000000000000000000000000000
const ZERO_PREIMAGE_PAYMENT_HASH = '66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925';
const BOLT11_INVOICE = 'lnbc1testinvoice';
const NWC_URI = 'nostr+walletconnect://wallet?relay=wss://relay.example&secret=test';
const NWC_URI_WITH_LUD16 = `${NWC_URI}&lud16=test%40example.com`;
const originalConsoleError = console.error;
const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');

Expand All @@ -65,6 +76,14 @@ function makeNode() {
return new NwcNode({ nwcUri: NWC_URI });
}

function makeJsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
headers: {
'content-type': 'application/json',
},
});
}

function mockBolt11Decode() {
bolt11Mocks.decode.mockImplementation((invoice: string) => {
if (invoice !== BOLT11_INVOICE) {
Expand Down Expand Up @@ -105,6 +124,21 @@ afterEach(() => {
});

describe('NwcNode.payInvoice', () => {
it('preserves typed NIP-47 wallet error codes from the SDK', async () => {
nwcMocks.payInvoice.mockRejectedValue(new nwcMocks.Nip47Error('quota spent', 'QUOTA_EXCEEDED'));

await expect(makeNode().payInvoice({ invoice: BOLT11_INVOICE })).rejects.toMatchObject({
name: 'NwcError',
code: 'NwcError',
nwcCode: 'QUOTA_EXCEEDED',
nwcMessage: 'quota spent',
operation: 'pay_invoice',
message: 'quota spent',
});

await expect(makeNode().payInvoice({ invoice: BOLT11_INVOICE })).rejects.toBeInstanceOf(NwcError);
});

it('hashes returned preimages with a registered fallback when global crypto.subtle is absent', async () => {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
Expand Down Expand Up @@ -145,6 +179,69 @@ describe('NwcNode.payInvoice', () => {
});
});

describe('NwcNode.getLightningAddress', () => {
it('returns the lud16 Lightning Address and true when LNURL verify succeeds', async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
makeJsonResponse({
callback: 'https://example.com/lnurl/callback',
maxSendable: 500_000_000,
minSendable: 1,
metadata: '[["text/plain","test"]]',
tag: 'payRequest',
}),
)
.mockResolvedValueOnce(
makeJsonResponse({
pr: 'lnbc1testinvoice',
verify: 'https://example.com/lnurl/verify',
}),
)
.mockResolvedValueOnce(makeJsonResponse({ status: 'OK' }));

const response = await new NwcNode({ nwcUri: NWC_URI_WITH_LUD16 }, { fetch: fetchMock }).getLightningAddress();

expect(response).toEqual({
lightningAddress: 'test@example.com',
lnurlVerifySupported: true,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://example.com/.well-known/lnurlp/test');
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://example.com/lnurl/callback?amount=100000');
expect(fetchMock.mock.calls[2]?.[0]).toBe('https://example.com/lnurl/verify');
});

it('returns false when the Lightning Address LNURL callback has no verify endpoint', async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
makeJsonResponse({
callback: 'https://example.com/lnurl/callback',
maxSendable: 500_000_000,
minSendable: 1,
metadata: '[["text/plain","test"]]',
tag: 'payRequest',
}),
)
.mockResolvedValueOnce(makeJsonResponse({ pr: 'lnbc1testinvoice' }));

const response = await new NwcNode({ nwcUri: NWC_URI_WITH_LUD16 }, { fetch: fetchMock }).getLightningAddress();

expect(response).toEqual({
lightningAddress: 'test@example.com',
lnurlVerifySupported: false,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('throws clearly when the NWC URI has no lud16 value', async () => {
await expect(makeNode().getLightningAddress()).rejects.toThrow(
'NWC URI does not include a lud16 Lightning Address.',
);
});
});

function hexToBytesForTest(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
Expand Down
44 changes: 43 additions & 1 deletion bindings/typescript/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,31 @@ export type LniErrorCode =
| 'Json'
| 'NetworkError'
| 'InvalidInput'
| 'LnurlError';
| 'LnurlError'
| 'NwcError';

export type NwcStandardErrorCode =
| 'RATE_LIMITED'
| 'NOT_IMPLEMENTED'
| 'INSUFFICIENT_BALANCE'
| 'PAYMENT_FAILED'
| 'NOT_FOUND'
| 'QUOTA_EXCEEDED'
| 'RESTRICTED'
| 'UNAUTHORIZED'
| 'INTERNAL'
| 'UNSUPPORTED_ENCRYPTION'
| 'OTHER';

export type NwcErrorCode = NwcStandardErrorCode | (string & {});

export type NwcErrorOperation =
| 'get_info'
| 'get_balance'
| 'make_invoice'
| 'pay_invoice'
| 'lookup_invoice'
| 'list_transactions';

export class LniError extends Error {
public readonly code: LniErrorCode;
Expand All @@ -20,6 +44,24 @@ export class LniError extends Error {
}
}

export class NwcError extends LniError {
public readonly nwcCode: NwcErrorCode;
public readonly nwcMessage: string;
public readonly operation?: NwcErrorOperation;

constructor(
nwcCode: NwcErrorCode,
message: string,
options?: { operation?: NwcErrorOperation; cause?: unknown },
) {
super('NwcError', message, options?.cause !== undefined ? { cause: options.cause } : undefined);
this.name = 'NwcError';
this.nwcCode = nwcCode;
this.nwcMessage = message;
this.operation = options?.operation;
}
}

export function asLniError(error: unknown, fallbackCode: LniErrorCode = 'Api'): LniError {
if (error instanceof LniError) {
return error;
Expand Down
Loading