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 };
}
},
};
|