Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.
This repository was archived by the owner on Aug 12, 2026. It is now read-only.

Potential credit accounting issue: deduction is non-atomic and not tied to stream completion #17

Description

@chenshj73

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions