Skip to content

Add eCash (ECX) plugins and Electrum sync support - #466

Merged
peachbits merged 10 commits into
masterfrom
matthew/ecx-plugin
Oct 29, 2026
Merged

peachbits merged 10 commits into
masterfrom
matthew/ecx-plugin

Conversation

@peachbits

@peachbits peachbits commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Asana task

Adds eCash (ECX), Paul Sztorc's Bitcoin hard fork, as two plugins, plus the Electrum support needed to sync and sweep over Electrum servers.

  • ecashbitcoin (mainnet) and ecashbitcoinbetanet plugins, using Bitcoin's address and key formats. Every ECX transaction sets the network's magic nLockTime (499999998 on mainnet, 499999999 on betanet) and keeps at least one input below sequence 0xffffffff, so Bitcoin nodes reject ECX spends as non-final. Fee rates and explorer links come from eCash's mempool.space-style explorers (explorer.ecash.ninja and explorer.beta.ecash.ninja), as Bitcoin's come from mempool.space. Both plugins default to eCash's Blockbook servers (blockbook.ecash.ninja and blockbook.beta.ecash.ninja). Electrum servers from the info server or custom server settings act as backups.
  • CoinInfo.lockTime sets a magic nLockTime for any fork that uses one for opt-in replay protection.
  • Bitcoin wallets list both ECX wallet types as forks, so they can be split. The protection sweep after the split is in edge-core-js.
  • Electrum (electrumws(s)://) connections can now broadcast, fetch transactions with blockchain.transaction.get, and track the block height with blockchain.headers.subscribe. They also stay connected, because Fulcrum rejected server.ping when its params were sent as {}, and block announcements that arrive before the block subscription is registered are skipped instead of dropping the connection.
  • A failed Electrum request now reaches the task that made it, and a task with no Electrum translation fails. Before, sync could stall waiting for the failed request, or the task got the previous task's response.
  • After a failed or timed-out request, the socket now sends the next request the task makes, on Blockbook servers too. Before, a task that caught the failure and moved on, such as the loop over unconfirmed transactions, had that request dropped, leaving the rest of its work undone until the next block.
  • Electrum only reports how many transactions an address has, so address histories go to a connected Blockbook server whenever there is one. A wallet with only Electrum servers still finishes syncing on those counts. Electrum answers never move an address's history cursor, and the first Blockbook server to pick a task queries those addresses again, which also covers histories Electrum answered before a Blockbook server connected.

Fetched Electrum transactions carry the hex, height, inputs, and outputs. The height comes from the history of a script the transaction pays, trying each in turn since servers refuse busy scripts' histories, and when none answers the task fails so the engine retries it. Fees, block time, and input amounts stay at zero, since filling them needs one request per previous transaction. That is enough to sign legacy UTXOs and confirm saved transactions.

Testing: unit tests, plus two betanet wallets synced in the simulator over Electrum only (explorer.beta.ecash.ninja through a local WebSocket bridge), with a signed sweep built but not broadcast. New-block notifications are covered by unit tests only, because betanet produced no block during the test.


BlockbookElectrum now sends blockchain.transaction.broadcast instead of
throwing, so an electrumws(s) connection joins the broadcast like any
Blockbook socket. The server's txid is returned as Blockbook's result,
and a rejection surfaces the server's reason.
Fulcrum rejects server.ping when the socket's `{}` default arrives as named
params, which closed the connection right after every ping and kept
Electrum-only wallets reconnecting.

@j0ntz j0ntz left a comment

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.

Rejected in curation (3)
  • src/common/utxobased/network/BlockbookElectrum.ts:249: Electrum-fetched transactions report zero fees and block time. Rejected: the engine keeps the date, fee, and input amounts it already stored, as the comment above the return states.
  • src/common/utxobased/engine/ServerStates.ts:869: gating Electrum history on any connected Blockbook can stall sync. Rejected: a connected Blockbook serves that history through its normal path, and a stall needs that path to fail, which affects Blockbook-only wallets the same way.
  • src/common/utxobased/network/BlockbookElectrum.ts:226: fetching a script's full history to read one height is costly for busy addresses. Rejected: this runs only when no Blockbook server is connected, and Electrum has no cheaper height lookup without verbose transaction support.

],
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.

const history = (yield {
...addressTransactionsMessage(toElectrumScriptHash(output.script))
}) as AddressTransactionsResponse
blockHeight = history.find(item => item.tx_hash === txid)?.height ?? 0

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: a failed get_history here is swallowed and the tx comes back at blockHeight: 0. processCheckTransactionConfirmation returns early on blockHeight < 1 without its retry, and the tx was already dropped from transactionUpdateCache. A confirmed tx stays shown as unconfirmed until another block event re-adds it. A Blockbook server throws in this case and the engine retries. Rethrowing (or only defaulting to 0 when the lookup succeeded but the tx is absent) keeps that behavior.

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.

Fixed. The height lookup tries each paid script in turn, since servers refuse busy scripts' histories, and fails the task when none answers, so the confirmation check's 10s retry runs. Failing on the first refusal would also have failed legacy UTXO signing, which only needs the hex. Retrying exposed a socket gap where a task's next request after a failed one was never sent; "Send the requests a task makes after a failed one" fixes that for Blockbook servers too.

}
instance.fetchInfo = async () => {
throw new Error('fetchInfo not supported for Electrum connections')
const header = await instance.promisifyWsMessage(headersSubscribeMessage())

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: this blockchain.headers.subscribe subscribes the connection server-side on connect, but the socket registers subscriptions['blockchain.headers.subscribe'] only later, when pickNextTask calls watchBlocks. A new block in that window arrives as a method notification that asResponseMessage maps to that id. It matches neither a subscription nor a pending request. Socket.onMessage then throws Unknown message id and the connection drops. Ignoring header notifications with no registered subscription in asResponseMessage would close the gap.

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.

Fixed. Header announcements are skipped until watchBlocks files the subscription, so they no longer drop the connection. A test covers an announcement right after fetchInfo.

walletType: 'wallet:ecashbitcoin',

// Explorers:
addressExplorer: '',

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.

Suggestion: mainnet has empty addressExplorer/transactionExplorer while betanet sets both. ECX mainnet users get no explorer links. Fine if no explorer exists yet; a TODO would make that explicit.

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.

explorer.ecash.ninja serves mainnet with the same paths as betanet, so mainnet now has block, address, and transaction links there.

totalSent: '0', // unused
txs: electrumUtxos.length,
unconfirmedBalance: '0', // unused
unconfirmedTxs: electrumUtxos.reduce(

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.

Suggestion (existed before this PR): operator precedence makes this reducer compare sum + tx.height against 0 and return the ternary's result, dropping the running sum. The count tops out at one and includes confirmed txs. Only used reads it, and txs already covers that. Since this PR moves the code anyway, wrapping the ternary in parentheses after sum + would fix it.

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.

Fixed in its own commit with a test. The parentheses alone would still count confirmed transactions, since Electrum puts mempool ones at height 0 or -1, so the test is tx.height <= 0.

fetchInfo reads the chain tip with blockchain.headers.subscribe, and
watchBlocks subscribes to the same method so each new header reaches the
engine as a block height change. Electrum notifications carry the method in
place of an id, which is the id the socket files subscriptions under, and the
subscription's first answer counts as its acknowledgement.

fetchInfo's request already subscribes the connection, so a block can be
announced before watchBlocks files the subscription. Those announcements are
skipped, since the socket drops a connection over a message it cannot route.
A task resumes with each response, and after a failed or timed-out
request it resumes with the error. The socket sent the next request a
task made only after a response. A task that caught a failure and moved
on, such as pickNextTask's loop over unconfirmed transactions, never got
its next request sent, and the item that request was for had already
left its cache. Failures now continue the task the same way responses
do.
The Electrum translation now answers getTransaction with
blockchain.transaction.get, decoding the raw transaction locally, and reads the
transaction's height from the history of a script it pays. Servers refuse the
history of busy scripts, so each paid script is tried in turn, and when none
answers the task fails so the engine retries it. Legacy UTXOs need this for
their previous transaction, and unconfirmed transactions need it to pick up
their confirmation height on each new block.

A failed Electrum request now reaches the task that made it, so that task can
retry, and tasks with no Electrum translation fail instead of receiving the
previous task's response.
The count added each transaction's height to the running sum before
comparing, so it reached at most one and included confirmed
transactions. Electrum puts mempool transactions at height 0 or -1. Only
an address's used flag reads the count, and the transaction count
already sets that flag, so wallets behave the same.
Electrum only reports how many transactions an address has, so a
wallet that also connects to an Electrum server lost the transactions
of every address that server answered for. Address histories now go to
a connected Blockbook server whenever there is one. Without one,
Electrum still answers, so an Electrum-only wallet can finish syncing.
Its answers never move an address's history cursor, and the first
Blockbook server to pick a task queries those addresses again, so their
transactions arrive once one connects.
CoinInfo.lockTime sets nLockTime on every transaction for forks that use
a magic nLockTime as opt-in replay protection. Without RBF, inputs use
sequence 0xfffffffe so nodes still enforce the nLockTime.
ECX is a Bitcoin hard fork with Bitcoin's address and key formats. Both
plugins set the network's magic nLockTime on every transaction, so ECX
spends cannot be replayed on Bitcoin. Both default to eCash's Blockbook
servers, and take fee rates and explorer links from eCash's
mempool.space-style explorers.
Every Bitcoin wallet format can split into the ECX mainnet and betanet
wallet types. edge-core-js sweeps the new ECX wallet to itself after the
split, which moves its coins off the UTXOs it shares with Bitcoin.
@peachbits
peachbits merged commit 30f36b7 into master Oct 29, 2026
2 checks passed
@peachbits
peachbits deleted the matthew/ecx-plugin branch October 29, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants