diff options
Diffstat (limited to 'src/routes/(app)/migrate/+page.server.ts')
| -rw-r--r-- | src/routes/(app)/migrate/+page.server.ts | 106 |
1 files changed, 106 insertions, 0 deletions
diff --git a/src/routes/(app)/migrate/+page.server.ts b/src/routes/(app)/migrate/+page.server.ts new file mode 100644 index 00000000..6559485f --- /dev/null +++ b/src/routes/(app)/migrate/+page.server.ts @@ -0,0 +1,106 @@ +import { auth, post } from "$lib/utils"; +import { redirect } from "@sveltejs/kit"; +import { env } from "$env/dynamic/private"; + +const REGISTRAR = "https://names.coinos.io"; + +// Tell the v3 registrar who may claim the released name. This session — the +// old account's authenticated login — is the proof of ownership; the +// registrar's own claim guard otherwise only accepts a claim signed by the +// old account's nostr key, which a v3 wallet never has. +async function grantMigration(username: string, to: string) { + if (!env.MIGRATE_TOKEN) throw new Error("migration hand-off isn't configured"); + let lastErr; + for (let i = 0; i < 3; i++) { + try { + const r = await fetch(`${REGISTRAR}/migrate-grant`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${env.MIGRATE_TOKEN}` }, + body: JSON.stringify({ name: username, to }), + }); + if (r.ok) return; + lastErr = new Error((await r.json().catch(() => null))?.error || `registrar said ${r.status}`); + } catch (e) { + lastErr = e; + } + await new Promise((res) => setTimeout(res, 1500)); + } + throw lastErr; +} + +// An invoice from the v3 registrar for this address and amount (LNURL-pay). +async function invoiceFor(address: string, sats: number) { + const [name, domain] = String(address).split("@"); + if (!name || !domain) throw new Error("bad destination address"); + const p = await fetch( + `${REGISTRAR}/.well-known/lnurlp/${name}?domain=${encodeURIComponent(domain)}`, + ).then((r) => r.json()); + if (!p?.callback) throw new Error(p?.reason || "that address isn't registered on coinos v3 yet"); + const msat = sats * 1000; + if (msat < p.minSendable) throw new Error("balance is below the destination's minimum"); + if (msat > p.maxSendable) throw new Error("balance is above the destination's maximum"); + const sep = p.callback.includes("?") ? "&" : "?"; + const inv = await fetch(`${p.callback}${sep}amount=${msat}`).then((r) => r.json()); + if (!inv?.pr) throw new Error(inv?.reason || "could not get an invoice"); + return inv.pr as string; +} + +export async function load({ parent, url }) { + const { user } = await parent(); + if (!user) redirect(307, `/login?redirect=${encodeURIComponent(url.pathname + url.search)}`); + return { + to: url.searchParams.get("to") || "", + back: url.searchParams.get("back") || "", + }; +} + +export const actions = { + default: async ({ cookies, request, url }) => { + const form = await request.formData(); + const to = String(form.get("to") || url.searchParams.get("to") || ""); + const pin = String(form.get("pin") || ""); + const balance = Number(form.get("balance") || 0); + const username = String(form.get("username") || ""); + if (!to) return { error: "no destination" }; + + let sent = 0; + try { + if (balance > 0) { + // coinos and the ASP peer directly, so a sweep normally costs + // nothing. Try the whole balance first and only hold a little + // back if the payment actually comes up short. + const attempts = [balance, balance - Math.max(1, Math.ceil(balance * 0.005))]; + let lastErr; + for (const amount of attempts) { + if (amount <= 0) continue; + try { + const payreq = await invoiceFor(to, amount); + await post("/payments", { payreq, ...(pin ? { pin } : {}) }, auth(cookies)); + sent = amount; + break; + } catch (e) { + lastErr = e; + const msg = String((e as any)?.message || e); + // only a shortfall is worth retrying smaller. The server's debit + // error is in the user's language ("Unzureichende Mittel ⚡️20582 / + // 20600"), so match its numeric tail rather than just the words. + if (!/insufficient|balance|not enough|fee|⚡\uFE0F?\s*\d+\s*(\/|of)\s*⚡?\uFE0F?\s*\d+/i.test(msg)) + throw e; + } + } + if (!sent) throw lastErr; + } + // Hand the name over for receiving. The account itself stays exactly + // as it is — same username, same history, still usable here; the + // registrar just becomes the answer for incoming payments. + await post("/user", { migrated: true, ...(pin ? { pin } : {}) }, auth(cookies)); + // …and tell the registrar which v3 identity the name now belongs + // to, or their claim over there can never pass the squat guard. + await grantMigration(username, to); + return { ok: true, sent, released: username }; + } catch (e) { + const msg = e && typeof e === "object" && "message" in e ? String(e.message) : String(e); + return { error: msg, sent }; + } + }, +}; |
