From 8239e5935190990df2235d8fa5ebca0f8d30f55d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 16:13:46 -0700 Subject: [PATCH 1/2] Add opt-in replay protection locktime to transaction building Chains that fork from Bitcoin can mark their transactions with an nLockTime value that their own nodes treat as final and the parent chain rejects as non-final. Coins can now declare that value through CoinInfo.replayProtectionLocktime, and makeTx writes it into the PSBT while holding input sequence numbers below 0xffffffff so the locktime is actually enforced. --- src/common/plugin/types.ts | 10 ++++++++++ src/common/utxobased/keymanager/keymanager.ts | 12 +++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/common/plugin/types.ts b/src/common/plugin/types.ts index dbc6f3bf..6a2137b1 100644 --- a/src/common/plugin/types.ts +++ b/src/common/plugin/types.ts @@ -204,6 +204,16 @@ export interface CoinInfo { */ sighash?: number + /** + * Transaction locktime for chains that use nLockTime as an opt-in replay + * protection marker (eCash.com/ECX uses `499999999`). When this is set, + * `makeTx` writes the locktime into every transaction and keeps input + * sequence numbers below `0xffffffff`, which is what makes the locktime + * enforceable and therefore the transaction unreplayable on the parent + * chain. + */ + replayProtectionLocktime?: 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. diff --git a/src/common/utxobased/keymanager/keymanager.ts b/src/common/utxobased/keymanager/keymanager.ts index ef5600e6..8e6ca128 100644 --- a/src/common/utxobased/keymanager/keymanager.ts +++ b/src/common/utxobased/keymanager/keymanager.ts @@ -1005,14 +1005,21 @@ export function signMessageBase64( export function makeTx(args: MakeTxArgs): MakeTxReturn { const { log, outputSort, memos, memoIndex } = args + const coin = getCoinFromString(args.coin) + + // Coins carrying a replay protection locktime need a non-final sequence + // number, because a transaction whose inputs are all `0xffffffff` is final + // and its locktime is never checked: + const { replayProtectionLocktime } = coin let sequence = 0xffffffff if (args.enableRbf) { sequence -= 2 + } else if (replayProtectionLocktime != null) { + sequence -= 1 } // get coin specific replay protection sighhash bits let sighashType = Transaction.SIGHASH_ALL - const coin = getCoinFromString(args.coin) if (coin.sighash != null) { sighashType = coin.sighash } @@ -1169,6 +1176,9 @@ export function makeTx(args: MakeTxArgs): MakeTxReturn { const psbt = new Psbt() try { + if (replayProtectionLocktime != null) { + psbt.setLocktime(replayProtectionLocktime) + } psbt.addInputs(sortedInputs) psbt.addOutputs(sortedOutputs) } catch (error) { From 22d81a8af94f45a0c2d43db0f7d75274afb5bef0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 16:14:01 -0700 Subject: [PATCH 2/2] Add the eCash.com (ECX) fork plugin ECX is the ecash.com hard fork of Bitcoin, which credits every Bitcoin address 1:1 at the fork block and is unrelated to the Bitcoin ABC eCash chain this repo already ships as XEC. It reuses Bitcoin's key and address formats and Bitcoin's coin type, so a Bitcoin wallet can split into it and find the forked coins, and it marks every transaction with the fork's replay protection locktime. --- CHANGELOG.md | 2 + src/common/utxobased/info/all.ts | 3 + src/common/utxobased/info/bitcoin.ts | 2 +- src/common/utxobased/info/ecashcom.ts | 127 ++++++++++++++++++ .../currencies/bitcoin.ts | 8 +- .../currencies/ecashcom.ts | 81 +++++++++++ .../plugin/CurrencyPlugin.fixtures/index.ts | 2 + test/common/utxobased/info/all.spec.ts | 36 +++++ .../coins/ecashcomtransactiontest.spec.ts | 115 ++++++++++++++++ 9 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 src/common/utxobased/info/ecashcom.ts create mode 100644 test/common/plugin/CurrencyPlugin.fixtures/currencies/ecashcom.ts create mode 100644 test/common/utxobased/info/all.spec.ts create mode 100644 test/common/utxobased/keymanager/coins/ecashcomtransactiontest.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d3fbe4..99162317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: `ecashcom` plugin for eCash (ECX), the ecash.com hard fork of Bitcoin, including opt-in `nLockTime` replay protection and Bitcoin wallet splitting. + ## 3.12.0 (2026-08-05) - added: `signatureFormat` option on `signMessage`, accepting `electrum` (the default) or `bip137`. Existing callers keep the legacy Electrum header byte; BIP137 is opt-in. diff --git a/src/common/utxobased/info/all.ts b/src/common/utxobased/info/all.ts index 67837cfd..d197c4f8 100644 --- a/src/common/utxobased/info/all.ts +++ b/src/common/utxobased/info/all.ts @@ -12,6 +12,7 @@ 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 ecashcom } from './ecashcom' import { info as feathercoin } from './feathercoin' import { info as groestlcoin } from './groestlcoin' import { info as litecoin } from './litecoin' @@ -37,6 +38,7 @@ 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 ecashcom } from './ecashcom' export { info as feathercoin } from './feathercoin' export { info as groestlcoin } from './groestlcoin' export { info as litecoin } from './litecoin' @@ -63,6 +65,7 @@ export const all = [ dogecoin, eboost, ecash, + ecashcom, feathercoin, groestlcoin, litecoin, diff --git a/src/common/utxobased/info/bitcoin.ts b/src/common/utxobased/info/bitcoin.ts index abc5cde0..71cc8db8 100644 --- a/src/common/utxobased/info/bitcoin.ts +++ b/src/common/utxobased/info/bitcoin.ts @@ -62,7 +62,7 @@ const engineInfo: EngineInfo = { } ], formats: ['bip49', 'bip84', 'bip44', 'bip32'], - forks: ['bitcoincash', 'bitcoingold'], + forks: ['bitcoincash', 'bitcoingold', 'ecashcom'], gapLimit: 25, feeUpdateInterval: 60000, mempoolSpaceFeeInfoServer: 'https://mempool.space/api/v1/fees/recommended', diff --git a/src/common/utxobased/info/ecashcom.ts b/src/common/utxobased/info/ecashcom.ts new file mode 100644 index 00000000..3ce94c29 --- /dev/null +++ b/src/common/utxobased/info/ecashcom.ts @@ -0,0 +1,127 @@ +import { EdgeCurrencyInfo } from 'edge-core-js/types' + +import { CoinInfo, EngineInfo, PluginInfo } from '../../plugin/types' +import { + legacyMemoInfo, + utxoCustomFeeTemplate, + utxoMemoOptions +} from './commonInfo' + +/** + * eCash (ECX), the ecash.com hard fork of Bitcoin by Layer Two Labs, which + * activates drivechains (BIP300/BIP301) and credits every Bitcoin address 1:1 + * at the fork block. It is unrelated to the Bitcoin ABC eCash (XEC) chain that + * this repo ships as the `ecash` plugin, and the two only share a brand name. + * + * The chain is pre-launch, so the values below track the `drynet4` dry run + * network and the `drynet4` branch of github.com/ecash-com/bitcoin. Launch + * parameters are published at https://drivechain.info/dev.txt and + * https://ecash.com. Anything the fork has not published yet is left empty or + * omitted here rather than guessed. + */ + +const currencyInfo: EdgeCurrencyInfo = { + // Layer Two Labs disambiguates its chain from XEC as "eCash.com", the same + // label their Blockbook coin config uses: + assetDisplayName: 'eCash.com', + canReplaceByFee: true, + chainDisplayName: 'eCash.com', + currencyCode: 'ECX', + customFeeTemplate: utxoCustomFeeTemplate, + memoOptions: utxoMemoOptions, + pluginId: 'ecashcom', + walletType: 'wallet:ecashcom', + + // Explorers: + // ECX has no explorer. Every public service still runs against the drynet + // dry-run networks, including the one Layer Two Labs' own wallet ships + // (`explorer.drynet3.drivechain.dev` in its NetworkRegistry), and no + // ecash.com explorer host resolves. An empty string is the "no explorer" + // signal the app already understands, so the explorer rows stay hidden until + // a real host is published. + addressExplorer: '', + transactionExplorer: '', + + denominations: [ + // No symbol: XEC uses "e" and ECX has not published one. + { name: 'ECX', multiplier: '100000000' }, + { name: 'sats', multiplier: '1', symbol: 's' } + ], + + // Deprecated: + ...legacyMemoInfo, + defaultSettings: { + customFeeSettings: ['satPerByte'], + // No public ECX Blockbook endpoint exists. The fork's own stack is + // Electrum and Esplora, so a Blockbook has to be deployed from Layer Two + // Labs' coin config (github.com/ecash-com/blockbook, branch `ecash-com`) + // before a wallet can sync: + blockbookServers: [], + enableCustomServers: false + }, + displayName: 'eCash.com', + metaTokens: [] +} + +const engineInfo: EngineInfo = { + formats: ['bip49', 'bip84', 'bip44', 'bip32'], + gapLimit: 25, + feeUpdateInterval: 60000, + defaultFeeInfo: { + lowFeeFudgeFactor: undefined, + standardFeeLowFudgeFactor: undefined, + standardFeeHighFudgeFactor: undefined, + highFeeFudgeFactor: undefined, + + // The fork inherits Bitcoin's block size, supply and divisibility, so + // Bitcoin's fee levels carry over as the starting point. `maximumFeeRate` + // is omitted: it is derived from a USD price, ECX does not trade yet, and + // a made-up price yields a cap that guards nothing. Leaving it unset keeps + // the signing library's own default until a real price exists. + highFee: '150', + lowFee: '20', + standardFeeLow: '50', + standardFeeHigh: '100', + standardFeeLowAmount: '173200', + standardFeeHighAmount: '8670000' + } +} + +export const coinInfo: CoinInfo = { + name: 'ecashcom', + segwit: true, + + // ECX has no SLIP-44 index of its own and reuses Bitcoin's key space. This + // is what lets a wallet split from Bitcoin: the forked coins sit on the + // Bitcoin derivation paths, so any other coin type would derive addresses + // that hold nothing. + coinType: 0, + + // A transaction with `nLockTime` of `LOCKTIME_THRESHOLD - 1` is final to + // eCash nodes, while Bitcoin reads it as a block height roughly 500 million + // blocks away and rejects it as non-final. That asymmetry is the fork's + // opt-in replay protection, and it only holds while the input sequence + // numbers stay below `0xffffffff`. + replayProtectionLocktime: 499999999, + + // Keys and addresses are byte for byte identical to Bitcoin: + 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 +} diff --git a/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts b/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts index 58efefd7..89ce80fd 100644 --- a/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts +++ b/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts @@ -297,9 +297,9 @@ export const bitcoin: FixtureType = { ] }, getSplittableTypes: { - bip32: ['wallet:bitcoincash', 'wallet:bitcoingold'], - bip44: ['wallet:bitcoincash', 'wallet:bitcoingold'], - bip49: ['wallet:bitcoingold'], - bip84: ['wallet:bitcoingold'] + bip32: ['wallet:bitcoincash', 'wallet:bitcoingold', 'wallet:ecashcom'], + bip44: ['wallet:bitcoincash', 'wallet:bitcoingold', 'wallet:ecashcom'], + bip49: ['wallet:bitcoingold', 'wallet:ecashcom'], + bip84: ['wallet:bitcoingold', 'wallet:ecashcom'] } } diff --git a/test/common/plugin/CurrencyPlugin.fixtures/currencies/ecashcom.ts b/test/common/plugin/CurrencyPlugin.fixtures/currencies/ecashcom.ts new file mode 100644 index 00000000..1e8c5b53 --- /dev/null +++ b/test/common/plugin/CurrencyPlugin.fixtures/currencies/ecashcom.ts @@ -0,0 +1,81 @@ +import { FixtureType, key, mnemonics } from '../common' + +export const ecashcom: FixtureType = { + pluginId: 'ecashcom', + WALLET_TYPE: 'wallet:ecashcom', + WALLET_FORMAT: 'bip32', + 'Test Currency code': 'ECX', + key, + // Identical to the bitcoin fixture's xpub, because ECX derives on Bitcoin's + // coin type with Bitcoin's key prefixes: + xpub: + 'xpub69FqMgncSEcrs989ejBWTBBcDNFDqkwEd7y53pVeXm8368TNfb9jCd2ne3ccpx9vvgBdpv79Edc69i2Q69kXtrdmLcQM8seffnCXzwzvWa6', + 'invalid key name': { + id: 'unknown', + type: 'wallet:ecashcom', + keys: { ecashcomKeyz: '12345678abcd' } + }, + 'invalid wallet type': { + id: 'unknown', + type: 'shitcoin', + keys: { ecashcomKeyz: '12345678abcd' } + }, + importKey: { + validKeys: [...mnemonics], + invalidKeys: [ + ...mnemonics.map(mnemonic => mnemonic.split(' ').slice(1).join(' ')), + 'bunch of garbly gook !@#$%^&*()' + ], + unsupportedKeys: [] + }, + parseUri: { + 'address only': [ + '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX', + { + publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX', + metadata: {} + } + ], + 'bech32 address only': [ + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + { + publicAddress: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + metadata: {} + } + ], + 'uri address with amount': [ + 'ecashcom:1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX?amount=1.23', + { + publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX', + metadata: {}, + nativeAmount: '123000000', + currencyCode: 'ECX' + } + ], + // ECX and Bitcoin addresses are indistinguishable, so the URI scheme is + // the only thing naming the chain and it must not be interchangeable: + 'bitcoin uri protocol rejected': [ + 'bitcoin:1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX' + ] + }, + encodeUri: { + 'address only': [ + { publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX' }, + '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX' + ], + 'address & amount': [ + { + publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX', + nativeAmount: '123000000' + }, + 'ecashcom:1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX?amount=1.23' + ], + 'invalid currencyCode': [ + { + publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX', + nativeAmount: '123000000', + currencyCode: 'INVALID' + } + ] + } +} diff --git a/test/common/plugin/CurrencyPlugin.fixtures/index.ts b/test/common/plugin/CurrencyPlugin.fixtures/index.ts index 3090aebc..11410177 100644 --- a/test/common/plugin/CurrencyPlugin.fixtures/index.ts +++ b/test/common/plugin/CurrencyPlugin.fixtures/index.ts @@ -4,6 +4,7 @@ import { bitcoincash } from './currencies/bitcoincash' import { bitcoinsv } from './currencies/bitcoinsv' import { digibyte } from './currencies/digibyte' import { ecash } from './currencies/ecash' +import { ecashcom } from './currencies/ecashcom' import { feathercoin } from './currencies/feathercoin' import { groestlcoin } from './currencies/groestlcoin' import { litecoin } from './currencies/litecoin' @@ -16,6 +17,7 @@ export const fixtures: FixtureType[] = [ bitcoinsv, digibyte, ecash, + ecashcom, feathercoin, groestlcoin, litecoin, diff --git a/test/common/utxobased/info/all.spec.ts b/test/common/utxobased/info/all.spec.ts new file mode 100644 index 00000000..52c988cc --- /dev/null +++ b/test/common/utxobased/info/all.spec.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { all } from '../../../../src/common/utxobased/info/all' + +describe('utxo currency info', () => { + it('keeps the ecash.com fork distinct from the Bitcoin ABC eCash chain', () => { + const ecash = all.find(info => info.currencyInfo.pluginId === 'ecash') + const ecashcom = all.find(info => info.currencyInfo.pluginId === 'ecashcom') + + if (ecash == null || ecashcom == null) + throw new Error('Missing eCash plugin info') + + expect(ecash.currencyInfo.currencyCode).to.equal('XEC') + expect(ecashcom.currencyInfo.currencyCode).to.equal('ECX') + expect(ecashcom.currencyInfo.displayName).to.not.equal( + ecash.currencyInfo.displayName + ) + + // ECX shares Bitcoin's key space, which is what allows a Bitcoin wallet to + // split into it and still see the forked coins: + expect(ecashcom.coinInfo.coinType).to.equal(0) + expect(ecashcom.coinInfo.prefixes.pubkeyHash).to.deep.equal([0x00]) + expect(ecashcom.coinInfo.prefixes.scriptHash).to.deep.equal([0x05]) + expect(ecashcom.coinInfo.prefixes.bech32).to.deep.equal(['bc']) + expect(ecashcom.coinInfo.replayProtectionLocktime).to.equal(499999999) + }) + + it('has unique plugin IDs and wallet types', () => { + const pluginIds = all.map(info => info.currencyInfo.pluginId) + const walletTypes = all.map(info => info.currencyInfo.walletType) + + expect(new Set(pluginIds).size).to.equal(pluginIds.length) + expect(new Set(walletTypes).size).to.equal(walletTypes.length) + }) +}) diff --git a/test/common/utxobased/keymanager/coins/ecashcomtransactiontest.spec.ts b/test/common/utxobased/keymanager/coins/ecashcomtransactiontest.spec.ts new file mode 100644 index 00000000..8ff95304 --- /dev/null +++ b/test/common/utxobased/keymanager/coins/ecashcomtransactiontest.spec.ts @@ -0,0 +1,115 @@ +import { Transaction } from 'altcoin-js' +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { info as bitcoin } from '../../../../../src/common/utxobased/info/bitcoin' +import { info as ecashcom } from '../../../../../src/common/utxobased/info/ecashcom' +import { + makeTx, + MakeTxArgs, + privateKeyEncodingToPubkey, + pubkeyToScriptPubkey, + ScriptTypeEnum, + signTx, + wifToPrivateKeyEncoding +} from '../../../../../src/common/utxobased/keymanager/keymanager' + +describe('ecash.com transaction replay protection', function () { + this.timeout(10000) + + // key with control on the unspent output and used to sign the transaction + const wifKey = 'L2uPYXe17xSTqbCjZvL2DsyXPCbXspvcu5mHLDYUgzdUbZGSKrSr' + const privateKeyEncoding = wifToPrivateKeyEncoding({ + wifKey, + coin: 'ecashcom' + }) + const scriptPubkey: string = pubkeyToScriptPubkey({ + pubkey: privateKeyEncodingToPubkey(privateKeyEncoding), + scriptType: ScriptTypeEnum.p2pkh + }).scriptPubkey + + const makeTxArgs = (coin: string, enableRbf: boolean): MakeTxArgs => ({ + forceUseUtxo: [], + coin, + currencyCode: coin === 'ecashcom' ? 'ECX' : 'BTC', + enableRbf, + freshChangeAddress: '1KRMKfeZcmosxALVYESdPNez1AP1mEtywp', + feeRate: 0, + subtractFee: false, + utxos: [ + { + id: '0', + scriptType: ScriptTypeEnum.p2pkh, + txid: + '7d067b4a697a09d2c3cff7d4d9506c9955e93bff41bf82d439da7d030382bc3e', + // prev_tx only for non segwit inputs + scriptPubkey, + value: '80000', + blockHeight: 0, + spent: false, + script: + '0200000001f9f34e95b9d5c8abcd20fc5bd4a825d1517be62f0f775e5f36da944d9' + + '452e550000000006b483045022100c86e9a111afc90f64b4904bd609e9eaed80d48' + + 'ca17c162b1aca0a788ac3526f002207bb79b60d4fc6526329bf18a77135dc566020' + + '9e761da46e1c2f1152ec013215801210211755115eabf846720f5cb18f248666fec' + + '631e5e1e66009ce3710ceea5b1ad13ffffffff01' + + // value in satoshis (Int64LE) = 0x015f90 = 90000 + '905f010000000000' + + // scriptPubkey length + '19' + + // scriptPubkey + scriptPubkey + + // locktime + '00000000', + vout: 0 + } + ], + targets: [], + memos: [], + outputSort: 'bip69' + }) + + it('signs an ECX transaction that Bitcoin rejects as non-final', async () => { + const { psbtBase64 } = makeTx(makeTxArgs('ecashcom', false)) + const signedTx = await signTx({ + coin: 'ecashcom', + feeInfo: ecashcom.engineInfo.defaultFeeInfo, + privateKeyEncodings: [privateKeyEncoding], + psbtBase64 + }) + const tx = Transaction.fromHex(signedTx.hex) + + expect(tx.locktime).to.equal(499999999) + // Bitcoin only enforces the locktime while a sequence is non-final, so + // this value is what makes the transaction unreplayable: + expect(tx.ins[0].sequence).to.equal(0xfffffffe) + }) + + it('keeps the ECX locktime when replace by fee is enabled', async () => { + const { psbtBase64 } = makeTx(makeTxArgs('ecashcom', true)) + const signedTx = await signTx({ + coin: 'ecashcom', + feeInfo: ecashcom.engineInfo.defaultFeeInfo, + privateKeyEncodings: [privateKeyEncoding], + psbtBase64 + }) + const tx = Transaction.fromHex(signedTx.hex) + + expect(tx.locktime).to.equal(499999999) + expect(tx.ins[0].sequence).to.equal(0xfffffffd) + }) + + it('leaves coins without a replay locktime untouched', async () => { + const { psbtBase64 } = makeTx(makeTxArgs('bitcoin', false)) + const signedTx = await signTx({ + coin: 'bitcoin', + feeInfo: bitcoin.engineInfo.defaultFeeInfo, + privateKeyEncodings: [privateKeyEncoding], + psbtBase64 + }) + const tx = Transaction.fromHex(signedTx.hex) + + expect(tx.locktime).to.equal(0) + expect(tx.ins[0].sequence).to.equal(0xffffffff) + }) +})