aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(app)/migrate
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/(app)/migrate')
-rw-r--r--src/routes/(app)/migrate/+page.server.ts106
-rw-r--r--src/routes/(app)/migrate/+page.svelte82
2 files changed, 188 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 };
+ }
+ },
+};
diff --git a/src/routes/(app)/migrate/+page.svelte b/src/routes/(app)/migrate/+page.svelte
new file mode 100644
index 00000000..e246b1e8
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.svelte
@@ -0,0 +1,82 @@
+<script>
+ // Migrate a legacy coinos account to coinos v3.
+ //
+ // v3 sends the user here with ?to=<their new payment address>&back=<url>.
+ // 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, form } = $props();
+ let { user, to, back } = $derived(data);
+
+ let working = $state(false);
+ let balance = $derived(user?.balance || 0);
+ let backUrl = $derived.by(() => {
+ try {
+ const u = new URL(back || "https://v3.coinos.io/");
+ u.searchParams.set("migrated", user.username);
+ return u.toString();
+ } catch {
+ return "https://v3.coinos.io/";
+ }
+ });
+</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 move from coinos v3.</p>
+ {:else if form?.ok}
+ <div class="space-y-3">
+ <p class="text-xl">✓ All done!</p>
+ {#if form.sent}
+ <p>{form.sent.toLocaleString()} sats are on their way to your new wallet.</p>
+ {/if}
+ <p>
+ <strong>{form.released}@coinos.io</strong> now receives into your coinos v3 wallet.
+ </p>
+ <a class="btn btn-primary w-full" href={backUrl}>Back to coinos v3</a>
+ </div>
+ {:else}
+ <p>
+ This sends your balance to your new wallet and points
+ <strong>{user.username}@coinos.io</strong> at it, so payments to your address arrive
+ there from now on. This account stays exactly as it is — same name, same history —
+ you just receive on coinos v3.
+ </p>
+
+ <div class="rounded-xl border p-3">
+ <div class="flex justify-between"><span>Balance</span><strong>{balance.toLocaleString()} sats</strong></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"
+ name="pin" placeholder="Your PIN" />
+ {/if}
+ <button class="btn btn-primary w-full" disabled={working}>
+ {#if working}Migrating…{:else}Migrate{/if}
+ </button>
+ </form>
+
+ <p class="text-sm opacity-60">You can also do nothing — your old account keeps working as it is.</p>
+ {/if}
+</div>