Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getCloudflareContext } from "@opennextjs/cloudflare";
import { isChatRequestId, normalizeChatText, type ChatPost, type ChatPostResult } from "@launchpad/chat";
import { chatReply as reply, readChatBody as readBody, isChatAdmin } from "@/lib/chat-server";
import { chatIdentity } from "@/lib/chat-identity";
import { holdsChatMinimum } from "@/lib/chat-eligibility";
import { readCookie, readSession, sameOrigin, SESSION_COOKIE } from "@/lib/session";

// A narrow structural binding type keeps Worker implementation code out of
Expand All @@ -21,7 +22,11 @@ export async function GET(request: Request) {
const bindings = env as unknown as { CHAT_HANDLE_SECRET?: string; CHAT_ADMIN_ADDRESSES?: string };
const secret = bindings.CHAT_HANDLE_SECRET;
if (!secret) return reply({ error: "unavailable" }, 503);
return reply({ address, ...await chatIdentity(address, secret), isAdmin: isChatAdmin(address, bindings.CHAT_ADMIN_ADDRESSES) }, 200);
const isAdmin = isChatAdmin(address, bindings.CHAT_ADMIN_ADDRESSES);
// Null when the balance could not be read: the composer stays open and
// the post itself is checked, rather than locking a holder out on a blip.
const holdsMinimum = isAdmin ? true : await holdsChatMinimum(address).catch(() => null);
return reply({ address, ...await chatIdentity(address, secret), isAdmin, holdsMinimum }, 200);
} catch {
return reply({ error: "unavailable" }, 503);
}
Expand Down Expand Up @@ -52,8 +57,14 @@ export async function POST(request: Request) {
}
try {
const { env } = await getCloudflareContext({ async: true });
const bindings = env as unknown as { CHAT_ROOM?: ChatBinding; CHAT_HANDLE_SECRET?: string };
const bindings = env as unknown as { CHAT_ROOM?: ChatBinding; CHAT_HANDLE_SECRET?: string; CHAT_ADMIN_ADDRESSES?: string };
if (!bindings.CHAT_ROOM || !bindings.CHAT_HANDLE_SECRET) return reply({ error: "unavailable" }, 503);
// Free identities made bans and cooldowns optional; holding 10 XCP makes
// each new identity cost something. An unreadable balance falls through
// to the catch below as "unavailable", never as a refusal or a pass.
if (!isChatAdmin(address, bindings.CHAT_ADMIN_ADDRESSES) && !await holdsChatMinimum(address)) {
return reply({ error: "insufficient_xcp" }, 403);
}
const identity = await chatIdentity(address, bindings.CHAT_HANDLE_SECRET);
const room = bindings.CHAT_ROOM.get(bindings.CHAT_ROOM.idFromName("global"));
// Never forward the address, signature, session cookie or client-supplied
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ function ChatContent({ messages, status, connections, acceptMessage, identity, d
</ul>
</>}
</div>}
<ChatComposer key={address ?? "guest"} ref={composer} drafts={drafts} rememberDraft={rememberDraft} ownHandle={identity?.handle ?? null} acceptMessage={acceptMessage} runCommand={runCommand} />
<ChatComposer key={address ?? "guest"} ref={composer} drafts={drafts} rememberDraft={rememberDraft} ownHandle={identity?.handle ?? null} belowMinimum={identity?.holdsMinimum === false} acceptMessage={acceptMessage} runCommand={runCommand} />
</div>
);
}
Expand Down Expand Up @@ -183,8 +183,10 @@ function ChatText({ text, ownHandle, assetLinks }: { text: string; ownHandle: st

interface ChatComposerHandle { mention: (handle: string) => void }

function ChatComposer({ acceptMessage, ref, drafts, rememberDraft, ownHandle, runCommand }: {
function ChatComposer({ acceptMessage, ref, drafts, rememberDraft, ownHandle, belowMinimum, runCommand }: {
acceptMessage: (message: ChatMessage) => void;
/** The verified wallet holds under the 10 XCP chat minimum. */
belowMinimum: boolean;
ref: Ref<ChatComposerHandle>;
drafts: Record<string, ChatDraftMemory>;
rememberDraft: (address: string, update: Partial<ChatDraftMemory>) => void;
Expand All @@ -195,7 +197,10 @@ function ChatComposer({ acceptMessage, ref, drafts, rememberDraft, ownHandle, ru
const num = useNumbers();
const wallet = useWallet();
const session = useSession();
const canPost = wallet.status === "connected" && !!wallet.address && session.address === wallet.address;
const { markBelowMinimum } = useChatContext();
const verified = wallet.status === "connected" && !!wallet.address && session.address === wallet.address;
const needsXcp = verified && belowMinimum;
const canPost = verified && !needsXcp;
const draftKey = wallet.address ?? "guest";
const memory = drafts[draftKey];
const [draft, setDraft] = useState(memory?.draft ?? "");
Expand Down Expand Up @@ -305,6 +310,8 @@ function ChatComposer({ acceptMessage, ref, drafts, rememberDraft, ownHandle, ru
} else if (result.error === "unauthorized" || result.error === "session_mismatch") {
setError(t("Reconnect your wallet to verify again."));
session.invalidate();
} else if (result.error === "insufficient_xcp") {
markBelowMinimum();
} else if (result.error === "rate_limited") {
const retryAfter = typeof result.retryAfter === "number" && Number.isFinite(result.retryAfter) ? Math.max(1, Math.min(3600, result.retryAfter)) : CHAT_COOLDOWN_MS / 1000;
setNow(Date.now()); setCooldownUntil(Date.now() + retryAfter * 1000);
Expand Down Expand Up @@ -337,7 +344,7 @@ function ChatComposer({ acceptMessage, ref, drafts, rememberDraft, ownHandle, ru
aria-label={seconds > 0 && !notice && canPost ? t("Wait {n}s before sending again.", { n: num.commas(seconds) }) : undefined}
className="h-6 min-w-0 flex-1 overflow-y-auto text-[11px] leading-3 text-gray-500 outline-none dark:text-gray-400">
<div className="flex min-h-full items-center"><span>
{notice ?? (!canPost ? <>
{notice ?? (needsXcp ? t("Must hold 10 XCP to chat.") : !canPost ? <>
{session.status === "checking" ? t("Verifying wallet…") : session.status === "unauthenticated" ? t("Reconnect your wallet to verify again.") : t("Verify your wallet to chat.")}
{session.status === "unavailable" && <> {" "}<button type="button" onClick={() => session.retry()} className={`text-purple-600 hover:underline dark:text-purple-400 ${FOCUS}`}>{t("Retry verification")}</button></>}
</> : lines > CHAT_MAX_LINES ? t("Use at most {max} lines.", { max: CHAT_MAX_LINES })
Expand Down
22 changes: 17 additions & 5 deletions apps/web/src/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,19 @@ export function useMutedChatAuthors() {
};
}

export interface ChatIdentity { address: string; authorId: string; handle: string; isAdmin: boolean }
export interface ChatIdentity { address: string; authorId: string; handle: string; isAdmin: boolean;
/** Whether the wallet holds the chat minimum; null when the server could not tell. Display only: the server checks every post. */
holdsMinimum: boolean | null }
export function useChatIdentity(active: boolean, address: string | null) {
const [identity, setIdentity] = useState<ChatIdentity | null>(null);
const [resolvedAddress, setResolvedAddress] = useState<string | null>(null);
// Read through a ref so learning the verdict does not itself re-ask.
const belowMinimum = useRef(false);
useEffect(() => { belowMinimum.current = identity?.holdsMinimum === false; }, [identity?.holdsMinimum]);
useEffect(() => {
if (!active || !address || (resolvedAddress === address && identity?.address === address)) return;
// Under the minimum is re-asked whenever chat reopens, so a top-up is
// picked up without a reload.
if (!active || !address || (resolvedAddress === address && identity?.address === address && !belowMinimum.current)) return;
let stopped = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
Expand All @@ -130,16 +137,21 @@ export function useChatIdentity(active: boolean, address: string | null) {
if (stopped || !value || typeof value !== "object") return;
const candidate = value as Partial<ChatIdentity>;
if (candidate.address === address && isChatAuthorId(candidate.authorId) && isChatHandle(candidate.handle)) {
setIdentity({ address, authorId: candidate.authorId, handle: candidate.handle, isAdmin: candidate.isAdmin === true });
setIdentity({ address, authorId: candidate.authorId, handle: candidate.handle, isAdmin: candidate.isAdmin === true,
holdsMinimum: typeof candidate.holdsMinimum === "boolean" ? candidate.holdsMinimum : null });
setResolvedAddress(address);
}
}).catch(() => {}).finally(() => clearTimeout(timeout));
return () => { stopped = true; clearTimeout(timeout); controller.abort(); };
}, [active, address, resolvedAddress, identity?.address]);
const rememberIdentity = (messageIdentity: Omit<ChatIdentity, "isAdmin">) => setIdentity((previous) => ({
const rememberIdentity = (messageIdentity: Omit<ChatIdentity, "isAdmin" | "holdsMinimum">) => setIdentity((previous) => ({
...messageIdentity, isAdmin: previous?.address === messageIdentity.address && previous.isAdmin === true,
// An accepted post is proof the server let this wallet in.
holdsMinimum: true,
}));
return { identity: identity?.address === address ? identity : null, rememberIdentity };
/** A post refused for the minimum. Reopening chat re-asks. */
const markBelowMinimum = () => setIdentity((previous) => previous?.address === address ? { ...previous, holdsMinimum: false } : previous);
return { identity: identity?.address === address ? identity : null, rememberIdentity, markBelowMinimum };
}

/** Moderation is explicit and private: no background list polling. */
Expand Down
18 changes: 11 additions & 7 deletions apps/web/src/lib/api/counterparty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,18 +535,22 @@ export async function fetchHolderConcentration(
* question the profile actually asks, without paging every asset held. */
export async function fetchAssetBalance(address: string, asset: string): Promise<string> {
try {
const data = await get<{ result: { quantity: Raw }[] }>(
`/addresses/${encodeURIComponent(address)}/balances/${encodeURIComponent(asset)}`,
30,
);
return (data.result ?? [])
.reduce((sum, r) => sum + big(r.quantity), 0n)
.toString();
return (await fetchAssetBalanceOrThrow(address, asset)).toString();
} catch {
return "0";
}
}

/** The same read, for a caller that must not mistake "could not read" for
* "holds nothing": a failure throws instead of reading as zero. */
export async function fetchAssetBalanceOrThrow(address: string, asset: string): Promise<bigint> {
const data = await get<{ result: { quantity: Raw }[] }>(
`/addresses/${encodeURIComponent(address)}/balances/${encodeURIComponent(asset)}`,
30,
);
return (data.result ?? []).reduce((sum, r) => sum + big(r.quantity), 0n);
}

/**
* All fairminters, following next_cursor to the end (the old site's hardcoded
* limit=200 silently dropped a third of the records). The XCP-69 universe is
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/lib/api/launchpad-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1913,3 +1913,32 @@ export function fetchActivityLaunches(limit = 50): Promise<ActivityLaunch[] | nu
minters: r.minters,
}));
}

/**
* XCP this address has paid into launches that are still minting.
*
* That XCP is escrowed until the launch graduates or refunds, so it has left
* the address's balance without leaving its owner. Paged to exhaustion; a
* failure throws, because a caller gating on this must not read "unknown" as
* "none".
*/
export async function fetchMintingXcpPaid(address: string): Promise<bigint> {
let total = 0n;
let offset: number | null = 0;
while (offset !== null) {
const res = await launchpadApiFetch(
`/v2/mints/by/${encodeURIComponent(address)}?limit=1000&offset=${offset}`,
{ next: { revalidate: 15 } },
);
if (!res.ok) throw new Error(`Mints by address unavailable (${res.status})`);
const data = (await res.json()) as {
result?: { phase?: string; paid_quantity?: string | null }[];
next_offset?: number | null;
};
for (const mint of data.result ?? []) {
if (mint.phase === "minting" && mint.paid_quantity) total += BigInt(mint.paid_quantity);
}
offset = data.next_offset ?? null;
}
return total;
}
40 changes: 40 additions & 0 deletions apps/web/src/lib/chat-eligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Who may post: a wallet holding at least 10 XCP.
*
* Signing in is free, so a ban or a per-author cooldown only binds an
* identity that costs something to replace. Holding is the bar, not paying:
* nothing is spent, and XCP paid into a launch that is still minting counts,
* because it is escrowed rather than gone and minting is exactly what a
* participant should not lose chat for.
*
* Server-only. The verdict is cached per isolate: a holder keeps it for ten
* minutes, so moving the same 10 XCP between wallets unlocks them slowly; a
* wallet under the bar is re-read after a minute, so a top-up is noticed soon
* after it confirms.
*/
import { fetchAssetBalanceOrThrow } from "@/lib/api/counterparty";
import { fetchMintingXcpPaid } from "@/lib/api/launchpad-api";

/** 10 XCP in raw satoshi units. */
export const CHAT_MIN_XCP_RAW = 1_000_000_000n;
const HOLDS_TTL_MS = 10 * 60_000;
const SHORT_TTL_MS = 60_000;
const MAX_ENTRIES = 2_000;

const verdicts = new Map<string, { holds: boolean; at: number }>();

/** Throws when either read fails, so "unknown" never reads as "under 10". */
export async function holdsChatMinimum(address: string, now = Date.now()): Promise<boolean> {
const cached = verdicts.get(address);
if (cached && now - cached.at < (cached.holds ? HOLDS_TTL_MS : SHORT_TTL_MS)) return cached.holds;
const [free, minting] = await Promise.all([
fetchAssetBalanceOrThrow(address, "XCP"),
fetchMintingXcpPaid(address),
]);
const holds = free + minting >= CHAT_MIN_XCP_RAW;
verdicts.delete(address);
verdicts.set(address, { holds, at: now });
// Oldest first in insertion order, so this drops the stalest verdict.
if (verdicts.size > MAX_ENTRIES) verdicts.delete(verdicts.keys().next().value!);
return holds;
}
3 changes: 3 additions & 0 deletions apps/web/src/locales/_source.json
Original file line number Diff line number Diff line change
Expand Up @@ -2471,6 +2471,9 @@
"More than one wallet is installed. Pick the one to connect with.": [
"components/connect-button.tsx"
],
"Must hold 10 XCP to chat.": [
"components/chat-panel.tsx"
],
"Mute {name}": [
"components/chat-panel.tsx"
],
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@
"Mints, refunds, and pool or order-book fills on XCP-69 launches. “Other” reconciles those events to the live balance and can represent a send, receive, burn, or liquidity movement. An open mint shows what you've committed — that XCP is escrowed by consensus and comes back automatically if the launch misses its soft cap.": "Acuñaciones, reembolsos y ejecuciones en el pool o en el libro de órdenes de lanzamientos XCP-69. “Otro” concilia esos eventos con el saldo actual y puede representar un envío, una recepción, una quema o un movimiento de liquidez. Una acuñación abierta muestra lo que has comprometido — esos XCP están en custodia por consenso y vuelven automáticamente si el lanzamiento no alcanza su soft cap.",
"missed the cap, paid back": "no alcanzaron la meta, devueltos",
"More than one wallet is installed. Pick the one to connect with.": "Hay más de una billetera instalada. Elige con cuál conectar.",
"Must hold 10 XCP to chat.": "Debes tener 10 XCP para chatear.",
"Name": "Nombre",
"Name, image, description. Everything else is the standard.": "Nombre, imagen, descripción. Todo lo demás lo define el estándar.",
"named assets only, divisible": "solo activos con nombre, divisibles",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/es.status.json
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@
"Mints {from}–{to}",
"Mints, refunds, and pool or order-book fills on XCP-69 launches. “Other” reconciles those events to the live balance and can represent a send, receive, burn, or liquidity movement. An open mint shows what you've committed — that XCP is escrowed by consensus and comes back automatically if the launch misses its soft cap.",
"More than one wallet is installed. Pick the one to connect with.",
"Must hold 10 XCP to chat.",
"Name",
"Name, image, description. Everything else is the standard.",
"Need XCP?",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@
"Mints, refunds, and pool or order-book fills on XCP-69 launches. “Other” reconciles those events to the live balance and can represent a send, receive, burn, or liquidity movement. An open mint shows what you've committed — that XCP is escrowed by consensus and comes back automatically if the launch misses its soft cap.": "Mints, remboursements et exécutions dans le pool ou le carnet d'ordres sur des lancements XCP-69. « Autre » rapproche ces événements du solde actuel et peut représenter un envoi, une réception, un brûlage ou un mouvement de liquidité. Un mint ouvert montre ce que vous avez engagé — ces XCP sont sous séquestre par consensus et reviennent automatiquement si le lancement n'atteint pas son soft cap.",
"missed the cap, paid back": "objectif manqué, remboursés",
"More than one wallet is installed. Pick the one to connect with.": "Plusieurs portefeuilles sont installés. Choisissez celui avec lequel vous connecter.",
"Must hold 10 XCP to chat.": "Il faut détenir 10 XCP pour chatter.",
"Name": "Nom",
"Name, image, description. Everything else is the standard.": "Nom, image, description. Tout le reste, c'est le standard.",
"named assets only, divisible": "actifs nommés uniquement, divisibles",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/fr.status.json
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@
"Mints {from}–{to}",
"Mints, refunds, and pool or order-book fills on XCP-69 launches. “Other” reconciles those events to the live balance and can represent a send, receive, burn, or liquidity movement. An open mint shows what you've committed — that XCP is escrowed by consensus and comes back automatically if the launch misses its soft cap.",
"More than one wallet is installed. Pick the one to connect with.",
"Must hold 10 XCP to chat.",
"Name",
"Name, image, description. Everything else is the standard.",
"Need XCP?",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@
"Mints, refunds, and pool or order-book fills on XCP-69 launches. “Other” reconciles those events to the live balance and can represent a send, receive, burn, or liquidity movement. An open mint shows what you've committed — that XCP is escrowed by consensus and comes back automatically if the launch misses its soft cap.": "XCP-69 ローンチでのミント・返金・プールや板での約定。「その他」はこれらのイベントと実残高との差分で、送金・受取・バーン・流動性の移動を表します。進行中のミントにはコミット済みの量が表示されます — その XCP はコンセンサスによりエスクローされ、ローンチがソフトキャップに届かなければ自動的に戻ります。",
"missed the cap, paid back": "キャップ未達、返金済み",
"More than one wallet is installed. Pick the one to connect with.": "複数のウォレットがインストールされています。接続するものを選んでください。",
"Must hold 10 XCP to chat.": "チャットするには10 XCPの保有が必要です。",
"Name": "名前",
"Name, image, description. Everything else is the standard.": "名前、画像、説明文。それ以外はすべて標準どおりです。",
"named assets only, divisible": "名前付きアセットのみ、分割可能",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/ja.status.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@
"Mints {from}–{to}",
"Mints, refunds, and pool or order-book fills on XCP-69 launches. “Other” reconciles those events to the live balance and can represent a send, receive, burn, or liquidity movement. An open mint shows what you've committed — that XCP is escrowed by consensus and comes back automatically if the launch misses its soft cap.",
"More than one wallet is installed. Pick the one to connect with.",
"Must hold 10 XCP to chat.",
"Name",
"Name, image, description. Everything else is the standard.",
"Need XCP?",
Expand Down
Loading
Loading