diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts
index 592c5ffc..4cf553d9 100644
--- a/apps/web/src/app/api/chat/route.ts
+++ b/apps/web/src/app/api/chat/route.ts
@@ -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
@@ -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);
}
@@ -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
diff --git a/apps/web/src/components/chat-panel.tsx b/apps/web/src/components/chat-panel.tsx
index 6524f1fd..0876f662 100644
--- a/apps/web/src/components/chat-panel.tsx
+++ b/apps/web/src/components/chat-panel.tsx
@@ -142,7 +142,7 @@ function ChatContent({ messages, status, connections, acceptMessage, identity, d
>}
}
-
+
);
}
@@ -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;
drafts: Record;
rememberDraft: (address: string, update: Partial) => void;
@@ -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 ?? "");
@@ -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);
@@ -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">
- {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" && <> {" "}>}
> : lines > CHAT_MAX_LINES ? t("Use at most {max} lines.", { max: CHAT_MAX_LINES })
diff --git a/apps/web/src/hooks/use-chat.ts b/apps/web/src/hooks/use-chat.ts
index 7a12d03a..13c2fbb9 100644
--- a/apps/web/src/hooks/use-chat.ts
+++ b/apps/web/src/hooks/use-chat.ts
@@ -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(null);
const [resolvedAddress, setResolvedAddress] = useState(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);
@@ -130,16 +137,21 @@ export function useChatIdentity(active: boolean, address: string | null) {
if (stopped || !value || typeof value !== "object") return;
const candidate = value as Partial;
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) => setIdentity((previous) => ({
+ const rememberIdentity = (messageIdentity: Omit) => 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. */
diff --git a/apps/web/src/lib/api/counterparty.ts b/apps/web/src/lib/api/counterparty.ts
index e71e6b54..4e75f34b 100644
--- a/apps/web/src/lib/api/counterparty.ts
+++ b/apps/web/src/lib/api/counterparty.ts
@@ -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 {
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 {
+ 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
diff --git a/apps/web/src/lib/api/launchpad-api.ts b/apps/web/src/lib/api/launchpad-api.ts
index e5257046..323ccd24 100644
--- a/apps/web/src/lib/api/launchpad-api.ts
+++ b/apps/web/src/lib/api/launchpad-api.ts
@@ -1913,3 +1913,32 @@ export function fetchActivityLaunches(limit = 50): Promise {
+ 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;
+}
diff --git a/apps/web/src/lib/chat-eligibility.ts b/apps/web/src/lib/chat-eligibility.ts
new file mode 100644
index 00000000..d6d24c92
--- /dev/null
+++ b/apps/web/src/lib/chat-eligibility.ts
@@ -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();
+
+/** Throws when either read fails, so "unknown" never reads as "under 10". */
+export async function holdsChatMinimum(address: string, now = Date.now()): Promise {
+ 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;
+}
diff --git a/apps/web/src/locales/_source.json b/apps/web/src/locales/_source.json
index bdc14cb1..d1a38f89 100644
--- a/apps/web/src/locales/_source.json
+++ b/apps/web/src/locales/_source.json
@@ -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"
],
diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json
index 1de43f0f..524eb8ef 100644
--- a/apps/web/src/locales/es.json
+++ b/apps/web/src/locales/es.json
@@ -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",
diff --git a/apps/web/src/locales/es.status.json b/apps/web/src/locales/es.status.json
index a469d6d3..d82b1fde 100644
--- a/apps/web/src/locales/es.status.json
+++ b/apps/web/src/locales/es.status.json
@@ -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?",
diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json
index 56ca7c42..f378b4c7 100644
--- a/apps/web/src/locales/fr.json
+++ b/apps/web/src/locales/fr.json
@@ -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",
diff --git a/apps/web/src/locales/fr.status.json b/apps/web/src/locales/fr.status.json
index a469d6d3..d82b1fde 100644
--- a/apps/web/src/locales/fr.status.json
+++ b/apps/web/src/locales/fr.status.json
@@ -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?",
diff --git a/apps/web/src/locales/ja.json b/apps/web/src/locales/ja.json
index b1fbd279..3e0ee062 100644
--- a/apps/web/src/locales/ja.json
+++ b/apps/web/src/locales/ja.json
@@ -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": "名前付きアセットのみ、分割可能",
diff --git a/apps/web/src/locales/ja.status.json b/apps/web/src/locales/ja.status.json
index 88fcf22d..73b96359 100644
--- a/apps/web/src/locales/ja.status.json
+++ b/apps/web/src/locales/ja.status.json
@@ -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?",
diff --git a/apps/web/src/locales/ko.json b/apps/web/src/locales/ko.json
index 2fce4ab1..8b79558a 100644
--- a/apps/web/src/locales/ko.json
+++ b/apps/web/src/locales/ko.json
@@ -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": "이름 있는 자산만, 분할 가능",
diff --git a/apps/web/src/locales/ko.status.json b/apps/web/src/locales/ko.status.json
index fcc20714..a27c813c 100644
--- a/apps/web/src/locales/ko.status.json
+++ b/apps/web/src/locales/ko.status.json
@@ -401,6 +401,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?",
diff --git a/apps/web/src/locales/notes.json b/apps/web/src/locales/notes.json
index d8b3eae1..27753e59 100644
--- a/apps/web/src/locales/notes.json
+++ b/apps/web/src/locales/notes.json
@@ -111,5 +111,6 @@
"{year} asset": "Asset-origin chip: the asset name was first registered in the calendar year {year}, before this fairminter. Preserve {year}; it does not date the issuer address or newly minted tokens.",
"Created by this fairminter.": "Tooltip for New asset: the current fairminter registered the asset name as part of this launch.",
"Asset name first registered in {year}.": "Tooltip for an existing asset: {year} is the calendar year when its name was first registered, not when this launch minted tokens. Preserve {year}.",
- "If it fails, every minter’s XCP is automatically refunded.": "All-or-nothing pool launch deadline explanation: if the sale misses its target, consensus automatically returns every participant’s full XCP contribution without a manual claim. Explicitly preserve automatic and full refund; this does not include Bitcoin transaction fees or describe other mint types."
+ "If it fails, every minter’s XCP is automatically refunded.": "All-or-nothing pool launch deadline explanation: if the sale misses its target, consensus automatically returns every participant’s full XCP contribution without a manual claim. Explicitly preserve automatic and full refund; this does not include Bitcoin transaction fees or describe other mint types.",
+ "Must hold 10 XCP to chat.": "Shown in the chat box to a signed-in wallet with under 10 XCP. It means the wallet must HOLD (keep a balance of) at least 10 XCP; nothing is paid, spent or charged. Avoid wording that reads as a fee or price."
}
diff --git a/apps/web/src/locales/pt.json b/apps/web/src/locales/pt.json
index 321c9dd1..c6ed1b28 100644
--- a/apps/web/src/locales/pt.json
+++ b/apps/web/src/locales/pt.json
@@ -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, reembolsos e execuções no pool ou no livro de ofertas de lançamentos XCP-69. “Outro” concilia esses eventos com o saldo atual e pode representar um envio, um recebimento, uma queima ou uma movimentação de liquidez. Um mint aberto mostra o que você comprometeu — esses XCP estão em custódia por consenso e voltam automaticamente se o lançamento não atingir o soft cap.",
"missed the cap, paid back": "não atingiram a meta, devolvidos",
"More than one wallet is installed. Pick the one to connect with.": "Há mais de uma carteira instalada. Escolha com qual conectar.",
+ "Must hold 10 XCP to chat.": "É preciso ter 10 XCP para usar o chat.",
"Name": "Nome",
"Name, image, description. Everything else is the standard.": "Nome, imagem, descrição. Todo o resto é o padrão.",
"named assets only, divisible": "só ativos nomeados, divisíveis",
diff --git a/apps/web/src/locales/pt.status.json b/apps/web/src/locales/pt.status.json
index a469d6d3..d82b1fde 100644
--- a/apps/web/src/locales/pt.status.json
+++ b/apps/web/src/locales/pt.status.json
@@ -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?",
diff --git a/apps/web/src/locales/ru.json b/apps/web/src/locales/ru.json
index 60bac7f5..d45f86b6 100644
--- a/apps/web/src/locales/ru.json
+++ b/apps/web/src/locales/ru.json
@@ -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": "только именованные активы, делимые",
diff --git a/apps/web/src/locales/ru.status.json b/apps/web/src/locales/ru.status.json
index faec0bfa..9a3c0381 100644
--- a/apps/web/src/locales/ru.status.json
+++ b/apps/web/src/locales/ru.status.json
@@ -404,6 +404,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?",
diff --git a/apps/web/src/locales/uk.json b/apps/web/src/locales/uk.json
index e1e2cf41..b88ff58e 100644
--- a/apps/web/src/locales/uk.json
+++ b/apps/web/src/locales/uk.json
@@ -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": "лише іменовані активи, подільні",
diff --git a/apps/web/src/locales/uk.status.json b/apps/web/src/locales/uk.status.json
index faec0bfa..9a3c0381 100644
--- a/apps/web/src/locales/uk.status.json
+++ b/apps/web/src/locales/uk.status.json
@@ -404,6 +404,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?",
diff --git a/apps/web/src/locales/zh-hk.json b/apps/web/src/locales/zh-hk.json
index addd9f6d..b5ac2827 100644
--- a/apps/web/src/locales/zh-hk.json
+++ b/apps/web/src/locales/zh-hk.json
@@ -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": "僅限命名資產,可分割",
diff --git a/apps/web/src/locales/zh-hk.status.json b/apps/web/src/locales/zh-hk.status.json
index d17221e5..e7db87e2 100644
--- a/apps/web/src/locales/zh-hk.status.json
+++ b/apps/web/src/locales/zh-hk.status.json
@@ -698,6 +698,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.",
"missed the cap, paid back",
"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.",
"named assets only, divisible",
diff --git a/apps/web/src/locales/zh-tw.json b/apps/web/src/locales/zh-tw.json
index 9506fb6e..6cd8a526 100644
--- a/apps/web/src/locales/zh-tw.json
+++ b/apps/web/src/locales/zh-tw.json
@@ -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": "僅限命名資產,可分割",
diff --git a/apps/web/src/locales/zh-tw.status.json b/apps/web/src/locales/zh-tw.status.json
index d17221e5..e7db87e2 100644
--- a/apps/web/src/locales/zh-tw.status.json
+++ b/apps/web/src/locales/zh-tw.status.json
@@ -698,6 +698,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.",
"missed the cap, paid back",
"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.",
"named assets only, divisible",
diff --git a/apps/web/src/locales/zh.json b/apps/web/src/locales/zh.json
index 95a9d403..ebe25e2b 100644
--- a/apps/web/src/locales/zh.json
+++ b/apps/web/src/locales/zh.json
@@ -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": "仅限命名资产,可分割",
diff --git a/apps/web/src/locales/zh.status.json b/apps/web/src/locales/zh.status.json
index c4c6a640..4e4d6c38 100644
--- a/apps/web/src/locales/zh.status.json
+++ b/apps/web/src/locales/zh.status.json
@@ -399,6 +399,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?",
diff --git a/apps/web/src/providers/chat-context.tsx b/apps/web/src/providers/chat-context.tsx
index 2a4ad1e2..3fc6620d 100644
--- a/apps/web/src/providers/chat-context.tsx
+++ b/apps/web/src/providers/chat-context.tsx
@@ -94,14 +94,14 @@ function useChatState() {
const session = useSession();
const address = wallet.status === "connected" && wallet.address && session.address === wallet.address ? wallet.address : null;
const room = useChat(active);
- const { identity, rememberIdentity } = useChatIdentity(active, address);
+ const { identity, rememberIdentity, markBelowMinimum } = useChatIdentity(active, address);
const acceptMessage = (message: ChatMessage) => {
room.acceptMessage(message);
if (address) rememberIdentity({ address, authorId: message.authorId, handle: message.handle });
};
const moderation = useChatModeration(identity);
const [showBanned, setShowBanned] = useState(false);
- return { ...room, acceptMessage, identity, moderation, showBanned, setShowBanned, desktop, expanded, collapsed, setCollapsed, open, setOpen, drafts, rememberDraft, navigateAsset, registerRoute };
+ return { ...room, acceptMessage, identity, markBelowMinimum, moderation, showBanned, setShowBanned, desktop, expanded, collapsed, setCollapsed, open, setOpen, drafts, rememberDraft, navigateAsset, registerRoute };
}
/** The locale layout survives asset page changes; the page only owns its UI. */
diff --git a/tests/chat-eligibility.test.ts b/tests/chat-eligibility.test.ts
new file mode 100644
index 00000000..c7b9e080
--- /dev/null
+++ b/tests/chat-eligibility.test.ts
@@ -0,0 +1,61 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { fetchAssetBalanceOrThrow } from "@/lib/api/counterparty";
+import { fetchMintingXcpPaid } from "@/lib/api/launchpad-api";
+import { CHAT_MIN_XCP_RAW, holdsChatMinimum } from "@/lib/chat-eligibility";
+
+vi.mock("@/lib/api/counterparty", () => ({ fetchAssetBalanceOrThrow: vi.fn() }));
+vi.mock("@/lib/api/launchpad-api", () => ({ fetchMintingXcpPaid: vi.fn() }));
+
+const MINUTE = 60_000;
+let n = 0;
+// A fresh address per test, since verdicts are cached per isolate.
+const wallet = () => `bc1qwallet${n++}`;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(fetchMintingXcpPaid).mockResolvedValue(0n);
+});
+
+describe("chat minimum balance", () => {
+ it("is 10 XCP in raw units, inclusive", async () => {
+ expect(CHAT_MIN_XCP_RAW).toBe(1_000_000_000n);
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValueOnce(1_000_000_000n);
+ expect(await holdsChatMinimum(wallet())).toBe(true);
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValueOnce(999_999_999n);
+ expect(await holdsChatMinimum(wallet())).toBe(false);
+ });
+
+ it("counts XCP escrowed in launches that are still minting", async () => {
+ const address = wallet();
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValueOnce(400_000_000n);
+ vi.mocked(fetchMintingXcpPaid).mockResolvedValueOnce(600_000_000n);
+ expect(await holdsChatMinimum(address)).toBe(true);
+ expect(fetchAssetBalanceOrThrow).toHaveBeenCalledWith(address, "XCP");
+ expect(fetchMintingXcpPaid).toHaveBeenCalledWith(address);
+ });
+
+ it("keeps a holder for ten minutes and re-reads a short wallet after one", async () => {
+ const holder = wallet(); const short = wallet(); const now = 1_000_000;
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValueOnce(2_000_000_000n).mockResolvedValueOnce(0n);
+ expect(await holdsChatMinimum(holder, now)).toBe(true);
+ expect(await holdsChatMinimum(short, now)).toBe(false);
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValue(0n);
+ expect(await holdsChatMinimum(holder, now + 10 * MINUTE - 1)).toBe(true);
+ expect(await holdsChatMinimum(short, now + MINUTE - 1)).toBe(false);
+ expect(fetchAssetBalanceOrThrow).toHaveBeenCalledTimes(2);
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValueOnce(1_000_000_000n);
+ expect(await holdsChatMinimum(short, now + MINUTE)).toBe(true);
+ expect(await holdsChatMinimum(holder, now + 10 * MINUTE)).toBe(false);
+ });
+
+ it("throws rather than deciding when either read fails, and caches nothing", async () => {
+ const address = wallet();
+ vi.mocked(fetchAssetBalanceOrThrow).mockRejectedValueOnce(new Error("node down"));
+ await expect(holdsChatMinimum(address)).rejects.toThrow("node down");
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValueOnce(5_000_000_000n);
+ vi.mocked(fetchMintingXcpPaid).mockRejectedValueOnce(new Error("index down"));
+ await expect(holdsChatMinimum(address)).rejects.toThrow("index down");
+ vi.mocked(fetchAssetBalanceOrThrow).mockResolvedValueOnce(5_000_000_000n);
+ expect(await holdsChatMinimum(address)).toBe(true);
+ });
+});
diff --git a/tests/chat-panel.test.tsx b/tests/chat-panel.test.tsx
index 50618de1..9536324d 100644
--- a/tests/chat-panel.test.tsx
+++ b/tests/chat-panel.test.tsx
@@ -198,6 +198,29 @@ describe("chat writes require the current verified wallet", () => {
expect(button("Send").disabled).toBe(true);
});
+ it("says a wallet under the minimum must hold 10 XCP, and blocks sending", async () => {
+ boundary.identity.mockResolvedValue(Response.json({ address: "wallet-one", authorId: "a".repeat(64), handle: HANDLE_A, isAdmin: false, holdsMinimum: false }));
+ await render(); await type("hello");
+ expect(document.body.textContent).toContain("Must hold 10 XCP to chat.");
+ expect(button("Send").disabled).toBe(true); expect(boundary.fetch).not.toHaveBeenCalled();
+ });
+
+ it("closes the composer when a send is refused for the minimum, keeping the draft", async () => {
+ boundary.fetch.mockResolvedValueOnce(Response.json({ error: "insufficient_xcp" }, { status: 403 }));
+ await render(); await type("still here"); await submit();
+ expect(document.body.textContent).toContain("Must hold 10 XCP to chat.");
+ expect(input().value).toBe("still here"); expect(button("Send").disabled).toBe(true);
+ });
+
+ it("re-checks the minimum when chat reopens, so a top-up lets the wallet post", async () => {
+ boundary.identity.mockResolvedValue(Response.json({ address: "wallet-one", authorId: "a".repeat(64), handle: HANDLE_A, isAdmin: false, holdsMinimum: false }));
+ await render(); expect(document.body.textContent).toContain("Must hold 10 XCP to chat.");
+ boundary.identity.mockResolvedValue(Response.json({ address: "wallet-one", authorId: "a".repeat(64), handle: HANDLE_A, isAdmin: false, holdsMinimum: true }));
+ await click(button("Collapse chat")); await click(button("Open chat"));
+ expect(document.body.textContent).not.toContain("Must hold 10 XCP to chat.");
+ await type("topped up"); expect(button("Send").disabled).toBe(false);
+ });
+
it("preserves a failed message and reuses its request ID on retry", async () => {
boundary.fetch.mockRejectedValueOnce(new Error("connection lost"));
await render(); await type("retry this exact message"); await submit();
diff --git a/tests/chat-route.test.ts b/tests/chat-route.test.ts
index df99c7b7..95e49b96 100644
--- a/tests/chat-route.test.ts
+++ b/tests/chat-route.test.ts
@@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { GET, POST } from "@/app/api/chat/route";
import { chatIdentity } from "@/lib/chat-identity";
+import { holdsChatMinimum } from "@/lib/chat-eligibility";
import { issueSession, SESSION_COOKIE } from "@/lib/session";
vi.mock("@opennextjs/cloudflare", () => ({ getCloudflareContext: vi.fn() }));
+vi.mock("@/lib/chat-eligibility", () => ({ holdsChatMinimum: vi.fn() }));
const address = "bc1qtest-wallet-address";
const secret = "chat-test-secret-0123456789abcdef0123456789";
@@ -18,6 +20,7 @@ const env = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getCloudflareContext).mockResolvedValue({ env } as unknown as Awaited>);
+ vi.mocked(holdsChatMinimum).mockResolvedValue(true);
publish.mockImplementation(async (post) => ({
ok: true,
message: { id: "message-1", authorId: post.authorId, handle: post.handle, text: post.text, createdAt: Date.now() },
@@ -90,6 +93,30 @@ describe("authenticated chat publishing", () => {
expect(await failed.json()).toEqual({ error: "unavailable" });
});
+ it("refuses a wallet under the XCP minimum before it reaches the room", async () => {
+ vi.mocked(holdsChatMinimum).mockResolvedValueOnce(false);
+ const response = await POST(await request());
+ expect(response.status).toBe(403);
+ expect(await response.json()).toEqual({ error: "insufficient_xcp" });
+ expect(holdsChatMinimum).toHaveBeenCalledWith(address);
+ expect(publish).not.toHaveBeenCalled();
+ });
+
+ it("answers an unreadable balance as unavailable, never as a pass or a refusal", async () => {
+ vi.mocked(holdsChatMinimum).mockRejectedValueOnce(new Error("node down"));
+ const response = await POST(await request());
+ expect(response.status).toBe(503);
+ expect(await response.json()).toEqual({ error: "unavailable" });
+ expect(publish).not.toHaveBeenCalled();
+ });
+
+ it("does not hold configured chat admins to the minimum", async () => {
+ vi.mocked(getCloudflareContext).mockResolvedValue({ env: { ...env, CHAT_ADMIN_ADDRESSES: address } } as unknown as Awaited>);
+ vi.mocked(holdsChatMinimum).mockResolvedValue(false);
+ expect((await POST(await request())).status).toBe(200);
+ expect(holdsChatMinimum).not.toHaveBeenCalled();
+ });
+
it("fails closed when the chat identity secret is absent", async () => {
const req = await request();
vi.mocked(getCloudflareContext).mockResolvedValue({ env: { ...env, CHAT_HANDLE_SECRET: undefined } } as unknown as Awaited>);
@@ -104,11 +131,19 @@ describe("chat pseudonyms", () => {
const response = await GET(new Request("https://xcp.fun/api/chat", { headers: { cookie: req.headers.get("cookie")! } }));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("private, no-store");
- expect(await response.json()).toEqual({ address, ...await chatIdentity(address, secret), isAdmin: false });
+ expect(await response.json()).toEqual({ address, ...await chatIdentity(address, secret), isAdmin: false, holdsMinimum: true });
expect(env.CHAT_ROOM.get).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
expect((await GET(new Request("https://xcp.fun/api/chat"))).status).toBe(401);
});
+ it("tells the browser whether the wallet holds the minimum, and null when it cannot tell", async () => {
+ const cookie = (await request()).headers.get("cookie")!;
+ const read = async () => (await (await GET(new Request("https://xcp.fun/api/chat", { headers: { cookie } }))).json()).holdsMinimum;
+ vi.mocked(holdsChatMinimum).mockResolvedValueOnce(false);
+ expect(await read()).toBe(false);
+ vi.mocked(holdsChatMinimum).mockRejectedValueOnce(new Error("node down"));
+ expect(await read()).toBeNull();
+ });
it("is stable for the same wallet and secret, with separate identities for different wallets or secrets", async () => {
const first = await chatIdentity(address, secret);
expect(await chatIdentity(address, secret)).toEqual(first);