245 lines
6.6 KiB
JavaScript
245 lines
6.6 KiB
JavaScript
import Service, { service } from '@ember/service';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { ZapRequestFactory } from 'applesauce-common/factories';
|
|
import {
|
|
getInvoice,
|
|
parseBolt11,
|
|
parseLNURLOrAddress,
|
|
} from 'applesauce-common/helpers';
|
|
import { getInboxes } from 'applesauce-core/helpers/mailboxes';
|
|
import { firstValueFrom, timeout, catchError, of } from 'rxjs';
|
|
|
|
const DEFAULT_ZAP_RELAYS = [
|
|
'wss://relay.damus.io',
|
|
'wss://nos.lol',
|
|
'wss://relay.primal.net',
|
|
];
|
|
|
|
const DIRECTORY_RELAYS = [
|
|
'wss://relay.primal.net',
|
|
'wss://nos.lol',
|
|
'wss://relay.damus.io',
|
|
];
|
|
|
|
const RELAY_FETCH_TIMEOUT = 10_000;
|
|
|
|
export default class NostrZapService extends Service {
|
|
@service nostrAuth;
|
|
@service nostrData;
|
|
@service nostrRelay;
|
|
|
|
@tracked recipientRelays = null;
|
|
|
|
getLightningAddress(pubkey) {
|
|
const profile = this.nostrData.getProfile(pubkey);
|
|
return profile?.lud16 || profile?.lud06 || null;
|
|
}
|
|
|
|
async resolveLnurl(address) {
|
|
const url = parseLNURLOrAddress(address);
|
|
if (!url) throw new Error('Invalid lightning address or LNURL');
|
|
|
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
|
const res = await fetch(url.toString());
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to fetch LNURL pay endpoint: ${res.statusText}`);
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (!data.callback) {
|
|
throw new Error('Invalid LNURL pay endpoint: missing callback');
|
|
}
|
|
if (!data.allowsNostr) {
|
|
throw new Error('This lightning address does not support Nostr zaps');
|
|
}
|
|
|
|
return {
|
|
callback: data.callback,
|
|
minSendable: data.minSendable ?? 1000,
|
|
maxSendable: data.maxSendable ?? 100_000_000_000,
|
|
allowsNostr: !!data.allowsNostr,
|
|
nostrPubkey: data.nostrPubkey,
|
|
};
|
|
}
|
|
|
|
async loadRecipientRelays(pubkey) {
|
|
if (!pubkey) return this._fallbackRelays();
|
|
|
|
// Check the store first — the event may already be cached
|
|
const fromStore = await this._getMailboxesFromStore(pubkey);
|
|
if (fromStore?.length) {
|
|
this.recipientRelays = fromStore;
|
|
return fromStore;
|
|
}
|
|
|
|
// Fetch from network if not in the store
|
|
const fetched = await this._fetchMailboxesFromNetwork(pubkey);
|
|
if (fetched?.length) {
|
|
this.recipientRelays = fetched;
|
|
return fetched;
|
|
}
|
|
|
|
// Fallback: sender's inbox relays + popular defaults
|
|
const fallback = this._fallbackRelays();
|
|
this.recipientRelays = fallback;
|
|
return fallback;
|
|
}
|
|
|
|
async _getMailboxesFromStore(pubkey) {
|
|
try {
|
|
const event = await firstValueFrom(
|
|
this.nostrData.store.replaceable(10002, pubkey).pipe(
|
|
timeout(500),
|
|
catchError(() => of(null))
|
|
)
|
|
);
|
|
if (event) return getInboxes(event);
|
|
} catch {
|
|
// Event not in store yet
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async _fetchMailboxesFromNetwork(pubkey) {
|
|
const relays = this.nostrData.activeReadRelays?.length
|
|
? this.nostrData.activeReadRelays
|
|
: DIRECTORY_RELAYS;
|
|
|
|
try {
|
|
const event = await firstValueFrom(
|
|
this.nostrRelay.pool
|
|
.request(relays, [{ kinds: [10002], authors: [pubkey] }])
|
|
.pipe(
|
|
timeout(RELAY_FETCH_TIMEOUT),
|
|
catchError(() => of(null))
|
|
)
|
|
);
|
|
if (event) {
|
|
this.nostrData.store.add(event);
|
|
return getInboxes(event);
|
|
}
|
|
} catch {
|
|
// Network fetch failed
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_fallbackRelays() {
|
|
const senderRelays = this.nostrData.mailboxReadRelays || [];
|
|
return [...new Set([...senderRelays, ...DEFAULT_ZAP_RELAYS])].slice(0, 5);
|
|
}
|
|
|
|
getZapRelays() {
|
|
if (this.recipientRelays?.length) {
|
|
return this.recipientRelays.slice(0, 5);
|
|
}
|
|
return this._fallbackRelays();
|
|
}
|
|
|
|
async createZapRequest(photo, amountMsats, message) {
|
|
const event = this.nostrData.store.getEvent(photo.eventId);
|
|
if (!event) {
|
|
throw new Error('Photo event not found in store');
|
|
}
|
|
|
|
const relays = this.getZapRelays();
|
|
const signer = this.nostrAuth.signer;
|
|
if (!signer) {
|
|
throw new Error('Nostr signer not available. Please connect Nostr.');
|
|
}
|
|
|
|
return await ZapRequestFactory.event(event, amountMsats, relays)
|
|
.message(message || '')
|
|
.as(signer)
|
|
.sign();
|
|
}
|
|
|
|
async fetchInvoice(callbackUrl, zapRequest, amountMsats) {
|
|
const url = new URL(callbackUrl);
|
|
url.searchParams.set('amount', amountMsats.toString());
|
|
url.searchParams.set('nostr', JSON.stringify(zapRequest));
|
|
return await getInvoice(url);
|
|
}
|
|
|
|
parseInvoice(invoice) {
|
|
return parseBolt11(invoice);
|
|
}
|
|
|
|
subscribeForZapReceipt(zapRequest, photo, onReceipt) {
|
|
const eventId = photo.eventId;
|
|
const relays = this.getZapRelays();
|
|
const since = Math.floor(Date.now() / 1000) - 10;
|
|
|
|
return this.nostrRelay.pool
|
|
.subscription(relays, {
|
|
kinds: [9735],
|
|
'#e': [eventId],
|
|
since,
|
|
})
|
|
.subscribe({
|
|
next: (event) => {
|
|
this.nostrData.store.add(event);
|
|
|
|
const descTag = event.tags.find((t) => t[0] === 'description');
|
|
if (!descTag) return;
|
|
|
|
try {
|
|
const req = JSON.parse(descTag[1]);
|
|
if (req.id === zapRequest.id) {
|
|
onReceipt(event);
|
|
}
|
|
} catch {
|
|
// Invalid JSON in description tag
|
|
}
|
|
},
|
|
error: (err) => {
|
|
console.error('[nostr-zap] Receipt subscription error:', err);
|
|
},
|
|
});
|
|
}
|
|
|
|
hasWebLN() {
|
|
return typeof window !== 'undefined' && typeof window.webln !== 'undefined';
|
|
}
|
|
|
|
async payWithWebln(invoice) {
|
|
if (!this.hasWebLN()) throw new Error('WebLN not available');
|
|
await window.webln.enable();
|
|
return await window.webln.sendPayment(invoice);
|
|
}
|
|
|
|
async zap(photo, amountMsats, message) {
|
|
const pubkey = photo?.pubkey;
|
|
if (!pubkey) throw new Error('Photo has no pubkey');
|
|
|
|
// Ensure recipient relays are loaded before building the zap request
|
|
if (!this.recipientRelays) {
|
|
await this.loadRecipientRelays(pubkey);
|
|
}
|
|
|
|
const address = this.getLightningAddress(pubkey);
|
|
if (!address) {
|
|
throw new Error(
|
|
'This user has no lightning address set in their profile'
|
|
);
|
|
}
|
|
|
|
const lnurl = await this.resolveLnurl(address);
|
|
|
|
if (amountMsats < lnurl.minSendable || amountMsats > lnurl.maxSendable) {
|
|
throw new Error(
|
|
`Amount must be between ${lnurl.minSendable} and ${lnurl.maxSendable} millisats`
|
|
);
|
|
}
|
|
|
|
const zapRequest = await this.createZapRequest(photo, amountMsats, message);
|
|
const invoice = await this.fetchInvoice(
|
|
lnurl.callback,
|
|
zapRequest,
|
|
amountMsats
|
|
);
|
|
|
|
return { invoice, zapRequest };
|
|
}
|
|
}
|