From 2e40f3b4a85b315b204032666de242f6da60ad41 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Wed, 15 Jul 2026 20:20:36 -0400 Subject: [PATCH 01/19] docs: implementation plan for turbo free-tier restriction PE-9132 10 MiB free pool per wallet, 105 KiB per-item eligibility, paid-only after exhaustion, credits never replenish free. Inventories the 15 silent-free posting paths, the current failure behavior on payment rejection, and phases the work: failure honesty (unblocked now), pool-aware eligibility (needs turbo API contract), surfacing UX. Co-Authored-By: Claude Fable 5 --- docs/implementation_plan_turbo_free_tier.md | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/implementation_plan_turbo_free_tier.md diff --git a/docs/implementation_plan_turbo_free_tier.md b/docs/implementation_plan_turbo_free_tier.md new file mode 100644 index 000000000..d0bd392d8 --- /dev/null +++ b/docs/implementation_plan_turbo_free_tier.md @@ -0,0 +1,131 @@ +# Turbo Free-Tier Restriction — ArDrive Client Implementation Plan + +## Policy (PE-9132) + +- Each wallet receives a **10 MiB free pool**. +- An upload is free-eligible only when the data item is **≤ 105 KiB (107,520 bytes)** AND the pool has remaining room. Eligible items draw down the pool by their size. ArFS metadata items (~1–2 KiB: renames, moves, creates, hides, pins, license assertions, bulk-import entries, thumbnails metadata) draw from the same pool. +- When the pool is exhausted, **every** upload is paid — including metadata operations. Purchasing credits does **not** replenish the pool; free and credits are independent. +- Reset cadence (monthly vs lifetime) is server policy; the client must treat remaining-free as **server-reported state**, never a client-side computation. + +## Current client reality (verified inventory, July 2026) + +"Free" today is a client-side guess: anything under `allowedDataItemSizeForTurbo` +(100,000 bytes; `assets/config/*.json:12`) is posted to `POST /v1/tx` with **no +cost check, no payment header, no quota awareness** (`lib/turbo/services/upload_service.dart:59-106`). + +**Fifteen operation paths post silently on this assumption** (all wallet-signed, +none has payment UI): + +| Op | Post site | +|---|---| +| File/folder rename | `lib/blocs/fs_entry_rename/fs_entry_rename_cubit.dart:176/:101` | +| Drive rename | `lib/blocs/drive_rename/drive_rename_cubit.dart:71` | +| Move (per item!) | `lib/blocs/fs_entry_move/fs_entry_move_bloc.dart:275` | +| Hide/unhide | `lib/blocs/hide/hide_bloc.dart:330` | +| Folder create | `lib/blocs/folder_create/folder_create_cubit.dart:85` | +| Drive create (+root folder) | `lib/blocs/drive_create/drive_create_cubit.dart:134` | +| Pin file | `lib/blocs/pin_file/pin_file_bloc.dart:337` | +| License assertion (2/file×rev) | `lib/blocs/fs_entry_license/fs_entry_license_bloc.dart:320` | +| Ghost fixer | `lib/blocs/ghost_fixer/ghost_fixer_cubit.dart:141` | +| ArNS name revision | `lib/arns/domain/arns_repository.dart:196` | +| Thumbnail metadata | `lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart:210` | +| Private-drive migration | `lib/shared/blocs/private_drive_migration/private_drive_migration_bloc.dart:134` | +| Bulk import folder meta (per folder) | `lib/core/arfs/use_cases/upload_folder_metadata.dart:81` | +| Bulk import file meta (per file) | `lib/core/arfs/use_cases/upload_file_metadata.dart:80` | +| Snapshot create (has payment UI already) | `lib/blocs/create_snapshot/create_snapshot_cubit.dart:693` | + +**Known failure behavior on payment rejection today** (nothing decodes 402/429): + +- Rename: progress dialog never dismisses (`fs_entry_rename_form.dart:76-137` has no failure case). +- Move: no failure state at all (`fs_entry_move_state.dart`), progress dialog hangs; **DB commits BEFORE the network post** (`fs_entry_move_bloc.dart:261-279`) → local state diverges from chain on rejection. +- Chunked uploader retries any failure **8×** including payment rejections (`packages/ardrive_uploader/lib/src/turbo_upload_service.dart:28,99-100`). +- App-side `TurboUploadService._handleException` special-cases only 408 (`upload_service.dart:108-129`); everything else becomes a generic `Exception`. +- No quota/allowance API is consumed anywhere; balance response (`payment_service.dart:77`) has no free-tier fields today. + +## Budget math (why per-op UX must be silent) + +- Metadata op ≈ 2 KiB → the 10 MiB pool covers ~5,000 metadata operations. +- A single 105 KiB file consumes ~50 metadata-ops worth of pool; ~97 max-size files exhaust the pool. +- Bulk import of a 1,000-file manifest ≈ 2 MiB of pool in one click. Folder + uploads of many small files can exhaust the pool in one action. +- Conclusion: prompting per metadata op is unacceptable (dust); prompting per + BURST (bulk import, folder upload) with a pool-aware preflight is required. + +## Phase 1 — Failure honesty (policy-independent; build first, no server dependency) + +1. **Typed payment errors.** In both TurboUploadServices: decode HTTP 402 (and + distinguish free-exhausted vs insufficient-credits when the server provides a + reason code) into `TurboPaymentRequiredException`; decode 429 into a typed + rate-limit error. Exclude both from blind retry (`retryIf`). +2. **Fix the stuck dialogs.** Add failure states + dialog handling for Rename + (both file/folder) and Move; generic message now, payment-specific once + Phase 2 lands. +3. **Move: post-then-commit.** Reorder `fs_entry_move_bloc` so network posts + succeed before DB writes commit (or wrap in a rollback) — fixes the + divergence bug independent of any free-tier change. +4. **Tests:** unit tests for error decoding; bloc tests for rename/move failure + states; regression test that a 402 is not retried. + +## Phase 2 — Pool-aware eligibility (needs Turbo API contract) + +**Server asks (blockers for this phase, not Phase 1):** +- Remaining-free bytes (+ reset timestamp if any) on the existing balance + endpoint (`GET /v1/account/balance/arweave`) — client already polls it. +- Deterministic 402 with machine-readable reason: `free_exhausted` | + `insufficient_credits`. +- Confirm per-item eligibility threshold (105 KiB) is queryable or stable. + +**Client work:** +1. **`TurboConditionsService`** (new, `lib/turbo/`): caches + `{creditBalance, freeRemainingBytes, freeItemLimit=107520}`; refreshed on + login, after every upload, and immediately on any 402. Single source of + truth; all "is this free?" questions go here. +2. **Retire the client-side constant as authority.** `allowedDataItemSizeForTurbo` + (100 KB) becomes the fallback for the item-size gate only (bump to 107,520); + eligibility = size-gate AND `freeRemainingBytes >= itemSize`. +3. **Decision ladder for every post** (file uploads AND all 15 metadata paths): + - free-covered → post silently (pool decremented server-side; client + decrements optimistically, reconciles on next balance poll) + - credits cover it → post silently for dust-sized metadata; show cost line + for user-perceivable sizes (uploads keep existing payment UI) + - neither → pre-flight prompt with top-up flow (replaces today's hang) + - server 402 anyway (stale cache) → typed error → refresh conditions → one + honest dialog, never blind retry +4. **Payment evaluator changes** (`lib/core/upload/uploader.dart:379-382, + :464-476, :534-564`; `upload_payment_method_bloc.dart:111-137`): + `isFreeUploadPossibleUsingTurbo` must consult the conditions service, not + just size. Multi-file: free only if the whole plan fits remaining pool; + otherwise split display (N free / M paid) or fall to paid entirely + (simpler v1: all-or-nothing per upload plan). +5. **Burst preflight.** Bulk import (`bulk_import_files.dart`) and folder + uploads estimate total bytes against the pool up front: "This import needs + 2.1 MiB; you have 0.8 MiB free — the rest uses ~0.0004 credits + [Continue] [Top up]". On mid-burst exhaustion: pause-and-resume, not + fail-halfway (bulk import's failure tracking is the foundation). + +## Phase 3 — Surfacing (UX) + +- FREE badge (`upload_form.dart:1937-1948`, `create_snapshot_dialog.dart:431`) + becomes dynamic: "Free — X MiB left" / absent when exhausted. +- Pool meter + low-pool nudge in the profile card next to credits + (`profile_card.dart:568-592` area); "free used up — everything now uses + credits" one-time notice on the free→paid transition. +- Onboarding guard: drive-create is a new wallet's first action (~2 KiB). With + a fresh 10 MiB pool this always succeeds; the dead-end only exists for + exhausted wallets — those get the pre-flight prompt, never a hang. + +## Explicit non-goals / accepted + +- No client-side enforcement (abusers bypass the app; enforcement is Turbo's). + Client goals: honest UX for legitimate users + never amplifying rejected + load (no 402 retries). +- No hardcoded reset cadence; display whatever the server reports. +- L1 (direct Arweave) fallback for metadata ops remains available where it + exists today (rows 1–10, 13–14) for users who prefer paying AR — unchanged. + +## Sequencing + +Phase 1 is unblocked today and ships value regardless of policy timing. +Phase 2 blocks on the two server asks. Phase 3 rides Phase 2. Estimated: +Phase 1 ≈ 2–4 days incl. tests; Phase 2 ≈ 1 week client-side once the API +contract exists; Phase 3 ≈ 2–3 days. From e6a88ecea904bf6ac6e91e1b2f3d6718a61672a3 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Wed, 15 Jul 2026 21:10:16 -0400 Subject: [PATCH 02/19] feat: phase 1 of turbo free-tier readiness - typed payment failures PE-9132 Prepares the client for the restricted free tier (10 MiB pool, 105 KiB per-item, paid-only after exhaustion). Policy-independent hardening: - decode HTTP 402 into TurboPaymentRequiredException and 429 into TurboRateLimitException in the app-side TurboUploadService (pure turboExceptionForStatusCode mapping, unit tested); the uploader package maps 402 to its existing UnderFundException and excludes 402/429 from its 8-attempt retry loops (retrying payment rejections multiplies load and metered usage) - rename (file/folder) failures now dismiss the progress dialog and show an honest error - payment-specific copy when the rejection was 402 (previously: spinner forever) - move gains a failure state and dialog handling, no longer emits Success after an error (removes the TODO admitting it), and is reordered to post-then-commit: data items are prepared and posted BEFORE the local database transaction, so a rejected move can no longer leave local state claiming a move the chain never saw - TurboUploadService fetches maxItemBytes from GET /v1/info once at construction (maxFreeItemSizeBytes, config fallback) - the server-driven per-item free threshold per the descoped plan; wiring it into UploadPaymentEvaluator is the next commit - implementation plan doc updated with the decision: no pool tracking, no balance-endpoint dependency, static free-tier messaging Co-Authored-By: Claude Fable 5 --- docs/implementation_plan_turbo_free_tier.md | 19 ++- .../fs_entry_move/fs_entry_move_bloc.dart | 135 ++++++++++-------- .../fs_entry_move/fs_entry_move_state.dart | 11 ++ .../fs_entry_rename_cubit.dart | 6 +- .../fs_entry_rename_state.dart | 10 +- lib/components/fs_entry_move_form.dart | 13 ++ lib/components/fs_entry_rename_form.dart | 17 +++ lib/turbo/services/upload_service.dart | 77 ++++++++-- .../ardrive_uploader/lib/src/exceptions.dart | 13 ++ .../lib/src/turbo_upload_service.dart | 37 ++++- .../turbo_exception_mapping_test.dart | 46 ++++++ 11 files changed, 303 insertions(+), 81 deletions(-) create mode 100644 test/turbo/services/turbo_exception_mapping_test.dart diff --git a/docs/implementation_plan_turbo_free_tier.md b/docs/implementation_plan_turbo_free_tier.md index d0bd392d8..eb01cc70b 100644 --- a/docs/implementation_plan_turbo_free_tier.md +++ b/docs/implementation_plan_turbo_free_tier.md @@ -66,7 +66,24 @@ none has payment UI): 4. **Tests:** unit tests for error decoding; bloc tests for rename/move failure states; regression test that a 402 is not retried. -## Phase 2 — Pool-aware eligibility (needs Turbo API contract) +## DECISION (2026-07-15, after review with Turbo team) + +Phase 1 green-lit. The balance-endpoint remaining-free dependency and the +dynamic pool meter are DROPPED: no pool tracking client-side. Instead: +- **Static free-tier messaging** — when a post is rejected with 402, show a + static explanation ("free allowance is used up — add Credits") with the + top-up path. No live "X MiB remaining" anywhere. +- **`maxItemBytes` from `GET /v1/info`** is kept as the server-driven + per-item eligibility threshold (implemented: + `TurboUploadService.maxFreeItemSizeBytes`, fetched once at construction, + config value as fallback). Follow-up: consume it in + `UploadPaymentEvaluator` in place of `allowedDataItemSizeForTurbo`. +- Typed-402 handling everywhere (Phase 1) is the backbone of the UX. + +The original Phase 2/3 below is retained for reference but is NOT the +current plan; only the pieces named above survive. + +## Phase 2 — Pool-aware eligibility (SUPERSEDED by decision above) **Server asks (blockers for this phase, not Phase 1):** - Remaining-free bytes (+ reset timestamp if any) on the existing balance diff --git a/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart b/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart index bbb76253c..dc3babf61 100644 --- a/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart +++ b/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart @@ -81,11 +81,13 @@ class FsEntryMoveBloc extends Bloc { parentFolder: folderInView, showHiddenItems: event.showHiddenItems, ); + emit(const FsEntryMoveSuccess()); } catch (err, stacktrace) { - // TODO: we must handle this error better. Currently, if an error occurs, it will emit the success state anyway. logger.e('Error moving items', err, stacktrace); + emit(FsEntryMoveFailure( + isPaymentError: err is TurboPaymentRequiredException, + )); } - emit(const FsEntryMoveSuccess()); } else { emit( FsEntryMoveNameConflict( @@ -100,13 +102,20 @@ class FsEntryMoveBloc extends Bloc { if (event is FsEntryMoveSkipConflicts) { emit(const FsEntryMoveLoadInProgress()); final folderInView = event.folderInView; - await moveEntities( - parentFolder: folderInView, - conflictingItems: event.conflictingItems, - profile: profile, - showHiddenItems: event.showHiddenItems, - ); - emit(const FsEntryMoveSuccess()); + try { + await moveEntities( + parentFolder: folderInView, + conflictingItems: event.conflictingItems, + profile: profile, + showHiddenItems: event.showHiddenItems, + ); + emit(const FsEntryMoveSuccess()); + } catch (err, stacktrace) { + logger.e('Error moving items', err, stacktrace); + emit(FsEntryMoveFailure( + isPaymentError: err is TurboPaymentRequiredException, + )); + } } if (event is FsEntryMoveUpdateTargetFolder) { @@ -213,63 +222,55 @@ class FsEntryMoveBloc extends Bloc { final folderMap = {}; - await _driveDao.transaction(() async { - for (var fileToMove in filesToMove) { - var file = await _driveDao.fileById(fileId: fileToMove.id).getSingle(); - file = file.copyWith( - parentFolderId: parentFolder.id, lastUpdated: DateTime.now()); - final fileKey = driveKey != null - ? await _crypto.deriveFileKey(driveKey.key, file.id) - : null; - - final fileEntity = file.asEntity(); - - final fileDataItem = await _arweave.prepareEntityDataItem( - fileEntity, - profile.user.wallet, - key: fileKey, - ); - - moveTxDataItems.add(fileDataItem); + // Prepare and sign everything first, WITHOUT touching the database: a + // network rejection (e.g. payment required) must not leave the local + // database claiming the move happened when the chain never saw it. + final preparedFileMoves = >[]; + final preparedFolderMoves = >[]; - await _driveDao.writeToFile(file); - fileEntity.txId = fileDataItem.id; + for (var fileToMove in filesToMove) { + var file = await _driveDao.fileById(fileId: fileToMove.id).getSingle(); + file = file.copyWith( + parentFolderId: parentFolder.id, lastUpdated: DateTime.now()); + final fileKey = driveKey != null + ? await _crypto.deriveFileKey(driveKey.key, file.id) + : null; - await _driveDao.insertFileRevision(fileEntity.toRevisionCompanion( - performedAction: RevisionAction.move, - )); - } - - for (var folderToMove in foldersToMove) { - var folder = - await _driveDao.folderById(folderId: folderToMove.id).getSingle(); - folder = folder.copyWith( - parentFolderId: Value(parentFolder.id), - lastUpdated: DateTime.now(), - ); + final fileEntity = file.asEntity(); - final folderEntity = folder.asEntity(); - - final folderDataItem = await _arweave.prepareEntityDataItem( - folderEntity, - profile.user.wallet, - key: driveKey?.key, - ); + final fileDataItem = await _arweave.prepareEntityDataItem( + fileEntity, + profile.user.wallet, + key: fileKey, + ); - moveTxDataItems.add(folderDataItem); + moveTxDataItems.add(fileDataItem); + fileEntity.txId = fileDataItem.id; + preparedFileMoves.add(MapEntry(file, fileEntity)); + } - await _driveDao.writeToFolder(folder); + for (var folderToMove in foldersToMove) { + var folder = + await _driveDao.folderById(folderId: folderToMove.id).getSingle(); + folder = folder.copyWith( + parentFolderId: Value(parentFolder.id), + lastUpdated: DateTime.now(), + ); - folderEntity.txId = folderDataItem.id; + final folderEntity = folder.asEntity(); - await _driveDao.insertFolderRevision(folderEntity.toRevisionCompanion( - performedAction: RevisionAction.move, - )); + final folderDataItem = await _arweave.prepareEntityDataItem( + folderEntity, + profile.user.wallet, + key: driveKey?.key, + ); - folderMap.addAll({folder.id: folder.toCompanion(false)}); - } - }); + moveTxDataItems.add(folderDataItem); + folderEntity.txId = folderDataItem.id; + preparedFolderMoves.add(MapEntry(folder, folderEntity)); + } + // Post to the network before committing locally. if (_turboUploadService.useTurboUpload) { for (var dataItem in moveTxDataItems) { await _turboUploadService.postDataItem( @@ -286,5 +287,25 @@ class FsEntryMoveBloc extends Bloc { ); await _arweave.postTx(moveTx); } + + // Network accepted everything: commit the move locally. + await _driveDao.transaction(() async { + for (final prepared in preparedFileMoves) { + await _driveDao.writeToFile(prepared.key); + await _driveDao.insertFileRevision(prepared.value.toRevisionCompanion( + performedAction: RevisionAction.move, + )); + } + + for (final prepared in preparedFolderMoves) { + await _driveDao.writeToFolder(prepared.key); + await _driveDao + .insertFolderRevision(prepared.value.toRevisionCompanion( + performedAction: RevisionAction.move, + )); + + folderMap.addAll({prepared.key.id: prepared.key.toCompanion(false)}); + } + }); } } diff --git a/lib/blocs/fs_entry_move/fs_entry_move_state.dart b/lib/blocs/fs_entry_move/fs_entry_move_state.dart index aeb76bbee..09813b679 100644 --- a/lib/blocs/fs_entry_move/fs_entry_move_state.dart +++ b/lib/blocs/fs_entry_move/fs_entry_move_state.dart @@ -35,6 +35,17 @@ class FsEntryMoveSuccess extends FsEntryMoveState { const FsEntryMoveSuccess() : super(); } +class FsEntryMoveFailure extends FsEntryMoveState { + /// True when the network rejected the move for payment reasons + /// (free allowance exhausted / insufficient credits). + final bool isPaymentError; + + const FsEntryMoveFailure({this.isPaymentError = false}) : super(); + + @override + List get props => [isPaymentError]; +} + class FsEntryMoveNameConflict extends FsEntryMoveState { final List conflictingItems; final FolderEntry folderInView; diff --git a/lib/blocs/fs_entry_rename/fs_entry_rename_cubit.dart b/lib/blocs/fs_entry_rename/fs_entry_rename_cubit.dart index 27c7f2289..62bb686c1 100644 --- a/lib/blocs/fs_entry_rename/fs_entry_rename_cubit.dart +++ b/lib/blocs/fs_entry_rename/fs_entry_rename_cubit.dart @@ -233,10 +233,12 @@ class FsEntryRenameCubit extends Cubit { @override void onError(Object error, StackTrace stackTrace) { if (_isRenamingFolder) { - emit(const FolderEntryRenameFailure()); + emit(FolderEntryRenameFailure( + isPaymentError: error is TurboPaymentRequiredException)); logger.e('Failed to rename folder', error, stackTrace); } else { - emit(const FileEntryRenameFailure()); + emit(FileEntryRenameFailure( + isPaymentError: error is TurboPaymentRequiredException)); logger.e('Failed to rename file', error, stackTrace); } diff --git a/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart b/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart index 96575862e..7b3e80f0c 100644 --- a/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart +++ b/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart @@ -26,7 +26,10 @@ class FolderEntryRenameSuccess extends FsEntryRenameState { } class FolderEntryRenameFailure extends FsEntryRenameState { - const FolderEntryRenameFailure() : super(isRenamingFolder: true); + final bool isPaymentError; + + const FolderEntryRenameFailure({this.isPaymentError = false}) + : super(isRenamingFolder: true); } class EntityAlreadyExists extends FsEntryRenameState { @@ -63,7 +66,10 @@ class FileEntryRenameSuccess extends FsEntryRenameState { } class FileEntryRenameFailure extends FsEntryRenameState { - const FileEntryRenameFailure() : super(isRenamingFolder: false); + final bool isPaymentError; + + const FileEntryRenameFailure({this.isPaymentError = false}) + : super(isRenamingFolder: false); } class FileEntryRenameWalletMismatch extends FsEntryRenameState { diff --git a/lib/components/fs_entry_move_form.dart b/lib/components/fs_entry_move_form.dart index 82a240b61..d36888826 100644 --- a/lib/components/fs_entry_move_form.dart +++ b/lib/components/fs_entry_move_form.dart @@ -62,6 +62,19 @@ class FsEntryMoveForm extends StatelessWidget { Navigator.pop(context); } else if (state is FsEntryMoveWalletMismatch) { Navigator.pop(context); + } else if (state is FsEntryMoveFailure) { + Navigator.pop(context); // dismiss the progress dialog + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: state.isPaymentError + ? 'Turbo\'s free allowance is used up, so this action now ' + 'requires Credits. Add Credits and try again.' + : 'Failed to move the selected items. Please check your ' + 'connection and try again.', + ), + ); } }, builder: (context, state) { diff --git a/lib/components/fs_entry_rename_form.dart b/lib/components/fs_entry_rename_form.dart index cc9ba2974..6ec990ba9 100644 --- a/lib/components/fs_entry_rename_form.dart +++ b/lib/components/fs_entry_rename_form.dart @@ -95,6 +95,23 @@ class _FsEntryRenameFormState extends State { } else if (state is FolderEntryRenameWalletMismatch || state is FileEntryRenameWalletMismatch) { Navigator.pop(context); + } else if (state is FolderEntryRenameFailure || + state is FileEntryRenameFailure) { + Navigator.pop(context); // dismiss the progress dialog + final isPaymentError = + (state is FolderEntryRenameFailure && state.isPaymentError) || + (state is FileEntryRenameFailure && state.isPaymentError); + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: isPaymentError + ? 'Turbo\'s free allowance is used up, so this action ' + 'now requires Credits. Add Credits and try again.' + : 'Failed to rename. Please check your connection and ' + 'try again.', + ), + ); } else if (state is FsEntryRenameInitialized) { _nameController.text = widget.entryName; } else if (state is EntityAlreadyExists) { diff --git a/lib/turbo/services/upload_service.dart b/lib/turbo/services/upload_service.dart index c69dfb9f7..fe490ccfe 100644 --- a/lib/turbo/services/upload_service.dart +++ b/lib/turbo/services/upload_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:ardrive/utils/data_item_utils.dart'; import 'package:ardrive/utils/logger.dart'; @@ -16,7 +17,9 @@ class TurboUploadService { required this.turboUploadUri, required this.allowedDataItemSize, required this.httpClient, - }); + }) { + unawaited(refreshMaxItemBytes()); + } Stream postDataItemWithProgress({ required DataItem dataItem, @@ -108,31 +111,73 @@ class TurboUploadService { Exception _handleException(Object error) { logger.e('Handling exception in UploadService', error); - if (error is ArDriveHTTPResponse && error.statusCode == 408) { - logger.e( - 'Handling exception in UploadService with status code: ${error.statusCode}', - error, - ); + final statusCode = error is ArDriveHTTPResponse + ? error.statusCode + : error is ArDriveHTTPException + ? error.statusCode + : null; - return TurboUploadTimeoutException(); - } - if (error is ArDriveHTTPException && error.statusCode == 408) { + final typed = turboExceptionForStatusCode(statusCode); + if (typed != null) { logger.e( - 'Handling exception in UploadService with status code: ${error.statusCode}', + 'Handling exception in UploadService with status code: $statusCode', error, ); - - return TurboUploadTimeoutException(); + return typed; } return Exception(error); } + + /// Server-reported maximum size of an item eligible for free upload, + /// fetched once from `GET /v1/info`. Falls back to the config-injected + /// [allowedDataItemSize] until (or unless) the server reports one. + int? _serverMaxItemBytes; + + int get maxFreeItemSizeBytes => _serverMaxItemBytes ?? allowedDataItemSize; + + Future refreshMaxItemBytes() async { + try { + final response = await httpClient.get(url: '$turboUploadUri/v1/info'); + final raw = response.data; + final data = raw is String ? json.decode(raw) : raw; + final value = data is Map ? data['maxItemBytes'] : null; + if (value is int && value > 0) { + _serverMaxItemBytes = value; + logger.d('Turbo free item size limit from /v1/info: $value bytes'); + } + } catch (e) { + logger.w( + 'Could not fetch turbo /v1/info; using configured item size: $e'); + } + } +} + +/// Maps a turbo upload HTTP status code to a typed exception, or null for +/// codes without special semantics. +Exception? turboExceptionForStatusCode(int? statusCode) { + switch (statusCode) { + case 408: + return TurboUploadTimeoutException(); + case 402: + return TurboPaymentRequiredException(); + case 429: + return TurboRateLimitException(); + default: + return null; + } } class DontUseUploadService implements TurboUploadService { @override int get allowedDataItemSize => throw UnimplementedError(); + @override + int get maxFreeItemSizeBytes => throw UnimplementedError(); + + @override + Future refreshMaxItemBytes() async {} + @override Future postDataItem({ required DataItem dataItem, @@ -169,3 +214,11 @@ class DontUseUploadService implements TurboUploadService { class TurboUploadExceptions implements Exception {} class TurboUploadTimeoutException implements TurboUploadExceptions {} + +/// The upload was rejected for payment reasons (HTTP 402): the free +/// allowance is exhausted and/or credits are insufficient. Must never be +/// blindly retried. +class TurboPaymentRequiredException implements TurboUploadExceptions {} + +/// The upload was rate-limited (HTTP 429). Must never be blindly retried. +class TurboRateLimitException implements TurboUploadExceptions {} diff --git a/packages/ardrive_uploader/lib/src/exceptions.dart b/packages/ardrive_uploader/lib/src/exceptions.dart index 228dee80f..53b632dfb 100644 --- a/packages/ardrive_uploader/lib/src/exceptions.dart +++ b/packages/ardrive_uploader/lib/src/exceptions.dart @@ -152,3 +152,16 @@ class TurboUploadTimeoutException implements ArDriveUploaderExceptions { @override Object? error; } + +/// The upload was rate-limited (HTTP 429). Must never be blindly retried. +class TurboRateLimitException implements ArDriveUploaderExceptions { + TurboRateLimitException({ + this.message = 'Rate limited by the upload service', + this.error, + }); + + @override + final String message; + @override + Object? error; +} diff --git a/packages/ardrive_uploader/lib/src/turbo_upload_service.dart b/packages/ardrive_uploader/lib/src/turbo_upload_service.dart index b37f960cc..50f36c0cd 100644 --- a/packages/ardrive_uploader/lib/src/turbo_upload_service.dart +++ b/packages/ardrive_uploader/lib/src/turbo_upload_service.dart @@ -20,6 +20,19 @@ abstract class TurboUploadService { Future cancel(); } +/// Payment (402) and rate-limit (429) rejections are deterministic within a +/// request window: retrying multiplies server load and, for metered free +/// tiers, recorded usage — so they are excluded from retries. +bool shouldRetryTurboRequest(Exception e) { + if (e is DioException) { + final status = e.response?.statusCode; + if (status == 402 || status == 429) { + return false; + } + } + return e is! UnderFundException && e is! TurboRateLimitException; +} + abstract class TurboUploadServiceChunkUploadsBase implements TurboUploadService { TurboUploadServiceChunkUploadsBase(this.turboUploadUri); @@ -52,6 +65,7 @@ abstract class TurboUploadServiceChunkUploadsBase // 2) Fetch basic upload info and tell server chunkSize (server will assert) final uploadInfo = await r.retry( + retryIf: shouldRetryTurboRequest, () => dio.get('$turboUploadUri/chunks/arweave/-1/-1?chunkSize=$chunkSize'), ); @@ -265,6 +279,7 @@ class TurboUploadServiceMultipart extends TurboUploadServiceChunkUploadsBase { try { // POST /finalize final finalizeResponse = await r.retry( + retryIf: shouldRetryTurboRequest, () => dio.post( '$turboUploadUri/chunks/arweave/$uploadId/finalize', options: Options( @@ -449,13 +464,21 @@ class TurboUploadServiceNonChunked extends TurboUploadService { Exception _handleException(Object error) { logger.e('Handling exception in UploadService', error); - if (error is DioException && error.response?.statusCode == 408) { - logger.e( - 'Handling exception in UploadService with status code: ${error.response?.statusCode}', - error, - ); - - return TurboUploadTimeoutException(); + if (error is DioException) { + final statusCode = error.response?.statusCode; + if (statusCode == 408) { + return TurboUploadTimeoutException(); + } + if (statusCode == 402) { + return UnderFundException( + message: 'Upload rejected: payment required (free allowance ' + 'exhausted or insufficient credits).', + error: error.response?.data, + ); + } + if (statusCode == 429) { + return TurboRateLimitException(error: error.response?.data); + } } return Exception(error); diff --git a/test/turbo/services/turbo_exception_mapping_test.dart b/test/turbo/services/turbo_exception_mapping_test.dart new file mode 100644 index 000000000..b9012a225 --- /dev/null +++ b/test/turbo/services/turbo_exception_mapping_test.dart @@ -0,0 +1,46 @@ +import 'package:ardrive/turbo/services/upload_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('turboExceptionForStatusCode', () { + test('maps 408 to a timeout exception', () { + expect( + turboExceptionForStatusCode(408), + isA(), + ); + }); + + test('maps 402 to a payment-required exception', () { + expect( + turboExceptionForStatusCode(402), + isA(), + ); + }); + + test('maps 429 to a rate-limit exception', () { + expect( + turboExceptionForStatusCode(429), + isA(), + ); + }); + + test('returns null for codes without special semantics', () { + expect(turboExceptionForStatusCode(500), isNull); + expect(turboExceptionForStatusCode(400), isNull); + expect(turboExceptionForStatusCode(null), isNull); + }); + + test('payment and rate-limit exceptions are TurboUploadExceptions', () { + // The metadata-op blocs distinguish payment failures by type; the + // hierarchy must hold or their dialogs regress to generic errors. + expect( + turboExceptionForStatusCode(402), + isA(), + ); + expect( + turboExceptionForStatusCode(429), + isA(), + ); + }); + }); +} From c771fa40bfce9e718439efd069afbadfeb405c2d Mon Sep 17 00:00:00 2001 From: vilenarios Date: Wed, 15 Jul 2026 21:17:18 -0400 Subject: [PATCH 03/19] fix: analyzer errors in phase 1 (entities import, stub private member) Co-Authored-By: Claude Fable 5 --- lib/blocs/fs_entry_move/fs_entry_move_bloc.dart | 1 + lib/turbo/services/upload_service.dart | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart b/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart index dc3babf61..aec825eee 100644 --- a/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart +++ b/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/entities/entities.dart'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/pages/drive_detail/models/data_table_item.dart'; diff --git a/lib/turbo/services/upload_service.dart b/lib/turbo/services/upload_service.dart index fe490ccfe..375ea6667 100644 --- a/lib/turbo/services/upload_service.dart +++ b/lib/turbo/services/upload_service.dart @@ -172,6 +172,10 @@ class DontUseUploadService implements TurboUploadService { @override int get allowedDataItemSize => throw UnimplementedError(); + // Same-library interface implementation includes private members. + @override + int? _serverMaxItemBytes; + @override int get maxFreeItemSizeBytes => throw UnimplementedError(); From fe9d9ac63dd5d3f707eb0861ac04d177bed43622 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Thu, 16 Jul 2026 11:53:01 -0400 Subject: [PATCH 04/19] feat: payment-aware failure UX across all metadata ops + server threshold PE-9132 Completes the free-tier client readiness: every operation that posts to Turbo now recognizes a payment rejection and shows one consistent, actionable message instead of a generic error or (previously) a hang. - new shared TurboPaymentRequired dialog (ArDriveStandardModalNew with a "Buy Credits" action into the existing top-up flow) as the single source of truth for the free-allowance-used-up UX; localized strings freeAllowanceUsedUpTitle/Description added to all six ARB files - new isTurboPaymentError() classifier; each op's failure state carries an isPaymentError flag set from the caught exception: rename, move, drive rename, folder create, drive create, hide/unhide, pin, license, ghost fixer - every corresponding form/dialog branches to the shared payment dialog on payment errors and keeps its existing generic error otherwise; folder-create, drive-rename and ghost-fixer gained the failure handling they previously lacked - UploadPaymentEvaluator now resolves the free per-item threshold from the server (TurboUploadService.maxFreeItemSizeBytes via /v1/info), falling back to allowedDataItemSizeForTurbo; wired through DI for the main upload flow (metadata/manifest paths keep the config fallback) Co-Authored-By: Claude Fable 5 --- .../drive_create/drive_create_cubit.dart | 2 +- .../drive_create/drive_create_state.dart | 6 ++-- .../drive_rename/drive_rename_cubit.dart | 2 +- .../drive_rename/drive_rename_state.dart | 5 ++- .../folder_create/folder_create_cubit.dart | 2 +- .../folder_create/folder_create_state.dart | 5 ++- .../fs_entry_license_bloc.dart | 5 +-- .../fs_entry_license_state.dart | 3 +- lib/blocs/ghost_fixer/ghost_fixer_cubit.dart | 2 +- lib/blocs/ghost_fixer/ghost_fixer_state.dart | 5 ++- lib/blocs/hide/hide_bloc.dart | 5 ++- lib/blocs/hide/hide_state.dart | 6 +++- lib/blocs/pin_file/pin_file_bloc.dart | 1 + lib/blocs/pin_file/pin_file_state.dart | 4 ++- lib/components/drive_create_form.dart | 17 +++++---- lib/components/drive_rename_form.dart | 15 ++++++++ lib/components/folder_create_form.dart | 15 ++++++++ lib/components/fs_entry_license_form.dart | 14 +++++--- lib/components/fs_entry_move_form.dart | 24 +++++++------ lib/components/fs_entry_rename_form.dart | 24 +++++++------ lib/components/ghost_fixer_form.dart | 15 ++++++++ lib/components/hide_dialog.dart | 8 +++++ lib/components/pin_file_dialog.dart | 20 +++++++---- .../turbo_payment_required_dialog.dart | 35 +++++++++++++++++++ lib/core/upload/uploader.dart | 17 ++++++--- lib/l10n/app_en.arb | 8 +++++ lib/l10n/app_es.arb | 2 ++ lib/l10n/app_hi.arb | 2 ++ lib/l10n/app_ja.arb | 2 ++ lib/l10n/app_zh-HK.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/turbo/services/upload_service.dart | 7 ++++ lib/utils/dependency_injection_utils.dart | 1 + 33 files changed, 225 insertions(+), 58 deletions(-) create mode 100644 lib/components/turbo_payment_required_dialog.dart diff --git a/lib/blocs/drive_create/drive_create_cubit.dart b/lib/blocs/drive_create/drive_create_cubit.dart index e486a8612..7234703c7 100644 --- a/lib/blocs/drive_create/drive_create_cubit.dart +++ b/lib/blocs/drive_create/drive_create_cubit.dart @@ -179,7 +179,7 @@ class DriveCreateCubit extends Cubit { @override void onError(Object error, StackTrace stackTrace) { - emit(DriveCreateFailure(privacy: state.privacy)); + emit(DriveCreateFailure(privacy: state.privacy, isPaymentError: isTurboPaymentError(error))); super.onError(error, stackTrace); logger.e('Failed to create drive', error, stackTrace); diff --git a/lib/blocs/drive_create/drive_create_state.dart b/lib/blocs/drive_create/drive_create_state.dart index 7c07247a4..06ba7b9ef 100644 --- a/lib/blocs/drive_create/drive_create_state.dart +++ b/lib/blocs/drive_create/drive_create_state.dart @@ -51,11 +51,13 @@ class DriveCreateSuccess extends DriveCreateState { } class DriveCreateFailure extends DriveCreateState { - const DriveCreateFailure({required super.privacy}); + final bool isPaymentError; + const DriveCreateFailure({required super.privacy, this.isPaymentError = false}); @override DriveCreateFailure copyWith({DrivePrivacy? privacy}) { - return DriveCreateFailure(privacy: privacy ?? this.privacy); + return DriveCreateFailure( + privacy: privacy ?? this.privacy, isPaymentError: isPaymentError); } } diff --git a/lib/blocs/drive_rename/drive_rename_cubit.dart b/lib/blocs/drive_rename/drive_rename_cubit.dart index 11ee64078..ed2de9473 100644 --- a/lib/blocs/drive_rename/drive_rename_cubit.dart +++ b/lib/blocs/drive_rename/drive_rename_cubit.dart @@ -107,7 +107,7 @@ class DriveRenameCubit extends Cubit { @override void onError(Object error, StackTrace stackTrace) { - emit(DriveRenameFailure()); + emit(DriveRenameFailure(isPaymentError: isTurboPaymentError(error))); super.onError(error, stackTrace); } diff --git a/lib/blocs/drive_rename/drive_rename_state.dart b/lib/blocs/drive_rename/drive_rename_state.dart index 7bf128a41..64ed85f54 100644 --- a/lib/blocs/drive_rename/drive_rename_state.dart +++ b/lib/blocs/drive_rename/drive_rename_state.dart @@ -13,7 +13,10 @@ class DriveRenameInProgress extends DriveRenameState {} class DriveRenameSuccess extends DriveRenameState {} -class DriveRenameFailure extends DriveRenameState {} +class DriveRenameFailure extends DriveRenameState { + final bool isPaymentError; + DriveRenameFailure({this.isPaymentError = false}); +} class DriveRenameWalletMismatch extends DriveRenameState {} diff --git a/lib/blocs/folder_create/folder_create_cubit.dart b/lib/blocs/folder_create/folder_create_cubit.dart index 66edd207e..6bdd1a30c 100644 --- a/lib/blocs/folder_create/folder_create_cubit.dart +++ b/lib/blocs/folder_create/folder_create_cubit.dart @@ -128,7 +128,7 @@ class FolderCreateCubit extends Cubit { @override void onError(Object error, StackTrace stackTrace) { - emit(FolderCreateFailure()); + emit(FolderCreateFailure(isPaymentError: isTurboPaymentError(error))); super.onError(error, stackTrace); logger.e('Failed to create folder', error, stackTrace); diff --git a/lib/blocs/folder_create/folder_create_state.dart b/lib/blocs/folder_create/folder_create_state.dart index 7379c8f89..e389c1f3c 100644 --- a/lib/blocs/folder_create/folder_create_state.dart +++ b/lib/blocs/folder_create/folder_create_state.dart @@ -12,7 +12,10 @@ class FolderCreateInProgress extends FolderCreateState {} class FolderCreateSuccess extends FolderCreateState {} -class FolderCreateFailure extends FolderCreateState {} +class FolderCreateFailure extends FolderCreateState { + final bool isPaymentError; + FolderCreateFailure({this.isPaymentError = false}); +} class FolderCreateWalletMismatch extends FolderCreateState {} diff --git a/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart b/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart index 302466f73..d06dc2426 100644 --- a/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart +++ b/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart @@ -162,9 +162,10 @@ class FsEntryLicenseBloc licenseParams: licenseParams, ); emit(const FsEntryLicenseSuccess()); - } catch (_, trace) { + } catch (error, trace) { addError('Error licensing entities', trace); - emit(const FsEntryLicenseFailure()); + emit(FsEntryLicenseFailure( + isPaymentError: isTurboPaymentError(error))); } } diff --git a/lib/blocs/fs_entry_license/fs_entry_license_state.dart b/lib/blocs/fs_entry_license/fs_entry_license_state.dart index 1906d1438..a7d4cd04d 100644 --- a/lib/blocs/fs_entry_license/fs_entry_license_state.dart +++ b/lib/blocs/fs_entry_license/fs_entry_license_state.dart @@ -36,7 +36,8 @@ class FsEntryLicenseSuccess extends FsEntryLicenseState { } class FsEntryLicenseFailure extends FsEntryLicenseState { - const FsEntryLicenseFailure() : super(); + final bool isPaymentError; + const FsEntryLicenseFailure({this.isPaymentError = false}) : super(); } class FsEntryLicenseComplete extends FsEntryLicenseState { diff --git a/lib/blocs/ghost_fixer/ghost_fixer_cubit.dart b/lib/blocs/ghost_fixer/ghost_fixer_cubit.dart index fd863dd63..4cfe7e4eb 100644 --- a/lib/blocs/ghost_fixer/ghost_fixer_cubit.dart +++ b/lib/blocs/ghost_fixer/ghost_fixer_cubit.dart @@ -173,7 +173,7 @@ class GhostFixerCubit extends Cubit { @override void onError(Object error, StackTrace stackTrace) { - emit(GhostFixerFailure()); + emit(GhostFixerFailure(isPaymentError: isTurboPaymentError(error))); super.onError(error, stackTrace); logger.e('Failed to create folder', error, stackTrace); diff --git a/lib/blocs/ghost_fixer/ghost_fixer_state.dart b/lib/blocs/ghost_fixer/ghost_fixer_state.dart index 8bc3ce956..9162000d8 100644 --- a/lib/blocs/ghost_fixer/ghost_fixer_state.dart +++ b/lib/blocs/ghost_fixer/ghost_fixer_state.dart @@ -38,6 +38,9 @@ class GhostFixerNameConflict extends GhostFixerState { List get props => [name]; } -class GhostFixerFailure extends GhostFixerState {} +class GhostFixerFailure extends GhostFixerState { + final bool isPaymentError; + GhostFixerFailure({this.isPaymentError = false}); +} class GhostFixerWalletMismatch extends GhostFixerState {} diff --git a/lib/blocs/hide/hide_bloc.dart b/lib/blocs/hide/hide_bloc.dart index d7c4e5672..1b30758b4 100644 --- a/lib/blocs/hide/hide_bloc.dart +++ b/lib/blocs/hide/hide_bloc.dart @@ -345,7 +345,10 @@ class HideBloc extends Bloc { }); } catch (e) { logger.e('Error while hiding', e); - emit(FailureHideState(hideAction: state.hideAction)); + emit(FailureHideState( + hideAction: state.hideAction, + isPaymentError: isTurboPaymentError(e), + )); } } diff --git a/lib/blocs/hide/hide_state.dart b/lib/blocs/hide/hide_state.dart index 76c2b96f6..2dcd90ed8 100644 --- a/lib/blocs/hide/hide_state.dart +++ b/lib/blocs/hide/hide_state.dart @@ -66,7 +66,11 @@ class SuccessHideState extends HideState { } class FailureHideState extends HideState { - const FailureHideState({required super.hideAction}); + final bool isPaymentError; + const FailureHideState({ + required super.hideAction, + this.isPaymentError = false, + }); } enum HideAction { diff --git a/lib/blocs/pin_file/pin_file_bloc.dart b/lib/blocs/pin_file/pin_file_bloc.dart index 88aa965ce..20da258c0 100644 --- a/lib/blocs/pin_file/pin_file_bloc.dart +++ b/lib/blocs/pin_file/pin_file_bloc.dart @@ -387,6 +387,7 @@ class PinFileBloc extends Bloc { name: state.name, nameValidation: state.nameValidation, idValidation: state.idValidation, + isPaymentError: isTurboPaymentError(err), )); }); } diff --git a/lib/blocs/pin_file/pin_file_state.dart b/lib/blocs/pin_file/pin_file_state.dart index 6d17e3ce4..159cd8813 100644 --- a/lib/blocs/pin_file/pin_file_state.dart +++ b/lib/blocs/pin_file/pin_file_state.dart @@ -147,13 +147,15 @@ class PinFileSuccess extends PinFileState { } class PinFileError extends PinFileState { + final bool isPaymentError; const PinFileError({ required super.id, required super.name, required super.nameValidation, required super.idValidation, + this.isPaymentError = false, }); @override - List get props => []; + List get props => [isPaymentError]; } diff --git a/lib/components/drive_create_form.dart b/lib/components/drive_create_form.dart index 23d135c3c..e5a0e2e67 100644 --- a/lib/components/drive_create_form.dart +++ b/lib/components/drive_create_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/authentication/login/views/modals/common.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/core/arfs/entities/arfs_entities.dart'; import 'package:ardrive/l11n/l11n.dart'; @@ -66,12 +67,16 @@ class _DriveCreateFormState extends State { Navigator.pop(context); } else if (state is DriveCreateFailure) { Navigator.pop(context); - showErrorDialog( - context: context, - title: appLocalizationsOf(context).error, - message: - 'There was a problem creating this drive.\nPlease try again later.', - ); + if (state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showErrorDialog( + context: context, + title: appLocalizationsOf(context).error, + message: + 'There was a problem creating this drive.\nPlease try again later.', + ); + } } }, builder: (context, state) { diff --git a/lib/components/drive_rename_form.dart b/lib/components/drive_rename_form.dart index c2649dc52..f7772689f 100644 --- a/lib/components/drive_rename_form.dart +++ b/lib/components/drive_rename_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/drive_rename/drive_rename_cubit.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/services/services.dart'; @@ -81,6 +82,20 @@ class _DriveRenameFormState extends State { Navigator.pop(context); } else if (state is DriveRenameWalletMismatch) { Navigator.pop(context); + } else if (state is DriveRenameFailure) { + Navigator.pop(context); + if (state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: 'Failed to rename the drive. Please check ' + 'your connection and try again.', + ), + ); + } } else if (state is DriveNameAlreadyExists) { showStandardDialog( context, diff --git a/lib/components/folder_create_form.dart b/lib/components/folder_create_form.dart index 0736de844..5630c9c45 100644 --- a/lib/components/folder_create_form.dart +++ b/lib/components/folder_create_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/services/services.dart'; import 'package:ardrive/theme/theme.dart'; @@ -79,6 +80,20 @@ class _FolderCreateFormState extends State { Navigator.pop(context); } else if (state is FolderCreateWalletMismatch) { Navigator.pop(context); + } else if (state is FolderCreateFailure) { + Navigator.pop(context); + if (state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: 'Failed to create the folder. Please check ' + 'your connection and try again.', + ), + ); + } } else if (state is FolderCreateNameAlreadyExists) { Navigator.pop(context); diff --git a/lib/components/fs_entry_license_form.dart b/lib/components/fs_entry_license_form.dart index 45b1f4947..97d5391be 100644 --- a/lib/components/fs_entry_license_form.dart +++ b/lib/components/fs_entry_license_form.dart @@ -569,15 +569,21 @@ class _FsEntryLicenseFormState extends State { const SizedBox(height: 16), Flexible( child: Text( - // TODO: Localize - 'No dice.', + state.isPaymentError + ? appLocalizationsOf(context) + .freeAllowanceUsedUpTitle + // TODO: Localize + : 'No dice.', style: ArDriveTypography.headline.headline4Bold(), ), ), const SizedBox(height: 16), Text( - // TODO: Localize - 'Your attempted licensing failed, want to try again now?', + state.isPaymentError + ? appLocalizationsOf(context) + .freeAllowanceUsedUpDescription + // TODO: Localize + : 'Your attempted licensing failed, want to try again now?', textAlign: TextAlign.center, style: ArDriveTypography.body.buttonLargeRegular( color: ArDriveTheme.of(context) diff --git a/lib/components/fs_entry_move_form.dart b/lib/components/fs_entry_move_form.dart index d36888826..6551fba78 100644 --- a/lib/components/fs_entry_move_form.dart +++ b/lib/components/fs_entry_move_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/hide/global_hide_bloc.dart'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/models/models.dart'; @@ -64,17 +65,18 @@ class FsEntryMoveForm extends StatelessWidget { Navigator.pop(context); } else if (state is FsEntryMoveFailure) { Navigator.pop(context); // dismiss the progress dialog - showArDriveDialog( - context, - content: ArDriveStandardModalNew( - title: appLocalizationsOf(context).error, - description: state.isPaymentError - ? 'Turbo\'s free allowance is used up, so this action now ' - 'requires Credits. Add Credits and try again.' - : 'Failed to move the selected items. Please check your ' - 'connection and try again.', - ), - ); + if (state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: 'Failed to move the selected items. Please ' + 'check your connection and try again.', + ), + ); + } } }, builder: (context, state) { diff --git a/lib/components/fs_entry_rename_form.dart b/lib/components/fs_entry_rename_form.dart index 6ec990ba9..9b48e4a88 100644 --- a/lib/components/fs_entry_rename_form.dart +++ b/lib/components/fs_entry_rename_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/components/progress_dialog.dart'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/models/models.dart'; @@ -101,17 +102,18 @@ class _FsEntryRenameFormState extends State { final isPaymentError = (state is FolderEntryRenameFailure && state.isPaymentError) || (state is FileEntryRenameFailure && state.isPaymentError); - showArDriveDialog( - context, - content: ArDriveStandardModalNew( - title: appLocalizationsOf(context).error, - description: isPaymentError - ? 'Turbo\'s free allowance is used up, so this action ' - 'now requires Credits. Add Credits and try again.' - : 'Failed to rename. Please check your connection and ' - 'try again.', - ), - ); + if (isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: 'Failed to rename. Please check your ' + 'connection and try again.', + ), + ); + } } else if (state is FsEntryRenameInitialized) { _nameController.text = widget.entryName; } else if (state is EntityAlreadyExists) { diff --git a/lib/components/ghost_fixer_form.dart b/lib/components/ghost_fixer_form.dart index fe8df7708..eee558ecc 100644 --- a/lib/components/ghost_fixer_form.dart +++ b/lib/components/ghost_fixer_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/ghost_fixer/ghost_fixer_cubit.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; @@ -71,6 +72,20 @@ class _GhostFixerFormState extends State { widget.driveDetailCubit.refreshDriveDataTable(); } else if (state is GhostFixerWalletMismatch) { Navigator.pop(context); + } else if (state is GhostFixerFailure) { + Navigator.pop(context); + if (state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: 'Failed to recreate the folder. Please check ' + 'your connection and try again.', + ), + ); + } } else if (state is GhostFixerNameConflict) { showStandardDialog( context, diff --git a/lib/components/hide_dialog.dart b/lib/components/hide_dialog.dart index 13177abfa..072ccba65 100644 --- a/lib/components/hide_dialog.dart +++ b/lib/components/hide_dialog.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/drive_detail/drive_detail_cubit.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/hide/global_hide_bloc.dart'; import 'package:ardrive/blocs/hide/hide_bloc.dart'; import 'package:ardrive/blocs/hide/hide_event.dart'; @@ -100,6 +101,9 @@ class HideDialog extends StatelessWidget { String _buildTitle(BuildContext context, HideState state) { final hideAction = state.hideAction; if (state is FailureHideState) { + if (state.isPaymentError) { + return appLocalizationsOf(context).freeAllowanceUsedUpTitle; + } switch (hideAction) { case HideAction.hideFile: return appLocalizationsOf(context).failedToHideFile; @@ -134,6 +138,10 @@ class HideDialog extends StatelessWidget { Widget _buildContent(BuildContext context, HideState state) { if (state is FailureHideState) { + if (state.isPaymentError) { + return Text( + appLocalizationsOf(context).freeAllowanceUsedUpDescription); + } final hideAction = state.hideAction; switch (hideAction) { diff --git a/lib/components/pin_file_dialog.dart b/lib/components/pin_file_dialog.dart index 4d80b7565..ef7b13b4a 100644 --- a/lib/components/pin_file_dialog.dart +++ b/lib/components/pin_file_dialog.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/drive_detail/drive_detail_cubit.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/pin_file/pin_file_bloc.dart'; import 'package:ardrive/blocs/profile/profile_cubit.dart'; import 'package:ardrive/core/crypto/crypto.dart'; @@ -68,14 +69,19 @@ class PinFileDialog extends StatelessWidget { if (state is PinFileAbort || state is PinFileSuccess) { Navigator.of(context).pop(); } else if (state is PinFileError) { - showArDriveDialog( - context, - content: _errorDialog( + if (state.isPaymentError) { + Navigator.of(context).pop(); // close the pin dialog + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( context, - errorText: appLocalizationsOf(context).pinFailedToUpload, - doublePop: true, - ), - ); + content: _errorDialog( + context, + errorText: appLocalizationsOf(context).pinFailedToUpload, + doublePop: true, + ), + ); + } } else if (state is PinFileFieldsValidationError) { if (state.networkError) { showArDriveDialog( diff --git a/lib/components/turbo_payment_required_dialog.dart b/lib/components/turbo_payment_required_dialog.dart new file mode 100644 index 000000000..047425291 --- /dev/null +++ b/lib/components/turbo_payment_required_dialog.dart @@ -0,0 +1,35 @@ +import 'package:ardrive/turbo/topup/views/topup_modal.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; +import 'package:ardrive/utils/show_general_dialog.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/material.dart'; + +/// Shown when an operation is rejected by Turbo for payment reasons — the +/// free allowance is used up (or credits are insufficient), so the action +/// now requires Credits. +/// +/// Single source of truth for this message and its "Add Credits" action, so +/// every metadata/upload path presents the same UX. Purely informational — +/// the caller has already dismissed its own progress UI before calling this. +void showTurboPaymentRequiredDialog(BuildContext context) { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).freeAllowanceUsedUpTitle, + description: appLocalizationsOf(context).freeAllowanceUsedUpDescription, + actions: [ + ModalAction( + action: () => Navigator.of(context).pop(), + title: appLocalizationsOf(context).cancel, + ), + ModalAction( + action: () { + Navigator.of(context).pop(); + showTurboTopupModal(context); + }, + title: appLocalizationsOf(context).buyCredits, + ), + ], + ), + ); +} diff --git a/lib/core/upload/uploader.dart b/lib/core/upload/uploader.dart index 0841dac86..a337269a6 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -327,6 +327,7 @@ class UploadPaymentEvaluator { final ArDriveAuth _auth; final SizeUtils sizeUtils = SizeUtils(); final AppConfig _appConfig; + final TurboUploadService? _turboUploadService; UploadPaymentEvaluator({ required TurboBalanceRetriever turboBalanceRetriever, @@ -335,12 +336,21 @@ class UploadPaymentEvaluator { required ArDriveAuth auth, required TurboUploadCostCalculator turboUploadCostCalculator, required AppConfig appConfig, + TurboUploadService? turboUploadService, }) : _turboBalanceRetriever = turboBalanceRetriever, _appConfig = appConfig, _uploadCostEstimateCalculatorForAR = uploadCostEstimateCalculatorForAR, _auth = auth, + _turboUploadService = turboUploadService, _turboUploadCostCalculator = turboUploadCostCalculator; + /// The maximum size, in bytes, of an item eligible for a free Turbo upload. + /// Prefers the server-reported value from GET /v1/info (via the upload + /// service); falls back to the config value when no service is wired. + int get _maxFreeItemBytes => + _turboUploadService?.maxFreeItemSizeBytes ?? + _appConfig.allowedDataItemSizeForTurbo; + /// Even if this feature flag is off, it will be possible to upload using turbo /// for free files bool get _canUseTurbo => _appConfig.useTurboUpload; @@ -376,7 +386,7 @@ class UploadPaymentEvaluator { arCostEstimate = UploadCostEstimate.zero(); } - final allowedDataItemSizeForTurbo = _appConfig.allowedDataItemSizeForTurbo; + final allowedDataItemSizeForTurbo = _maxFreeItemBytes; bool isFreeUploadPossibleUsingTurbo = dataItem.getSize() <= allowedDataItemSizeForTurbo; @@ -458,8 +468,7 @@ class UploadPaymentEvaluator { bool isFreeUploadPossibleUsingTurbo = false; if (isUploadEligibleToTurbo) { - final allowedDataItemSizeForTurbo = - _appConfig.allowedDataItemSizeForTurbo; + final allowedDataItemSizeForTurbo = _maxFreeItemBytes; isFreeUploadPossibleUsingTurbo = uploadPlanForTurbo.bundleUploadHandles.every( @@ -479,7 +488,7 @@ class UploadPaymentEvaluator { : await _determineUploadMethod( turboBalance.balance, turboBundleSizes, - _appConfig.allowedDataItemSizeForTurbo, + _maxFreeItemBytes, _isTurboAvailableToUploadAllFiles, ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5a7c6cc0b..4e6bd21f2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -170,6 +170,14 @@ }, "buyCredits": "Buy Credits", "@buyCredits": {}, + "freeAllowanceUsedUpTitle": "Free allowance used up", + "@freeAllowanceUsedUpTitle": { + "description": "Title of the dialog shown when a Turbo action is rejected because the free allowance is exhausted" + }, + "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", + "@freeAllowanceUsedUpDescription": { + "description": "Body of the free-allowance-used-up dialog" + }, "camera": "Camera", "@camera": { "description": "Button label to select a file from camera" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 09bd1c5bf..9bd5552a7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -169,6 +169,8 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "Comprar créditos", + "freeAllowanceUsedUpTitle": "Free allowance used up", + "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, "camera": "Cámara", "@camera": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 0376437d0..a23240601 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -169,6 +169,8 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "क्रेडिट्स खरीदें ", + "freeAllowanceUsedUpTitle": "Free allowance used up", + "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, "camera": "कैमरा", "@camera": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 370175e16..6d8a68506 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -169,6 +169,8 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "クレジット購入", + "freeAllowanceUsedUpTitle": "Free allowance used up", + "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, "camera": "カメラ", "@camera": { diff --git a/lib/l10n/app_zh-HK.arb b/lib/l10n/app_zh-HK.arb index 62f82103b..321b50265 100644 --- a/lib/l10n/app_zh-HK.arb +++ b/lib/l10n/app_zh-HK.arb @@ -169,6 +169,8 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "購買積分", + "freeAllowanceUsedUpTitle": "Free allowance used up", + "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, "camera": "相機", "@camera": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d431af2bd..0b132cad8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -169,6 +169,8 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "购买积分", + "freeAllowanceUsedUpTitle": "Free allowance used up", + "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, "camera": "摄像头", "@camera": { diff --git a/lib/turbo/services/upload_service.dart b/lib/turbo/services/upload_service.dart index 375ea6667..5f81531a6 100644 --- a/lib/turbo/services/upload_service.dart +++ b/lib/turbo/services/upload_service.dart @@ -153,6 +153,13 @@ class TurboUploadService { } } +/// True when [error] indicates the upload service rejected an operation for +/// payment reasons (free allowance exhausted / insufficient credits). Used by +/// metadata-op blocs to choose payment-specific failure UX. +bool isTurboPaymentError(Object? error) { + return error is TurboPaymentRequiredException; +} + /// Maps a turbo upload HTTP status code to a typed exception, or null for /// codes without special semantics. Exception? turboExceptionForStatusCode(int? statusCode) { diff --git a/lib/utils/dependency_injection_utils.dart b/lib/utils/dependency_injection_utils.dart index a358c3ffc..42045ec33 100644 --- a/lib/utils/dependency_injection_utils.dart +++ b/lib/utils/dependency_injection_utils.dart @@ -69,6 +69,7 @@ UploadPaymentEvaluator _uploadPaymentEvaluator(BuildContext context) { auth: context.read(), turboBalanceRetriever: _turboBalanceRetriever(context), turboUploadCostCalculator: _turboUploadCostCalculator(context), + turboUploadService: context.read(), uploadCostEstimateCalculatorForAR: _uploadCostEstimateCalculatorForAR(context), ); From 047645e534d65db5b0db0d63ac921605d6afd67c Mon Sep 17 00:00:00 2001 From: vilenarios Date: Thu, 16 Jul 2026 12:00:27 -0400 Subject: [PATCH 05/19] fix: const DriveRenameFailure constructor, drop unused hide import Co-Authored-By: Claude Fable 5 --- lib/blocs/drive_rename/drive_rename_state.dart | 2 +- lib/components/hide_dialog.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/blocs/drive_rename/drive_rename_state.dart b/lib/blocs/drive_rename/drive_rename_state.dart index 64ed85f54..a5d5d420e 100644 --- a/lib/blocs/drive_rename/drive_rename_state.dart +++ b/lib/blocs/drive_rename/drive_rename_state.dart @@ -15,7 +15,7 @@ class DriveRenameSuccess extends DriveRenameState {} class DriveRenameFailure extends DriveRenameState { final bool isPaymentError; - DriveRenameFailure({this.isPaymentError = false}); + const DriveRenameFailure({this.isPaymentError = false}); } class DriveRenameWalletMismatch extends DriveRenameState {} diff --git a/lib/components/hide_dialog.dart b/lib/components/hide_dialog.dart index 072ccba65..83bdad6dd 100644 --- a/lib/components/hide_dialog.dart +++ b/lib/components/hide_dialog.dart @@ -1,5 +1,4 @@ import 'package:ardrive/blocs/drive_detail/drive_detail_cubit.dart'; -import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/hide/global_hide_bloc.dart'; import 'package:ardrive/blocs/hide/hide_bloc.dart'; import 'package:ardrive/blocs/hide/hide_event.dart'; From f40205b81f6ed6e8a6454462445b295c487407cc Mon Sep 17 00:00:00 2001 From: vilenarios Date: Thu, 16 Jul 2026 12:48:56 -0400 Subject: [PATCH 06/19] fix: address CodeRabbit review on free-tier UX PE-9132 - include isPaymentError in Equatable props on every failure state (drive/folder create, drive/folder/file rename, hide, ghost fixer, license) so a payment failure emitted after a generic one is not treated as an equal state and actually re-triggers the listener - license: preserve the original exception via logger.e before addError - license failure card: on a payment error the action becomes "Buy Credits" (opens the shared payment dialog) instead of retrying the same rejected operation - localize the generic metadata-op failure message via a shared actionFailedTryAgain key across all locales, replacing hardcoded English descriptions Co-Authored-By: Claude Fable 5 --- lib/blocs/drive_create/drive_create_state.dart | 3 +++ lib/blocs/drive_rename/drive_rename_state.dart | 3 +++ .../folder_create/folder_create_state.dart | 3 +++ .../fs_entry_license/fs_entry_license_bloc.dart | 2 ++ .../fs_entry_license_state.dart | 3 +++ .../fs_entry_rename/fs_entry_rename_state.dart | 6 ++++++ lib/blocs/ghost_fixer/ghost_fixer_state.dart | 3 +++ lib/blocs/hide/hide_state.dart | 3 +++ lib/components/drive_rename_form.dart | 4 ++-- lib/components/folder_create_form.dart | 4 ++-- lib/components/fs_entry_license_form.dart | 17 +++++++++++++---- lib/components/fs_entry_move_form.dart | 4 ++-- lib/components/fs_entry_rename_form.dart | 4 ++-- lib/components/ghost_fixer_form.dart | 4 ++-- lib/l10n/app_en.arb | 4 ++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_zh-HK.arb | 1 + lib/l10n/app_zh.arb | 1 + 20 files changed, 58 insertions(+), 14 deletions(-) diff --git a/lib/blocs/drive_create/drive_create_state.dart b/lib/blocs/drive_create/drive_create_state.dart index 06ba7b9ef..c7d9aa44f 100644 --- a/lib/blocs/drive_create/drive_create_state.dart +++ b/lib/blocs/drive_create/drive_create_state.dart @@ -59,6 +59,9 @@ class DriveCreateFailure extends DriveCreateState { return DriveCreateFailure( privacy: privacy ?? this.privacy, isPaymentError: isPaymentError); } + + @override + List get props => [privacy, isPaymentError]; } class DriveCreateWalletMismatch extends DriveCreateState { diff --git a/lib/blocs/drive_rename/drive_rename_state.dart b/lib/blocs/drive_rename/drive_rename_state.dart index a5d5d420e..320344d5f 100644 --- a/lib/blocs/drive_rename/drive_rename_state.dart +++ b/lib/blocs/drive_rename/drive_rename_state.dart @@ -16,6 +16,9 @@ class DriveRenameSuccess extends DriveRenameState {} class DriveRenameFailure extends DriveRenameState { final bool isPaymentError; const DriveRenameFailure({this.isPaymentError = false}); + + @override + List get props => [isPaymentError]; } class DriveRenameWalletMismatch extends DriveRenameState {} diff --git a/lib/blocs/folder_create/folder_create_state.dart b/lib/blocs/folder_create/folder_create_state.dart index e389c1f3c..bec5c620d 100644 --- a/lib/blocs/folder_create/folder_create_state.dart +++ b/lib/blocs/folder_create/folder_create_state.dart @@ -15,6 +15,9 @@ class FolderCreateSuccess extends FolderCreateState {} class FolderCreateFailure extends FolderCreateState { final bool isPaymentError; FolderCreateFailure({this.isPaymentError = false}); + + @override + List get props => [isPaymentError]; } class FolderCreateWalletMismatch extends FolderCreateState {} diff --git a/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart b/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart index d06dc2426..41ef9b1a3 100644 --- a/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart +++ b/lib/blocs/fs_entry_license/fs_entry_license_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/utils/logger.dart'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/models/forms/cc.dart'; import 'package:ardrive/models/forms/udl.dart'; @@ -163,6 +164,7 @@ class FsEntryLicenseBloc ); emit(const FsEntryLicenseSuccess()); } catch (error, trace) { + logger.e('Error licensing entities', error, trace); addError('Error licensing entities', trace); emit(FsEntryLicenseFailure( isPaymentError: isTurboPaymentError(error))); diff --git a/lib/blocs/fs_entry_license/fs_entry_license_state.dart b/lib/blocs/fs_entry_license/fs_entry_license_state.dart index a7d4cd04d..a1d1e62e0 100644 --- a/lib/blocs/fs_entry_license/fs_entry_license_state.dart +++ b/lib/blocs/fs_entry_license/fs_entry_license_state.dart @@ -38,6 +38,9 @@ class FsEntryLicenseSuccess extends FsEntryLicenseState { class FsEntryLicenseFailure extends FsEntryLicenseState { final bool isPaymentError; const FsEntryLicenseFailure({this.isPaymentError = false}) : super(); + + @override + List get props => [isPaymentError]; } class FsEntryLicenseComplete extends FsEntryLicenseState { diff --git a/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart b/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart index 7b3e80f0c..bf99f42a7 100644 --- a/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart +++ b/lib/blocs/fs_entry_rename/fs_entry_rename_state.dart @@ -30,6 +30,9 @@ class FolderEntryRenameFailure extends FsEntryRenameState { const FolderEntryRenameFailure({this.isPaymentError = false}) : super(isRenamingFolder: true); + + @override + List get props => [isRenamingFolder, isPaymentError]; } class EntityAlreadyExists extends FsEntryRenameState { @@ -70,6 +73,9 @@ class FileEntryRenameFailure extends FsEntryRenameState { const FileEntryRenameFailure({this.isPaymentError = false}) : super(isRenamingFolder: false); + + @override + List get props => [isRenamingFolder, isPaymentError]; } class FileEntryRenameWalletMismatch extends FsEntryRenameState { diff --git a/lib/blocs/ghost_fixer/ghost_fixer_state.dart b/lib/blocs/ghost_fixer/ghost_fixer_state.dart index 9162000d8..9e48569b6 100644 --- a/lib/blocs/ghost_fixer/ghost_fixer_state.dart +++ b/lib/blocs/ghost_fixer/ghost_fixer_state.dart @@ -41,6 +41,9 @@ class GhostFixerNameConflict extends GhostFixerState { class GhostFixerFailure extends GhostFixerState { final bool isPaymentError; GhostFixerFailure({this.isPaymentError = false}); + + @override + List get props => [isPaymentError]; } class GhostFixerWalletMismatch extends GhostFixerState {} diff --git a/lib/blocs/hide/hide_state.dart b/lib/blocs/hide/hide_state.dart index 2dcd90ed8..4c1dad6f8 100644 --- a/lib/blocs/hide/hide_state.dart +++ b/lib/blocs/hide/hide_state.dart @@ -71,6 +71,9 @@ class FailureHideState extends HideState { required super.hideAction, this.isPaymentError = false, }); + + @override + List get props => [hideAction, isPaymentError]; } enum HideAction { diff --git a/lib/components/drive_rename_form.dart b/lib/components/drive_rename_form.dart index f7772689f..b1c090124 100644 --- a/lib/components/drive_rename_form.dart +++ b/lib/components/drive_rename_form.dart @@ -91,8 +91,8 @@ class _DriveRenameFormState extends State { context, content: ArDriveStandardModalNew( title: appLocalizationsOf(context).error, - description: 'Failed to rename the drive. Please check ' - 'your connection and try again.', + description: + appLocalizationsOf(context).actionFailedTryAgain, ), ); } diff --git a/lib/components/folder_create_form.dart b/lib/components/folder_create_form.dart index 5630c9c45..fff9b6cc3 100644 --- a/lib/components/folder_create_form.dart +++ b/lib/components/folder_create_form.dart @@ -89,8 +89,8 @@ class _FolderCreateFormState extends State { context, content: ArDriveStandardModalNew( title: appLocalizationsOf(context).error, - description: 'Failed to create the folder. Please check ' - 'your connection and try again.', + description: + appLocalizationsOf(context).actionFailedTryAgain, ), ); } diff --git a/lib/components/fs_entry_license_form.dart b/lib/components/fs_entry_license_form.dart index 97d5391be..301f38fcf 100644 --- a/lib/components/fs_entry_license_form.dart +++ b/lib/components/fs_entry_license_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/components/license/cc_type_form.dart'; import 'package:ardrive/components/license/udl_params_form.dart'; import 'package:ardrive/components/license_summary.dart'; @@ -607,10 +608,18 @@ class _FsEntryLicenseFormState extends State { .themeAccentSubtle, ) .copyWith(fontWeight: FontWeight.bold), - text: appLocalizationsOf(context).tryAgain, - onPressed: () => context - .read() - .add(const FsEntryLicenseFailureTryAgain()), + text: state.isPaymentError + ? appLocalizationsOf(context).buyCredits + : appLocalizationsOf(context).tryAgain, + onPressed: () { + if (state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + context + .read() + .add(const FsEntryLicenseFailureTryAgain()); + } + }, ), ], ), diff --git a/lib/components/fs_entry_move_form.dart b/lib/components/fs_entry_move_form.dart index 6551fba78..974ecc374 100644 --- a/lib/components/fs_entry_move_form.dart +++ b/lib/components/fs_entry_move_form.dart @@ -72,8 +72,8 @@ class FsEntryMoveForm extends StatelessWidget { context, content: ArDriveStandardModalNew( title: appLocalizationsOf(context).error, - description: 'Failed to move the selected items. Please ' - 'check your connection and try again.', + description: + appLocalizationsOf(context).actionFailedTryAgain, ), ); } diff --git a/lib/components/fs_entry_rename_form.dart b/lib/components/fs_entry_rename_form.dart index 9b48e4a88..193dc9f95 100644 --- a/lib/components/fs_entry_rename_form.dart +++ b/lib/components/fs_entry_rename_form.dart @@ -109,8 +109,8 @@ class _FsEntryRenameFormState extends State { context, content: ArDriveStandardModalNew( title: appLocalizationsOf(context).error, - description: 'Failed to rename. Please check your ' - 'connection and try again.', + description: + appLocalizationsOf(context).actionFailedTryAgain, ), ); } diff --git a/lib/components/ghost_fixer_form.dart b/lib/components/ghost_fixer_form.dart index eee558ecc..2329d33aa 100644 --- a/lib/components/ghost_fixer_form.dart +++ b/lib/components/ghost_fixer_form.dart @@ -81,8 +81,8 @@ class _GhostFixerFormState extends State { context, content: ArDriveStandardModalNew( title: appLocalizationsOf(context).error, - description: 'Failed to recreate the folder. Please check ' - 'your connection and try again.', + description: + appLocalizationsOf(context).actionFailedTryAgain, ), ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4e6bd21f2..da2adfcf1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -170,6 +170,10 @@ }, "buyCredits": "Buy Credits", "@buyCredits": {}, + "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", + "@actionFailedTryAgain": { + "description": "Generic failure message for metadata operations (rename, move, create, etc.)" + }, "freeAllowanceUsedUpTitle": "Free allowance used up", "@freeAllowanceUsedUpTitle": { "description": "Title of the dialog shown when a Turbo action is rejected because the free allowance is exhausted" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9bd5552a7..8a4009351 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -169,6 +169,7 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "Comprar créditos", + "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a23240601..a49a1946d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -169,6 +169,7 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "क्रेडिट्स खरीदें ", + "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 6d8a68506..1beedb735 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -169,6 +169,7 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "クレジット購入", + "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, diff --git a/lib/l10n/app_zh-HK.arb b/lib/l10n/app_zh-HK.arb index 321b50265..79cf69559 100644 --- a/lib/l10n/app_zh-HK.arb +++ b/lib/l10n/app_zh-HK.arb @@ -169,6 +169,7 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "購買積分", + "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0b132cad8..4e0a2ee56 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -169,6 +169,7 @@ "description": "The app is bundling together and signing the transactions" }, "buyCredits": "购买积分", + "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "@buyCredits": {}, From dd3853598b4d8f3a474f8ecafaf68ab7ff1e32e4 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Fri, 17 Jul 2026 13:55:28 -0400 Subject: [PATCH 07/19] feat: extend payment-failure UX to file uploads and all remaining post paths PE-9132 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found the metadata ops were covered but the highest-visibility upload surfaces were not. Closes those gaps. Cross-cutting fix — the two upload services throw different payment exceptions (app-side TurboPaymentRequiredException vs ardrive_uploader package UnderFundException, sometimes wrapped in UploadStrategyException). isTurboPaymentError() now recognizes all of them; ardrive_uploader exports its exceptions so the app can classify them. Newly covered: - main file upload and folder upload: UploadCubit classifies a payment rejection from the failed-task list into UploadErrors.turboPaymentRequired (UploadFailure gains isPaymentError-carrying props); the failure widget shows the Buy-Credits dialog instead of a "Re-Upload" that would 402 again - post-upload ArNS name assignment: wrapped in try/catch so a rejected name data item can no longer hang an already-successful upload (the name can be reassigned later) - snapshot creation, manifest creation (unwrapping the task-list error through ManifestCreationException), standalone ArNS assignment, bulk import (unwrapping FileMetadataUploadException.originalError), and single/multi thumbnail creation all classify payment errors and show the shared dialog Deferred (documented): private-drive migration and login verification posts — tiny, effectively always-free signature items where a payment dialog mid-flow would be worse UX than the near-impossible failure. Co-Authored-By: Claude Fable 5 --- .../assign_name_bloc/assign_name_bloc.dart | 3 +- .../assign_name_bloc/assign_name_state.dart | 8 ++- lib/arns/presentation/assign_name_modal.dart | 11 +++- lib/blocs/bulk_import/bulk_import_bloc.dart | 22 ++++++- lib/blocs/bulk_import/bulk_import_state.dart | 5 +- .../create_manifest_cubit.dart | 6 +- .../create_manifest_state.dart | 8 ++- .../create_snapshot_cubit.dart | 2 +- .../create_snapshot_state.dart | 5 +- lib/blocs/upload/upload_cubit.dart | 60 ++++++++++++++----- lib/blocs/upload/upload_state.dart | 4 ++ lib/components/create_manifest_form.dart | 7 +++ lib/components/create_snapshot_dialog.dart | 8 +++ lib/components/upload_form.dart | 25 ++++++++ .../bloc/multi_thumbnail_creation_bloc.dart | 4 +- .../bloc/multi_thumbnail_creation_state.dart | 5 +- .../multi_thumbnail_creation_modal.dart | 8 +++ .../bloc/thumbnail_creation_bloc.dart | 3 +- .../bloc/thumbnail_creation_state.dart | 8 ++- .../page/thumbnail_creation_modal.dart | 8 +++ lib/manifest/domain/manifest_repository.dart | 12 +++- .../components/bulk_import_modal.dart | 3 + lib/turbo/services/upload_service.dart | 19 +++++- .../lib/ardrive_uploader.dart | 1 + 24 files changed, 214 insertions(+), 31 deletions(-) diff --git a/lib/arns/presentation/assign_name_bloc/assign_name_bloc.dart b/lib/arns/presentation/assign_name_bloc/assign_name_bloc.dart index 9f553b430..f2911d52d 100644 --- a/lib/arns/presentation/assign_name_bloc/assign_name_bloc.dart +++ b/lib/arns/presentation/assign_name_bloc/assign_name_bloc.dart @@ -1,4 +1,5 @@ import 'package:ardrive/arns/domain/arns_repository.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/arns/utils/arns_address_utils.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/pages/drive_detail/models/data_table_item.dart'; @@ -187,7 +188,7 @@ class AssignNameBloc extends Bloc { )); } catch (e, stackTrace) { logger.e('Failed to confirm ArNS name assignment', e, stackTrace); - emit(SelectionFailed()); + emit(SelectionFailed(isPaymentError: isTurboPaymentError(e))); } }); diff --git a/lib/arns/presentation/assign_name_bloc/assign_name_state.dart b/lib/arns/presentation/assign_name_bloc/assign_name_state.dart index e2e255213..0b9fa8faf 100644 --- a/lib/arns/presentation/assign_name_bloc/assign_name_state.dart +++ b/lib/arns/presentation/assign_name_bloc/assign_name_state.dart @@ -91,7 +91,13 @@ final class SelectionConfirmed extends AssignNameState { final class LoadingUndernames extends AssignNameState {} -class SelectionFailed extends AssignNameState {} +class SelectionFailed extends AssignNameState { + final bool isPaymentError; + const SelectionFailed({this.isPaymentError = false}); + + @override + List get props => [isPaymentError]; +} final class LoadingNamesFailed extends AssignNameState {} diff --git a/lib/arns/presentation/assign_name_modal.dart b/lib/arns/presentation/assign_name_modal.dart index c12d04741..4532dbfef 100644 --- a/lib/arns/presentation/assign_name_modal.dart +++ b/lib/arns/presentation/assign_name_modal.dart @@ -1,6 +1,7 @@ // ignore_for_file: unnecessary_string_escapes, unused_element import 'package:ardrive/arns/domain/arns_repository.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/arns/presentation/assign_name_bloc/assign_name_bloc.dart'; import 'package:ardrive/arns/presentation/create_undername.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; @@ -343,9 +344,17 @@ class _AssignArNSNameModalState extends State<_AssignArNSNameModal> { } else if (state is SelectionFailed) { final colorTokens = ArDriveTheme.of(context).themeData.colorTokens; + if (state.isPaymentError) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showTurboPaymentRequiredDialog(context); + }); + } return Center( child: Text( - 'Error assigning ArNS name. Please try again later', + state.isPaymentError + ? appLocalizationsOf(context) + .freeAllowanceUsedUpDescription + : 'Error assigning ArNS name. Please try again later', style: typography.paragraphLarge( color: colorTokens.textMid, ), diff --git a/lib/blocs/bulk_import/bulk_import_bloc.dart b/lib/blocs/bulk_import/bulk_import_bloc.dart index 28310e214..80cdf669f 100644 --- a/lib/blocs/bulk_import/bulk_import_bloc.dart +++ b/lib/blocs/bulk_import/bulk_import_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/blocs/bulk_import/bulk_import_event.dart'; import 'package:ardrive/blocs/bulk_import/bulk_import_state.dart'; import 'package:ardrive/core/arfs/use_cases/bulk_import_files.dart'; @@ -128,9 +129,15 @@ class BulkImportBloc extends Bloc { return; } catch (e) { logger.e('Error during bulk import', e); + final paymentError = _isBulkImportPaymentError(e); emit(BulkImportError( - 'An unexpected error occurred during the import process. Please try again.', + paymentError + ? 'Your free upload allowance has been used up. Add Credits to ' + 'continue importing.' + : 'An unexpected error occurred during the import process. ' + 'Please try again.', e, + paymentError, )); } } @@ -289,3 +296,16 @@ class BulkImportBloc extends Bloc { emit(const BulkImportInitial()); } } + +/// Detects a Turbo payment rejection inside bulk-import failures. Metadata +/// upload use-cases wrap the original exception in +/// FileMetadataUploadException/FolderMetadataUploadException. +bool _isBulkImportPaymentError(Object? error) { + if (isTurboPaymentError(error)) return true; + try { + final dynamic e = error; + return isTurboPaymentError(e.originalError); + } catch (_) { + return false; + } +} diff --git a/lib/blocs/bulk_import/bulk_import_state.dart b/lib/blocs/bulk_import/bulk_import_state.dart index 433e4cf73..930c19f39 100644 --- a/lib/blocs/bulk_import/bulk_import_state.dart +++ b/lib/blocs/bulk_import/bulk_import_state.dart @@ -117,11 +117,12 @@ class BulkImportSuccess extends BulkImportState { class BulkImportError extends BulkImportState { final String message; final Object? error; + final bool isPaymentError; - const BulkImportError(this.message, [this.error]); + const BulkImportError(this.message, [this.error, this.isPaymentError = false]); @override - List get props => [message, error]; + List get props => [message, error, isPaymentError]; } class BulkImportResolvingPaths extends BulkImportState { diff --git a/lib/blocs/create_manifest/create_manifest_cubit.dart b/lib/blocs/create_manifest/create_manifest_cubit.dart index 611a0a494..aaf19f696 100644 --- a/lib/blocs/create_manifest/create_manifest_cubit.dart +++ b/lib/blocs/create_manifest/create_manifest_cubit.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:ardrive/arns/domain/arns_repository.dart'; +import 'package:ardrive/manifest/domain/exceptions.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/blocs/upload/models/payment_method_info.dart'; @@ -493,7 +495,9 @@ class CreateManifestCubit extends Cubit { @override void onError(Object error, StackTrace stackTrace) { logger.e('Failed to create manifest', error, stackTrace); - emit(CreateManifestFailure()); + final wrapped = error is ManifestCreationException ? error.error : error; + emit(CreateManifestFailure( + isPaymentError: isTurboPaymentError(wrapped))); super.onError(error, stackTrace); } } diff --git a/lib/blocs/create_manifest/create_manifest_state.dart b/lib/blocs/create_manifest/create_manifest_state.dart index 5a7836f04..39fd9aff8 100644 --- a/lib/blocs/create_manifest/create_manifest_state.dart +++ b/lib/blocs/create_manifest/create_manifest_state.dart @@ -227,7 +227,13 @@ class CreateManifestPrivacyMismatch extends CreateManifestState {} class CreateManifestWalletMismatch extends CreateManifestState {} /// Manifest transaction upload has failed -class CreateManifestFailure extends CreateManifestState {} +class CreateManifestFailure extends CreateManifestState { + final bool isPaymentError; + CreateManifestFailure({this.isPaymentError = false}); + + @override + List get props => [isPaymentError]; +} /// Manifest transaction has been successfully uploaded class CreateManifestSuccess extends CreateManifestState { diff --git a/lib/blocs/create_snapshot/create_snapshot_cubit.dart b/lib/blocs/create_snapshot/create_snapshot_cubit.dart index f80cfbd4c..6b23e1186 100644 --- a/lib/blocs/create_snapshot/create_snapshot_cubit.dart +++ b/lib/blocs/create_snapshot/create_snapshot_cubit.dart @@ -680,7 +680,7 @@ class CreateSnapshotCubit extends Cubit { emit(SnapshotUploadSuccess()); } catch (err, stacktrace) { logger.e('Error while posting the snapshot transaction', err, stacktrace); - emit(SnapshotUploadFailure()); + emit(SnapshotUploadFailure(isPaymentError: isTurboPaymentError(err))); } } diff --git a/lib/blocs/create_snapshot/create_snapshot_state.dart b/lib/blocs/create_snapshot/create_snapshot_state.dart index fcbbf515a..f921532d9 100644 --- a/lib/blocs/create_snapshot/create_snapshot_state.dart +++ b/lib/blocs/create_snapshot/create_snapshot_state.dart @@ -143,8 +143,11 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { class UploadingSnapshot extends CreateSnapshotState {} class SnapshotUploadFailure extends CreateSnapshotState { + final bool isPaymentError; + SnapshotUploadFailure({this.isPaymentError = false}); + @override - List get props => []; + List get props => [isPaymentError]; } class SnapshotUploadSuccess extends CreateSnapshotState {} diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 7e51cdaa9..9a1b97979 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -272,12 +272,19 @@ class UploadCubit extends Cubit { completedCount: ++completedCount, )); - await _arnsRepository.setUndernamesToFile( - undername: undername, - driveId: _driveId, - fileId: manifestModels[i].existingManifestFileId, - processId: manifestModels[i].antRecord!.processId, - ); + try { + await _arnsRepository.setUndernamesToFile( + undername: undername, + driveId: _driveId, + fileId: manifestModels[i].existingManifestFileId, + processId: manifestModels[i].antRecord!.processId, + ); + } catch (e) { + // The manifest already uploaded; a failed name assignment (e.g. a + // payment rejection on the name data item) must not hang the flow. + // The name can be reassigned later from the details panel. + logger.e('Failed to assign name to uploaded manifest', e); + } manifestModels[i] = manifestModels[i].copyWith( isCompleted: true, isUploading: false, isAssigningUndername: false); @@ -1275,7 +1282,7 @@ class UploadCubit extends Cubit { uploadController.onError((tasks) { logger.i('Error uploading folders. Number of tasks: ${tasks.length}'); emit(UploadFailure( - error: UploadErrors.unknown, + error: _uploadErrorFromTasks(tasks), failedTasks: tasks, controller: uploadController)); }); @@ -1344,7 +1351,7 @@ class UploadCubit extends Cubit { logger.i('Error uploading files. Number of tasks: ${tasks.length}'); emit( UploadFailure( - error: UploadErrors.unknown, + error: _uploadErrorFromTasks(tasks), failedTasks: tasks, controller: uploadController, ), @@ -1437,13 +1444,20 @@ class UploadCubit extends Cubit { transactionId: metadata.dataTxId!, ); - await _arnsRepository.setUndernamesToFile( - undername: newUndername, - driveId: _targetDrive.id, - fileId: metadata.id, - processId: _selectedAntRecord!.processId, - uploadNewRevision: false, - ); + try { + await _arnsRepository.setUndernamesToFile( + undername: newUndername, + driveId: _targetDrive.id, + fileId: metadata.id, + processId: _selectedAntRecord!.processId, + uploadNewRevision: false, + ); + } catch (e) { + // The file already uploaded; a failed name assignment (e.g. a + // payment rejection on the name data item) must not hang the + // upload. The name can be reassigned later from the details panel. + logger.e('Failed to assign name to uploaded file', e); + } } } } @@ -1505,8 +1519,24 @@ class UploadCubit extends Cubit { return; } + if (isTurboPaymentError(error)) { + emit(UploadFailure(error: UploadErrors.turboPaymentRequired)); + + return; + } + emit(UploadFailure(error: UploadErrors.unknown)); } + + /// Classifies a failed-task list from the uploader into an [UploadErrors]. + /// A payment rejection (free allowance exhausted / insufficient credits) + /// arrives as an UnderFundException on one of the tasks. + UploadErrors _uploadErrorFromTasks(List tasks) { + final hasPaymentError = tasks.any((t) => isTurboPaymentError(t.error)); + return hasPaymentError + ? UploadErrors.turboPaymentRequired + : UploadErrors.unknown; + } } class UploadFolder extends IOFolder { diff --git a/lib/blocs/upload/upload_state.dart b/lib/blocs/upload/upload_state.dart index abd64dde4..a398a5412 100644 --- a/lib/blocs/upload/upload_state.dart +++ b/lib/blocs/upload/upload_state.dart @@ -304,6 +304,9 @@ class UploadFailure extends UploadState { final UploadController? controller; UploadFailure({this.failedTasks, required this.error, this.controller}); + + @override + List get props => [error, failedTasks]; } class UploadComplete extends UploadState { @@ -345,6 +348,7 @@ class EmptyUpload extends UploadState {} enum UploadErrors { turboTimeout, + turboPaymentRequired, unknown, } diff --git a/lib/components/create_manifest_form.dart b/lib/components/create_manifest_form.dart index 27d40680b..4ab053178 100644 --- a/lib/components/create_manifest_form.dart +++ b/lib/components/create_manifest_form.dart @@ -1,4 +1,5 @@ import 'package:ardrive/arns/domain/arns_repository.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/arns/presentation/assign_name_modal.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; @@ -171,6 +172,12 @@ class _CreateManifestFormState extends State { ); } else if (state is CreateManifestFailure) { Navigator.pop(context); + if (state.isPaymentError) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showTurboPaymentRequiredDialog(context); + }); + return const SizedBox.shrink(); + } return errorDialog( errorText: appLocalizationsOf(context).manifestTransactionUnexpectedlyFailed, diff --git a/lib/components/create_snapshot_dialog.dart b/lib/components/create_snapshot_dialog.dart index a60e284cb..60aebf6e0 100644 --- a/lib/components/create_snapshot_dialog.dart +++ b/lib/components/create_snapshot_dialog.dart @@ -1,4 +1,5 @@ import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/blocs/create_snapshot/create_snapshot_cubit.dart'; import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; @@ -93,6 +94,13 @@ class CreateSnapshotDialog extends StatelessWidget { return _loadingDialog(context, state); } else if (state is SnapshotUploadSuccess) { return _successDialog(context, drive.name); + } else if (state is SnapshotUploadFailure && + state.isPaymentError) { + // Defer to post-frame so the dialog can be shown over this one. + WidgetsBinding.instance.addPostFrameCallback((_) { + showTurboPaymentRequiredDialog(context); + }); + return const SizedBox.shrink(); } else if (state is SnapshotUploadFailure || state is ComputeSnapshotDataFailure) { return _failureDialog(context, drive.id); diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index c753d3043..0087b8a47 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:math'; import 'package:ardrive/arns/domain/arns_repository.dart'; +import 'package:ardrive/turbo/topup/views/topup_modal.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/arns/presentation/assign_name_modal.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; @@ -2377,6 +2379,29 @@ class _UploadFailureWidget extends StatelessWidget { ); } + if (state.error == UploadErrors.turboPaymentRequired) { + // Free allowance exhausted / insufficient credits: point the user at + // the top-up flow instead of offering a re-upload that would 402 again. + return ArDriveStandardModalNew( + title: appLocalizationsOf(context).freeAllowanceUsedUpTitle, + description: + appLocalizationsOf(context).freeAllowanceUsedUpDescription, + actions: [ + ModalAction( + action: () => Navigator.of(context).pop(false), + title: appLocalizationsOf(context).cancel, + ), + ModalAction( + action: () { + Navigator.of(context).pop(false); + showTurboTopupModal(context); + }, + title: appLocalizationsOf(context).buyCredits, + ), + ], + ); + } + return ArDriveStandardModalNew( hasCloseButton: true, width: state.failedTasks != null ? kLargeDialogWidth : kMediumDialogWidth, diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart index 041832bae..ebed70d1c 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart @@ -1,4 +1,5 @@ import 'package:ardrive/core/arfs/repository/drive_repository.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/utils/constants.dart'; @@ -190,7 +191,8 @@ class MultiThumbnailCreationBloc } logger.e('Error creating thumbnails: $e'); - emit(MultiThumbnailCreationError()); + emit(MultiThumbnailCreationError( + isPaymentError: isTurboPaymentError(e))); } _skippedDrives.clear(); diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart index 3759703fb..725f70976 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart @@ -70,6 +70,9 @@ final class MultiThumbnailCreationCancelled extends MultiThumbnailCreationState {} final class MultiThumbnailCreationError extends MultiThumbnailCreationState { + final bool isPaymentError; + MultiThumbnailCreationError({this.isPaymentError = false}); + @override - List get props => []; + List get props => [isPaymentError]; } diff --git a/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart index bb8820bb6..a9159ae6c 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart @@ -1,4 +1,5 @@ import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/core/arfs/repository/drive_repository.dart'; import 'package:ardrive/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart'; import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; @@ -160,6 +161,13 @@ class _MultiThumbnailCreationModalContentState ); } + if (state is MultiThumbnailCreationError && state.isPaymentError) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showTurboPaymentRequiredDialog(context); + }); + return const SizedBox.shrink(); + } + if (state is MultiThumbnailCreationError) { return Material( child: ArDriveStandardModalNew( diff --git a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart index 7edf597d2..159be19ee 100644 --- a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart +++ b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart @@ -1,4 +1,5 @@ import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/pages/drive_detail/models/data_table_item.dart'; import 'package:ardrive/utils/logger.dart'; import 'package:equatable/equatable.dart'; @@ -31,7 +32,7 @@ class ThumbnailCreationBloc emit(ThumbnailCreationSuccess()); } catch (e, stackTrace) { logger.e('Error uploading thumbnail', e, stackTrace); - emit(ThumbnailCreationError()); + emit(ThumbnailCreationError(isPaymentError: isTurboPaymentError(e))); } }); } diff --git a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart index 0d1882420..aebcd4e74 100644 --- a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart +++ b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart @@ -13,4 +13,10 @@ final class ThumbnailCreationLoading extends ThumbnailCreationState {} final class ThumbnailCreationSuccess extends ThumbnailCreationState {} -final class ThumbnailCreationError extends ThumbnailCreationState {} +final class ThumbnailCreationError extends ThumbnailCreationState { + final bool isPaymentError; + ThumbnailCreationError({this.isPaymentError = false}); + + @override + List get props => [isPaymentError]; +} diff --git a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart index a4e669922..548b2634b 100644 --- a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart +++ b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart'; import 'package:ardrive/pages/drive_detail/models/data_table_item.dart'; @@ -48,6 +49,13 @@ class _ThumbnailCreationModal extends StatelessWidget { if (state is ThumbnailCreationLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is ThumbnailCreationError) { + if (state.isPaymentError) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showTurboPaymentRequiredDialog(context); + }); + return Text( + appLocalizationsOf(context).freeAllowanceUsedUpDescription); + } return const Text( 'An error occurred while creating the thumbnail.'); } else if (state is ThumbnailCreationSuccess) { diff --git a/lib/manifest/domain/manifest_repository.dart b/lib/manifest/domain/manifest_repository.dart index 937a39982..9d5d94232 100644 --- a/lib/manifest/domain/manifest_repository.dart +++ b/lib/manifest/domain/manifest_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ardrive/arns/domain/arns_repository.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/blocs/create_manifest/create_manifest_cubit.dart'; import 'package:ardrive/core/arfs/repository/file_repository.dart'; import 'package:ardrive/core/arfs/repository/folder_repository.dart'; @@ -173,7 +174,16 @@ class ManifestRepositoryImpl implements ManifestRepository { completer.complete(manifestMetadata.dataTxId); }); - controller.onError((err) => completer.completeError(err)); + controller.onError((tasks) { + // Preserve payment rejections as a typed exception through the + // ManifestCreationException wrapping so the UI can react to them. + if (tasks.any((t) => isTurboPaymentError(t.error))) { + completer.completeError(UnderFundException( + message: 'Manifest upload requires payment', error: tasks)); + } else { + completer.completeError(tasks); + } + }); final result = await completer.future; diff --git a/lib/pages/drive_detail/components/bulk_import_modal.dart b/lib/pages/drive_detail/components/bulk_import_modal.dart index ff4fbdfb0..27d12f2c5 100644 --- a/lib/pages/drive_detail/components/bulk_import_modal.dart +++ b/lib/pages/drive_detail/components/bulk_import_modal.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/bulk_import/bulk_import_bloc.dart'; +import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/bulk_import/bulk_import_event.dart'; import 'package:ardrive/blocs/bulk_import/bulk_import_state.dart'; import 'package:ardrive/core/arfs/use_cases/bulk_import_files.dart'; @@ -138,6 +139,8 @@ class _BulkImportModalContentState extends State<_BulkImportModalContent> { ], ), ); + } else if (state is BulkImportError && state.isPaymentError) { + showTurboPaymentRequiredDialog(context); } else if (state is BulkImportError) { showArDriveDialog( context, diff --git a/lib/turbo/services/upload_service.dart b/lib/turbo/services/upload_service.dart index 5f81531a6..6279104d2 100644 --- a/lib/turbo/services/upload_service.dart +++ b/lib/turbo/services/upload_service.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:ardrive/utils/data_item_utils.dart'; import 'package:ardrive/utils/logger.dart'; import 'package:ardrive_http/ardrive_http.dart'; +import 'package:ardrive_uploader/ardrive_uploader.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; @@ -157,7 +158,23 @@ class TurboUploadService { /// payment reasons (free allowance exhausted / insufficient credits). Used by /// metadata-op blocs to choose payment-specific failure UX. bool isTurboPaymentError(Object? error) { - return error is TurboPaymentRequiredException; + // The app-side TurboUploadService throws TurboPaymentRequiredException on + // 402; the ardrive_uploader package throws UnderFundException (sometimes + // wrapped in an UploadStrategyException). Recognize all of them so every + // upload path — metadata ops AND file/folder/manifest uploads — maps a + // payment rejection to the same UX. + if (error is TurboPaymentRequiredException) return true; + if (error is UnderFundException) return true; + if (error is UploadStrategyException && error.error is UnderFundException) { + return true; + } + return false; +} + +/// Like [isTurboPaymentError] but inspects a collection of failed upload +/// tasks (the ardrive_uploader package reports failures as a task list). +bool anyTaskIsTurboPaymentError(Iterable taskErrors) { + return taskErrors.any(isTurboPaymentError); } /// Maps a turbo upload HTTP status code to a typed exception, or null for diff --git a/packages/ardrive_uploader/lib/ardrive_uploader.dart b/packages/ardrive_uploader/lib/ardrive_uploader.dart index 0c13db4b9..55759e072 100644 --- a/packages/ardrive_uploader/lib/ardrive_uploader.dart +++ b/packages/ardrive_uploader/lib/ardrive_uploader.dart @@ -6,5 +6,6 @@ export 'src/arfs_upload_metadata.dart'; export 'src/factories.dart'; export 'src/metadata_generator.dart'; export 'src/upload_controller.dart'; +export 'src/exceptions.dart'; export 'src/upload_strategy.dart'; export 'src/upload_task.dart'; From 853f55399d39802bb2d11bce45241dea41d57010 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Fri, 17 Jul 2026 14:07:07 -0400 Subject: [PATCH 08/19] fix: resolve analyzer issues from upload-surface coverage - hide the ardrive_uploader package's TurboUploadTimeoutException / TurboRateLimitException in upload_cubit (they collide with the app-side classes of the same name now that the package exports its exceptions); the app-side types are the ones _emitError intends - add missing app_localizations imports to assign_name and thumbnail creation modals - drop the unused shared-dialog import in upload_form (its failure widget builds the payment modal inline) - const the thumbnail error state constructors - remove now-redundant direct exceptions.dart imports in three ardrive_uploader files (the barrel provides them) Co-Authored-By: Claude Fable 5 --- lib/arns/presentation/assign_name_modal.dart | 1 + lib/blocs/upload/upload_cubit.dart | 3 ++- lib/components/upload_form.dart | 1 - .../bloc/multi_thumbnail_creation_state.dart | 2 +- .../thumbnail_creation/bloc/thumbnail_creation_state.dart | 2 +- .../thumbnail_creation/page/thumbnail_creation_modal.dart | 1 + packages/ardrive_uploader/lib/src/turbo_streamed_upload.dart | 1 - packages/ardrive_uploader/lib/src/upload_controller.dart | 1 - packages/ardrive_uploader/lib/src/upload_strategy.dart | 1 - 9 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/arns/presentation/assign_name_modal.dart b/lib/arns/presentation/assign_name_modal.dart index 4532dbfef..b12be13d2 100644 --- a/lib/arns/presentation/assign_name_modal.dart +++ b/lib/arns/presentation/assign_name_modal.dart @@ -1,6 +1,7 @@ // ignore_for_file: unnecessary_string_escapes, unused_element import 'package:ardrive/arns/domain/arns_repository.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/arns/presentation/assign_name_bloc/assign_name_bloc.dart'; import 'package:ardrive/arns/presentation/create_undername.dart'; diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 9a1b97979..b20013dcb 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -28,7 +28,8 @@ import 'package:ardrive/utils/plausible_event_tracker/plausible_custom_event_pro import 'package:ardrive/utils/plausible_event_tracker/plausible_event_tracker.dart'; import 'package:ardrive/utils/upload_plan_utils.dart'; import 'package:ardrive_io/ardrive_io.dart'; -import 'package:ardrive_uploader/ardrive_uploader.dart'; +import 'package:ardrive_uploader/ardrive_uploader.dart' + hide TurboUploadTimeoutException, TurboRateLimitException; import 'package:ario_sdk/ario_sdk.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index 0087b8a47..8fa84770a 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:ardrive/arns/domain/arns_repository.dart'; import 'package:ardrive/turbo/topup/views/topup_modal.dart'; -import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/arns/presentation/assign_name_modal.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart index 725f70976..12ae0ac32 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart @@ -71,7 +71,7 @@ final class MultiThumbnailCreationCancelled final class MultiThumbnailCreationError extends MultiThumbnailCreationState { final bool isPaymentError; - MultiThumbnailCreationError({this.isPaymentError = false}); + const MultiThumbnailCreationError({this.isPaymentError = false}); @override List get props => [isPaymentError]; diff --git a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart index aebcd4e74..25b09b155 100644 --- a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart +++ b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart @@ -15,7 +15,7 @@ final class ThumbnailCreationSuccess extends ThumbnailCreationState {} final class ThumbnailCreationError extends ThumbnailCreationState { final bool isPaymentError; - ThumbnailCreationError({this.isPaymentError = false}); + const ThumbnailCreationError({this.isPaymentError = false}); @override List get props => [isPaymentError]; diff --git a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart index 548b2634b..b719b779a 100644 --- a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart +++ b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart @@ -1,4 +1,5 @@ import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart'; diff --git a/packages/ardrive_uploader/lib/src/turbo_streamed_upload.dart b/packages/ardrive_uploader/lib/src/turbo_streamed_upload.dart index 5e5af91fb..909f1b2ec 100644 --- a/packages/ardrive_uploader/lib/src/turbo_streamed_upload.dart +++ b/packages/ardrive_uploader/lib/src/turbo_streamed_upload.dart @@ -1,5 +1,4 @@ import 'package:ardrive_uploader/ardrive_uploader.dart'; -import 'package:ardrive_uploader/src/exceptions.dart'; import 'package:ardrive_uploader/src/streamed_upload.dart'; import 'package:ardrive_uploader/src/turbo_upload_service.dart'; import 'package:ardrive_uploader/src/utils/logger.dart'; diff --git a/packages/ardrive_uploader/lib/src/upload_controller.dart b/packages/ardrive_uploader/lib/src/upload_controller.dart index ca9a61ba8..ec288ee4a 100644 --- a/packages/ardrive_uploader/lib/src/upload_controller.dart +++ b/packages/ardrive_uploader/lib/src/upload_controller.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:ardrive_uploader/src/exceptions.dart'; import 'package:ardrive_uploader/src/upload_dispatcher.dart'; import 'package:ardrive_uploader/src/utils/logger.dart'; import 'package:arweave/arweave.dart'; diff --git a/packages/ardrive_uploader/lib/src/upload_strategy.dart b/packages/ardrive_uploader/lib/src/upload_strategy.dart index 7c94851fd..7d7d20f5c 100644 --- a/packages/ardrive_uploader/lib/src/upload_strategy.dart +++ b/packages/ardrive_uploader/lib/src/upload_strategy.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:ardrive_uploader/ardrive_uploader.dart'; import 'package:ardrive_uploader/src/data_bundler.dart'; -import 'package:ardrive_uploader/src/exceptions.dart'; import 'package:ardrive_uploader/src/utils/data_bundler_utils.dart'; import 'package:ardrive_uploader/src/utils/logger.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; From 9b46403d8f6ce963368d362e665e9655fae731b4 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Fri, 17 Jul 2026 14:33:01 -0400 Subject: [PATCH 09/19] fix: close the two remaining payment-UX holes found in verification PE-9132 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification audit found my earlier thumbnail/bulk-import fixes were in the wrong place — the failure never reached the bloc catch I'd wired. - thumbnail_repository: the upload controller's onError only logged and never completed the completer, so a 402 (which fires onError, not onDone) hung single AND multi thumbnail creation in Loading forever. onError now errors the completer, unwrapping the task list via anyTaskIsTurboPaymentError (previously dead) into the typed exception so the blocs classify it. The onDone body (which posts the thumbnail metadata data item) is also guarded so a payment rejection there errors the completer instead of hanging. - bulk_import_bloc: the actual import-execution catch swallowed the error and emitted a const BulkImportError (isPaymentError always false), so a 402 during real bulk import showed a generic dialog. The error is now captured and classified via _isBulkImportPaymentError (unwrapping FileMetadataUploadException.originalError). Co-Authored-By: Claude Fable 5 --- lib/blocs/bulk_import/bulk_import_bloc.dart | 13 +++++++++-- .../repository/thumbnail_repository.dart | 22 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lib/blocs/bulk_import/bulk_import_bloc.dart b/lib/blocs/bulk_import/bulk_import_bloc.dart index 80cdf669f..38ca05a8a 100644 --- a/lib/blocs/bulk_import/bulk_import_bloc.dart +++ b/lib/blocs/bulk_import/bulk_import_bloc.dart @@ -189,6 +189,7 @@ class BulkImportBloc extends Bloc { }) async { var processedFiles = 0; final failedPaths = []; + Object? lastImportError; try { emit(BulkImportInProgress( @@ -251,6 +252,7 @@ class BulkImportBloc extends Bloc { } catch (e) { failedPaths.add(files.first.path); logger.e('Failed to import file: ${files.first.path}', e); + lastImportError = e; } final totalFiles = files.length; @@ -258,8 +260,15 @@ class BulkImportBloc extends Bloc { final failedFiles = failedPaths; if (successfulFiles == 0) { - emit(const BulkImportError( - 'Failed to import any files. Please check the manifest and try again.', + final paymentError = _isBulkImportPaymentError(lastImportError); + emit(BulkImportError( + paymentError + ? 'Your free upload allowance has been used up. Add Credits to ' + 'continue importing.' + : 'Failed to import any files. Please check the manifest and ' + 'try again.', + lastImportError, + paymentError, )); } else { emit(BulkImportSuccess( diff --git a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart index c13043ed2..5b10342b9 100644 --- a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart +++ b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart @@ -179,9 +179,20 @@ class ThumbnailRepository { Completer completer = Completer(); - controller.onError((error) { - logger.e('Error uploading thumbnail on upload controller', error, + controller.onError((tasks) { + logger.e('Error uploading thumbnail on upload controller', tasks, StackTrace.current); + // The controller reports failures as a task list and does NOT call + // onDone, so the completer must be errored here or the awaiting bloc + // hangs forever. Surface payment rejections as the typed exception so + // the bloc classifies them. + if (!completer.isCompleted) { + completer.completeError( + anyTaskIsTurboPaymentError(tasks.map((t) => t.error)) + ? TurboPaymentRequiredException() + : Exception('Thumbnail upload failed'), + ); + } }); controller.onDone((tasks) async { @@ -220,6 +231,13 @@ class ThumbnailRepository { performedAction: RevisionAction.createThumbnail)); completer.complete(); + }).catchError((Object e) { + // A failure while posting the thumbnail metadata (e.g. a payment + // rejection) must error the completer, not hang the awaiting bloc. + logger.e('Error finalizing thumbnail upload', e); + if (!completer.isCompleted) { + completer.completeError(e); + } }); }); From db0443cb0af9a23a1abd795aad94fdb510b26093 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Fri, 17 Jul 2026 14:58:29 -0400 Subject: [PATCH 10/19] fix: surface payment errors swallowed by the shared WorkerPool PE-9132 Final verification found multi-thumbnail and bulk import still swallowed 402s: the ardrive_utils WorkerPool caught task exceptions in Worker._execute and passed only the task (not the exception) to onWorkerError, so the pool completed normally and the awaiting bloc never saw the failure. - WorkerPool.onWorkerError now receives the exception (Function(T, Object)) - multi thumbnail: onWorkerError captures a payment rejection into a flag and, after onAllTasksCompleted, emits MultiThumbnailCreationError( isPaymentError: true) instead of reporting completion - bulk import: FileImportFailure now preserves originalError; the worker records failures into BulkImportResult (was: only logged); the bloc captures the result and classifies the terminal error from the failures' originalError as well as the outer catch - fixes a scope bug: importResult is declared before the try so it is visible in the post-catch classification This closes the last two surfaces; single-thumbnail and the other seven were already verified. Co-Authored-By: Claude Fable 5 --- lib/blocs/bulk_import/bulk_import_bloc.dart | 8 ++++++-- lib/core/arfs/use_cases/bulk_import_files.dart | 15 +++++++++++++-- .../bloc/multi_thumbnail_creation_bloc.dart | 18 ++++++++++++++++-- packages/ardrive_utils/lib/src/worker.dart | 4 ++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/lib/blocs/bulk_import/bulk_import_bloc.dart b/lib/blocs/bulk_import/bulk_import_bloc.dart index 38ca05a8a..687f6ba10 100644 --- a/lib/blocs/bulk_import/bulk_import_bloc.dart +++ b/lib/blocs/bulk_import/bulk_import_bloc.dart @@ -190,6 +190,7 @@ class BulkImportBloc extends Bloc { var processedFiles = 0; final failedPaths = []; Object? lastImportError; + BulkImportResult? importResult; try { emit(BulkImportInProgress( @@ -209,7 +210,7 @@ class BulkImportBloc extends Bloc { return; } - await _bulkImportFiles( + importResult = await _bulkImportFiles( driveId: driveId, parentFolderId: parentFolderId, files: files, @@ -260,7 +261,10 @@ class BulkImportBloc extends Bloc { final failedFiles = failedPaths; if (successfulFiles == 0) { - final paymentError = _isBulkImportPaymentError(lastImportError); + final paymentError = _isBulkImportPaymentError(lastImportError) || + (importResult?.failures.any( + (f) => _isBulkImportPaymentError(f.originalError)) ?? + false); emit(BulkImportError( paymentError ? 'Your free upload allowance has been used up. Add Credits to ' diff --git a/lib/core/arfs/use_cases/bulk_import_files.dart b/lib/core/arfs/use_cases/bulk_import_files.dart index 1e337e808..caafa1384 100644 --- a/lib/core/arfs/use_cases/bulk_import_files.dart +++ b/lib/core/arfs/use_cases/bulk_import_files.dart @@ -418,8 +418,18 @@ class BulkImportFiles { ? 1 : 5, taskQueue: fileEntries, - onWorkerError: (e) { - logger.e('Bulk import worker error', e, StackTrace.current); + onWorkerError: (file, error) { + logger.e('Bulk import worker error', error, StackTrace.current); + // Record the failure so BulkImportResult carries it (with its + // originalError) — the pool otherwise swallows task exceptions. + failures.add(error is FileImportFailure + ? error + : FileImportFailure( + path: file.name ?? '', + dataTxId: file.dataTxId ?? '', + error: error.toString(), + originalError: error, + )); }, execute: (file) async { if (_isCancelled) { @@ -529,6 +539,7 @@ class BulkImportFiles { path: fileName, dataTxId: dataTxId, error: 'Failed to upload metadata: ${e.toString()}', + originalError: e, ); } fileEntity.txId = metadataUploadResult.metadataTxId; diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart index ebed70d1c..5b2ab3004 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart @@ -13,6 +13,8 @@ part 'multi_thumbnail_creation_state.dart'; class MultiThumbnailCreationBloc extends Bloc { + bool _thumbnailPaymentError = false; + final DriveRepository _driveRepository; final ThumbnailRepository _thumbnailRepository; @@ -134,6 +136,7 @@ class MultiThumbnailCreationBloc int loadedCount = 0; + _thumbnailPaymentError = false; _worker = WorkerPool( numWorkers: drive.isPrivate ? 1 : 2, maxTasksPerWorker: 2, @@ -166,13 +169,24 @@ class MultiThumbnailCreationBloc emit: emit, ); }, - onWorkerError: (thumbnail) { - logger.d('Error creating thumbnail for file ${thumbnail.file.id}'); + onWorkerError: (thumbnail, error) { + logger.d('Error creating thumbnail for file ' + '${thumbnail.file.id}: $error'); + // The pool completes normally even on task errors, so capture a + // payment rejection here to surface it after completion. + if (isTurboPaymentError(error)) { + _thumbnailPaymentError = true; + } }, ); await _worker?.onAllTasksCompleted; + if (_thumbnailPaymentError) { + emit(MultiThumbnailCreationError(isPaymentError: true)); + return; + } + loadedDrives++; } diff --git a/packages/ardrive_utils/lib/src/worker.dart b/packages/ardrive_utils/lib/src/worker.dart index 914625b3b..ecb50a0c9 100644 --- a/packages/ardrive_utils/lib/src/worker.dart +++ b/packages/ardrive_utils/lib/src/worker.dart @@ -48,7 +48,7 @@ class WorkerPool { final List taskQueue; late List> workers; final Function(T) execute; - final Function(T) onWorkerError; + final Function(T, Object) onWorkerError; final Completer _completer = Completer(); int _totalTasks = 0; int _completedTasks = 0; @@ -69,7 +69,7 @@ class WorkerPool { workers = List>.generate(numWorkers, (i) { final worker = Worker( execute: execute, - onError: (task, exception) => onWorkerError(task), + onError: (task, exception) => onWorkerError(task, exception), maxTasks: maxTasksPerWorker, onTaskCompleted: (task) { if (_isCanceled) { From 2ab0dc3ad9bfbc8442d9bbd6fbaa89ba0019e02a Mon Sep 17 00:00:00 2001 From: vilenarios Date: Fri, 17 Jul 2026 15:06:35 -0400 Subject: [PATCH 11/19] nit: const MultiThumbnailCreationError emit Co-Authored-By: Claude Fable 5 --- .../bloc/multi_thumbnail_creation_bloc.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart index 5b2ab3004..0572aff91 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart @@ -183,7 +183,7 @@ class MultiThumbnailCreationBloc await _worker?.onAllTasksCompleted; if (_thumbnailPaymentError) { - emit(MultiThumbnailCreationError(isPaymentError: true)); + emit(const MultiThumbnailCreationError(isPaymentError: true)); return; } From 58b560e94cd0c6ab1c011be72eaadbeef0d558a6 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Fri, 17 Jul 2026 17:36:53 -0400 Subject: [PATCH 12/19] feat: config-based direct-to-network fallback for private-drive migration PE-9132 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private-drive migration was the one ArFS metadata op with no L1 fallback — it posted the drive-signature data item only via Turbo. It now uses the same config-based branch every other op has: post via Turbo when useTurboUpload is enabled, otherwise wrap the already-signed data item in a DataBundle and post directly to the network via ArweaveService.postTx (pays AR from the wallet). Closes the deferred migration item from the free-tier work. Note this is a config switch (useTurboUpload=false), not an automatic on-402 fallback; migration signature items are ~1 KB and effectively always free-eligible, so pool exhaustion here is negligible. Login wallet-creation verification posts are intentionally NOT given this branch: the wallet is brand-new with no AR during creation, and the ETH path involves cross-chain signing — an L1 fallback there would fail, not help. Co-Authored-By: Claude Fable 5 --- lib/pages/app_router_delegate.dart | 1 + .../private_drive_migration_bloc.dart | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/pages/app_router_delegate.dart b/lib/pages/app_router_delegate.dart index bf1cdb7ff..54fcaef38 100644 --- a/lib/pages/app_router_delegate.dart +++ b/lib/pages/app_router_delegate.dart @@ -322,6 +322,7 @@ class AppRouterDelegate extends RouterDelegate ardriveAuth: context.read(), crypto: ArDriveCrypto(), turboUploadService: context.read(), + arweave: context.read(), ), ), ], diff --git a/lib/shared/blocs/private_drive_migration/private_drive_migration_bloc.dart b/lib/shared/blocs/private_drive_migration/private_drive_migration_bloc.dart index 168c16454..4a944aafc 100644 --- a/lib/shared/blocs/private_drive_migration/private_drive_migration_bloc.dart +++ b/lib/shared/blocs/private_drive_migration/private_drive_migration_bloc.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/services/arweave/arweave_service.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/entities/drive_signature.dart'; @@ -30,6 +31,7 @@ class PrivateDriveMigrationBloc final ArDriveAuth ardriveAuth; final ArDriveCrypto crypto; final TurboUploadService turboUploadService; + final ArweaveService arweave; List drivesRequiringMigration = []; Set completedMigration = {}; @@ -40,6 +42,7 @@ class PrivateDriveMigrationBloc required this.ardriveAuth, required this.crypto, required this.turboUploadService, + required this.arweave, }) : super(PrivateDriveMigrationHidden()) { _drivesSubscription = drivesCubit.stream.listen((state) { if (state is DrivesLoadSuccess) { @@ -130,11 +133,23 @@ class PrivateDriveMigrationBloc await driveSignatureDataItem.sign(ArweaveSigner(wallet)); - // upload via turbo - await turboUploadService.postDataItem( - dataItem: driveSignatureDataItem, - wallet: wallet, - ); + // Post via Turbo (free/credits) when enabled, otherwise directly to + // the network (pays AR from the wallet) — same config-based branch + // every other ArFS metadata op uses. + if (turboUploadService.useTurboUpload) { + await turboUploadService.postDataItem( + dataItem: driveSignatureDataItem, + wallet: wallet, + ); + } else { + final tx = await arweave.prepareDataBundleTx( + await DataBundle.fromDataItems( + items: [driveSignatureDataItem], + ), + wallet, + ); + await arweave.postTx(tx); + } // comment upload above and uncomment await below for dev testing // await Future.delayed(const Duration(seconds: 1)); From b5fb639a283c89de23d2003c800bbc843c2cbfd9 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Fri, 17 Jul 2026 18:00:33 -0400 Subject: [PATCH 13/19] fix: trigger payment dialog from BlocConsumer listener, not builder PE-9132 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (Critical/Major) caught the payment dialog being triggered from inside the builder via addPostFrameCallback in five modals. A builder can run many times for the same state (repaints, resizes, ancestor rebuilds), each queuing another dialog → duplicate/stacked modals. Moved every trigger to the BlocConsumer listener, which fires once per state transition: snapshot, single + multi thumbnail, standalone ArNS assign, and manifest (the last two CodeRabbit didn't flag but had the identical bug). Builders now render only the static fallback content. Co-Authored-By: Claude Fable 5 --- lib/arns/presentation/assign_name_modal.dart | 8 +++----- lib/components/create_manifest_form.dart | 12 ++++++------ lib/components/create_snapshot_dialog.dart | 10 ++++------ .../multi_thumbnail_creation_modal.dart | 10 ++++++---- .../page/thumbnail_creation_modal.dart | 6 +++--- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/lib/arns/presentation/assign_name_modal.dart b/lib/arns/presentation/assign_name_modal.dart index b12be13d2..e3afb5e41 100644 --- a/lib/arns/presentation/assign_name_modal.dart +++ b/lib/arns/presentation/assign_name_modal.dart @@ -126,6 +126,9 @@ class _AssignArNSNameModalState extends State<_AssignArNSNameModal> { return BlocConsumer( listener: (previous, current) { + if (current is SelectionFailed && current.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } if (current is NameAssignedWithSuccess) { showArDriveDialog( context, @@ -345,11 +348,6 @@ class _AssignArNSNameModalState extends State<_AssignArNSNameModal> { } else if (state is SelectionFailed) { final colorTokens = ArDriveTheme.of(context).themeData.colorTokens; - if (state.isPaymentError) { - WidgetsBinding.instance.addPostFrameCallback((_) { - showTurboPaymentRequiredDialog(context); - }); - } return Center( child: Text( state.isPaymentError diff --git a/lib/components/create_manifest_form.dart b/lib/components/create_manifest_form.dart index 4ab053178..f20b2de75 100644 --- a/lib/components/create_manifest_form.dart +++ b/lib/components/create_manifest_form.dart @@ -133,6 +133,9 @@ class _CreateManifestFormState extends State { listener: (context, state) { if (state is CreateManifestPrivacyMismatch) { Navigator.pop(context); + } else if (state is CreateManifestFailure && state.isPaymentError) { + Navigator.pop(context); + showTurboPaymentRequiredDialog(context); } }, builder: (context, state) { final textStyle = typography.paragraphNormal( @@ -170,14 +173,11 @@ class _CreateManifestFormState extends State { errorText: appLocalizationsOf(context).walletChangedDuringManifestCreation, ); + } else if (state is CreateManifestFailure && state.isPaymentError) { + // Pop + payment dialog handled in the listener; render nothing. + return const SizedBox.shrink(); } else if (state is CreateManifestFailure) { Navigator.pop(context); - if (state.isPaymentError) { - WidgetsBinding.instance.addPostFrameCallback((_) { - showTurboPaymentRequiredDialog(context); - }); - return const SizedBox.shrink(); - } return errorDialog( errorText: appLocalizationsOf(context).manifestTransactionUnexpectedlyFailed, diff --git a/lib/components/create_snapshot_dialog.dart b/lib/components/create_snapshot_dialog.dart index 60aebf6e0..a945c3cdc 100644 --- a/lib/components/create_snapshot_dialog.dart +++ b/lib/components/create_snapshot_dialog.dart @@ -83,6 +83,8 @@ class CreateSnapshotDialog extends StatelessWidget { /// txsSyncedWithGqlCount: state.notSnapshottedTxsCount, ), ); + } else if (state is SnapshotUploadFailure && state.isPaymentError) { + showTurboPaymentRequiredDialog(context); } }, builder: (context, state) { @@ -94,12 +96,8 @@ class CreateSnapshotDialog extends StatelessWidget { return _loadingDialog(context, state); } else if (state is SnapshotUploadSuccess) { return _successDialog(context, drive.name); - } else if (state is SnapshotUploadFailure && - state.isPaymentError) { - // Defer to post-frame so the dialog can be shown over this one. - WidgetsBinding.instance.addPostFrameCallback((_) { - showTurboPaymentRequiredDialog(context); - }); + } else if (state is SnapshotUploadFailure && state.isPaymentError) { + // The payment dialog is shown from the listener; render nothing. return const SizedBox.shrink(); } else if (state is SnapshotUploadFailure || state is ComputeSnapshotDataFailure) { diff --git a/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart index a9159ae6c..0da7d9a29 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart @@ -108,7 +108,11 @@ class _MultiThumbnailCreationModalContentState return BlocConsumer( bloc: widget.bloc, - listener: (context, state) {}, + listener: (context, state) { + if (state is MultiThumbnailCreationError && state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } + }, builder: (context, state) { final typography = ArDriveTypographyNew.of(context); @@ -162,9 +166,7 @@ class _MultiThumbnailCreationModalContentState } if (state is MultiThumbnailCreationError && state.isPaymentError) { - WidgetsBinding.instance.addPostFrameCallback((_) { - showTurboPaymentRequiredDialog(context); - }); + // Payment dialog shown from the listener; render nothing. return const SizedBox.shrink(); } diff --git a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart index b719b779a..1178ef111 100644 --- a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart +++ b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart @@ -44,6 +44,8 @@ class _ThumbnailCreationModal extends StatelessWidget { if (state is ThumbnailCreationSuccess) { context.read().refreshDriveDataTable(); Navigator.of(context).pop(); + } else if (state is ThumbnailCreationError && state.isPaymentError) { + showTurboPaymentRequiredDialog(context); } }, builder: (context, state) { @@ -51,9 +53,7 @@ class _ThumbnailCreationModal extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } else if (state is ThumbnailCreationError) { if (state.isPaymentError) { - WidgetsBinding.instance.addPostFrameCallback((_) { - showTurboPaymentRequiredDialog(context); - }); + // The payment dialog is shown from the listener. return Text( appLocalizationsOf(context).freeAllowanceUsedUpDescription); } From 6fa15eed510a13114044f496ccf2ac515e8140f4 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Wed, 22 Jul 2026 18:43:10 -0400 Subject: [PATCH 14/19] feat: only promise a free upload when the wallet allowance covers it PE-9132 Turbo's new GET /v1/account/free?address= reports how many free bytes a wallet has left. Until now isFreeThanksToTurbo was derived purely from item size, so a user whose free pool was used up was shown "this transaction is free thanks to Turbo", had the payment method selector hidden, and then hit a 402 mid-upload. The 402 handling recovered gracefully but the promise should never have been made. - add TurboFreeAllowance, modelling unlimited / limited / disabled / unknown, plus covers() and isExhaustedFor() - add PaymentService.getFreeAllowance and a non-throwing TurboBalanceRetriever.getFreeAllowance wrapper - require both size eligibility and allowance coverage before treating an upload as free, in the entity, bundle and snapshot paths - explain the switch to a payment selector when the allowance ran out, instead of silently swapping the UI - fetch the allowance per preparation, unlike the static item-size limit The value is advisory: Turbo's response stays the authority on whether an upload was free, and an unreachable endpoint falls back to the previous size-only behaviour rather than telling a user with allowance left to pay. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PsV2WFHZMGD9DUfX65cxuW --- .../create_snapshot_cubit.dart | 32 +++-- .../create_snapshot_state.dart | 9 ++ .../upload/models/payment_method_info.dart | 11 ++ .../bloc/upload_payment_method_bloc.dart | 2 + lib/components/create_snapshot_dialog.dart | 15 ++ lib/components/upload_form.dart | 26 +++- lib/core/upload/uploader.dart | 66 +++++++-- lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_zh-HK.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/turbo/models/turbo_free_allowance.dart | 104 ++++++++++++++ lib/turbo/services/payment_service.dart | 19 +++ lib/turbo/turbo.dart | 19 +++ test/blocs/create_snapshot_cubit_test.dart | 6 + test/core/upload/uploader_test.dart | 107 ++++++++++++++ .../models/turbo_free_allowance_test.dart | 136 ++++++++++++++++++ 19 files changed, 538 insertions(+), 23 deletions(-) create mode 100644 lib/turbo/models/turbo_free_allowance.dart create mode 100644 test/turbo/models/turbo_free_allowance_test.dart diff --git a/lib/blocs/create_snapshot/create_snapshot_cubit.dart b/lib/blocs/create_snapshot/create_snapshot_cubit.dart index 6b23e1186..8fdde2598 100644 --- a/lib/blocs/create_snapshot/create_snapshot_cubit.dart +++ b/lib/blocs/create_snapshot/create_snapshot_cubit.dart @@ -75,6 +75,7 @@ class CreateSnapshotCubit extends Cubit { bool _sufficentCreditsBalance = false; bool _sufficientArBalance = false; bool _isFreeThanksToTurbo = false; + bool _isFreeAllowanceExhausted = false; bool _wasSnapshotDataComputingCanceled = false; bool get _useTurboUpload => @@ -154,7 +155,7 @@ class CreateSnapshotCubit extends Cubit { await _computeBalanceEstimate(); _computeIsSufficientBalance(); _computeIsTurboEnabled(); - _computeIsFreeThanksToTurbo(); + await _computeIsFreeThanksToTurbo(); _computeIsButtonEnabled(); logger.d('Computed cost and balance estimate'); @@ -173,6 +174,7 @@ class CreateSnapshotCubit extends Cubit { sufficientBalanceToPayWithAr: _sufficientArBalance, sufficientBalanceToPayWithTurbo: _sufficentCreditsBalance, isFreeThanksToTurbo: _isFreeThanksToTurbo, + isFreeAllowanceExhausted: _isFreeAllowanceExhausted, ), ); } catch (e) { @@ -203,10 +205,8 @@ class CreateSnapshotCubit extends Cubit { _wasSnapshotDataComputingCanceled = false; // Cache drive privacy once to avoid N+1 DB queries during metadata fetch - final drive = - await _driveDao.driveById(driveId: driveId).getSingleOrNull(); - _isPrivateDrive = - drive != null && drive.privacy != DrivePrivacyTag.public; + final drive = await _driveDao.driveById(driveId: driveId).getSingleOrNull(); + _isPrivateDrive = drive != null && drive.privacy != DrivePrivacyTag.public; // Clear MetadataCache so it's refreshed on next use (lazy init in // _jsonMetadataOfTxId avoids shared_preferences plugin in tests) @@ -548,11 +548,25 @@ class CreateSnapshotCubit extends Cubit { _sufficentCreditsBalance = sufficientBalanceToPayWithTurbo; } - void _computeIsFreeThanksToTurbo() { + Future _computeIsFreeThanksToTurbo() async { final allowedDataItemSizeForTurbo = appConfig.allowedDataItemSizeForTurbo; - final isFreeThanksToTurbo = - _snapshotEntity!.data!.length <= allowedDataItemSizeForTurbo; - _isFreeThanksToTurbo = isFreeThanksToTurbo; + final snapshotSize = _snapshotEntity!.data!.length; + final isSizeEligibleForFree = snapshotSize <= allowedDataItemSizeForTurbo; + + if (!isSizeEligibleForFree) { + _isFreeThanksToTurbo = false; + _isFreeAllowanceExhausted = false; + return; + } + + /// Being small enough is not sufficient: the wallet's free allowance has + /// to cover it too, or Turbo rejects the upload with a 402 after we have + /// already told the user it was free. + final freeAllowance = + await turboBalanceRetriever.getFreeAllowance(auth.currentUser.wallet); + + _isFreeThanksToTurbo = freeAllowance.covers(snapshotSize); + _isFreeAllowanceExhausted = freeAllowance.isExhaustedFor(snapshotSize); } void setUploadMethod(UploadMethod method) { diff --git a/lib/blocs/create_snapshot/create_snapshot_state.dart b/lib/blocs/create_snapshot/create_snapshot_state.dart index f921532d9..db08ec8b3 100644 --- a/lib/blocs/create_snapshot/create_snapshot_state.dart +++ b/lib/blocs/create_snapshot/create_snapshot_state.dart @@ -72,6 +72,10 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { final bool sufficientBalanceToPayWithTurbo; final bool isFreeThanksToTurbo; + /// Small enough to be free, but the wallet's free allowance is known to + /// be used up. False when the allowance could not be determined. + final bool isFreeAllowanceExhausted; + ConfirmingSnapshotCreation({ required this.snapshotSize, required this.costEstimateAr, @@ -85,6 +89,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { required this.sufficientBalanceToPayWithAr, required this.sufficientBalanceToPayWithTurbo, required this.isFreeThanksToTurbo, + this.isFreeAllowanceExhausted = false, }); @override @@ -101,6 +106,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { sufficientBalanceToPayWithAr, sufficientBalanceToPayWithTurbo, isFreeThanksToTurbo, + isFreeAllowanceExhausted, ]; ConfirmingSnapshotCreation copyWith({ @@ -118,6 +124,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { bool? sufficientBalanceToPayWithAr, bool? sufficientBalanceToPayWithTurbo, bool? isFreeThanksToTurbo, + bool? isFreeAllowanceExhausted, }) { return ConfirmingSnapshotCreation( snapshotSize: snapshotSize ?? this.snapshotSize, @@ -136,6 +143,8 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { sufficientBalanceToPayWithTurbo: sufficientBalanceToPayWithTurbo ?? this.sufficientBalanceToPayWithTurbo, isFreeThanksToTurbo: isFreeThanksToTurbo ?? this.isFreeThanksToTurbo, + isFreeAllowanceExhausted: + isFreeAllowanceExhausted ?? this.isFreeAllowanceExhausted, ); } } diff --git a/lib/blocs/upload/models/payment_method_info.dart b/lib/blocs/upload/models/payment_method_info.dart index a37d0bca0..b98870081 100644 --- a/lib/blocs/upload/models/payment_method_info.dart +++ b/lib/blocs/upload/models/payment_method_info.dart @@ -14,6 +14,12 @@ class UploadPaymentMethodInfo extends Equatable { final String turboCredits; final bool sufficentCreditsBalance; final bool isFreeThanksToTurbo; + + /// This upload qualifies for the free tier on size, but the wallet's free + /// allowance is known to be used up — so it needs Credits or AR after all. + /// False when the allowance could not be determined, so an unreachable + /// endpoint never tells the user they ran out. + final bool isFreeAllowanceExhausted; final UploadPlan? uploadPlanForAR; final UploadPlan? uploadPlanForTurbo; final int totalSize; @@ -30,6 +36,7 @@ class UploadPaymentMethodInfo extends Equatable { required this.turboCredits, required this.sufficentCreditsBalance, required this.isFreeThanksToTurbo, + this.isFreeAllowanceExhausted = false, this.uploadPlanForAR, this.uploadPlanForTurbo, required this.totalSize, @@ -48,6 +55,7 @@ class UploadPaymentMethodInfo extends Equatable { String? turboCredits, bool? sufficentCreditsBalance, bool? isFreeThanksToTurbo, + bool? isFreeAllowanceExhausted, UploadPlan? uploadPlanForAR, UploadPlan? uploadPlanForTurbo, int? totalSize, @@ -69,6 +77,8 @@ class UploadPaymentMethodInfo extends Equatable { sufficentCreditsBalance: sufficentCreditsBalance ?? this.sufficentCreditsBalance, isFreeThanksToTurbo: isFreeThanksToTurbo ?? this.isFreeThanksToTurbo, + isFreeAllowanceExhausted: + isFreeAllowanceExhausted ?? this.isFreeAllowanceExhausted, paidBy: paidBy ?? this.paidBy, ); } @@ -85,6 +95,7 @@ class UploadPaymentMethodInfo extends Equatable { turboCredits, sufficentCreditsBalance, isFreeThanksToTurbo, + isFreeAllowanceExhausted, paidBy, ]; } diff --git a/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart b/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart index e7b9cad98..6d42cd8a5 100644 --- a/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart +++ b/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart @@ -80,6 +80,8 @@ class UploadPaymentMethodBloc hasNoTurboBalance: isTurboZeroBalance, isFreeThanksToTurbo: uploadPreparation .uploadPaymentInfo.isFreeUploadPossibleUsingTurbo, + isFreeAllowanceExhausted: + uploadPreparation.uploadPaymentInfo.isFreeAllowanceExhausted, isTurboUploadPossible: paymentInfo.isUploadEligibleToTurbo, sufficentCreditsBalance: _canUploadWithMethod(UploadMethod.turbo), sufficientArBalance: _canUploadWithMethod(UploadMethod.ar), diff --git a/lib/components/create_snapshot_dialog.dart b/lib/components/create_snapshot_dialog.dart index a945c3cdc..6cc75bf02 100644 --- a/lib/components/create_snapshot_dialog.dart +++ b/lib/components/create_snapshot_dialog.dart @@ -445,6 +445,21 @@ Widget _confirmDialog( ), ), } else ...{ + // Would have been free on size, but the allowance ran + // out — explain the switch to a payment selector. + if (state.isFreeAllowanceExhausted) ...{ + Text( + appLocalizationsOf(context) + .freeAllowanceUsedUpUploadNote, + style: typography.paragraphNormal( + color: ArDriveTheme.of(context) + .themeData + .colors + .themeFgDefault, + ), + ), + const SizedBox(height: 12), + }, PaymentMethodSelector( uploadMethodInfo: UploadPaymentMethodInfo( uploadMethod: state.uploadMethod, diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index 8fa84770a..1b3d7b7d9 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -70,7 +70,7 @@ Future promptToUpload( bool autoReplaceConflicts = false, }) async { final driveDetailCubit = context.read(); - final manifestRepository = ManifestRepositoryImpl( + final manifestRepository = ManifestRepositoryImpl( context.read(), ArDriveUploader( turboUploadUri: Uri.parse(configService.config.defaultTurboUploadUrl!), @@ -1256,8 +1256,8 @@ class _UploadReadyModalState extends State { context.read(), context.read(), )..add(PrepareUploadPaymentMethod( - params: state.params, - )), + params: state.params, + )), child: UploadPaymentMethodView( useDropdown: true, onError: () { @@ -1266,7 +1266,9 @@ class _UploadReadyModalState extends State { .emitErrorFromPreparation(); }, onTurboTopupSucess: () { - context.read().startUploadPreparation( + context + .read() + .startUploadPreparation( isRetryingToPayWithTurbo: true, ); }, @@ -1947,6 +1949,19 @@ class _UploadReadyWidget extends StatelessWidget { const SizedBox(height: 20), ], if (!state.paymentInfo.isFreeThanksToTurbo) ...[ + // Small enough to have been free, but the allowance ran out. + // Say so explicitly: without this the modal silently switches + // from "free" to a payment selector with no explanation. + if (state.paymentInfo.isFreeAllowanceExhausted) ...[ + const SizedBox(height: 8), + Text( + appLocalizationsOf(context).freeAllowanceUsedUpUploadNote, + style: typography.paragraphNormal( + color: colorTokens.textMid, + ), + ), + const SizedBox(height: 12), + ], RepositoryProvider.value( value: context.read(), child: UploadPaymentMethodView( @@ -2383,8 +2398,7 @@ class _UploadFailureWidget extends StatelessWidget { // the top-up flow instead of offering a re-upload that would 402 again. return ArDriveStandardModalNew( title: appLocalizationsOf(context).freeAllowanceUsedUpTitle, - description: - appLocalizationsOf(context).freeAllowanceUsedUpDescription, + description: appLocalizationsOf(context).freeAllowanceUsedUpDescription, actions: [ ModalAction( action: () => Navigator.of(context).pop(false), diff --git a/lib/core/upload/uploader.dart b/lib/core/upload/uploader.dart index a337269a6..1e56dfc61 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -11,6 +11,7 @@ import 'package:ardrive/entities/constants.dart'; import 'package:ardrive/models/database/database.dart'; import 'package:ardrive/services/arweave/arweave.dart'; import 'package:ardrive/services/config/app_config.dart'; +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/turbo/turbo.dart'; import 'package:ardrive/user/user.dart'; @@ -377,8 +378,7 @@ class UploadPaymentEvaluator { /// show Turbo as an option instead of crashing entirely. UploadCostEstimate arCostEstimate; try { - arCostEstimate = - await _uploadCostEstimateCalculatorForAR.calculateCost( + arCostEstimate = await _uploadCostEstimateCalculatorForAR.calculateCost( totalSize: dataItemSize, ); } catch (e) { @@ -388,14 +388,21 @@ class UploadPaymentEvaluator { final allowedDataItemSizeForTurbo = _maxFreeItemBytes; + /// An item is free only if it is both small enough to qualify AND the + /// wallet still has free allowance to cover it. Size alone would promise + /// "free" to a user whose pool is used up, and then fail with a 402. + final isSizeEligibleForFree = dataItemSize <= allowedDataItemSizeForTurbo; + final freeAllowance = await _getFreeAllowance(canUseTurbo: _canUseTurbo); + bool isFreeUploadPossibleUsingTurbo = - dataItem.getSize() <= allowedDataItemSizeForTurbo; + isSizeEligibleForFree && freeAllowance.covers(dataItemSize); uploadMethod = await _determineUploadMethod( turboBalance.balance, dataItemSize, dataItemSize, _isTurboAvailableToUploadAllFiles, + freeAllowance, ); return UploadPaymentInfo( @@ -405,6 +412,9 @@ class UploadPaymentEvaluator { arCostEstimate: arCostEstimate, turboCostEstimate: turboCostEstimate, isFreeUploadPossibleUsingTurbo: isFreeUploadPossibleUsingTurbo, + isSizeEligibleForFree: isSizeEligibleForFree, + isFreeAllowanceExhausted: + isSizeEligibleForFree && freeAllowance.isExhaustedFor(dataItemSize), totalSize: totalSize, turboBalance: turboBalance, ); @@ -456,8 +466,7 @@ class UploadPaymentEvaluator { /// show Turbo as an option instead of crashing entirely. UploadCostEstimate arCostEstimate; try { - arCostEstimate = - await _uploadCostEstimateCalculatorForAR.calculateCost( + arCostEstimate = await _uploadCostEstimateCalculatorForAR.calculateCost( totalSize: arBundleSizes + arFileSizes, ); } catch (e) { @@ -465,17 +474,25 @@ class UploadPaymentEvaluator { arCostEstimate = UploadCostEstimate.zero(); } + bool isSizeEligibleForFree = false; bool isFreeUploadPossibleUsingTurbo = false; + final freeAllowance = await _getFreeAllowance(canUseTurbo: _canUseTurbo); + if (isUploadEligibleToTurbo) { final allowedDataItemSizeForTurbo = _maxFreeItemBytes; - isFreeUploadPossibleUsingTurbo = - uploadPlanForTurbo.bundleUploadHandles.every( + isSizeEligibleForFree = uploadPlanForTurbo.bundleUploadHandles.every( (bundle) => bundle.fileDataItemUploadHandles.every( (file) => file.size <= allowedDataItemSizeForTurbo, ), ); + + /// Every item being small enough is not sufficient — the wallet's free + /// pool has to cover the whole upload too. Turbo bills the entire upload + /// once the pool runs out, so partial coverage is not free either. + isFreeUploadPossibleUsingTurbo = + isSizeEligibleForFree && freeAllowance.covers(turboBundleSizes); } // Checking isFreeUploadPossibleUsingTurbo uses the 100KB file size check @@ -490,6 +507,7 @@ class UploadPaymentEvaluator { turboBundleSizes, _maxFreeItemBytes, _isTurboAvailableToUploadAllFiles, + freeAllowance, ); if (uploadMethod == UploadMethod.turbo) { @@ -507,11 +525,29 @@ class UploadPaymentEvaluator { arCostEstimate: arCostEstimate, turboCostEstimate: turboCostEstimate, isFreeUploadPossibleUsingTurbo: isFreeUploadPossibleUsingTurbo, + isSizeEligibleForFree: isSizeEligibleForFree, + isFreeAllowanceExhausted: isSizeEligibleForFree && + freeAllowance.isExhaustedFor(turboBundleSizes), totalSize: totalSize, turboBalance: turboBalance, ); } + /// The wallet's remaining free-upload allowance for this preparation. + /// + /// Fetched per preparation rather than cached like the item-size limit: + /// the limit is static server config, but the allowance is mutable + /// per-wallet state that other devices and uploads consume. + Future _getFreeAllowance({ + required bool canUseTurbo, + }) async { + if (!canUseTurbo) { + return const TurboFreeAllowance.unknown(); + } + + return _turboBalanceRetriever.getFreeAllowance(_auth.currentUser.wallet); + } + Future _getTurboBalance({ required bool canUseTurbo, }) async { @@ -545,9 +581,11 @@ class UploadPaymentEvaluator { int turboBundleSizes, int allowedSizeForTurbo, bool isTurboAvailableToUploadAllFiles, + TurboFreeAllowance freeAllowance, ) async { bool isFreeUploadPossibleUsingTurbo = - turboBundleSizes <= allowedSizeForTurbo; + turboBundleSizes <= allowedSizeForTurbo && + freeAllowance.covers(turboBundleSizes); if (isFreeUploadPossibleUsingTurbo) { return UploadMethod.turbo; @@ -593,6 +631,16 @@ class UploadPaymentInfo { final int totalSize; final TurboBalanceInterface turboBalance; + /// Whether every item is small enough to qualify for a free upload, + /// regardless of how much allowance the wallet has left. + final bool isSizeEligibleForFree; + + /// Whether this upload would have been free on size alone but the wallet's + /// free allowance is known to be used up. Distinguishes "you ran out" from + /// "this file is too big", which have different remedies — and is false when + /// the allowance simply could not be determined. + final bool isFreeAllowanceExhausted; + UploadPaymentInfo({ required this.defaultPaymentMethod, required this.isUploadEligibleToTurbo, @@ -602,6 +650,8 @@ class UploadPaymentInfo { required this.totalSize, required this.isTurboAvailable, required this.turboBalance, + this.isSizeEligibleForFree = false, + this.isFreeAllowanceExhausted = false, }); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3e29f1d99..2da5f7eed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -182,6 +182,10 @@ "@freeAllowanceUsedUpDescription": { "description": "Body of the free-allowance-used-up dialog" }, + "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", + "@freeAllowanceUsedUpUploadNote": { + "description": "Note shown above the payment method selector when an upload would have been free on size but the wallet's free allowance is used up" + }, "camera": "Camera", "@camera": { "description": "Button label to select a file from camera" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 44711f0e8..9a981a51a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -172,6 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", + "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", "@buyCredits": {}, "camera": "Cámara", "@camera": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index cf0daa9c4..eb77f94a0 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -172,6 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", + "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", "@buyCredits": {}, "camera": "कैमरा", "@camera": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 66454ed3f..ae696c897 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -172,6 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", + "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", "@buyCredits": {}, "camera": "カメラ", "@camera": { diff --git a/lib/l10n/app_zh-HK.arb b/lib/l10n/app_zh-HK.arb index ae81b136f..df2a3da40 100644 --- a/lib/l10n/app_zh-HK.arb +++ b/lib/l10n/app_zh-HK.arb @@ -172,6 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", + "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", "@buyCredits": {}, "camera": "相機", "@camera": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4bbaabbc6..6c345f9b2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -172,6 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", + "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", "@buyCredits": {}, "camera": "摄像头", "@camera": { diff --git a/lib/turbo/models/turbo_free_allowance.dart b/lib/turbo/models/turbo_free_allowance.dart new file mode 100644 index 000000000..2621312bd --- /dev/null +++ b/lib/turbo/models/turbo_free_allowance.dart @@ -0,0 +1,104 @@ +import 'package:equatable/equatable.dart'; + +/// How much of the wallet's Turbo free-upload allowance is left. +enum TurboFreeAllowanceStatus { + /// The wallet is exempt from the free-tier cap (partner/exempt wallet). + unlimited, + + /// The wallet has a finite, non-zero number of free bytes left. + limited, + + /// The free tier is off for this wallet, or its pool is fully used up. + disabled, + + /// We could not determine the allowance (endpoint unavailable, unexpected + /// payload, wallet unknown to the payment service, ...). + unknown, +} + +/// The wallet's remaining Turbo free-upload allowance, from +/// `GET /v1/account/free?address=`. +/// +/// This value is **advisory only**. It is a point-in-time, wallet-level +/// snapshot that races with uploads from other tabs, devices and in-flight +/// bundles. Use it to decide what to *promise* the user before an upload — +/// never to gate one. The authority on whether an upload is actually free +/// remains Turbo's response to the upload itself: a 402 means it was not. +/// +/// See also [TurboFreeAllowance.covers], which deliberately fails open. +class TurboFreeAllowance extends Equatable { + final TurboFreeAllowanceStatus status; + + /// Bytes left in the free pool. Only meaningful when [status] is + /// [TurboFreeAllowanceStatus.limited]; zero otherwise. + final int bytesRemaining; + + const TurboFreeAllowance._(this.status, this.bytesRemaining); + + const TurboFreeAllowance.unlimited() + : this._(TurboFreeAllowanceStatus.unlimited, 0); + + const TurboFreeAllowance.disabled() + : this._(TurboFreeAllowanceStatus.disabled, 0); + + const TurboFreeAllowance.unknown() + : this._(TurboFreeAllowanceStatus.unknown, 0); + + /// A finite allowance. A non-positive [bytesRemaining] is normalised to + /// [TurboFreeAllowance.disabled] so callers never have to special-case zero. + factory TurboFreeAllowance.bytes(int bytesRemaining) => bytesRemaining <= 0 + ? const TurboFreeAllowance.disabled() + : TurboFreeAllowance._(TurboFreeAllowanceStatus.limited, bytesRemaining); + + /// Parses the `/v1/account/free` payload: `{ "bytesRemaining": 7340032 }`, + /// where a null `bytesRemaining` means unlimited and `0` means the free tier + /// is off. Anything unrecognised is [TurboFreeAllowance.unknown] rather than + /// an exception — an advisory value must never break upload preparation. + factory TurboFreeAllowance.fromJson(dynamic data) { + if (data is! Map || !data.containsKey('bytesRemaining')) { + return const TurboFreeAllowance.unknown(); + } + + final value = data['bytesRemaining']; + + // Explicit null is the documented "unlimited" signal, not a missing field. + if (value == null) return const TurboFreeAllowance.unlimited(); + if (value is num) return TurboFreeAllowance.bytes(value.toInt()); + + return const TurboFreeAllowance.unknown(); + } + + bool get isKnown => status != TurboFreeAllowanceStatus.unknown; + + /// Whether the pool can cover [byteCount] of free-eligible upload. + /// + /// An [TurboFreeAllowanceStatus.unknown] allowance answers `true`: when the + /// endpoint is unreachable we fall back to promising free on item size + /// alone, exactly as the app behaved before this endpoint existed. Failing + /// closed would tell users with allowance left that they must pay, which is + /// a worse and less recoverable error than the 402 we already handle. + bool covers(int byteCount) { + switch (status) { + case TurboFreeAllowanceStatus.unlimited: + case TurboFreeAllowanceStatus.unknown: + return true; + case TurboFreeAllowanceStatus.disabled: + return false; + case TurboFreeAllowanceStatus.limited: + return byteCount <= bytesRemaining; + } + } + + /// True only when we *know* the pool cannot cover [byteCount] — i.e. the + /// allowance is genuinely used up, as opposed to merely unknown. This is the + /// signal for telling the user their free allowance ran out; [covers] alone + /// cannot distinguish "used up" from "could not check". + bool isExhaustedFor(int byteCount) => isKnown && !covers(byteCount); + + @override + List get props => [status, bytesRemaining]; + + @override + String toString() => + 'TurboFreeAllowance{status: $status, bytesRemaining: $bytesRemaining}'; +} diff --git a/lib/turbo/services/payment_service.dart b/lib/turbo/services/payment_service.dart index bda462d0e..79850bd71 100644 --- a/lib/turbo/services/payment_service.dart +++ b/lib/turbo/services/payment_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; import 'package:ardrive/turbo/topup/models/payment_model.dart'; import 'package:ardrive/turbo/utils/get_signature_headers_for_turbo.dart'; import 'package:ardrive/utils/logger.dart'; @@ -113,6 +114,24 @@ class PaymentService { return turboBalance.balance; } + /// Fetches how much of the wallet's Turbo free-upload allowance is left. + /// + /// Advisory only — see [TurboFreeAllowance]. Throws on transport/HTTP + /// failure; callers are expected to fall back to + /// [TurboFreeAllowance.unknown] rather than block the upload. + Future getFreeAllowance({ + required Wallet wallet, + }) async { + final result = await httpClient.get( + url: + '$turboPaymentUri/v1/account/free?address=${await wallet.getAddress()}', + ); + + final raw = result.data; + + return TurboFreeAllowance.fromJson(raw is String ? json.decode(raw) : raw); + } + Future getPaymentIntent({ required Wallet wallet, required double amount, diff --git a/lib/turbo/turbo.dart b/lib/turbo/turbo.dart index 01600e497..1f0a930c6 100644 --- a/lib/turbo/turbo.dart +++ b/lib/turbo/turbo.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:ardrive/core/upload/cost_calculator.dart'; import 'package:ardrive/services/config/app_config.dart'; import 'package:ardrive/turbo/models/payment_user_information.dart'; +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; import 'package:ardrive/turbo/services/payment_service.dart'; import 'package:ardrive/turbo/topup/models/payment_model.dart'; import 'package:ardrive/turbo/topup/models/price_estimate.dart'; @@ -316,6 +317,24 @@ class TurboBalanceRetriever { rethrow; } } + + /// The wallet's remaining free-upload allowance, or + /// [TurboFreeAllowance.unknown] if it could not be determined. + /// + /// Never throws: this value only decides what we promise the user, so a + /// failure here must degrade to the pre-endpoint behaviour rather than + /// break upload preparation. A wallet unknown to the payment service is + /// also "unknown" — a brand new wallet has its full allowance, so it must + /// not be reported as used up. + Future getFreeAllowance(Wallet wallet) async { + try { + return await paymentService.getFreeAllowance(wallet: wallet); + } catch (e, stackTrace) { + logger.w('Could not get the turbo free allowance: $e'); + logger.d('$stackTrace'); + return const TurboFreeAllowance.unknown(); + } + } } class TurboPriceEstimator extends Disposable implements ConvertForUSD { diff --git a/test/blocs/create_snapshot_cubit_test.dart b/test/blocs/create_snapshot_cubit_test.dart index bdc84a9ba..0c79d22fa 100644 --- a/test/blocs/create_snapshot_cubit_test.dart +++ b/test/blocs/create_snapshot_cubit_test.dart @@ -5,6 +5,7 @@ import 'package:ardrive/entities/snapshot_entity.dart'; import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; import 'package:ardrive/models/database/database.dart'; import 'package:ardrive/services/config/app_config.dart'; +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; import 'package:ardrive/turbo/services/payment_service.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/user/user.dart'; @@ -205,6 +206,11 @@ void main() { when(() => turboBalanceRetriever.getBalance(any())) .thenAnswer((invocation) async => BigInt.one); + /// Free allowance covers everything unless a test overrides it, so + /// these cases exercise the size-based free logic in isolation. + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => const TurboFreeAllowance.unlimited()); + final MockWallet wallet = MockWallet(); const address = 'addr'; final cipher = SecretKey([1, 2, 3, 4, 5]); diff --git a/test/core/upload/uploader_test.dart b/test/core/upload/uploader_test.dart index 4650e7d54..53fc1f9a8 100644 --- a/test/core/upload/uploader_test.dart +++ b/test/core/upload/uploader_test.dart @@ -5,6 +5,7 @@ import 'package:ardrive/blocs/upload/upload_cubit.dart'; import 'package:ardrive/blocs/upload/upload_handles/handles.dart'; import 'package:ardrive/core/upload/cost_calculator.dart'; import 'package:ardrive/core/upload/uploader.dart'; +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; import 'package:ardrive/entities/profile_types.dart'; import 'package:ardrive/models/database/database.dart'; import 'package:ardrive/services/config/selected_gateway.dart'; @@ -260,6 +261,11 @@ void main() { when(() => turboBalanceRetriever.getBalanceAndPaidBy(any())).thenAnswer( (_) async => TurboBalanceInterface(paidBy: [], balance: BigInt.from(500))); + + /// Free allowance covers everything unless a test overrides it, so these + /// cases exercise the size-based free logic in isolation. + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => const TurboFreeAllowance.unlimited()); when(() => sizeUtils.getSizeOfAllBundles(any())) .thenAnswer((_) async => 200); when(() => sizeUtils.getSizeOfAllV2Files(any())) @@ -291,6 +297,11 @@ void main() { ]); when(() => uploadPlan.bundleUploadHandles).thenReturn([mockBundle]); + + // The mocks are built once in setUpAll, so re-stub the allowance for + // every test: otherwise a test that overrides it leaks into the next. + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => const TurboFreeAllowance.unlimited()); }); /// Tests `isFreeUploadPossibleUsingTurbo` @@ -402,6 +413,102 @@ void main() { /// Tests `isTurboAvailable` /// + /// The free-tier promise depends on the wallet's remaining allowance, + /// not just item size. Bundle size is stubbed to 200 bytes in setUp. + group('testing free allowance logic', () { + setUp(() { + when(() => uploadPlan.fileV2UploadHandles).thenReturn({}); + // Every item comfortably under the 500 byte item limit. + when(() => mockFile.size).thenReturn(100); + when(() => mockFile2.size).thenReturn(100); + when(() => mockBundle.computeBundleSize()) + .thenAnswer((_) => Future.value(200)); + when(() => uploadPlan.bundleUploadHandles).thenReturn([mockBundle]); + }); + + test( + 'is not free when the wallet allowance cannot cover the upload, ' + 'even though every item is small enough', () async { + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => TurboFreeAllowance.bytes(199)); + + final result = + await uploadPaymentEvaluator.getUploadPaymentInfoForUploadPlans( + uploadPlanForAR: uploadPlan, + uploadPlanForTurbo: uploadPlan, + ); + + expect(result.isFreeUploadPossibleUsingTurbo, isFalse); + expect(result.isSizeEligibleForFree, isTrue); + expect(result.isFreeAllowanceExhausted, isTrue); + }); + + test('is not free when the free tier is off for the wallet', () async { + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => const TurboFreeAllowance.disabled()); + + final result = + await uploadPaymentEvaluator.getUploadPaymentInfoForUploadPlans( + uploadPlanForAR: uploadPlan, + uploadPlanForTurbo: uploadPlan, + ); + + expect(result.isFreeUploadPossibleUsingTurbo, isFalse); + expect(result.isFreeAllowanceExhausted, isTrue); + }); + + test('is free when the allowance exactly covers the upload', () async { + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => TurboFreeAllowance.bytes(200)); + + final result = + await uploadPaymentEvaluator.getUploadPaymentInfoForUploadPlans( + uploadPlanForAR: uploadPlan, + uploadPlanForTurbo: uploadPlan, + ); + + expect(result.isFreeUploadPossibleUsingTurbo, isTrue); + expect(result.isFreeAllowanceExhausted, isFalse); + }); + + test( + 'falls back to size-only free when the allowance is unknown, so an ' + 'unreachable endpoint never forces the user to pay', () async { + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => const TurboFreeAllowance.unknown()); + + final result = + await uploadPaymentEvaluator.getUploadPaymentInfoForUploadPlans( + uploadPlanForAR: uploadPlan, + uploadPlanForTurbo: uploadPlan, + ); + + expect(result.isFreeUploadPossibleUsingTurbo, isTrue); + expect(result.isFreeAllowanceExhausted, isFalse, + reason: '"could not check" must not be reported as "used up"'); + }); + + test( + 'does not report an exhausted allowance when the real problem is ' + 'item size', () async { + // Over the 500 byte item limit, so it never qualified on size. + when(() => mockFile.size).thenReturn(501); + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => const TurboFreeAllowance.disabled()); + + final result = + await uploadPaymentEvaluator.getUploadPaymentInfoForUploadPlans( + uploadPlanForAR: uploadPlan, + uploadPlanForTurbo: uploadPlan, + ); + + expect(result.isFreeUploadPossibleUsingTurbo, isFalse); + expect(result.isSizeEligibleForFree, isFalse); + expect(result.isFreeAllowanceExhausted, isFalse, + reason: 'the remedy is a smaller file, not more allowance'); + }); + }); + group('testing turbo eligibility', () { setUp(() { when(() => uploadPlan.fileV2UploadHandles).thenReturn({}); diff --git a/test/turbo/models/turbo_free_allowance_test.dart b/test/turbo/models/turbo_free_allowance_test.dart new file mode 100644 index 000000000..4b7afc854 --- /dev/null +++ b/test/turbo/models/turbo_free_allowance_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TurboFreeAllowance.fromJson', () { + test('parses a finite allowance', () { + final allowance = + TurboFreeAllowance.fromJson(const {'bytesRemaining': 7340032}); + + expect(allowance.status, TurboFreeAllowanceStatus.limited); + expect(allowance.bytesRemaining, 7340032); + expect(allowance.isKnown, isTrue); + }); + + test('treats an explicit null as unlimited, not unknown', () { + final allowance = + TurboFreeAllowance.fromJson(const {'bytesRemaining': null}); + + expect(allowance.status, TurboFreeAllowanceStatus.unlimited); + expect(allowance.isKnown, isTrue); + }); + + test('treats zero as the free tier being off', () { + final allowance = + TurboFreeAllowance.fromJson(const {'bytesRemaining': 0}); + + expect(allowance.status, TurboFreeAllowanceStatus.disabled); + expect(allowance.isKnown, isTrue); + }); + + test('normalises a negative allowance to disabled', () { + final allowance = + TurboFreeAllowance.fromJson(const {'bytesRemaining': -1}); + + expect(allowance.status, TurboFreeAllowanceStatus.disabled); + }); + + test('parses a decoded JSON string payload', () { + final allowance = TurboFreeAllowance.fromJson( + json.decode('{"bytesRemaining": 1024}'), + ); + + expect(allowance.bytesRemaining, 1024); + }); + + test('accepts a non-int number', () { + final allowance = + TurboFreeAllowance.fromJson(const {'bytesRemaining': 1024.0}); + + expect(allowance.status, TurboFreeAllowanceStatus.limited); + expect(allowance.bytesRemaining, 1024); + }); + + group('is unknown rather than throwing when the payload is unusable', () { + test('missing field', () { + expect(TurboFreeAllowance.fromJson(const {}).isKnown, isFalse); + }); + + test('not a map', () { + expect(TurboFreeAllowance.fromJson('nope').isKnown, isFalse); + expect(TurboFreeAllowance.fromJson(null).isKnown, isFalse); + }); + + test('wrong value type', () { + expect( + TurboFreeAllowance.fromJson(const {'bytesRemaining': 'lots'}).isKnown, + isFalse, + ); + }); + }); + }); + + group('covers', () { + test('a limited allowance covers only what fits', () { + final allowance = TurboFreeAllowance.bytes(1000); + + expect(allowance.covers(999), isTrue); + expect(allowance.covers(1000), isTrue, reason: 'boundary is inclusive'); + expect(allowance.covers(1001), isFalse); + }); + + test('unlimited covers anything', () { + expect(const TurboFreeAllowance.unlimited().covers(1 << 40), isTrue); + }); + + test('disabled covers nothing, including a zero-byte upload', () { + expect(const TurboFreeAllowance.disabled().covers(1), isFalse); + expect(const TurboFreeAllowance.disabled().covers(0), isFalse); + }); + + test('unknown fails open so an unreachable endpoint never forces payment', + () { + expect(const TurboFreeAllowance.unknown().covers(1 << 40), isTrue); + }); + }); + + group('isExhaustedFor', () { + test('is true only when the allowance is known not to cover the upload', + () { + expect(TurboFreeAllowance.bytes(100).isExhaustedFor(101), isTrue); + expect(const TurboFreeAllowance.disabled().isExhaustedFor(1), isTrue); + }); + + test('is false when the allowance covers the upload', () { + expect(TurboFreeAllowance.bytes(100).isExhaustedFor(100), isFalse); + expect(const TurboFreeAllowance.unlimited().isExhaustedFor(1), isFalse); + }); + + test('is false when unknown — "could not check" is not "used up"', () { + expect( + const TurboFreeAllowance.unknown().isExhaustedFor(1 << 40), isFalse); + }); + }); + + test( + 'bytes() normalises zero to disabled so callers need not special-case it', + () { + expect(TurboFreeAllowance.bytes(0), const TurboFreeAllowance.disabled()); + }); + + test('value equality', () { + expect(TurboFreeAllowance.bytes(10), TurboFreeAllowance.bytes(10)); + expect( + TurboFreeAllowance.bytes(10), + isNot(TurboFreeAllowance.bytes(11)), + ); + expect( + const TurboFreeAllowance.unknown(), + isNot(const TurboFreeAllowance.disabled()), + reason: 'unknown and disabled drive different UX and must not compare ' + 'equal', + ); + }); +} From de85bfbc146bb3758a72acd0e60e835b83fd58da Mon Sep 17 00:00:00 2001 From: vilenarios Date: Wed, 22 Jul 2026 19:29:07 -0400 Subject: [PATCH 15/19] fix: close two free-allowance gaps in the manifest upload paths PE-9132 An audit of every surface that promises a free upload found two the first pass missed, both in the manifest flow: - UploadManifestModel.freeThanksToTurbo was still derived from item size alone, in both prepareManifestUpload and prepareUploadPlanAndCostEstimates. This is worse than a cosmetic false promise: when every manifest is marked free, UploadCubit skips the payment method selection entirely, so an exhausted wallet went straight to an upload that 402s. - create_manifest_form showed the payment options with no explanation when the allowance ran out, unlike the upload and snapshot dialogs. Also stores arDriveUploadManager, until now a required UploadCubit constructor parameter that was never assigned to a field, so the cubit can reach the allowance without a new dependency. Adds a regression test for the manifest path, verified to fail without the fix, plus the missing allowance stubs for the shared setUpAll mocks. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PsV2WFHZMGD9DUfX65cxuW --- .../create_manifest_cubit.dart | 4 +- .../create_manifest_state.dart | 9 ++ lib/blocs/upload/upload_cubit.dart | 20 ++- lib/components/create_manifest_form.dart | 12 ++ lib/core/upload/uploader.dart | 11 ++ test/blocs/upload_cubit_test.dart | 127 ++++++++++++++++++ 6 files changed, 178 insertions(+), 5 deletions(-) diff --git a/lib/blocs/create_manifest/create_manifest_cubit.dart b/lib/blocs/create_manifest/create_manifest_cubit.dart index aaf19f696..71d720e15 100644 --- a/lib/blocs/create_manifest/create_manifest_cubit.dart +++ b/lib/blocs/create_manifest/create_manifest_cubit.dart @@ -113,6 +113,7 @@ class CreateManifestCubit extends Cubit { uploadMethod: method, canUpload: canUpload, freeUpload: info.isFreeThanksToTurbo, + isFreeAllowanceExhausted: info.isFreeAllowanceExhausted, assignedName: (state as CreateManifestUploadReview).assignedName, fallbackTxId: (state as CreateManifestUploadReview).fallbackTxId, ), @@ -496,8 +497,7 @@ class CreateManifestCubit extends Cubit { void onError(Object error, StackTrace stackTrace) { logger.e('Failed to create manifest', error, stackTrace); final wrapped = error is ManifestCreationException ? error.error : error; - emit(CreateManifestFailure( - isPaymentError: isTurboPaymentError(wrapped))); + emit(CreateManifestFailure(isPaymentError: isTurboPaymentError(wrapped))); super.onError(error, stackTrace); } } diff --git a/lib/blocs/create_manifest/create_manifest_state.dart b/lib/blocs/create_manifest/create_manifest_state.dart index 39fd9aff8..2e23f742c 100644 --- a/lib/blocs/create_manifest/create_manifest_state.dart +++ b/lib/blocs/create_manifest/create_manifest_state.dart @@ -139,6 +139,10 @@ class CreateManifestUploadReview extends CreateManifestState { final bool folderHasPendingFiles; final IOFile manifestFile; final bool freeUpload; + + /// Small enough to be free, but the wallet's free allowance is known to + /// be used up. False when the allowance could not be determined. + final bool isFreeAllowanceExhausted; final UploadMethod? uploadMethod; final Drive drive; final FolderEntry parentFolder; @@ -153,6 +157,7 @@ class CreateManifestUploadReview extends CreateManifestState { required this.folderHasPendingFiles, required this.manifestFile, this.freeUpload = false, + this.isFreeAllowanceExhausted = false, this.uploadMethod, required this.drive, required this.parentFolder, @@ -169,6 +174,7 @@ class CreateManifestUploadReview extends CreateManifestState { manifestFile, folderHasPendingFiles, freeUpload, + isFreeAllowanceExhausted, uploadMethod, drive, parentFolder, @@ -183,6 +189,7 @@ class CreateManifestUploadReview extends CreateManifestState { bool? folderHasPendingFiles, IOFile? manifestFile, bool? freeUpload, + bool? isFreeAllowanceExhausted, UploadMethod? uploadMethod, Drive? drive, FolderEntry? parentFolder, @@ -198,6 +205,8 @@ class CreateManifestUploadReview extends CreateManifestState { folderHasPendingFiles ?? this.folderHasPendingFiles, manifestFile: manifestFile ?? this.manifestFile, freeUpload: freeUpload ?? this.freeUpload, + isFreeAllowanceExhausted: + isFreeAllowanceExhausted ?? this.isFreeAllowanceExhausted, uploadMethod: uploadMethod ?? this.uploadMethod, drive: drive ?? this.drive, parentFolder: parentFolder ?? this.parentFolder, diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index b20013dcb..c3e87a554 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -76,6 +76,7 @@ class UploadCubit extends Cubit { _uploadThumbnail = configService.config.uploadThumbnails, _manifestRepository = manifestRepository, _createManifestCubit = createManifestCubit, + _arDriveUploadManager = arDriveUploadManager, _autoReplaceConflicts = autoReplaceConflicts, super(uploadFolders ? UploadLoadingFolders() : UploadLoadingFiles()); @@ -89,6 +90,7 @@ class UploadCubit extends Cubit { final ARNSRepository _arnsRepository; final ManifestRepository _manifestRepository; final CreateManifestCubit _createManifestCubit; + final ArDriveUploadPreparationManager _arDriveUploadManager; final String _driveId; final String _parentFolderId; @@ -141,6 +143,8 @@ class UploadCubit extends Cubit { } Future prepareManifestUpload() async { + final freeAllowance = await _arDriveUploadManager.getFreeAllowance(); + final manifestModels = _selectedManifestModels .map((e) => UploadManifestModel( entry: e.manifest, @@ -177,7 +181,11 @@ class UploadCubit extends Cubit { final manifestSize = await manifestFile.length; - if (manifestSize <= configService.config.allowedDataItemSizeForTurbo) { + /// Size alone is not enough: with the free allowance used up, every + /// manifest here would be marked free, payment selection would be + /// skipped entirely, and each upload would then fail with a 402. + if (manifestSize <= configService.config.allowedDataItemSizeForTurbo && + freeAllowance.covers(manifestSize)) { manifestModels[i] = manifestModels[i].copyWith(freeThanksToTurbo: true); } } @@ -752,7 +760,8 @@ class UploadCubit extends Cubit { if (_conflictingFiles.isNotEmpty) { // Auto-replace conflicts when flag is set (used for markdown editing) if (_autoReplaceConflicts) { - logger.d('Auto-replacing ${_conflictingFiles.length} conflicting file(s)'); + logger.d( + 'Auto-replacing ${_conflictingFiles.length} conflicting file(s)'); await prepareUploadPlanAndCostEstimates( uploadAction: UploadActions.replace, ); @@ -1056,12 +1065,17 @@ class UploadCubit extends Cubit { _manifestFiles = {}; + final manifestFreeAllowance = + await _arDriveUploadManager.getFreeAllowance(); + for (var entry in manifestFileEntries) { _manifestFiles[entry.id] = UploadManifestModel( entry: entry, existingManifestFileId: entry.id, + // Free requires both a small enough item and allowance to cover it. freeThanksToTurbo: - entry.size <= configService.config.allowedDataItemSizeForTurbo, + entry.size <= configService.config.allowedDataItemSizeForTurbo && + manifestFreeAllowance.covers(entry.size), ); } diff --git a/lib/components/create_manifest_form.dart b/lib/components/create_manifest_form.dart index f20b2de75..9efc1e9ad 100644 --- a/lib/components/create_manifest_form.dart +++ b/lib/components/create_manifest_form.dart @@ -633,6 +633,18 @@ class _CreateManifestFormState extends State { ), ], if (!state.freeUpload) ...[ + // Would have been free on size, but the allowance ran out — + // explain the switch instead of silently showing payment options. + if (state.isFreeAllowanceExhausted) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + appLocalizationsOf(context).freeAllowanceUsedUpUploadNote, + style: typography.paragraphNormal( + color: colorTokens.textMid, + ), + ), + ), Padding( padding: const EdgeInsets.only(bottom: 24), child: _paymentOptions(state, context), diff --git a/lib/core/upload/uploader.dart b/lib/core/upload/uploader.dart index 1e56dfc61..69ce4d3df 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -533,6 +533,11 @@ class UploadPaymentEvaluator { ); } + /// The wallet's remaining free-upload allowance, honouring the turbo + /// feature flag. Never throws — see [TurboBalanceRetriever.getFreeAllowance]. + Future getFreeAllowance() => + _getFreeAllowance(canUseTurbo: _canUseTurbo); + /// The wallet's remaining free-upload allowance for this preparation. /// /// Fetched per preparation rather than cached like the item-size limit: @@ -675,6 +680,12 @@ class ArDriveUploadPreparationManager { }) : _uploadPreparer = uploadPreparer, _uploadPaymentEvaluator = uploadPreparePaymentOptions; + /// The wallet's remaining free-upload allowance, for callers that decide + /// free-vs-paid themselves instead of going through [prepareUpload]. + /// Returns [TurboFreeAllowance.unknown] rather than throwing. + Future getFreeAllowance() => + _uploadPaymentEvaluator.getFreeAllowance(); + Future prepareUpload({ required UploadParams params, }) async { diff --git a/test/blocs/upload_cubit_test.dart b/test/blocs/upload_cubit_test.dart index 2791867b1..273fe5cd0 100644 --- a/test/blocs/upload_cubit_test.dart +++ b/test/blocs/upload_cubit_test.dart @@ -7,16 +7,19 @@ import 'package:ardrive/blocs/create_manifest/create_manifest_cubit.dart'; import 'package:ardrive/blocs/profile/profile_cubit.dart'; import 'package:ardrive/blocs/upload/models/upload_file.dart'; import 'package:ardrive/blocs/upload/models/upload_plan.dart'; +import 'package:ardrive/blocs/upload/models/payment_method_info.dart'; import 'package:ardrive/blocs/upload/upload_cubit.dart'; import 'package:ardrive/blocs/upload/upload_file_checker.dart'; import 'package:ardrive/core/upload/cost_calculator.dart'; import 'package:ardrive/core/upload/domain/repository/upload_repository.dart'; +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; import 'package:ardrive/core/upload/uploader.dart'; import 'package:ardrive/entities/profile_types.dart'; import 'package:ardrive/manifest/domain/manifest_repository.dart'; import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; import 'package:ardrive/models/database/database.dart'; import 'package:ardrive/services/config/selected_gateway.dart'; +import 'package:ardrive/main.dart' as ardrive_main; import 'package:ardrive/services/services.dart'; import 'package:ardrive/turbo/services/payment_service.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; @@ -211,6 +214,11 @@ void main() { mockTurboBalanceRetriever = MockTurboBalanceRetriever(); mockTurboUploadCostCalculator = MockTurboUploadCostCalculator(); mockArDriveUploadPreparationManager = MockArDriveUploadPreparationManager(); + + // Free allowance covers everything unless a test overrides it, so these + // cases exercise the size-based free logic in isolation. + when(() => mockArDriveUploadPreparationManager.getFreeAllowance()) + .thenAnswer((_) async => const TurboFreeAllowance.unlimited()); mockArnsRepository = MockArnsRepository(); late MockUploadPlan uploadPlan; mockUploadRepository = MockUploadRepository(); @@ -322,6 +330,125 @@ void main() { ), )); + /// The manifest re-upload path marks each manifest free/paid itself rather + /// than going through UploadPaymentEvaluator, so it needs its own coverage: + /// if every manifest is marked free, payment selection is skipped entirely. + group('manifest free-vs-paid honours the wallet free allowance', () { + final stubPaymentInfo = UploadPaymentMethodInfo( + uploadMethod: UploadMethod.turbo, + costEstimateTurbo: costEstimate, + costEstimateAr: costEstimate, + hasNoTurboBalance: false, + isTurboUploadPossible: true, + arBalance: '0', + sufficientArBalance: true, + turboCredits: '0', + sufficentCreditsBalance: true, + isFreeThanksToTurbo: true, + totalSize: 1, + ); + + FileEntry manifestEntry(int size) => FileEntry( + dataTxId: 'manifest-tx', + dateCreated: DateTime(2026), + size: size, + name: 'manifest', + parentFolderId: tRootFolderId, + lastUpdated: DateTime(2026), + lastModifiedDate: DateTime(2026), + id: 'manifest-file-id', + driveId: tDriveId, + isHidden: false, + path: '', + ); + + setUp(() { + when(() => mockProfileCubit!.state).thenReturn( + ProfileLoggedIn( + user: User( + password: '123', + wallet: tWallet, + walletAddress: tWalletAddress!, + walletBalance: BigInt.one, + cipherKey: SecretKey(tKeyBytes), + profileType: ProfileType.json, + errorFetchingIOTokens: false, + ), + useTurbo: false, + ), + ); + when(() => mockProfileCubit!.checkIfWalletMismatch()) + .thenAnswer((i) => Future.value(false)); + when(() => mockProfileCubit!.isCurrentProfileArConnect()) + .thenAnswer((i) => Future.value(false)); + // UploadCubit reads the global configService from main.dart directly, + // not the injected one, so it must be set for this branch to run. + ardrive_main.configService = mockConfigService; + + when(() => mockArDriveAuth.getWalletAddress()) + .thenAnswer((_) async => tWalletAddress); + when(() => mockArDriveAuth.currentUser).thenAnswer( + (_) => User( + password: 'password', + wallet: getTestWallet(), + walletAddress: tWalletAddress!, + walletBalance: BigInt.one, + cipherKey: SecretKey([]), + profileType: ProfileType.json, + errorFetchingIOTokens: false, + ), + ); + when(() => mockUploadFileSizeChecker.hasFileAboveWarningSizeLimit( + files: any(named: 'files'))).thenAnswer((_) async => false); + + // Non-empty manifest entries take the ArNS lookup branch. + when(() => mockArnsRepository.getAntRecordsForWallet(any())) + .thenAnswer((_) async => []); + + // A manifest at the free item size limit. + when(() => mockManifestRepository.getManifestFilesInFolder( + driveId: any(named: 'driveId'), folderId: any(named: 'folderId'))) + .thenAnswer((_) async => [manifestEntry(1)]); + }); + + blocTest( + 'marks a small manifest free when the allowance covers it', + build: () { + when(() => mockArDriveUploadPreparationManager.getFreeAllowance()) + .thenAnswer((_) async => const TurboFreeAllowance.unlimited()); + return getUploadCubitInstanceWith([]); + }, + act: (bloc) async { + await bloc.startUploadPreparation(); + await bloc.checkConflictingFiles(); + bloc.setUploadMethod(UploadMethod.turbo, stubPaymentInfo, true); + }, + verify: (bloc) { + final state = bloc.state as UploadReady; + expect(state.manifestFiles.single.freeThanksToTurbo, isTrue); + }, + ); + + blocTest( + 'does not mark it free when the allowance is used up, so payment ' + 'selection is not skipped', + build: () { + when(() => mockArDriveUploadPreparationManager.getFreeAllowance()) + .thenAnswer((_) async => const TurboFreeAllowance.disabled()); + return getUploadCubitInstanceWith([]); + }, + act: (bloc) async { + await bloc.startUploadPreparation(); + await bloc.checkConflictingFiles(); + bloc.setUploadMethod(UploadMethod.turbo, stubPaymentInfo, true); + }, + verify: (bloc) { + final state = bloc.state as UploadReady; + expect(state.manifestFiles.single.freeThanksToTurbo, isFalse); + }, + ); + }); + group('check if there are some conflicting file', () { setUp(() { when(() => mockProfileCubit!.state).thenReturn( From eadd175bcbaf9d302c37b392d6964457e28636d5 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Thu, 23 Jul 2026 12:11:10 -0400 Subject: [PATCH 16/19] refactor: model free-tier state as one value and render it in one widget PE-9132 The free-tier UX was carried by two parallel booleans (isFreeThanksToTurbo and isFreeAllowanceExhausted) threaded through four state classes, where "free" and "allowance used up" could both be true, and by six hand-rolled conditionals across three dialogs. That duplication is how the manifest form ended up promising "free" without ever explaining what happened when it stopped being free. - add FreeUploadStatus (free / allowanceUsedUp / notEligible) and a freeUploadStatusFor helper holding the two rules in one place - store that single value in UploadPaymentInfo, UploadPaymentMethodInfo, ConfirmingSnapshotCreation and CreateManifestUploadReview, keeping the existing booleans as derived getters so no consumer changes - add TurboFreeStatusMessage, the one widget that renders the one status line, collapsing entirely when there is nothing to say - tighten the used-up copy to lead with the fact Behaviour is unchanged: same 720 tests pass, including the ~35 existing isFreeUploadPossibleUsingTurbo assertions untouched, and the manifest regression test still fails when the underlying fix is reverted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PsV2WFHZMGD9DUfX65cxuW --- .../create_manifest_cubit.dart | 4 +- .../create_manifest_state.dart | 25 +++---- .../create_snapshot_cubit.dart | 22 +++--- .../create_snapshot_state.dart | 25 +++---- .../upload/models/payment_method_info.dart | 30 ++++---- .../bloc/upload_payment_method_bloc.dart | 5 +- lib/components/create_manifest_form.dart | 29 ++------ lib/components/create_snapshot_dialog.dart | 35 ++------- lib/components/turbo_free_status_message.dart | 51 +++++++++++++ lib/components/upload_form.dart | 29 ++------ lib/core/upload/uploader.dart | 74 +++++++++---------- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_ja.arb | 2 +- lib/l10n/app_zh-HK.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/turbo/models/free_upload_status.dart | 42 +++++++++++ test/blocs/upload_cubit_test.dart | 5 +- test/core/upload/uploader_test.dart | 3 +- 20 files changed, 213 insertions(+), 178 deletions(-) create mode 100644 lib/components/turbo_free_status_message.dart create mode 100644 lib/turbo/models/free_upload_status.dart diff --git a/lib/blocs/create_manifest/create_manifest_cubit.dart b/lib/blocs/create_manifest/create_manifest_cubit.dart index 71d720e15..63c4c1900 100644 --- a/lib/blocs/create_manifest/create_manifest_cubit.dart +++ b/lib/blocs/create_manifest/create_manifest_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:ardrive/arns/domain/arns_repository.dart'; import 'package:ardrive/manifest/domain/exceptions.dart'; +import 'package:ardrive/turbo/models/free_upload_status.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; @@ -112,8 +113,7 @@ class CreateManifestCubit extends Cubit { (state as CreateManifestUploadReview).copyWith( uploadMethod: method, canUpload: canUpload, - freeUpload: info.isFreeThanksToTurbo, - isFreeAllowanceExhausted: info.isFreeAllowanceExhausted, + freeStatus: info.freeStatus, assignedName: (state as CreateManifestUploadReview).assignedName, fallbackTxId: (state as CreateManifestUploadReview).fallbackTxId, ), diff --git a/lib/blocs/create_manifest/create_manifest_state.dart b/lib/blocs/create_manifest/create_manifest_state.dart index 2e23f742c..15786fd67 100644 --- a/lib/blocs/create_manifest/create_manifest_state.dart +++ b/lib/blocs/create_manifest/create_manifest_state.dart @@ -138,11 +138,9 @@ class CreateManifestUploadReview extends CreateManifestState { final String manifestName; final bool folderHasPendingFiles; final IOFile manifestFile; - final bool freeUpload; - /// Small enough to be free, but the wallet's free allowance is known to - /// be used up. False when the allowance could not be determined. - final bool isFreeAllowanceExhausted; + /// Whether this manifest upload is free, and if not, why not. + final FreeUploadStatus freeStatus; final UploadMethod? uploadMethod; final Drive drive; final FolderEntry parentFolder; @@ -156,8 +154,7 @@ class CreateManifestUploadReview extends CreateManifestState { required this.manifestName, required this.folderHasPendingFiles, required this.manifestFile, - this.freeUpload = false, - this.isFreeAllowanceExhausted = false, + this.freeStatus = FreeUploadStatus.notEligible, this.uploadMethod, required this.drive, required this.parentFolder, @@ -167,14 +164,19 @@ class CreateManifestUploadReview extends CreateManifestState { this.fallbackTxId, }); + bool get freeUpload => freeStatus == FreeUploadStatus.free; + + /// Small enough to be free, but the allowance is known to be used up. + bool get isFreeAllowanceExhausted => + freeStatus == FreeUploadStatus.allowanceUsedUp; + @override List get props => [ manifestSize, manifestName, manifestFile, folderHasPendingFiles, - freeUpload, - isFreeAllowanceExhausted, + freeStatus, uploadMethod, drive, parentFolder, @@ -188,8 +190,7 @@ class CreateManifestUploadReview extends CreateManifestState { String? manifestName, bool? folderHasPendingFiles, IOFile? manifestFile, - bool? freeUpload, - bool? isFreeAllowanceExhausted, + FreeUploadStatus? freeStatus, UploadMethod? uploadMethod, Drive? drive, FolderEntry? parentFolder, @@ -204,9 +205,7 @@ class CreateManifestUploadReview extends CreateManifestState { folderHasPendingFiles: folderHasPendingFiles ?? this.folderHasPendingFiles, manifestFile: manifestFile ?? this.manifestFile, - freeUpload: freeUpload ?? this.freeUpload, - isFreeAllowanceExhausted: - isFreeAllowanceExhausted ?? this.isFreeAllowanceExhausted, + freeStatus: freeStatus ?? this.freeStatus, uploadMethod: uploadMethod ?? this.uploadMethod, drive: drive ?? this.drive, parentFolder: parentFolder ?? this.parentFolder, diff --git a/lib/blocs/create_snapshot/create_snapshot_cubit.dart b/lib/blocs/create_snapshot/create_snapshot_cubit.dart index 8fdde2598..cd3b7e36f 100644 --- a/lib/blocs/create_snapshot/create_snapshot_cubit.dart +++ b/lib/blocs/create_snapshot/create_snapshot_cubit.dart @@ -14,6 +14,7 @@ import 'package:ardrive/models/database/database.dart'; import 'package:ardrive/models/enums.dart'; import 'package:drift/drift.dart'; import 'package:ardrive/services/services.dart'; +import 'package:ardrive/turbo/models/free_upload_status.dart'; import 'package:ardrive/turbo/services/payment_service.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/turbo/turbo.dart'; @@ -74,12 +75,12 @@ class CreateSnapshotCubit extends Cubit { bool _isTurboUploadPossible = true; bool _sufficentCreditsBalance = false; bool _sufficientArBalance = false; - bool _isFreeThanksToTurbo = false; - bool _isFreeAllowanceExhausted = false; + FreeUploadStatus _freeStatus = FreeUploadStatus.notEligible; bool _wasSnapshotDataComputingCanceled = false; bool get _useTurboUpload => - _uploadMethod == UploadMethod.turbo || _isFreeThanksToTurbo; + _uploadMethod == UploadMethod.turbo || + _freeStatus == FreeUploadStatus.free; AppConfig get appConfig => configService.config; @@ -173,8 +174,7 @@ class CreateSnapshotCubit extends Cubit { isButtonToUploadEnabled: _isButtonToUploadEnabled, sufficientBalanceToPayWithAr: _sufficientArBalance, sufficientBalanceToPayWithTurbo: _sufficentCreditsBalance, - isFreeThanksToTurbo: _isFreeThanksToTurbo, - isFreeAllowanceExhausted: _isFreeAllowanceExhausted, + freeStatus: _freeStatus, ), ); } catch (e) { @@ -554,8 +554,7 @@ class CreateSnapshotCubit extends Cubit { final isSizeEligibleForFree = snapshotSize <= allowedDataItemSizeForTurbo; if (!isSizeEligibleForFree) { - _isFreeThanksToTurbo = false; - _isFreeAllowanceExhausted = false; + _freeStatus = FreeUploadStatus.notEligible; return; } @@ -565,8 +564,11 @@ class CreateSnapshotCubit extends Cubit { final freeAllowance = await turboBalanceRetriever.getFreeAllowance(auth.currentUser.wallet); - _isFreeThanksToTurbo = freeAllowance.covers(snapshotSize); - _isFreeAllowanceExhausted = freeAllowance.isExhaustedFor(snapshotSize); + _freeStatus = freeUploadStatusFor( + isSizeEligible: true, + byteCount: snapshotSize, + allowance: freeAllowance, + ); } void setUploadMethod(UploadMethod method) { @@ -597,7 +599,7 @@ class CreateSnapshotCubit extends Cubit { _sufficentCreditsBalance) { logger.d('Enabling button for Turbo payment method'); _isButtonToUploadEnabled = true; - } else if (_isFreeThanksToTurbo) { + } else if (_freeStatus == FreeUploadStatus.free) { logger.d('Enabling button for free upload using Turbo'); _isButtonToUploadEnabled = true; } else { diff --git a/lib/blocs/create_snapshot/create_snapshot_state.dart b/lib/blocs/create_snapshot/create_snapshot_state.dart index db08ec8b3..2cdd95376 100644 --- a/lib/blocs/create_snapshot/create_snapshot_state.dart +++ b/lib/blocs/create_snapshot/create_snapshot_state.dart @@ -70,11 +70,9 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { final bool isButtonToUploadEnabled; final bool sufficientBalanceToPayWithAr; final bool sufficientBalanceToPayWithTurbo; - final bool isFreeThanksToTurbo; - /// Small enough to be free, but the wallet's free allowance is known to - /// be used up. False when the allowance could not be determined. - final bool isFreeAllowanceExhausted; + /// Whether this snapshot upload is free, and if not, why not. + final FreeUploadStatus freeStatus; ConfirmingSnapshotCreation({ required this.snapshotSize, @@ -88,10 +86,15 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { required this.isButtonToUploadEnabled, required this.sufficientBalanceToPayWithAr, required this.sufficientBalanceToPayWithTurbo, - required this.isFreeThanksToTurbo, - this.isFreeAllowanceExhausted = false, + required this.freeStatus, }); + bool get isFreeThanksToTurbo => freeStatus == FreeUploadStatus.free; + + /// Small enough to be free, but the allowance is known to be used up. + bool get isFreeAllowanceExhausted => + freeStatus == FreeUploadStatus.allowanceUsedUp; + @override List get props => [ snapshotSize, @@ -105,8 +108,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { isButtonToUploadEnabled, sufficientBalanceToPayWithAr, sufficientBalanceToPayWithTurbo, - isFreeThanksToTurbo, - isFreeAllowanceExhausted, + freeStatus, ]; ConfirmingSnapshotCreation copyWith({ @@ -123,8 +125,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { bool? isButtonToUploadEnabled, bool? sufficientBalanceToPayWithAr, bool? sufficientBalanceToPayWithTurbo, - bool? isFreeThanksToTurbo, - bool? isFreeAllowanceExhausted, + FreeUploadStatus? freeStatus, }) { return ConfirmingSnapshotCreation( snapshotSize: snapshotSize ?? this.snapshotSize, @@ -142,9 +143,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { sufficientBalanceToPayWithAr ?? this.sufficientBalanceToPayWithAr, sufficientBalanceToPayWithTurbo: sufficientBalanceToPayWithTurbo ?? this.sufficientBalanceToPayWithTurbo, - isFreeThanksToTurbo: isFreeThanksToTurbo ?? this.isFreeThanksToTurbo, - isFreeAllowanceExhausted: - isFreeAllowanceExhausted ?? this.isFreeAllowanceExhausted, + freeStatus: freeStatus ?? this.freeStatus, ); } } diff --git a/lib/blocs/upload/models/payment_method_info.dart b/lib/blocs/upload/models/payment_method_info.dart index b98870081..1223b7a93 100644 --- a/lib/blocs/upload/models/payment_method_info.dart +++ b/lib/blocs/upload/models/payment_method_info.dart @@ -1,6 +1,7 @@ import 'package:ardrive/blocs/upload/models/upload_plan.dart'; import 'package:ardrive/blocs/upload/upload_cubit.dart'; import 'package:ardrive/core/upload/cost_calculator.dart'; +import 'package:ardrive/turbo/models/free_upload_status.dart'; import 'package:equatable/equatable.dart'; class UploadPaymentMethodInfo extends Equatable { @@ -13,13 +14,9 @@ class UploadPaymentMethodInfo extends Equatable { final bool sufficientArBalance; final String turboCredits; final bool sufficentCreditsBalance; - final bool isFreeThanksToTurbo; - /// This upload qualifies for the free tier on size, but the wallet's free - /// allowance is known to be used up — so it needs Credits or AR after all. - /// False when the allowance could not be determined, so an unreachable - /// endpoint never tells the user they ran out. - final bool isFreeAllowanceExhausted; + /// Whether this upload is free, and if not, why not. + final FreeUploadStatus freeStatus; final UploadPlan? uploadPlanForAR; final UploadPlan? uploadPlanForTurbo; final int totalSize; @@ -35,14 +32,21 @@ class UploadPaymentMethodInfo extends Equatable { required this.sufficientArBalance, required this.turboCredits, required this.sufficentCreditsBalance, - required this.isFreeThanksToTurbo, - this.isFreeAllowanceExhausted = false, + required this.freeStatus, this.uploadPlanForAR, this.uploadPlanForTurbo, required this.totalSize, this.paidBy, }); + bool get isFreeThanksToTurbo => freeStatus == FreeUploadStatus.free; + + /// Would have been free on size, but the wallet's allowance is known to be + /// used up. False when the allowance could not be determined, so an + /// unreachable endpoint never tells the user they ran out. + bool get isFreeAllowanceExhausted => + freeStatus == FreeUploadStatus.allowanceUsedUp; + // copy with UploadPaymentMethodInfo copyWith({ UploadMethod? uploadMethod, @@ -54,8 +58,7 @@ class UploadPaymentMethodInfo extends Equatable { bool? sufficientArBalance, String? turboCredits, bool? sufficentCreditsBalance, - bool? isFreeThanksToTurbo, - bool? isFreeAllowanceExhausted, + FreeUploadStatus? freeStatus, UploadPlan? uploadPlanForAR, UploadPlan? uploadPlanForTurbo, int? totalSize, @@ -76,9 +79,7 @@ class UploadPaymentMethodInfo extends Equatable { turboCredits: turboCredits ?? this.turboCredits, sufficentCreditsBalance: sufficentCreditsBalance ?? this.sufficentCreditsBalance, - isFreeThanksToTurbo: isFreeThanksToTurbo ?? this.isFreeThanksToTurbo, - isFreeAllowanceExhausted: - isFreeAllowanceExhausted ?? this.isFreeAllowanceExhausted, + freeStatus: freeStatus ?? this.freeStatus, paidBy: paidBy ?? this.paidBy, ); } @@ -94,8 +95,7 @@ class UploadPaymentMethodInfo extends Equatable { sufficientArBalance, turboCredits, sufficentCreditsBalance, - isFreeThanksToTurbo, - isFreeAllowanceExhausted, + freeStatus, paidBy, ]; } diff --git a/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart b/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart index 6d42cd8a5..fa4dd348f 100644 --- a/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart +++ b/lib/blocs/upload/payment_method/bloc/upload_payment_method_bloc.dart @@ -78,10 +78,7 @@ class UploadPaymentMethodBloc costEstimateTurbo: uploadPreparation.uploadPaymentInfo.turboCostEstimate, hasNoTurboBalance: isTurboZeroBalance, - isFreeThanksToTurbo: uploadPreparation - .uploadPaymentInfo.isFreeUploadPossibleUsingTurbo, - isFreeAllowanceExhausted: - uploadPreparation.uploadPaymentInfo.isFreeAllowanceExhausted, + freeStatus: uploadPreparation.uploadPaymentInfo.freeStatus, isTurboUploadPossible: paymentInfo.isUploadEligibleToTurbo, sufficentCreditsBalance: _canUploadWithMethod(UploadMethod.turbo), sufficientArBalance: _canUploadWithMethod(UploadMethod.ar), diff --git a/lib/components/create_manifest_form.dart b/lib/components/create_manifest_form.dart index 9efc1e9ad..ce2b194cc 100644 --- a/lib/components/create_manifest_form.dart +++ b/lib/components/create_manifest_form.dart @@ -1,3 +1,4 @@ +import 'package:ardrive/components/turbo_free_status_message.dart'; import 'package:ardrive/arns/domain/arns_repository.dart'; import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/arns/presentation/assign_name_modal.dart'; @@ -620,31 +621,11 @@ class _CreateManifestFormState extends State { ), ), const Divider(height: 48), - if (state.freeUpload) ...[ - Padding( - padding: const EdgeInsets.only(bottom: 24), - child: Text( - appLocalizationsOf(context).freeTurboTransaction, - style: typography.paragraphNormal( - color: colorTokens.textMid, - fontWeight: ArFontWeight.bold, - ), - ), - ), - ], + TurboFreeStatusMessage( + status: state.freeStatus, + padding: const EdgeInsets.only(bottom: 24), + ), if (!state.freeUpload) ...[ - // Would have been free on size, but the allowance ran out — - // explain the switch instead of silently showing payment options. - if (state.isFreeAllowanceExhausted) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - appLocalizationsOf(context).freeAllowanceUsedUpUploadNote, - style: typography.paragraphNormal( - color: colorTokens.textMid, - ), - ), - ), Padding( padding: const EdgeInsets.only(bottom: 24), child: _paymentOptions(state, context), diff --git a/lib/components/create_snapshot_dialog.dart b/lib/components/create_snapshot_dialog.dart index 6cc75bf02..0b19c2eab 100644 --- a/lib/components/create_snapshot_dialog.dart +++ b/lib/components/create_snapshot_dialog.dart @@ -1,3 +1,5 @@ +import 'package:ardrive/turbo/models/free_upload_status.dart'; +import 'package:ardrive/components/turbo_free_status_message.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/components/turbo_payment_required_dialog.dart'; import 'package:ardrive/blocs/blocs.dart'; @@ -434,32 +436,11 @@ Widget _confirmDialog( ), const Divider(), const SizedBox(height: 16), - if (state.isFreeThanksToTurbo) ...{ - Text( - appLocalizationsOf(context).freeTurboTransaction, - style: typography.paragraphNormal( - color: ArDriveTheme.of(context) - .themeData - .colors - .themeFgDefault, - ), - ), - } else ...{ - // Would have been free on size, but the allowance ran - // out — explain the switch to a payment selector. - if (state.isFreeAllowanceExhausted) ...{ - Text( - appLocalizationsOf(context) - .freeAllowanceUsedUpUploadNote, - style: typography.paragraphNormal( - color: ArDriveTheme.of(context) - .themeData - .colors - .themeFgDefault, - ), - ), - const SizedBox(height: 12), - }, + TurboFreeStatusMessage( + status: state.freeStatus, + padding: const EdgeInsets.only(bottom: 12), + ), + if (!state.isFreeThanksToTurbo) ...{ PaymentMethodSelector( uploadMethodInfo: UploadPaymentMethodInfo( uploadMethod: state.uploadMethod, @@ -474,7 +455,7 @@ Widget _confirmDialog( turboCredits: state.turboCredits, sufficentCreditsBalance: state.sufficientBalanceToPayWithTurbo, - isFreeThanksToTurbo: false, + freeStatus: FreeUploadStatus.notEligible, ), onTurboTopupSucess: () { createSnapshotCubit.refreshTurboBalance(); diff --git a/lib/components/turbo_free_status_message.dart b/lib/components/turbo_free_status_message.dart new file mode 100644 index 000000000..53b86d61a --- /dev/null +++ b/lib/components/turbo_free_status_message.dart @@ -0,0 +1,51 @@ +import 'package:ardrive/turbo/models/free_upload_status.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/material.dart'; + +/// The free-tier status line shown above the payment method selector. +/// +/// There is only ever one such line, so this renders all of its cases in one +/// place: the upload is free, the free allowance ran out, or there is nothing +/// to say. Keeping it in a single widget is what stops the upload, snapshot +/// and manifest dialogs from drifting apart — the manifest form previously +/// promised "free" without ever explaining what happened when it stopped +/// being free. +/// +/// Renders nothing (and consumes no [padding]) for +/// [FreeUploadStatus.notEligible], where the payment selector alone is the +/// whole story. +class TurboFreeStatusMessage extends StatelessWidget { + const TurboFreeStatusMessage({ + super.key, + required this.status, + this.padding = EdgeInsets.zero, + }); + + final FreeUploadStatus status; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + if (status == FreeUploadStatus.notEligible) { + return const SizedBox.shrink(); + } + + final typography = ArDriveTypographyNew.of(context); + final colorTokens = ArDriveTheme.of(context).themeData.colorTokens; + final isFree = status == FreeUploadStatus.free; + + return Padding( + padding: padding, + child: Text( + isFree + ? appLocalizationsOf(context).freeTurboTransaction + : appLocalizationsOf(context).freeAllowanceUsedUpUploadNote, + style: typography.paragraphNormal( + color: colorTokens.textMid, + fontWeight: isFree ? ArFontWeight.bold : ArFontWeight.book, + ), + ), + ); + } +} diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index 1b3d7b7d9..95726921f 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -1,3 +1,4 @@ +import 'package:ardrive/components/turbo_free_status_message.dart'; import 'dart:async'; import 'dart:math'; @@ -1937,31 +1938,11 @@ class _UploadReadyWidget extends StatelessWidget { ), ), const Divider(), - if (state.paymentInfo.isFreeThanksToTurbo) ...[ - const SizedBox(height: 8), - Text( - appLocalizationsOf(context).freeTurboTransaction, - style: typography.paragraphNormal( - color: colorTokens.textMid, - fontWeight: ArFontWeight.bold, - ), - ), - const SizedBox(height: 20), - ], + TurboFreeStatusMessage( + status: state.paymentInfo.freeStatus, + padding: const EdgeInsets.only(top: 8, bottom: 20), + ), if (!state.paymentInfo.isFreeThanksToTurbo) ...[ - // Small enough to have been free, but the allowance ran out. - // Say so explicitly: without this the modal silently switches - // from "free" to a payment selector with no explanation. - if (state.paymentInfo.isFreeAllowanceExhausted) ...[ - const SizedBox(height: 8), - Text( - appLocalizationsOf(context).freeAllowanceUsedUpUploadNote, - style: typography.paragraphNormal( - color: colorTokens.textMid, - ), - ), - const SizedBox(height: 12), - ], RepositoryProvider.value( value: context.read(), child: UploadPaymentMethodView( diff --git a/lib/core/upload/uploader.dart b/lib/core/upload/uploader.dart index 69ce4d3df..24fb1a4ef 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -11,6 +11,7 @@ import 'package:ardrive/entities/constants.dart'; import 'package:ardrive/models/database/database.dart'; import 'package:ardrive/services/arweave/arweave.dart'; import 'package:ardrive/services/config/app_config.dart'; +import 'package:ardrive/turbo/models/free_upload_status.dart'; import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/turbo/turbo.dart'; @@ -391,11 +392,12 @@ class UploadPaymentEvaluator { /// An item is free only if it is both small enough to qualify AND the /// wallet still has free allowance to cover it. Size alone would promise /// "free" to a user whose pool is used up, and then fail with a 402. - final isSizeEligibleForFree = dataItemSize <= allowedDataItemSizeForTurbo; final freeAllowance = await _getFreeAllowance(canUseTurbo: _canUseTurbo); - - bool isFreeUploadPossibleUsingTurbo = - isSizeEligibleForFree && freeAllowance.covers(dataItemSize); + final freeStatus = freeUploadStatusFor( + isSizeEligible: dataItemSize <= allowedDataItemSizeForTurbo, + byteCount: dataItemSize, + allowance: freeAllowance, + ); uploadMethod = await _determineUploadMethod( turboBalance.balance, @@ -411,10 +413,7 @@ class UploadPaymentEvaluator { isUploadEligibleToTurbo: true, arCostEstimate: arCostEstimate, turboCostEstimate: turboCostEstimate, - isFreeUploadPossibleUsingTurbo: isFreeUploadPossibleUsingTurbo, - isSizeEligibleForFree: isSizeEligibleForFree, - isFreeAllowanceExhausted: - isSizeEligibleForFree && freeAllowance.isExhaustedFor(dataItemSize), + freeStatus: freeStatus, totalSize: totalSize, turboBalance: turboBalance, ); @@ -474,27 +473,29 @@ class UploadPaymentEvaluator { arCostEstimate = UploadCostEstimate.zero(); } - bool isSizeEligibleForFree = false; - bool isFreeUploadPossibleUsingTurbo = false; - final freeAllowance = await _getFreeAllowance(canUseTurbo: _canUseTurbo); + /// Every item being small enough is not sufficient — the wallet's free + /// pool has to cover the whole upload too. Turbo bills the entire upload + /// once the pool runs out, so partial coverage is not free either. + var freeStatus = FreeUploadStatus.notEligible; + if (isUploadEligibleToTurbo) { final allowedDataItemSizeForTurbo = _maxFreeItemBytes; - isSizeEligibleForFree = uploadPlanForTurbo.bundleUploadHandles.every( - (bundle) => bundle.fileDataItemUploadHandles.every( - (file) => file.size <= allowedDataItemSizeForTurbo, + freeStatus = freeUploadStatusFor( + isSizeEligible: uploadPlanForTurbo.bundleUploadHandles.every( + (bundle) => bundle.fileDataItemUploadHandles.every( + (file) => file.size <= allowedDataItemSizeForTurbo, + ), ), + byteCount: turboBundleSizes, + allowance: freeAllowance, ); - - /// Every item being small enough is not sufficient — the wallet's free - /// pool has to cover the whole upload too. Turbo bills the entire upload - /// once the pool runs out, so partial coverage is not free either. - isFreeUploadPossibleUsingTurbo = - isSizeEligibleForFree && freeAllowance.covers(turboBundleSizes); } + final isFreeUploadPossibleUsingTurbo = freeStatus == FreeUploadStatus.free; + // Checking isFreeUploadPossibleUsingTurbo uses the 100KB file size check // against the date, but using _determineUploadMethod() additionally uses the // Turbo bundle headers as part of the size check. A 100KB file might be @@ -524,10 +525,7 @@ class UploadPaymentEvaluator { isUploadEligibleToTurbo: isUploadEligibleToTurbo, arCostEstimate: arCostEstimate, turboCostEstimate: turboCostEstimate, - isFreeUploadPossibleUsingTurbo: isFreeUploadPossibleUsingTurbo, - isSizeEligibleForFree: isSizeEligibleForFree, - isFreeAllowanceExhausted: isSizeEligibleForFree && - freeAllowance.isExhaustedFor(turboBundleSizes), + freeStatus: freeStatus, totalSize: totalSize, turboBalance: turboBalance, ); @@ -629,35 +627,37 @@ class UploadPreparation { class UploadPaymentInfo { final UploadMethod defaultPaymentMethod; final bool isUploadEligibleToTurbo; - final bool isFreeUploadPossibleUsingTurbo; final bool isTurboAvailable; final UploadCostEstimate arCostEstimate; final UploadCostEstimate turboCostEstimate; final int totalSize; final TurboBalanceInterface turboBalance; - /// Whether every item is small enough to qualify for a free upload, - /// regardless of how much allowance the wallet has left. - final bool isSizeEligibleForFree; - - /// Whether this upload would have been free on size alone but the wallet's - /// free allowance is known to be used up. Distinguishes "you ran out" from - /// "this file is too big", which have different remedies — and is false when - /// the allowance simply could not be determined. - final bool isFreeAllowanceExhausted; + /// Whether this upload is free, and if not, why not. Single source of truth + /// for the booleans below, which cannot therefore contradict each other. + final FreeUploadStatus freeStatus; UploadPaymentInfo({ required this.defaultPaymentMethod, required this.isUploadEligibleToTurbo, required this.arCostEstimate, required this.turboCostEstimate, - required this.isFreeUploadPossibleUsingTurbo, + required this.freeStatus, required this.totalSize, required this.isTurboAvailable, required this.turboBalance, - this.isSizeEligibleForFree = false, - this.isFreeAllowanceExhausted = false, }); + + bool get isFreeUploadPossibleUsingTurbo => + freeStatus == FreeUploadStatus.free; + + /// Would have been free on size, but the wallet's allowance is known to be + /// used up. False when the allowance simply could not be determined. + bool get isFreeAllowanceExhausted => + freeStatus == FreeUploadStatus.allowanceUsedUp; + + /// Every item is small enough to qualify, regardless of allowance left. + bool get isSizeEligibleForFree => freeStatus != FreeUploadStatus.notEligible; } class UploadPlansPreparation { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2da5f7eed..04f6a3955 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -182,7 +182,7 @@ "@freeAllowanceUsedUpDescription": { "description": "Body of the free-allowance-used-up dialog" }, - "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", + "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", "@freeAllowanceUsedUpUploadNote": { "description": "Note shown above the payment method selector when an upload would have been free on size but the wallet's free allowance is used up" }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9a981a51a..6997f4bce 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -172,7 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", - "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", + "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", "@buyCredits": {}, "camera": "Cámara", "@camera": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index eb77f94a0..ac56b8298 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -172,7 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", - "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", + "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", "@buyCredits": {}, "camera": "कैमरा", "@camera": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ae696c897..1b33cfa47 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -172,7 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", - "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", + "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", "@buyCredits": {}, "camera": "カメラ", "@camera": { diff --git a/lib/l10n/app_zh-HK.arb b/lib/l10n/app_zh-HK.arb index df2a3da40..5527fd9c5 100644 --- a/lib/l10n/app_zh-HK.arb +++ b/lib/l10n/app_zh-HK.arb @@ -172,7 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", - "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", + "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", "@buyCredits": {}, "camera": "相機", "@camera": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6c345f9b2..ab8b65740 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -172,7 +172,7 @@ "actionFailedTryAgain": "Something went wrong. Please check your connection and try again.", "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", - "freeAllowanceUsedUpUploadNote": "Your free upload allowance is used up, so this upload requires Credits or AR.", + "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", "@buyCredits": {}, "camera": "摄像头", "@camera": { diff --git a/lib/turbo/models/free_upload_status.dart b/lib/turbo/models/free_upload_status.dart new file mode 100644 index 000000000..8d19e0770 --- /dev/null +++ b/lib/turbo/models/free_upload_status.dart @@ -0,0 +1,42 @@ +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; + +/// Whether an upload qualifies for Turbo's free tier, and if not, why not. +/// +/// This is the single source of truth behind the free-tier message and the +/// payment method selector. It replaces a pair of booleans that could +/// contradict each other ("free" and "allowance used up" at the same time). +enum FreeUploadStatus { + /// Small enough to qualify, and the wallet's allowance covers it. + free, + + /// Would have been free on size, but the wallet's free allowance is known + /// to be used up — so it needs Credits or AR after all. The remedy is more + /// allowance or payment. + allowanceUsedUp, + + /// Too large for the free tier (or Turbo is unavailable). The remedy is a + /// smaller item, not more allowance. + notEligible, +} + +/// Derives the free-tier status for an upload of [byteCount] bytes. +/// +/// [isSizeEligible] is the per-item size rule; [allowance] is the wallet's +/// remaining free pool. Both must pass for an upload to be free. +/// +/// An unknown allowance yields [FreeUploadStatus.free] rather than +/// [FreeUploadStatus.allowanceUsedUp], because [TurboFreeAllowance.covers] +/// fails open: if we could not check, we fall back to the size-only behaviour +/// instead of telling a user with allowance left that they must pay. +FreeUploadStatus freeUploadStatusFor({ + required bool isSizeEligible, + required int byteCount, + required TurboFreeAllowance allowance, +}) { + if (!isSizeEligible) return FreeUploadStatus.notEligible; + if (allowance.isExhaustedFor(byteCount)) { + return FreeUploadStatus.allowanceUsedUp; + } + + return FreeUploadStatus.free; +} diff --git a/test/blocs/upload_cubit_test.dart b/test/blocs/upload_cubit_test.dart index 273fe5cd0..d1ad2898b 100644 --- a/test/blocs/upload_cubit_test.dart +++ b/test/blocs/upload_cubit_test.dart @@ -1,3 +1,4 @@ +import 'package:ardrive/turbo/models/free_upload_status.dart'; import 'dart:io'; import 'dart:typed_data'; @@ -273,7 +274,7 @@ void main() { isUploadEligibleToTurbo: false, arCostEstimate: mockUploadCostEstimateAR, turboCostEstimate: mockUploadCostEstimateTurbo, - isFreeUploadPossibleUsingTurbo: false, + freeStatus: FreeUploadStatus.notEligible, totalSize: 100, isTurboAvailable: true, turboBalance: @@ -344,7 +345,7 @@ void main() { sufficientArBalance: true, turboCredits: '0', sufficentCreditsBalance: true, - isFreeThanksToTurbo: true, + freeStatus: FreeUploadStatus.free, totalSize: 1, ); diff --git a/test/core/upload/uploader_test.dart b/test/core/upload/uploader_test.dart index 53fc1f9a8..1857fb75c 100644 --- a/test/core/upload/uploader_test.dart +++ b/test/core/upload/uploader_test.dart @@ -1,3 +1,4 @@ +import 'package:ardrive/turbo/models/free_upload_status.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/upload/models/models.dart'; import 'package:ardrive/blocs/upload/models/upload_plan.dart'; @@ -1027,7 +1028,7 @@ void main() { isUploadEligibleToTurbo: true, arCostEstimate: mockUploadCostEstimateAR, turboCostEstimate: mockUploadCostEstimateTurbo, - isFreeUploadPossibleUsingTurbo: true, + freeStatus: FreeUploadStatus.free, totalSize: 100, isTurboAvailable: true, turboBalance: From 90f8e468b89868ab12b124ca36df4b35c57dd4c0 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Thu, 23 Jul 2026 12:31:37 -0400 Subject: [PATCH 17/19] fix: address CodeRabbit review on the free-tier work PE-9132 Three of the five findings were valid: - manifest free-eligibility used the static config item-size limit while the shared upload path prefers Turbo's /v1/info value. Adds ArDriveUploadPreparationManager.getMaxFreeItemBytes(), alongside the existing getFreeAllowance(), and uses it at both manifest sites. This also removes UploadCubit's last read of the global configService from main.dart. - getUploadPaymentInfoForEntities passed the item size as its own size limit, so the free-tier per-item cap was vacuously satisfied (x <= x). Passes the actual limit. No practical change for metadata items, which are far below it, but the cap is now real. - the snapshot dialog is shown with barrierDismissible: false and did not pop itself before opening the payment dialog, leaving an invisible undismissable barrier over the app once that dialog was closed. It now pops first, like create_manifest_form already did. Declined, with reasons: - gating snapshot free-status on appConfig.useTurboUpload: free uploads deliberately bypass that flag ("Even if this feature flag is off, it will be possible to upload using turbo for free files"), and gating it would restore the false "free" promise this PR exists to remove. - catching getFreeAllowance exceptions in the snapshot cubit: the retriever wrapper already catches everything and returns unknown, and auth.currentUser is read earlier in the same try by _computeBalanceEstimate. - popping the route in multi_thumbnail_creation_modal: it is an OverlayEntry, not a route, so Navigator.pop would dismiss the drive page underneath it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PsV2WFHZMGD9DUfX65cxuW --- lib/blocs/upload/upload_cubit.dart | 13 ++++++++----- lib/components/create_snapshot_dialog.dart | 7 ++++++- lib/core/upload/uploader.dart | 10 +++++++++- test/blocs/upload_cubit_test.dart | 10 +++++----- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index c3e87a554..0c75dea7c 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -12,7 +12,6 @@ import 'package:ardrive/core/upload/domain/repository/upload_repository.dart'; import 'package:ardrive/core/upload/uploader.dart'; import 'package:ardrive/core/upload/view/blocs/upload_manifest_options_bloc.dart'; import 'package:ardrive/entities/constants.dart'; -import 'package:ardrive/main.dart'; import 'package:ardrive/manifest/domain/manifest_repository.dart'; import 'package:ardrive/models/forms/cc.dart'; import 'package:ardrive/models/forms/udl.dart'; @@ -144,6 +143,7 @@ class UploadCubit extends Cubit { Future prepareManifestUpload() async { final freeAllowance = await _arDriveUploadManager.getFreeAllowance(); + final maxFreeItemBytes = _arDriveUploadManager.getMaxFreeItemBytes(); final manifestModels = _selectedManifestModels .map((e) => UploadManifestModel( @@ -184,7 +184,7 @@ class UploadCubit extends Cubit { /// Size alone is not enough: with the free allowance used up, every /// manifest here would be marked free, payment selection would be /// skipped entirely, and each upload would then fail with a 402. - if (manifestSize <= configService.config.allowedDataItemSizeForTurbo && + if (manifestSize <= maxFreeItemBytes && freeAllowance.covers(manifestSize)) { manifestModels[i] = manifestModels[i].copyWith(freeThanksToTurbo: true); } @@ -1067,15 +1067,18 @@ class UploadCubit extends Cubit { final manifestFreeAllowance = await _arDriveUploadManager.getFreeAllowance(); + final manifestMaxFreeItemBytes = + _arDriveUploadManager.getMaxFreeItemBytes(); for (var entry in manifestFileEntries) { _manifestFiles[entry.id] = UploadManifestModel( entry: entry, existingManifestFileId: entry.id, // Free requires both a small enough item and allowance to cover it. - freeThanksToTurbo: - entry.size <= configService.config.allowedDataItemSizeForTurbo && - manifestFreeAllowance.covers(entry.size), + // The size limit comes from the upload manager, which prefers + // Turbo's server-reported value over the static config one. + freeThanksToTurbo: entry.size <= manifestMaxFreeItemBytes && + manifestFreeAllowance.covers(entry.size), ); } diff --git a/lib/components/create_snapshot_dialog.dart b/lib/components/create_snapshot_dialog.dart index 0b19c2eab..63767bb88 100644 --- a/lib/components/create_snapshot_dialog.dart +++ b/lib/components/create_snapshot_dialog.dart @@ -86,6 +86,10 @@ class CreateSnapshotDialog extends StatelessWidget { ), ); } else if (state is SnapshotUploadFailure && state.isPaymentError) { + // This dialog is shown with barrierDismissible: false, so it has to + // be popped first — otherwise dismissing the payment dialog leaves + // an invisible, undismissable barrier over the app. + Navigator.of(context).pop(); showTurboPaymentRequiredDialog(context); } }, @@ -99,7 +103,8 @@ class CreateSnapshotDialog extends StatelessWidget { } else if (state is SnapshotUploadSuccess) { return _successDialog(context, drive.name); } else if (state is SnapshotUploadFailure && state.isPaymentError) { - // The payment dialog is shown from the listener; render nothing. + // Pop + payment dialog handled in the listener; render nothing for + // any frame between the state landing and the pop taking effect. return const SizedBox.shrink(); } else if (state is SnapshotUploadFailure || state is ComputeSnapshotDataFailure) { diff --git a/lib/core/upload/uploader.dart b/lib/core/upload/uploader.dart index 24fb1a4ef..70700ef7f 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -402,7 +402,7 @@ class UploadPaymentEvaluator { uploadMethod = await _determineUploadMethod( turboBalance.balance, dataItemSize, - dataItemSize, + allowedDataItemSizeForTurbo, _isTurboAvailableToUploadAllFiles, freeAllowance, ); @@ -536,6 +536,10 @@ class UploadPaymentEvaluator { Future getFreeAllowance() => _getFreeAllowance(canUseTurbo: _canUseTurbo); + /// The maximum size of an item eligible for a free upload, preferring + /// Turbo's server-reported value over the static config one. + int get maxFreeItemBytes => _maxFreeItemBytes; + /// The wallet's remaining free-upload allowance for this preparation. /// /// Fetched per preparation rather than cached like the item-size limit: @@ -686,6 +690,10 @@ class ArDriveUploadPreparationManager { Future getFreeAllowance() => _uploadPaymentEvaluator.getFreeAllowance(); + /// The maximum size of an item eligible for a free upload, for callers that + /// decide free-vs-paid themselves instead of going through [prepareUpload]. + int getMaxFreeItemBytes() => _uploadPaymentEvaluator.maxFreeItemBytes; + Future prepareUpload({ required UploadParams params, }) async { diff --git a/test/blocs/upload_cubit_test.dart b/test/blocs/upload_cubit_test.dart index d1ad2898b..310adc2eb 100644 --- a/test/blocs/upload_cubit_test.dart +++ b/test/blocs/upload_cubit_test.dart @@ -20,7 +20,6 @@ import 'package:ardrive/manifest/domain/manifest_repository.dart'; import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; import 'package:ardrive/models/database/database.dart'; import 'package:ardrive/services/config/selected_gateway.dart'; -import 'package:ardrive/main.dart' as ardrive_main; import 'package:ardrive/services/services.dart'; import 'package:ardrive/turbo/services/payment_service.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; @@ -220,6 +219,11 @@ void main() { // cases exercise the size-based free logic in isolation. when(() => mockArDriveUploadPreparationManager.getFreeAllowance()) .thenAnswer((_) async => const TurboFreeAllowance.unlimited()); + + // Matches allowedDataItemSizeForTurbo in the mocked AppConfig, so the + // size rule behaves exactly as it did when it read the config directly. + when(() => mockArDriveUploadPreparationManager.getMaxFreeItemBytes()) + .thenReturn(1); mockArnsRepository = MockArnsRepository(); late MockUploadPlan uploadPlan; mockUploadRepository = MockUploadRepository(); @@ -382,10 +386,6 @@ void main() { .thenAnswer((i) => Future.value(false)); when(() => mockProfileCubit!.isCurrentProfileArConnect()) .thenAnswer((i) => Future.value(false)); - // UploadCubit reads the global configService from main.dart directly, - // not the injected one, so it must be set for this branch to run. - ardrive_main.configService = mockConfigService; - when(() => mockArDriveAuth.getWalletAddress()) .thenAnswer((_) async => tWalletAddress); when(() => mockArDriveAuth.currentUser).thenAnswer( From 45569a1879e043295e1e567051d8c8ead783e0fe Mon Sep 17 00:00:00 2001 From: vilenarios Date: Thu, 23 Jul 2026 13:38:38 -0400 Subject: [PATCH 18/19] fix: check free allowance regardless of turbo flag; dismiss thumbnail overlay PE-9132 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the two CodeRabbit findings I had previously declined, correctly: - The free allowance was fetched only when the useTurboUpload flag was on, but free uploads deliberately bypass that flag. So with the flag off a small item still uploaded via Turbo yet was promised "free" without ever checking the allowance — the exact bug this PR removes, hidden behind a flag — and the snapshot path (which checks unconditionally) disagreed. CodeRabbit proposed making snapshot honor the flag; that is the wrong direction, as it would send free-eligible items down the paid path against the documented intent. Instead getFreeAllowance is now unconditional, so free-ness is always verified. The paid-turbo gate on _getTurboBalance is unchanged. No runtime effect today (useTurboUpload is true in all flavors). - The multi-thumbnail modal is an OverlayEntry, not a route, so Navigator.pop would have dismissed the drive page underneath — which is why popping was declined. But the overlay was still left behind the payment dialog. It now dismisses through its own CloseMultiThumbnailCreation event, the mechanism the modal already uses for closing. Adds an assertion that the allowance is consulted even with the flag off. Endpoint host confirmed empirically: GET payment.ardrive.io/v1/account/free returns 200 {"bytesRemaining":10485760} (10 MiB), and upload.ardrive.io 404s, so turboPaymentUri is the correct host. An unknown address returns the full allowance rather than 404, so new wallets correctly read as free. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PsV2WFHZMGD9DUfX65cxuW --- lib/core/upload/uploader.dart | 32 ++++++++----------- .../multi_thumbnail_creation_modal.dart | 4 +++ test/core/upload/uploader_test.dart | 6 ++++ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lib/core/upload/uploader.dart b/lib/core/upload/uploader.dart index 70700ef7f..b20604bf8 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -392,7 +392,7 @@ class UploadPaymentEvaluator { /// An item is free only if it is both small enough to qualify AND the /// wallet still has free allowance to cover it. Size alone would promise /// "free" to a user whose pool is used up, and then fail with a 402. - final freeAllowance = await _getFreeAllowance(canUseTurbo: _canUseTurbo); + final freeAllowance = await getFreeAllowance(); final freeStatus = freeUploadStatusFor( isSizeEligible: dataItemSize <= allowedDataItemSizeForTurbo, byteCount: dataItemSize, @@ -473,7 +473,7 @@ class UploadPaymentEvaluator { arCostEstimate = UploadCostEstimate.zero(); } - final freeAllowance = await _getFreeAllowance(canUseTurbo: _canUseTurbo); + final freeAllowance = await getFreeAllowance(); /// Every item being small enough is not sufficient — the wallet's free /// pool has to cover the whole upload too. Turbo bills the entire upload @@ -531,29 +531,23 @@ class UploadPaymentEvaluator { ); } - /// The wallet's remaining free-upload allowance, honouring the turbo - /// feature flag. Never throws — see [TurboBalanceRetriever.getFreeAllowance]. - Future getFreeAllowance() => - _getFreeAllowance(canUseTurbo: _canUseTurbo); - /// The maximum size of an item eligible for a free upload, preferring /// Turbo's server-reported value over the static config one. int get maxFreeItemBytes => _maxFreeItemBytes; /// The wallet's remaining free-upload allowance for this preparation. /// - /// Fetched per preparation rather than cached like the item-size limit: - /// the limit is static server config, but the allowance is mutable - /// per-wallet state that other devices and uploads consume. - Future _getFreeAllowance({ - required bool canUseTurbo, - }) async { - if (!canUseTurbo) { - return const TurboFreeAllowance.unknown(); - } - - return _turboBalanceRetriever.getFreeAllowance(_auth.currentUser.wallet); - } + /// Deliberately NOT gated on [_canUseTurbo]: free uploads bypass the turbo + /// feature flag (see [_canUseTurbo]), so an upload that can still go over + /// Turbo for free must have its free-ness checked against the allowance + /// even when the flag is off. Gating this was the last place "free" was + /// promised without verifying the allowance. Fetched per preparation rather + /// than cached like the item-size limit: the limit is static server config, + /// but the allowance is mutable per-wallet state that other devices and + /// uploads consume. Never throws — see + /// [TurboBalanceRetriever.getFreeAllowance]. + Future getFreeAllowance() => + _turboBalanceRetriever.getFreeAllowance(_auth.currentUser.wallet); Future _getTurboBalance({ required bool canUseTurbo, diff --git a/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart index 0da7d9a29..9ce233c80 100644 --- a/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart +++ b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart @@ -110,6 +110,10 @@ class _MultiThumbnailCreationModalContentState bloc: widget.bloc, listener: (context, state) { if (state is MultiThumbnailCreationError && state.isPaymentError) { + // This modal is an OverlayEntry, not a route, so it cannot be popped + // with Navigator. Dismiss it through its own close event — otherwise + // it lingers behind the payment dialog. + widget.bloc.add(CloseMultiThumbnailCreation()); showTurboPaymentRequiredDialog(context); } }, diff --git a/test/core/upload/uploader_test.dart b/test/core/upload/uploader_test.dart index 1857fb75c..1123237ef 100644 --- a/test/core/upload/uploader_test.dart +++ b/test/core/upload/uploader_test.dart @@ -647,6 +647,8 @@ void main() { .thenAnswer((invocation) => Future.value(503)); when(() => uploadPlan.bundleUploadHandles).thenReturn([mockBundle]); + when(() => turboBalanceRetriever.getFreeAllowance(any())) + .thenAnswer((_) async => const TurboFreeAllowance.unlimited()); final result = await paymentEvaluatorWithFeatureFlagFalse .getUploadPaymentInfoForUploadPlans( @@ -658,7 +660,11 @@ void main() { expect(result.isUploadEligibleToTurbo, isTrue); expect(result.isTurboAvailable, isFalse); + // Paid turbo is gated by the flag... verifyNever(() => turboBalanceRetriever.getBalance(any())); + // ...but free-ness is still checked against the allowance, so we + // never promise "free" without verifying it, flag off or not. + verify(() => turboBalanceRetriever.getFreeAllowance(any())).called(1); }); test('isTurboAvailable returns false when getBalance throws', () async { From a60a001b5aab60da556b71f1dcdcb5438e211b45 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Thu, 23 Jul 2026 16:19:26 -0400 Subject: [PATCH 19/19] feat: distinguish an upload exceeding the free allowance from it being used up PE-9132 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bulk/folder upload of small files whose total is larger than the wallet's remaining free pool was labelled "Free allowance used up" — wrong when the user still has most of their allowance and the upload simply exceeds it. Adds FreeUploadStatus.exceedsAllowance, distinct from allowanceUsedUp, chosen when the wallet still has a positive allowance but the upload is bigger than it. TurboFreeStatusMessage now shows an honest note for that case: "This upload exceeds your free allowance and will need Credits or AR." Deliberately robust rather than precise. The message states the fact (upload > remaining) and the outcome (needs payment) without predicting how many bytes end up free, because the client cannot know that: Turbo applies the free tier server-side, does not expose whether it bills per-item or per-bundle, and enforces a second per-IP pool that /v1/account/free does not report. So it is true whether Turbo frees part of the upload or none of it, and the 402 remains the authority on what is actually charged. This also corrects a stale comment that asserted all-or-nothing billing as fact. Behaviour is otherwise unchanged: exceedsAllowance is not free, so the payment selector still shows and upload-method selection is untouched. Single item / snapshot / manifest paths are unaffected in practice. The one existing assertion that expected "used up" for a partial multi-item upload is updated to expect the new, more accurate status; the derived getters and the widget's now-exhaustive switch keep every other consumer compiling unchanged. Adds unit coverage for freeUploadStatusFor (including boundaries and fail-open) and a widget test asserting each status renders the right message, both verified to fail under a collapsing mutation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PsV2WFHZMGD9DUfX65cxuW --- lib/components/turbo_free_status_message.dart | 25 +++- lib/core/upload/uploader.dart | 6 +- lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_zh-HK.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/turbo/models/free_upload_status.dart | 36 ++++-- .../turbo_free_status_message_test.dart | 58 +++++++++ test/core/upload/uploader_test.dart | 13 +- .../turbo/models/free_upload_status_test.dart | 113 ++++++++++++++++++ 12 files changed, 241 insertions(+), 19 deletions(-) create mode 100644 test/components/turbo_free_status_message_test.dart create mode 100644 test/turbo/models/free_upload_status_test.dart diff --git a/lib/components/turbo_free_status_message.dart b/lib/components/turbo_free_status_message.dart index 53b86d61a..700cfaa71 100644 --- a/lib/components/turbo_free_status_message.dart +++ b/lib/components/turbo_free_status_message.dart @@ -27,23 +27,38 @@ class TurboFreeStatusMessage extends StatelessWidget { @override Widget build(BuildContext context) { + // notEligible has nothing to say — the payment selector alone is the whole + // story — so render nothing and consume no padding. if (status == FreeUploadStatus.notEligible) { return const SizedBox.shrink(); } + final l10n = appLocalizationsOf(context); final typography = ArDriveTypographyNew.of(context); final colorTokens = ArDriveTheme.of(context).themeData.colorTokens; - final isFree = status == FreeUploadStatus.free; + + // Exhaustive switch: adding a FreeUploadStatus becomes a compile error here + // rather than a silently wrong message. + final (String text, bool bold) = switch (status) { + FreeUploadStatus.free => (l10n.freeTurboTransaction, true), + FreeUploadStatus.exceedsAllowance => ( + l10n.freeAllowanceExceededUploadNote, + false, + ), + FreeUploadStatus.allowanceUsedUp => ( + l10n.freeAllowanceUsedUpUploadNote, + false, + ), + FreeUploadStatus.notEligible => ('', false), // handled above + }; return Padding( padding: padding, child: Text( - isFree - ? appLocalizationsOf(context).freeTurboTransaction - : appLocalizationsOf(context).freeAllowanceUsedUpUploadNote, + text, style: typography.paragraphNormal( color: colorTokens.textMid, - fontWeight: isFree ? ArFontWeight.bold : ArFontWeight.book, + fontWeight: bold ? ArFontWeight.bold : ArFontWeight.book, ), ), ); diff --git a/lib/core/upload/uploader.dart b/lib/core/upload/uploader.dart index b20604bf8..0e9438388 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -476,8 +476,10 @@ class UploadPaymentEvaluator { final freeAllowance = await getFreeAllowance(); /// Every item being small enough is not sufficient — the wallet's free - /// pool has to cover the whole upload too. Turbo bills the entire upload - /// once the pool runs out, so partial coverage is not free either. + /// pool has to cover the whole upload too. When it does not, we report the + /// upload as exceeding the allowance rather than guessing how much of it + /// ends up free: Turbo decides that server-side and does not expose the + /// split (see [freeUploadStatusFor]). var freeStatus = FreeUploadStatus.notEligible; if (isUploadEligibleToTurbo) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 04f6a3955..38a568a88 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -186,6 +186,10 @@ "@freeAllowanceUsedUpUploadNote": { "description": "Note shown above the payment method selector when an upload would have been free on size but the wallet's free allowance is used up" }, + "freeAllowanceExceededUploadNote": "This upload exceeds your free allowance and will need Credits or AR.", + "@freeAllowanceExceededUploadNote": { + "description": "Note shown above the payment method selector when an upload is size-eligible for the free tier but larger than the wallet's remaining free allowance, so part or all of it needs payment" + }, "camera": "Camera", "@camera": { "description": "Button label to select a file from camera" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 6997f4bce..7eec86547 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -173,6 +173,7 @@ "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", + "freeAllowanceExceededUploadNote": "This upload exceeds your free allowance and will need Credits or AR.", "@buyCredits": {}, "camera": "Cámara", "@camera": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ac56b8298..c5858fb9c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -173,6 +173,7 @@ "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", + "freeAllowanceExceededUploadNote": "This upload exceeds your free allowance and will need Credits or AR.", "@buyCredits": {}, "camera": "कैमरा", "@camera": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 1b33cfa47..f8d68b747 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -173,6 +173,7 @@ "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", + "freeAllowanceExceededUploadNote": "This upload exceeds your free allowance and will need Credits or AR.", "@buyCredits": {}, "camera": "カメラ", "@camera": { diff --git a/lib/l10n/app_zh-HK.arb b/lib/l10n/app_zh-HK.arb index 5527fd9c5..a16089798 100644 --- a/lib/l10n/app_zh-HK.arb +++ b/lib/l10n/app_zh-HK.arb @@ -173,6 +173,7 @@ "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", + "freeAllowanceExceededUploadNote": "This upload exceeds your free allowance and will need Credits or AR.", "@buyCredits": {}, "camera": "相機", "@camera": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ab8b65740..e0043f799 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -173,6 +173,7 @@ "freeAllowanceUsedUpTitle": "Free allowance used up", "freeAllowanceUsedUpDescription": "Your free upload allowance has been used up, so this action now requires Credits. Add Credits and try again.", "freeAllowanceUsedUpUploadNote": "Free allowance used up. This upload requires Credits or AR.", + "freeAllowanceExceededUploadNote": "This upload exceeds your free allowance and will need Credits or AR.", "@buyCredits": {}, "camera": "摄像头", "@camera": { diff --git a/lib/turbo/models/free_upload_status.dart b/lib/turbo/models/free_upload_status.dart index 8d19e0770..ee1e3aaec 100644 --- a/lib/turbo/models/free_upload_status.dart +++ b/lib/turbo/models/free_upload_status.dart @@ -9,6 +9,13 @@ enum FreeUploadStatus { /// Small enough to qualify, and the wallet's allowance covers it. free, + /// Size-eligible, and the wallet still has some free allowance, but this + /// upload is larger than what is left — so it needs Credits or AR. Distinct + /// from [allowanceUsedUp]: the user has NOT exhausted their free tier, the + /// upload simply exceeds it. We deliberately do not predict how much of the + /// upload ends up free (see [freeUploadStatusFor]). + exceedsAllowance, + /// Would have been free on size, but the wallet's free allowance is known /// to be used up — so it needs Credits or AR after all. The remedy is more /// allowance or payment. @@ -24,19 +31,32 @@ enum FreeUploadStatus { /// [isSizeEligible] is the per-item size rule; [allowance] is the wallet's /// remaining free pool. Both must pass for an upload to be free. /// -/// An unknown allowance yields [FreeUploadStatus.free] rather than -/// [FreeUploadStatus.allowanceUsedUp], because [TurboFreeAllowance.covers] -/// fails open: if we could not check, we fall back to the size-only behaviour -/// instead of telling a user with allowance left that they must pay. +/// When the upload is size-eligible but the allowance does not cover it, the +/// result distinguishes [FreeUploadStatus.exceedsAllowance] (some allowance +/// remains, the upload is just bigger) from [FreeUploadStatus.allowanceUsedUp] +/// (the free tier is gone). It deliberately does NOT try to say how many bytes +/// end up free: the client cannot know that. Turbo applies the free tier +/// server-side, its billing granularity (per-item vs per-bundle) is not +/// exposed, and a second per-IP pool that `/v1/account/free` does not report +/// can constrain it further. So [byteCount] vs the wallet allowance only tells +/// us the upload exceeds what is free — never the exact split. The 402 on +/// upload remains the authority on what is actually charged. +/// +/// An unknown allowance yields [FreeUploadStatus.free] rather than a paid +/// status, because [TurboFreeAllowance.covers] fails open: if we could not +/// check, we fall back to the size-only behaviour instead of telling a user +/// with allowance left that they must pay. FreeUploadStatus freeUploadStatusFor({ required bool isSizeEligible, required int byteCount, required TurboFreeAllowance allowance, }) { if (!isSizeEligible) return FreeUploadStatus.notEligible; - if (allowance.isExhaustedFor(byteCount)) { - return FreeUploadStatus.allowanceUsedUp; - } + if (!allowance.isExhaustedFor(byteCount)) return FreeUploadStatus.free; - return FreeUploadStatus.free; + // Known not to cover the upload. If a positive allowance remains, the upload + // exceeds it rather than the free tier being spent. + return allowance.bytesRemaining > 0 + ? FreeUploadStatus.exceedsAllowance + : FreeUploadStatus.allowanceUsedUp; } diff --git a/test/components/turbo_free_status_message_test.dart b/test/components/turbo_free_status_message_test.dart new file mode 100644 index 000000000..61764504b --- /dev/null +++ b/test/components/turbo_free_status_message_test.dart @@ -0,0 +1,58 @@ +import 'package:ardrive/components/turbo_free_status_message.dart'; +import 'package:ardrive/turbo/models/free_upload_status.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget wrap(FreeUploadStatus status) { + return ArDriveTheme( + themeData: lightTheme(), + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en', '')], + home: Scaffold( + body: TurboFreeStatusMessage(status: status), + ), + ), + ); + } + + testWidgets('free shows the free-transaction message', (tester) async { + await tester.pumpWidget(wrap(FreeUploadStatus.free)); + await tester.pumpAndSettle(); + + expect(find.textContaining('free thanks to Turbo'), findsOneWidget); + }); + + testWidgets('exceedsAllowance explains the upload is bigger than the pool', + (tester) async { + await tester.pumpWidget(wrap(FreeUploadStatus.exceedsAllowance)); + await tester.pumpAndSettle(); + + expect(find.textContaining('exceeds your free allowance'), findsOneWidget); + // It must NOT claim the tier is used up — the user still has allowance. + expect(find.textContaining('used up'), findsNothing); + }); + + testWidgets('allowanceUsedUp says the free tier is used up', (tester) async { + await tester.pumpWidget(wrap(FreeUploadStatus.allowanceUsedUp)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Free allowance used up'), findsOneWidget); + }); + + testWidgets('notEligible renders nothing at all', (tester) async { + await tester.pumpWidget(wrap(FreeUploadStatus.notEligible)); + await tester.pumpAndSettle(); + + expect(find.byType(Text), findsNothing); + }); +} diff --git a/test/core/upload/uploader_test.dart b/test/core/upload/uploader_test.dart index 1123237ef..96f4139d3 100644 --- a/test/core/upload/uploader_test.dart +++ b/test/core/upload/uploader_test.dart @@ -428,8 +428,9 @@ void main() { }); test( - 'is not free when the wallet allowance cannot cover the upload, ' - 'even though every item is small enough', () async { + 'exceeds the allowance (not "used up") when some free allowance ' + 'remains but the upload is bigger, even though every item is ' + 'small enough', () async { when(() => turboBalanceRetriever.getFreeAllowance(any())) .thenAnswer((_) async => TurboFreeAllowance.bytes(199)); @@ -441,10 +442,13 @@ void main() { expect(result.isFreeUploadPossibleUsingTurbo, isFalse); expect(result.isSizeEligibleForFree, isTrue); - expect(result.isFreeAllowanceExhausted, isTrue); + expect(result.freeStatus, FreeUploadStatus.exceedsAllowance); + // The user still has free allowance, so this is NOT "used up". + expect(result.isFreeAllowanceExhausted, isFalse); }); - test('is not free when the free tier is off for the wallet', () async { + test('is used up (not merely exceeded) when the free tier is off', + () async { when(() => turboBalanceRetriever.getFreeAllowance(any())) .thenAnswer((_) async => const TurboFreeAllowance.disabled()); @@ -455,6 +459,7 @@ void main() { ); expect(result.isFreeUploadPossibleUsingTurbo, isFalse); + expect(result.freeStatus, FreeUploadStatus.allowanceUsedUp); expect(result.isFreeAllowanceExhausted, isTrue); }); diff --git a/test/turbo/models/free_upload_status_test.dart b/test/turbo/models/free_upload_status_test.dart new file mode 100644 index 000000000..b8eb7f932 --- /dev/null +++ b/test/turbo/models/free_upload_status_test.dart @@ -0,0 +1,113 @@ +import 'package:ardrive/turbo/models/free_upload_status.dart'; +import 'package:ardrive/turbo/models/turbo_free_allowance.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('freeUploadStatusFor', () { + test('is notEligible when an item is too large, regardless of allowance', + () { + expect( + freeUploadStatusFor( + isSizeEligible: false, + byteCount: 10, + allowance: const TurboFreeAllowance.unlimited(), + ), + FreeUploadStatus.notEligible, + ); + }); + + test('is free when the allowance covers the upload', () { + expect( + freeUploadStatusFor( + isSizeEligible: true, + byteCount: 100, + allowance: TurboFreeAllowance.bytes(200), + ), + FreeUploadStatus.free, + ); + }); + + test('is free at the exact boundary where allowance equals the upload', () { + expect( + freeUploadStatusFor( + isSizeEligible: true, + byteCount: 200, + allowance: TurboFreeAllowance.bytes(200), + ), + FreeUploadStatus.free, + ); + }); + + test('is free for an unlimited allowance', () { + expect( + freeUploadStatusFor( + isSizeEligible: true, + byteCount: 1 << 30, + allowance: const TurboFreeAllowance.unlimited(), + ), + FreeUploadStatus.free, + ); + }); + + test( + 'exceedsAllowance when some allowance remains but the upload is bigger', + () { + expect( + freeUploadStatusFor( + isSizeEligible: true, + byteCount: 201, + allowance: TurboFreeAllowance.bytes(200), + ), + FreeUploadStatus.exceedsAllowance, + ); + }); + + test('exceedsAllowance even one byte over the remaining allowance', () { + expect( + freeUploadStatusFor( + isSizeEligible: true, + byteCount: 2, + allowance: TurboFreeAllowance.bytes(1), + ), + FreeUploadStatus.exceedsAllowance, + ); + }); + + test('is allowanceUsedUp when the free tier is off (zero remaining)', () { + expect( + freeUploadStatusFor( + isSizeEligible: true, + byteCount: 100, + allowance: const TurboFreeAllowance.disabled(), + ), + FreeUploadStatus.allowanceUsedUp, + ); + }); + + test( + 'falls open to free when the allowance is unknown, so an unreachable ' + 'endpoint never forces payment', () { + expect( + freeUploadStatusFor( + isSizeEligible: true, + byteCount: 1 << 30, + allowance: const TurboFreeAllowance.unknown(), + ), + FreeUploadStatus.free, + ); + }); + + test( + 'item-size ineligibility wins over an exhausted allowance: the remedy ' + 'is a smaller item, not payment framing about the pool', () { + expect( + freeUploadStatusFor( + isSizeEligible: false, + byteCount: 100, + allowance: const TurboFreeAllowance.disabled(), + ), + FreeUploadStatus.notEligible, + ); + }); + }); +}