diff --git a/docs/implementation_plan_turbo_free_tier.md b/docs/implementation_plan_turbo_free_tier.md new file mode 100644 index 0000000000..eb01cc70b6 --- /dev/null +++ b/docs/implementation_plan_turbo_free_tier.md @@ -0,0 +1,148 @@ +# 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. + +## 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 + 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. 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 9f553b430a..f2911d52dd 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 e2e2552137..0b9fa8faf3 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 c12d04741b..e3afb5e41a 100644 --- a/lib/arns/presentation/assign_name_modal.dart +++ b/lib/arns/presentation/assign_name_modal.dart @@ -1,6 +1,8 @@ // 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'; import 'package:ardrive/authentication/ardrive_auth.dart'; @@ -124,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,7 +350,10 @@ class _AssignArNSNameModalState extends State<_AssignArNSNameModal> { ArDriveTheme.of(context).themeData.colorTokens; 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 28310e2146..687f6ba104 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, )); } } @@ -182,6 +189,8 @@ class BulkImportBloc extends Bloc { }) async { var processedFiles = 0; final failedPaths = []; + Object? lastImportError; + BulkImportResult? importResult; try { emit(BulkImportInProgress( @@ -201,7 +210,7 @@ class BulkImportBloc extends Bloc { return; } - await _bulkImportFiles( + importResult = await _bulkImportFiles( driveId: driveId, parentFolderId: parentFolderId, files: files, @@ -244,6 +253,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; @@ -251,8 +261,18 @@ 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) || + (importResult?.failures.any( + (f) => _isBulkImportPaymentError(f.originalError)) ?? + false); + 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( @@ -289,3 +309,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 433e4cf73a..930c19f39e 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 611a0a4944..63c4c19009 100644 --- a/lib/blocs/create_manifest/create_manifest_cubit.dart +++ b/lib/blocs/create_manifest/create_manifest_cubit.dart @@ -1,6 +1,9 @@ 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'; import 'package:ardrive/blocs/upload/models/payment_method_info.dart'; @@ -110,7 +113,7 @@ class CreateManifestCubit extends Cubit { (state as CreateManifestUploadReview).copyWith( uploadMethod: method, canUpload: canUpload, - freeUpload: info.isFreeThanksToTurbo, + freeStatus: info.freeStatus, assignedName: (state as CreateManifestUploadReview).assignedName, fallbackTxId: (state as CreateManifestUploadReview).fallbackTxId, ), @@ -493,7 +496,8 @@ 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 5a7836f043..15786fd67c 100644 --- a/lib/blocs/create_manifest/create_manifest_state.dart +++ b/lib/blocs/create_manifest/create_manifest_state.dart @@ -138,7 +138,9 @@ class CreateManifestUploadReview extends CreateManifestState { final String manifestName; final bool folderHasPendingFiles; final IOFile manifestFile; - final bool freeUpload; + + /// Whether this manifest upload is free, and if not, why not. + final FreeUploadStatus freeStatus; final UploadMethod? uploadMethod; final Drive drive; final FolderEntry parentFolder; @@ -152,7 +154,7 @@ class CreateManifestUploadReview extends CreateManifestState { required this.manifestName, required this.folderHasPendingFiles, required this.manifestFile, - this.freeUpload = false, + this.freeStatus = FreeUploadStatus.notEligible, this.uploadMethod, required this.drive, required this.parentFolder, @@ -162,13 +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, + freeStatus, uploadMethod, drive, parentFolder, @@ -182,7 +190,7 @@ class CreateManifestUploadReview extends CreateManifestState { String? manifestName, bool? folderHasPendingFiles, IOFile? manifestFile, - bool? freeUpload, + FreeUploadStatus? freeStatus, UploadMethod? uploadMethod, Drive? drive, FolderEntry? parentFolder, @@ -197,7 +205,7 @@ class CreateManifestUploadReview extends CreateManifestState { folderHasPendingFiles: folderHasPendingFiles ?? this.folderHasPendingFiles, manifestFile: manifestFile ?? this.manifestFile, - freeUpload: freeUpload ?? this.freeUpload, + freeStatus: freeStatus ?? this.freeStatus, uploadMethod: uploadMethod ?? this.uploadMethod, drive: drive ?? this.drive, parentFolder: parentFolder ?? this.parentFolder, @@ -227,7 +235,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 f80cfbd4ce..cd3b7e36f2 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,11 +75,12 @@ class CreateSnapshotCubit extends Cubit { bool _isTurboUploadPossible = true; bool _sufficentCreditsBalance = false; bool _sufficientArBalance = false; - bool _isFreeThanksToTurbo = 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; @@ -154,7 +156,7 @@ class CreateSnapshotCubit extends Cubit { await _computeBalanceEstimate(); _computeIsSufficientBalance(); _computeIsTurboEnabled(); - _computeIsFreeThanksToTurbo(); + await _computeIsFreeThanksToTurbo(); _computeIsButtonEnabled(); logger.d('Computed cost and balance estimate'); @@ -172,7 +174,7 @@ class CreateSnapshotCubit extends Cubit { isButtonToUploadEnabled: _isButtonToUploadEnabled, sufficientBalanceToPayWithAr: _sufficientArBalance, sufficientBalanceToPayWithTurbo: _sufficentCreditsBalance, - isFreeThanksToTurbo: _isFreeThanksToTurbo, + freeStatus: _freeStatus, ), ); } 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,27 @@ 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) { + _freeStatus = FreeUploadStatus.notEligible; + 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); + + _freeStatus = freeUploadStatusFor( + isSizeEligible: true, + byteCount: snapshotSize, + allowance: freeAllowance, + ); } void setUploadMethod(UploadMethod method) { @@ -583,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 { @@ -680,7 +696,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 fcbbf515ac..2cdd95376d 100644 --- a/lib/blocs/create_snapshot/create_snapshot_state.dart +++ b/lib/blocs/create_snapshot/create_snapshot_state.dart @@ -70,7 +70,9 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { final bool isButtonToUploadEnabled; final bool sufficientBalanceToPayWithAr; final bool sufficientBalanceToPayWithTurbo; - final bool isFreeThanksToTurbo; + + /// Whether this snapshot upload is free, and if not, why not. + final FreeUploadStatus freeStatus; ConfirmingSnapshotCreation({ required this.snapshotSize, @@ -84,9 +86,15 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { required this.isButtonToUploadEnabled, required this.sufficientBalanceToPayWithAr, required this.sufficientBalanceToPayWithTurbo, - required this.isFreeThanksToTurbo, + 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, @@ -100,7 +108,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { isButtonToUploadEnabled, sufficientBalanceToPayWithAr, sufficientBalanceToPayWithTurbo, - isFreeThanksToTurbo, + freeStatus, ]; ConfirmingSnapshotCreation copyWith({ @@ -117,7 +125,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { bool? isButtonToUploadEnabled, bool? sufficientBalanceToPayWithAr, bool? sufficientBalanceToPayWithTurbo, - bool? isFreeThanksToTurbo, + FreeUploadStatus? freeStatus, }) { return ConfirmingSnapshotCreation( snapshotSize: snapshotSize ?? this.snapshotSize, @@ -135,7 +143,7 @@ class ConfirmingSnapshotCreation extends CreateSnapshotState { sufficientBalanceToPayWithAr ?? this.sufficientBalanceToPayWithAr, sufficientBalanceToPayWithTurbo: sufficientBalanceToPayWithTurbo ?? this.sufficientBalanceToPayWithTurbo, - isFreeThanksToTurbo: isFreeThanksToTurbo ?? this.isFreeThanksToTurbo, + freeStatus: freeStatus ?? this.freeStatus, ); } } @@ -143,8 +151,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/drive_create/drive_create_cubit.dart b/lib/blocs/drive_create/drive_create_cubit.dart index e486a86128..7234703c7b 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 7c07247a49..c7d9aa44f3 100644 --- a/lib/blocs/drive_create/drive_create_state.dart +++ b/lib/blocs/drive_create/drive_create_state.dart @@ -51,12 +51,17 @@ 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); } + + @override + List get props => [privacy, isPaymentError]; } class DriveCreateWalletMismatch extends DriveCreateState { diff --git a/lib/blocs/drive_rename/drive_rename_cubit.dart b/lib/blocs/drive_rename/drive_rename_cubit.dart index 11ee64078a..ed2de94731 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 7bf128a41e..320344d5f4 100644 --- a/lib/blocs/drive_rename/drive_rename_state.dart +++ b/lib/blocs/drive_rename/drive_rename_state.dart @@ -13,7 +13,13 @@ class DriveRenameInProgress extends DriveRenameState {} class DriveRenameSuccess extends DriveRenameState {} -class DriveRenameFailure 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_cubit.dart b/lib/blocs/folder_create/folder_create_cubit.dart index 66edd207e3..6bdd1a30ca 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 7379c8f895..bec5c620d7 100644 --- a/lib/blocs/folder_create/folder_create_state.dart +++ b/lib/blocs/folder_create/folder_create_state.dart @@ -12,7 +12,13 @@ class FolderCreateInProgress extends FolderCreateState {} class FolderCreateSuccess extends FolderCreateState {} -class FolderCreateFailure 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 302466f734..41ef9b1a32 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'; @@ -162,9 +163,11 @@ class FsEntryLicenseBloc licenseParams: licenseParams, ); emit(const FsEntryLicenseSuccess()); - } catch (_, trace) { + } catch (error, trace) { + logger.e('Error licensing entities', 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 1906d1438e..a1d1e62e02 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,11 @@ class FsEntryLicenseSuccess extends FsEntryLicenseState { } class FsEntryLicenseFailure extends FsEntryLicenseState { - const FsEntryLicenseFailure() : super(); + 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_move/fs_entry_move_bloc.dart b/lib/blocs/fs_entry_move/fs_entry_move_bloc.dart index bbb76253c1..aec825eee8 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'; @@ -81,11 +82,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 +103,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 +223,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 +288,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 aeb76bbeed..09813b6796 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 27c7f22890..62bb686c19 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 96575862e4..bf99f42a78 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,13 @@ class FolderEntryRenameSuccess extends FsEntryRenameState { } class FolderEntryRenameFailure extends FsEntryRenameState { - const FolderEntryRenameFailure() : super(isRenamingFolder: true); + final bool isPaymentError; + + const FolderEntryRenameFailure({this.isPaymentError = false}) + : super(isRenamingFolder: true); + + @override + List get props => [isRenamingFolder, isPaymentError]; } class EntityAlreadyExists extends FsEntryRenameState { @@ -63,7 +69,13 @@ class FileEntryRenameSuccess extends FsEntryRenameState { } class FileEntryRenameFailure extends FsEntryRenameState { - const FileEntryRenameFailure() : super(isRenamingFolder: false); + final bool isPaymentError; + + 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_cubit.dart b/lib/blocs/ghost_fixer/ghost_fixer_cubit.dart index fd863dd63c..4cfe7e4ebc 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 8bc3ce9561..9e48569b62 100644 --- a/lib/blocs/ghost_fixer/ghost_fixer_state.dart +++ b/lib/blocs/ghost_fixer/ghost_fixer_state.dart @@ -38,6 +38,12 @@ class GhostFixerNameConflict extends GhostFixerState { List get props => [name]; } -class GhostFixerFailure 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_bloc.dart b/lib/blocs/hide/hide_bloc.dart index d7c4e56729..1b30758b44 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 76c2b96f64..4c1dad6f85 100644 --- a/lib/blocs/hide/hide_state.dart +++ b/lib/blocs/hide/hide_state.dart @@ -66,7 +66,14 @@ class SuccessHideState extends HideState { } class FailureHideState extends HideState { - const FailureHideState({required super.hideAction}); + final bool isPaymentError; + const FailureHideState({ + required super.hideAction, + this.isPaymentError = false, + }); + + @override + List get props => [hideAction, isPaymentError]; } enum HideAction { diff --git a/lib/blocs/pin_file/pin_file_bloc.dart b/lib/blocs/pin_file/pin_file_bloc.dart index 88aa965ce5..20da258c09 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 6d17e3ce40..159cd8813f 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/blocs/upload/models/payment_method_info.dart b/lib/blocs/upload/models/payment_method_info.dart index a37d0bca0d..1223b7a936 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,7 +14,9 @@ class UploadPaymentMethodInfo extends Equatable { final bool sufficientArBalance; final String turboCredits; final bool sufficentCreditsBalance; - final bool isFreeThanksToTurbo; + + /// Whether this upload is free, and if not, why not. + final FreeUploadStatus freeStatus; final UploadPlan? uploadPlanForAR; final UploadPlan? uploadPlanForTurbo; final int totalSize; @@ -29,13 +32,21 @@ class UploadPaymentMethodInfo extends Equatable { required this.sufficientArBalance, required this.turboCredits, required this.sufficentCreditsBalance, - required this.isFreeThanksToTurbo, + 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, @@ -47,7 +58,7 @@ class UploadPaymentMethodInfo extends Equatable { bool? sufficientArBalance, String? turboCredits, bool? sufficentCreditsBalance, - bool? isFreeThanksToTurbo, + FreeUploadStatus? freeStatus, UploadPlan? uploadPlanForAR, UploadPlan? uploadPlanForTurbo, int? totalSize, @@ -68,7 +79,7 @@ class UploadPaymentMethodInfo extends Equatable { turboCredits: turboCredits ?? this.turboCredits, sufficentCreditsBalance: sufficentCreditsBalance ?? this.sufficentCreditsBalance, - isFreeThanksToTurbo: isFreeThanksToTurbo ?? this.isFreeThanksToTurbo, + freeStatus: freeStatus ?? this.freeStatus, paidBy: paidBy ?? this.paidBy, ); } @@ -84,7 +95,7 @@ class UploadPaymentMethodInfo extends Equatable { sufficientArBalance, turboCredits, sufficentCreditsBalance, - isFreeThanksToTurbo, + 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 e7b9cad986..fa4dd348fa 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,8 +78,7 @@ class UploadPaymentMethodBloc costEstimateTurbo: uploadPreparation.uploadPaymentInfo.turboCostEstimate, hasNoTurboBalance: isTurboZeroBalance, - isFreeThanksToTurbo: uploadPreparation - .uploadPaymentInfo.isFreeUploadPossibleUsingTurbo, + freeStatus: uploadPreparation.uploadPaymentInfo.freeStatus, isTurboUploadPossible: paymentInfo.isUploadEligibleToTurbo, sufficentCreditsBalance: _canUploadWithMethod(UploadMethod.turbo), sufficientArBalance: _canUploadWithMethod(UploadMethod.ar), diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 7e51cdaa92..0c75dea7cd 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'; @@ -28,7 +27,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'; @@ -75,6 +75,7 @@ class UploadCubit extends Cubit { _uploadThumbnail = configService.config.uploadThumbnails, _manifestRepository = manifestRepository, _createManifestCubit = createManifestCubit, + _arDriveUploadManager = arDriveUploadManager, _autoReplaceConflicts = autoReplaceConflicts, super(uploadFolders ? UploadLoadingFolders() : UploadLoadingFiles()); @@ -88,6 +89,7 @@ class UploadCubit extends Cubit { final ARNSRepository _arnsRepository; final ManifestRepository _manifestRepository; final CreateManifestCubit _createManifestCubit; + final ArDriveUploadPreparationManager _arDriveUploadManager; final String _driveId; final String _parentFolderId; @@ -140,6 +142,9 @@ class UploadCubit extends Cubit { } Future prepareManifestUpload() async { + final freeAllowance = await _arDriveUploadManager.getFreeAllowance(); + final maxFreeItemBytes = _arDriveUploadManager.getMaxFreeItemBytes(); + final manifestModels = _selectedManifestModels .map((e) => UploadManifestModel( entry: e.manifest, @@ -176,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 <= maxFreeItemBytes && + freeAllowance.covers(manifestSize)) { manifestModels[i] = manifestModels[i].copyWith(freeThanksToTurbo: true); } } @@ -272,12 +281,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); @@ -744,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, ); @@ -1048,12 +1065,20 @@ class UploadCubit extends Cubit { _manifestFiles = {}; + final manifestFreeAllowance = + await _arDriveUploadManager.getFreeAllowance(); + final manifestMaxFreeItemBytes = + _arDriveUploadManager.getMaxFreeItemBytes(); + for (var entry in manifestFileEntries) { _manifestFiles[entry.id] = UploadManifestModel( entry: entry, existingManifestFileId: entry.id, - freeThanksToTurbo: - entry.size <= configService.config.allowedDataItemSizeForTurbo, + // Free requires both a small enough item and allowance to cover it. + // 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), ); } @@ -1275,7 +1300,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 +1369,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 +1462,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 +1537,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 abd64dde47..a398a54125 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 27d40680b9..ce2b194ccd 100644 --- a/lib/components/create_manifest_form.dart +++ b/lib/components/create_manifest_form.dart @@ -1,4 +1,6 @@ +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'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; @@ -132,6 +134,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( @@ -169,6 +174,9 @@ 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); return errorDialog( @@ -613,18 +621,10 @@ 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) ...[ Padding( padding: const EdgeInsets.only(bottom: 24), diff --git a/lib/components/create_snapshot_dialog.dart b/lib/components/create_snapshot_dialog.dart index a60e284cb7..63767bb889 100644 --- a/lib/components/create_snapshot_dialog.dart +++ b/lib/components/create_snapshot_dialog.dart @@ -1,4 +1,7 @@ +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'; import 'package:ardrive/blocs/create_snapshot/create_snapshot_cubit.dart'; import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; @@ -82,6 +85,12 @@ class CreateSnapshotDialog extends StatelessWidget { /// txsSyncedWithGqlCount: state.notSnapshottedTxsCount, ), ); + } 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); } }, builder: (context, state) { @@ -93,6 +102,10 @@ 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) { + // 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) { return _failureDialog(context, drive.id); @@ -428,17 +441,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 ...{ + TurboFreeStatusMessage( + status: state.freeStatus, + padding: const EdgeInsets.only(bottom: 12), + ), + if (!state.isFreeThanksToTurbo) ...{ PaymentMethodSelector( uploadMethodInfo: UploadPaymentMethodInfo( uploadMethod: state.uploadMethod, @@ -453,7 +460,7 @@ Widget _confirmDialog( turboCredits: state.turboCredits, sufficentCreditsBalance: state.sufficientBalanceToPayWithTurbo, - isFreeThanksToTurbo: false, + freeStatus: FreeUploadStatus.notEligible, ), onTurboTopupSucess: () { createSnapshotCubit.refreshTurboBalance(); diff --git a/lib/components/drive_create_form.dart b/lib/components/drive_create_form.dart index 23d135c3c7..e5a0e2e67e 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 c2649dc52d..b1c090124c 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: + appLocalizationsOf(context).actionFailedTryAgain, + ), + ); + } } else if (state is DriveNameAlreadyExists) { showStandardDialog( context, diff --git a/lib/components/folder_create_form.dart b/lib/components/folder_create_form.dart index 0736de8444..fff9b6cc3d 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: + appLocalizationsOf(context).actionFailedTryAgain, + ), + ); + } } 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 45b1f4947e..301f38fcfd 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'; @@ -569,15 +570,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) @@ -601,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 82a240b611..974ecc3749 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'; @@ -62,6 +63,20 @@ 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 + if (state.isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: + appLocalizationsOf(context).actionFailedTryAgain, + ), + ); + } } }, builder: (context, state) { diff --git a/lib/components/fs_entry_rename_form.dart b/lib/components/fs_entry_rename_form.dart index cc9ba29741..193dc9f954 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'; @@ -95,6 +96,24 @@ 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); + if (isPaymentError) { + showTurboPaymentRequiredDialog(context); + } else { + showArDriveDialog( + context, + content: ArDriveStandardModalNew( + title: appLocalizationsOf(context).error, + description: + appLocalizationsOf(context).actionFailedTryAgain, + ), + ); + } } 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 fe8df7708b..2329d33aa1 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: + appLocalizationsOf(context).actionFailedTryAgain, + ), + ); + } } else if (state is GhostFixerNameConflict) { showStandardDialog( context, diff --git a/lib/components/hide_dialog.dart b/lib/components/hide_dialog.dart index 13177abfa4..83bdad6dda 100644 --- a/lib/components/hide_dialog.dart +++ b/lib/components/hide_dialog.dart @@ -100,6 +100,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 +137,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 4d80b7565c..ef7b13b4af 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_free_status_message.dart b/lib/components/turbo_free_status_message.dart new file mode 100644 index 0000000000..700cfaa711 --- /dev/null +++ b/lib/components/turbo_free_status_message.dart @@ -0,0 +1,66 @@ +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) { + // 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; + + // 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( + text, + style: typography.paragraphNormal( + color: colorTokens.textMid, + fontWeight: bold ? ArFontWeight.bold : ArFontWeight.book, + ), + ), + ); + } +} diff --git a/lib/components/turbo_payment_required_dialog.dart b/lib/components/turbo_payment_required_dialog.dart new file mode 100644 index 0000000000..0474252919 --- /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/components/upload_form.dart b/lib/components/upload_form.dart index c753d30436..95726921f8 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -1,7 +1,9 @@ +import 'package:ardrive/components/turbo_free_status_message.dart'; 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/arns/presentation/assign_name_modal.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; @@ -69,7 +71,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!), @@ -1255,8 +1257,8 @@ class _UploadReadyModalState extends State { context.read(), context.read(), )..add(PrepareUploadPaymentMethod( - params: state.params, - )), + params: state.params, + )), child: UploadPaymentMethodView( useDropdown: true, onError: () { @@ -1265,7 +1267,9 @@ class _UploadReadyModalState extends State { .emitErrorFromPreparation(); }, onTurboTopupSucess: () { - context.read().startUploadPreparation( + context + .read() + .startUploadPreparation( isRetryingToPayWithTurbo: true, ); }, @@ -1934,17 +1938,10 @@ 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) ...[ RepositoryProvider.value( value: context.read(), @@ -2377,6 +2374,28 @@ 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/core/arfs/use_cases/bulk_import_files.dart b/lib/core/arfs/use_cases/bulk_import_files.dart index 1e337e808f..caafa13848 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/core/upload/uploader.dart b/lib/core/upload/uploader.dart index 0841dac860..0e94383886 100644 --- a/lib/core/upload/uploader.dart +++ b/lib/core/upload/uploader.dart @@ -11,6 +11,8 @@ 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'; import 'package:ardrive/user/user.dart'; @@ -327,6 +329,7 @@ class UploadPaymentEvaluator { final ArDriveAuth _auth; final SizeUtils sizeUtils = SizeUtils(); final AppConfig _appConfig; + final TurboUploadService? _turboUploadService; UploadPaymentEvaluator({ required TurboBalanceRetriever turboBalanceRetriever, @@ -335,12 +338,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; @@ -367,8 +379,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) { @@ -376,16 +387,24 @@ class UploadPaymentEvaluator { arCostEstimate = UploadCostEstimate.zero(); } - final allowedDataItemSizeForTurbo = _appConfig.allowedDataItemSizeForTurbo; + final allowedDataItemSizeForTurbo = _maxFreeItemBytes; - bool isFreeUploadPossibleUsingTurbo = - dataItem.getSize() <= allowedDataItemSizeForTurbo; + /// 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(); + final freeStatus = freeUploadStatusFor( + isSizeEligible: dataItemSize <= allowedDataItemSizeForTurbo, + byteCount: dataItemSize, + allowance: freeAllowance, + ); uploadMethod = await _determineUploadMethod( turboBalance.balance, dataItemSize, - dataItemSize, + allowedDataItemSizeForTurbo, _isTurboAvailableToUploadAllFiles, + freeAllowance, ); return UploadPaymentInfo( @@ -394,7 +413,7 @@ class UploadPaymentEvaluator { isUploadEligibleToTurbo: true, arCostEstimate: arCostEstimate, turboCostEstimate: turboCostEstimate, - isFreeUploadPossibleUsingTurbo: isFreeUploadPossibleUsingTurbo, + freeStatus: freeStatus, totalSize: totalSize, turboBalance: turboBalance, ); @@ -446,8 +465,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) { @@ -455,20 +473,31 @@ class UploadPaymentEvaluator { arCostEstimate = UploadCostEstimate.zero(); } - bool isFreeUploadPossibleUsingTurbo = false; + final freeAllowance = await getFreeAllowance(); + + /// Every item being small enough is not sufficient — the wallet's free + /// 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) { - final allowedDataItemSizeForTurbo = - _appConfig.allowedDataItemSizeForTurbo; + final allowedDataItemSizeForTurbo = _maxFreeItemBytes; - isFreeUploadPossibleUsingTurbo = - 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, ); } + 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 @@ -479,8 +508,9 @@ class UploadPaymentEvaluator { : await _determineUploadMethod( turboBalance.balance, turboBundleSizes, - _appConfig.allowedDataItemSizeForTurbo, + _maxFreeItemBytes, _isTurboAvailableToUploadAllFiles, + freeAllowance, ); if (uploadMethod == UploadMethod.turbo) { @@ -497,12 +527,30 @@ class UploadPaymentEvaluator { isUploadEligibleToTurbo: isUploadEligibleToTurbo, arCostEstimate: arCostEstimate, turboCostEstimate: turboCostEstimate, - isFreeUploadPossibleUsingTurbo: isFreeUploadPossibleUsingTurbo, + freeStatus: freeStatus, totalSize: totalSize, turboBalance: turboBalance, ); } + /// 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. + /// + /// 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, }) async { @@ -536,9 +584,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; @@ -577,23 +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 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, }); + + 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 { @@ -616,6 +680,16 @@ 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(); + + /// 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/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 041832baef..0572aff910 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'; @@ -12,6 +13,8 @@ part 'multi_thumbnail_creation_state.dart'; class MultiThumbnailCreationBloc extends Bloc { + bool _thumbnailPaymentError = false; + final DriveRepository _driveRepository; final ThumbnailRepository _thumbnailRepository; @@ -133,6 +136,7 @@ class MultiThumbnailCreationBloc int loadedCount = 0; + _thumbnailPaymentError = false; _worker = WorkerPool( numWorkers: drive.isPrivate ? 1 : 2, maxTasksPerWorker: 2, @@ -165,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(const MultiThumbnailCreationError(isPaymentError: true)); + return; + } + loadedDrives++; } @@ -190,7 +205,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 3759703fb3..12ae0ac32d 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; + const 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 bb8820bb6e..9ce233c800 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'; @@ -107,7 +108,15 @@ class _MultiThumbnailCreationModalContentState return BlocConsumer( bloc: widget.bloc, - listener: (context, state) {}, + 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); + } + }, builder: (context, state) { final typography = ArDriveTypographyNew.of(context); @@ -160,6 +169,11 @@ class _MultiThumbnailCreationModalContentState ); } + if (state is MultiThumbnailCreationError && state.isPaymentError) { + // Payment dialog shown from the listener; render nothing. + return const SizedBox.shrink(); + } + if (state is MultiThumbnailCreationError) { return Material( child: ArDriveStandardModalNew( diff --git a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart index c13043ed20..5b10342b96 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); + } }); }); 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 7edf597d24..159be19eeb 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 0d18824209..25b09b155f 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; + 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 a4e6699224..1178ef1114 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,6 @@ 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'; import 'package:ardrive/pages/drive_detail/models/data_table_item.dart'; @@ -42,12 +44,19 @@ 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) { if (state is ThumbnailCreationLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is ThumbnailCreationError) { + if (state.isPaymentError) { + // The payment dialog is shown from the listener. + return Text( + appLocalizationsOf(context).freeAllowanceUsedUpDescription); + } return const Text( 'An error occurred while creating the thumbnail.'); } else if (state is ThumbnailCreationSuccess) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index af5d3578ed..38a568a885 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -170,6 +170,26 @@ }, "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" + }, + "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" + }, + "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" + }, + "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 f40867ccb9..7eec865476 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -169,6 +169,11 @@ "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.", + "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 d69bad59b8..c5858fb9c7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -169,6 +169,11 @@ "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.", + "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 93892ced55..f8d68b7476 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -169,6 +169,11 @@ "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.", + "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 c2ed23a7ee..a160897984 100644 --- a/lib/l10n/app_zh-HK.arb +++ b/lib/l10n/app_zh-HK.arb @@ -169,6 +169,11 @@ "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.", + "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 967ae4abb8..e0043f7993 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -169,6 +169,11 @@ "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.", + "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/manifest/domain/manifest_repository.dart b/lib/manifest/domain/manifest_repository.dart index 937a399825..9d5d94232f 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/app_router_delegate.dart b/lib/pages/app_router_delegate.dart index 22ca4e4486..2b74ea94e6 100644 --- a/lib/pages/app_router_delegate.dart +++ b/lib/pages/app_router_delegate.dart @@ -299,6 +299,7 @@ class AppRouterDelegate extends RouterDelegate ardriveAuth: context.read(), crypto: ArDriveCrypto(), turboUploadService: context.read(), + arweave: context.read(), ), ), ], diff --git a/lib/pages/drive_detail/components/bulk_import_modal.dart b/lib/pages/drive_detail/components/bulk_import_modal.dart index ff4fbdfb0e..27d12f2c54 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/shared/blocs/private_drive_migration/private_drive_migration_bloc.dart b/lib/shared/blocs/private_drive_migration/private_drive_migration_bloc.dart index 168c164543..4a944aafc9 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)); diff --git a/lib/turbo/models/free_upload_status.dart b/lib/turbo/models/free_upload_status.dart new file mode 100644 index 0000000000..ee1e3aaecd --- /dev/null +++ b/lib/turbo/models/free_upload_status.dart @@ -0,0 +1,62 @@ +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, + + /// 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. + 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. +/// +/// 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.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/lib/turbo/models/turbo_free_allowance.dart b/lib/turbo/models/turbo_free_allowance.dart new file mode 100644 index 0000000000..2621312bdf --- /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 bda462d0e1..79850bd711 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/services/upload_service.dart b/lib/turbo/services/upload_service.dart index c69dfb9f71..6279104d2c 100644 --- a/lib/turbo/services/upload_service.dart +++ b/lib/turbo/services/upload_service.dart @@ -1,8 +1,10 @@ import 'dart:async'; +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'; @@ -16,7 +18,9 @@ class TurboUploadService { required this.turboUploadUri, required this.allowedDataItemSize, required this.httpClient, - }); + }) { + unawaited(refreshMaxItemBytes()); + } Stream postDataItemWithProgress({ required DataItem dataItem, @@ -108,31 +112,100 @@ 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'); + } + } +} + +/// 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) { + // 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 +/// 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(); + // Same-library interface implementation includes private members. + @override + int? _serverMaxItemBytes; + + @override + int get maxFreeItemSizeBytes => throw UnimplementedError(); + + @override + Future refreshMaxItemBytes() async {} + @override Future postDataItem({ required DataItem dataItem, @@ -169,3 +242,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/lib/turbo/turbo.dart b/lib/turbo/turbo.dart index 01600e4978..1f0a930c68 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/lib/utils/dependency_injection_utils.dart b/lib/utils/dependency_injection_utils.dart index a358c3ffcc..42045ec333 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), ); diff --git a/packages/ardrive_uploader/lib/ardrive_uploader.dart b/packages/ardrive_uploader/lib/ardrive_uploader.dart index 0c13db4b99..55759e0720 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'; diff --git a/packages/ardrive_uploader/lib/src/exceptions.dart b/packages/ardrive_uploader/lib/src/exceptions.dart index 228dee80f6..53b632dfb4 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_streamed_upload.dart b/packages/ardrive_uploader/lib/src/turbo_streamed_upload.dart index 5e5af91fb9..909f1b2ec7 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/turbo_upload_service.dart b/packages/ardrive_uploader/lib/src/turbo_upload_service.dart index b37f960ccb..50f36c0cd7 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/packages/ardrive_uploader/lib/src/upload_controller.dart b/packages/ardrive_uploader/lib/src/upload_controller.dart index ca9a61ba88..ec288ee4a7 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 7c94851fd7..7d7d20f5cd 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'; diff --git a/packages/ardrive_utils/lib/src/worker.dart b/packages/ardrive_utils/lib/src/worker.dart index 914625b3b6..ecb50a0c9b 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) { diff --git a/test/blocs/create_snapshot_cubit_test.dart b/test/blocs/create_snapshot_cubit_test.dart index bdc84a9ba8..0c79d22fac 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/blocs/upload_cubit_test.dart b/test/blocs/upload_cubit_test.dart index 2791867b15..310adc2ebf 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'; @@ -7,10 +8,12 @@ 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'; @@ -211,6 +214,16 @@ 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()); + + // 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(); @@ -265,7 +278,7 @@ void main() { isUploadEligibleToTurbo: false, arCostEstimate: mockUploadCostEstimateAR, turboCostEstimate: mockUploadCostEstimateTurbo, - isFreeUploadPossibleUsingTurbo: false, + freeStatus: FreeUploadStatus.notEligible, totalSize: 100, isTurboAvailable: true, turboBalance: @@ -322,6 +335,121 @@ 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, + freeStatus: FreeUploadStatus.free, + 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)); + 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( 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 0000000000..61764504b1 --- /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 4650e7d540..96f4139d3c 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'; @@ -5,6 +6,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 +262,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 +298,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 +414,107 @@ 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( + '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)); + + final result = + await uploadPaymentEvaluator.getUploadPaymentInfoForUploadPlans( + uploadPlanForAR: uploadPlan, + uploadPlanForTurbo: uploadPlan, + ); + + expect(result.isFreeUploadPossibleUsingTurbo, isFalse); + expect(result.isSizeEligibleForFree, isTrue); + expect(result.freeStatus, FreeUploadStatus.exceedsAllowance); + // The user still has free allowance, so this is NOT "used up". + expect(result.isFreeAllowanceExhausted, isFalse); + }); + + test('is used up (not merely exceeded) when the free tier is off', + () 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.freeStatus, FreeUploadStatus.allowanceUsedUp); + 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({}); @@ -539,6 +652,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( @@ -550,7 +665,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 { @@ -920,7 +1039,7 @@ void main() { isUploadEligibleToTurbo: true, arCostEstimate: mockUploadCostEstimateAR, turboCostEstimate: mockUploadCostEstimateTurbo, - isFreeUploadPossibleUsingTurbo: true, + freeStatus: FreeUploadStatus.free, totalSize: 100, isTurboAvailable: true, turboBalance: 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 0000000000..b8eb7f9326 --- /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, + ); + }); + }); +} 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 0000000000..4b7afc8548 --- /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', + ); + }); +} 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 0000000000..b9012a2258 --- /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(), + ); + }); + }); +}