Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## Unreleased

- added: Added change-server subscription timeout fallback.
- added: Remove empty transaction metadata files when loading transaction data.
- changed: Use syncRepo from upgraded edge-sync-client for syncing repo algorithm.

## 2.39.0 (2026-01-16)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/core/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
10 changes: 4 additions & 6 deletions src/core/context/internal-api.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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.
Expand Down Expand Up @@ -98,13 +99,10 @@ export class EdgeInternalStuff extends Bridgeable<{}> {
await sendLobbyReply(this._ai, lobbyId, lobbyRequest, replyData)
}

async syncRepo(syncKey: Uint8Array): Promise<SyncResult> {
syncRepo(syncKey: Uint8Array): Promise<SyncResult> {
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(
Expand Down
73 changes: 71 additions & 2 deletions src/core/currency/wallet/currency-wallet-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -46,6 +46,38 @@ 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 currencyCode of file.currencies.keys()) {
const asset = file.currencies.get(currencyCode)
if (asset == null) continue
if (!isEmptyMetadata(asset.metadata)) return false
if (asset.assetAction != null) return false
}

// Check tokens map for non-empty metadata:
for (const tokenId of file.tokens.keys()) {
const asset = file.tokens.get(tokenId)
if (asset == null) continue
if (!isEmptyMetadata(asset.metadata)) return false
if (asset.assetAction != null) return false
}

return true
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing feeRateUsed check in isEmptyTxFile function

Medium Severity

The isEmptyTxFile function checks many fields (savedAction, swap, payees, deviceDescription, secret, feeRateRequested) but does not check feeRateUsed. If a transaction file contains only feeRateUsed data (and no user metadata), it will be incorrectly identified as "empty" and deleted. The feeRateUsed value is used as a fallback in combineTxWithFile when the engine doesn't provide it, so deleting such files causes data loss for synced transactions.

Fix in Cursor Fix in Web


const legacyAddressFile = makeJsonFile(asLegacyAddressFile)
const legacyMapFile = makeJsonFile(asLegacyMapFile)
const legacyTokensFile = makeJsonFile(asLegacyTokensFile)
Expand Down Expand Up @@ -297,26 +329,63 @@ 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
const path = `Transactions/${fileNames[txidHash].fileName}`
const clean = await legacyTransactionFile.load(disklet, path)
if (clean == null) return
out[txidHash] = fixLegacyFile(clean, walletCurrency, walletFiat)
if (isEmptyTxFile(out[txidHash])) {
emptyFileInfos.push({ txidHash, path })
}
})
)

// Load new transaction files:
await Promise.all(
txIdHashes.map(async txidHash => {
if (fileNames[txidHash] == null) return
const path = `transaction/${fileNames[txidHash].fileName}`
const clean = await transactionFile.load(disklet, path)
if (clean == null) return
out[txidHash] = clean
if (isEmptyTxFile(out[txidHash])) {
emptyFileInfos.push({ txidHash, path })
}
})
)

// Delete empty files in a non-blocking way (fire-and-forget):
if (emptyFileInfos.length > 0) {
const txidHashesWithNoWalletFiles: string[] = []
for (const info of emptyFileInfos) {
// If the out file is still empty, add it to the list of files to delete
// and remove it from the out object.
if (isEmptyTxFile(out[info.txidHash])) {
txidHashesWithNoWalletFiles.push(info.txidHash)
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete out[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: txidHashesWithNoWalletFiles, walletId }
})
}

dispatch({
type: 'CURRENCY_WALLET_FILES_LOADED',
payload: { files: out, walletId }
Expand Down
18 changes: 18 additions & 0 deletions src/core/currency/wallet/currency-wallet-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,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
},
Expand All @@ -310,6 +319,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
},
Expand Down
17 changes: 17 additions & 0 deletions src/core/currency/wallet/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
22 changes: 19 additions & 3 deletions src/core/storage/encrypt-disklet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +20 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deletion logic needs to live in the edge-sync-client, as part of the wrapped disklet. The edge-sync-client needs abstract all the sync stuff (adds / changes / deletions / etc.) behind the simple Disklet API, managing the deletions folder internally.

): Disklet {
const out = {
delete(path: string): Promise<unknown> {
return disklet.delete(path)
async delete(path: string): Promise<unknown> {
// 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<Uint8Array> {
Expand Down
Loading
Loading