diff --git a/lib/components/profile_card.dart b/lib/components/profile_card.dart index ef3f7a1225..68398c7677 100644 --- a/lib/components/profile_card.dart +++ b/lib/components/profile_card.dart @@ -11,7 +11,11 @@ import 'package:ardrive/entities/profile_types.dart'; import 'package:ardrive/gar/domain/repositories/gar_repository.dart'; import 'package:ardrive/gar/presentation/widgets/gateway_input_modal.dart'; import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; +import 'package:ardrive/permanence/permanence_panel.dart'; +import 'package:ardrive/permanence/ar_spend.dart'; +import 'package:ardrive/turbo/topup/models/crypto_token.dart'; import 'package:ardrive/services/arweave/arweave_service.dart'; +import 'package:ardrive/utils/local_key_value_store.dart'; import 'package:ardrive/services/config/config.dart'; import 'package:ardrive/services/config/config_service.dart'; import 'package:ardrive/utils/constants.dart'; @@ -55,6 +59,17 @@ class _ProfileCardState extends State { /// Whose statistics [_accountStatsFuture] holds. String? _accountStatsForWallet; + /// The last stats that finished, shown while a fresh count runs, so the + /// line does not blink out every time the card opens. + _AccountStats? _lastAccountStats; + + /// The last stats counted, and what they were counted from: the wallet, + /// its drives and [DriveDao.permanentDataVersion]. The card counts again + /// each time it opens, and while nothing it counts has changed that is + /// this, rather than a scan of every revision. + _AccountStats? _countedStats; + String? _countedStatsKey; + @override Widget build(BuildContext context) { return BlocBuilder( @@ -436,6 +451,10 @@ class _ProfileCardState extends State { // while the profile changes between logged-in and logged-out, so a plain // `??=` let a second account read the first one's drive count, file count // and size. + if (_accountStatsForWallet != walletAddress) { + _lastAccountStats = null; + } + if (_accountStatsFuture == null || _accountStatsForWallet != walletAddress) { _accountStatsForWallet = walletAddress; @@ -445,17 +464,170 @@ class _ProfileCardState extends State { return FutureBuilder<_AccountStats>( future: _accountStatsFuture, builder: (context, snapshot) { - if (!snapshot.hasData) return const SizedBox.shrink(); - final stats = snapshot.data!; + if (snapshot.hasData) { + _lastAccountStats = snapshot.data; + } + + final stats = snapshot.data ?? _lastAccountStats; + + // Nothing synced, or nothing owned: no line saying "0 drives". The + // numbers appear once there is something to count. + if (stats == null || stats.driveCount == 0) { + return const SizedBox.shrink(); + } + + // What is stored for good - each piece of data once, only what + // landed - rather than the size of every file row above. + final permanentBytes = stats.permanentBytes; + return Padding( padding: const EdgeInsets.only(bottom: 8), - child: Center( - child: Text( - '${stats.driveCount} ${stats.driveCount == 1 ? 'drive' : 'drives'} · ${stats.fileCount} ${stats.fileCount == 1 ? 'file' : 'files'} · ${_formatBytes(stats.totalSize)}', - style: typography.caption( - color: colorTokens.textLow, - fontWeight: ArFontWeight.book, - ), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + [ + permanenceCount(stats.driveCount, 'drive'), + permanenceCount(stats.fileCount, 'file'), + formatPermanenceSize(stats.permanentBytes), + ].join(permanenceSeparator), + style: typography.caption( + color: colorTokens.textLow, + fontWeight: ArFontWeight.book, + ), + ), + // A way in, not a figure: the panel says what it costs. + if (permanentBytes > 0) ...[ + const SizedBox(height: 4), + ArDriveClickArea( + child: GestureDetector( + onTap: () { + // Everything below is asked for here, when the panel + // opens, and shown as it arrives - never on the card. + final paymentUrl = context + .read() + .config + .defaultTurboPaymentUrl ?? + 'https://payment.ardrive.io'; + final paymentService = context.read(); + final arweave = context.read(); + final wallet = + context.read().currentUser.wallet; + + // A signed request. A keyfile, or the wallet an + // Ethereum or Solana sign-in derives, signs without + // a word, so it is read as the panel opens. A browser + // extension shows its owner a prompt instead, so + // there it waits until they ask. + Future readSpend() => loadCreditsSpent( + paymentService: paymentService, + wallet: wallet, + ); + final signsSilently = context + .read() + .currentUser + .profileType != + ProfileType.arConnect; + + // Public, so no signature: what this wallet paid in + // AR to upload directly, counted a page at a time and + // remembered. See [tallyArSpend]. + final owner = context + .read() + .currentUser + .walletAddress; + // Which transactions this wallet's synced drives + // are made of: what makes one of its payments ArDrive + // storage when its tags don't say. Asked only about + // those, never for the whole set. See + // [isArDriveTransaction]. + final driveDao = context.read(); + Future> knownAmong(Set txIds) => + driveDao.arDriveTransactionsAmong( + stats.driveIds, + txIds, + ); + final arSpend = LocalKeyValueStore.getInstance().then( + (store) async => tallyArSpend( + // v4: counts made without the tips direct + // uploads sent, or before the drives' own + // transactions decided what is ArDrive's. + storageKey: 'permanenceArSpend_v4_$owner', + read: store.getString, + write: (key, value) => store.putString(key, value), + knownAmong: knownAmong, + fetchPage: ({required minHeight, after}) async => + arweave.getArDriveFeesPage( + owner: owner, + minHeight: minHeight, + knownAmong: knownAmong, + after: after, + ), + ), + ); + + // What the network charges today for the same size, + // from the configured gateway, with AR's dollar price. + final costToday = loadNetworkCost( + price: () => + arweave.getPrice(byteSize: permanentBytes), + usdPerAr: fetchUsdPerAr(paymentUrl), + ); + + setState(() { + _showProfileCard = false; + }); + + showPermanencePanel( + context, + PermanenceSummary( + totalBytes: permanentBytes, + fileCount: stats.fileCount, + driveCount: stats.driveCount, + bytesByYear: { + for (final year in stats.permanentByYear.entries) + year.key: year.value.bytes, + }, + costToday: costToday, + spend: signsSilently ? readSpend() : null, + loadSpend: signsSilently ? null : readSpend, + signInChain: switch (walletTypeOfSourceAddress( + context + .read() + .currentUser + .sourceWalletAddress, + )) { + WalletType.ethereum => 'Ethereum', + WalletType.solana => 'Solana', + _ => null, + }, + arSpend: arSpend, + ), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Your permanence', + style: typography.caption( + color: colorTokens.textRed, + fontWeight: ArFontWeight.semiBold, + ), + ), + const SizedBox(width: 2), + ArDriveIcons.carretRight( + size: 14, + color: colorTokens.textRed, + ), + ], + ), + ), + ), + ], + ], ), ), ); @@ -463,28 +635,17 @@ class _ProfileCardState extends State { ); } - /// What this account holds locally, counted the way the drives list counts - /// it. - /// - /// This used `filesInDriveWithRevisionTransactions`, once per drive, and - /// added up the rows it returned. That query inner-joins each file to the - /// metadata and data transactions of its latest revision, so **a file whose - /// transaction rows have not synced yet is not in the result at all** - it is - /// dropped from the count and its bytes from the total. As a sync fills those - /// rows in, the same drive reports a larger total than it did before, which - /// is what a reader sees as "the same drive, a different number of GB in each - /// session". + /// What this account keeps on Arweave, as far as this device knows: its own + /// drives' data, every version, each piece once, only once its upload is + /// confirmed, and not pinned files. See [DriveDao.permanentDataByYear]. /// - /// [DriveDao.driveContentSummaries] asks the question this line is actually - /// asking: how many files are in the local tables, and how big are they. One - /// aggregate query for every drive rather than one query per drive returning - /// every row, summed in SQL rather than in Dart - and, being the same source - /// the drives list reads, it cannot disagree with the per-drive figures shown - /// there. + /// The same figure the permanence panel shows, so the two can never + /// disagree. It used to be every file row, from the query the drives list + /// reads - which counted a copy twice, and an upload that never landed - so + /// this line said 13.1 GB beside a panel that said 12.4. /// - /// Both numbers still describe this device rather than Arweave: a drive that - /// has not been walked contributes nothing, because nothing about it is - /// known. + /// It describes this device rather than Arweave: a drive that has not been + /// walked contributes nothing, because nothing about it is known. Future<_AccountStats> _getAccountStats( DriveDao driveDao, String walletAddress, @@ -510,36 +671,30 @@ class _ProfileCardState extends State { (drive) => drive.ownerAddress == walletAddress && !drive.isHidden) .toList(); - final summaries = await driveDao.driveContentSummaries(); - - var fileCount = 0; - var totalSize = 0; - - for (final drive in drives) { - final summary = summaries[drive.id]; + final driveIds = [for (final drive in drives) drive.id]; + final key = [ + walletAddress, + ...driveIds, + await driveDao.permanentDataVersion(), + ].join('|'); - if (summary == null) { - continue; - } - - fileCount += summary.fileCount; - totalSize += summary.totalSize; + if (key == _countedStatsKey && _countedStats != null) { + return _countedStats!; } - return _AccountStats( + final permanentByYear = await driveDao.permanentDataByYear(driveIds); + final fileCount = await driveDao.permanentFileCount(driveIds); + + final stats = _AccountStats( driveCount: drives.length, fileCount: fileCount, - totalSize: totalSize, + permanentByYear: permanentByYear, + driveIds: driveIds, ); - } + _countedStats = stats; + _countedStatsKey = key; - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + return stats; } Widget _buildIdentityHeader(BuildContext context, ProfileLoggedIn state) { @@ -1522,12 +1677,25 @@ Color getWalletIndicatorColor(User user) { class _AccountStats { final int driveCount; + + /// What is stored for good, by the year it was uploaded. See + /// [DriveDao.permanentDataByYear]. + final Map permanentByYear; + + /// The wallet's own drives that were counted. + final List driveIds; + + /// Files with at least one version stored for good. See + /// [DriveDao.permanentFileCount]. final int fileCount; - final int totalSize; + + int get permanentBytes => + permanentByYear.values.fold(0, (sum, year) => sum + year.bytes); _AccountStats({ required this.driveCount, - required this.fileCount, - required this.totalSize, + this.fileCount = 0, + this.permanentByYear = const {}, + this.driveIds = const [], }); } diff --git a/lib/models/daos/drive_dao/drive_dao.dart b/lib/models/daos/drive_dao/drive_dao.dart index c4462ac412..0e3193963d 100644 --- a/lib/models/daos/drive_dao/drive_dao.dart +++ b/lib/models/daos/drive_dao/drive_dao.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'package:ardrive/utils/snapshots/misdecoded_text.dart'; import 'package:ardrive/utils/preview_byte_cache.dart'; @@ -1002,6 +1003,186 @@ class DriveDao extends DatabaseAccessor with _$DriveDaoMixin { }; } + /// What [driveIds] hold permanently, by the year it was uploaded, oldest + /// first. + /// + /// Stricter than [driveContentSummaries], which counts every file row, + /// because this is a figure somebody may hold us to: + /// + /// - **Every version.** Read from the revisions, not the files: a file's + /// earlier versions are stored, and were paid for, as much as its + /// newest. + /// - **Each piece of data once.** A file copied to a second folder, or + /// renamed, is another revision over the same data transaction, stored + /// once and paid for once. + /// - **Only data that landed.** An upload still pending, or one that + /// failed, is not preserved. Data with no transaction row at all was + /// synced from the network, so it is there. + /// - **Not pinned files.** A pin points at somebody else's data, which + /// they paid to store. + /// + /// A piece of data is dated by the first revision that carried it: when + /// it was uploaded, not when its file was first made. + Future> permanentDataByYear( + Iterable driveIds, + ) async { + final ids = driveIds.toList(); + + if (ids.isEmpty) { + return {}; + } + + final rows = await customSelect( + ''' + SELECT CAST(strftime('%Y', created, 'unixepoch') AS INTEGER) AS year, + SUM(size) AS bytes, + COUNT(*) AS items + FROM ( + SELECT r.dataTxId, MIN(r.dateCreated) AS created, MAX(r.size) AS size + FROM file_revisions r + WHERE ${_permanentRevisions(ids)} + GROUP BY r.dataTxId + ) + GROUP BY year + ORDER BY year + ''', + variables: [for (final id in ids) Variable.withString(id)], + readsFrom: {fileRevisions, networkTransactions}, + ).get(); + + return { + for (final row in rows) + if (row.read('year') != null) + row.read('year'): PermanentData( + bytes: row.read('bytes') ?? 0, + items: row.read('items'), + ), + }; + } + + /// How many files in [driveIds] have at least one version stored for + /// good, counted as [permanentDataByYear] counts their data. A copy is a + /// file of its own here, though its data is counted once there. + Future permanentFileCount(Iterable driveIds) async { + final ids = driveIds.toList(); + + if (ids.isEmpty) { + return 0; + } + + final row = await customSelect( + ''' + SELECT COUNT(DISTINCT r.fileId) AS files + FROM file_revisions r + WHERE ${_permanentRevisions(ids)} + ''', + variables: [for (final id in ids) Variable.withString(id)], + readsFrom: {fileRevisions, networkTransactions}, + ).getSingle(); + + return row.read('files'); + } + + /// Changes whenever [permanentDataByYear] or [permanentFileCount] could: + /// when a revision is written, or an upload starts, lands or fails. For + /// deciding whether figures already counted still hold, at a fraction of a + /// millisecond against a scan of every revision. + /// + /// Revisions are only ever added, so the newest row's id moves with every + /// one written. An upload that lands leaves the pending count; one that + /// fails moves to the failed count. + Future permanentDataVersion() async { + final row = await customSelect( + ''' + SELECT (SELECT MAX(rowid) FROM file_revisions) AS revisions, + (SELECT COUNT(*) FROM network_transactions + WHERE status = '${TransactionStatus.pending}') AS pending, + (SELECT COUNT(*) FROM network_transactions + WHERE status = '${TransactionStatus.failed}') AS failed + ''', + readsFrom: {fileRevisions, networkTransactions}, + ).getSingle(); + + return '${row.read('revisions')}:${row.read('pending')}' + ':${row.read('failed')}'; + } + + /// The revisions of [ids] whose data is stored for good: not a pin, and + /// not an upload still pending or one that failed. Binds one variable per + /// drive id. + String _permanentRevisions(List ids) => ''' + r.driveId IN (${List.filled(ids.length, '?').join(', ')}) + AND r.pinnedDataOwnerAddress IS NULL + AND NOT EXISTS ( + SELECT 1 FROM network_transactions t + WHERE t.id = r.dataTxId + AND t.status IN ('${TransactionStatus.pending}', + '${TransactionStatus.failed}') + )'''; + + /// Which of [txIds] [driveIds] are made of, as sync recorded them: the + /// metadata and data of every file revision, the metadata of every folder + /// and drive revision, and the bundles any of them travelled in. + /// + /// What makes a wallet's transaction ArDrive storage, for the AR it paid + /// directly - the same drives the permanence panel sizes, known the way + /// sync knows them rather than guessed from an App-Name tag. + /// + /// Asked about the transactions in hand, never for the whole set: a drive + /// of 100,000 files is made of 400,000 of them, which took a second and a + /// half to read out natively and more on the web, where every row crosses + /// to the page from the database worker. Asked in batches of + /// [_txLookupBatch], to stay well inside SQLite's limit on variables. + Future> arDriveTransactionsAmong( + Iterable driveIds, + Iterable txIds, + ) async { + final drives = driveIds.toList(); + final candidates = txIds.toSet().toList(); + + if (drives.isEmpty || candidates.isEmpty) { + return {}; + } + + final drivePlaceholders = List.filled(drives.length, '?').join(', '); + final found = {}; + + for (var i = 0; i < candidates.length; i += _txLookupBatch) { + final batch = candidates.sublist( + i, math.min(i + _txLookupBatch, candidates.length)); + final txPlaceholders = List.filled(batch.length, '?').join(', '); + // The transaction first: dataTxId has an index, and for the rest + // SQLite checks each row against the batch in one pass. + String among(String column, String table) => ''' + SELECT $column AS tx FROM $table + WHERE $column IN ($txPlaceholders) AND driveId IN ($drivePlaceholders)'''; + + final rows = await customSelect( + [ + among('dataTxId', 'file_revisions'), + among('metadataTxId', 'file_revisions'), + among('bundledIn', 'file_revisions'), + among('metadataTxId', 'folder_revisions'), + among('metadataTxId', 'drive_revisions'), + among('bundledIn', 'drive_revisions'), + ].join('\nUNION '), + variables: [ + for (var branch = 0; branch < 6; branch++) ...[ + for (final tx in batch) Variable.withString(tx), + for (final drive in drives) Variable.withString(drive), + ], + ], + readsFrom: {fileRevisions, folderRevisions, driveRevisions}, + ).get(); + + found.addAll(rows.map((row) => row.read('tx'))); + } + + return found; + } + + static const _txLookupBatch = 300; + /// Undoes names damaged by reading drive snapshots as Latin-1, in every /// table that keeps one: drives, folders and files, and their revisions. /// Returns how many it repaired. @@ -1130,3 +1311,17 @@ class DriveNotFoundException implements Exception { return 'Drive with id $driveId not found'; } } + +/// Data stored permanently in one year: see [DriveDao.permanentDataByYear]. +class PermanentData extends Equatable { + const PermanentData({required this.bytes, required this.items}); + + final int bytes; + + /// Pieces of data, each counted once however many files or versions + /// point at it. + final int items; + + @override + List get props => [bytes, items]; +} diff --git a/lib/permanence/ar_spend.dart b/lib/permanence/ar_spend.dart new file mode 100644 index 0000000000..0bb12ee78c --- /dev/null +++ b/lib/permanence/ar_spend.dart @@ -0,0 +1,402 @@ +import 'dart:convert'; + +/// One page of a wallet's directly paid ArDrive uploads. See +/// `ArweaveService.getArDriveFeesPage`. +class ArFeePage { + const ArFeePage({ + required this.winston, + required this.count, + required this.lastHeight, + required this.cursor, + required this.hasMore, + this.excluded = const {}, + }); + + /// What was paid on this page: fees, and tips. See [arDrivePayment]. + final BigInt winston; + + /// Transactions on this page. + final int count; + + /// The block height of the page's last transaction, if it had any. + final int? lastHeight; + + /// Where the next page starts. + final String? cursor; + + final bool hasMore; + + /// Paid transactions on this page that were not taken for ArDrive's, by + /// id, with their fees. See [ArSpendTally.excluded]. + final Map excluded; +} + +/// What a wallet has paid in AR to upload with ArDrive directly, counted so +/// far. +class ArSpendTally { + const ArSpendTally({ + required this.winston, + required this.uploads, + required this.lastHeight, + this.runMinHeight, + this.cursor, + this.runBase, + this.excluded = const {}, + }); + + static final empty = ArSpendTally( + winston: BigInt.zero, + uploads: 0, + lastHeight: -1, + ); + + final BigInt winston; + final int uploads; + + /// The highest block counted. + final int lastHeight; + + /// Set while a count is unfinished: the height it started from, and where + /// it got to. The next count carries on from there rather than starting + /// again. + final int? runMinHeight; + final String? cursor; + + /// What had been counted before the unfinished count began, so it can be + /// started again cleanly - from its first page, without counting twice - + /// when its cursor stops working: a gateway switched, a cursor expired. + final ArSpendTally? runBase; + + /// Paid transactions a count left out because nothing yet said they were + /// ArDrive's, by id, with their fees. + /// + /// The count moves on past them, so without this one left out before its + /// drive had synced - the data of a direct upload carries no tag that says + /// whose it is - would never be counted. Each count looks at these again + /// first, against what sync knows by then. At most [maxExcluded], the most + /// recent kept. + final Map excluded; + + static const maxExcluded = 5000; + + /// Whether everything up to the last count has been counted. When it is + /// not, the figure is a lower bound. + bool get complete => cursor == null; + + /// The amount in AR (a winston is a trillionth of one). + double get ar => winston / BigInt.from(10).pow(12); + + Map toJson() => { + 'winston': winston.toString(), + 'uploads': uploads, + 'lastHeight': lastHeight, + if (runMinHeight != null) 'runMinHeight': runMinHeight, + if (cursor != null) 'cursor': cursor, + if (runBase != null) 'runBase': runBase!.toJson(), + if (excluded.isNotEmpty) + 'excluded': { + for (final entry in excluded.entries) + entry.key: entry.value.toString(), + }, + }; + + static ArSpendTally? fromJson(String? source) { + if (source == null) { + return null; + } + + try { + return _fromMap(jsonDecode(source) as Map); + } catch (_) { + return null; + } + } + + static ArSpendTally _fromMap(Map json) => ArSpendTally( + winston: BigInt.parse(json['winston'] as String), + uploads: json['uploads'] as int, + lastHeight: json['lastHeight'] as int, + runMinHeight: json['runMinHeight'] as int?, + cursor: json['cursor'] as String?, + runBase: json['runBase'] == null + ? null + : _fromMap(json['runBase'] as Map), + excluded: { + for (final entry + in ((json['excluded'] as Map?) ?? {}).entries) + entry.key: BigInt.parse(entry.value as String), + }, + ); + + /// Everything this tally, and the count it carries on from, left out. + Set get excludedIds => { + ...excluded.keys, + ...?runBase?.excludedIds, + }; + + /// This tally with whatever it left out that [knownTxIds] now say is + /// ArDrive's counted in. + ArSpendTally reclaim(Set knownTxIds) { + final found = excluded.keys.where(knownTxIds.contains).toList(); + final base = runBase?.reclaim(knownTxIds); + + if (found.isEmpty && identical(base, runBase)) { + return this; + } + + return ArSpendTally( + winston: found.fold(winston, (sum, id) => sum + excluded[id]!), + uploads: uploads + found.length, + lastHeight: lastHeight, + runMinHeight: runMinHeight, + cursor: cursor, + runBase: base, + excluded: { + for (final entry in excluded.entries) + if (!knownTxIds.contains(entry.key)) entry.key: entry.value, + }, + ); + } +} + +/// [current] with [more] added, keeping the most recent +/// [ArSpendTally.maxExcluded]. +Map _withExcluded( + Map current, + Map more, +) { + final all = {...current, ...more}; + + if (all.length <= ArSpendTally.maxExcluded) { + return all; + } + + return Map.fromEntries( + all.entries.skip(all.length - ArSpendTally.maxExcluded), + ); +} + +/// Counts what a wallet has paid in AR, a little at a time. +/// +/// Remembers what it has counted, under [storageKey], so the first count is +/// the only long one: each later count asks only for blocks after the last +/// one counted. No count makes more than [maxPages] requests; one that would +/// carries on where it stopped next time, and says the figure so far is a +/// lower bound. +/// +/// Any request that fails ends the count with what was already counted, +/// rather than with nothing. A count that was carrying on from a cursor is +/// set back to where it began, so the next one starts it again from its +/// first page: a cursor that has stopped working would otherwise fail every +/// time, and the figure would say "at least" forever. +Future tallyArSpend({ + required String storageKey, + required String? Function(String key) read, + required Future Function(String key, String value) write, + required Future Function({required int minHeight, String? after}) + fetchPage, + KnownTxLookup? knownAmong, + int maxPages = 10, +}) async { + final stored = ArSpendTally.fromJson(read(storageKey)) ?? ArSpendTally.empty; + + // What an earlier count left out, before sync knew it was ArDrive's. Only + // those are asked about, and only when there are any. + var tally = stored.reclaim( + await lookUpKnown(knownAmong, stored.excludedIds), + ); + + // What this count adds to, kept until it finishes. + final base = tally.runBase ?? + ArSpendTally( + winston: tally.winston, + uploads: tally.uploads, + lastHeight: tally.lastHeight, + excluded: tally.excluded, + ); + final minHeight = tally.runMinHeight ?? tally.lastHeight + 1; + var cursor = tally.cursor; + var winston = tally.winston; + var uploads = tally.uploads; + var lastHeight = tally.lastHeight; + var excluded = tally.excluded; + + for (var page = 0; page < maxPages; page++) { + final ArFeePage result; + + try { + result = await fetchPage(minHeight: minHeight, after: cursor); + } catch (_) { + if (cursor != null) { + await write(storageKey, jsonEncode(base.toJson())); + } + + return tally; + } + + winston += result.winston; + uploads += result.count; + excluded = _withExcluded(excluded, result.excluded); + if (result.lastHeight != null && result.lastHeight! > lastHeight) { + lastHeight = result.lastHeight!; + } + + tally = ArSpendTally( + winston: winston, + uploads: uploads, + lastHeight: lastHeight, + runMinHeight: result.hasMore ? minHeight : null, + cursor: result.hasMore ? result.cursor : null, + runBase: result.hasMore ? base : null, + excluded: excluded, + ); + await write(storageKey, jsonEncode(tally.toJson())); + + if (!result.hasMore) { + break; + } + + cursor = result.cursor; + } + + return tally; +} + +/// One page as the gateway answered it, before deciding what it means. +class RawFeePage { + const RawFeePage({ + required this.page, + required this.rows, + required this.unbundledRows, + }); + + /// What the page adds up to: ArDrive's unbundled transactions only. + final ArFeePage page; + + /// Every row the gateway returned, bundled or not. + final int rows; + + /// Rows that are transactions of their own, not data items in a bundle. + final int unbundledRows; +} + +/// How to ask a gateway for a wallet's own transactions without the data +/// items in bundles - which are free to the wallet, and for a Turbo user are +/// every upload they ever made. +/// +/// The standard filter, `bundledIn: null`, is ignored by ar.io gateways +/// (ardrive.net among them): they return the data items anyway, so a Turbo +/// user's count paged through every upload and never finished. Those +/// gateways do honour `bundledIn: [""]`, which is not in any spec. So that is +/// asked first, and a gateway that answers it with nothing is asked once the +/// standard way: if that brings back transactions of their own, this gateway +/// honours `null`, and is asked that way from then on. +class UnbundledFilter { + /// Whether to ask with `[""]` rather than `null`. + bool useEmptyId = true; + + /// The filter to send. + List? get bundledIn => useEmptyId ? const [''] : null; +} + +/// A page of [UnbundledFilter]-filtered transactions, learning which filter +/// the gateway honours on the way. Costs a second request only when the +/// first page of a count is empty. +Future fetchDirectFeesPage({ + required UnbundledFilter filter, + required bool firstPage, + required Future Function(List? bundledIn) fetch, +}) async { + final raw = await fetch(filter.bundledIn); + + if (!filter.useEmptyId || !firstPage || raw.rows > 0) { + return raw.page; + } + + final standard = await fetch(null); + + if (standard.unbundledRows > 0) { + filter.useEmptyId = false; + return standard.page; + } + + // Nothing of its own either way: a wallet that only ever used Turbo. + return raw.page; +} + +/// Which of [txIds] the wallet's synced drives are made of. See +/// `DriveDao.arDriveTransactionsAmong`. +typedef KnownTxLookup = Future> Function(Set txIds); + +/// [knownAmong] asked about [txIds], and nothing asked when there is nothing +/// to ask. A lookup that fails knows nothing: what it would have counted is +/// left out for now, and asked about again by the next count. +Future> lookUpKnown( + KnownTxLookup? knownAmong, + Set txIds, +) async { + if (knownAmong == null || txIds.isEmpty) { + return const {}; + } + + try { + return await knownAmong(txIds); + } catch (_) { + return const {}; + } +} + +/// Whether a transaction a wallet paid for is ArDrive storage. +/// +/// First by what sync knows: [knownTxIds] are the transactions the wallet's +/// synced drives are made of, data and bundles included - see +/// `DriveDao.arDriveTransactionIds`. Then by tag, for what has not synced +/// yet: an `ArFS` tag, which every file and folder metadata transaction +/// carries, or an App-Name one of ArDrive's apps has written. +/// +/// The tags alone missed what matters most: the data transaction of a direct +/// upload is the costly part, and it carries an App-Name but no `ArFS` tag. +bool isArDriveTransaction({ + required String txId, + required Map tags, + required Set knownTxIds, +}) => + knownTxIds.contains(txId) || isArDriveByTags(tags); + +/// Whether a transaction's tags alone say it is ArDrive's: an `ArFS` tag, or +/// an App-Name of one of ArDrive's apps. What they leave open is for +/// [isArDriveTransaction]'s known transactions to settle. +bool isArDriveByTags(Map tags) => + tags.containsKey('ArFS') || arDriveAppNames.contains(tags['App-Name']); + +/// What a wallet paid for one of its ArDrive transactions, in winston: the +/// network's fee, and the tip a direct upload sent ArDrive's community with +/// it. +/// +/// The tip travels as the transaction's own transfer to a token holder, +/// tagged `Tip-Type`, so it is not in the fee. Any other transfer is not a +/// payment for storage and is left out. +BigInt arDrivePayment({ + required String feeWinston, + required String quantityWinston, + required Map tags, +}) { + final fee = BigInt.tryParse(feeWinston) ?? BigInt.zero; + + if (!tags.containsKey('Tip-Type')) { + return fee; + } + + return fee + (BigInt.tryParse(quantityWinston) ?? BigInt.zero); +} + +/// The App-Name values ArDrive's apps have tagged uploads with: a fallback +/// for transactions sync has not recorded yet. +const arDriveAppNames = { + 'ArDrive-App', + 'ArDrive-Web', + 'ArDrive-CLI', + 'ArDrive-Core', + 'ArDrive-Desktop', + 'ArDrive-Sync', +}; diff --git a/lib/permanence/permanence_panel.dart b/lib/permanence/permanence_panel.dart new file mode 100644 index 0000000000..38db611fb0 --- /dev/null +++ b/lib/permanence/permanence_panel.dart @@ -0,0 +1,1295 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:ardrive/misc/resources.dart'; +import 'package:ardrive/permanence/ar_spend.dart'; +import 'package:ardrive/permanence/spend_estimate.dart'; +import 'package:ardrive/turbo/services/payment_service.dart'; +import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive/utils/open_url.dart'; +import 'package:ardrive/utils/show_general_dialog.dart'; +import 'package:ardrive_io/ardrive_io.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:arweave/arweave.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:intl/intl.dart' hide TextDirection; + +/// What the Arweave network charges, today, to store a given size for good. +class NetworkCost { + const NetworkCost({required this.winston, required this.usdPerAr}); + + /// The network's price, from a gateway's `/price` for the exact size. + final BigInt winston; + + /// What one AR is worth in US dollars now. Null when it could not be read, + /// and the price is shown in AR alone. + final double? usdPerAr; + + double get ar => winston / BigInt.from(10).pow(12); + + double? get usd => usdPerAr == null ? null : ar * usdPerAr!; +} + +/// What one AR is worth in US dollars now, from Turbo's public +/// `GET /v1/rates/usd`. +Future fetchUsdPerAr(String paymentUrl) async { + try { + final response = await http + .get(Uri.parse('$paymentUrl/v1/rates/usd')) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + return null; + } + + final rate = (jsonDecode(response.body) as Map)['rate']; + return rate is num && rate > 0 ? rate.toDouble() : null; + } catch (_) { + return null; + } +} + +/// The network's price for the size, and the dollar rate beside it. Null when +/// the price itself could not be read. +Future loadNetworkCost({ + required Future Function() price, + required Future usdPerAr, +}) async { + final BigInt winston; + + try { + winston = await price(); + } catch (e) { + logger.w('Could not read the network price for the permanence panel: $e'); + return null; + } + + return NetworkCost(winston: winston, usdPerAr: await usdPerAr); +} + +/// How reading the wallet's Turbo credits went. +class SpendLookup { + const SpendLookup._({ + this.creditsWinc, + this.failed = false, + this.tooMany = false, + }); + + /// The history could not be read: a signature the wallet refused, the + /// service unreachable. Not the same as there being nothing to read. + const SpendLookup.failed() : this._(failed: true); + + /// The history was read, and holds no top-ups. + const SpendLookup.none() : this._(); + + /// More top-ups than one look reads. Credits used from some of them, set + /// against the whole balance, would be wrong and look right, so there is + /// no figure. + const SpendLookup.tooMany() : this._(tooMany: true); + + const SpendLookup.found(BigInt creditsWinc) + : this._(creditsWinc: creditsWinc); + + /// Turbo credits used, in winc. See [creditsUsed]. + final BigInt? creditsWinc; + final bool failed; + final bool tooMany; +} + +/// The Turbo credits [wallet] has used. See [creditsUsed]. +Future loadCreditsSpent({ + required PaymentService paymentService, + required Wallet wallet, +}) async { + final List> history; + + try { + final read = await paymentService.getPaymentHistory(wallet: wallet); + + if (!read.complete) { + return const SpendLookup.tooMany(); + } + + history = read.payments; + } catch (e) { + logger.w('Could not read the Turbo payment history: $e'); + return const SpendLookup.failed(); + } + + if (history.isEmpty) { + return const SpendLookup.none(); + } + + // The wallet's own credits: what it can spend would count credits shared + // with it, which came with no top-up. See [creditsUsed]. + BigInt balance; + try { + balance = (await paymentService.getBalanceAndPaidBy(wallet: wallet)) + .controlledWinc; + } on TurboUserNotFound { + balance = BigInt.zero; + } catch (e) { + logger.w('Could not read the Turbo balance: $e'); + return const SpendLookup.failed(); + } + + return SpendLookup.found( + creditsUsed(payments: history, balanceWinc: balance), + ); +} + +/// What the permanence panel shows. +class PermanenceSummary { + const PermanenceSummary({ + required this.totalBytes, + required this.fileCount, + required this.driveCount, + required this.bytesByYear, + this.costToday, + this.spend, + this.loadSpend, + this.arSpend, + this.signInChain, + }); + + final int totalBytes; + final int fileCount; + final int driveCount; + + /// Bytes uploaded in each year, oldest first. + final Map bytesByYear; + + /// What the network charges today to store [totalBytes], as it arrives. + final Future? costToday; + + /// What the wallet has spent, still arriving when the panel opens. + final Future? spend; + + /// Reads what the wallet has spent when the reader asks, in place of + /// [spend], for a wallet that would ask its owner to sign: a browser + /// extension shows a prompt for every signature, and one that opens with + /// a summary nobody asked to sign for reads as something gone wrong. + final Future Function()? loadSpend; + + /// What the wallet paid in AR to upload directly, counted as it arrives. + /// Null when it could not be asked. + final Future? arSpend; + + /// The chain of the wallet the reader signed in with ("Ethereum", + /// "Solana"), when it was not an Arweave one. Credits that wallet shared + /// with the Arweave wallet made for it are spent here, but are in neither + /// one's own history as this reads it, so the fine print says so: to + /// them, and to nobody else. + final String? signInChain; + + int? get firstYear => bytesByYear.isEmpty ? null : bytesByYear.keys.first; +} + +final _usd = NumberFormat.currency(symbol: r'$', decimalDigits: 0); +final _count = NumberFormat.decimalPattern(); + +/// Sizes to one decimal place until that stops meaning anything. The profile +/// card uses it too, so its line and the panel always say the same size the +/// same way. +/// +/// In the units the rest of the app counts in (KiB, MiB, GiB, as `filesize` +/// prints them), so the drives list and this line measure alike. +String formatPermanenceSize(int bytes) => _formatSize(bytes); + +String _formatSize(int bytes) { + const kb = 1024; + const mb = kb * 1024; + const gb = mb * 1024; + const tb = gb * 1024; + + String scaled(double value, String unit) => value >= 100 + ? '${_count.format(value.round())} $unit' + : '${value.toStringAsFixed(1)} $unit'; + + if (bytes >= tb) return scaled(bytes / tb, 'TiB'); + if (bytes >= gb) return scaled(bytes / gb, 'GiB'); + if (bytes >= mb) return scaled(bytes / mb, 'MiB'); + return scaled(bytes / kb, 'KiB'); +} + +/// Between items in a line of facts, here and on the profile card. Wavehaus +/// draws its middle dot low and small, so it read as a full stop; its bullet +/// sits on the line's middle. +const permanenceSeparator = ' • '; + +/// A count and what it counts, with thousands separated: "1,776 files". +String permanenceCount(int n, String one) => + '${_count.format(n)} $one${n == 1 ? '' : 's'}'; + +void showPermanencePanel(BuildContext context, PermanenceSummary summary) { + // Held from here because the profile card closes as it opens this, so + // [context] is gone by the time Share is pressed: the share card is opened + // from the navigator's own context, which outlives both. + final navigator = Navigator.of(context); + + showArDriveDialog( + context, + content: _NoScrollbar( + child: ArDriveStandardModalNew( + width: 460, + hasCloseButton: true, + scrollableContent: true, + titleWidget: Text( + 'Your permanence', + style: ArDriveTypographyNew.of(context).heading3( + fontWeight: ArFontWeight.bold, + ), + ), + content: PermanencePanel(summary: summary), + actions: [ + ModalAction( + title: 'Share my permanence', + customWidth: 240, + action: () { + navigator.pop(); + showPermanenceShare(navigator.context, summary); + }, + ), + ], + ), + ), + ); +} + +class PermanencePanel extends StatelessWidget { + const PermanencePanel({super.key, required this.summary}); + + final PermanenceSummary summary; + + @override + Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + final colors = ArDriveTheme.of(context).themeData.colorTokens; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: _formatSize(summary.totalBytes), + style: typography.heading1( + color: colors.textHigh, + fontWeight: ArFontWeight.bold, + ), + ), + TextSpan( + text: ' preserved', + style: typography.paragraphXLarge( + color: colors.textMid, + fontWeight: ArFontWeight.semiBold, + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + [ + permanenceCount(summary.fileCount, 'file'), + permanenceCount(summary.driveCount, 'drive'), + if (summary.firstYear != null) 'since ${summary.firstYear}', + ].join(permanenceSeparator), + style: typography.paragraphNormal( + color: colors.textLow, + fontWeight: ArFontWeight.book, + ), + ), + if (summary.bytesByYear.isNotEmpty) ...[ + const SizedBox(height: 20), + Semantics( + // The chart is painted, so it says in words what it draws. + label: 'Uploaded by year: ${[ + for (final year in summary.bytesByYear.entries) + '${year.key}, ${_formatSize(year.value)}', + ].join('; ')}', + child: SizedBox( + // Taller with each year past six, so every year keeps room for + // its label. + height: math.max(120, 20.0 * summary.bytesByYear.length), + child: CustomPaint( + painter: _StrataPainter( + bytesByYear: summary.bytesByYear, + brand: colors.buttonPrimaryDefault, + faint: colors.textOnPrimary, + outline: colors.strokeLow, + label: colors.textHigh, + muted: colors.textLow, + labelStyle: typography.caption(fontWeight: ArFontWeight.bold), + mutedStyle: typography.caption(fontWeight: ArFontWeight.book), + ), + ), + ), + ), + ], + const SizedBox(height: 20), + LayoutBuilder(builder: (context, box) { + final spendTile = _SpendTile( + spend: summary.spend, + loadSpend: summary.loadSpend, + arSpend: summary.arSpend, + ); + final costTile = _CostTile(cost: summary.costToday); + + // Side by side even on a phone, their notes wrapping by the word: + // stacked, the panel no longer fit, and nothing said it scrolled. + // Only a screen too narrow for that stacks them. + if (box.maxWidth < 280) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [spendTile, const SizedBox(height: 12), costTile], + ); + } + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: spendTile), + const SizedBox(width: 12), + Expanded(child: costTile), + ], + ), + ); + }), + const SizedBox(height: 12), + _FinePrint(signInChain: summary.signInChain), + ], + ); + } +} + +class _Tile extends StatelessWidget { + const _Tile({ + required this.label, + required this.value, + required this.note, + this.extra, + this.action, + this.onAction, + }); + + final String label; + final String value; + final String note; + + /// A second figure under the note: AR paid directly, beside dollars. + final String? extra; + + /// A link under the note, and what it does. + final String? action; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + final colors = ArDriveTheme.of(context).themeData.colorTokens; + + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + // A step off the modal's own ground (containerL3), in either theme. + color: colors.containerL1, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: typography + .caption( + color: colors.textLow, + fontWeight: ArFontWeight.semiBold, + ) + .copyWith(letterSpacing: 0.6), + ), + const SizedBox(height: 8), + Text( + value, + style: typography.heading3( + color: colors.textHigh, + fontWeight: ArFontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + note, + style: typography.caption( + color: colors.textLow, + fontWeight: ArFontWeight.book, + ), + ), + if (action != null) ...[ + const SizedBox(height: 6), + ArDriveClickArea( + child: GestureDetector( + onTap: onAction, + child: Semantics( + button: true, + child: Text( + action!, + style: typography.caption( + color: colors.textRed, + fontWeight: ArFontWeight.semiBold, + ), + ), + ), + ), + ), + ], + if (extra != null) ...[ + const SizedBox(height: 6), + Text( + extra!, + style: typography.caption( + color: colors.textMid, + fontWeight: ArFontWeight.semiBold, + ), + ), + ], + ], + ), + ); + } +} + +/// What the network charges today: dollars, with the AR they come from +/// beside them - so it reads as the price of storage, not as ours. +class _CostTile extends StatelessWidget { + const _CostTile({required this.cost}); + + final Future? cost; + + static const _label = 'COST TODAY'; + static const _note = 'Network price to store it'; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: cost, + builder: (context, snapshot) { + if (cost != null && snapshot.connectionState != ConnectionState.done) { + return const _Tile(label: _label, value: '...', note: _note); + } + + final network = snapshot.data; + + if (network == null) { + return const _Tile( + label: _label, + value: '-', + note: "couldn't reach the network", + ); + } + + final usd = network.usd; + + // Without a dollar rate, the AR price alone. + if (usd == null) { + return _Tile( + label: _label, + value: _formatAr(network.ar), + note: _note, + ); + } + + return _Tile( + label: _label, + value: _usd.format(usd), + note: _note, + extra: _formatAr(network.ar), + ); + }, + ); + } +} + +/// AR as the credits are shown: two places from one AR up, separated by +/// thousands ("343.40 AR"); below one, up to four, without trailing zeros +/// ("0.0373 AR", "0.5 AR"). +String _formatAr(double ar) { + if (ar > 0 && ar < 0.0001) { + return '<0.0001 AR'; + } + + if (ar < 1) { + final small = ar.toStringAsFixed(4).replaceFirst(RegExp(r'0+$'), ''); + return '${small.endsWith('.') ? '${small}0' : small} AR'; + } + + return '${NumberFormat.decimalPatternDigits(decimalDigits: 2).format(ar)} AR'; +} + +/// Credits as a bare number, the unit said in the note beneath: two +/// places once there is a whole credit, four below that, separated by +/// thousands. A credit is a trillion winc, as an AR is a trillion winston. +String _formatCredits(BigInt winc) { + final credits = winc / BigInt.from(10).pow(12); + + if (credits > 0 && credits < 0.0001) { + return '<0.0001'; + } + + return NumberFormat.decimalPatternDigits( + decimalDigits: credits >= 1 ? 2 : 4, + ).format(credits); +} + +/// What the wallet has spent, in what it spent it in: Turbo credits, and AR +/// paid directly. No dollars - nothing here is a price, so there is nothing +/// to set against what storage costs today. +class _SpendTile extends StatefulWidget { + const _SpendTile({ + required this.spend, + required this.loadSpend, + required this.arSpend, + }); + + final Future? spend; + final Future Function()? loadSpend; + final Future? arSpend; + + @override + State<_SpendTile> createState() => _SpendTileState(); +} + +class _SpendTileState extends State<_SpendTile> { + static const _label = 'SPENT'; + + /// The credits being read: from the start, or once asked for. + late Future? spend = widget.spend; + + Future? get arSpend => widget.arSpend; + + @override + void didUpdateWidget(covariant _SpendTile oldWidget) { + super.didUpdateWidget(oldWidget); + + // Another summary: whatever was read for the last one is not this one's. + if (widget.spend != oldWidget.spend || + widget.loadSpend != oldWidget.loadSpend) { + spend = widget.spend; + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: arSpend, + builder: (context, arSnapshot) { + final arCounting = arSpend != null && + arSnapshot.connectionState != ConnectionState.done; + final ar = arSnapshot.data; + final paidAr = ar != null && ar.winston > BigInt.zero ? ar : null; + + String? arLine() { + if (arCounting) { + return 'counting AR paid directly...'; + } + + if (paidAr == null) { + return null; + } + + return '+ ${paidAr.complete ? '' : 'at least '}' + '${_formatAr(paidAr.ar)} paid directly'; + } + + // AR on its own, when there are no credits to show beside it. + _Tile arOnly() => _Tile( + label: _label, + value: _formatAr(paidAr!.ar), + note: paidAr.complete + ? 'paid directly in AR' + : 'paid directly in AR, at least', + ); + + // Not read yet: this wallet signs to read them, and the reader has + // not asked. + final loadSpend = widget.loadSpend; + if (spend == null && loadSpend != null) { + return _Tile( + label: _label, + value: paidAr == null ? '-' : _formatAr(paidAr.ar), + note: paidAr == null + ? 'your wallet signs to read your credits' + : paidAr.complete + ? 'paid directly in AR' + : 'paid directly in AR, at least', + action: 'Show credits used', + onAction: () { + final read = loadSpend(); + setState(() { + spend = read; + }); + }, + extra: paidAr == null ? arLine() : null, + ); + } + + return FutureBuilder( + future: spend, + builder: (context, snapshot) { + if (spend != null && + snapshot.connectionState != ConnectionState.done) { + return const _Tile( + label: _label, + value: '...', + note: 'reading your credits', + ); + } + + final lookup = snapshot.data; + final credits = lookup?.creditsWinc; + + if (lookup != null && lookup.tooMany) { + return _Tile( + label: _label, + value: '-', + note: 'more top-ups than we can count here', + extra: arLine(), + ); + } + + if (spend == null || lookup == null || lookup.failed) { + return paidAr != null + ? arOnly() + : _Tile( + label: _label, + value: '-', + note: "couldn't read your credits", + extra: arLine(), + ); + } + + if (credits == null || credits == BigInt.zero) { + return paidAr != null + ? arOnly() + : _Tile( + label: _label, + value: '-', + note: credits == null + ? 'no top-ups found' + : 'no credits used yet', + extra: arLine(), + ); + } + + return _Tile( + label: _label, + value: _formatCredits(credits), + note: 'Turbo credits used', + extra: arLine(), + ); + }, + ); + }, + ); + } +} + +/// How the panel's numbers are made, one tap away. Closed by default, so the +/// panel stays a summary; open, it says plainly what each figure is and is +/// not. +class _FinePrint extends StatefulWidget { + const _FinePrint({this.signInChain}); + + /// See [PermanenceSummary.signInChain]. + final String? signInChain; + + @override + State<_FinePrint> createState() => _FinePrintState(); +} + +class _FinePrintState extends State<_FinePrint> { + bool _open = false; + + static const _duration = Duration(milliseconds: 200); + + @override + Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + final colors = ArDriveTheme.of(context).themeData.colorTokens; + final body = typography.caption( + color: colors.textMid, + fontWeight: ArFontWeight.book, + ); + + // Each point opens with the name of the figure it explains, as the + // panel labels it. + TextSpan lead(String figure) => TextSpan( + text: figure, + style: body.copyWith( + color: colors.textHigh, + fontWeight: ArFontWeight.semiBold.weight, + ), + ); + + Widget point(InlineSpan text) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5, right: 10), + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: colors.textLow, + shape: BoxShape.circle, + ), + ), + ), + Expanded(child: Text.rich(text, style: body)), + ], + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ArDriveClickArea( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _open = !_open), + child: Semantics( + button: true, + expanded: _open, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'How these numbers work', + style: typography.caption( + color: colors.textMid, + fontWeight: ArFontWeight.semiBold, + ), + ), + const SizedBox(width: 4), + AnimatedRotation( + turns: _open ? 0.5 : 0, + duration: _duration, + child: ArDriveIcons.carretDown( + size: 14, + color: colors.textMid, + ), + ), + ], + ), + ), + ), + ), + AnimatedSize( + duration: _duration, + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: !_open + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + point(TextSpan(children: [ + lead('Size'), + const TextSpan( + text: ' counts every version of every file in your ' + 'own drives synced on this device, once its ' + 'upload is confirmed. Data saved in more than ' + 'one place counts once. Hidden drives and ' + "pinned files aren't included, so this can " + 'differ from what your drives list adds up to.', + ), + ])), + point(TextSpan(children: [ + lead('Spent'), + const TextSpan( + text: " is the Turbo credits you've used: " + 'everything credited to this wallet, minus ' + 'your balance. It includes credits spent on ' + 'ArNS names and credits used by wallets you ' + 'shared them with. Under it is the AR this ' + 'wallet paid for direct uploads (network fees ' + 'and ArDrive tips), read from Arweave. Neither ' + 'is in dollars: credits and AR were bought at ' + 'different prices over time.', + ), + if (widget.signInChain != null) + TextSpan( + text: ' Credits shared from your ' + "${widget.signInChain} wallet aren't counted.", + ), + ])), + point(TextSpan(children: [ + lead('Cost today'), + const TextSpan( + text: " is the Arweave network's own price to " + 'store this much data now, in AR, converted at ' + "today's AR price. The network's price and " + "AR's dollar value both change. It isn't what " + "ArDrive or Turbo would charge, and it isn't a " + 'bill or a quote.', + ), + ])), + point(TextSpan( + children: [ + lead('Permanence'), + const TextSpan( + text: ' is provided by the Arweave network, not ' + 'by ArDrive. Arweave says its storage ' + 'endowment is designed to pay for storage for ' + '200 years or more. See the ', + ), + TextSpan( + text: 'Terms of Service', + style: body.copyWith( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = + () => openUrl(url: Resources.agreementLink), + ), + const TextSpan(text: '.'), + ], + )), + ], + ), + ), + ), + ], + ); + } +} + +/// Hides the scrollbar of the modal's own scroll view, which draws it inside +/// the modal's padding, over the content. The panel still scrolls on a +/// screen too short for it - by touch, wheel or keyboard. +class _NoScrollbar extends StatelessWidget { + const _NoScrollbar({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: child, + ); +} + +/// The text that goes with the share card. No dollar amounts: what someone +/// spent is theirs to tell. No promise of a duration, either. +String permanenceShareText(PermanenceSummary summary) => [ + '${_formatSize(summary.totalBytes)} of my files, stored permanently ' + 'on Arweave.', + if (summary.firstYear != null) 'Preserving since ${summary.firstYear}.', + 'ardrive.io', + ].join(' '); + +void showPermanenceShare(BuildContext context, PermanenceSummary summary) { + showArDriveDialog( + context, + content: _NoScrollbar(child: _PermanenceShareDialog(summary: summary)), + ); +} + +/// The share card with its text, to copy and to save. Both leave it open, +/// since a post usually wants the two, and each says when it is done. +class _PermanenceShareDialog extends StatefulWidget { + const _PermanenceShareDialog({required this.summary}); + + final PermanenceSummary summary; + + @override + State<_PermanenceShareDialog> createState() => _PermanenceShareDialogState(); +} + +class _PermanenceShareDialogState extends State<_PermanenceShareDialog> { + final _cardKey = GlobalKey(); + + bool _copied = false; + bool _saving = false; + bool _saved = false; + + /// How long a button says it is done before it reads as itself again. + static const _doneFor = Duration(seconds: 2); + + Future _copy() async { + await Clipboard.setData( + ClipboardData(text: permanenceShareText(widget.summary)), + ); + + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(_doneFor); + if (mounted) setState(() => _copied = false); + } + + Future _save() async { + final boundary = + _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + + if (boundary == null || _saving) { + return; + } + + setState(() => _saving = true); + + try { + final image = await boundary.toImage(pixelRatio: 2); + final png = await image.toByteData(format: ui.ImageByteFormat.png); + + if (png == null) { + return; + } + + await ArDriveIO().saveFile( + await IOFile.fromData( + png.buffer.asUint8List(), + name: 'my-permanence.png', + lastModifiedDate: DateTime.now(), + contentType: 'image/png', + ), + ); + } catch (e) { + logger.w('Could not save the permanence card: $e'); + return; + } finally { + if (mounted) setState(() => _saving = false); + } + + if (!mounted) return; + setState(() => _saved = true); + await Future.delayed(_doneFor); + if (mounted) setState(() => _saved = false); + } + + @override + Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + + return ArDriveStandardModalNew( + width: 600, + hasCloseButton: true, + scrollableContent: true, + titleWidget: Text( + 'Share your permanence', + style: typography.heading3(fontWeight: ArFontWeight.bold), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + // Drawn at 600 x 315 and saved at twice that: the 1200 x 630 that + // social sites expect for a link card. + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FittedBox( + child: RepaintBoundary( + key: _cardKey, + child: PermanenceShareCard(summary: widget.summary), + ), + ), + ), + const SizedBox(height: 16), + Text( + permanenceShareText(widget.summary), + style: typography.paragraphSmall( + color: ArDriveTheme.of(context).themeData.colorTokens.textMid, + fontWeight: ArFontWeight.book, + ), + ), + ], + ), + actions: [ + ModalAction( + title: _copied ? 'Copied' : 'Copy text', + customWidth: 120, + action: _copy, + ), + ModalAction( + title: _saving + ? 'Saving...' + : _saved + ? 'Saved' + : 'Download image', + customWidth: 160, + isEnable: !_saving, + action: _save, + ), + ], + ); + } +} + +/// The card people share: the size, the years and the strata, on the brand's +/// dark ground. Always dark, whatever the app's theme, so it looks the same +/// wherever it is posted. +class PermanenceShareCard extends StatelessWidget { + const PermanenceShareCard({super.key, required this.summary}); + + final PermanenceSummary summary; + + static const _ground = Color(0xFF0E0E0F); + static const _brand = Color(0xFFFE0230); + static const _text = Color(0xFFFAFAFA); + static const _white = Color(0xFFFFFFFF); + static const _muted = Color(0xFF9A9A9A); + + @override + Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + + return Container( + width: 600, + height: 315, + color: _ground, + padding: const EdgeInsets.fromLTRB(36, 32, 32, 32), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 11, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset(Resources.images.brand.whiteLogo2, height: 24), + const Spacer(), + Text( + _formatSize(summary.totalBytes), + style: typography + .display(fontWeight: ArFontWeight.bold) + .copyWith(color: _text, fontSize: 56, height: 1), + ), + const SizedBox(height: 10), + Text( + 'stored permanently on Arweave', + style: typography.paragraphXLarge( + color: _text, + fontWeight: ArFontWeight.semiBold, + ), + ), + const SizedBox(height: 6), + Text( + [ + if (summary.firstYear != null) 'Since ${summary.firstYear}', + permanenceCount(summary.fileCount, 'file'), + ].join(permanenceSeparator), + style: typography.paragraphNormal( + color: _muted, + fontWeight: ArFontWeight.book, + ), + ), + ], + ), + ), + const SizedBox(width: 28), + Expanded( + flex: 9, + child: CustomPaint( + painter: _StrataPainter( + bytesByYear: summary.bytesByYear, + brand: _brand, + faint: _white, + label: _text, + muted: _muted, + labelStyle: typography.caption(fontWeight: ArFontWeight.bold), + mutedStyle: typography.caption(fontWeight: ArFontWeight.book), + ), + ), + ), + ], + ), + ); + } +} + +/// The archive as rock strata: one band per year of uploads, oldest at the +/// bottom, thickness by size. +class _StrataPainter extends CustomPainter { + _StrataPainter({ + required this.bytesByYear, + required this.brand, + required this.faint, + this.outline, + required this.label, + required this.muted, + required this.labelStyle, + required this.mutedStyle, + }); + + final Map bytesByYear; + final Color brand; + + /// The quietest year's colour: the palette's white. + final Color faint; + + /// Drawn round the bands, so a white one still has an edge on a light + /// ground. + final Color? outline; + final Color label; + final Color muted; + final TextStyle labelStyle; + final TextStyle mutedStyle; + + @override + void paint(Canvas canvas, Size size) { + if (bytesByYear.isEmpty) { + return; + } + + const labelWidth = 112.0; + final layers = bytesByYear.entries.toList(); + // No band thinner than a label, unless there are more years than fit: + // then they share the height, rather than the oldest falling off the + // top. + final minBand = math.min(16.0, size.height / layers.length); + final bandWidth = size.width - labelWidth; + final total = layers.fold(0, (a, e) => a + e.value); + final maxBytes = layers.fold(0, (a, e) => math.max(a, e.value)); + final minBytes = layers.fold(maxBytes, (a, e) => math.min(a, e.value)); + final flex = math.max(0.0, size.height - minBand * layers.length); + + final tops = []; + var bottom = size.height; + for (final layer in layers) { + bottom -= minBand + flex * (total == 0 ? 0 : layer.value / total); + tops.add(bottom); + } + + final frame = RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, bandWidth, size.height), + const Radius.circular(8), + ); + + canvas.save(); + canvas.clipRRect(frame); + + // The newest first, so each older layer is drawn over it. + for (var i = layers.length - 1; i >= 0; i--) { + // A heat map, the full width of the palette: the quietest year is + // white, the busiest the brand red. Position already says how old a + // layer is, so colour says how much. + // + // On a log scale. Uploads vary by orders of magnitude - 30 MB one + // year, 16 GB the next - and on a straight one a single big year left + // every other year the same near-white. + final logMin = math.log(math.max(minBytes, 1)); + final logRange = math.log(math.max(maxBytes, 1)) - logMin; + final heat = logRange <= 0 + ? 1.0 + : (math.log(math.max(layers[i].value, 1)) - logMin) / logRange; + final color = Color.lerp(faint, brand, heat)!; + final amp = i == layers.length - 1 ? 0.0 : 3.0; + final path = Path() + ..moveTo(0, size.height) + ..lineTo(0, tops[i]); + for (var x = 0.0; x <= bandWidth; x += 4) { + path.lineTo( + x, + tops[i] + amp * math.sin((x / bandWidth) * 5 * math.pi + i * 1.7), + ); + } + path + ..lineTo(bandWidth, size.height) + ..close(); + canvas.drawPath(path, Paint()..color = color); + } + canvas.restore(); + + final outline = this.outline; + if (outline != null) { + canvas.drawRRect( + frame.deflate(0.5), + Paint() + ..color = outline + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + } + + TextPainter text(String value, TextStyle style) => TextPainter( + text: TextSpan(text: value, style: style), + textDirection: TextDirection.ltr, + )..layout(); + + final years = [ + for (final layer in layers) + text('${layer.key}', labelStyle.copyWith(color: label)), + ]; + final amounts = [ + for (final layer in layers) + text(_formatSize(layer.value), mutedStyle.copyWith(color: muted)), + ]; + final lineHeight = [...years, ...amounts] + .fold(0, (a, painter) => math.max(a, painter.height)); + + // Each label beside the middle of its band, unless two thin years sit + // together: then they are spread a line apart. + final ys = spreadStrataLabels( + [ + for (var i = 0; i < layers.length; i++) + (tops[i] + (i == 0 ? size.height : tops[i - 1])) / 2, + ], + gap: lineHeight + 2, + height: size.height, + ); + + for (var i = 0; i < layers.length; i++) { + years[i].paint( + canvas, + Offset(bandWidth + 14, ys[i] - years[i].height / 2), + ); + amounts[i].paint( + canvas, + Offset(bandWidth + 54, ys[i] - amounts[i].height / 2), + ); + } + } + + @override + bool shouldRepaint(covariant _StrataPainter old) => + old.bytesByYear != bytesByYear || + old.brand != brand || + old.faint != faint; +} + +/// Where each year's label goes: [mids] are the bands' middles, oldest +/// (lowest) first, and each label is moved as little as it takes to sit at +/// least [gap] from the next, inside [height]. +@visibleForTesting +List spreadStrataLabels( + List mids, { + required double gap, + required double height, +}) { + final ys = List.of(mids); + + // Up from the bottom: each one at least a gap above the one below it. + for (var i = 0; i < ys.length; i++) { + final ceiling = i == 0 ? height - gap / 2 : ys[i - 1] - gap; + ys[i] = math.min(ys[i], ceiling); + } + + // Down from the top: nothing pushed out of the top edge. + for (var i = ys.length - 1; i >= 0; i--) { + final floor = i == ys.length - 1 ? gap / 2 : ys[i + 1] + gap; + ys[i] = math.max(ys[i], floor); + } + + return ys; +} diff --git a/lib/permanence/spend_estimate.dart b/lib/permanence/spend_estimate.dart new file mode 100644 index 0000000000..92f76c1ee6 --- /dev/null +++ b/lib/permanence/spend_estimate.dart @@ -0,0 +1,26 @@ +/// Turbo credits a wallet has used, from its top-ups and what is left. +/// +/// Everything credited - bought, granted or gifted - minus [balanceWinc], +/// which must be the wallet's *own* credits (Turbo's `controlledWinc`), not +/// what it can spend: that includes credits other wallets have shared with +/// it, which arrived with no top-up of their own and would make the wallet +/// look as if it had used less. Exact, with no price in it. +/// +/// It counts every use of the wallet's own credits, so it includes ArNS +/// names bought with them and credits it shared that the other wallet has +/// spent. Never below zero. +/// +/// [payments] are rows of `GET /v1/account/payments`. +BigInt creditsUsed({ + required List> payments, + required BigInt balanceWinc, +}) { + final credited = payments.fold( + BigInt.zero, + (sum, row) => + sum + (BigInt.tryParse('${row['wincCredited']}') ?? BigInt.zero), + ); + final used = credited - balanceWinc; + + return used < BigInt.zero ? BigInt.zero : used; +} diff --git a/lib/services/arweave/arweave_service.dart b/lib/services/arweave/arweave_service.dart index 87cb78fd4e..1987d3157d 100644 --- a/lib/services/arweave/arweave_service.dart +++ b/lib/services/arweave/arweave_service.dart @@ -17,6 +17,7 @@ import 'package:ardrive/services/services.dart'; import 'package:ardrive/sync/domain/models/drive_entity_history.dart'; import 'package:ardrive/utils/arfs_txs_filter.dart'; import 'package:ardrive/utils/constants.dart'; +import 'package:ardrive/permanence/ar_spend.dart'; import 'package:ardrive/utils/graphql_retry.dart'; import 'package:ardrive/utils/http_retry.dart'; import 'package:ardrive/utils/internet_checker.dart'; @@ -2080,6 +2081,135 @@ class ArweaveService { return transactionConfirmations; } + /// Which filter this gateway honours for leaving out bundled data items. + /// See [UnbundledFilter]. + final _unbundledFilter = UnbundledFilter(); + + /// One page of the AR [owner] paid to upload with ArDrive directly: its own + /// mined transactions - not data items in bundles - from [minHeight] up, + /// oldest first. + /// + /// Leaving out data items is what makes this cheap: an upload through + /// Turbo is a data item in Turbo's bundle, which Turbo paid for, so a + /// wallet that only ever used Turbo has nothing here at all. How to ask a + /// gateway for that is not the same everywhere - see [UnbundledFilter]. + /// + /// Filtered by owner alone on the gateway, and to ArDrive's own here: a tag + /// filter on top made the ardrive.net indexer read 40 million rows and + /// refuse. Every transaction still moves the page on, so another app's + /// uploads are skipped, not read again. + Future getArDriveFeesPage({ + required String owner, + required int minHeight, + KnownTxLookup? knownAmong, + String? after, + }) => + fetchDirectFeesPage( + filter: _unbundledFilter, + firstPage: after == null, + fetch: (bundledIn) => _arDriveFeesPage( + owner: owner, + minHeight: minHeight, + after: after, + bundledIn: bundledIn, + knownAmong: knownAmong, + ), + ); + + Future _arDriveFeesPage({ + required String owner, + required int minHeight, + required String? after, + required List? bundledIn, + required KnownTxLookup? knownAmong, + }) async { + final query = await graphQLRetry.execute( + WalletArDriveFeesQuery( + variables: WalletArDriveFeesArguments( + owner: owner, + minHeight: minHeight, + bundledIn: bundledIn, + after: after, + ), + ), + ); + + final transactions = query.data?.transactions; + + if (transactions == null) { + throw ArweaveServiceException( + 'The query `WalletArDriveFeesQuery` returned no data', + ); + } + + final tagsById = { + for (final edge in transactions.edges) + edge.node.id: {for (final tag in edge.node.tags) tag.name: tag.value}, + }; + + // Only what the tags leave open is asked of the database, and only when + // there is any: for ArDrive's own uploads the tags settle it. + final known = await lookUpKnown(knownAmong, { + for (final edge in transactions.edges) + if (edge.node.bundledIn == null && + !isArDriveByTags(tagsById[edge.node.id]!)) + edge.node.id, + }); + + var winston = BigInt.zero; + var count = 0; + var unbundled = 0; + final excluded = {}; + int? lastHeight; + + for (final edge in transactions.edges) { + lastHeight = edge.node.block?.height ?? lastHeight; + + // A data item in a bundle, from a gateway that would not leave them + // out: free to the wallet, whatever its tags say. + if (edge.node.bundledIn != null) { + continue; + } + + unbundled++; + + final tags = tagsById[edge.node.id]!; + final isArDrive = isArDriveTransaction( + txId: edge.node.id, + tags: tags, + knownTxIds: known, + ); + + final fee = arDrivePayment( + feeWinston: edge.node.fee.winston, + quantityWinston: edge.node.quantity.winston, + tags: tags, + ); + + if (isArDrive) { + winston += fee; + count++; + } else if (fee > BigInt.zero) { + // Perhaps not yet synced: looked at again by the next count. + excluded[edge.node.id] = fee; + } + } + + return RawFeePage( + page: ArFeePage( + winston: winston, + count: count, + lastHeight: lastHeight, + cursor: + transactions.edges.isEmpty ? after : transactions.edges.last.cursor, + hasMore: transactions.pageInfo.hasNextPage, + excluded: excluded, + ), + rows: transactions.edges.length, + unbundledRows: unbundled, + ); + } + Future getFirstTxForWallet(String owner) async { final firstTxForWalletQuery = await graphQLRetry.execute( FirstTxForWalletQuery( @@ -2449,8 +2579,8 @@ class ArweaveService { return DataTxSizeAndType( size: size, // Mime only, without parameters such as charset. - contentType: response.headers['content-type'] - ?.replaceFirst(RegExp(r';.*$'), ''), + contentType: + response.headers['content-type']?.replaceFirst(RegExp(r';.*$'), ''), ); } catch (e) { logger.w('HEAD for $txId failed: $e'); diff --git a/lib/services/arweave/graphql/queries/WalletArDriveFees.graphql b/lib/services/arweave/graphql/queries/WalletArDriveFees.graphql new file mode 100644 index 0000000000..c2797b67de --- /dev/null +++ b/lib/services/arweave/graphql/queries/WalletArDriveFees.graphql @@ -0,0 +1,41 @@ +query WalletArDriveFees( + $owner: String! + $minHeight: Int! + $bundledIn: [ID!] + $after: String +) { + transactions( + owners: [$owner] + bundledIn: $bundledIn + block: { min: $minHeight } + sort: HEIGHT_ASC + first: 100 + after: $after + ) { + edges { + node { + id + fee { + winston + } + quantity { + winston + } + block { + height + } + bundledIn { + id + } + tags { + name + value + } + } + cursor + } + pageInfo { + hasNextPage + } + } +} diff --git a/lib/turbo/services/payment_service.dart b/lib/turbo/services/payment_service.dart index b6e8e08c68..8af01cbd80 100644 --- a/lib/turbo/services/payment_service.dart +++ b/lib/turbo/services/payment_service.dart @@ -11,14 +11,33 @@ import 'package:arweave/arweave.dart'; import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; +/// A wallet's top-ups as `GET /v1/account/payments` returned them. +class TurboPaymentHistory { + const TurboPaymentHistory({required this.payments, required this.complete}); + + final List> payments; + + /// False when there were more pages than were read. + final bool complete; +} + class TurboBalanceInterface { + /// What the wallet can spend now: its own credits plus what is left of + /// credits other wallets have shared with it (Turbo's `effectiveBalance`). final BigInt balance; final List paidBy; + /// The wallet's own credits alone, including any it has shared that have + /// not been spent yet (Turbo's `controlledWinc`). Credits shared *to* it + /// are not in here. Where only [balance] is known, it is that. + BigInt get controlledWinc => _controlledWinc ?? balance; + final BigInt? _controlledWinc; + TurboBalanceInterface({ required this.balance, required this.paidBy, - }); + BigInt? controlledWinc, + }) : _controlledWinc = controlledWinc; @override String toString() { @@ -87,6 +106,7 @@ class PaymentService { final data = json.decode(result.data); final balance = BigInt.parse(data['effectiveBalance']); + final controlled = BigInt.tryParse('${data['controlledWinc']}'); final receivedApprovals = data['receivedApprovals'] as List; final paidBy = receivedApprovals .map((approval) => approval['payingAddress'] as String) @@ -95,6 +115,7 @@ class PaymentService { return TurboBalanceInterface( balance: balance, paidBy: paidBy, + controlledWinc: controlled, ); } catch (error, stackTrace) { if (error is ArDriveHTTPException) { @@ -108,6 +129,52 @@ class PaymentService { } } + /// The wallet's own completed top-ups, card and crypto, newest first. + /// + /// A signed request: the service reads whose history it is from the + /// signature, so it can only ever return the signer's own. The signature + /// headers are cached per wallet, so a wallet that has already signed for + /// Turbo this session is not asked again. + /// + /// Reads up to [maxPages] pages of a hundred rows, and says whether that + /// was all of it: a history cut short is not a smaller history, and must + /// not be read as one. + Future getPaymentHistory({ + required Wallet wallet, + int maxPages = 10, + }) async { + final headers = await turboSignatureHeadersManager.getSignatureHeaders( + wallet: wallet, + ); + final payments = >[]; + String? cursor; + var complete = false; + + for (var page = 0; page < maxPages; page++) { + final query = cursor == null + ? 'limit=100' + : 'limit=100&cursor=${Uri.encodeQueryComponent(cursor)}'; + final result = await httpClient.get( + url: '$turboPaymentUri/v1/account/payments?$query', + headers: headers, + ); + final data = json.decode(result.data) as Map; + + payments.addAll( + (data['payments'] as List).cast>(), + ); + + cursor = data['cursor'] as String?; + + if (data['hasMore'] != true || cursor == null) { + complete = true; + break; + } + } + + return TurboPaymentHistory(payments: payments, complete: complete); + } + Future getBalance({ required Wallet wallet, }) async { diff --git a/test/models/daos/drive_dao_ardrive_transactions_test.dart b/test/models/daos/drive_dao_ardrive_transactions_test.dart new file mode 100644 index 0000000000..39494337d3 --- /dev/null +++ b/test/models/daos/drive_dao_ardrive_transactions_test.dart @@ -0,0 +1,127 @@ +import 'package:ardrive/models/models.dart'; +import 'package:drift/drift.dart'; +import 'package:test/test.dart'; + +import '../../test_utils/utils.dart'; + +/// The transactions a wallet's drives are made of, which decide what of its +/// AR spending was ArDrive storage. +void main() { + late Database db; + late DriveDao driveDao; + + setUp(() { + db = getTestDb(); + driveDao = db.driveDao; + }); + + tearDown(() async => db.close()); + + Future tx(String id) => db.into(db.networkTransactions).insert( + NetworkTransactionsCompanion.insert(id: id), + mode: InsertMode.insertOrIgnore, + ); + + Future fileRevision( + String driveId, + String fileId, { + required String metadata, + required String data, + String? bundle, + }) async { + await tx(metadata); + await tx(data); + await db.into(db.fileRevisions).insert(FileRevisionsCompanion.insert( + fileId: fileId, + driveId: driveId, + name: fileId, + parentFolderId: 'root', + size: 1, + lastModifiedDate: DateTime(2024), + metadataTxId: metadata, + dataTxId: data, + action: 'create', + bundledIn: Value(bundle), + )); + } + + test('every file revision, folder and drive transaction, and their bundles', + () async { + await fileRevision('mine', 'a', metadata: 'a-meta', data: 'a-data'); + await fileRevision('mine', 'b', + metadata: 'b-meta', data: 'b-data', bundle: 'bundle-1'); + + await tx('folder-meta'); + await db.into(db.folderRevisions).insert(FolderRevisionsCompanion.insert( + folderId: 'f', + driveId: 'mine', + name: 'f', + metadataTxId: 'folder-meta', + action: 'create', + )); + + await tx('drive-meta'); + await db.into(db.driveRevisions).insert(DriveRevisionsCompanion.insert( + driveId: 'mine', + rootFolderId: 'root', + ownerAddress: 'me', + name: 'mine', + privacy: 'public', + metadataTxId: 'drive-meta', + action: 'create', + bundledIn: const Value('bundle-2'), + )); + + const all = { + 'a-meta', + 'a-data', + 'b-meta', + 'b-data', + 'bundle-1', + 'folder-meta', + 'drive-meta', + 'bundle-2', + }; + expect( + await driveDao.arDriveTransactionsAmong(['mine'], {...all, 'other'}), + all, + ); + }); + + test('answers only for the transactions asked about', () async { + await fileRevision('mine', 'a', metadata: 'a-meta', data: 'a-data'); + + expect( + await driveDao.arDriveTransactionsAmong(['mine'], {'a-data', 'other'}), + {'a-data'}, + ); + expect(await driveDao.arDriveTransactionsAmong(['mine'], {}), isEmpty); + }); + + test('asks about any number, a batch at a time', () async { + await fileRevision('mine', 'a', metadata: 'a-meta', data: 'a-data'); + await fileRevision('mine', 'b', metadata: 'b-meta', data: 'b-data'); + + // More than one batch, with a match in the first and in the last. + final asked = { + 'a-data', + for (var i = 0; i < 1000; i++) 'unknown-$i', + 'b-meta', + }; + + expect( + await driveDao.arDriveTransactionsAmong(['mine'], asked), + {'a-data', 'b-meta'}, + ); + }); + + test('only the drives asked about', () async { + await fileRevision('mine', 'a', metadata: 'a-meta', data: 'a-data'); + await fileRevision('theirs', 'z', metadata: 'z-meta', data: 'z-data'); + + const asked = {'a-meta', 'a-data', 'z-meta', 'z-data'}; + expect(await driveDao.arDriveTransactionsAmong(['mine'], asked), + {'a-meta', 'a-data'}); + expect(await driveDao.arDriveTransactionsAmong([], asked), isEmpty); + }); +} diff --git a/test/models/daos/drive_dao_permanent_data_test.dart b/test/models/daos/drive_dao_permanent_data_test.dart new file mode 100644 index 0000000000..9edee4a39a --- /dev/null +++ b/test/models/daos/drive_dao_permanent_data_test.dart @@ -0,0 +1,196 @@ +import 'package:ardrive/models/models.dart'; +import 'package:drift/drift.dart'; +import 'package:test/test.dart'; + +import '../../test_utils/utils.dart'; + +/// What the permanence panel says is stored for good. A figure somebody may +/// hold us to, so it counts only data that is really there, once. +void main() { + late Database db; + late DriveDao driveDao; + + setUp(() { + db = getTestDb(); + driveDao = db.driveDao; + }); + + tearDown(() async => db.close()); + + /// One revision of [fileId], made in [year] (in [month], so a file's + /// revisions in the same year stay apart). + Future addRevision( + String fileId, { + required int size, + String driveId = 'drive', + String? dataTxId, + int year = 2024, + int month = 6, + String? pinnedBy, + }) => + db.into(db.fileRevisions).insert( + FileRevisionsCompanion.insert( + fileId: fileId, + driveId: driveId, + parentFolderId: 'root', + name: fileId, + metadataTxId: '$fileId-$year-$month-meta', + dataTxId: dataTxId ?? '$fileId-tx', + size: size, + lastModifiedDate: DateTime(year), + dateCreated: Value(DateTime(year, month)), + action: RevisionAction.create, + pinnedDataOwnerAddress: Value(pinnedBy), + ), + ); + + Future markTx(String txId, String status) => + db.into(db.networkTransactions).insert( + NetworkTransactionsCompanion.insert( + id: txId, + status: Value(status), + ), + ); + + test('groups what is stored by the year it was uploaded', () async { + await addRevision('a', size: 100, year: 2022); + await addRevision('b', size: 200, year: 2024); + await addRevision('c', size: 50, year: 2024); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2022: const PermanentData(bytes: 100, items: 1), + 2024: const PermanentData(bytes: 250, items: 2), + }); + expect(await driveDao.permanentFileCount(['drive']), 3); + }); + + test('counts every version of a file, each in the year it was uploaded', + () async { + // Made in 2021, replaced in 2024: both versions are stored, and the + // second is 2024's, not 2021's. + await addRevision('report', size: 100, dataTxId: 'v1', year: 2021); + await addRevision('report', size: 300, dataTxId: 'v2', year: 2024); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2021: const PermanentData(bytes: 100, items: 1), + 2024: const PermanentData(bytes: 300, items: 1), + }); + expect(await driveDao.permanentFileCount(['drive']), 1); + }); + + test('counts a rename once, in the year its data came', () async { + // A rename is a new revision over the same data. + await addRevision('photo', size: 100, dataTxId: 'data', year: 2022); + await addRevision('photo', size: 100, dataTxId: 'data', year: 2024); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2022: const PermanentData(bytes: 100, items: 1), + }); + }); + + test('counts a copy of the same data once, though it is two files', () async { + // The same upload, filed in two folders. + await addRevision('original', size: 100, dataTxId: 'shared', year: 2022); + await addRevision('copy', size: 100, dataTxId: 'shared', year: 2024); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2022: const PermanentData(bytes: 100, items: 1), + }); + expect(await driveDao.permanentFileCount(['drive']), 2); + }); + + test('leaves out uploads that are pending or failed', () async { + await addRevision('landed', size: 100); + await addRevision('waiting', size: 200); + await addRevision('lost', size: 400); + await markTx('landed-tx', TransactionStatus.confirmed); + await markTx('waiting-tx', TransactionStatus.pending); + await markTx('lost-tx', TransactionStatus.failed); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2024: const PermanentData(bytes: 100, items: 1), + }); + expect(await driveDao.permanentFileCount(['drive']), 1); + }); + + test('keeps a file whose newest version is still uploading', () async { + await addRevision('draft', size: 100, dataTxId: 'old', year: 2023); + await addRevision('draft', size: 200, dataTxId: 'new', year: 2024); + await markTx('new', TransactionStatus.pending); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2023: const PermanentData(bytes: 100, items: 1), + }); + expect(await driveDao.permanentFileCount(['drive']), 1); + }); + + test('counts data synced with no transaction row as stored', () async { + // A sync writes revisions before, or without, their transaction rows. + await addRevision('synced', size: 100); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2024: const PermanentData(bytes: 100, items: 1), + }); + }); + + test('leaves out pinned files, which somebody else paid to store', () async { + await addRevision('mine', size: 100); + await addRevision('pin', size: 9000, pinnedBy: 'someone-else'); + + expect(await driveDao.permanentDataByYear(['drive']), { + 2024: const PermanentData(bytes: 100, items: 1), + }); + expect(await driveDao.permanentFileCount(['drive']), 1); + }); + + test('reads only the drives asked about', () async { + await addRevision('mine', size: 100, driveId: 'mine'); + await addRevision('shared-with-me', size: 9000, driveId: 'theirs'); + + expect(await driveDao.permanentDataByYear(['mine']), { + 2024: const PermanentData(bytes: 100, items: 1), + }); + expect(await driveDao.permanentFileCount(['mine']), 1); + expect(await driveDao.permanentDataByYear([]), isEmpty); + expect(await driveDao.permanentFileCount([]), 0); + }); + + group('the data version', () { + // The profile card keeps its figures while this stays the same, so it + // must change whenever they could. + test('changes when a revision is written', () async { + final before = await driveDao.permanentDataVersion(); + await addRevision('new', size: 100); + + expect(await driveDao.permanentDataVersion(), isNot(before)); + }); + + test('changes when an upload lands or fails', () async { + await addRevision('a', size: 100); + await addRevision('b', size: 100); + await markTx('a-tx', TransactionStatus.pending); + await markTx('b-tx', TransactionStatus.pending); + final pending = await driveDao.permanentDataVersion(); + + Future settle(String txId, String status) => + (db.update(db.networkTransactions)..where((t) => t.id.equals(txId))) + .write(NetworkTransactionsCompanion(status: Value(status))); + + await settle('a-tx', TransactionStatus.failed); + final oneFailed = await driveDao.permanentDataVersion(); + expect(oneFailed, isNot(pending)); + + await settle('b-tx', TransactionStatus.confirmed); + expect(await driveDao.permanentDataVersion(), isNot(oneFailed)); + }); + + test('stays the same while nothing changes', () async { + await addRevision('a', size: 100); + + expect( + await driveDao.permanentDataVersion(), + await driveDao.permanentDataVersion(), + ); + }); + }); +} diff --git a/test/permanence/ar_spend_test.dart b/test/permanence/ar_spend_test.dart new file mode 100644 index 0000000000..666c8d22ed --- /dev/null +++ b/test/permanence/ar_spend_test.dart @@ -0,0 +1,438 @@ +import 'package:ardrive/permanence/ar_spend.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Map storage; + late List<({int minHeight, String? after})> asked; + + /// What each count asked the database about. + late List> looked; + + setUp(() { + storage = {}; + asked = []; + looked = []; + }); + + ArFeePage page(int winston, int count, int? lastHeight, + {String? cursor, + bool hasMore = false, + Map excluded = const {}}) => + ArFeePage( + winston: BigInt.from(winston), + count: count, + lastHeight: lastHeight, + cursor: cursor, + hasMore: hasMore, + excluded: { + for (final e in excluded.entries) e.key: BigInt.from(e.value) + }, + ); + + Future tally( + List pages, { + int maxPages = 10, + bool failOnFirst = false, + Set known = const {}, + bool lookupFails = false, + }) { + var next = 0; + return tallyArSpend( + storageKey: 'key', + read: (key) => storage[key], + write: (key, value) async => storage[key] = value, + maxPages: maxPages, + knownAmong: (txIds) async { + looked.add(txIds); + if (lookupFails) throw Exception('database closed'); + return txIds.intersection(known); + }, + fetchPage: ({required int minHeight, String? after}) async { + asked.add((minHeight: minHeight, after: after)); + if (failOnFirst) throw Exception('gateway down'); + return pages[next++]; + }, + ); + } + + test('a wallet that only used Turbo costs one empty request', () async { + final result = await tally([page(0, 0, null)]); + + expect(result.winston, BigInt.zero); + expect(result.uploads, 0); + expect(result.complete, isTrue); + expect(asked, hasLength(1)); + }); + + test('adds up every page', () async { + final result = await tally([ + page(100, 100, 10, cursor: 'a', hasMore: true), + page(50, 30, 12), + ]); + + expect(result.winston, BigInt.from(150)); + expect(result.uploads, 130); + expect(result.lastHeight, 12); + expect(result.complete, isTrue); + expect(asked.map((a) => a.after), [null, 'a']); + }); + + test('later counts ask only for blocks after the last one counted', () async { + await tally([page(100, 3, 500)]); + asked.clear(); + + final result = await tally([page(20, 1, 600)]); + + expect(asked.single.minHeight, 501); + expect(result.winston, BigInt.from(120)); + expect(result.uploads, 4); + }); + + test('never makes more than its page limit, and carries on next time', + () async { + final first = await tally( + [ + page(10, 100, 1, cursor: 'p1', hasMore: true), + page(10, 100, 2, cursor: 'p2', hasMore: true), + ], + maxPages: 2, + ); + + expect(asked, hasLength(2)); + expect(first.complete, isFalse, reason: 'a lower bound so far'); + expect(first.winston, BigInt.from(20)); + + asked.clear(); + final second = await tally([page(5, 40, 3)], maxPages: 2); + + // From where it stopped: the same run, the next page. + expect(asked.single, (minHeight: 0, after: 'p2')); + expect(second.winston, BigInt.from(25)); + expect(second.complete, isTrue); + }); + + test( + 'a cursor that stops working starts its count again, counting nothing ' + 'twice', () async { + // A gateway switched, a cursor expired: the unfinished count cannot go on. + await tally([page(100, 3, 500)]); + await tally( + [page(10, 100, 600, cursor: 'stale', hasMore: true)], + maxPages: 1, + ); + asked.clear(); + + final failed = await tally([], failOnFirst: true); + expect(failed.winston, BigInt.from(110), reason: 'shown as it was'); + + asked.clear(); + final restarted = await tally([ + page(10, 100, 600, cursor: 'fresh', hasMore: true), + page(5, 20, 700), + ]); + + // From the start of that count, not from the dead cursor. + expect(asked.first, (minHeight: 501, after: null)); + expect(restarted.winston, BigInt.from(115)); + expect(restarted.uploads, 123); + expect(restarted.complete, isTrue); + }); + + group('a transaction left out before its drive synced', () { + test('is counted once sync knows it, without asking again', () async { + // The data of a direct upload: no tag says it is ArDrive's, and its + // drive had not synced when this count passed it. + await tally([ + page(100, 1, 500, excluded: {'late-data': 40}) + ]); + asked.clear(); + + final later = await tally([page(0, 0, null)], known: {'late-data'}); + + expect(later.winston, BigInt.from(140)); + expect(later.uploads, 2); + expect(later.excluded, isEmpty); + expect(asked.single.minHeight, 501, reason: 'not counted again'); + }); + + test('is counted only once', () async { + await tally([ + page(0, 0, 500, excluded: {'late-data': 40}) + ]); + await tally([page(0, 0, null)], known: {'late-data'}); + + final again = await tally([page(0, 0, null)], known: {'late-data'}); + + expect(again.winston, BigInt.from(40)); + }); + + test('one from another app stays left out, and remembered', () async { + final result = await tally( + [ + page(0, 0, 500, excluded: {'arkb-upload': 9}) + ], + known: {'something-else'}, + ); + + expect(result.winston, BigInt.zero); + expect(ArSpendTally.fromJson(storage['key'])!.excluded.keys, + ['arkb-upload']); + }); + + test('is all the database is asked about, and only when there is one', + () async { + // A drive of 100,000 files is made of 400,000 transactions: reading + // them all on every open cost seconds on the web. + await tally([page(100, 1, 500)]); + expect(looked, isEmpty, reason: 'nothing left out, nothing to ask'); + + await tally([ + page(0, 0, 600, excluded: {'late-data': 40}) + ]); + looked.clear(); + + await tally([page(0, 0, null)], known: {'late-data'}); + expect(looked, [ + {'late-data'} + ]); + }); + + test('stays left out while the database cannot say', () async { + await tally([ + page(0, 0, 500, excluded: {'late-data': 40}) + ]); + + final result = await tally( + [page(0, 0, null)], + known: {'late-data'}, + lookupFails: true, + ); + + expect(result.winston, BigInt.zero); + expect(result.excluded.keys, ['late-data'], reason: 'asked again later'); + }); + + test('keeps only the most recent of them', () async { + final many = { + for (var i = 0; i < ArSpendTally.maxExcluded + 10; i++) 'tx-$i': 1 + }; + + final result = await tally([page(0, 0, 500, excluded: many)]); + + expect(result.excluded, hasLength(ArSpendTally.maxExcluded)); + expect(result.excluded.containsKey('tx-0'), isFalse); + expect(result.excluded.containsKey('tx-${ArSpendTally.maxExcluded + 9}'), + isTrue); + }); + }); + + test('a failed request keeps what was already counted', () async { + await tally([page(100, 3, 500)]); + + final result = await tally([], failOnFirst: true); + + expect(result.winston, BigInt.from(100)); + expect(result.uploads, 3); + }); + + test('a stored tally it cannot read is started again', () async { + storage['key'] = 'not json'; + + final result = await tally([page(7, 1, 9)]); + + expect(result.winston, BigInt.from(7)); + expect(asked.single.minHeight, 0); + }); + + group('what one transaction cost', () { + test('is its fee', () { + expect( + arDrivePayment( + feeWinston: '100', + quantityWinston: '0', + tags: const {'App-Name': 'ArDrive-App'}, + ), + BigInt.from(100), + ); + }); + + test("adds the tip a direct upload sent ArDrive's community", () { + expect( + arDrivePayment( + feeWinston: '100', + quantityWinston: '15', + tags: const {'App-Name': 'ArDrive-App', 'Tip-Type': 'data upload'}, + ), + BigInt.from(115), + ); + }); + + test('leaves out a transfer that is not a tip', () { + expect( + arDrivePayment( + feeWinston: '100', + quantityWinston: '5000', + tags: const {'App-Name': 'ArDrive-App'}, + ), + BigInt.from(100), + ); + }); + }); + + test('a winston is a trillionth of an AR', () { + final tally = ArSpendTally( + winston: BigInt.from(1500000000000), + uploads: 1, + lastHeight: 1, + ); + + expect(tally.ar, 1.5); + }); + + group('asking only for transactions of the wallet itself', () { + RawFeePage raw({required int rows, required int unbundled, int ar = 0}) => + RawFeePage( + page: ArFeePage( + winston: BigInt.from(ar), + count: unbundled, + lastHeight: rows == 0 ? null : 10, + cursor: null, + hasMore: false, + ), + rows: rows, + unbundledRows: unbundled, + ); + + test('an ar.io gateway, which honours [""], is asked once', () async { + final filter = UnbundledFilter(); + final asked = ?>[]; + + final page = await fetchDirectFeesPage( + filter: filter, + firstPage: true, + fetch: (bundledIn) async { + asked.add(bundledIn); + return raw(rows: 100, unbundled: 100, ar: 7); + }, + ); + + expect(asked, [ + [''] + ]); + expect(page.winston, BigInt.from(7)); + expect(filter.useEmptyId, isTrue); + }); + + test('a wallet that only used Turbo costs two requests, and is zero', + () async { + // [""] finds nothing; null on an ar.io gateway brings back only the + // wallet's data items, which are not its own transactions. + final filter = UnbundledFilter(); + final asked = ?>[]; + + final page = await fetchDirectFeesPage( + filter: filter, + firstPage: true, + fetch: (bundledIn) async { + asked.add(bundledIn); + return bundledIn == null + ? raw(rows: 100, unbundled: 0) + : raw(rows: 0, unbundled: 0); + }, + ); + + expect(asked, [ + [''], + null + ]); + expect(page.winston, BigInt.zero); + expect(filter.useEmptyId, isTrue, reason: 'nothing learned about null'); + }); + + test( + 'a gateway that honours null, not [""], is asked with null from then ' + 'on', () async { + final filter = UnbundledFilter(); + final asked = ?>[]; + + Future gateway(List? bundledIn) async { + asked.add(bundledIn); + return bundledIn == null + ? raw(rows: 100, unbundled: 100, ar: 9) + : raw(rows: 0, unbundled: 0); + } + + final first = await fetchDirectFeesPage( + filter: filter, firstPage: true, fetch: gateway); + await fetchDirectFeesPage( + filter: filter, firstPage: false, fetch: gateway); + + expect(first.winston, BigInt.from(9)); + expect(filter.useEmptyId, isFalse); + expect(asked, [ + [''], + null, + null + ]); + }); + + test('an empty page part-way through a count asks nothing more', () async { + final asked = ?>[]; + + await fetchDirectFeesPage( + filter: UnbundledFilter(), + firstPage: false, + fetch: (bundledIn) async { + asked.add(bundledIn); + return raw(rows: 0, unbundled: 0); + }, + ); + + expect(asked, hasLength(1)); + }); + }); + + group('what counts as ArDrive storage', () { + test('a transaction a synced drive is made of, whatever its tags', () { + // The data of a direct upload: costly, and with no ArFS tag. + expect( + isArDriveTransaction( + txId: 'data-tx', + tags: const {'Content-Type': 'video/mp4'}, + knownTxIds: const {'data-tx'}, + ), + isTrue, + ); + }); + + test('by tag, for what has not synced yet', () { + expect( + isArDriveTransaction( + txId: 'x', + tags: const {'ArFS': '0.11'}, + knownTxIds: const {}, + ), + isTrue, + ); + expect( + isArDriveTransaction( + txId: 'x', + tags: const {'App-Name': 'ArDrive-Web'}, + knownTxIds: const {}, + ), + isTrue, + ); + }); + + test('not another app', () { + expect( + isArDriveTransaction( + txId: 'x', + tags: const {'App-Name': 'arkb'}, + knownTxIds: const {'something-else'}, + ), + isFalse, + ); + }); + }); +} diff --git a/test/permanence/permanence_panel_test.dart b/test/permanence/permanence_panel_test.dart new file mode 100644 index 0000000000..9478699257 --- /dev/null +++ b/test/permanence/permanence_panel_test.dart @@ -0,0 +1,530 @@ +import 'package:ardrive/core/activity_tracker.dart'; +import 'package:ardrive/permanence/ar_spend.dart'; +import 'package:ardrive/permanence/permanence_panel.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + const gb = 1024 * 1024 * 1024; + + PermanenceSummary summary({ + Future? spend, + Future? arSpend, + Future? costToday, + }) => + PermanenceSummary( + totalBytes: 2 * gb, + fileCount: 1200, + driveCount: 3, + bytesByYear: const {2022: gb, 2024: gb}, + costToday: costToday, + spend: spend, + arSpend: arSpend, + ); + + Future pump( + WidgetTester tester, + PermanenceSummary summary, { + double width = 460, + }) async { + tester.view.physicalSize = Size(width, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget(ArDriveTheme( + themeData: lightTheme(), + child: MaterialApp( + home: Material( + child: SingleChildScrollView( + child: PermanencePanel(summary: summary), + ), + ), + ), + )); + await tester.pump(); + } + + testWidgets('says what is stored, since when, and what it costs today', + (tester) async { + // 20 AR from the network, at $5 an AR. + await pump( + tester, + summary( + costToday: Future.value(NetworkCost( + winston: BigInt.from(20) * BigInt.from(10).pow(12), + usdPerAr: 5, + )), + ), + ); + + expect(find.textContaining('2.0 GiB'), findsOneWidget); + expect(find.textContaining('1,200 files'), findsOneWidget); + expect(find.textContaining('since 2022'), findsOneWidget); + expect(find.text(r'$100'), findsOneWidget); + expect(find.text('20.00 AR'), findsOneWidget, + reason: 'the AR it comes from'); + expect(find.text('Network price to store it'), findsOneWidget); + expect(find.textContaining('Turbo'), findsNothing, + reason: 'the price of storage, not of a service'); + }); + + group('the cost today', () { + testWidgets('without a dollar rate is the AR price alone', (tester) async { + await pump( + tester, + summary( + costToday: Future.value(NetworkCost( + winston: BigInt.from(15) * BigInt.from(10).pow(11), + usdPerAr: null, + )), + ), + ); + + expect(find.text('1.50 AR'), findsOneWidget); + }); + + testWidgets('says so when the network cannot be reached', (tester) async { + await pump(tester, summary(costToday: Future.value(null))); + + expect(find.text("couldn't reach the network"), findsOneWidget); + }); + }); + + test('a network price that cannot be read is no cost, not a wrong one', + () async { + expect( + await loadNetworkCost( + price: () async => throw Exception('gateway down'), + usdPerAr: Future.value(4.0), + ), + isNull, + ); + + final cost = await loadNetworkCost( + price: () async => BigInt.from(2) * BigInt.from(10).pow(12), + usdPerAr: Future.value(4.0), + ); + expect(cost!.ar, 2); + expect(cost.usd, 8); + }); + + testWidgets('promises no duration, and no price per year', (tester) async { + // "Stored for 200 years" read as a guarantee, and "$6.00 a year over 200 + // years" was a sum nobody could follow. Neither is said. + await pump(tester, summary()); + + expect(find.textContaining('Stored for', findRichText: true), findsNothing); + expect(find.textContaining('a year', findRichText: true), findsNothing); + }); + + group('the spend', () { + // Made inside each test, so it completes on the test's own clock. + Future credits(double amount) => Future.value( + SpendLookup.found(BigInt.from(amount * 1e12)), + ); + + testWidgets('is the credits used, with no dollars beside it', + (tester) async { + await pump(tester, summary(spend: credits(92.0812))); + + expect(find.text('SPENT'), findsOneWidget); + expect(find.text('92.08'), findsOneWidget); + expect(find.text('Turbo credits used'), findsOneWidget); + expect(find.textContaining('estimated'), findsNothing, + reason: 'credits used are exact'); + }); + + testWidgets('says it could not be read, apart from finding none', + (tester) async { + await pump( + tester, + summary(spend: Future.value(const SpendLookup.failed())), + ); + expect(find.text("couldn't read your credits"), findsOneWidget); + + await pump( + tester, summary(spend: Future.value(const SpendLookup.none()))); + expect(find.text('no top-ups found'), findsOneWidget); + }); + + testWidgets('of a history too long to read in full shows no figure', + (tester) async { + await pump( + tester, + summary(spend: Future.value(const SpendLookup.tooMany())), + ); + + expect(find.text('more top-ups than we can count here'), findsOneWidget); + }); + + testWidgets('for a wallet that would prompt waits until asked', + (tester) async { + // A browser extension asks its owner to approve every signature, and + // reading the credits takes one. + var reads = 0; + await pump( + tester, + PermanenceSummary( + totalBytes: 2 * gb, + fileCount: 1200, + driveCount: 3, + bytesByYear: const {2022: gb, 2024: gb}, + loadSpend: () { + reads++; + return credits(92.0812); + }, + ), + ); + + expect(reads, 0, reason: 'not on opening the panel'); + expect(find.text('Show credits used'), findsOneWidget); + expect( + find.text('your wallet signs to read your credits'), findsOneWidget); + + await tester.tap(find.text('Show credits used')); + await tester.pump(); + await tester.pump(); + + expect(reads, 1); + expect(find.text('92.08'), findsOneWidget); + expect(find.text('Show credits used'), findsNothing); + }); + + testWidgets('of credits not yet used says so', (tester) async { + await pump(tester, summary(spend: credits(0))); + + expect(find.text('no credits used yet'), findsOneWidget); + }); + }); + + group('AR paid directly', () { + ArSpendTally ar(double amount, int uploads, {bool complete = true}) => + ArSpendTally( + winston: BigInt.from(amount * 1e12), + uploads: uploads, + lastHeight: 100, + runMinHeight: complete ? null : 0, + cursor: complete ? null : 'next', + ); + + // Made inside each test, so it completes on the test's own clock. + Future used() => + Future.value(SpendLookup.found(BigInt.from(40e12))); + + testWidgets('sits under the credits, never added to them', (tester) async { + await pump( + tester, + summary(spend: used(), arSpend: Future.value(ar(1.2345, 7))), + ); + + expect(find.text('40.00'), findsOneWidget); + expect(find.text('+ 1.23 AR paid directly'), findsOneWidget); + }); + + testWidgets('is the figure when no credits were used', (tester) async { + await pump( + tester, + summary( + spend: Future.value(const SpendLookup.none()), + arSpend: Future.value(ar(0.5, 3)), + ), + ); + + expect(find.text('0.5 AR'), findsOneWidget); + expect(find.text('paid directly in AR'), findsOneWidget); + }); + + testWidgets('an unfinished count says it is a lower bound', (tester) async { + await pump( + tester, + summary( + spend: used(), + arSpend: Future.value(ar(2, 1000, complete: false)), + ), + ); + + expect(find.text('+ at least 2.00 AR paid directly'), findsOneWidget); + }); + + testWidgets('says nothing when there is none', (tester) async { + await pump( + tester, + summary(spend: used(), arSpend: Future.value(ArSpendTally.empty)), + ); + + expect(find.textContaining('AR'), findsNothing); + }); + }); + + testWidgets('keeps its fine print one tap away', (tester) async { + await pump(tester, summary()); + + expect( + find.textContaining("isn't a bill", findRichText: true), findsNothing); + + await tester.tap(find.text('How these numbers work')); + await tester.pumpAndSettle(); + + expect(find.textContaining("isn't a bill", findRichText: true), + findsOneWidget); + expect(find.textContaining('Terms of Service', findRichText: true), + findsOneWidget); + }); + + testWidgets('says plainly what each figure counts', (tester) async { + await pump(tester, summary()); + await tester.tap(find.text('How these numbers work')); + await tester.pumpAndSettle(); + + final fineText = tester + .widgetList(find.byType(RichText)) + .map((text) => text.text.toPlainText()) + .where((text) => text.contains(' is ') || text.contains(' counts ')) + .join('\n'); + + expect(fineText, contains('Size counts every version')); + expect(fineText, contains('Hidden drives')); + expect(fineText, contains('network fees and ArDrive tips')); + expect(fineText, contains('Neither is in dollars')); + expect(fineText, contains("isn't what ArDrive or Turbo would charge")); + expect(fineText, contains('Arweave says'), + reason: "a duration is Arweave's claim, not ours"); + expect(fineText, isNot(contains(' - ')), reason: 'no dashes'); + expect(fineText, isNot(contains('shared from your')), + reason: 'only for a reader who signed in with another chain'); + }); + + testWidgets( + 'tells a reader who signed in with another chain what it leaves out', + (tester) async { + await pump( + tester, + const PermanenceSummary( + totalBytes: gb, + fileCount: 1, + driveCount: 1, + bytesByYear: {2024: gb}, + signInChain: 'Solana', + ), + ); + await tester.tap(find.text('How these numbers work')); + await tester.pumpAndSettle(); + + expect( + find.textContaining("Credits shared from your Solana wallet aren't", + findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('keeps its two figures side by side on a phone', (tester) async { + // Stacked, the panel did not fit a phone, and nothing said it scrolled. + await pump(tester, summary(), width: 340); + + final spend = tester.getTopLeft(find.text('SPENT')); + final cost = tester.getTopLeft(find.text('COST TODAY')); + + expect(cost.dy, spend.dy, reason: 'one beside the other'); + expect(tester.takeException(), isNull); + }); + + testWidgets('stacks them only on a screen too narrow for that', + (tester) async { + await pump(tester, summary(), width: 260); + + final spend = tester.getTopLeft(find.text('SPENT')); + final cost = tester.getTopLeft(find.text('COST TODAY')); + + expect(cost.dy, greaterThan(spend.dy)); + }); + + // The profile card closes as it opens the panel, so the context that + // opened it is gone by the time Share is pressed. + testWidgets('opens the share card after what opened the panel has gone', + (tester) async { + tester.view.physicalSize = const Size(1200, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget(ChangeNotifierProvider( + create: (_) => ActivityTracker(), + child: ArDriveTheme( + themeData: lightTheme(), + child: MaterialApp( + home: Material( + child: _Opener( + onOpen: (context) => showPermanencePanel(context, summary()), + ), + ), + ), + ), + )); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Open'), findsNothing); + + await tester.tap(find.text('Share my permanence')); + await tester.pumpAndSettle(); + + // The card is drawn at a fixed size and the test font is wider than the + // app's, so its text can overflow here. What this checks is that it + // opened, from a context that was still there. + expect('${tester.takeException()}', isNot(contains('deactivated'))); + expect(find.text('Share your permanence'), findsOneWidget); + }); + + test('sizes are in the units the rest of the app uses', () { + // The drives list says GiB for 1024-based sizes; this said GB for the + // same arithmetic, so the two looked like different numbers. + expect(formatPermanenceSize(512), '0.5 KiB'); + expect(formatPermanenceSize(3 * 1024 * 1024), '3.0 MiB'); + expect(formatPermanenceSize((24.9 * gb).round()), '24.9 GiB'); + expect(formatPermanenceSize(150 * gb), '150 GiB'); + expect(formatPermanenceSize(2 * 1024 * gb), '2.0 TiB'); + }); + + test('counts are separated by thousands, with a bullet between them', () { + expect( + [permanenceCount(21, 'drive'), permanenceCount(1776, 'file')] + .join(permanenceSeparator), + '21 drives • 1,776 files', + ); + expect(permanenceCount(1, 'file'), '1 file'); + }); + + testWidgets('keeps a label for every year of a long history', (tester) async { + // Six years filled the chart; a seventh and eighth pushed the oldest off + // its top. + await pump( + tester, + PermanenceSummary( + totalBytes: 10 * gb, + fileCount: 10, + driveCount: 1, + bytesByYear: {for (var y = 2017; y <= 2026; y++) y: gb}, + ), + ); + + expect( + find.byWidgetPredicate( + (w) => w is SizedBox && w.child is CustomPaint && w.height == 200, + ), + findsOneWidget, + reason: 'twenty points a year', + ); + expect( + find.bySemanticsLabel(RegExp('2017, 1.0 GiB.*2026, 1.0 GiB')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + test('spreads the labels of two thin years apart', () { + // 2025 and 2026 were small and sat together at the top of the chart, so + // their labels touched. + final ys = spreadStrataLabels( + [110, 80, 40, 12, 6, 2], + gap: 16, + height: 120, + ); + + for (var i = 1; i < ys.length; i++) { + expect(ys[i - 1] - ys[i], greaterThanOrEqualTo(16)); + } + expect(ys.last, greaterThanOrEqualTo(8), reason: 'inside the top edge'); + expect(ys.first, lessThanOrEqualTo(112), reason: 'inside the bottom'); + expect(ys.take(3), [110, 80, 56], + reason: 'the rest move no more than needed'); + }); + + testWidgets('copies the text without closing the share card', (tester) async { + tester.view.physicalSize = const Size(1200, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + String? copied; + tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String; + } + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + + await tester.pumpWidget(ChangeNotifierProvider( + create: (_) => ActivityTracker(), + child: ArDriveTheme( + themeData: lightTheme(), + child: MaterialApp( + home: Material( + child: Builder( + builder: (context) => TextButton( + onPressed: () => showPermanenceShare(context, summary()), + child: const Text('Share'), + ), + ), + ), + ), + ), + )); + + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + // The card's fixed size overflows in the test font only; see above. + tester.takeException(); + + await tester.tap(find.text('Copy text')); + await tester.pump(); + + expect(copied, permanenceShareText(summary())); + expect(find.text('Copied'), findsOneWidget, reason: 'says it is done'); + expect(find.text('Share your permanence'), findsOneWidget, + reason: 'still open, for the image too'); + + await tester.pump(const Duration(seconds: 3)); + expect(find.text('Copy text'), findsOneWidget); + tester.takeException(); + }); + + test('the share text carries no dollar figure and no promised duration', () { + final text = permanenceShareText(summary()); + + expect(text, contains('2.0 GiB')); + expect(text, isNot(contains(r'$'))); + expect(text, isNot(contains('200'))); + }); +} + +/// A button that takes itself away as it is pressed, as the profile card +/// closes when it opens the panel. +class _Opener extends StatefulWidget { + const _Opener({required this.onOpen}); + + final void Function(BuildContext context) onOpen; + + @override + State<_Opener> createState() => _OpenerState(); +} + +class _OpenerState extends State<_Opener> { + bool _shown = true; + + @override + Widget build(BuildContext context) => _shown + ? Builder( + builder: (context) => TextButton( + onPressed: () { + setState(() => _shown = false); + widget.onOpen(context); + }, + child: const Text('Open'), + ), + ) + : const SizedBox(); +} diff --git a/test/permanence/spend_estimate_test.dart b/test/permanence/spend_estimate_test.dart new file mode 100644 index 0000000000..f4ee69ecdc --- /dev/null +++ b/test/permanence/spend_estimate_test.dart @@ -0,0 +1,60 @@ +import 'package:ardrive/permanence/spend_estimate.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Map topUp(String type, int winc, {String? giftMessage}) => { + 'type': type, + 'wincCredited': '$winc', + if (giftMessage != null) 'giftMessage': giftMessage, + }; + + test('credits used are everything credited minus what is left', () { + expect( + creditsUsed( + payments: [topUp('fiat', 1000), topUp('crypto', 500)], + balanceWinc: BigInt.from(300), + ), + BigInt.from(1200), + ); + }); + + test('granted and gifted credits count like bought ones', () { + // What the console calls "Credit grant via admin", and a gift: credits + // all the same, spent the same way. + expect( + creditsUsed( + payments: [ + topUp('fiat', 5000), + topUp('fiat', 400, giftMessage: 'happy birthday'), + ], + balanceWinc: BigInt.from(100), + ), + BigInt.from(5300), + ); + }); + + test('a balance above what came in is nothing used, not less than nothing', + () { + // Credits shared to this wallet sit in its balance with no top-up. + expect( + creditsUsed( + payments: [topUp('fiat', 1000)], + balanceWinc: BigInt.from(5000), + ), + BigInt.zero, + ); + }); + + test('a row it cannot read adds nothing', () { + expect( + creditsUsed( + payments: [ + topUp('fiat', 1000), + {'type': 'fiat', 'wincCredited': 'not a number'}, + ], + balanceWinc: BigInt.zero, + ), + BigInt.from(1000), + ); + }); +} diff --git a/test/services/arweave/fragment_free_graphql_test.dart b/test/services/arweave/fragment_free_graphql_test.dart index d537d5c07b..9f8c353114 100644 --- a/test/services/arweave/fragment_free_graphql_test.dart +++ b/test/services/arweave/fragment_free_graphql_test.dart @@ -49,6 +49,7 @@ final _documents = { TRANSACTION_DETAILS_WITH_SIGNATURE_QUERY_DOCUMENT, 'DRIVE_STATE_ENTITY_HISTORY_QUERY_DOCUMENT': DRIVE_STATE_ENTITY_HISTORY_QUERY_DOCUMENT, + 'WALLET_AR_DRIVE_FEES_QUERY_DOCUMENT': WALLET_AR_DRIVE_FEES_QUERY_DOCUMENT, }; /// What [selectionSet] selects, as one path per field: the path of aliases or diff --git a/test/turbo/services/payment_history_test.dart b/test/turbo/services/payment_history_test.dart new file mode 100644 index 0000000000..684d6d7e26 --- /dev/null +++ b/test/turbo/services/payment_history_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:ardrive/permanence/permanence_panel.dart'; +import 'package:ardrive/turbo/services/payment_service.dart'; +import 'package:ardrive/turbo/utils/get_signature_headers_for_turbo.dart'; +import 'package:ardrive_http/ardrive_http.dart'; +import 'package:arweave/arweave.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockHttp extends Mock implements ArDriveHTTP {} + +class _MockSigner extends Mock implements TurboSignatureHeadersManager {} + +class _MockWallet extends Mock implements Wallet {} + +class _MockPaymentService extends Mock implements PaymentService {} + +void main() { + late _MockHttp http; + late _MockSigner signer; + late _MockWallet wallet; + late PaymentService service; + + setUpAll(() => registerFallbackValue(_MockWallet())); + + setUp(() { + http = _MockHttp(); + signer = _MockSigner(); + wallet = _MockWallet(); + service = PaymentService( + turboPaymentUri: Uri.parse('https://payment.example'), + httpClient: http, + turboSignatureHeadersManager: signer, + ); + when(() => signer.getSignatureHeaders(wallet: any(named: 'wallet'))) + .thenAnswer((_) async => {'x-signature': 'sig'}); + }); + + /// A history of [pages] pages, each of one row, the last saying there is + /// no more. + void stubPages(int pages) { + var served = 0; + when(() => http.get( + url: any(named: 'url'), + headers: any(named: 'headers'), + )).thenAnswer((_) async { + served++; + return ArDriveHTTPResponse( + data: jsonEncode({ + 'payments': [ + {'type': 'fiat', 'wincCredited': '1'} + ], + 'hasMore': served < pages, + 'cursor': served < pages ? 'page-$served' : null, + }), + statusCode: 200, + retryAttempts: 0, + ); + }); + } + + group('getPaymentHistory', () { + test('reads every page, and says it has all of them', () async { + stubPages(3); + + final history = await service.getPaymentHistory(wallet: wallet); + + expect(history.payments, hasLength(3)); + expect(history.complete, isTrue); + }); + + test('a history longer than it reads says it is not complete', () async { + // Cut short is not smaller: a reader of the rows alone would take 2 + // pages for the whole history. + stubPages(5); + + final history = + await service.getPaymentHistory(wallet: wallet, maxPages: 2); + + expect(history.payments, hasLength(2)); + expect(history.complete, isFalse); + verify(() => http.get( + url: any(named: 'url'), + headers: any(named: 'headers'), + )).called(2); + }); + }); + + test('a history cut short gives no figure at all', () async { + final payments = _MockPaymentService(); + when(() => payments.getPaymentHistory(wallet: any(named: 'wallet'))) + .thenAnswer((_) async => const TurboPaymentHistory( + payments: [ + {'type': 'crypto', 'wincCredited': '100'} + ], + complete: false, + )); + + final lookup = await loadCreditsSpent( + paymentService: payments, + wallet: wallet, + ); + + expect(lookup.tooMany, isTrue); + expect(lookup.creditsWinc, isNull); + verifyNever( + () => payments.getBalanceAndPaidBy(wallet: any(named: 'wallet'))); + }); + + test( + 'credits used are what came in less the wallet\'s own credits, not ' + 'what it can spend', () async { + // 1,000 credited, 250 of its own left, and 4,000 more it can spend that + // another wallet shared with it - with no top-up of their own. + final payments = _MockPaymentService(); + when(() => payments.getPaymentHistory(wallet: any(named: 'wallet'))) + .thenAnswer((_) async => const TurboPaymentHistory( + payments: [ + {'type': 'fiat', 'wincCredited': '1000'} + ], + complete: true, + )); + when(() => payments.getBalanceAndPaidBy(wallet: any(named: 'wallet'))) + .thenAnswer((_) async => TurboBalanceInterface( + balance: BigInt.from(4250), + paidBy: ['sharer'], + controlledWinc: BigInt.from(250), + )); + + final lookup = await loadCreditsSpent( + paymentService: payments, + wallet: wallet, + ); + + expect(lookup.creditsWinc, BigInt.from(750)); + }); +}