Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- added: Broadcast transactions through Electrum (`electrumws(s):`) servers with `blockchain.transaction.broadcast`, alongside the Blockbook servers that already receive them.
- added: Track the block height through Electrum servers with `blockchain.headers.subscribe`, and confirm unconfirmed transactions on each new block from the history of a script they pay.
- added: Fetch transactions through Electrum servers with `blockchain.transaction.get`, so legacy (bip44 and bip32) wallets can sync their UTXOs.
- added: eCash (ECX) mainnet and betanet plugins, which set the network's magic nLockTime on every transaction as replay protection against Bitcoin.
- added: Bitcoin wallets can be split into eCash (ECX) wallets.
- fixed: Electrum connections closing after every `server.ping`, because Fulcrum rejects the object the params were sent as.
- fixed: Electrum request failures stalling the wallet sync. A failed request now reaches the task that made it, which retries, and a task Electrum cannot answer fails instead of receiving the previous task's response.
- fixed: Missing transaction history in wallets that also connect to an Electrum server, which only reports how many transactions an address has. Address histories now come from a connected Blockbook server whenever there is one.
- fixed: Requests a sync task made after a failed or timed-out request never being sent, which left the rest of that task's work, such as other transactions' confirmation checks, undone until the next block.

## 3.16.0 (2026-10-09)

- changed: Share each server's health probe between every wallet of the same currency. Probes of one server less than ten seconds apart now share a single REST request, so N wallets connected to the same Edge Blockbook cost one probe per keepalive interval rather than N.
Expand Down
11 changes: 11 additions & 0 deletions src/common/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ export interface CoinInfo {
*/
sighash?: number

/**
* Optional nLockTime value set on every transaction. This is used by forks
* whose nodes treat a magic nLockTime as final while the parent chain reads
* it as an unreachable block height, making the fork's transactions
* impossible to replay on the parent chain.
*
* Transactions for these currencies always have a non-final input sequence,
* since nodes ignore nLockTime when every input sequence is final.
*/
lockTime?: number

/**
* A function to be passed to AltcoinJS `signInput` method. This is used to
* get the input hash for the signature algorithm before signing the input.
Expand Down
20 changes: 18 additions & 2 deletions src/common/utxobased/engine/ServerStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
BlockbookTransaction,
SubscribeAddressResponse
} from '../network/blockbookApi'
import { makeBlockbookElectrum } from '../network/BlockbookElectrum'
import {
isElectrumUri,
makeBlockbookElectrum
} from '../network/BlockbookElectrum'
import Deferred from '../network/Deferred'
import { WsTask, WsTaskGenerator } from '../network/Socket'
import { SocketEmitter, SocketEvent } from '../network/SocketEmitter'
Expand Down Expand Up @@ -73,6 +76,7 @@ export interface ServerStates {
stop: () => void
serverCanGetTx: (uri: string, txid: string) => boolean
serverCanGetAddress: (uri: string, address: string) => boolean
serverCanGetAddressHistory: (uri: string, address: string) => boolean
serverIsAwareOfAddress: (uri: string, address: string) => boolean
getServerState: (uri: string) => ServerState | undefined
refillServers: () => void
Expand Down Expand Up @@ -510,7 +514,7 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {
}

// Create a new blockbook instance based on the URI scheme
if (['electrumwss', 'electrumws'].includes(parsed.scheme)) {
if (isElectrumUri(uri)) {
// Electrum wrapper
blockbook = makeBlockbookElectrum({
asAddress: pluginInfo.engineInfo.asBlockbookAddress,
Expand Down Expand Up @@ -862,6 +866,18 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {
return true
},

serverCanGetAddressHistory(uri: string, address: string): boolean {
// Electrum only reports how many transactions an address has, so the
// history goes to a connected Blockbook server whenever there is one.
// Without one, the counts still let the wallet finish syncing:
const hasBlockbook = Object.entries(serverStatesCache).some(
([otherUri, state]) =>
!isElectrumUri(otherUri) && state.blockbook.isConnected
)
if (isElectrumUri(uri) && hasBlockbook) return false
return instance.serverCanGetAddress(uri, address)
},

serverIsAwareOfAddress(uri: string, address: string): boolean {
const serverState = serverStatesCache[uri]
if (serverState == null) return false
Expand Down
71 changes: 53 additions & 18 deletions src/common/utxobased/engine/UtxoEngineProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
SubscribeAddressResponse,
TransactionResponse
} from '../network/blockbookApi'
import { isElectrumUri } from '../network/BlockbookElectrum'
import { WsTask, WsTaskAsyncGenerator } from '../network/Socket'
import AwaitLock from './await-lock'
import { CACHE_THROTTLE } from './constants'
Expand Down Expand Up @@ -126,6 +127,11 @@ export function makeUtxoEngineProcessor(
pendingTimeouts.clear()
}

// Addresses whose history an Electrum server answered. Electrum only
// reports how many transactions an address has, so a Blockbook server
// queries these again once one is picking tasks:
const electrumHistoryAddresses = new Set<string>()

const clearTaskCache = (): void => {
for (const key of Object.keys(taskCache.addressForTransactionsCache)) {
removeItem(taskCache.addressForTransactionsCache, key)
Expand All @@ -148,6 +154,7 @@ export function makeUtxoEngineProcessor(
for (const key of Object.keys(taskCache.transactionUpdateCache)) {
removeItem(taskCache.transactionUpdateCache, key)
}
electrumHistoryAddresses.clear()
}

/**
Expand Down Expand Up @@ -224,6 +231,28 @@ export function makeUtxoEngineProcessor(
}
}

/**
* Queues already-processed addresses to be queried again. Each query starts
* from the address's stored lastQueriedBlockHeight, and the address only
* counts as processed once the new queries complete. Returns how many
* addresses were re-queued.
*/
const reopenAddresses = (addresses: Iterable<string>): number => {
let reopened = 0
for (const address of addresses) {
const cacheItem = taskCache.addressSubscribeCache[address]
if (cacheItem == null || !cacheItem.processing) continue
cacheItem.processing = false
taskCache.addressForUtxosCache[address] = {
processing: false,
path: cacheItem.path
}
progressByScriptPubkey.delete(walletTools.addressToScriptPubkey(address))
reopened++
}
return reopened
}

const lock = new AwaitLock()

const serverStates = makeServerStates({
Expand All @@ -243,6 +272,8 @@ export function makeUtxoEngineProcessor(
emitter,
taskCache,
pendingTimeouts,
electrumHistoryAddresses,
reopenAddresses,
updateProgressRatio,
updateSeenTxCheckpoint,
io,
Expand Down Expand Up @@ -296,21 +327,9 @@ export function makeUtxoEngineProcessor(
// query starts from the address's stored lastQueriedBlockHeight, which is
// never above the height of the server that answered it, so the range
// the dropped server missed is fetched again.
let requeued = 0
for (const [address, cacheItem] of Object.entries(
taskCache.addressSubscribeCache
)) {
if (!cacheItem.processing) continue
cacheItem.processing = false
taskCache.addressForUtxosCache[address] = {
processing: false,
path: cacheItem.path
}
// The re-query has to complete again before the address counts as
// processed:
progressByScriptPubkey.delete(walletTools.addressToScriptPubkey(address))
requeued++
}
const requeued = reopenAddresses(
Object.keys(taskCache.addressSubscribeCache)
)
log(`${uri} dropped, re-queued ${requeued} addresses`)
})

Expand Down Expand Up @@ -712,6 +731,8 @@ interface CommonParams {
emitter: EngineEmitter
taskCache: TaskCache
pendingTimeouts: Set<NodeJS.Timeout>
electrumHistoryAddresses: Set<string>
reopenAddresses: (addresses: Iterable<string>) => number
updateProgressRatio: (
scriptPubkey: string,
process: AddressProcess
Expand Down Expand Up @@ -926,6 +947,15 @@ export async function* pickNextTask(
const serverState = common.serverStates.getServerState(serverUri)
if (serverState == null) return false

// Re-query the addresses whose history came from Electrum, now that a
// Blockbook server can answer with their transactions:
const { electrumHistoryAddresses } = common
if (!isElectrumUri(serverUri) && electrumHistoryAddresses.size > 0) {
const reopened = common.reopenAddresses(electrumHistoryAddresses)
electrumHistoryAddresses.clear()
common.log(`${serverUri} re-queued ${reopened} addresses Electrum answered`)
}

// subscribe all servers to new blocks
if (serverState.blockSubscriptionStatus === 'unsubscribed') {
common.serverStates.watchBlocks(serverUri)
Expand Down Expand Up @@ -1096,7 +1126,7 @@ export async function* pickNextTask(
)) {
if (
!cacheItem.processing &&
common.serverStates.serverCanGetAddress(serverUri, address)
common.serverStates.serverCanGetAddressHistory(serverUri, address)
) {
cacheItem.processing = true

Expand Down Expand Up @@ -1289,9 +1319,10 @@ async function* processAddressForTransactions(
// answering server has indexed, so a lagging server cannot move
// lastQueriedBlockHeight past blocks it never saw. A height of 0 means this
// server has not reported one yet, so nothing it answers can be trusted to
// move the cursor; the next query covers the range again.
// move the cursor; the next query covers the range again. Electrum answers
// carry no transactions, so they never move it either:
const queriedHeight =
serverState.blockHeight > 0
serverState.blockHeight > 0 && !isElectrumUri(serverUri)
? Math.min(blockHeight, serverState.blockHeight)
: addressData.lastQueriedBlockHeight

Expand Down Expand Up @@ -1386,6 +1417,10 @@ async function* processAddressForTransactions(
// Save/update the fully-processed address
await common.dataLayer.saveAddress(addressData)

// Electrum saved no transactions, so a Blockbook server queries this
// address again once one is picking tasks:
if (isElectrumUri(serverUri)) common.electrumHistoryAddresses.add(address)

// Extend the lookahead before counting progress, so any addresses it adds
// are in the total before this address's flag can bring the ratio to 1:
await setLookAhead(common)
Expand Down
6 changes: 6 additions & 0 deletions src/common/utxobased/info/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { info as digibyte } from './digibyte'
import { info as dogecoin } from './dogecoin'
import { info as eboost } from './eboost'
import { info as ecash } from './ecash'
import { info as ecashbitcoin } from './ecashbitcoin'
import { info as ecashbitcoinbetanet } from './ecashbitcoinbetanet'
import { info as feathercoin } from './feathercoin'
import { info as groestlcoin } from './groestlcoin'
import { info as litecoin } from './litecoin'
Expand All @@ -37,6 +39,8 @@ export { info as digibyte } from './digibyte'
export { info as dogecoin } from './dogecoin'
export { info as eboost } from './eboost'
export { info as ecash } from './ecash'
export { info as ecashbitcoin } from './ecashbitcoin'
export { info as ecashbitcoinbetanet } from './ecashbitcoinbetanet'
export { info as feathercoin } from './feathercoin'
export { info as groestlcoin } from './groestlcoin'
export { info as litecoin } from './litecoin'
Expand All @@ -63,6 +67,8 @@ export const all = [
dogecoin,
eboost,
ecash,
ecashbitcoin,
ecashbitcoinbetanet,
feathercoin,
groestlcoin,
litecoin,
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/bitcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const engineInfo: EngineInfo = {
}
],
formats: ['bip49', 'bip84', 'bip44', 'bip32'],
forks: ['bitcoincash', 'bitcoingold'],
forks: ['bitcoincash', 'bitcoingold', 'ecashbitcoin', 'ecashbitcoinbetanet'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning: ecashbitcoinbetanet is listed as a fork of mainnet Bitcoin, and EdgeApp/edge-react-gui#6294 registers it in corePlugins.ts. Every production BTC wallet will offer a split into a test network, which also runs the high-fee sweep. Is that intended, or should betanet stay behind a dev/beta flag?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Recommend disabling betanet for prod

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. EdgeApp/edge-react-gui#6294 now registers ecashbitcoinbetanet only when CONFIG.BETA_FEATURES is on, so production Bitcoin wallets never offer the betanet split. The fork list here stays, so beta builds can still test it.

gapLimit: 25,
feeUpdateInterval: 60000,
mempoolSpaceFeeInfoServer: 'https://mempool.space/api/v1/fees/precise',
Expand Down
90 changes: 90 additions & 0 deletions src/common/utxobased/info/ecashbitcoin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { EdgeCurrencyInfo } from 'edge-core-js/types'

import { CoinInfo, EngineInfo, PluginInfo } from '../../plugin/types'
import { maximumFeeRateCalculator } from '../../plugin/util/maximumFeeRateCalculator'
import {
legacyMemoInfo,
utxoCustomFeeTemplate,
utxoMemoOptions
} from './commonInfo'

const currencyInfo: EdgeCurrencyInfo = {
assetDisplayName: 'eCash',
canReplaceByFee: true,
chainDisplayName: 'eCash',
currencyCode: 'ECX',
customFeeTemplate: utxoCustomFeeTemplate,
memoOptions: utxoMemoOptions,
pluginId: 'ecashbitcoin',
walletType: 'wallet:ecashbitcoin',

// Explorers:
blockExplorer: 'https://explorer.ecash.ninja/block/%s',
addressExplorer: 'https://explorer.ecash.ninja/address/%s',
transactionExplorer: 'https://explorer.ecash.ninja/tx/%s',

denominations: [
{ name: 'ECX', multiplier: '100000000' },
{ name: 'sats', multiplier: '1', symbol: 's' }
],

// Deprecated:
...legacyMemoInfo,
defaultSettings: {
customFeeSettings: ['satPerByte'],
blockbookServers: ['wss://blockbook.ecash.ninja/websocket'],
enableCustomServers: false
},
displayName: 'eCash',
metaTokens: []
}

const engineInfo: EngineInfo = {
formats: ['bip49', 'bip84', 'bip44', 'bip32'],
// Matches Bitcoin, so wallets split from Bitcoin find every pre-fork address:
gapLimit: 25,
feeUpdateInterval: 60000,
mempoolSpaceFeeInfoServer: 'https://explorer.ecash.ninja/api/v1/fees/precise',
defaultFeeInfo: {
lowFeeFudgeFactor: undefined,
standardFeeLowFudgeFactor: undefined,
standardFeeHighFudgeFactor: undefined,
highFeeFudgeFactor: undefined,

highFee: '10',
lowFee: '1',
standardFeeLow: '2',
standardFeeHigh: '5',
standardFeeLowAmount: '173200',
standardFeeHighAmount: '8670000',
maximumFeeRate: maximumFeeRateCalculator(currencyInfo, 100)
}
}

export const coinInfo: CoinInfo = {
name: 'ecashbitcoin',
segwit: true,
coinType: 0,
// LOCKTIME_THRESHOLD - 2, final only on eCash mainnet:
lockTime: 499999998,

prefixes: {
messagePrefix: ['\x18Bitcoin Signed Message:\n'],
wif: [0x80],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
wrappedSegwitXPriv: [0x049d7878],
wrappedSegwitXPub: [0x049d7cb2],
segwitXPriv: [0x04b2430c],
segwitXPub: [0x04b24746],
pubkeyHash: [0x00],
scriptHash: [0x05],
bech32: ['bc']
}
}

export const info: PluginInfo = {
currencyInfo,
engineInfo,
coinInfo
}
Loading
Loading