Skip to content
Merged
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
132 changes: 95 additions & 37 deletions scripts/fix-missing-paylinks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
#!/usr/bin/env npx tsx
/**
* Fix existing LN wallets that are missing pay links.
* Finds users with user_ln_wallets but no ln_address containing -ugig@
* Repair users whose Lightning Address does not resolve.
*
* /.well-known/lnurlp/<username> looks up the user's LNbits wallet and returns
* its first lnurlp link. If the wallet has no link the address 404s, even though
* the profile advertises it. This finds those wallets and creates the missing link.
*
* The link is always created on the user's OWN wallet, so payments land with the
* right person. The LNbits-side `username` is claimed only when it is still free —
* on the shared LNbits instance it is often already held by an orphaned wallet,
* and it is not needed for the address to resolve through ugig.net.
*
* Usage: npx tsx scripts/fix-missing-paylinks.ts [--apply]
* Runs read-only unless --apply is passed.
*/
import { config } from "dotenv";
config();
Expand All @@ -12,56 +23,103 @@ const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const LNBITS_URL = process.env.LNBITS_URL || "https://ln.coinpayportal.com";

const APPLY = process.argv.includes("--apply");
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/** LNbits rate-limits bursts with 429s; retry with backoff. */
async function lnbits(path: string, init: RequestInit = {}): Promise<Response> {
for (let attempt = 0; attempt < 6; attempt++) {
const res = await fetch(`${LNBITS_URL}${path}`, init);
if (res.status !== 429) return res;
await sleep(1500 * (attempt + 1));
}
return fetch(`${LNBITS_URL}${path}`, init);
}

async function main() {
const { data: wallets } = await supabase.from("user_ln_wallets" as any).select("user_id, admin_key, wallet_id") as any;
if (!wallets?.length) { console.log("No wallets found"); return; }
const { data: wallets } = (await supabase
.from("user_ln_wallets" as any)
.select("user_id, admin_key, wallet_id")) as any;
if (!wallets?.length) {
console.log("No wallets found");
return;
}

console.log(`Checking ${wallets.length} wallets${APPLY ? "" : " (dry run — pass --apply to fix)"}\n`);

let broken = 0;
let fixed = 0;
let failed = 0;

for (const w of wallets) {
const { data: profile } = await supabase.from("profiles").select("username, ln_address").eq("id", w.user_id).single();
if (!profile?.username) continue;
if (profile.ln_address?.includes("-ugig@")) { continue; } // already has pay link

console.log(`Fixing ${profile.username}...`);

// Enable lnurlp and wait
const start = Date.now();
let extReady = false;
while (Date.now() - start < 15000) {
const check = await fetch(`${LNBITS_URL}/lnurlp/api/v1/links`, {
headers: { "X-Api-Key": w.admin_key },
});
if (check.status === 200) { extReady = true; break; }
await new Promise((r) => setTimeout(r, 2000));
const { data: profile } = await supabase
.from("profiles")
.select("username")
.eq("id", w.user_id)
.single();
const username = profile?.username;
if (!username) continue; // wallet with no profile — nothing to advertise

const listRes = await lnbits("/lnurlp/api/v1/links", {
headers: { "X-Api-Key": w.admin_key, Accept: "application/json" },
});
if (!listRes.ok) {
console.log(`[SKIP] ${username}: cannot list links (${listRes.status})`);
failed++;
continue;
}
if (!extReady) {
console.log(" [FAIL] lnurlp not enabled after 15s");

const links = await listRes.json();
if (Array.isArray(links) && links.length > 0) continue; // already payable

broken++;
if (!APPLY) {
console.log(`[BROKEN] ${username}`);
continue;
}

// Try creating pay link
const res = await fetch(`${LNBITS_URL}/lnurlp/api/v1/links`, {
method: "POST",
headers: { "X-Api-Key": w.admin_key, "Content-Type": "application/json" },
body: JSON.stringify({
description: `ugig.net wallet for ${profile.username.toLowerCase()}`,
min: 1, max: 10000000, comment_chars: 255,
username: `${profile.username.toLowerCase()}-ugig`,
}),
});
const lnUsername = username.toLowerCase();
const base = {
description: `ugig.net wallet for ${lnUsername}`,
min: 1,
max: 10000000,
comment_chars: 255,
disposable: false,
};

if (res.ok || (await res.text()).includes("already")) {
const ln_address = `${profile.username.toLowerCase()}-ugig@coinpayportal.com`;
await supabase.from("profiles" as any).update({ ln_address } as any).eq("id", w.user_id);
console.log(` [OK] ${ln_address}`);
let created = null;
for (const body of [{ ...base, username: lnUsername, domain: "ugig.net" }, base]) {
const res = await lnbits("/lnurlp/api/v1/links", {
method: "POST",
headers: { "X-Api-Key": w.admin_key, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
created = await res.json();
break;
}
}

if (created) {
await supabase
.from("profiles" as any)
.update({ ln_address: `${lnUsername}@ugig.net` } as any)
.eq("id", w.user_id);
console.log(`[OK] ${username} -> ${lnUsername}@ugig.net (link ${created.id})`);
fixed++;
} else {
console.log(` [FAIL] Could not create pay link`);
console.log(`[FAIL] ${username}: could not create pay link`);
failed++;
}

await sleep(300);
}
console.log(`\nFixed: ${fixed}/${wallets.length}`);

console.log(
`\nwallets=${wallets.length} broken=${broken} ${APPLY ? `fixed=${fixed} ` : ""}failed=${failed}`
);
}

main();
161 changes: 106 additions & 55 deletions src/lib/lightning/create-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,71 +13,122 @@ interface LnWalletResult {
ln_address: string;
}

export async function createUserLnWallet(username: string, supabase?: any, userId?: string): Promise<LnWalletResult | null> {
const lnUsername = username.toLowerCase();
try {
// Create wallet on LNbits
const res = await fetch(`${LNBITS_URL}/api/v1/account`, {
interface PayLink {
id: string;
}

/**
* Make sure the wallet has at least one lnurlp pay link, returning it.
*
* /.well-known/lnurlp/<username> resolves through the user's own wallet and
* uses the link id, so the link's LNbits `username`/`domain` fields are not
* required for the Lightning Address to work. They are only set opportunistically
* so LNbits-side resolution matches; if the name is already claimed on the shared
* LNbits instance we retry without it rather than leaving the wallet with no link.
*/
async function ensurePayLink(adminKey: string, lnUsername: string): Promise<PayLink | null> {
// Wait for the lnurlp extension (systemd timer auto-enables every 10s)
const start = Date.now();
let existing: PayLink[] | null = null;
while (Date.now() - start < 15000) {
try {
const check = await fetch(`${LNBITS_URL}/lnurlp/api/v1/links`, {
headers: { "X-Api-Key": adminKey, Accept: "application/json" },
});
if (check.status === 200) {
existing = await check.json();
break;
}
} catch {}
await new Promise((r) => setTimeout(r, 2000));
}

if (existing === null) {
console.warn("[LN Wallet] lnurlp not enabled after 15s");
return null;
}

// Idempotent: a wallet that already has a link is already payable.
if (existing.length > 0) return existing[0];

const base = {
description: `ugig.net wallet for ${lnUsername}`,
min: 1,
max: 10000000,
comment_chars: 255,
// Lightning Addresses are reused indefinitely — never mark them single-use.
disposable: false,
};

// First try claiming the LNbits-side username, then fall back to a plain link.
for (const body of [{ ...base, username: lnUsername, domain: "ugig.net" }, base]) {
const res = await fetch(`${LNBITS_URL}/lnurlp/api/v1/links`, {
method: "POST",
headers: {
"X-Api-Key": LNBITS_ADMIN_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: `ugig-${lnUsername}` }),
headers: { "X-Api-Key": adminKey, "Content-Type": "application/json" },
body: JSON.stringify(body),
});

if (!res.ok) {
console.error("[LN Wallet] Failed to create wallet:", await res.text());
return null;
}
if (res.ok) return (await res.json()) as PayLink;

const wallet = await res.json();
const errText = await res.text();
console.warn(`[LN Wallet] Pay link creation failed (${res.status}):`, errText);
}

// Create a pay link (lightning address) for the wallet
// Wait for lnurlp extension (systemd timer auto-enables every 10s)
const start = Date.now();
let extReady = false;
while (Date.now() - start < 15000) {
return null;
}

export async function createUserLnWallet(username: string, supabase?: any, userId?: string): Promise<LnWalletResult | null> {
const lnUsername = username.toLowerCase();
try {
// Reuse an existing wallet if we already made one for this user. Creating a
// second LNbits wallet here would orphan the first one (and any pay link and
// balance on it) when the upsert below replaces the stored credentials.
let wallet: { id: string; adminkey: string; inkey: string } | null = null;
if (supabase && userId) {
try {
const check = await fetch(`${LNBITS_URL}/lnurlp/api/v1/links`, {
headers: { "X-Api-Key": wallet.adminkey },
});
if (check.status === 200) { extReady = true; break; }
} catch {}
await new Promise((r) => setTimeout(r, 2000));
}
if (!extReady) {
console.warn("[LN Wallet] lnurlp not enabled after 15s");
const { data: stored } = await supabase
.from("user_ln_wallets")
.select("wallet_id, admin_key, invoice_key")
.eq("user_id", userId)
.maybeSingle();
if (stored?.wallet_id && stored?.admin_key) {
wallet = { id: stored.wallet_id, adminkey: stored.admin_key, inkey: stored.invoice_key };
console.log(`[LN Wallet] Reusing existing wallet for ${lnUsername}`);
}
} catch (e) {
console.warn("[LN Wallet] Failed to look up existing wallet:", e);
}
}

const payLinkRes = await fetch(`${LNBITS_URL}/lnurlp/api/v1/links`, {
method: "POST",
headers: {
"X-Api-Key": wallet.adminkey,
"Content-Type": "application/json",
},
body: JSON.stringify({
description: `ugig.net wallet for ${lnUsername}`,
min: 1,
max: 10000000,
comment_chars: 255,
username: lnUsername,
domain: "ugig.net",
}),
});
if (!wallet) {
// Create wallet on LNbits
const res = await fetch(`${LNBITS_URL}/api/v1/account`, {
method: "POST",
headers: {
"X-Api-Key": LNBITS_ADMIN_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: `ugig-${lnUsername}` }),
});

let ln_address = "";
if (payLinkRes.ok) {
ln_address = `${lnUsername}@ugig.net`;
} else {
const errText = await payLinkRes.text();
// If username already taken on LNbits, the address already exists
if (errText.includes("already") || errText.includes("unique")) {
ln_address = `${lnUsername}@ugig.net`;
console.warn("[LN Wallet] Pay link username already exists, reusing:", ln_address);
} else {
console.warn("[LN Wallet] Pay link creation failed:", errText);
if (!res.ok) {
console.error("[LN Wallet] Failed to create wallet:", await res.text());
return null;
}

wallet = await res.json();
}

if (!wallet) return null;

// Create a pay link (lightning address) for the wallet
const link = await ensurePayLink(wallet.adminkey, lnUsername);

// Only advertise an address that actually resolves. Claiming one without a
// backing pay link makes the profile show a Lightning Address that 404s.
const ln_address = link ? `${lnUsername}@ugig.net` : "";
if (!link) {
console.warn(`[LN Wallet] No pay link for ${lnUsername} — leaving ln_address unset`);
}

// Store wallet credentials for future use
Expand Down
Loading