Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2e40f3b
docs: implementation plan for turbo free-tier restriction PE-9132
vilenarios Jul 16, 2026
e6a88ec
feat: phase 1 of turbo free-tier readiness - typed payment failures P…
vilenarios Jul 16, 2026
c771fa4
fix: analyzer errors in phase 1 (entities import, stub private member)
vilenarios Jul 16, 2026
fe9d9ac
feat: payment-aware failure UX across all metadata ops + server thres…
vilenarios Jul 16, 2026
047645e
fix: const DriveRenameFailure constructor, drop unused hide import
vilenarios Jul 16, 2026
f40205b
fix: address CodeRabbit review on free-tier UX PE-9132
vilenarios Jul 16, 2026
dd38535
feat: extend payment-failure UX to file uploads and all remaining pos…
vilenarios Jul 17, 2026
853f553
fix: resolve analyzer issues from upload-surface coverage
vilenarios Jul 17, 2026
9b46403
fix: close the two remaining payment-UX holes found in verification P…
vilenarios Jul 17, 2026
db0443c
fix: surface payment errors swallowed by the shared WorkerPool PE-9132
vilenarios Jul 17, 2026
2ab0dc3
nit: const MultiThumbnailCreationError emit
vilenarios Jul 17, 2026
58b560e
feat: config-based direct-to-network fallback for private-drive migra…
vilenarios Jul 17, 2026
b5fb639
fix: trigger payment dialog from BlocConsumer listener, not builder P…
vilenarios Jul 17, 2026
aec1326
Merge remote-tracking branch 'origin/dev' into feat/turbo-free-tier
vilenarios Jul 22, 2026
6fa15ee
feat: only promise a free upload when the wallet allowance covers it …
Jul 22, 2026
de85bfb
fix: close two free-allowance gaps in the manifest upload paths PE-9132
Jul 22, 2026
eadd175
refactor: model free-tier state as one value and render it in one wid…
Jul 23, 2026
90f8e46
fix: address CodeRabbit review on the free-tier work PE-9132
Jul 23, 2026
45569a1
fix: check free allowance regardless of turbo flag; dismiss thumbnail…
Jul 23, 2026
a60a001
feat: distinguish an upload exceeding the free allowance from it bein…
Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions docs/implementation_plan_turbo_free_tier.md
Original file line number Diff line number Diff line change
@@ -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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Define partial-failure semantics for multi-item moves.

The inventory says moves post one item at a time. Posting before committing prevents one-item divergence, but item 1 can still commit locally while item 2 fails remotely. Specify whether the operation is atomic, resumable, or rolled back, and test that behavior.

Also applies to: 63-65

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/implementation_plan_turbo_free_tier.md` at line 23, Update the
multi-item move implementation around fs_entry_move_bloc.dart’s move flow to
define and implement explicit partial-failure semantics, choosing atomic,
resumable, or rollback behavior for cases where an earlier item commits locally
and a later remote post fails. Document the chosen contract in the
implementation plan and add tests covering the partial-failure scenario.

| 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.
2 changes: 1 addition & 1 deletion lib/blocs/drive_create/drive_create_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class DriveCreateCubit extends Cubit<DriveCreateState> {

@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);
Expand Down
6 changes: 4 additions & 2 deletions lib/blocs/drive_create/drive_create_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ class DriveCreateSuccess extends DriveCreateState {
}

class DriveCreateFailure extends DriveCreateState {
const DriveCreateFailure({required super.privacy});
final bool isPaymentError;
const DriveCreateFailure({required super.privacy, this.isPaymentError = false});

@override
DriveCreateFailure copyWith({DrivePrivacy? privacy}) {
return DriveCreateFailure(privacy: privacy ?? this.privacy);
return DriveCreateFailure(
privacy: privacy ?? this.privacy, isPaymentError: isPaymentError);
Comment on lines +54 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include isPaymentError in failure-state equality.

The new payment classification is not included in Equatable equality, so payment and non-payment failures can compare equal and suppress distinct state transitions.

  • lib/blocs/drive_create/drive_create_state.dart#L54-L60: override failure-state props to include isPaymentError.
  • lib/blocs/drive_rename/drive_rename_state.dart#L16-L18: override failure-state props to include isPaymentError.
  • lib/blocs/folder_create/folder_create_state.dart#L15-L18: override failure-state props to include isPaymentError.
📍 Affects 3 files
  • lib/blocs/drive_create/drive_create_state.dart#L54-L60 (this comment)
  • lib/blocs/drive_rename/drive_rename_state.dart#L16-L18
  • lib/blocs/folder_create/folder_create_state.dart#L15-L18
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/blocs/drive_create/drive_create_state.dart` around lines 54 - 60, Update
the failure-state equality props to include isPaymentError in DriveCreateFailure
at lib/blocs/drive_create/drive_create_state.dart:54-60, DriveRenameFailure at
lib/blocs/drive_rename/drive_rename_state.dart:16-18, and FolderCreateFailure at
lib/blocs/folder_create/folder_create_state.dart:15-18, preserving each state’s
existing equality fields.

}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/blocs/drive_rename/drive_rename_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class DriveRenameCubit extends Cubit<DriveRenameState> {

@override
void onError(Object error, StackTrace stackTrace) {
emit(DriveRenameFailure());
emit(DriveRenameFailure(isPaymentError: isTurboPaymentError(error)));

super.onError(error, stackTrace);
}
Expand Down
5 changes: 4 additions & 1 deletion lib/blocs/drive_rename/drive_rename_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ class DriveRenameInProgress extends DriveRenameState {}

class DriveRenameSuccess extends DriveRenameState {}

class DriveRenameFailure extends DriveRenameState {}
class DriveRenameFailure extends DriveRenameState {
final bool isPaymentError;
const DriveRenameFailure({this.isPaymentError = false});
}

class DriveRenameWalletMismatch extends DriveRenameState {}

Expand Down
2 changes: 1 addition & 1 deletion lib/blocs/folder_create/folder_create_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ class FolderCreateCubit extends Cubit<FolderCreateState> {

@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);
Expand Down
5 changes: 4 additions & 1 deletion lib/blocs/folder_create/folder_create_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ class FolderCreateInProgress extends FolderCreateState {}

class FolderCreateSuccess extends FolderCreateState {}

class FolderCreateFailure extends FolderCreateState {}
class FolderCreateFailure extends FolderCreateState {
final bool isPaymentError;
FolderCreateFailure({this.isPaymentError = false});
}

class FolderCreateWalletMismatch extends FolderCreateState {}

Expand Down
5 changes: 3 additions & 2 deletions lib/blocs/fs_entry_license/fs_entry_license_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,10 @@ class FsEntryLicenseBloc
licenseParams: licenseParams,
);
emit(const FsEntryLicenseSuccess());
} catch (_, trace) {
} catch (error, trace) {
addError('Error licensing entities', trace);
emit(const FsEntryLicenseFailure());
emit(FsEntryLicenseFailure(
isPaymentError: isTurboPaymentError(error)));
Comment on lines +166 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Preserve the original exception in diagnostics.

The catch block captures error, but addError records only 'Error licensing entities'. The Bloc’s error log therefore loses the exception type and message, making payment/network failures difficult to diagnose.

-      addError('Error licensing entities', trace);
+      addError(error, trace);

Based on the changed exception-handling path, the original error is available but discarded from diagnostics.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error, trace) {
addError('Error licensing entities', trace);
emit(const FsEntryLicenseFailure());
emit(FsEntryLicenseFailure(
isPaymentError: isTurboPaymentError(error)));
} catch (error, trace) {
addError(error, trace);
emit(FsEntryLicenseFailure(
isPaymentError: isTurboPaymentError(error)));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/blocs/fs_entry_license/fs_entry_license_bloc.dart` around lines 165 -
168, Update the catch block in the licensing flow to include the captured error
in the addError call, preserving the original exception type and message
alongside the existing context and trace. Keep the FsEntryLicenseFailure
emission and isTurboPaymentError(error) handling unchanged.

}
}

Expand Down
3 changes: 2 additions & 1 deletion lib/blocs/fs_entry_license/fs_entry_license_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ class FsEntryLicenseSuccess extends FsEntryLicenseState {
}

class FsEntryLicenseFailure extends FsEntryLicenseState {
const FsEntryLicenseFailure() : super();
final bool isPaymentError;
const FsEntryLicenseFailure({this.isPaymentError = false}) : super();
}

class FsEntryLicenseComplete extends FsEntryLicenseState {
Expand Down
Loading
Loading