diff options
Diffstat (limited to 'src/routes/(app)/migrate')
| -rw-r--r-- | src/routes/(app)/migrate/+page.server.ts | 106 | ||||
| -rw-r--r-- | src/routes/(app)/migrate/+page.svelte | 82 |
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> |
