Skip to content

Commit ea961ca

Browse files
committed
Add Lightning Address and LNURL-pay support for NWC wallet
1 parent 143b451 commit ea961ca

7 files changed

Lines changed: 1021 additions & 17 deletions

File tree

src/main/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const bl = require('./blocklist')
99
const doh = require('./doh')
1010
const v4v = require('./value4value')
1111
const cashu = require('./cashu')
12+
const lnurl = require('./lnurl')
1213

1314
const isDev = !app.isPackaged
1415

@@ -524,7 +525,18 @@ ipcMain.handle('nostr-nip04-decrypt', async (ipcEvent, { pubkey, text }) => {
524525

525526
return nip04.decrypt(privKeyHex, pubkey, text)
526527
})
528+
// ── IPC: LNURL / Lightning Address ────────────────────────────────────────────
529+
ipcMain.handle('lnurl-is-lightning-address', (_, { value }) => {
530+
return lnurl.isLightningAddress(value)
531+
})
532+
533+
ipcMain.handle('lnurl-fetch-pay-params', (_, { address }) => {
534+
return lnurl.fetchPayParams(address)
535+
})
527536

537+
ipcMain.handle('lnurl-request-invoice', (_, args) => {
538+
return lnurl.requestInvoice(args)
539+
})
528540
// ── IPC: NWC lightning ────────────────────────────────────────────────────────
529541
ipcMain.handle('nwc-connect', (_, args) => nwc.connect(DB, args))
530542
ipcMain.handle('nwc-disconnect', () => nwc.disconnect(DB))

src/main/lnurl.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
'use strict'
2+
3+
const axios = require('axios')
4+
5+
function isLightningAddress(value) {
6+
if (!value || typeof value !== 'string') return false
7+
8+
const v = value.trim()
9+
10+
// Basic Lightning Address format: user@domain.tld
11+
// Avoid treating normal emails inside search queries as payments.
12+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)
13+
}
14+
15+
function lightningAddressToUrl(address) {
16+
const [name, domain] = address.trim().split('@')
17+
18+
if (!name || !domain) {
19+
throw new Error('Invalid Lightning Address')
20+
}
21+
22+
return `https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`
23+
}
24+
25+
async function fetchPayParams(address) {
26+
if (!isLightningAddress(address)) {
27+
throw new Error('Invalid Lightning Address')
28+
}
29+
30+
const url = lightningAddressToUrl(address)
31+
32+
const res = await axios.get(url, {
33+
timeout: 12000,
34+
headers: {
35+
accept: 'application/json',
36+
'user-agent': 'Zap-Browser-LNURL',
37+
},
38+
})
39+
40+
const data = res.data || {}
41+
42+
if (data.status === 'ERROR') {
43+
throw new Error(data.reason || 'LNURL service returned an error')
44+
}
45+
46+
if (data.tag && data.tag !== 'payRequest') {
47+
throw new Error(`Unsupported LNURL tag: ${data.tag}`)
48+
}
49+
50+
if (!data.callback) {
51+
throw new Error('LNURL response missing callback')
52+
}
53+
54+
if (!Number.isFinite(Number(data.minSendable)) || !Number.isFinite(Number(data.maxSendable))) {
55+
throw new Error('LNURL response missing min/max amount')
56+
}
57+
58+
return {
59+
address,
60+
callback: data.callback,
61+
minSendable: Number(data.minSendable),
62+
maxSendable: Number(data.maxSendable),
63+
metadata: data.metadata || '',
64+
commentAllowed: Number(data.commentAllowed || 0),
65+
}
66+
}
67+
68+
async function requestInvoice({ callback, amountMsat, comment }) {
69+
if (!callback || typeof callback !== 'string') {
70+
throw new Error('Invalid LNURL callback')
71+
}
72+
73+
const url = new URL(callback)
74+
url.searchParams.set('amount', String(amountMsat))
75+
76+
if (comment) {
77+
url.searchParams.set('comment', comment)
78+
}
79+
80+
const res = await axios.get(url.toString(), {
81+
timeout: 12000,
82+
headers: {
83+
accept: 'application/json',
84+
'user-agent': 'Zap-Browser-LNURL',
85+
},
86+
})
87+
88+
const data = res.data || {}
89+
90+
if (data.status === 'ERROR') {
91+
throw new Error(data.reason || 'LNURL callback returned an error')
92+
}
93+
94+
if (!data.pr) {
95+
throw new Error('LNURL callback did not return an invoice')
96+
}
97+
98+
return {
99+
invoice: data.pr,
100+
routes: data.routes || [],
101+
successAction: data.successAction || null,
102+
}
103+
}
104+
105+
module.exports = {
106+
isLightningAddress,
107+
lightningAddressToUrl,
108+
fetchPayParams,
109+
requestInvoice,
110+
}

src/main/nwc.js

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,43 @@ function openWs(relayUrl, walletPubkey, secretHex) {
4747
try {
4848
const msg = JSON.parse(data.toString())
4949
if (msg[0] !== 'EVENT') return
50+
5051
const event = msg[2]
51-
if (event.kind !== 23195) return
52+
if (!event || event.kind !== 23195) return
53+
5254
const decrypted = await nip04.decrypt(secretHex, walletPubkey, event.content)
5355
const response = JSON.parse(decrypted)
54-
// Resolve the oldest pending call
55-
for (const [key, p] of pendingCalls.entries()) {
56-
clearTimeout(p.timer)
57-
if (response.error) p.reject(new Error(response.error.message || 'NWC error'))
58-
else p.resolve(response.result)
59-
pendingCalls.delete(key)
60-
break
56+
57+
const requestTag = Array.isArray(event.tags)
58+
? event.tags.find(t => Array.isArray(t) && t[0] === 'e')
59+
: null
60+
61+
const requestId = requestTag?.[1]
62+
63+
let pending = requestId ? pendingCalls.get(requestId) : null
64+
let pendingKey = requestId
65+
66+
// Legacy fallback for wallet services that do not return an e tag.
67+
// This keeps compatibility but still prefers proper NIP-47 correlation.
68+
if (!pending) {
69+
const first = pendingCalls.entries().next()
70+
if (first.done) return
71+
pendingKey = first.value[0]
72+
pending = first.value[1]
6173
}
62-
} catch (_) {}
74+
75+
clearTimeout(pending.timer)
76+
77+
if (response.error) {
78+
pending.reject(new Error(response.error.message || 'NWC error'))
79+
} else {
80+
pending.resolve(response.result)
81+
}
82+
83+
pendingCalls.delete(pendingKey)
84+
} catch (err) {
85+
console.error('[NWC] Failed to process response:', err.message)
86+
}
6387
})
6488

6589
ws.on('error', (err) => { clearTimeout(timeout); reject(err) })
@@ -89,12 +113,21 @@ async function nwcRequest(method, params = {}) {
89113
event.sig = getSignature(event, secret)
90114

91115
return new Promise((resolve, reject) => {
92-
const key = method + '_' + Date.now()
116+
const requestId = event.id
117+
93118
const timer = setTimeout(() => {
94-
pendingCalls.delete(key)
119+
pendingCalls.delete(requestId)
95120
reject(new Error(`NWC request timed out: ${method}`))
96121
}, 30000)
97-
pendingCalls.set(key, { resolve, reject, timer })
122+
123+
pendingCalls.set(requestId, {
124+
method,
125+
resolve,
126+
reject,
127+
timer,
128+
createdAt: Date.now(),
129+
})
130+
98131
activeWs.send(JSON.stringify(['EVENT', event]))
99132
})
100133
}

src/preload/shell.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ contextBridge.exposeInMainWorld('zap', {
5353
nostrSignEvent: (a) => ipcRenderer.invoke('nostr-sign-event', a),
5454
nostrGetPubkey: () => ipcRenderer.invoke('nostr-get-pubkey'),
5555

56+
// LNURL / Lightning Address
57+
lnurlIsLightningAddress: (a) => ipcRenderer.invoke('lnurl-is-lightning-address', a),
58+
lnurlFetchPayParams: (a) => ipcRenderer.invoke('lnurl-fetch-pay-params', a),
59+
lnurlRequestInvoice: (a) => ipcRenderer.invoke('lnurl-request-invoice', a),
60+
5661
// NWC Lightning
5762
nwcConnect: (a) => ipcRenderer.invoke('nwc-connect', a),
5863
nwcDisconnect: () => ipcRenderer.invoke('nwc-disconnect'),

src/renderer/components/wallet/WalletPanel.tsx

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ function NWCTab() {
3434
const [nwcUri,setNwcUri]=useState('')
3535
const [name,setName]=useState('')
3636
const [invoice,setInvoice]=useState('')
37+
const [sendAmount,setSendAmount]=useState('')
38+
const [sendComment,setSendComment]=useState('')
3739
const [amount,setAmount]=useState('')
3840
const [desc,setDesc]=useState('')
3941
const [genInv,setGenInv]=useState('')
@@ -55,10 +57,42 @@ function NWCTab() {
5557
catch(e:any){show(String(e),'err')}
5658
setLoading(false)
5759
}
60+
const isLightningAddress=(v:string)=>/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.trim())
61+
5862
const pay=async()=>{
63+
const target=invoice.trim()
5964
setLoading(true);setMsg('')
60-
try{ await zap()?.nwcPayInvoice({invoice}); show('Pagato! ⚡'); setInvoice(''); setAction(null) }
61-
catch(e:any){show(String(e),'err')}
65+
66+
try{
67+
if(isLightningAddress(target)){
68+
const sats=parseInt(sendAmount)
69+
if(!Number.isFinite(sats)||sats<=0) throw new Error('Inserisci un importo valido in sats')
70+
71+
const params=await zap()?.lnurlFetchPayParams({address:target})
72+
const amountMsat=sats*1000
73+
74+
if(amountMsat<params.minSendable||amountMsat>params.maxSendable){
75+
throw new Error(`Importo fuori range. Min ${Math.ceil(params.minSendable/1000)} sats, max ${Math.floor(params.maxSendable/1000)} sats`)
76+
}
77+
78+
const inv=await zap()?.lnurlRequestInvoice({
79+
callback:params.callback,
80+
amountMsat,
81+
comment:sendComment.trim()||undefined
82+
})
83+
84+
await zap()?.nwcPayInvoice({invoice:inv.invoice})
85+
}else{
86+
await zap()?.nwcPayInvoice({invoice:target})
87+
}
88+
89+
show('Pagato! ⚡')
90+
setInvoice('')
91+
setSendAmount('')
92+
setSendComment('')
93+
setAction(null)
94+
}
95+
catch(e:any){show(String(e?.message||e),'err')}
6296
setLoading(false)
6397
}
6498
const mkInv=async()=>{
@@ -112,11 +146,19 @@ function NWCTab() {
112146
<button className="act-btn" onClick={()=>setAction('receive')}>↓ Invoice</button>
113147
</div>
114148
{action==='send'&&<>
115-
<div className="field"><label>Invoice Lightning</label>
116-
<textarea className="inp inp-mono" rows={4} placeholder="lnbc..." value={invoice} onChange={e=>setInvoice(e.target.value)}/></div>
149+
<div className="field"><label>Invoice Lightning o Lightning Address</label>
150+
<textarea className="inp inp-mono" rows={3} placeholder="lnbc... oppure name@domain.com" value={invoice} onChange={e=>setInvoice(e.target.value)}/></div>
151+
152+
{isLightningAddress(invoice)&&<>
153+
<div className="field"><label>Importo (sats)</label>
154+
<input className="inp" type="number" min="1" placeholder="21" value={sendAmount} onChange={e=>setSendAmount(e.target.value)}/></div>
155+
<div className="field"><label>Commento opzionale</label>
156+
<input className="inp" placeholder="Zap from Zap Browser" value={sendComment} onChange={e=>setSendComment(e.target.value)}/></div>
157+
</>}
158+
117159
{msg&&<div className={`msg ${msgK}`}>{msg}</div>}
118160
<div className="act-row">
119-
<button className="act-btn primary" disabled={loading||!invoice} onClick={pay}>{loading?'Pagando...':'Paga ⚡'}</button>
161+
<button className="act-btn primary" disabled={loading||!invoice||isLightningAddress(invoice)&&!sendAmount} onClick={pay}>{loading?'Pagando...':'Paga ⚡'}</button>
120162
<button className="act-btn" onClick={()=>setAction(null)}>Annulla</button>
121163
</div>
122164
</>}

0 commit comments

Comments
 (0)