aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 10:06:16 -0700
committerAdam Soltys <[email protected]>2026-08-03 10:06:16 -0700
commit969fc0ca8041f492ebede65275890c22bd48b012 (patch)
tree6e2b0ecc71198e94a4511336b52301e9315da3e5 /src
parent4f8c04659fd6efe4912a1a79453b8f6ed0b6a652 (diff)
downloadcoinos-ui-969fc0ca8041f492ebede65275890c22bd48b012.tar.gz
coinos-ui-969fc0ca8041f492ebede65275890c22bd48b012.zip
Add /migrate: hand a legacy account over to coinos v3
coinos v3 links here with ?to=<the user's new payment address>&back=<url>. The user is already signed in on this site, so no password or key has to travel anywhere: we fetch an invoice from the v3 registrar for the balance (less a small routing reserve), pay it, then rename the account to <username>_v2 — which releases the username so v3's registrar will let them claim it. Old account stays, renamed, and stops receiving. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
-rw-r--r--src/routes/(app)/migrate/+page.server.ts10
-rw-r--r--src/routes/(app)/migrate/+page.svelte118
2 files changed, 128 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..4529c14c
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.server.ts
@@ -0,0 +1,10 @@
+import { redirect } from "@sveltejs/kit";
+
+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") || "",
+ };
+}
diff --git a/src/routes/(app)/migrate/+page.svelte b/src/routes/(app)/migrate/+page.svelte
new file mode 100644
index 00000000..4493709b
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.svelte
@@ -0,0 +1,118 @@
+<script>
+ // 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";
+
+ let { data } = $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 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";
+ 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 = String(e && e.message ? e.message : e);
+ step = "error";
+ }
+ }
+
+ 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"}
+ <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}
+ <p>
+ Your old username was released, so <strong>{user.username}</strong> can now be
+ yours on coinos v3.
+ </p>
+ <button class="btn btn-primary w-full" onclick={goBack}>Back to coinos v3</button>
+ </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>
+ {#if user?.haspin}
+ <input class="input input-bordered w-full" type="password" inputmode="numeric"
+ placeholder="Your PIN" bind:value={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>
+ <p class="text-sm opacity-60">
+ You can also do nothing — your old account keeps working as it is.
+ </p>
+ </div>
+ {/if}
+</div>