Skip to content

Commit eed8e31

Browse files
authored
fix(lightning): harden LN address claims and add username availability checks (#46)
1 parent c4e8cdb commit eed8e31

File tree

11 files changed

+401
-57
lines changed

11 files changed

+401
-57
lines changed

packages/sdk/test/lightning.test.js

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,15 @@ describe('LightningClient (via CoinPayClient)', () => {
6666
amount_msat: 100000,
6767
});
6868

69-
expect(client.request).toHaveBeenCalledWith('/lightning/offers', {
70-
method: 'POST',
71-
body: expect.stringContaining('"wallet_id":"w-1"'),
72-
});
73-
expect(client.request).toHaveBeenCalledWith('/lightning/offers', {
74-
method: 'POST',
75-
body: expect.stringContaining('"node_id":"n-1"'),
76-
});
77-
expect(client.request).toHaveBeenCalledWith('/lightning/offers', {
78-
method: 'POST',
79-
body: expect.stringContaining('"description":"Coffee"'),
69+
expect(client.request).toHaveBeenCalledTimes(1);
70+
const [url, opts] = client.request.mock.calls[0];
71+
expect(url).toBe('/lightning/offers');
72+
expect(opts.method).toBe('POST');
73+
expect(JSON.parse(opts.body)).toMatchObject({
74+
wallet_id: 'w-1',
75+
node_id: 'n-1',
76+
description: 'Coffee',
77+
amount_msat: 100000,
8078
});
8179
});
8280
});
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
4+
const mockCreatePayLink = vi.fn();
5+
const mockCreateUserWallet = vi.fn();
6+
const mockGetPayLink = vi.fn();
7+
const mockFrom = vi.fn();
8+
9+
vi.mock('@/lib/lightning/lnbits', () => ({
10+
createPayLink: (...args: unknown[]) => mockCreatePayLink(...args),
11+
createUserWallet: (...args: unknown[]) => mockCreateUserWallet(...args),
12+
getPayLink: (...args: unknown[]) => mockGetPayLink(...args),
13+
}));
14+
15+
vi.mock('@supabase/supabase-js', () => ({
16+
createClient: vi.fn(() => ({
17+
from: (...args: unknown[]) => mockFrom(...args),
18+
})),
19+
}));
20+
21+
describe('/api/lightning/address', () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'http://localhost:54321');
25+
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-key');
26+
27+
mockFrom.mockImplementation((table: string) => {
28+
const state: {
29+
selectCols?: string;
30+
eqMap: Record<string, unknown>;
31+
neqMap: Record<string, unknown>;
32+
updateValues?: Record<string, unknown>;
33+
} = {
34+
eqMap: {},
35+
neqMap: {},
36+
};
37+
38+
const query = {
39+
select: vi.fn((cols: string) => {
40+
state.selectCols = cols;
41+
return query;
42+
}),
43+
eq: vi.fn((key: string, value: unknown) => {
44+
state.eqMap[key] = value;
45+
return query;
46+
}),
47+
neq: vi.fn((key: string, value: unknown) => {
48+
state.neqMap[key] = value;
49+
return query;
50+
}),
51+
update: vi.fn((values: Record<string, unknown>) => {
52+
state.updateValues = values;
53+
return query;
54+
}),
55+
maybeSingle: vi.fn(async () => {
56+
if (table !== 'wallets') return { data: null, error: null };
57+
58+
if (state.selectCols === 'id' && state.eqMap.ln_username) {
59+
return { data: null, error: null };
60+
}
61+
62+
return { data: null, error: null };
63+
}),
64+
single: vi.fn(async () => {
65+
if (table !== 'wallets') return { data: null, error: null };
66+
67+
// Username availability check
68+
if (state.selectCols === 'id' && state.eqMap.ln_username) {
69+
return { data: null, error: null };
70+
}
71+
72+
// Wallet lookup
73+
if (typeof state.selectCols === 'string' && state.selectCols.includes('ln_wallet_adminkey')) {
74+
return {
75+
data: {
76+
id: 'w1',
77+
user_id: 'u1',
78+
ln_username: null,
79+
ln_wallet_adminkey: 'stale-admin-key',
80+
ln_paylink_id: null,
81+
},
82+
error: null,
83+
};
84+
}
85+
86+
return { data: null, error: null };
87+
}),
88+
};
89+
90+
return query;
91+
});
92+
});
93+
94+
it('auto-recovers when LNbits wallet is missing and retries claim', async () => {
95+
mockCreatePayLink
96+
.mockRejectedValueOnce(new Error('LNbits API error 404: No wallet found'))
97+
.mockResolvedValueOnce({ id: 123 });
98+
99+
mockCreateUserWallet.mockResolvedValue({
100+
id: 'ln-wallet-1',
101+
adminkey: 'fresh-admin-key',
102+
inkey: 'fresh-invoice-key',
103+
});
104+
105+
const { POST } = await import('./route');
106+
const req = new NextRequest('http://localhost:3000/api/lightning/address', {
107+
method: 'POST',
108+
body: JSON.stringify({ wallet_id: 'w1', username: 'chovy' }),
109+
headers: { 'content-type': 'application/json' },
110+
});
111+
112+
const res = await POST(req);
113+
const body = await res.json();
114+
115+
expect(res.status).toBe(200);
116+
expect(body.success).toBe(true);
117+
expect(body.lightning_address).toBe('chovy@coinpayportal.com');
118+
119+
expect(mockCreatePayLink).toHaveBeenCalledTimes(2);
120+
expect(mockCreateUserWallet).toHaveBeenCalledTimes(1);
121+
});
122+
123+
it('returns 404 when wallet does not exist', async () => {
124+
mockFrom.mockImplementation(() => {
125+
const query = {
126+
select: vi.fn(() => query),
127+
eq: vi.fn(() => query),
128+
neq: vi.fn(() => query),
129+
update: vi.fn(() => query),
130+
maybeSingle: vi.fn(async () => ({ data: null, error: null })),
131+
single: vi.fn(async () => ({ data: null, error: { message: 'not found' } })),
132+
};
133+
return query;
134+
});
135+
136+
const { POST } = await import('./route');
137+
const req = new NextRequest('http://localhost:3000/api/lightning/address', {
138+
method: 'POST',
139+
body: JSON.stringify({ wallet_id: 'missing', username: 'chovy' }),
140+
headers: { 'content-type': 'application/json' },
141+
});
142+
143+
const res = await POST(req);
144+
const body = await res.json();
145+
146+
expect(res.status).toBe(404);
147+
expect(body.error).toBe('Wallet not found');
148+
});
149+
150+
it('self-heals LNbits wallet linkage on GET when username exists', async () => {
151+
mockFrom.mockImplementation((table: string) => {
152+
const state: { selectCols?: string; eqMap: Record<string, unknown> } = { eqMap: {} };
153+
const query = {
154+
select: vi.fn((cols: string) => {
155+
state.selectCols = cols;
156+
return query;
157+
}),
158+
eq: vi.fn((key: string, value: unknown) => {
159+
state.eqMap[key] = value;
160+
return query;
161+
}),
162+
update: vi.fn(() => query),
163+
single: vi.fn(async () => {
164+
if (table !== 'wallets') return { data: null, error: null };
165+
return {
166+
data: {
167+
ln_username: 'chovy',
168+
ln_wallet_adminkey: 'stale-admin-key',
169+
ln_paylink_id: 99,
170+
},
171+
error: null,
172+
};
173+
}),
174+
};
175+
return query;
176+
});
177+
178+
mockGetPayLink.mockRejectedValueOnce(new Error('LNbits API error 404: No wallet found'));
179+
mockCreateUserWallet.mockResolvedValue({
180+
id: 'ln-wallet-2',
181+
adminkey: 'fresh-admin-key',
182+
inkey: 'fresh-invoice-key',
183+
});
184+
mockCreatePayLink.mockResolvedValue({ id: 321 });
185+
186+
const { GET } = await import('./route');
187+
const req = new NextRequest('http://localhost:3000/api/lightning/address?wallet_id=w1');
188+
189+
const res = await GET(req);
190+
const body = await res.json();
191+
192+
expect(res.status).toBe(200);
193+
expect(body.lightning_address).toBe('chovy@coinpayportal.com');
194+
expect(mockGetPayLink).toHaveBeenCalledTimes(1);
195+
expect(mockCreateUserWallet).toHaveBeenCalledTimes(1);
196+
expect(mockCreatePayLink).toHaveBeenCalledTimes(1);
197+
});
198+
});

0 commit comments

Comments
 (0)