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/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/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/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) { 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) + }) +})