From 2cc244c19632e9f9f3083e6edf71649a366ba719 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 10:44:46 -0700 Subject: migrate: run the sweep server-side, and send the whole balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the first live test: The POST went out from the browser, where $lib/utils' base is "" — so it hit the SvelteKit page route and came back "POST method not allowed". Payments in this app all run server-side with auth(cookies), because that's where the session token is usable; /migrate is a form action now like the rest of the send flows. And the routing reserve is gone: the ASP's node has two direct channels to ln.coinos.io, so a sweep is one hop and costs nothing. It sends the full balance, only retrying 0.5% lighter if a payment actually comes back short. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/routes/(app)/migrate/+page.server.ts | 67 +++++++++++++++ src/routes/(app)/migrate/+page.svelte | 142 ++++++++++++------------------- 2 files changed, 121 insertions(+), 88 deletions(-) diff --git a/src/routes/(app)/migrate/+page.server.ts b/src/routes/(app)/migrate/+page.server.ts index 4529c14c..dc3beb02 100644 --- a/src/routes/(app)/migrate/+page.server.ts +++ b/src/routes/(app)/migrate/+page.server.ts @@ -1,5 +1,25 @@ +import { auth, post } from "$lib/utils"; import { redirect } from "@sveltejs/kit"; +const REGISTRAR = "https://names.coinos.io"; + +// 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)}`); @@ -8,3 +28,50 @@ export async function load({ parent, url }) { 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 + if (!/insufficient|balance|not enough|fee/i.test(msg)) throw e; + } + } + if (!sent) throw lastErr; + } + // Releasing the username is what lets them keep it on v3. + await post( + "/user", + { username: `${username}_v2`, ...(pin ? { pin } : {}) }, + auth(cookies), + ); + 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 }; + } + }, +}; diff --git a/src/routes/(app)/migrate/+page.svelte b/src/routes/(app)/migrate/+page.svelte index 4bec63a3..1869dc8d 100644 --- a/src/routes/(app)/migrate/+page.svelte +++ b/src/routes/(app)/migrate/+page.svelte @@ -2,117 +2,83 @@ // Migrate a legacy coinos account to coinos v3. // // v3 sends the user here with ?to=&back=. - // They are already signed in on this site, so nothing has to handle their - // password: we sweep the balance over Lightning to the new wallet and then - // release the username, which lets v3 claim it on the new registrar. - import { post, success } from "$lib/utils"; - import { goto } from "$app/navigation"; + // They're already signed in here, so no password or key has to travel: + // the form action (server side, with their session cookie) sweeps the + // balance over Lightning and then releases the username, which is what + // lets v3's registrar hand it to them. + import { enhance } from "$app/forms"; - let { data } = $props(); + let { data, form } = $props(); let { user, to, back } = $derived(data); - let step = $state("intro"); // intro | working | done | error - let note = $state(""); - let error = $state(""); - let pin = $state(""); - let sent = $state(0); - + let working = $state(false); let balance = $derived(user?.balance || 0); - // Lightning routing takes a cut on the way out; leave a little behind so - // the sweep can't fail for being a few sats over. - let reserve = $derived(Math.max(10, Math.ceil(balance * 0.005))); - let amount = $derived(Math.max(0, balance - reserve)); let newName = $derived(`${user?.username}_v2`); - - // Ask the v3 registrar for an invoice for this amount (LNURL-pay against - // the destination address). The registrar is CORS-open. - async function invoiceFor(address, sats) { - const [name, domain] = String(address).split("@"); - if (!name || !domain) throw new Error("bad destination address"); - const base = "https://names.coinos.io"; - const p = await fetch(`${base}/.well-known/lnurlp/${name}?domain=${domain}`).then((r) => r.json()); - if (!p?.callback) throw new Error(p?.reason || "destination not found"); - const msat = sats * 1000; - if (msat < p.minSendable || msat > p.maxSendable) throw new Error("amount out of range for the destination"); - const inv = await fetch(`${p.callback}${p.callback.includes("?") ? "&" : "?"}amount=${msat}`).then((r) => r.json()); - if (!inv?.pr) throw new Error(inv?.reason || "could not get an invoice"); - return inv.pr; - } - - async function migrate() { - error = ""; - step = "working"; + let backUrl = $derived.by(() => { try { - if (amount > 0) { - note = "Getting an invoice from your new wallet…"; - const payreq = await invoiceFor(to, amount); - note = `Sending ${amount.toLocaleString()} sats…`; - await post("/payments", { payreq, ...(pin ? { pin } : {}) }); - sent = amount; - } - note = "Releasing your username…"; - await post("/user", { username: newName, ...(pin ? { pin } : {}) }); - step = "done"; - success("Migrated"); - } catch (e) { - error = e && typeof e === "object" && "message" in e ? String(e.message) : String(e); - step = "error"; + const u = new URL(back || "https://v3.coinos.io/"); + u.searchParams.set("migrated", user.username); + return u.toString(); + } catch { + return "https://v3.coinos.io/"; } - } - - function goBack() { - const url = new URL(back || "https://v3.coinos.io/"); - url.searchParams.set("migrated", user.username); - goto(url.toString(), { replaceState: true }); - } + });

Move your account to coinos v3

{#if !to} -

- This page needs a destination. Start the migration from coinos v3. -

- {:else if step === "done"} +

This page needs a destination — start the move from coinos v3.

+ {:else if form?.ok}

✓ All done!

- {#if sent} -

{sent.toLocaleString()} sats are on their way to your new wallet.

+ {#if form.sent} +

{form.sent.toLocaleString()} sats are on their way to your new wallet.

{/if}

- Your old username was released, so {user.username} can now be - yours on coinos v3. + Your username was released, so {form.released} can be yours again on + coinos v3.

- + Back to coinos v3
- {:else if step === "working"} -

{note}

- {:else} -
-

- This moves your balance to your new wallet and frees up your username so you can - keep it there. Your old account stays, renamed to - {newName}, and stops receiving payments. -

-
-
Balance{balance.toLocaleString()} sats
-
Sending{amount.toLocaleString()} sats
-
Left for routing{reserve.toLocaleString()} sats
-
To{to}
-
+

+ This sends your balance to your new wallet and frees up your username so you can keep + it there. Your old account stays, renamed to {newName}, and stops + receiving payments. +

+ +
+
Balance{balance.toLocaleString()} sats
+
To{to}
+
+ + {#if form?.error}

{form.error}

{/if} + +
{ + working = true; + return async ({ update }) => { + await update({ reset: false }); + working = false; + }; + }} + class="space-y-3" + > + + + {#if user?.haspin} + name="pin" placeholder="Your PIN" /> {/if} - {#if error}

{error}

{/if} - -

- You can also do nothing — your old account keeps working as it is. -

-
+ + +

You can also do nothing — your old account keeps working as it is.

{/if}
-- cgit v1.2.3