Sync production with main - #633
Conversation
Exposes four authenticated REST endpoints under /credits, gated by the
buyCredits feature flag (same middleware as /intents):
GET /credits/summary — user's remaining bytes, next expiry,
canPurchase flag, maxPurchasableBytes,
and googleVerified status
GET /credits/batches — full purchase history (incl. expired rows)
GET /credits/batches/expiring — active rows expiring within 30 days
GET /credits/economics — admin-only system-wide expiry stats
Key design decisions:
- All responses are scoped to the authenticated user via JWT (no accountId
in the URL); /economics is further gated by UserRole.Admin.
- canPurchase / maxPurchasableBytes use the larger of upload vs download
remaining as the binding constraint, since each purchase grows both equally.
- BigInt fields are serialised as strings on the wire (matches intents pattern).
- Added getExpiringCreditsByAccountId(accountId, withinDays) to the
purchasedCredits repository — per-user variant of the existing system-wide
getExpiringCredits, needed for the /batches/expiring endpoint.
- 14 unit tests covering balance calculations, asymmetric byte scenarios,
cap-boundary edge cases, admin/non-admin gating, and 30-day window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…all rows getExpiringCredits() was doing SELECT * and pulling every system-wide expiring credit row into Node.js memory, only to reduce them for a count and two sums. Replace with a single SQL COUNT/SUM aggregate query to avoid unbounded memory usage as credit purchases grow. Made-with: Cursor
…te retry When a confirmed intent cannot be converted to credits because the user is already at the 100 GiB per-user cap, onConfirmedIntent previously returned an error — causing the payment manager polling loop to retry the same intent every 30 seconds forever while the user's money sat on-chain with no credits granted. ## What changes **`packages/models`** — adds `OVER_CAP = 'over_cap'` to `IntentStatus`. No migration needed: the intents.status column is VARCHAR(32) with no check constraint, so the new string value is accepted as-is. **`intentsRepository`** — adds `getOverCapIntents()` returning all rows with `status = 'over_cap'`, ordered by id. **`IntentsUseCases.onConfirmedIntent`** — when `addCreditsToAccount` returns a `ForbiddenError` (cap exceeded), the intent is now marked `OVER_CAP` and the function returns `ok()` instead of `err()`. The polling loop stops retrying because it only processes `CONFIRMED` rows. Non-ForbiddenError failures still propagate as errors so they continue to be retried (those represent transient infrastructure failures, not a permanent business-rule block). **`IntentsUseCases.getOverCapIntents(executor)`** — admin-only use case that lists all OVER_CAP intents for review. Returns `ForbiddenError` for non-admin callers. **`GET /intents/over-cap`** (admin only) — new HTTP endpoint that surfaces stuck intents so the team can decide whether to adjust a user's cap and reprocess, or arrange an out-of-band refund. ## What admins can do with this An OVER_CAP intent contains the userPublicId, paymentAmount, and the shannonsPerByte price at which bytes were calculated. From there the admin can: - Bump the user's cap via the existing `POST /accounts/update` endpoint and re-queue the intent by flipping it back to CONFIRMED in the DB. - Arrange an out-of-band on-chain refund via the treasury contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rocess When a confirmed intent cannot be converted to credits because the user is already at the 100 GiB per-user cap, onConfirmedIntent previously returned an error — causing the payment manager polling loop to retry the same intent every 30 seconds forever while the user's money sat on-chain with no credits granted. **`packages/models`** — adds `OVER_CAP = 'over_cap'` to `IntentStatus`. No migration needed: the intents.status column is VARCHAR(32) with no check constraint. **`errors/index.ts`** — adds `ConflictError` (HTTP 409) for operations that are valid but applied to a resource in the wrong state. **`intentsRepository`** — adds `getOverCapIntents()` returning all rows with `status = 'over_cap'`, ordered by id. **`IntentsUseCases.onConfirmedIntent`** — when `addCreditsToAccount` returns a `ForbiddenError` (cap exceeded), the intent is now marked `OVER_CAP` and the function returns `ok()` instead of `err()`. The polling loop stops retrying because it only fetches `CONFIRMED` rows. **`IntentsUseCases.getOverCapIntents(executor)`** — admin-only use case listing all OVER_CAP intents for review. **`IntentsUseCases.reprocessOverCapIntent(executor, intentId)`** — admin resets a single OVER_CAP intent back to CONFIRMED so the polling loop re-attempts credit grant on its next tick (~30 s). Returns ConflictError if the intent is not OVER_CAP, preventing accidental re-queuing. **Controller** — adds two admin-only endpoints: - `GET /intents/over-cap` list stuck intents - `POST /intents/:id/reprocess` re-queue after cap is raised Also fixes a route-ordering bug: the static `GET /over-cap` route was registered after the dynamic `GET /:id` route, causing Express to match `GET /intents/over-cap` as `id = 'over-cap'`. Static routes are now registered before dynamic ones. 1. User's payment arrives on-chain but cap is hit → intent becomes OVER_CAP. 2. Admin calls `GET /intents/over-cap` to see the stuck intent with its userPublicId and paymentAmount. 3. Admin calls `POST /accounts/update` to raise the user's cap. 4. Admin calls `POST /intents/:id/reprocess` to flip status back to CONFIRMED. 5. Polling loop picks it up within ~30 s and credits land automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two OVER_CAP tests used `await import('neverthrow')` inside the
test body to obtain the `err` helper. Dynamic imports in Jest's ESM
mode (--experimental-vm-modules) can cause module-caching surprises.
Replaced with a static `import { ok, err } from 'neverthrow'` at the
top of the file — simpler and unambiguous.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test fixtures used role: 'admin' / 'user' (lowercase) but UserRole enum values are 'Admin' / 'User' (capitalized), causing admin role checks to always fail and 5 tests to get ForbiddenError instead of proceeding. Made-with: Cursor
07a – Cap pre-purchase guard
- isPackageOverCap() utility compares a package's byte size against
maxPurchasableBytes from GET /credits/summary.
- Named packages that exceed the remaining cap are disabled (opacity-50,
pointer-events-none) and show an "Exceeds cap" badge.
- A top-level amber banner is shown when canPurchase === false.
- The "custom" package is never disabled here; exact-amount validation
is left to Step 2 where the user enters a value.
- Free-tier users (creditSummary === null) are never blocked.
07b – Fix over_cap terminal state in useTransactionConfirmation
- The polling loop now returns early when intent.status === 'over_cap',
sets isOverCap=true, and stops polling instead of spinning forever.
- Step3_TransferTokens surfaces a clear "Credit cap reached" error box
and disables the Continue button when isOverCap is true.
- Both queryKey caches (account + creditSummary) are invalidated on
a successful completed transition so balances refresh instantly.
07c – GET /credits/summary integrated into the frontend
- CreditSummaryResponse and ExpiringCreditBatch wire types added to
api.ts; getCreditSummary() and getExpiringCreditBatches() methods
added to the API service.
- creditSummary field + setCreditSummary action added to the Zustand
user store (version bump handled by persist config).
- SessionEnsurer now queries GET /credits/summary every 30 s and
stores the result; the query key is 'creditSummary'.
07d – Fix "No expiration" copy
- All three named packages now show "Credits valid for 90 days"
(CREDIT_EXPIRY_DAYS constant) instead of the incorrect "No expiration".
07e – Expiry warning banner
- New ExpiryWarningBanner atom queries GET /credits/batches/expiring
(refreshed every 5 min) and renders an amber warning with the soonest
expiry date and total expiring bytes.
- Banner is injected in the drive layout, above {children}.
Tests (24 total, all passing):
- credits.spec.ts: 16 cases covering isPackageOverCap, daysUntilExpiry,
and sumExpiringUploadBytes edge cases including zero cap, exact match,
large values, partial days, negative days, and null inputs.
- useTransactionConfirmation.spec.ts: 8 cases verifying that
completed and over_cap both stop polling, are mutually exclusive,
and that all other statuses (pending, confirmed, failed, expired)
continue polling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nv-var Previously the UI hardcoded "Credits valid for 90 days" as a compile-time constant. If the operator changes CREDIT_EXPIRY_DAYS in their deployment the frontend would silently show stale copy. Changes: - Add `expiryDays: number` to the CreditSummary type and GET /credits/summary response so the backend config value is propagated to the frontend. - Add `expiryDays` to CreditSummaryResponse wire type in api.ts. - Step1_SelectPackage now reads creditSummary.expiryDays (falling back to DEFAULT_EXPIRY_DAYS=90 only before the first API response) instead of a hardcoded constant. - Package features array refactored: static baseFeatures are defined at module level; the expiry string is appended at render time using the runtime value from the API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The polling effect checked isBackendCompleted to prevent re-entry after the completed terminal state but omitted the symmetric check for isOverCap. If a dependency changed reference after over_cap was detected, the effect re-fired and made a redundant API call. Made-with: Cursor
…ed credits in banner daysUntilExpiry could return 0 or negative values when a batch expired between refetch intervals, producing confusing text like "will expire in 0 days". The utility now clamps to Math.max(0, ...) and the banner shows "today" instead of interpolating zero/negative day counts. Made-with: Cursor
The function clamps to non-negative via Math.max(0, ...), so a past expiry date returns 0, not a negative number. The test expectation was toBeLessThan(0) which always fails. Made-with: Cursor
…tence Credit data changes with every purchase and expiry tick, so persisting it caused a stale-data flash on page load — briefly showing an incorrect "Credit cap reached" banner or enabling packages that exceed the actual cap. Made-with: Cursor
- Purchase credits allocate upload bytes only (downloadBytesOriginal: 0n) - Cap check enforced on upload bytes only, download leg removed - maxPurchasableBytes derived from uploadBytesRemaining only - getPendingCreditsByAccountAndType skips purchased credits for downloads - registerInteraction skips consumeUpTo for downloads (fromPurchased = 0n) Download infrastructure (columns, repo methods, types) is preserved for future use but is not allocated or enforced at this stage.
The AccountInformation component previously only rendered the free-tier
upload progress bar. Users who purchased credits saw no indication of
their purchased storage in the sidebar — the bar only reflected free-tier
consumption.
Changes:
- Add optional `purchasedBytesRemaining?: number` and
`nextExpiryDate?: Date | null` props (both default to safe values so
all existing call-sites continue to work without modification).
- When `purchasedBytesRemaining > 0`, render a compact "Purchased credits"
row below the free-tier bar showing the remaining bytes and, if a
next-expiry date is available, a relative-time hint ("expires in 2 months").
- The purchased-credits section is invisible to every other account type:
Monthly accounts, free-only OneOff accounts, and users whose operator
has not enabled the buyCredits feature flag all see zero change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SideNavbar now reads creditSummary from the Zustand store (populated by SessionEnsurer's 30-second GET /credits/summary poll) and passes purchasedBytesRemaining and nextExpiryDate to AccountInformation. Guard conditions ensure the purchased-credits section only appears when ALL of the following are true, matching the existing buyCredits gate: - features.buyCredits feature flag is enabled by the operator - user is logged in - account model is OneOff (not Monthly) - creditSummary has loaded and uploadBytesRemaining > 0 Monthly accounts, unauthenticated users, free-tier-only OneOff accounts, and any deployment where buyCredits is disabled are completely unaffected. nextExpiryDate is parsed from the API's ISO string only when a non-null value is present, so null/undefined propagates safely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the "Current Credits Balance" row read account.pendingUploadCredits
which is the free-tier remaining quota. This was wrong for the purchase flow:
1. Free-tier credits and purchased credits are separate pools — showing the
free-tier balance when a user is about to buy more purchased credits is
misleading.
2. The "After Purchase" row showed only the new purchase size (sizeMB), not
the total purchased credits the user will have after the transaction.
Changes:
- "Current Credits Balance" → "Current Purchased Credits", reading
creditSummary.uploadBytesRemaining (the purchased pool) and safely
defaulting to 0 while the summary is loading or for users who have
never purchased.
- "After Purchase" now computes currentPurchasedBytes + new purchase bytes
so the user sees their real total purchased capacity post-transaction.
- sizeMB is in MiB so the conversion is sizeMB × 1024 × 1024 bytes.
Free-tier users (creditSummary.uploadBytesRemaining === "0") see
"Current Purchased Credits: 0 B" and "After Purchase: X MiB" which is
correct — they currently have no purchased credits and will have X MiB
after this purchase.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…it-api-endpoints feat/pay with ai3/step 05/ add Credit API endpoints
After a successful payment the user only saw "Credits Added: X MiB" with
no indication of their cumulative purchased storage balance.
Step4 now reads creditSummary.uploadBytesRemaining from the Zustand store.
By the time Step4 renders the intent is in 'completed' state and
useTransactionConfirmation has already invalidated the creditSummary query,
so the store holds the refreshed post-purchase balance.
"New Purchased Credits Total" is rendered only when the balance is loaded
and greater than zero, which means:
- The row is invisible until the query has resolved (no flash of 0 B).
- Edge cases where creditSummary is null (e.g. query not yet settled)
simply show nothing rather than a misleading zero.
- Free-tier-only users who somehow reach this page are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The folder upload finalisation path (handleFolderUploadFinalization) had no credit check whatsoever, allowing any user — including those with a zero or negative balance — to complete a folder DAG structure without any account validation. This commit adds a guard-only check that reads the current pending credit balance and throws if it has somehow gone negative, surfacing account inconsistencies early rather than silently producing a broken folder object. Why registerInteraction is NOT called here: Each child file upload is independently finalised via handleFileUploadFinalization, which calls registerInteraction and deducts the file's content bytes from the user's credit pool before this function runs. The folder root IPLD node carries no independent byte cost beyond its children: metadata.totalSize = sum(children[i].totalSize), so calling registerInteraction(metadata.totalSize) would double-charge the user for all folder content. The guard-only check preserves the correct economics while adding the missing account validation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new getCreditBatches() method that calls GET /credits/batches, returning the complete purchase history for the authenticated user (newest-first, including expired rows). Reuses the existing ExpiringCreditBatch wire type — the serialisation shape is identical for both endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces the CreditHistory view and its Next.js route (/[chain]/drive/credits). The page is wrapped in UserProtectedLayout so only authenticated users can access it. The view: - Lists all purchased credit batches (GET /credits/batches), newest- first, using the existing ExpiringCreditBatch wire type - Shows a status badge per batch: Active / Expiring soon / Depleted / Expired - Renders a consumption progress bar per batch showing how many bytes have been used out of the original purchase - Shows a "Buy more credits" CTA when the user has no active batches or all non-expired batches expire within 7 days - Guarded by hasBuyCreditsFeature (features.buyCredits && OneOff model) — non-qualifying users see a polite "not available" message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the buyCredits feature is active and the user has a non-zero
purchased credits balance, a 'View history →' link now appears below
the balance/expiry line in the AccountInformation sidebar widget,
linking to the new /[chain]/drive/credits history page.
Changes:
- AccountInformation: adds optional creditHistoryHref prop; renders
a Next.js Link when the prop is present and purchasedBytesRemaining > 0
- SideNavBar: derives creditHistoryHref = /${networkId}/drive/credits
when hasBuyCreditsFeature; passes it to AccountInformation
All existing callers of AccountInformation are unaffected (the new prop
is optional and defaults to undefined).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-cap-intent-status Feat/pay with ai3/step 06 over cap intent status
markIntentAsConfirmed previously performed an unconditional UPDATE,
meaning a duplicate call could overwrite an intent that was already
CONFIRMED, COMPLETED, or OVER_CAP — resetting it back to CONFIRMED and
potentially triggering a second credit grant on the next polling tick.
Duplicate calls arise legitimately from:
• chain reorganisations causing the same IntentPaymentReceived event
to be re-emitted
• the payment manager reconnecting after downtime and re-processing
event logs it already handled
• watchTransaction and the _checkConfirmedIntents polling fallback
racing each other on the same intent
The fix reads the current status before writing. If the intent is
already past the PENDING stage (CONFIRMED, COMPLETED, or OVER_CAP) the
function returns ok() without touching the row, so the caller does not
treat a duplicate as a failure and does not schedule a retry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getIntentCredits() divides paymentAmount by shannonsPerByte using BigInt integer division. A payment smaller than one shannonsPerByte produces 0 credits. Previously onConfirmedIntent would call addCreditsToAccount(0), mark the intent COMPLETED, and give the user nothing — a misleading outcome that silently discards the payment and wastes a DB row. The fix computes creditBytes before calling addCreditsToAccount and returns an error if the result is zero. The polling loop will retry on the next tick, giving operators visibility through the error log rather than a silent no-op COMPLETED status. In normal operation this guard should never fire: the frontend enforces a minimum package size well above one byte. It defends against buggy clients or unexpected future changes to the shannonsPerByte price. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If the payment manager restarted (or the EVM RPC was unavailable) while a user's on-chain transaction was in flight, the intent stays PENDING with its tx_hash set. The cleanup job correctly skips these rows (a PENDING+txHash intent is not abandoned — it is being watched), but the payment manager's polling loop only queries CONFIRMED intents, so the transaction is never re-processed. The user has paid on-chain but receives no credits. This commit adds a startup recovery sweep: intentsRepository: new getPendingWithTxHash() query — selects PENDING intents with a non-null tx_hash. IntentsUseCases: exposes getPendingWithTxHash() on the use-case layer. paymentManager._recoverOrphanedTransactions(): on startup, fetches all PENDING+txHash intents and calls watchTransaction() for each one. waitForTransactionReceipt() returns immediately for already-mined transactions, so the sweep completes quickly in the happy path. Errors are caught per-intent with Promise.allSettled() so a single bad tx does not abort recovery of the others. paymentManager.start(): fires the recovery sweep asynchronously (non-blocking) before starting the polling interval and event watcher. Safety: markIntentAsConfirmed is now idempotent (previous commit), so a sweep that re-discovers an already-CONFIRMED intent is a no-op. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The purchased-credits feature introduced 10 environment variables that were absent from .env.sample, making it impossible for a new operator to configure the payment pipeline without reading config.ts directly. Added with defaults and descriptions: BUY_CREDITS_ACTIVE — master feature flag (default: false) BUY_CREDITS_STAFF_ONLY — staged-rollout flag (default: false) EVM_CHAIN_ENDPOINT — Auto-EVM RPC URL EVM_CHAIN_CONTRACT_ADDRESS — AutoDriveCreditsReceiver address EVM_CHAIN_CONFIRMATIONS — block confirmations before finality (6) EVM_CHAIN_CHECK_INTERVAL — polling fallback interval ms (30 000) CREDITS_PRICE_MULTIPLIER — markup on consensus byte fee (5.00) CREDIT_EXPIRY_DAYS — days until a credit batch expires (90) MAX_CREDITS_PER_USER — per-user purchased-credit cap (100 GiB) CREDIT_EXPIRY_CHECK_INTERVAL — background job interval ms (3 600 000) INTENT_EXPIRY_MINUTES — price-lock window minutes (10) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surfaces all pay-with-AI3 purchase data in the admin UI so operators
can monitor credit health and resolve stuck payments without needing
direct DB access.
Backend
- purchasedCreditsRepository: add getAllWithUserPublicId() — joins
purchased_credits with intents to return every batch with its
owner's userPublicId, newest-first.
- CreditsUseCases: add getAllBatches(executor) — admin-only wrapper
returning ForbiddenError for non-admins.
- creditsController: add GET /credits/batches/all — serialises
bigint fields to strings and includes userPublicId on each row.
Frontend (api.ts)
- Add AdminCreditBatch, CreditEconomicsResponse, OverCapIntent types.
- Add getAdminCreditBatches(), getCreditEconomics(), getOverCapIntents(),
reprocessIntent() service methods.
Frontend (AdminPanel/AdminCredits.tsx — new component)
- Economics card: 3-metric summary (batch count, upload bytes,
download bytes) for credits expiring within 30 days.
- Over-Cap panel: table of OVER_CAP intents with a Reprocess button
per row; invalidates queries on success.
- All Batches table: every purchase across all users with user,
status badge, purchased date, original/remaining bytes, usage bar,
and expiry date.
Frontend (AdminPanel/index.tsx)
- Render <AdminCredits /> between the analytics section and the
users table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The showBuyMoreCta memo treated an empty batches array as "no active batches" and showed the CTA, but batches defaults to [] while the query is still loading — causing the banner to flash on every page load alongside the spinner. Guard on isLoading to prevent this. Made-with: Cursor
- Parse backend error body in promote API call so 30-day notice message triggers the override flow correctly - Add w-screen to interstitial so it centers properly within flex layout - Invalidate touStatus query after admin promote/activate/archive actions to avoid needing a hard refresh - Clear touStatus from Zustand store on signOut to prevent stale interstitial rendering after decline - Gate interstitial on active session to prevent render loop after signOut Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use transactional activation (archive + activate in single BEGIN/COMMIT) to prevent inconsistent state if the second DB call fails - Remove misleading totalActiveUsers field from TouVersionWithStats since users are in the auth DB and the value was hardcoded to 0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Show loading state while touStatus query is in-flight to prevent briefly rendering protected content before the interstitial appears - Remove unused getActiveVersion repository function (all callers use ensureActiveVersion instead) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ions - getTouStatus now throws on API errors instead of returning accepted:true, so the loading gate in SessionEnsurer blocks content until status resolves - Replace non-null assertions (promoted!, activated!, archived!) with explicit null checks that return NotFoundError on TOCTOU races Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The banner component renders body as plain text, so URLs aren't clickable. Replace with a message directing users to the interstitial where they can review and accept the changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…it-history Feat/pay with ai3/step 09 credit history
Explains the draft -> pending -> active lifecycle, material vs non-material change behavior, and the 30-day notice requirement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent-robustness Feat/pay with ai3/step 10 payment robustness
markIntentAsConfirmed previously performed an unconditional UPDATE,
meaning a duplicate call could overwrite an intent that was already
CONFIRMED, COMPLETED, or OVER_CAP — resetting it back to CONFIRMED and
potentially triggering a second credit grant on the next polling tick.
Duplicate calls arise legitimately from:
• chain reorganisations causing the same IntentPaymentReceived event
to be re-emitted
• the payment manager reconnecting after downtime and re-processing
event logs it already handled
• watchTransaction and the _checkConfirmedIntents polling fallback
racing each other on the same intent
The fix reads the current status before writing. If the intent is
already past the PENDING stage (CONFIRMED, COMPLETED, or OVER_CAP) the
function returns ok() without touching the row, so the caller does not
treat a duplicate as a failure and does not schedule a retry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getIntentCredits() divides paymentAmount by shannonsPerByte using BigInt integer division. A payment smaller than one shannonsPerByte produces 0 credits. Previously onConfirmedIntent would call addCreditsToAccount(0), mark the intent COMPLETED, and give the user nothing — a misleading outcome that silently discards the payment and wastes a DB row. The fix computes creditBytes before calling addCreditsToAccount and returns an error if the result is zero. The polling loop will retry on the next tick, giving operators visibility through the error log rather than a silent no-op COMPLETED status. In normal operation this guard should never fire: the frontend enforces a minimum package size well above one byte. It defends against buggy clients or unexpected future changes to the shannonsPerByte price. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If the payment manager restarted (or the EVM RPC was unavailable) while a user's on-chain transaction was in flight, the intent stays PENDING with its tx_hash set. The cleanup job correctly skips these rows (a PENDING+txHash intent is not abandoned — it is being watched), but the payment manager's polling loop only queries CONFIRMED intents, so the transaction is never re-processed. The user has paid on-chain but receives no credits. This commit adds a startup recovery sweep: intentsRepository: new getPendingWithTxHash() query — selects PENDING intents with a non-null tx_hash. IntentsUseCases: exposes getPendingWithTxHash() on the use-case layer. paymentManager._recoverOrphanedTransactions(): on startup, fetches all PENDING+txHash intents and calls watchTransaction() for each one. waitForTransactionReceipt() returns immediately for already-mined transactions, so the sweep completes quickly in the happy path. Errors are caught per-intent with Promise.allSettled() so a single bad tx does not abort recovery of the others. paymentManager.start(): fires the recovery sweep asynchronously (non-blocking) before starting the polling interval and event watcher. Safety: markIntentAsConfirmed is now idempotent (previous commit), so a sweep that re-discovers an already-CONFIRMED intent is a no-op. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The purchased-credits feature introduced 10 environment variables that were absent from .env.sample, making it impossible for a new operator to configure the payment pipeline without reading config.ts directly. Added with defaults and descriptions: BUY_CREDITS_ACTIVE — master feature flag (default: false) BUY_CREDITS_STAFF_ONLY — staged-rollout flag (default: false) EVM_CHAIN_ENDPOINT — Auto-EVM RPC URL EVM_CHAIN_CONTRACT_ADDRESS — AutoDriveCreditsReceiver address EVM_CHAIN_CONFIRMATIONS — block confirmations before finality (6) EVM_CHAIN_CHECK_INTERVAL — polling fallback interval ms (30 000) CREDITS_PRICE_MULTIPLIER — markup on consensus byte fee (5.00) CREDIT_EXPIRY_DAYS — days until a credit batch expires (90) MAX_CREDITS_PER_USER — per-user purchased-credit cap (100 GiB) CREDIT_EXPIRY_CHECK_INTERVAL — background job interval ms (3 600 000) INTENT_EXPIRY_MINUTES — price-lock window minutes (10) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surfaces all pay-with-AI3 purchase data in the admin UI so operators
can monitor credit health and resolve stuck payments without needing
direct DB access.
Backend
- purchasedCreditsRepository: add getAllWithUserPublicId() — joins
purchased_credits with intents to return every batch with its
owner's userPublicId, newest-first.
- CreditsUseCases: add getAllBatches(executor) — admin-only wrapper
returning ForbiddenError for non-admins.
- creditsController: add GET /credits/batches/all — serialises
bigint fields to strings and includes userPublicId on each row.
Frontend (api.ts)
- Add AdminCreditBatch, CreditEconomicsResponse, OverCapIntent types.
- Add getAdminCreditBatches(), getCreditEconomics(), getOverCapIntents(),
reprocessIntent() service methods.
Frontend (AdminPanel/AdminCredits.tsx — new component)
- Economics card: 3-metric summary (batch count, upload bytes,
download bytes) for credits expiring within 30 days.
- Over-Cap panel: table of OVER_CAP intents with a Reprocess button
per row; invalidates queries on success.
- All Batches table: every purchase across all users with user,
status badge, purchased date, original/remaining bytes, usage bar,
and expiry date.
Frontend (AdminPanel/index.tsx)
- Render <AdminCredits /> between the analytics section and the
users table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…trying The dust payment guard returned err() without moving the intent to a terminal state, leaving it CONFIRMED. Since _checkConfirmedIntents polls every 30s and paymentAmount/shannonsPerByte are immutable, this caused an infinite retry loop. Now marks the intent FAILED (terminal), matching the OVER_CAP pattern. Made-with: Cursor
…d state The button's disabled/label state relied solely on useMutation's `variables`, which persists after the mutation settles (including on error). Now checks `isPending && reprocessingId` so the button re-enables as soon as the mutation completes. Made-with: Cursor
…dits getBatchStatus, STATUS_CLASSES, STATUS_LABEL, and BatchStatus were duplicated between CreditHistory and AdminCredits. The AdminCredits copy also reimplemented the days-until-expiry calculation inline (missing the Math.max(0, …) clamp). Consolidate into utils/credits.ts so both views share a single source of truth and add unit tests for getBatchStatus. Made-with: Cursor
…mpotency guard The guard only checked CONFIRMED, COMPLETED, and OVER_CAP, allowing a chain reorg to overwrite a FAILED (dust-payment) or EXPIRED intent back to CONFIRMED — creating a perpetual confirm→fail cycle with unnecessary DB writes. Made-with: Cursor
…cceptance # Conflicts: # apps/frontend/src/services/api.ts
feat: ToU versioning, acceptance tracking, and re-consent flow
✅ Deploy Preview for auto-drive-storage ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
bugbot run |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR

Auto Drive release, March 31, 2026.