From fe366bec1a4a788bffd100d774524c58d4f27754 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 3 Aug 2026 13:26:00 -0700 Subject: [PATCH 1/2] Fix lint warnings in transaction export Add explicit return types, replace truthy checks on nullable denominations with explicit null checks, and type catch callback variables as unknown. --- eslint.config.mjs | 3 --- src/actions/TransactionExportActions.tsx | 20 +++++++++------- .../scenes/TransactionsExportScene.tsx | 24 +++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 2b4d8469310..b4374ea62b9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -133,7 +133,6 @@ export default [ 'src/actions/SoundActions.ts', 'src/actions/TokenTermsActions.tsx', - 'src/actions/TransactionExportActions.tsx', 'src/actions/WalletListActions.tsx', @@ -313,8 +312,6 @@ export default [ 'src/components/scenes/SwapSettingsScene.tsx', 'src/components/scenes/SwapSuccessScene.tsx', - 'src/components/scenes/TransactionsExportScene.tsx', - 'src/components/scenes/WalletRestoreScene.tsx', 'src/components/scenes/WcConnectionsScene.tsx', 'src/components/scenes/WcConnectScene.tsx', diff --git a/src/actions/TransactionExportActions.tsx b/src/actions/TransactionExportActions.tsx index e32155aca7f..82fd87a9acd 100644 --- a/src/actions/TransactionExportActions.tsx +++ b/src/actions/TransactionExportActions.tsx @@ -79,8 +79,8 @@ export function updateTxsFiat( } } }) - .catch(e => { - console.warn(e.message) + .catch((e: unknown) => { + console.warn(e instanceof Error ? e.message : String(e)) }) ) if (promises.length >= UPDATE_TXS_MAX_PROMISES) { @@ -269,6 +269,7 @@ export function exportTransactionsToQBO( ): string { const STMTTRN: any[] = [] const now = makeOfxDate((testDateNow ?? Date.now()) / 1000) + const hasDenom = denom != null for (const tx of edgeTransactions) { const newTxs = getTransferTx(tx, fiatCurrencyCode) @@ -280,8 +281,8 @@ export function exportTransactionsToQBO( } } - function edgeTxToQbo(edgeTx: EdgeTransaction) { - const TRNAMT: string = denom + function edgeTxToQbo(edgeTx: EdgeTransaction): void { + const TRNAMT: string = hasDenom ? div(edgeTx.nativeAmount, denom, DECIMAL_PRECISION) : edgeTx.nativeAmount const TRNTYPE = lt(edgeTx.nativeAmount, '0') ? 'DEBIT' : 'CREDIT' @@ -394,6 +395,7 @@ export function exportTransactionsToCSVInner( denomName: string = '' ): string { const items: any[] = [] + const hasDenom = denom != null for (const tx of edgeTransactions) { const newTxs = getTransferTx(tx, fiatCurrencyCode) @@ -405,11 +407,11 @@ export function exportTransactionsToCSVInner( } } - function edgeTxToCsv(edgeTx: EdgeTransaction) { - const amount: string = denom + function edgeTxToCsv(edgeTx: EdgeTransaction): void { + const amount: string = hasDenom ? div(edgeTx.nativeAmount, denom, DECIMAL_PRECISION) : edgeTx.nativeAmount - const networkFeeField: string = denom + const networkFeeField: string = hasDenom ? div(edgeTx.networkFee, denom, DECIMAL_PRECISION) : edgeTx.networkFee const { date, time } = makeCsvDateTime(edgeTx.date) @@ -459,7 +461,7 @@ export async function exportTransactionsToBitwave( edgeTxToCsv(tx) } - function edgeTxToCsv(edgeTx: EdgeTransaction) { + function edgeTxToCsv(edgeTx: EdgeTransaction): void { const { date, isSend, @@ -486,7 +488,7 @@ export async function exportTransactionsToBitwave( feeTicker = currencyCode fee = div(networkFee, multiplier, DECIMAL_PRECISION) } - if (spendTargets && spendTargets.length > 0) { + if (spendTargets != null && spendTargets.length > 0) { // We can only choose 1 `toAddress` so pick the first spendTarget toAddress = spendTargets[0].publicAddress } diff --git a/src/components/scenes/TransactionsExportScene.tsx b/src/components/scenes/TransactionsExportScene.tsx index bef1d145ddd..d990b644ebf 100644 --- a/src/components/scenes/TransactionsExportScene.tsx +++ b/src/components/scenes/TransactionsExportScene.tsx @@ -126,7 +126,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< } } - setThisMonth = () => { + setThisMonth = (): void => { this.setState({ startDate: new Date( new Date().getFullYear(), @@ -140,7 +140,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< }) } - setLastMonth = () => { + setLastMonth = (): void => { const lastMonth = new Date(new Date().setMonth(new Date().getMonth() - 1)) let lastYear = 0 if (lastMonth.getMonth() === 11) lastYear = 1 // Decrease year by 1 if previous month was December @@ -164,7 +164,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< }) } - loadInfoFile = async () => { + loadInfoFile = async (): Promise => { const { sourceWallet, tokenId } = this.props.route.params const { disklet } = sourceWallet const result = await disklet.getText(EXPORT_TX_INFO_FILE) @@ -189,7 +189,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< }) } - render() { + render(): React.ReactElement { const { startDate, endDate, isExportBitwave, isExportCsv, isExportQbo } = this.state const { currencyCode, theme, route } = this.props @@ -245,7 +245,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< ) } - renderSwitches() { + renderSwitches(): React.ReactElement { const { isExportBitwave, isExportCsv, isExportQbo } = this.state return ( <> @@ -268,7 +268,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< ) } - handleStartDate = async () => { + handleStartDate = async (): Promise => { const { startDate } = this.state const date = await Airship.show(bridge => ( @@ -276,7 +276,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< this.setState({ startDate: date }) } - handleEndDate = async () => { + handleEndDate = async (): Promise => { const { endDate } = this.state const date = await Airship.show(bridge => ( @@ -284,15 +284,15 @@ class TransactionsExportSceneComponent extends React.PureComponent< this.setState({ endDate: date }) } - handleQboToggle = () => { + handleQboToggle = (): void => { this.setState(state => ({ isExportQbo: !state.isExportQbo })) } - handleCsvToggle = () => { + handleCsvToggle = (): void => { this.setState(state => ({ isExportCsv: !state.isExportCsv })) } - handleBitwaveToggle = () => { + handleBitwaveToggle = (): void => { this.setState(state => ({ isExportBitwave: !state.isExportBitwave })) } @@ -494,7 +494,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< urls, failOnCancel: false, subject: title - }).catch(error => { + }).catch((error: unknown) => { console.log('Share error', error) }) } catch (error: any) { @@ -521,7 +521,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< title, urls, subject: title - }).catch(error => { + }).catch((error: unknown) => { showError(error) }) } From c2cce5bc5540e1f284e6531ef73a17034048f5ca Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 3 Aug 2026 13:29:03 -0700 Subject: [PATCH 2/2] Fix Bitwave CSV export issues Bitwave changed its import requirements, so the export no longer matched what the importer accepts: - Timestamps use ISO 8601 UTC (YYYY-MM-DDTHH:MM:SSZ) instead of MM/DD/YY HH:MM. - The fee and fee ticker columns stay blank, since Bitwave now calculates fees itself and populated columns duplicate them after import. - The custom metadata 2 column mirrors the description column. - The account id keeps the case and spacing it was entered with. The input modal inherited the platform default of capitalizing the first character, which silently corrupted ids beginning with a lowercase letter and forced a manual fix on every import. Dropping the fee columns leaves the wallet and parent multiplier arguments unused, so they are removed along with the scene prop that supplied them. --- CHANGELOG.md | 2 + .../actions/TransactionExportActions.test.ts | 94 +++++++++++++++++++ src/actions/TransactionExportActions.tsx | 30 +++--- .../scenes/TransactionsExportScene.tsx | 18 ++-- 4 files changed, 116 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ab0197c14..dc07dd23f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - changed: Use a custom chart icon for the side menu Markets row, so it matches the rest of the menu. - changed: Use the UI4 warning card for the Reveal Raw Keys and Reveal Master Private Key password confirmation warnings. - changed: Tron resource staking now describes its claim action as reclaiming your own TRX, instead of claiming a reward. +- fixed: Bitwave CSV exports now use ISO 8601 UTC timestamps, leave the fee columns blank so Bitwave does not double-count fees, and copy the description into the second custom metadata column. +- fixed: Bitwave account ids are no longer capitalized by the keyboard or padded with whitespace when entered, so exports import without hand-editing the account id. - fixed: NYM max swaps from EVM wallets now report the correct limit error instead of an unsupported-route error (edge-exchange-plugins 2.52.1). - fixed: Exchange rate queries no longer request each chain's own asset twice, which had been inflating every rate query with duplicate pairs. - fixed: XRP minimum balance warning copy to clarify the reserve is met once the address balance reaches 1 XRP, not on top of it. diff --git a/src/__tests__/actions/TransactionExportActions.test.ts b/src/__tests__/actions/TransactionExportActions.test.ts index 1b3f55927b0..a07bf137f02 100644 --- a/src/__tests__/actions/TransactionExportActions.test.ts +++ b/src/__tests__/actions/TransactionExportActions.test.ts @@ -3,6 +3,7 @@ import type { EdgeTransaction } from 'edge-core-js' import fs from 'fs' import { + exportTransactionsToBitwave, exportTransactionsToCSVInner, exportTransactionsToQBO } from '../../actions/TransactionExportActions' @@ -188,3 +189,96 @@ test('export QBO matches reference data', function () { ) expect(out).toEqual(qboResult) }) + +/** + * Splits one CSV row into its fields, unwrapping quoted values and their + * doubled-quote escapes. + */ +function parseCsvRow(row: string): string[] { + const fields: string[] = [] + let field = '' + let quoted = false + for (let i = 0; i < row.length; i++) { + const char = row[i] + if (quoted) { + if (char !== '"') field += char + else if (row[i + 1] === '"') { + field += '"' + i++ + } else quoted = false + } else if (char === '"') quoted = true + else if (char === ',') { + fields.push(field) + field = '' + } else field += char + } + fields.push(field) + return fields +} + +// Bitwave column letters, as the importer numbers them: +const COLUMN_G_FEE = 6 +const COLUMN_H_FEE_TICKER = 7 +const COLUMN_I_TIME = 8 +const COLUMN_M_ACCOUNT_ID = 12 +const COLUMN_R_DESCRIPTION = 17 +const COLUMN_W_CUSTOM_METADATA2 = 22 + +const BITWAVE_ACCOUNT_ID = 'pgM8cDt7bySnWTzs2MyI' + +async function exportBitwaveRows(): Promise { + const out = await exportTransactionsToBitwave( + BITWAVE_ACCOUNT_ID, + [...edgeTxs], + 'BTC', + '100' + ) + const [header, ...rows] = out.split('\n').filter(row => row !== '') + + // Guard the column letters this suite asserts on, so a reordered row object + // fails here rather than silently invalidating every assertion below: + const headerFields = parseCsvRow(header) + expect(headerFields[COLUMN_G_FEE]).toEqual('fee') + expect(headerFields[COLUMN_H_FEE_TICKER]).toEqual('feeTicker') + expect(headerFields[COLUMN_I_TIME]).toEqual('time') + expect(headerFields[COLUMN_M_ACCOUNT_ID]).toEqual('accountId') + expect(headerFields[COLUMN_R_DESCRIPTION]).toEqual('description') + expect(headerFields[COLUMN_W_CUSTOM_METADATA2]).toEqual( + 'metadata:myCustomMetadata2' + ) + + expect(rows.length).toBeGreaterThan(0) + return rows.map(parseCsvRow) +} + +test('export Bitwave leaves the fee columns blank', async function () { + for (const fields of await exportBitwaveRows()) { + expect(fields[COLUMN_G_FEE]).toEqual('') + expect(fields[COLUMN_H_FEE_TICKER]).toEqual('') + } +}) + +test('export Bitwave duplicates the description into custom metadata 2', async function () { + for (const fields of await exportBitwaveRows()) { + expect(fields[COLUMN_W_CUSTOM_METADATA2]).toEqual( + fields[COLUMN_R_DESCRIPTION] + ) + } +}) + +test('export Bitwave writes ISO 8601 UTC timestamps', async function () { + const rows = await exportBitwaveRows() + for (const fields of rows) { + expect(fields[COLUMN_I_TIME]).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/ + ) + } + // The first transaction's date is 1524476980 (2018-04-23T09:49:40Z): + expect(rows[0][COLUMN_I_TIME]).toEqual('2018-04-23T09:49:40Z') +}) + +test('export Bitwave preserves the account id exactly', async function () { + for (const fields of await exportBitwaveRows()) { + expect(fields[COLUMN_M_ACCOUNT_ID]).toEqual(BITWAVE_ACCOUNT_ID) + } +}) diff --git a/src/actions/TransactionExportActions.tsx b/src/actions/TransactionExportActions.tsx index 82fd87a9acd..5ebace768a9 100644 --- a/src/actions/TransactionExportActions.tsx +++ b/src/actions/TransactionExportActions.tsx @@ -179,15 +179,17 @@ function makeCsvDateTime(date: number): { date: string; time: string } { } } +/** ISO 8601 UTC, the timestamp format Bitwave requires for imports. */ function makeBitwaveDateTime(date: number): string { const d = new Date(date * 1000) - const yy = d.getUTCFullYear().toString().slice(-2) + const yyyy = d.getUTCFullYear().toString() const mm = padZero((d.getUTCMonth() + 1).toString()) const dd = padZero(d.getUTCDate().toString()) const hh = padZero(d.getUTCHours().toString()) const min = padZero(d.getUTCMinutes().toString()) + const ss = padZero(d.getUTCSeconds().toString()) - return `${mm}/${dd}/${yy} ${hh}:${min}` + return `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}Z` } // @@ -447,15 +449,12 @@ export function exportTransactionsToCSVInner( } export async function exportTransactionsToBitwave( - wallet: EdgeCurrencyWallet, accountId: string, edgeTransactions: EdgeTransaction[], currencyCode: string, - multiplier: string, - parentMultiplier: string + multiplier: string ): Promise { const items: any[] = [] - const parentCode = wallet.currencyInfo.currencyCode for (const tx of edgeTransactions) { edgeTxToCsv(tx) @@ -469,25 +468,15 @@ export async function exportTransactionsToBitwave( nativeAmount, networkFee, ourReceiveAddresses, - parentNetworkFee, spendTargets, txid } = edgeTx const amount: string = abs(div(nativeAmount, multiplier, DECIMAL_PRECISION)) const time = makeBitwaveDateTime(date) - let fee: string = '' - let feeTicker: string = '' const { name = '', category = '', notes = '' } = metadata ?? {} let toAddress = '' if (isSend) { - if (parentNetworkFee != null) { - feeTicker = parentCode - fee = div(parentNetworkFee, parentMultiplier, DECIMAL_PRECISION) - } else { - feeTicker = currencyCode - fee = div(networkFee, multiplier, DECIMAL_PRECISION) - } if (spendTargets != null && spendTargets.length > 0) { // We can only choose 1 `toAddress` so pick the first spendTarget toAddress = spendTargets[0].publicAddress @@ -509,8 +498,10 @@ export async function exportTransactionsToBitwave( amountTicker: currencyCode, cost: '', costTicker: '', - fee, - feeTicker, + // Bitwave calculates transaction fees on its own side. Exporting them + // here duplicates the fees after import, so both columns stay blank: + fee: '', + feeTicker: '', time, blockchainId: txid, memo: notes, @@ -525,7 +516,8 @@ export async function exportTransactionsToBitwave( toAddress, groupId: '', 'metadata:myCustomMetadata1': category, - 'metadata:myCustomMetadata2': notes + // Bitwave expects this to mirror the description column: + 'metadata:myCustomMetadata2': name }) } diff --git a/src/components/scenes/TransactionsExportScene.tsx b/src/components/scenes/TransactionsExportScene.tsx index d990b644ebf..3ea018673e3 100644 --- a/src/components/scenes/TransactionsExportScene.tsx +++ b/src/components/scenes/TransactionsExportScene.tsx @@ -58,7 +58,6 @@ interface StateProps { defaultIsoFiat: string exchangeMultiplier: string multiplier: string - parentMultiplier: string } interface DispatchProps { @@ -303,7 +302,6 @@ class TransactionsExportSceneComponent extends React.PureComponent< defaultIsoFiat, exchangeMultiplier, multiplier, - parentMultiplier, route } = this.props const { sourceWallet, tokenId } = route.params @@ -327,10 +325,15 @@ class TransactionsExportSceneComponent extends React.PureComponent< const fileAccountId = exportTxInfo?.bitwaveAccountId ?? '' if (isExportBitwave) { - accountId = + // Bitwave account ids are case-sensitive and may start with a lowercase + // letter, so the platform default of capitalizing the first character + // would corrupt them. Trim as well, since a pasted id often carries + // surrounding whitespace: + const rawAccountId = (await Airship.show(bridge => ( ))) ?? '' + accountId = rawAccountId.trim() } if ( @@ -451,12 +455,10 @@ class TransactionsExportSceneComponent extends React.PureComponent< if (isExportBitwave) { const bitwaveFile = await exportTransactionsToBitwave( - sourceWallet, accountId, txs, currencyCode, - exchangeMultiplier, - parentMultiplier + exchangeMultiplier ) files.push({ contents: bitwaveFile, @@ -544,9 +546,7 @@ export const TransactionsExportScene = connect< state, params.sourceWallet.currencyConfig, params.tokenId - ).multiplier, - parentMultiplier: getExchangeDenom(params.sourceWallet.currencyConfig, null) - .multiplier + ).multiplier }), dispatch => ({ updateTxsFiatDispatch: async (wallet, tokenId, currencyCode, txs) => {