aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(app)/migrate/+page.server.ts
blob: 5182e8a0f44e9e64f44a04d4b16589db9d2d5964 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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;
}

// The server only accepts letters and numbers, 2–24 of them, so the retired
// name can't carry a separator. Candidates are <name>v2, <name>v3, … each
// trimmed to fit, so a long username still lands inside the limit.
function retiredNames(username: string) {
	const base = String(username).replace(/[^\p{L}\p{N}]/gu, "");
	const out: string[] = [];
	for (let n = 2; n <= 9; n++) {
		const suffix = `v${n}`;
		out.push(base.slice(0, 24 - suffix.length) + suffix);
	}
	return out;
}

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") || "",
		newName: retiredNames(user.username)[0],
	};
}

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. If the
			// retired name is itself taken, step to the next candidate.
			let renamed = "";
			let renameErr;
			for (const candidate of retiredNames(username)) {
				try {
					await post("/user", { username: candidate, ...(pin ? { pin } : {}) }, auth(cookies));
					renamed = candidate;
					break;
				} catch (e) {
					renameErr = e;
					const msg = String((e as any)?.message || e);
					if (!/taken/i.test(msg)) throw e;
				}
			}
			if (!renamed) throw renameErr;
			return { ok: true, sent, released: username, renamed };
		} catch (e) {
			const msg = e && typeof e === "object" && "message" in e ? String(e.message) : String(e);
			return { error: msg, sent };
		}
	},
};