Skip to content

Commit e57be55

Browse files
ralyodioclaude
andauthored
feat(pit): buy names under someone else's TLD, paid via CoinPay (#127)
/pit now splits into Yours and Theirs. Yours is the endings you hold; Theirs is everyone else's, and where an operator has set a price you can buy a name under it -- foo.whatever without owning .whatever. A TLD is closed for business until its operator prices it. Minting under someone's namespace without their say-so is the thing the registry exists to prevent, so listing is an explicit act and NULL -- where every existing row starts -- means not for sale. Prices must be positive and finite: zero or negative would let anyone drain a namespace for free, and NaN would be stored and then charged. Checkout reuses the existing CoinPay rails: the same payments/create call as credit packs, the same webhook, the same conditional-UPDATE claim. That webhook is the single URL CoinPay posts to, so it now settles whichever kind of payment the id belongs to. The name is quoted three times -- rendering, checkout, settlement -- because each gap is a place another buyer lands. An open checkout holds the name for 30 minutes so two people do not both pay for it, and an expired hold releases it so an abandoned checkout cannot lock a name forever. The (tld,label) primary key remains the real arbiter. When a buyer loses that last race they have paid for a name they cannot have, so the purchase goes to `refund_due` and is logged rather than swallowed. Nothing refunds it automatically yet -- that is money, and it should be a deliberate action, not a side effect. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 66a2647 commit e57be55

5 files changed

Lines changed: 452 additions & 9 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Selling names under a TLD you do not own.
2+
--
3+
-- A TLD is only open for business once its operator sets a price: minting
4+
-- under someone's namespace without their say-so is the thing the registry
5+
-- exists to prevent, so "for sale" has to be an explicit act, and NULL --
6+
-- the state every existing row starts in -- means not for sale.
7+
ALTER TABLE moshpit_tlds ADD COLUMN price_usd REAL;
8+
9+
-- One row per CoinPay checkout for a name. Keyed on the payment id, so a
10+
-- webhook redelivery settles the same row rather than creating a second one.
11+
CREATE TABLE IF NOT EXISTS moshpit_name_purchases (
12+
id TEXT PRIMARY KEY,
13+
tld TEXT NOT NULL,
14+
label TEXT NOT NULL,
15+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16+
amount_usd REAL NOT NULL,
17+
-- pending -> cleared, or -> refund_due when the name was taken between
18+
-- checkout and confirmation. That last state is real money against a name
19+
-- the buyer cannot have, so it is recorded rather than swallowed.
20+
status TEXT NOT NULL,
21+
created_at INTEGER NOT NULL,
22+
-- How long this checkout holds the name against other buyers. The (tld,label)
23+
-- primary key on moshpit_names is still the real arbiter; this only stops the
24+
-- ordinary case of two people paying for the same name at the same time.
25+
reserved_until INTEGER NOT NULL
26+
);
27+
CREATE INDEX IF NOT EXISTS idx_name_purchases_name ON moshpit_name_purchases(tld, label);
28+
CREATE INDEX IF NOT EXISTS idx_name_purchases_user ON moshpit_name_purchases(user_id);

‎apps/pwa/src/moshpit.mjs‎

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export {
1818
normalizeMode, resolutionPreference,
1919
} from "./lib/moshpit-name.mjs";
2020

21-
const COLS = `tld, user_id, owner_email, alias_of, created_at`;
21+
const COLS = `tld, user_id, owner_email, alias_of, price_usd, created_at`;
2222

2323
export async function getTld(tld) {
2424
return get(`SELECT ${COLS} FROM moshpit_tlds WHERE tld = ?`, [tld]);
@@ -263,6 +263,131 @@ async function ownedName(tldInput, labelInput, userId) {
263263
return { ok: true, tld, label };
264264
}
265265

266+
/* ---- selling names under a TLD ---- */
267+
268+
/**
269+
* Put a TLD up for sale, or take it down (`null`).
270+
*
271+
* Minting under someone's namespace without their say-so is exactly what the
272+
* registry exists to prevent, so being open for business is an explicit act by
273+
* the operator rather than a default. A price of null means closed, and every
274+
* TLD that existed before this feature starts there.
275+
*/
276+
export async function setTldPrice({ tld: tldInput, userId, priceUsd }) {
277+
const tld = normalizeTld(tldInput);
278+
if (!tld) return { ok: false, error: "not a valid TLD" };
279+
const owner = await getTld(tld);
280+
if (!owner) return { ok: false, error: `.${tld} is not registered` };
281+
if (owner.user_id !== userId) return { ok: false, error: `you do not own .${tld}` };
282+
283+
let price = null;
284+
if (priceUsd !== null && priceUsd !== undefined && String(priceUsd).trim() !== "") {
285+
price = Number(priceUsd);
286+
// NaN/Infinity would be stored verbatim and then charged; a negative or
287+
// zero price would let anyone drain the namespace for free.
288+
if (!Number.isFinite(price) || price <= 0) return { ok: false, error: "price must be a positive number" };
289+
if (price > 1_000_000) return { ok: false, error: "price is implausibly large" };
290+
price = Math.round(price * 100) / 100;
291+
}
292+
293+
await run(`UPDATE moshpit_tlds SET price_usd = ? WHERE tld = ?`, [price, tld]);
294+
await logAction(tld, userId, price === null ? "unlist" : `list:${price}`);
295+
return { ok: true, tld, priceUsd: price };
296+
}
297+
298+
/** TLDs somebody else holds. `forSale` narrows to the ones actually buyable. */
299+
export async function listTldsNotOwnedBy(userId, { forSale = false, limit = 200 } = {}) {
300+
const sql = `SELECT tld, user_id, owner_email, alias_of, price_usd, created_at
301+
FROM moshpit_tlds
302+
WHERE user_id IS NOT ?${forSale ? " AND price_usd IS NOT NULL" : ""}
303+
ORDER BY price_usd IS NULL, created_at DESC LIMIT ?`;
304+
return all(sql, [userId ?? "", limit]);
305+
}
306+
307+
export async function getTldWithPrice(tld) {
308+
return get(`SELECT tld, user_id, owner_email, alias_of, price_usd, created_at FROM moshpit_tlds WHERE tld = ?`, [tld]);
309+
}
310+
311+
/** How long a checkout holds a name against other buyers. */
312+
export const RESERVATION_MS = 30 * 60 * 1000;
313+
314+
/**
315+
* Is this name buyable right now, and for how much?
316+
*
317+
* Checked before taking money and again before handing the name over, because
318+
* the gap between those two is exactly where someone else's purchase lands.
319+
*/
320+
export async function quoteName({ tld: tldInput, label: labelInput, buyerId, now = Date.now() }) {
321+
const tld = normalizeTld(tldInput);
322+
const label = normalizeLabel(labelInput);
323+
if (!tld || !label) return { ok: false, error: "not a valid name" };
324+
325+
const owner = await getTldWithPrice(tld);
326+
if (!owner) return { ok: false, error: `.${tld} is not registered` };
327+
if (owner.user_id === buyerId) return { ok: false, error: `you own .${tld} — mint names under it for free` };
328+
if (owner.price_usd === null || owner.price_usd === undefined) {
329+
return { ok: false, error: `.${tld} is not for sale` };
330+
}
331+
if (await getName(tld, label)) return { ok: false, error: `${label}.${tld} is already taken`, taken: true };
332+
333+
const held = await get(
334+
`SELECT id FROM moshpit_name_purchases
335+
WHERE tld = ? AND label = ? AND status = 'pending' AND reserved_until > ? LIMIT 1`,
336+
[tld, label, now],
337+
);
338+
if (held) return { ok: false, error: `${label}.${tld} is in someone's checkout right now — try again shortly`, taken: true };
339+
340+
return { ok: true, tld, label, priceUsd: owner.price_usd, sellerId: owner.user_id };
341+
}
342+
343+
/** Record a checkout so the webhook can settle it. */
344+
export async function openNamePurchase({ paymentId, tld, label, userId, amountUsd, now = Date.now() }) {
345+
await run(
346+
`INSERT INTO moshpit_name_purchases (id, tld, label, user_id, amount_usd, status, created_at, reserved_until)
347+
VALUES (?,?,?,?,?, 'pending', ?, ?)`,
348+
[paymentId, tld, label, userId, amountUsd, now, now + RESERVATION_MS],
349+
);
350+
}
351+
352+
/**
353+
* Hand over a paid-for name. Idempotent on the payment id.
354+
*
355+
* The claim is a conditional UPDATE for the same reason the credit ledger uses
356+
* one: CoinPay retries a webhook it never got an ack for, so two deliveries can
357+
* be in flight at once and both read 'pending' before either write lands.
358+
*
359+
* If the name went to someone else in the meantime the buyer is owed a refund.
360+
* That is money against a name they cannot have, so it is recorded as
361+
* `refund_due` and logged rather than quietly dropped.
362+
*/
363+
export async function settleNamePurchase(paymentId) {
364+
const p = await get(`SELECT * FROM moshpit_name_purchases WHERE id = ? AND status = 'pending'`, [paymentId]);
365+
if (!p) return { ok: false, error: "no pending purchase for that payment" };
366+
367+
const claimed = await run(
368+
`UPDATE moshpit_name_purchases SET status = 'settling' WHERE id = ? AND status = 'pending'`, [paymentId]);
369+
if (!claimed.rowsAffected) return { ok: false, error: "already settled" };
370+
371+
try {
372+
await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`,
373+
[p.tld, p.label, p.user_id, null, Date.now()]);
374+
} catch {
375+
await run(`UPDATE moshpit_name_purchases SET status = 'refund_due' WHERE id = ?`, [paymentId]);
376+
console.error(`[moshpit] ${p.label}.${p.tld} was taken before payment ${paymentId} settled — refund due to ${p.user_id}`);
377+
return { ok: false, error: "name was taken before payment settled", refundDue: true };
378+
}
379+
380+
await run(`UPDATE moshpit_name_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]);
381+
await logAction(p.tld, p.user_id, `bought:${p.label}`);
382+
return { ok: true, tld: p.tld, label: p.label, userId: p.user_id };
383+
}
384+
385+
export async function listNamePurchases(userId, limit = 50) {
386+
return all(
387+
`SELECT id, tld, label, amount_usd, status, created_at FROM moshpit_name_purchases
388+
WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, [userId, limit]);
389+
}
390+
266391
/* ---- resolution ---- */
267392

268393
/**

‎apps/pwa/src/routes/credits.mjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { id } from "../lib/crypto.mjs";
66
import { grant } from "../lib/credits.mjs";
77
import { verifySignature } from "../lib/signature.mjs";
88
import { requireAuth } from "../lib/session.mjs";
9+
import { settleNamePurchase } from "../moshpit.mjs";
910

1011
export const creditsRouter = Router();
1112

@@ -66,6 +67,12 @@ creditsRouter.post("/webhooks/coinpay", async (req, res) => {
6667
const event = req.body?.type || req.body?.event;
6768
const payId = req.body?.data?.id || req.body?.payment_id || req.body?.id;
6869
if (event && CONFIRMED_EVENT.test(event) && payId) {
70+
// CoinPay is configured with a single webhook URL, so this endpoint is the
71+
// entry point for every kind of payment the app takes. A name purchase and
72+
// a credit top-up are different rows in different tables; whichever one
73+
// this id belongs to is the one that settles.
74+
await settleNamePurchase(payId).catch((e) => console.error("[moshpit] settle failed:", e.message));
75+
6976
const p = await get(`SELECT * FROM credit_purchases WHERE id = ? AND status = 'pending'`, [payId]);
7077
if (p) {
7178
// Claim the purchase atomically, the same way /cli/token claims auth

‎apps/pwa/src/routes/moshpit.mjs‎

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import {
1919
getTld, listTlds, listTldsForUser, registerTld, normalizeLabel,
2020
setAlias, clearAlias, listExempt, setExempt, clearExempt,
2121
listNames, registerName, setNameTarget, releaseName,
22+
setTldPrice, listTldsNotOwnedBy, quoteName, openNamePurchase,
2223
resolveMoshpitName, normalizeTld, tldRejection,
2324
normalizeMode, resolutionPreference,
2425
} from "../moshpit.mjs";
26+
import { config } from "../config.mjs";
2527

2628
export const moshpitRouter = Router();
2729

@@ -145,6 +147,87 @@ moshpitRouter.delete("/api/moshpit/tlds/:tld/names", async (req, res) => {
145147
res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), released: true });
146148
});
147149

150+
/* ---- the market ---- */
151+
152+
/** TLDs other people hold. `?for_sale=1` narrows to the buyable ones. */
153+
moshpitRouter.get("/api/moshpit/market", async (req, res) => {
154+
const tlds = await listTldsNotOwnedBy(req.user?.id ?? null, { forSale: Boolean(req.query.for_sale) });
155+
res.json({
156+
tlds: tlds.map((t) => ({
157+
tld: t.tld, alias_of: t.alias_of, price_usd: t.price_usd,
158+
for_sale: t.price_usd !== null && t.price_usd !== undefined,
159+
})),
160+
});
161+
});
162+
163+
/** What a name would cost, and whether it can be bought at all. */
164+
moshpitRouter.get("/api/moshpit/tlds/:tld/quote", async (req, res) => {
165+
const q = await quoteName({ tld: req.params.tld, label: req.query.label, buyerId: req.user?.id ?? null });
166+
if (!q.ok) return bad(res, q.error, q.taken ? 409 : 400);
167+
res.json({ tld: q.tld, label: q.label, price_usd: q.priceUsd });
168+
});
169+
170+
moshpitRouter.put("/api/moshpit/tlds/:tld/price", async (req, res) => {
171+
if (!req.user) return unauthorized(res);
172+
const result = await setTldPrice({ tld: req.params.tld, userId: req.user.id, priceUsd: req.body?.price_usd });
173+
if (!result.ok) return bad(res, result.error || "could not set that price");
174+
res.json({ tld: result.tld, price_usd: result.priceUsd });
175+
});
176+
177+
/**
178+
* Start a CoinPay checkout for `label.tld`.
179+
*
180+
* The quote is taken again here rather than trusted from the page the buyer was
181+
* looking at: prices change, and names get taken, between rendering and
182+
* clicking. settleNamePurchase checks a third time, because the gap between
183+
* paying and confirming is where the last race lives.
184+
*/
185+
async function startCheckout(req, res, { json }) {
186+
const q = await quoteName({ tld: req.params.tld, label: req.body?.label, buyerId: req.user.id });
187+
if (!q.ok) {
188+
return json ? bad(res, q.error, q.taken ? 409 : 400) : back(res, { err: q.error });
189+
}
190+
if (!config.coinpay.businessId) {
191+
const msg = "payments are not configured yet";
192+
return json ? bad(res, msg, 503) : back(res, { err: msg });
193+
}
194+
195+
try {
196+
const r = await fetch(`${config.coinpay.apiBase}/api/payments/create`, {
197+
method: "POST",
198+
headers: { "content-type": "application/json" },
199+
body: JSON.stringify({
200+
business_id: config.coinpay.businessId,
201+
amount: q.priceUsd,
202+
currency: "USD",
203+
payment_method: "both",
204+
metadata: { app: "moshcode", kind: "moshpit_name", user_id: req.user.id, tld: q.tld, label: q.label },
205+
redirect_url: `${config.origin}/pit?bought=${encodeURIComponent(`${q.label}.${q.tld}`)}`,
206+
}),
207+
});
208+
const pay = await r.json();
209+
const payId = pay.id || pay.payment_id;
210+
if (!payId) throw new Error("no payment id in response");
211+
212+
// Recorded before the buyer leaves for the payment page: the webhook can
213+
// arrive before they are redirected back, and with no row it has nothing
214+
// to settle.
215+
await openNamePurchase({ paymentId: payId, tld: q.tld, label: q.label, userId: req.user.id, amountUsd: q.priceUsd });
216+
217+
const url = pay.hosted_url || pay.url || `${config.coinpay.apiBase}/pay/${payId}`;
218+
return json ? res.status(201).json({ payment_id: payId, checkout_url: url, price_usd: q.priceUsd }) : res.redirect(url);
219+
} catch (e) {
220+
console.error("[moshpit] checkout failed:", e.message);
221+
const msg = "could not start checkout";
222+
return json ? bad(res, msg, 502) : back(res, { err: msg });
223+
}
224+
}
225+
226+
moshpitRouter.post("/api/moshpit/tlds/:tld/buy", async (req, res) => {
227+
if (!req.user) return unauthorized(res);
228+
return startCheckout(req, res, { json: true });
229+
});
230+
148231
/**
149232
* Resolve a name, and say what a resolver should DO with the answer.
150233
*
@@ -196,13 +279,16 @@ const PIT_CSS = `
196279
.pit-names{margin-top:12px;padding-top:10px;border-top:1px dashed var(--line)}
197280
.pit-name{background:var(--bg-tint);border-radius:8px;padding:6px 10px;margin-bottom:6px}
198281
.pit-name .mono{min-width:150px}
282+
.pit-forsale{border-color:color-mix(in srgb,var(--acid) 35%,var(--line))}
199283
.pit-msg{border-radius:8px;padding:10px 14px;margin:14px 0;font-family:var(--mono);font-size:.84rem}
200284
.pit-msg.err{border:1px solid var(--danger);color:var(--danger)}
201285
.pit-msg.ok{border:1px solid var(--acid);color:var(--acid)}`;
202286

287+
const forSale = (t) => t.price_usd !== null && t.price_usd !== undefined;
288+
203289
moshpitRouter.get("/pit", async (req, res) => {
204-
const [registry, mine, bal] = await Promise.all([
205-
listTlds(50),
290+
const [theirs, mine, bal] = await Promise.all([
291+
listTldsNotOwnedBy(req.user?.id ?? null, { limit: 100 }),
206292
req.user ? listTldsForUser(req.user.id) : [],
207293
req.user ? balance(req.user.id) : 0,
208294
]);
@@ -263,13 +349,42 @@ moshpitRouter.get("/pit", async (req, res) => {
263349
(exemptions.get(t.tld) || []).map((l) => `${esc(l)}.${esc(t.tld)}`).join(" · ") || "none held back"
264350
}</span>
265351
</form>` : ""}
352+
<form method="post" action="/pit/${esc(t.tld)}/price" class="pit-row">
353+
${csrfInput(req)}
354+
<span class="mono faint" style="font-size:.72rem">sell names for $</span>
355+
<input name="price_usd" inputmode="decimal" placeholder="0.00"
356+
value="${t.price_usd === null || t.price_usd === undefined ? "" : esc(String(t.price_usd))}"
357+
autocomplete="off" style="min-width:90px">
358+
<button class="btn" type="submit">${forSale(t) ? "Update price" : "List for sale"}</button>
359+
${forSale(t) ? `<button class="btn" type="submit" name="unlist" value="1">Unlist</button>` : ""}
360+
<span class="mono ${forSale(t) ? "acid" : "faint"}" style="font-size:.72rem">${
361+
forSale(t) ? `anyone can buy a name under .${esc(t.tld)}` : "closed — only you can mint here"
362+
}</span>
363+
</form>
266364
</div>`).join("")
267365
: `<p class="dim">You don't hold a TLD yet. Claim one above.</p>`;
268366

269-
const registryHtml = registry.length
270-
? `<ul class="mono dim" style="line-height:1.9;padding-left:18px">${registry.map((t) =>
271-
`<li><span class="acid">.${esc(t.tld)}</span>${t.alias_of ? ` → .${esc(t.alias_of)}` : ""}</li>`).join("")}</ul>`
272-
: `<p class="dim">Nothing claimed yet.</p>`;
367+
// Sorted so the ones you can actually act on come first.
368+
const theirsHtml = theirs.length
369+
? theirs.map((t) => `
370+
<div class="pit-tld${forSale(t) ? " pit-forsale" : ""}">
371+
<div class="pit-row" style="margin:0;justify-content:space-between">
372+
<h3 class="${forSale(t) ? "acid" : "dim"}" style="font-family:var(--mono);font-size:1.05rem;text-transform:none">
373+
.${esc(t.tld)}${t.alias_of ? `<span class="faint"> → .${esc(t.alias_of)}</span>` : ""}
374+
</h3>
375+
<span class="pill${forSale(t) ? " on" : ""}">${forSale(t) ? `$${esc(String(t.price_usd))} a name` : "not for sale"}</span>
376+
</div>
377+
${forSale(t) ? (req.user ? `
378+
<form method="post" action="/pit/${esc(t.tld)}/buy" class="pit-row">
379+
${csrfInput(req)}
380+
<input name="label" placeholder="the name you want" autocomplete="off" spellcheck="false" required>
381+
<span class="mono faint">.${esc(t.tld)}</span>
382+
<button class="btn acid" type="submit">Buy for $${esc(String(t.price_usd))}</button>
383+
</form>`
384+
: `<p class="mono faint" style="font-size:.72rem;margin:8px 0 0"><a class="acid" href="/">Sign in</a> to buy a name here.</p>`)
385+
: ""}
386+
</div>`).join("")
387+
: `<p class="dim">Nobody else holds a TLD yet.</p>`;
273388

274389
res.type("html").send(page({
275390
title: "moshcode ▸ the pit",
@@ -288,9 +403,18 @@ moshpitRouter.get("/pit", async (req, res) => {
288403
${msg}
289404
${req.user ? claimForm(req) : ""}
290405
<h2 style="margin-top:34px;font-size:1.2rem">Yours</h2>
406+
<p class="dim" style="max-width:62ch;margin:4px 0 14px">
407+
Endings you hold. Names under them are yours to mint for nothing — or put a price on the
408+
ending and let anyone buy one.
409+
</p>
291410
${mineHtml}
292-
<h2 style="margin-top:34px;font-size:1.2rem">The registry</h2>
293-
${registryHtml}
411+
<h2 style="margin-top:34px;font-size:1.2rem">Theirs</h2>
412+
<p class="dim" style="max-width:62ch;margin:4px 0 14px">
413+
Endings somebody else holds. Where the operator has set a price you can buy a name under it —
414+
<span class="mono">foo.whatever</span> without owning <span class="mono">.whatever</span>. Paid in crypto
415+
through CoinPay; the name lands the moment the payment confirms.
416+
</p>
417+
${theirsHtml}
294418
</main>${footer}`,
295419
}));
296420
});
@@ -338,6 +462,21 @@ moshpitRouter.post("/pit/:tld/names", requireAuth, async (req, res) => {
338462
back(res, { ok: done });
339463
});
340464

465+
moshpitRouter.post("/pit/:tld/price", requireAuth, async (req, res) => {
466+
const result = await setTldPrice({
467+
tld: req.params.tld, userId: req.user.id,
468+
priceUsd: req.body?.unlist ? null : req.body?.price_usd,
469+
});
470+
if (!result.ok) return back(res, { err: result.error || "could not set that price" });
471+
back(res, {
472+
ok: result.priceUsd === null
473+
? `.${result.tld} is no longer for sale.`
474+
: `.${result.tld} names now cost $${result.priceUsd}.`,
475+
});
476+
});
477+
478+
moshpitRouter.post("/pit/:tld/buy", requireAuth, (req, res) => startCheckout(req, res, { json: false }));
479+
341480
moshpitRouter.post("/pit/:tld/exempt", requireAuth, async (req, res) => {
342481
const result = await setExempt({ tld: req.params.tld, label: req.body?.label, userId: req.user.id });
343482
if (!result.ok) return back(res, { err: result.error || "could not exempt that name" });

0 commit comments

Comments
 (0)