aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 10:44:46 -0700
committerAdam Soltys <[email protected]>2026-08-03 10:44:46 -0700
commit2cc244c19632e9f9f3083e6edf71649a366ba719 (patch)
treedcd499e6e590908e642e814858d6bab70e874066
parent7b2335636e28f43eb8d48c5d9fa3a3945421cf34 (diff)
downloadcoinos-ui-2cc244c19632e9f9f3083e6edf71649a366ba719.tar.gz
coinos-ui-2cc244c19632e9f9f3083e6edf71649a366ba719.zip
migrate: run the sweep server-side, and send the whole balance
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
-rw-r--r--src/routes/(app)/migrate/+page.server.ts67
-rw-r--r--src/routes/(app)/migrate/+page.svelte142
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=<their new payment address>&back=<url>.
- // 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 });
- }
+ });
</script>
<div class="max-w-md mx-auto p-4 space-y-5">
<h1 class="text-2xl font-bold">Move your account to coinos v3</h1>
{#if !to}
- <p class="text-red-600">
- This page needs a destination. Start the migration from coinos v3.
- </p>
- {:else if step === "done"}
+ <p class="text-red-600">This page needs a destination — start the move from coinos v3.</p>
+ {:else if form?.ok}
<div class="space-y-3">
<p class="text-xl">✓ All done!</p>
- {#if sent}
- <p>{sent.toLocaleString()} sats are on their way to your new wallet.</p>
+ {#if form.sent}
+ <p>{form.sent.toLocaleString()} sats are on their way to your new wallet.</p>
{/if}
<p>
- Your old username was released, so <strong>{user.username}</strong> can now be
- yours on coinos v3.
+ Your username was released, so <strong>{form.released}</strong> can be yours again on
+ coinos v3.
</p>
- <button class="btn btn-primary w-full" onclick={goBack}>Back to coinos v3</button>
+ <a class="btn btn-primary w-full" href={backUrl}>Back to coinos v3</a>
</div>
- {:else if step === "working"}
- <p>{note}</p>
- <progress class="progress w-full"></progress>
{:else}
- <div class="space-y-3">
- <p>
- 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
- <strong>{newName}</strong>, and stops receiving payments.
- </p>
- <div class="rounded-xl border p-3 space-y-1">
- <div class="flex justify-between"><span>Balance</span><strong>{balance.toLocaleString()} sats</strong></div>
- <div class="flex justify-between"><span>Sending</span><strong>{amount.toLocaleString()} sats</strong></div>
- <div class="flex justify-between text-sm opacity-60"><span>Left for routing</span><span>{reserve.toLocaleString()} sats</span></div>
- <div class="flex justify-between break-all gap-2"><span>To</span><span>{to}</span></div>
- </div>
+ <p>
+ 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 <strong>{newName}</strong>, and stops
+ receiving payments.
+ </p>
+
+ <div class="rounded-xl border p-3 space-y-1">
+ <div class="flex justify-between"><span>Balance</span><strong>{balance.toLocaleString()} sats</strong></div>
+ <div class="flex justify-between gap-3 break-all"><span>To</span><span>{to}</span></div>
+ </div>
+
+ {#if form?.error}<p class="text-red-600">{form.error}</p>{/if}
+
+ <form
+ method="POST"
+ use:enhance={() => {
+ working = true;
+ return async ({ update }) => {
+ await update({ reset: false });
+ working = false;
+ };
+ }}
+ class="space-y-3"
+ >
+ <input type="hidden" name="to" value={to} />
+ <input type="hidden" name="balance" value={balance} />
+ <input type="hidden" name="username" value={user.username} />
{#if user?.haspin}
<input class="input input-bordered w-full" type="password" inputmode="numeric"
- placeholder="Your PIN" bind:value={pin} />
+ name="pin" placeholder="Your PIN" />
{/if}
- {#if error}<p class="text-red-600">{error}</p>{/if}
- <button class="btn btn-primary w-full" onclick={migrate} disabled={!to}>
- Move {amount.toLocaleString()} sats and release {user.username}
+ <button class="btn btn-primary w-full" disabled={working}>
+ {#if working}Moving…{:else}Move {balance.toLocaleString()} sats and release {user.username}{/if}
</button>
- <p class="text-sm opacity-60">
- You can also do nothing — your old account keeps working as it is.
- </p>
- </div>
+ </form>
+
+ <p class="text-sm opacity-60">You can also do nothing — your old account keeps working as it is.</p>
{/if}
</div>