Hi, I noticed a possible credit accounting consistency issue in the chat submission flow.
In apps/web/src/components/thread/index.tsx, a credit is deducted before the LLM stream is submitted:
199: const handleSubmit = async (e: FormEvent) => {
204: // Deduct 1 credit before making the LLM request
205: const creditResult = await deductCredits({ reason: "send message" });
207: if (!creditResult.success) {
208: return;
209: }
The stream is submitted afterward, and refunds are only handled inside the synchronous try/catch around stream.submit(...):
227: try {
228: stream.submit(
229: { messages: [...toolMessages, newHumanMessage], context },
...
244: setInput("");
245: setContentBlocks([]);
246: } catch (error: any) {
252: // Refund credits for overloaded server
253: if (creditResult.refundCredits) {
254: await creditResult.refundCredits();
255: }
263: // For other errors, still refund credits since the request failed
264: if (creditResult.refundCredits) {
265: await creditResult.refundCredits();
266: }
In apps/web/src/hooks/use-credit-deduction.ts, the hook performs an optimistic local deduction, then calls the database deduction:
49: // Check if user has sufficient credits before optimistic update
50: if (credits !== null && credits < creditsToDeduct) {
55: return { success: false, error: "Insufficient credits" };
58: try {
61: if (credits !== null && credits >= creditsToDeduct) {
62: optimisticDeduct(creditsToDeduct);
63: didOptimisticDeduct = true;
64: }
66: // Deduct credits from database
67: const creditResult = await deductUserCredits(user.id, creditsToDeduct);
69: if (!creditResult.success) {
71: if (didOptimisticDeduct) {
72: optimisticAdd(creditsToDeduct);
In apps/web/src/lib/stripe.ts, the database update appears to be read-then-write:
93: // Deduct credits from user's balance
94: const { data: currentUser, error: fetchError } = await supabase
95: .from("users")
96: .select("credits_available")
97: .eq("id", userId)
98: .single();
102: const currentBalance =
103: ((currentUser as any)?.credits_available as number) || 0;
104: if (currentBalance < creditsToDeduct) {
105: throw new Error("Insufficient credits");
106: }
108: const newCredits = currentBalance - creditsToDeduct;
111: const { error } = await (supabase as any)
112: .from("users")
113: .update({ credits_available: newCredits })
114: .eq("id", userId);
The resulting flow appears to be:
client credit check -> optimistic deduction -> DB read current balance -> DB write new balance -> stream.submit
Why this may matter:
- The balance update is not atomic; concurrent requests can read the same balance and write overlapping decrements.
- The credit deduction is not transactionally tied to successful LLM stream completion.
- Refund handling appears limited to errors thrown synchronously by
stream.submit(...), while later stream failures may not hit this catch block.
A safer design would use an atomic database update or RPC such as "deduct where credits_available >= amount returning new balance", and bind refund/charge status to the actual stream lifecycle. I am reporting this as a potential issue rather than a confirmed exploit, since database constraints or deployment-level serialization could reduce the race risk, but I did not see that protection in the current source.
Hi, I noticed a possible credit accounting consistency issue in the chat submission flow.
In
apps/web/src/components/thread/index.tsx, a credit is deducted before the LLM stream is submitted:The stream is submitted afterward, and refunds are only handled inside the synchronous
try/catcharoundstream.submit(...):In
apps/web/src/hooks/use-credit-deduction.ts, the hook performs an optimistic local deduction, then calls the database deduction:In
apps/web/src/lib/stripe.ts, the database update appears to be read-then-write:The resulting flow appears to be:
Why this may matter:
stream.submit(...), while later stream failures may not hit this catch block.A safer design would use an atomic database update or RPC such as "deduct where credits_available >= amount returning new balance", and bind refund/charge status to the actual stream lifecycle. I am reporting this as a potential issue rather than a confirmed exploit, since database constraints or deployment-level serialization could reduce the race risk, but I did not see that protection in the current source.