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 @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/common/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/common/utxobased/info/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -63,6 +65,7 @@ export const all = [
dogecoin,
eboost,
ecash,
ecashcom,
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', 'ecashcom'],
gapLimit: 25,
feeUpdateInterval: 60000,
mempoolSpaceFeeInfoServer: 'https://mempool.space/api/v1/fees/recommended',
Expand Down
127 changes: 127 additions & 0 deletions src/common/utxobased/info/ecashcom.ts
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 11 additions & 1 deletion src/common/utxobased/keymanager/keymanager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
}
81 changes: 81 additions & 0 deletions test/common/plugin/CurrencyPlugin.fixtures/currencies/ecashcom.ts
Original file line number Diff line number Diff line change
@@ -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'
}
]
}
}
2 changes: 2 additions & 0 deletions test/common/plugin/CurrencyPlugin.fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -16,6 +17,7 @@ export const fixtures: FixtureType[] = [
bitcoinsv,
digibyte,
ecash,
ecashcom,
feathercoin,
groestlcoin,
litecoin,
Expand Down
36 changes: 36 additions & 0 deletions test/common/utxobased/info/all.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading