diff --git a/src/common/plugin/types.ts b/src/common/plugin/types.ts index dbc6f3bf..416982f8 100644 --- a/src/common/plugin/types.ts +++ b/src/common/plugin/types.ts @@ -204,6 +204,12 @@ export interface CoinInfo { */ sighash?: number + /** + * Optional transaction locktime value for currencies that use nLockTime as + * an opt-in replay-protection mechanism. + */ + 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..98fdf667 100644 --- a/src/common/utxobased/info/all.ts +++ b/src/common/utxobased/info/all.ts @@ -2,6 +2,7 @@ import { info as badcoin } from './badcoin' import { info as bitcoin } from './bitcoin' import { info as bitcoincash } from './bitcoincash' import { info as bitcoincashtestnet } from './bitcoincashtestnet' +import { info as bitcoinecash } from './bitcoinecash' import { info as bitcoingold } from './bitcoingold' import { info as bitcoingoldtestnet } from './bitcoingoldtestnet' import { info as bitcoinsv } from './bitcoinsv' @@ -27,6 +28,7 @@ export { info as badcoin } from './badcoin' export { info as bitcoin } from './bitcoin' export { info as bitcoincash } from './bitcoincash' export { info as bitcoincashtestnet } from './bitcoincashtestnet' +export { info as bitcoinecash } from './bitcoinecash' export { info as bitcoingold } from './bitcoingold' export { info as bitcoingoldtestnet } from './bitcoingoldtestnet' export { info as bitcoinsv } from './bitcoinsv' @@ -53,6 +55,7 @@ export const all = [ bitcoin, bitcoincash, bitcoincashtestnet, + bitcoinecash, bitcoingold, bitcoingoldtestnet, bitcoinsv, diff --git a/src/common/utxobased/info/bitcoin.ts b/src/common/utxobased/info/bitcoin.ts index abc5cde0..78dd1bf4 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', 'bitcoinecash'], gapLimit: 25, feeUpdateInterval: 60000, mempoolSpaceFeeInfoServer: 'https://mempool.space/api/v1/fees/recommended', diff --git a/src/common/utxobased/info/bitcoinecash.ts b/src/common/utxobased/info/bitcoinecash.ts new file mode 100644 index 00000000..26508d4b --- /dev/null +++ b/src/common/utxobased/info/bitcoinecash.ts @@ -0,0 +1,89 @@ +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: 'bitcoinecash', + walletType: 'wallet:bitcoinecash', + + // Explorers: + blockExplorer: 'https://explorer.ecash.com/block/%s', + addressExplorer: 'https://explorer.ecash.com/address/%s', + transactionExplorer: 'https://explorer.ecash.com/tx/%s', + + denominations: [ + { name: 'ECX', multiplier: '100000000', symbol: 'e' }, + { name: 'sats', multiplier: '1', symbol: 's' } + ], + + // Deprecated: + ...legacyMemoInfo, + defaultSettings: { + customFeeSettings: ['satPerByte'], + blockbookServers: [], + enableCustomServers: false + }, + displayName: 'eCash', + metaTokens: [] +} + +const engineInfo: EngineInfo = { + formats: ['bip49', 'bip84', 'bip44', 'bip32'], + gapLimit: 25, + feeUpdateInterval: 60000, + defaultFeeInfo: { + lowFeeFudgeFactor: undefined, + standardFeeLowFudgeFactor: undefined, + standardFeeHighFudgeFactor: undefined, + highFeeFudgeFactor: undefined, + + highFee: '150', + lowFee: '20', + standardFeeLow: '50', + standardFeeHigh: '100', + standardFeeLowAmount: '173200', + standardFeeHighAmount: '8670000', + maximumFeeRate: maximumFeeRateCalculator(currencyInfo, 1) + } +} + +export const coinInfo: CoinInfo = { + name: 'bitcoinecash', + segwit: true, + coinType: 0, + + // Official pre-launch/drynet4 replay-protection params must be reverified before go-live. + replayProtectionLocktime: 499999999, + + 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..f8c33dbe 100644 --- a/src/common/utxobased/keymanager/keymanager.ts +++ b/src/common/utxobased/keymanager/keymanager.ts @@ -1005,14 +1005,17 @@ export function signMessageBase64( export function makeTx(args: MakeTxArgs): MakeTxReturn { const { log, outputSort, memos, memoIndex } = args + const coin = getCoinFromString(args.coin) + const replayProtectionLocktime = coin.replayProtectionLocktime let sequence = 0xffffffff if (args.enableRbf) { sequence -= 2 + } else if (replayProtectionLocktime != null) { + sequence = 0xfffffffe } // 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 +1172,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..d5b01860 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:bitcoinecash'], + bip44: ['wallet:bitcoincash', 'wallet:bitcoingold', 'wallet:bitcoinecash'], + bip49: ['wallet:bitcoingold', 'wallet:bitcoinecash'], + bip84: ['wallet:bitcoingold', 'wallet:bitcoinecash'] } } diff --git a/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoinecash.ts b/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoinecash.ts new file mode 100644 index 00000000..8810e2a8 --- /dev/null +++ b/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoinecash.ts @@ -0,0 +1,77 @@ +import { FixtureType, key, mnemonics } from '../common' + +export const bitcoinecash: FixtureType = { + pluginId: 'bitcoinecash', + WALLET_TYPE: 'wallet:bitcoinecash', + WALLET_FORMAT: 'bip32', + 'Test Currency code': 'ECX', + key, + xpub: + 'xpub69FqMgncSEcrs989ejBWTBBcDNFDqkwEd7y53pVeXm8368TNfb9jCd2ne3ccpx9vvgBdpv79Edc69i2Q69kXtrdmLcQM8seffnCXzwzvWa6', + 'invalid key name': { + id: 'unknown', + type: 'wallet:bitcoinecash', + keys: { bitcoinecashKeyz: '12345678abcd' } + }, + 'invalid wallet type': { + id: 'unknown', + type: 'shitcoin', + keys: { bitcoinecashKeyz: '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': [ + 'bitcoinecash:1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX?amount=1.23', + { + publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX', + metadata: {}, + nativeAmount: '123000000', + currencyCode: 'ECX' + } + ], + 'bitcoin uri protocol rejected': [ + 'bitcoin:1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX' + ] + }, + encodeUri: { + 'address only': [ + { publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX' }, + '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX' + ], + 'address & amount': [ + { + publicAddress: '1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX', + nativeAmount: '123000000' + }, + 'bitcoinecash: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..7a8184d0 100644 --- a/test/common/plugin/CurrencyPlugin.fixtures/index.ts +++ b/test/common/plugin/CurrencyPlugin.fixtures/index.ts @@ -1,6 +1,7 @@ import { FixtureType } from './common' import { bitcoin } from './currencies/bitcoin' import { bitcoincash } from './currencies/bitcoincash' +import { bitcoinecash } from './currencies/bitcoinecash' import { bitcoinsv } from './currencies/bitcoinsv' import { digibyte } from './currencies/digibyte' import { ecash } from './currencies/ecash' @@ -13,6 +14,7 @@ import { zcoin } from './currencies/zcoin' export const fixtures: FixtureType[] = [ bitcoin, bitcoincash, + bitcoinecash, bitcoinsv, digibyte, ecash, diff --git a/test/common/plugin/Metadata.spec.ts b/test/common/plugin/Metadata.spec.ts index c7239878..3f0bb4b9 100644 --- a/test/common/plugin/Metadata.spec.ts +++ b/test/common/plugin/Metadata.spec.ts @@ -9,6 +9,7 @@ import { EngineEvent } from '../../../src/common/plugin/EngineEmitter' import { makeMetadata, Metadata } from '../../../src/common/plugin/Metadata' +import { all } from '../../../src/common/utxobased/info/all' import { makeFakeLog } from '../../utils' chai.should() @@ -49,3 +50,33 @@ describe('makeMetadata', () => { }) }) }) + +describe('currency metadata', () => { + it('keeps ECX distinct from XEC', () => { + const ecash = all.find(info => info.currencyInfo.pluginId === 'ecash') + const bitcoinecash = all.find( + info => info.currencyInfo.pluginId === 'bitcoinecash' + ) + + if (ecash == null || bitcoinecash == null) { + throw new Error('Missing eCash plugin metadata') + } + + ecash.currencyInfo.walletType.should.equal('wallet:ecash') + ecash.currencyInfo.currencyCode.should.equal('XEC') + bitcoinecash.currencyInfo.walletType.should.equal('wallet:bitcoinecash') + bitcoinecash.currencyInfo.currencyCode.should.equal('ECX') + bitcoinecash.coinInfo.coinType.should.equal(0) + bitcoinecash.coinInfo.prefixes.pubkeyHash.should.deep.equal([0x00]) + bitcoinecash.coinInfo.prefixes.scriptHash.should.deep.equal([0x05]) + bitcoinecash.coinInfo.prefixes.bech32?.should.deep.equal(['bc']) + }) + + it('has unique plugin IDs and wallet types', () => { + const pluginIds = all.map(info => info.currencyInfo.pluginId) + const walletTypes = all.map(info => info.currencyInfo.walletType) + + new Set(pluginIds).size.should.equal(pluginIds.length) + new Set(walletTypes).size.should.equal(walletTypes.length) + }) +}) diff --git a/test/common/utxobased/keymanager/coins/bitcointransactiontest.spec.ts b/test/common/utxobased/keymanager/coins/bitcointransactiontest.spec.ts index 698d3d82..52dd18b3 100644 --- a/test/common/utxobased/keymanager/coins/bitcointransactiontest.spec.ts +++ b/test/common/utxobased/keymanager/coins/bitcointransactiontest.spec.ts @@ -1,8 +1,10 @@ +import { Transaction } from 'altcoin-js' import { expect } from 'chai' import { describe, it } from 'mocha' import { UtxoData } from '../../../../../src/common/utxobased/db/types' import { info as bitcoin } from '../../../../../src/common/utxobased/info/bitcoin' +import { info as bitcoinecash } from '../../../../../src/common/utxobased/info/bitcoinecash' import { addressToScriptPubkey, AddressTypeEnum, @@ -207,6 +209,54 @@ describe('bitcoin transaction creation and signing test', function () { ) }) + it('Create ECX transaction with replay-protection locktime', async () => { + const { psbtBase64 } = makeTx({ + forceUseUtxo: [], + coin: 'bitcoinecash', + currencyCode: 'ECX', + enableRbf: false, + freshChangeAddress: '1KRMKfeZcmosxALVYESdPNez1AP1mEtywp', + feeRate: 0, + subtractFee: false, + utxos: [ + { + id: '0', + scriptType: ScriptTypeEnum.p2pkh, + txid: + '7d067b4a697a09d2c3cff7d4d9506c9955e93bff41bf82d439da7d030382bc3e', + scriptPubkey, + value: '80000', + blockHeight: 0, + spent: false, + script: + '0200000001f9f34e95b9d5c8abcd20fc5bd4a825d1517be62f0f775e5f36da944d9' + + '452e550000000006b483045022100c86e9a111afc90f64b4904bd609e9eaed80d48' + + 'ca17c162b1aca0a788ac3526f002207bb79b60d4fc6526329bf18a77135dc566020' + + '9e761da46e1c2f1152ec013215801210211755115eabf846720f5cb18f248666fec' + + '631e5e1e66009ce3710ceea5b1ad13ffffffff01' + + '905f010000000000' + + '19' + + scriptPubkey + + '00000000', + vout: 0 + } + ], + targets: [], + memos: [], + outputSort: 'bip69' + }) + const signedTx = await signTx({ + coin: 'bitcoinecash', + feeInfo: bitcoinecash.engineInfo.defaultFeeInfo, + privateKeyEncodings: [privateKeyEncoding], + psbtBase64 + }) + const tx = Transaction.fromHex(signedTx.hex) + + expect(tx.locktime).to.equal(499999999) + expect(tx.ins[0].sequence).to.equal(0xfffffffe) + }) + it('Create transaction three outputs using bip69 outputSort', async () => { /* This here is the rawtransaction as assembled below: