mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 17:32:26 +00:00
482 lines
12 KiB
JavaScript
482 lines
12 KiB
JavaScript
// js/payments/nwcClient.js
|
|
|
|
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
|
const URI_SCHEMES = [
|
|
"nostr+walletconnect://",
|
|
"walletconnect://",
|
|
"nwc://",
|
|
];
|
|
const REQUEST_KIND = 23194;
|
|
const RESPONSE_KIND = 23195;
|
|
const DEFAULT_REQUEST_TIMEOUT_MS = 25_000;
|
|
|
|
let activeState = null;
|
|
let pendingRequests = new Map();
|
|
let socket = null;
|
|
let subscriptionId = null;
|
|
let connectionPromise = null;
|
|
let requestCounter = 0;
|
|
|
|
function getGlobalWindow() {
|
|
if (typeof window !== "undefined") {
|
|
return window;
|
|
}
|
|
if (typeof globalThis !== "undefined") {
|
|
return globalThis;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function getNostrTools() {
|
|
const win = getGlobalWindow();
|
|
return win?.NostrTools || null;
|
|
}
|
|
|
|
function assertNostrTools(methods = []) {
|
|
const tools = getNostrTools();
|
|
if (!tools) {
|
|
throw new Error("NostrTools is required for NWC operations.");
|
|
}
|
|
for (const method of methods) {
|
|
const candidate = tools?.[method];
|
|
const isCallable = typeof candidate === "function";
|
|
const isNamespace = candidate && typeof candidate === "object";
|
|
if (!isCallable && !isNamespace) {
|
|
throw new Error(`NostrTools.${method} is unavailable.`);
|
|
}
|
|
}
|
|
return tools;
|
|
}
|
|
|
|
function decodePubkey(value) {
|
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
if (!trimmed) {
|
|
throw new Error("Wallet pubkey is missing from the NWC URI.");
|
|
}
|
|
|
|
if (HEX64_REGEX.test(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
const tools = getNostrTools();
|
|
const decoder = tools?.nip19?.decode;
|
|
if (typeof decoder === "function") {
|
|
try {
|
|
const decoded = decoder(trimmed);
|
|
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
|
const hex = decoded.data.trim();
|
|
if (HEX64_REGEX.test(hex)) {
|
|
return hex.toLowerCase();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Ignore decode errors and fall through to failure.
|
|
}
|
|
}
|
|
|
|
throw new Error("Wallet pubkey in the NWC URI is invalid.");
|
|
}
|
|
|
|
function parseNwcUri(uri) {
|
|
const trimmed = typeof uri === "string" ? uri.trim() : "";
|
|
if (!trimmed) {
|
|
throw new Error("Wallet URI is required.");
|
|
}
|
|
|
|
let stripped = trimmed;
|
|
let scheme = "";
|
|
for (const candidate of URI_SCHEMES) {
|
|
if (trimmed.toLowerCase().startsWith(candidate)) {
|
|
stripped = trimmed.slice(candidate.length);
|
|
scheme = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!scheme) {
|
|
throw new Error("Unsupported NWC URI scheme.");
|
|
}
|
|
|
|
const [identifierPart, queryPart] = stripped.split("?");
|
|
const walletPubkey = decodePubkey(identifierPart);
|
|
|
|
const params = new URLSearchParams(queryPart || "");
|
|
const relay = params.get("relay") || params.get("r");
|
|
const secret = params.get("secret") || params.get("s");
|
|
|
|
if (!relay) {
|
|
throw new Error("NWC URI is missing a relay parameter.");
|
|
}
|
|
|
|
if (!secret || !HEX64_REGEX.test(secret)) {
|
|
throw new Error("NWC URI secret must be a 64 character hex string.");
|
|
}
|
|
|
|
const relayUrl = decodeURIComponent(relay);
|
|
const secretKey = secret.toLowerCase();
|
|
|
|
const tools = assertNostrTools(["getPublicKey"]);
|
|
const clientPubkey = tools.getPublicKey(secretKey);
|
|
|
|
return {
|
|
normalizedUri: `nostr+walletconnect://${walletPubkey}?relay=${encodeURIComponent(
|
|
relayUrl
|
|
)}&secret=${secretKey}`,
|
|
relayUrl,
|
|
walletPubkey,
|
|
secretKey,
|
|
clientPubkey,
|
|
};
|
|
}
|
|
|
|
function closeSocket({ keepState = false } = {}) {
|
|
if (socket) {
|
|
try {
|
|
socket.close();
|
|
} catch (error) {
|
|
// Ignore close errors.
|
|
}
|
|
}
|
|
socket = null;
|
|
connectionPromise = null;
|
|
subscriptionId = null;
|
|
|
|
if (!keepState) {
|
|
activeState = null;
|
|
}
|
|
|
|
for (const [, entry] of pendingRequests.entries()) {
|
|
try {
|
|
entry.reject(new Error("Wallet connection closed."));
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
}
|
|
pendingRequests.clear();
|
|
}
|
|
|
|
function isSocketOpen() {
|
|
return socket && socket.readyState === socket.OPEN;
|
|
}
|
|
|
|
function handleSocketError(error) {
|
|
console.warn("[nwcClient] WebSocket error", error);
|
|
closeSocket({ keepState: true });
|
|
}
|
|
|
|
function handleSocketClose() {
|
|
console.warn("[nwcClient] Wallet connection closed.");
|
|
closeSocket({ keepState: true });
|
|
}
|
|
|
|
function resolveWebSocketImplementation() {
|
|
if (typeof WebSocket !== "undefined") {
|
|
return WebSocket;
|
|
}
|
|
if (typeof globalThis !== "undefined" && typeof globalThis.WebSocket !== "undefined") {
|
|
return globalThis.WebSocket;
|
|
}
|
|
throw new Error("WebSocket is not available in this environment.");
|
|
}
|
|
|
|
function subscribeToResponses(context) {
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
subscriptionId = `nwc-${Math.random().toString(36).slice(2, 10)}`;
|
|
const filters = {
|
|
kinds: [RESPONSE_KIND],
|
|
authors: [context.walletPubkey],
|
|
"#p": [context.clientPubkey],
|
|
};
|
|
socket.send(JSON.stringify(["REQ", subscriptionId, filters]));
|
|
}
|
|
|
|
async function decryptResponse(event) {
|
|
const tools = assertNostrTools(["nip04"]);
|
|
if (typeof tools.nip04?.decrypt !== "function") {
|
|
throw new Error("NostrTools.nip04.decrypt is not available.");
|
|
}
|
|
const context = activeState?.context;
|
|
if (!context) {
|
|
throw new Error("Wallet context is unavailable for decrypting responses.");
|
|
}
|
|
const plaintext = await tools.nip04.decrypt(
|
|
context.secretKey,
|
|
context.walletPubkey,
|
|
event.content
|
|
);
|
|
return JSON.parse(plaintext);
|
|
}
|
|
|
|
async function handleSocketMessage(messageEvent) {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(messageEvent.data);
|
|
} catch (error) {
|
|
console.warn("[nwcClient] Failed to parse relay message", error);
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(payload) || payload.length < 2) {
|
|
return;
|
|
}
|
|
|
|
const [type] = payload;
|
|
if (type === "EVENT" && payload.length >= 3) {
|
|
const event = payload[2];
|
|
if (event?.kind !== RESPONSE_KIND || !Array.isArray(event.tags)) {
|
|
return;
|
|
}
|
|
|
|
const eTag = event.tags.find((tag) => Array.isArray(tag) && tag[0] === "e");
|
|
const requestId = eTag?.[1];
|
|
if (!requestId || !pendingRequests.has(requestId)) {
|
|
return;
|
|
}
|
|
|
|
const pending = pendingRequests.get(requestId);
|
|
pendingRequests.delete(requestId);
|
|
clearTimeout(pending.timeoutId);
|
|
|
|
try {
|
|
const response = await decryptResponse(event);
|
|
if (response?.error) {
|
|
const message =
|
|
typeof response.error.message === "string" && response.error.message.trim()
|
|
? response.error.message.trim()
|
|
: "Wallet reported an error.";
|
|
const error = new Error(message);
|
|
error.code = response.error.code || null;
|
|
pending.reject(error);
|
|
return;
|
|
}
|
|
pending.resolve({
|
|
requestId,
|
|
result: response?.result || null,
|
|
response,
|
|
event,
|
|
});
|
|
} catch (error) {
|
|
pending.reject(error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (type === "NOTICE" && payload.length >= 2) {
|
|
console.warn("[nwcClient] Relay notice:", payload[1]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function connectSocket(context) {
|
|
if (connectionPromise) {
|
|
return connectionPromise;
|
|
}
|
|
|
|
const WebSocketImpl = resolveWebSocketImplementation();
|
|
const url = context.relayUrl;
|
|
|
|
connectionPromise = new Promise((resolve, reject) => {
|
|
try {
|
|
socket = new WebSocketImpl(url);
|
|
} catch (error) {
|
|
connectionPromise = null;
|
|
reject(new Error("Failed to open wallet WebSocket."));
|
|
return;
|
|
}
|
|
|
|
const handleOpen = () => {
|
|
socket.removeEventListener("open", handleOpen);
|
|
socket.removeEventListener("error", handleError);
|
|
socket.removeEventListener("close", handleCloseDuringConnect);
|
|
socket.addEventListener("message", handleSocketMessage);
|
|
socket.addEventListener("error", handleSocketError);
|
|
socket.addEventListener("close", handleSocketClose);
|
|
subscribeToResponses(context);
|
|
resolve();
|
|
};
|
|
|
|
const handleError = (event) => {
|
|
socket.removeEventListener("open", handleOpen);
|
|
socket.removeEventListener("error", handleError);
|
|
socket.removeEventListener("close", handleCloseDuringConnect);
|
|
connectionPromise = null;
|
|
reject(event instanceof Error ? event : new Error("Wallet WebSocket failed."));
|
|
};
|
|
|
|
const handleCloseDuringConnect = () => {
|
|
socket.removeEventListener("open", handleOpen);
|
|
socket.removeEventListener("error", handleError);
|
|
socket.removeEventListener("close", handleCloseDuringConnect);
|
|
connectionPromise = null;
|
|
reject(new Error("Wallet WebSocket closed before opening."));
|
|
};
|
|
|
|
socket.addEventListener("open", handleOpen);
|
|
socket.addEventListener("error", handleError);
|
|
socket.addEventListener("close", handleCloseDuringConnect);
|
|
});
|
|
|
|
return connectionPromise;
|
|
}
|
|
|
|
function ensureActiveState(settings) {
|
|
if (!settings || typeof settings !== "object") {
|
|
throw new Error("Wallet settings are required.");
|
|
}
|
|
|
|
const uri =
|
|
typeof settings.nwcUri === "string" && settings.nwcUri.trim()
|
|
? settings.nwcUri.trim()
|
|
: "";
|
|
if (!uri) {
|
|
throw new Error("Wallet URI is required.");
|
|
}
|
|
|
|
const parsed = parseNwcUri(uri);
|
|
if (activeState && activeState.normalizedUri === parsed.normalizedUri) {
|
|
activeState.settings = { ...settings, nwcUri: parsed.normalizedUri };
|
|
return activeState.context;
|
|
}
|
|
|
|
closeSocket();
|
|
|
|
activeState = {
|
|
normalizedUri: parsed.normalizedUri,
|
|
settings: { ...settings, nwcUri: parsed.normalizedUri },
|
|
context: {
|
|
relayUrl: parsed.relayUrl,
|
|
walletPubkey: parsed.walletPubkey,
|
|
secretKey: parsed.secretKey,
|
|
clientPubkey: parsed.clientPubkey,
|
|
uri: parsed.normalizedUri,
|
|
},
|
|
};
|
|
|
|
pendingRequests = new Map();
|
|
return activeState.context;
|
|
}
|
|
|
|
export async function ensureWallet({ settings } = {}) {
|
|
const candidateSettings =
|
|
settings || activeState?.settings || activeState?.context?.settings || null;
|
|
|
|
const context = ensureActiveState(candidateSettings);
|
|
|
|
if (!isSocketOpen()) {
|
|
await connectSocket(context);
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
async function encryptRequestPayload(context, payload) {
|
|
const tools = assertNostrTools(["nip04", "getEventHash", "signEvent"]);
|
|
if (typeof tools.nip04?.encrypt !== "function") {
|
|
throw new Error("NostrTools.nip04.encrypt is not available.");
|
|
}
|
|
|
|
const plaintext = JSON.stringify(payload);
|
|
const encrypted = await tools.nip04.encrypt(
|
|
context.secretKey,
|
|
context.walletPubkey,
|
|
plaintext
|
|
);
|
|
|
|
const event = {
|
|
kind: REQUEST_KIND,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
content: encrypted,
|
|
pubkey: context.clientPubkey,
|
|
tags: [["p", context.walletPubkey]],
|
|
};
|
|
|
|
event.id = tools.getEventHash(event);
|
|
event.tags.push(["e", event.id]);
|
|
event.sig = tools.signEvent(event, context.secretKey);
|
|
|
|
return event;
|
|
}
|
|
|
|
function registerPendingRequest(eventId, { resolve, reject, timeoutMs }) {
|
|
const timeoutId = setTimeout(() => {
|
|
if (pendingRequests.has(eventId)) {
|
|
pendingRequests.delete(eventId);
|
|
reject(new Error("Wallet request timed out."));
|
|
}
|
|
}, timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS);
|
|
|
|
pendingRequests.set(eventId, { resolve, reject, timeoutId });
|
|
}
|
|
|
|
async function sendWalletRequest(context, payload, { timeoutMs } = {}) {
|
|
if (!isSocketOpen()) {
|
|
await connectSocket(context);
|
|
}
|
|
|
|
const event = await encryptRequestPayload(context, payload);
|
|
|
|
const promise = new Promise((resolve, reject) => {
|
|
registerPendingRequest(event.id, { resolve, reject, timeoutMs });
|
|
});
|
|
|
|
socket.send(JSON.stringify(["EVENT", event]));
|
|
return promise;
|
|
}
|
|
|
|
function sanitizeInvoice(value) {
|
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
if (!trimmed) {
|
|
throw new Error("Invoice is required to send a payment.");
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function sanitizeAmount(amount) {
|
|
if (!Number.isFinite(amount)) {
|
|
return null;
|
|
}
|
|
const rounded = Math.round(amount);
|
|
return Math.max(0, rounded);
|
|
}
|
|
|
|
export async function sendPayment(bolt11, { settings, amountSats, zapRequest, timeoutMs } = {}) {
|
|
const context = await ensureWallet({ settings });
|
|
const invoice = sanitizeInvoice(bolt11);
|
|
const payload = {
|
|
id: `req-${Date.now()}-${++requestCounter}`,
|
|
method: "pay_invoice",
|
|
params: {
|
|
invoice,
|
|
},
|
|
};
|
|
|
|
const amount = sanitizeAmount(amountSats);
|
|
if (amount && amount > 0) {
|
|
payload.params.amount = amount;
|
|
}
|
|
|
|
if (zapRequest) {
|
|
payload.params.zap_request = zapRequest;
|
|
}
|
|
|
|
const response = await sendWalletRequest(context, payload, { timeoutMs });
|
|
return response;
|
|
}
|
|
|
|
export function getActiveWalletContext() {
|
|
return activeState?.context || null;
|
|
}
|
|
|
|
export function resetWalletClient() {
|
|
closeSocket();
|
|
}
|
|
|
|
export const __TESTING__ = Object.freeze({
|
|
parseNwcUri,
|
|
closeSocket,
|
|
pendingRequests,
|
|
ensureActiveState,
|
|
});
|