diff --git a/CHANGELOG.md b/CHANGELOG.md index b16e3f8ac..7008b1fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- added: Remove empty transaction metadata files when loading transaction data. +- changed: Use syncRepo from upgraded edge-sync-client for syncing repo algorithm. + ## 2.36.0 (2025-11-04) - added: Added `EdgeSubscribedAddress` type for `onSubscribeAddresses` and `startEngine`. diff --git a/package.json b/package.json index 6a1dbe7e9..ac3bf7664 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "cleaners": "^0.3.17", "currency-codes": "^1.5.1", "disklet": "^0.5.2", - "edge-sync-client": "^0.2.8", + "edge-sync-client": "../edge-sync-client", "elliptic": "^6.4.0", "hash.js": "^1.1.7", "hmac-drbg": "^1.0.1", diff --git a/src/core/actions.ts b/src/core/actions.ts index 26a578db9..ed889f7e5 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -319,6 +319,14 @@ export type RootAction = walletId: string } } + | { + // Called when empty transaction metadata files have been deleted. + type: 'CURRENCY_WALLET_FILE_DELETED' + payload: { + txidHashes: string[] + walletId: string + } + } | { type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE' payload: { diff --git a/src/core/context/internal-api.ts b/src/core/context/internal-api.ts index d50aec8f1..2201d6f8a 100644 --- a/src/core/context/internal-api.ts +++ b/src/core/context/internal-api.ts @@ -1,4 +1,5 @@ import { Disklet } from 'disklet' +import { SyncResult } from 'edge-sync-client' import { Bridgeable, bridgifyObject, close, emit, update } from 'yaob' import { Unsubscribe } from 'yavent' @@ -13,7 +14,7 @@ import { import { loginFetch } from '../login/login-fetch' import { hashUsername } from '../login/login-selectors' import { ApiInput } from '../root-pixie' -import { makeRepoPaths, syncRepo, SyncResult } from '../storage/repo' +import { makeRepoPaths, syncRepo } from '../storage/repo' /** * The requesting side of an Edge login lobby. @@ -98,13 +99,10 @@ export class EdgeInternalStuff extends Bridgeable<{}> { await sendLobbyReply(this._ai, lobbyId, lobbyRequest, replyData) } - async syncRepo(syncKey: Uint8Array): Promise { + syncRepo(syncKey: Uint8Array): Promise { const { io, syncClient } = this._ai.props const paths = makeRepoPaths(io, { dataKey: new Uint8Array(0), syncKey }) - return await syncRepo(syncClient, paths, { - lastSync: 0, - lastHash: undefined - }) + return syncRepo(syncClient, paths, { lastSync: 0, lastHash: undefined }) } async getRepoDisklet( diff --git a/src/core/currency/wallet/currency-wallet-files.ts b/src/core/currency/wallet/currency-wallet-files.ts index e09605775..b15a97bad 100644 --- a/src/core/currency/wallet/currency-wallet-files.ts +++ b/src/core/currency/wallet/currency-wallet-files.ts @@ -37,7 +37,7 @@ import { import { CurrencyWalletInput } from './currency-wallet-pixie' import { TxFileNames } from './currency-wallet-reducer' import { currencyCodesToTokenIds } from './enabled-tokens' -import { mergeMetadata } from './metadata' +import { isEmptyMetadata, mergeMetadata } from './metadata' const CURRENCY_FILE = 'Currency.json' const LEGACY_MAP_FILE = 'fixedLegacyFileNames.json' @@ -46,6 +46,34 @@ const SEEN_TX_CHECKPOINT_FILE = 'seenTxCheckpoint.json' const TOKENS_FILE = 'Tokens.json' const WALLET_NAME_FILE = 'WalletName.json' +/** + * Checks if a transaction file is "empty" (contains no user metadata). + * An empty file is one that matches the default template with no user-added data. + */ +export function isEmptyTxFile(file: TransactionFile): boolean { + // Check top-level user data fields: + if (file.savedAction != null) return false + if (file.swap != null) return false + if (file.payees != null && file.payees.length > 0) return false + if (file.deviceDescription != null) return false + if (file.secret != null) return false + if (file.feeRateRequested != null) return false + + // Check currencies map for non-empty metadata: + for (const asset of file.currencies.values()) { + if (!isEmptyMetadata(asset.metadata)) return false + if (asset.assetAction != null) return false + } + + // Check tokens map for non-empty metadata: + for (const asset of file.tokens.values()) { + if (!isEmptyMetadata(asset.metadata)) return false + if (asset.assetAction != null) return false + } + + return true +} + const legacyAddressFile = makeJsonFile(asLegacyAddressFile) const legacyMapFile = makeJsonFile(asLegacyMapFile) const legacyTokensFile = makeJsonFile(asLegacyTokensFile) @@ -297,7 +325,10 @@ export async function loadTxFiles( const fileNames = input.props.walletState.fileNames const walletFiat = input.props.walletState.fiat - const out: { [filename: string]: TransactionFile } = {} + const out: { [txidHash: string]: TransactionFile } = {} + const emptyFileInfos: Array<{ txidHash: string; path: string }> = [] + + // Load legacy transaction files: await Promise.all( txIdHashes.map(async txidHash => { if (fileNames[txidHash] == null) return @@ -307,6 +338,8 @@ export async function loadTxFiles( out[txidHash] = fixLegacyFile(clean, walletCurrency, walletFiat) }) ) + + // Load new transaction files: await Promise.all( txIdHashes.map(async txidHash => { if (fileNames[txidHash] == null) return @@ -317,6 +350,38 @@ export async function loadTxFiles( }) ) + // Detect empty files and queue them for deletion: + for (const txidHash of Object.keys(out)) { + const file = out[txidHash] + if (isEmptyTxFile(file)) { + const path = `transaction/${fileNames[txidHash].fileName}` + emptyFileInfos.push({ txidHash, path }) + // Remove from output so it's not loaded into state: + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete out[txidHash] + } + } + + // Delete empty files in a non-blocking way (fire-and-forget): + if (emptyFileInfos.length > 0) { + const txidHashes: string[] = [] + for (const info of emptyFileInfos) { + txidHashes.push(info.txidHash) + // Delete files without awaiting: + disklet.delete(info.path).catch(error => { + input.props.log.warn( + `Failed to delete empty tx file ${info.path}: ${String(error)}` + ) + }) + } + // Dispatch action to remove from fileNames state so that loadTxFiles + // won't attempt to load these empty files again: + dispatch({ + type: 'CURRENCY_WALLET_FILE_DELETED', + payload: { txidHashes, walletId } + }) + } + dispatch({ type: 'CURRENCY_WALLET_FILES_LOADED', payload: { files: out, walletId } diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 04131a6bb..1f3bc0fa0 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -286,6 +286,15 @@ const currencyWalletInner = buildReducer< ...files } } + case 'CURRENCY_WALLET_FILE_DELETED': { + const { txidHashes } = action.payload + const out = { ...state } + for (const txidHash of txidHashes) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete out[txidHash] + } + return out + } } return state }, @@ -309,6 +318,15 @@ const currencyWalletInner = buildReducer< } return state } + case 'CURRENCY_WALLET_FILE_DELETED': { + const { txidHashes } = action.payload + const out = { ...state } + for (const txidHash of txidHashes) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete out[txidHash] + } + return out + } } return state }, diff --git a/src/core/currency/wallet/metadata.ts b/src/core/currency/wallet/metadata.ts index 9e0aef912..c34bf0080 100644 --- a/src/core/currency/wallet/metadata.ts +++ b/src/core/currency/wallet/metadata.ts @@ -44,6 +44,23 @@ export function mergeMetadata( return out } +/** + * Checks if metadata is empty (contains no user-added data). + */ +export function isEmptyMetadata(metadata: EdgeMetadata): boolean { + if (metadata.bizId != null) return false + if (metadata.category != null && metadata.category !== '') return false + if (metadata.name != null && metadata.name !== '') return false + if (metadata.notes != null && metadata.notes !== '') return false + if ( + metadata.exchangeAmount != null && + Object.keys(metadata.exchangeAmount).length > 0 + ) { + return false + } + return true +} + const asDiskMetadata = asObject({ bizId: asOptional(asNumber), category: asOptional(asString), diff --git a/src/core/storage/encrypt-disklet.ts b/src/core/storage/encrypt-disklet.ts index e3266c290..fe3504354 100644 --- a/src/core/storage/encrypt-disklet.ts +++ b/src/core/storage/encrypt-disklet.ts @@ -6,14 +6,30 @@ import { EdgeIo } from '../../types/types' import { decrypt, decryptText, encrypt } from '../../util/crypto/crypto' import { utf8 } from '../../util/encoding' +/** + * Creates an encrypted disklet that wraps another disklet. + * Optionally accepts a deletedDisklet for sync-aware deletions. + * When deletedDisklet is provided, delete operations will mark files + * for deletion by writing an empty file to the deleted/ directory, + * which will be processed during the next sync. + */ export function encryptDisklet( io: EdgeIo, dataKey: Uint8Array, - disklet: Disklet + disklet: Disklet, + /** Provide when this disklet is synchronized with edge-sync-client's syncRepo */ + deletedDisklet?: Disklet ): Disklet { const out = { - delete(path: string): Promise { - return disklet.delete(path) + async delete(path: string): Promise { + // If we have a deletedDisklet, mark the file for deletion + // by writing an empty file to the deleted/ directory. + // The sync process will handle the actual deletion. + if (deletedDisklet != null) { + await deletedDisklet.setText(path, '') + } + // Also delete locally for immediate effect: + return await disklet.delete(path) }, async getData(path: string): Promise { diff --git a/src/core/storage/repo.ts b/src/core/storage/repo.ts index 2d0806f2f..bb6bdd33e 100644 --- a/src/core/storage/repo.ts +++ b/src/core/storage/repo.ts @@ -1,9 +1,8 @@ import { Disklet, mergeDisklets, navigateDisklet } from 'disklet' -import type { EdgeBox as SyncEdgeBox } from 'edge-sync-client' -import { SyncClient } from 'edge-sync-client' +import { SyncClient, SyncResult } from 'edge-sync-client' import { base16, base64 } from 'rfc4648' -import { asEdgeBox, wasEdgeBox } from '../../types/server-cleaners' +import { wasEdgeBox } from '../../types/server-cleaners' import { EdgeBox } from '../../types/server-types' import { EdgeIo } from '../../types/types' import { sha256 } from '../../util/crypto/hashes' @@ -12,17 +11,10 @@ import { EdgeStorageKeys } from '../login/storage-keys' import { encryptDisklet } from './encrypt-disklet' import { StorageWalletPaths, StorageWalletStatus } from './storage-reducer' -const CHANGESET_MAX_ENTRIES = 100 - interface RepoChanges { [path: string]: EdgeBox | null } -export interface SyncResult { - changes: RepoChanges - status: StorageWalletStatus -} - export function makeLocalDisklet(io: EdgeIo, walletId: string): Disklet { return navigateDisklet( io.disklet, @@ -45,10 +37,12 @@ export function makeRepoPaths( ) const changesDisklet = navigateDisklet(baseDisklet, 'changes') const dataDisklet = navigateDisklet(baseDisklet, 'data') + const deletedDisklet = navigateDisklet(baseDisklet, 'deleted') const disklet = encryptDisklet( io, dataKey, - mergeDisklets(changesDisklet, dataDisklet) + mergeDisklets(changesDisklet, dataDisklet), + deletedDisklet ) return { @@ -58,6 +52,7 @@ export function makeRepoPaths( baseDisklet, changesDisklet, dataDisklet, + deletedDisklet, disklet } } @@ -99,81 +94,10 @@ export async function syncRepo( paths: StorageWalletPaths, status: StorageWalletStatus ): Promise { - const { changesDisklet, dataDisklet, syncKey } = paths - - const ourChanges: Array<{ - path: string - box: EdgeBox - }> = await deepListWithLimit(changesDisklet).then(paths => { - return Promise.all( - paths.map(async path => ({ - path, - box: asEdgeBox(JSON.parse(await changesDisklet.getText(path))) - })) - ) - }) - - const syncKeyEncoded = base16.stringify(syncKey).toLowerCase() - - // Send a read request if no changes present locally, otherwise bundle the - // changes with the a update request. - const reply = await (() => { - // Read the repo if no changes present locally. - if (ourChanges.length === 0) { - return syncClient.readRepo(syncKeyEncoded, status.lastHash) - } - - // Write local changes to the repo. - const changes: { [name: string]: SyncEdgeBox } = {} - for (const change of ourChanges) { - changes[change.path] = wasEdgeBox(change.box) - } - return syncClient.updateRepo(syncKeyEncoded, status.lastHash, { changes }) - })() - - // Make the request: - const { hash } = reply - const changes: RepoChanges = {} - for (const path of Object.keys(reply.changes ?? {})) { - const json = reply.changes[path] - changes[path] = json == null ? null : asEdgeBox(json) - } - - // Save the incoming changes into our `data` folder: - await saveChanges(dataDisklet, changes) - - // Delete any changed keys (since the upload is done): - await Promise.all( - ourChanges.map(change => changesDisklet.delete(change.path)) + const syncKeyEncoded = base16.stringify(paths.syncKey).toLowerCase() + return await syncClient.syncRepo( + paths.baseDisklet, + syncKeyEncoded, + status.lastHash ) - - // Update the repo status: - status.lastSync = Date.now() / 1000 - if (hash != null) status.lastHash = hash - await paths.baseDisklet.setText('status.json', JSON.stringify(status)) - return { status, changes } -} - -/** - * Lists all files in a disklet, recursively up to a limit. - * Returns a list of full paths. - */ -async function deepListWithLimit( - disklet: Disklet, - path: string = '', - limit: number = CHANGESET_MAX_ENTRIES -): Promise { - const list = await disklet.list(path) - const paths = Object.keys(list).filter(path => list[path] === 'file') - const folders = Object.keys(list).filter(path => list[path] === 'folder') - - // Loop over folders to get subpaths - for (const folder of folders) { - if (paths.length >= limit) break - const remaining = limit - paths.length - const subpaths = await deepListWithLimit(disklet, folder, remaining) - paths.push(...subpaths.slice(0, remaining)) - } - - return paths } diff --git a/src/core/storage/storage-actions.ts b/src/core/storage/storage-actions.ts index f63266990..2de9c82bb 100644 --- a/src/core/storage/storage-actions.ts +++ b/src/core/storage/storage-actions.ts @@ -54,20 +54,26 @@ export async function addStorageWallet( } else await syncPromise } -export function syncStorageWallet( +export async function syncStorageWallet( ai: ApiInput, walletId: string ): Promise { const { dispatch, syncClient, state } = ai.props const { paths, status } = state.storageWallets[walletId] - return syncRepo(syncClient, paths, { ...status }).then( - ({ changes, status }) => { - dispatch({ - type: 'STORAGE_WALLET_SYNCED', - payload: { id: walletId, changes: Object.keys(changes), status } - }) - return Object.keys(changes) + const result = await syncRepo(syncClient, paths, status) + + // Save the updated status to disk: + await paths.baseDisklet.setText('status.json', JSON.stringify(result.status)) + + dispatch({ + type: 'STORAGE_WALLET_SYNCED', + payload: { + id: walletId, + changes: Object.keys(result.changes), + status: result.status } - ) + }) + + return Object.keys(result.changes) } diff --git a/src/core/storage/storage-reducer.ts b/src/core/storage/storage-reducer.ts index dfc9f62f8..1819b9b09 100644 --- a/src/core/storage/storage-reducer.ts +++ b/src/core/storage/storage-reducer.ts @@ -10,6 +10,7 @@ export interface StorageWalletPaths { baseDisklet: Disklet changesDisklet: Disklet dataDisklet: Disklet + deletedDisklet: Disklet disklet: Disklet } diff --git a/test/core/currency/wallet/currency-wallet-files.test.ts b/test/core/currency/wallet/currency-wallet-files.test.ts new file mode 100644 index 000000000..45ea50491 --- /dev/null +++ b/test/core/currency/wallet/currency-wallet-files.test.ts @@ -0,0 +1,337 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { TransactionFile } from '../../../../src/core/currency/wallet/currency-wallet-cleaners' +import { isEmptyTxFile } from '../../../../src/core/currency/wallet/currency-wallet-files' + +describe('currency wallet files', function () { + describe('isEmptyTxFile', function () { + it('returns true for minimal empty file', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map() + } + expect(isEmptyTxFile(file)).equals(true) + }) + + it('returns true for file with empty metadata in tokens', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([[null, { metadata: {} }]]) + } + expect(isEmptyTxFile(file)).equals(true) + }) + + it('returns true for file with empty metadata in currencies', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map([['BTC', { metadata: {} }]]), + tokens: new Map() + } + expect(isEmptyTxFile(file)).equals(true) + }) + + it('returns true for file with empty string metadata fields', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: { + name: '', + notes: '', + category: '', + exchangeAmount: {} + } + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(true) + }) + + it('returns false for file with savedAction', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + savedAction: { + actionType: 'swap', + swapInfo: { + pluginId: 'test', + displayName: 'Test', + supportEmail: 'test@test.com' + }, + isEstimate: false, + payoutAddress: '0x123', + payoutWalletId: 'wallet1', + fromAsset: { pluginId: 'btc', tokenId: null, nativeAmount: '100' }, + toAsset: { pluginId: 'eth', tokenId: null, nativeAmount: '200' } + } + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with swap data', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + swap: { + orderId: '123', + isEstimate: true, + plugin: { + pluginId: 'test', + displayName: 'Test' + }, + payoutAddress: '0x123', + payoutCurrencyCode: 'ETH', + payoutNativeAmount: '100', + payoutWalletId: 'wallet1' + } + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with payees', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + payees: [ + { + address: '0x123', + amount: '100', + currency: 'ETH' + } + ] + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns true for file with empty payees array', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + payees: [] + } + expect(isEmptyTxFile(file)).equals(true) + }) + + it('returns false for file with deviceDescription', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + deviceDescription: 'iPhone 12' + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with secret', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + secret: 'supersecret' + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with feeRateRequested', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + feeRateRequested: 'high' + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with user metadata name', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: { name: 'Coffee Shop' } + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with user metadata notes', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: { notes: 'Bought coffee' } + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with user metadata category', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: { category: 'expense:Food' } + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with user metadata bizId', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: { bizId: 123 } + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with user metadata exchangeAmount', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: { exchangeAmount: { 'iso:USD': 5.5 } } + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with assetAction in tokens', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: {}, + assetAction: { assetActionType: 'swap' } + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns false for file with assetAction in currencies', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map([ + [ + 'BTC', + { + metadata: {}, + assetAction: { assetActionType: 'swap' } + } + ] + ]), + tokens: new Map() + } + expect(isEmptyTxFile(file)).equals(false) + }) + + it('returns true for file with feeRateUsed (not user data)', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map(), + feeRateUsed: { satPerByte: 10 } + } + expect(isEmptyTxFile(file)).equals(true) + }) + + it('returns true for file with nativeAmount in tokens (not user data)', function () { + const file: TransactionFile = { + txid: 'abc123', + internal: true, + creationDate: 1234567890, + currencies: new Map(), + tokens: new Map([ + [ + null, + { + metadata: {}, + nativeAmount: '1000000' + } + ] + ]) + } + expect(isEmptyTxFile(file)).equals(true) + }) + }) +}) diff --git a/test/core/currency/wallet/currency-wallet.test.ts b/test/core/currency/wallet/currency-wallet.test.ts index e0e514195..a34d860c9 100644 --- a/test/core/currency/wallet/currency-wallet.test.ts +++ b/test/core/currency/wallet/currency-wallet.test.ts @@ -732,6 +732,106 @@ describe('currency wallets', function () { }) }) + it('removes empty transaction files', async function () { + const { wallet, config } = await makeFakeCurrencyWallet() + + // Create two transactions - one will have metadata cleared, one will keep it + await config.changeUserSettings({ + txs: { + a: { nativeAmount: '25' }, + b: { nativeAmount: '50' } + } + }) + + // Save metadata for both transactions + await wallet.saveTxMetadata({ + txid: 'a', + tokenId: null, + metadata: { name: 'Will Be Cleared' } + }) + await wallet.saveTxMetadata({ + txid: 'b', + tokenId: null, + metadata: { name: 'Should Stay' } + }) + + // Verify both transactions exist with metadata + let txs = await wallet.getTransactions({ tokenId: null }) + expect(txs.length).equals(2) + const txA = txs.find(tx => tx.txid === 'a') + const txB = txs.find(tx => tx.txid === 'b') + expect(txA?.metadata?.name).equals('Will Be Cleared') + expect(txB?.metadata?.name).equals('Should Stay') + + // Find the transaction files on disk and map them to their txids + const txFiles = await wallet.disklet.list('transaction') + const fileNames = Object.keys(txFiles) + expect(fileNames.length).equals(2) + + // Identify which file belongs to which transaction + let fileNameA = '' + let fileNameB = '' + for (const fileName of fileNames) { + const fileContent = await wallet.disklet.getText(fileName) + const parsed = JSON.parse(fileContent) + if (parsed.txid === 'a') fileNameA = fileName + if (parsed.txid === 'b') fileNameB = fileName + } + expect(fileNameA).to.not.equal('') + expect(fileNameB).to.not.equal('') + + // Clear all metadata on transaction 'a' to make its file "empty" + await wallet.saveTxMetadata({ + txid: 'a', + tokenId: null, + metadata: { + bizId: null, + name: null, + notes: null, + category: null + } + }) + + // Get transactions again + txs = await wallet.getTransactions({ tokenId: null }) + + // Both transactions should still exist + expect(txs.length).equals(2) + + // Transaction 'a' should have empty/default metadata + const txAAfter = txs.find(tx => tx.txid === 'a') + expect(txAAfter?.metadata).deep.equals({ + bizId: undefined, + category: undefined, + exchangeAmount: {}, + name: undefined, + notes: undefined + }) + + // Transaction 'b' should still have its metadata + const txBAfter = txs.find(tx => tx.txid === 'b') + expect(txBAfter?.metadata?.name).equals('Should Stay') + + // Both files should still exist on disk (empty file deletion only happens + // when files are loaded from disk, e.g., after syncing from another device) + const txFilesAfter = await wallet.disklet.list('transaction') + expect(Object.keys(txFilesAfter).length).equals(2) + + // Verify file 'a' has empty metadata + const fileAContent = await wallet.disklet.getText(fileNameA) + const parsedA = JSON.parse(fileAContent) + expect(parsedA.txid).equals('a') + expect(parsedA.tokens).deep.equals({ + '': { metadata: { exchangeAmount: {} } } + }) + + // Verify file 'b' still has its non-empty metadata + const fileBContent = await wallet.disklet.getText(fileNameB) + const parsedB = JSON.parse(fileBContent) + expect(parsedB.txid).equals('b') + expect(parsedB.tokens[''].metadata.name).equals('Should Stay') + }) + it('can be paused and un-paused', async function () { const { wallet, context } = await makeFakeCurrencyWallet(true) const isEngineRunning = async (): Promise => { diff --git a/test/core/currency/wallet/metadata.test.ts b/test/core/currency/wallet/metadata.test.ts new file mode 100644 index 000000000..1d7c6a3cd --- /dev/null +++ b/test/core/currency/wallet/metadata.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { isEmptyMetadata } from '../../../../src/core/currency/wallet/metadata' + +describe('metadata helpers', function () { + describe('isEmptyMetadata', function () { + it('returns true for empty metadata', function () { + expect(isEmptyMetadata({})).equals(true) + expect(isEmptyMetadata({ exchangeAmount: {} })).equals(true) + expect(isEmptyMetadata({ name: '' })).equals(true) + expect(isEmptyMetadata({ notes: '' })).equals(true) + expect(isEmptyMetadata({ category: '' })).equals(true) + expect( + isEmptyMetadata({ + name: '', + notes: '', + category: '', + exchangeAmount: {} + }) + ).equals(true) + }) + + it('returns false for metadata with name', function () { + expect(isEmptyMetadata({ name: 'Test' })).equals(false) + }) + + it('returns false for metadata with notes', function () { + expect(isEmptyMetadata({ notes: 'Some notes' })).equals(false) + }) + + it('returns false for metadata with category', function () { + expect(isEmptyMetadata({ category: 'expense:Food' })).equals(false) + }) + + it('returns false for metadata with bizId', function () { + expect(isEmptyMetadata({ bizId: 123 })).equals(false) + }) + + it('returns false for metadata with exchangeAmount', function () { + expect(isEmptyMetadata({ exchangeAmount: { 'iso:USD': 10.5 } })).equals( + false + ) + }) + + it('returns false for metadata with multiple fields', function () { + expect( + isEmptyMetadata({ + name: 'Test', + notes: 'Some notes', + category: 'expense:Food', + bizId: 123, + exchangeAmount: { 'iso:USD': 10.5 } + }) + ).equals(false) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index df8680ad2..a37913f26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2533,10 +2533,8 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -edge-sync-client@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/edge-sync-client/-/edge-sync-client-0.2.8.tgz#150c9d97676b5ddd158efd38565de1be29e525bc" - integrity sha512-5iaVYvws4fEk9gSkijo98nh46wyT2D0YSAYzwAyM4v/oHu+tVa18WrdGC+aP4HZVcrsg/LzrLu/NX9JyTsW9Cw== +edge-sync-client@../edge-sync-client: + version "0.2.9-1" dependencies: base-x "^1.0.4" cleaners "^0.3.9"