diff --git a/.eslintrc.js b/.eslintrc.js index 1e313d9f7..43f267f1a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,7 +59,7 @@ module.exports = { }, ], 'unused-imports/no-unused-imports': 'warn', - 'unused-imports/no-unused-vars': 'warn', + 'unused-imports/no-unused-vars': ['warn', { args: 'after-used' }], }, settings: { 'import/parsers': { diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 965449697..6ed41426e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,25 +26,26 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.7' + python-version: '3.10' + + - name: Auth GCP + uses: google-github-actions/auth@v1 + with: + credentials_json: ${{ secrets.GCP_ARTIFACTS_KEY }} # on Windows GCP Actions needs path to Python, see https://github.com/GoogleCloudPlatform/github-actions/issues/100 - name: Setup GCP (Windows) - uses: google-github-actions/setup-gcloud@v0 + uses: google-github-actions/setup-gcloud@v1 env: CLOUDSDK_PYTHON: ${{env.pythonLocation}}\python.exe with: project_id: ${{ secrets.GCP_PROJECT_ID }} - service_account_key: ${{ secrets.GCP_ARTIFACTS_KEY }} - export_default_credentials: true if: runner.os == 'Windows' - name: Setup GCP (non Windows) - uses: google-github-actions/setup-gcloud@v0 + uses: google-github-actions/setup-gcloud@v1 with: project_id: ${{ secrets.GCP_PROJECT_ID }} - service_account_key: ${{ secrets.GCP_ARTIFACTS_KEY }} - export_default_credentials: true if: runner.os != 'Windows' - name: Update apt index (Linux) diff --git a/.vscode/emerald-wallet.code-workspace b/.vscode/emerald-wallet.code-workspace index 4199273f1..e34edf033 100644 --- a/.vscode/emerald-wallet.code-workspace +++ b/.vscode/emerald-wallet.code-workspace @@ -68,7 +68,9 @@ "search.exclude": { "**/.emerald-dev": true, "**/.tests": true, + "**/.yarn": true, "**/app": true, + "**/coverage": true, "**/lib": true, "**/node_modules": true, "**/target": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d138f9b2f..69dc3ea29 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,6 +4,21 @@ { "type": "npm", "group": "build", + "label": "clean", + "script": "clean" + }, + { + "type": "npm", + "group": "build", + "label": "build:native", + "script": "build:native" + }, + { + "type": "npm", + "group": { + "isDefault": true, + "kind": "build" + }, "label": "build:dev", "script": "build:dev" }, diff --git a/packages/core/package.json b/packages/core/package.json index bd86f44e6..01da550f8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,14 +13,15 @@ "test:coverage": "jest --coverage" }, "dependencies": { - "@emeraldpay/bigamount": "^0.4.1", - "@emeraldpay/bigamount-crypto": "^0.4.1", + "@emeraldpay/bigamount": "^0.4.2", + "@emeraldpay/bigamount-crypto": "^0.4.2", "@emeraldpay/emerald-vault-core": "^0.12.0", "@ethereumjs/common": "^3.1.2", "@ethereumjs/tx": "^4.1.2", "@ethersproject/abi": "^5.7.0", "@ethersproject/bignumber": "^5.7.0", "bignumber.js": "8.0.2", + "bitcoinjs-lib": "^6.1.0", "ethereumjs-util": "^7.1.5", "jsonschema": "^1.4.1" }, diff --git a/packages/core/src/blockchains/blockchains.ts b/packages/core/src/blockchains/blockchains.ts index b9665440a..c5f22c349 100644 --- a/packages/core/src/blockchains/blockchains.ts +++ b/packages/core/src/blockchains/blockchains.ts @@ -1,5 +1,5 @@ import { BigAmount, CreateAmount, Unit, Units } from '@emeraldpay/bigamount'; -import { Satoshi, Wei, WeiAny, WeiEtc } from '@emeraldpay/bigamount-crypto'; +import { Satoshi, SatoshiAny, Wei, WeiAny, WeiEtc } from '@emeraldpay/bigamount-crypto'; import { LedgerApp } from '@emeraldpay/emerald-vault-core'; import { Bitcoin } from './Bitcoin'; import { Coin } from './coin'; @@ -165,7 +165,13 @@ export const WEIS_GOERLI = new Units([ new Unit(18, 'Goerli Ether', 'ETG'), ]); -export const SATOSHIS_TEST = new Units([new Unit(0, 'Test Satoshi', 'tsat'), new Unit(8, 'Test Bitcoin', 'TESTBTC')]); +export const SATOSHIS_TEST = new Units([ + new Unit(0, 'Test Satoshi', 'tsat'), + new Unit(1, 'Test Finney', 'tfin'), + new Unit(2, 'Test bit', 'tμBTC'), + new Unit(5, 'Test millibit', 'tmBTC'), + new Unit(8, 'Test Bitcoin', 'TestBTC'), +]); export function amountFactory(code: BlockchainCode): CreateAmount { switch (code) { @@ -178,7 +184,7 @@ export function amountFactory(code: BlockchainCode): CreateAmount { case BlockchainCode.Goerli: return (value) => new WeiAny(value, WEIS_GOERLI); case BlockchainCode.TestBTC: - return (value) => new BigAmount(value, SATOSHIS_TEST); + return (value) => new SatoshiAny(value, SATOSHIS_TEST); default: throw new Error(`Unsupported blockchain: ${code}`); } diff --git a/packages/core/src/currency.spec.ts b/packages/core/src/currency.spec.ts deleted file mode 100644 index 4106972ef..000000000 --- a/packages/core/src/currency.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Currency, CurrencyCode } from './Currency'; - -describe('Currency', () => { - it('should format', () => { - expect(Currency.format(5, CurrencyCode.USD)).toEqual('$5.00'); - }); -}); diff --git a/packages/core/src/workflow/create-tx/CreateBitcoinCancelTx.ts b/packages/core/src/workflow/CreateBitcoinCancelTx.ts similarity index 87% rename from packages/core/src/workflow/create-tx/CreateBitcoinCancelTx.ts rename to packages/core/src/workflow/CreateBitcoinCancelTx.ts index c64446e25..6ebcd23dd 100644 --- a/packages/core/src/workflow/create-tx/CreateBitcoinCancelTx.ts +++ b/packages/core/src/workflow/CreateBitcoinCancelTx.ts @@ -13,7 +13,7 @@ export class CreateBitcoinCancelTx extends CreateBitcoinTx { return [ { - address: this.changeAddress, + address: this.changeAddress ?? '', amount: totalChange.number.toNumber(), entryId: this.entryId, }, @@ -33,6 +33,10 @@ export class CreateBitcoinCancelTx extends CreateBitcoinTx { return ValidationResult.INSUFFICIENT_FEE_PRICE; } + if (this.changeAddress == null) { + return ValidationResult.NO_CHANGE_ADDRESS; + } + if (this.tx.from.length === 0) { return ValidationResult.NO_FROM; } diff --git a/packages/core/src/workflow/CreateBitcoinTx.spec.ts b/packages/core/src/workflow/CreateBitcoinTx.spec.ts new file mode 100644 index 000000000..afd0305d7 --- /dev/null +++ b/packages/core/src/workflow/CreateBitcoinTx.spec.ts @@ -0,0 +1,547 @@ +import { SATOSHIS, Satoshi } from '@emeraldpay/bigamount-crypto'; +import { BlockchainCode, InputUtxo, amountFactory } from '../blockchains'; +import { BitcoinTxMetric, BitcoinTxOutput, CreateBitcoinTx, convertWUToVB } from './CreateBitcoinTx'; +import { TxTarget, ValidationResult } from './types'; + +const basicEntryId = 'f76416d7-3510-4d80-85df-52e7222e56df-1'; +const restoreEntryId = '2a19e023-f119-4dab-b2cb-4b3e73fa32c9-1'; + +class TestMetric implements BitcoinTxMetric { + readonly inputWeight: number; + readonly outputWeight: number; + + constructor(inputWeight: number, outputWeight: number) { + this.inputWeight = inputWeight; + this.outputWeight = outputWeight; + } + + weight(inputs: number, outputs: number): number { + return inputs * this.inputWeight + outputs * this.outputWeight; + } + + weightOf(inputs: InputUtxo[], outputs: BitcoinTxOutput[]): number { + return this.weight(inputs.length, outputs.length); + } + + fees(inputs: number, outputs: number, create: CreateBitcoinTx): number { + return amountFactory(create.blockchain)(create.vkbPrice) + .multiply(convertWUToVB(this.weight(inputs, outputs))) + .number.dividedBy(SATOSHIS.top.multiplier) + .dividedBy(1024) + .toNumber(); + } +} + +const defaultMetric = new TestMetric(120, 80); + +describe('CreateBitcoinTx', () => { + const defaultBitcoin = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [], + }); + + defaultBitcoin.metric = defaultMetric; + defaultBitcoin.feePrice = 100; + + it('create', () => { + const act = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [], + }); + + expect(act).toBeDefined(); + expect(act.totalToSpend).toBeDefined(); + expect(act.totalToSpend.isZero()).toBeTruthy(); + expect(act.validate()).not.toBe(ValidationResult.OK); + }); + + it('total zero for empty utxo', () => expect(defaultBitcoin.totalUtxo([]).toString()).toBe(Satoshi.ZERO.toString())); + + it('total for single utxo', () => + expect( + defaultBitcoin + .totalUtxo([ + { + address: 'ADDR', + txid: '', + value: Satoshi.fromBitcoin(0.5).encode(), + vout: 0, + }, + ]) + .toString(), + ).toBe(Satoshi.fromBitcoin(0.5).toString())); + + it('total for few utxo', () => + expect( + defaultBitcoin + .totalUtxo([ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.5).encode(), address: 'ADDR' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.61).encode(), address: 'ADDR' }, + { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.756).encode(), address: 'ADDR' }, + ]) + .toString(), + ).toBe(Satoshi.fromBitcoin(0.5 + 0.61 + 0.756).toString())); + + it('rebalance when have enough', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.5).encode(), address: 'ADDR' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.61).encode(), address: 'ADDR' }, + { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.756).encode(), address: 'ADDR' }, + ], + }); + + create.metric = defaultMetric; + + create.amount = Satoshi.fromBitcoin(0.97); + create.feePrice = 100 * 1024; + create.to = 'AAA'; + + const rebalanced = create.rebalance(); + + expect(rebalanced).toBeTruthy(); + + expect(create.transaction.from.length).toBe(2); + expect(create.transaction.from[0].txid).toBe('1'); + expect(create.transaction.from[1].txid).toBe('2'); + + // sending + change + expect(create.outputs.length).toBe(2); + + // 100 sat per wu, ((2 * 120) + (2 * 80)) * 100 / 4 + expect(create.fees.number.toNumber()).toBe(10000); + + expect(create.fees.getNumberByUnit(SATOSHIS.top).toNumber()).toBe( + defaultMetric.fees(2, create.outputs.length, create), + ); + + // 40000 / 10^8 = 0.0004 + expect(create.change.toString()).toBe(Satoshi.fromBitcoin(0.5 + 0.61 - 0.97 - 0.0001).toString()); + expect(create.totalToSpend.toString()).toBe(Satoshi.fromBitcoin(0.5 + 0.61).toString()); + + expect(create.validate()).toBe(ValidationResult.OK); + }); + + it('rebalance when less that enough', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.5).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.61).encode(), address: 'addr2' }, + { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.756).encode(), address: 'addr3' }, + ], + }); + + create.amount = Satoshi.fromBitcoin(2); + create.to = 'addrTo'; + + const ok = create.rebalance(); + + expect(ok).toBeFalsy(); + + expect(create.transaction.from.length).toBe(3); + expect(create.transaction.from[0].txid).toBe('1'); + expect(create.transaction.from[1].txid).toBe('2'); + expect(create.transaction.from[2].txid).toBe('3'); + + expect(create.change.toString()).toBe(Satoshi.ZERO.toString()); + expect(create.totalToSpend.toString()).toBe(Satoshi.fromBitcoin(0.5 + 0.61 + 0.756).toString()); + + expect(create.validate()).toBe(ValidationResult.INSUFFICIENT_FUNDS); + }); + + it('rebalance when no change', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr2' }, + { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr3' }, + { txid: '4', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr4' }, + ], + }); + + create.metric = defaultMetric; + + create.amount = Satoshi.fromBitcoin(0.02 - 0.00008); + create.feePrice = 65 * 1024; + create.to = 'addrTo'; + + const ok = create.rebalance(); + + expect(ok).toBeTruthy(); + + expect(create.transaction.from.length).toBe(4); + expect(create.change.toString()).toBe(Satoshi.ZERO.toString()); + expect(create.totalToSpend.toString()).toBe(Satoshi.fromBitcoin(0.02).toString()); + + expect(create.outputs.length).toBe(1); + expect(create.outputs[0].address).toBe('addrTo'); + expect(create.outputs[0].amount).toBe(1992000); + + // ((4 * 120) + (1 * 80)) * 65 / 4 == 9100 (or 0.000091), but it doesn't have enough change, only 0.00008 + expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.00008).toString()); + + expect(create.validate()).toBe(ValidationResult.OK); + }); + + it('rebalance with send all target', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, + ], + }); + + create.metric = defaultMetric; + + create.feePrice = 100 * 1024; + create.to = 'addrTo'; + create.target = TxTarget.SEND_ALL; + + const ok = create.rebalance(); + + expect(ok).toBeTruthy(); + + expect(create.amount.number.toNumber()).toEqual(9992000); + }); + + it('simple fee', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, + ], + }); + + create.metric = defaultMetric; + + create.amount = Satoshi.fromBitcoin(0.08); + create.feePrice = 100 * 1024; + create.to = 'addrTo'; + + // ((2 * 120) + (2 * 80)) * 100 / 4== 10000 + expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.0001).toString()); + }); + + it('fee when not enough', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, + ], + }); + + create.metric = defaultMetric; + + create.amount = Satoshi.fromBitcoin(2); + create.to = 'addrTo'; + + expect(create.fees.toString()).toBe(Satoshi.ZERO.toString()); + }); + + it('update fee', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, + ], + }); + + create.metric = defaultMetric; + + create.amount = Satoshi.fromBitcoin(0.08); + create.feePrice = 100 * 1024; + create.to = 'addrTo'; + + // ((2 * 120) + (2 * 80)) * 100 / 4 + expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.0001).toString()); + + create.feePrice = 150 * 1024; + + // ((2 * 120) + (2 * 80)) * 150 / 4 + expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.00015).toString()); + }); + + it('estimate fees', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, + ], + }); + + create.metric = defaultMetric; + + create.amount = Satoshi.fromBitcoin(0.08); + create.to = 'addrTo'; + + // ((2 * 120) + (2 * 80)) * 100 * 1024 / 4 = 10000 + create.vkbPrice = 100 * 1024; + expect(create.getFees().toString()).toBe(Satoshi.fromBitcoin(0.0001).toString()); + + create.vkbPrice = 150 * 1024; + expect(create.getFees().toString()).toBe(Satoshi.fromBitcoin(0.00015).toString()); + + create.vkbPrice = 200 * 1024; + expect(create.getFees().toString()).toBe(Satoshi.fromBitcoin(0.0002).toString()); + + create.vkbPrice = 2000 * 1024; + expect(create.getFees().toString()).toBe(Satoshi.fromBitcoin(0.002).toString()); + }); + + it('estimate price', () => { + const tx = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 1, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, + ], + }); + + tx.amount = Satoshi.fromBitcoin(0.08); + tx.metric = defaultMetric; + tx.to = 'addrTo'; + + expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.0001))).toEqual(100 * 1024); + expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.00015))).toEqual(150 * 1024); + expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.0002))).toEqual(200 * 1024); + expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.002))).toEqual(2000 * 1024); + }); + + it('total available', () => { + let create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [{ txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }], + }); + + expect(create.totalAvailable.toString()).toBe(Satoshi.fromBitcoin(0.05).toString()); + + create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, + ], + }); + + expect(create.totalAvailable.toString()).toBe(Satoshi.fromBitcoin(0.1).toString()); + + create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, + { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.06).encode(), address: 'addr2' }, + { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.07).encode(), address: 'addr3' }, + ], + }); + + expect(create.totalAvailable.toString()).toBe(Satoshi.fromBitcoin(0.18).toString()); + }); + + it('creates unsigned', () => { + const create = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrChange', + utxo: [{ txid: '1', vout: 0, value: new Satoshi(112233).encode(), address: 'addr1' }], + }); + + create.metric = defaultMetric; + + create.amount = new Satoshi(80000); + create.feePrice = 100 * 1024; + create.to = 'addrTo'; + + const unsigned = create.build(); + + expect(unsigned.inputs.length).toBe(1); + expect(unsigned.inputs[0]).toEqual({ + address: 'addr1', + amount: 112233, + sequence: 4294967280, + txid: '1', + vout: 0, + entryId: 'f76416d7-3510-4d80-85df-52e7222e56df-1', + }); + + // ((1 * 120) + (2 * 80)) * 100 / 4 + expect(unsigned.fee).toBe(7000); + + expect(unsigned.outputs.length).toBe(2); + expect(unsigned.outputs[0]).toEqual({ + address: 'addrTo', + amount: 80000, + }); + expect(unsigned.outputs[1]).toEqual({ + address: 'addrChange', + amount: 112233 - 80000 - 7000, + entryId: 'f76416d7-3510-4d80-85df-52e7222e56df-1', + }); + }); + + it('creates restored', () => { + const tx = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: restoreEntryId, + changeAddress: 'tb1q8grga8c48wa4dsevt0v0gcl6378rfljj6vrz0u', + utxo: [ + { + address: 'tb1qjg445dvh6krr6gtmuh4eqgua372vxaf4q07nv9', + txid: 'fd53023c4a9627c26c5d930f3149890b2eecf4261f409bd1a340454b7dede244', + value: '1210185/SAT', + vout: 0, + }, + ], + }); + + tx.amount = new Satoshi(1000); + tx.feePrice = 1067; + tx.to = 'tb1q2h3wgjasuprzrmcljkpkcyeh69un3r0tzf9nnn'; + + const unsigned = tx.build(); + + expect(unsigned.fee).toEqual(208); + expect(unsigned.inputs.length).toEqual(1); + expect(unsigned.outputs.length).toEqual(2); + }); + + it('creates with zero fee', () => { + const tx = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [{ txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }], + }); + + tx.amount = new Satoshi(1000); + tx.feePrice = 0; + tx.to = 'addrTo'; + + const unsigned = tx.build(); + + expect(unsigned.fee).toEqual(0); + expect(unsigned.inputs.length).toEqual(1); + expect(unsigned.outputs.length).toEqual(1); + }); + + it('creates with enough inputs for fee', () => { + const tx = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }, + { txid: '2', vout: 1, value: new Satoshi(1000).encode(), address: 'addr2' }, + ], + }); + + tx.amount = new Satoshi(1000); + tx.feePrice = 1024; + tx.to = 'addrTo'; + + const unsigned = tx.build(); + + expect(unsigned.fee).toEqual(260); + expect(unsigned.inputs.length).toEqual(2); + expect(unsigned.outputs.length).toEqual(2); + }); + + it('creates with inputs amount equals required amount and zero fee', () => { + const tx = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }, + { txid: '2', vout: 1, value: new Satoshi(1000).encode(), address: 'addr2' }, + ], + }); + + tx.to = 'addrTo'; + tx.amount = new Satoshi(2000); + tx.feePrice = 1024; + + const unsigned = tx.build(); + + expect(unsigned.fee).toEqual(0); + expect(unsigned.inputs.length).toEqual(2); + expect(unsigned.outputs.length).toEqual(1); + }); + + it('creates cancel transaction', () => { + const tx = new CreateBitcoinTx({ + blockchain: BlockchainCode.BTC, + entryId: basicEntryId, + changeAddress: 'addrchange', + utxo: [ + { txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }, + { txid: '2', vout: 1, value: new Satoshi(1000).encode(), address: 'addr2' }, + ], + }); + + tx.amount = new Satoshi(1000); + tx.feePrice = 1024; + tx.to = 'addrTo'; + + const original = tx.build(); + + expect(original.inputs.length).toEqual(2); + expect(original.outputs.length).toEqual(2); + + expect(original.outputs).toEqual(expect.arrayContaining([expect.objectContaining({ address: 'addrTo' })])); + + tx.to = 'addrChange'; + tx.feePrice = 1536; + + const cancel = tx.build(); + + expect(cancel.fee).toBeGreaterThan(original.fee); + expect(cancel.inputs.length).toEqual(2); + /** + * TODO Make single output + * + * @see task WALLET-251 + */ + expect(cancel.outputs.length).toEqual(2); + + const changeAddress = expect.objectContaining({ address: 'addrChange' }); + + expect(cancel.outputs).toEqual(expect.arrayContaining([changeAddress, changeAddress])); + expect(cancel.outputs).not.toEqual(expect.arrayContaining([expect.objectContaining({ address: 'addrTo' })])); + }); +}); diff --git a/packages/core/src/workflow/create-tx/CreateBitcoinTx.ts b/packages/core/src/workflow/CreateBitcoinTx.ts similarity index 61% rename from packages/core/src/workflow/create-tx/CreateBitcoinTx.ts rename to packages/core/src/workflow/CreateBitcoinTx.ts index 59d1e6972..cc9b581f1 100644 --- a/packages/core/src/workflow/create-tx/CreateBitcoinTx.ts +++ b/packages/core/src/workflow/CreateBitcoinTx.ts @@ -1,7 +1,9 @@ -import { BigAmount, CreateAmount } from '@emeraldpay/bigamount'; -import { BitcoinEntry, EntryId, UnsignedBitcoinTx } from '@emeraldpay/emerald-vault-core'; -import { BlockchainCode, InputUtxo, amountDecoder, amountFactory, blockchainIdToCode } from '../../blockchains'; -import { TxTarget, ValidationResult } from './types'; +import { CreateAmount } from '@emeraldpay/bigamount'; +import { SatoshiAny } from '@emeraldpay/bigamount-crypto'; // TODO SatoshiAnyAny +import { EntryId, UnsignedBitcoinTx } from '@emeraldpay/emerald-vault-core'; +import BigNumber from 'bignumber.js'; +import { BlockchainCode, InputUtxo, amountDecoder, amountFactory } from '../blockchains'; +import { BitcoinPlainTx, TxTarget, ValidationResult } from './types'; const DEFAULT_SEQUENCE = 0xfffffff0 as const; const MAX_SEQUENCE = 0xffffffff as const; @@ -10,11 +12,11 @@ const MAX_SEQUENCE = 0xffffffff as const; * @see https://bitcoinfees.net * @see https://www.buybitcoinworldwide.com/fee-calculator */ -const DEFAULT_VKB_FEE = 1024 as const; +export const DEFAULT_VKB_FEE = 1024 as const; export interface BitcoinTxDetails { - amount?: BigAmount; - change?: BigAmount; + amount?: SatoshiAny; + change?: SatoshiAny; from: InputUtxo[]; target: TxTarget; to?: string; @@ -34,29 +36,29 @@ export interface BitcoinTxMetric { } interface CommonBitcoinTx { - readonly changeAddress: string; + readonly changeAddress?: string; readonly entryId: EntryId; readonly tx: BitcoinTxDetails; - vkbPrice: BigAmount; metric: BitcoinTxMetric; - create(): UnsignedBitcoinTx; - estimateFees(price: number): BigAmount; - estimateVkbPrice(price: BigAmount): number; + vkbPrice: number; + build(): UnsignedBitcoinTx; + estimateVkbPrice(price: SatoshiAny): number; + getFees(price: number): SatoshiAny; rebalance(): boolean; - totalUtxo(utxo: InputUtxo[]): BigAmount; + totalUtxo(utxo: InputUtxo[]): SatoshiAny; validate(): ValidationResult; } abstract class AbstractBitcoinTx { - abstract get change(): BigAmount; - abstract get fees(): BigAmount; + abstract set amount(value: SatoshiAny | BigNumber); + abstract get change(): SatoshiAny; + abstract get fees(): SatoshiAny; abstract set feePrice(price: number); abstract get outputs(): BitcoinTxOutput[]; - abstract set requiredAmount(value: BigAmount); abstract set target(target: TxTarget); - abstract set toAddress(address: string | undefined); - abstract get totalAvailable(): BigAmount; - abstract get totalToSpend(): BigAmount; + abstract set to(address: string | undefined); + abstract get totalAvailable(): SatoshiAny; + abstract get totalToSpend(): SatoshiAny; abstract get transaction(): BitcoinTxDetails; } @@ -82,47 +84,68 @@ export function convertWUToVB(wu: number): number { return wu / 4; } +export interface BitcoinTxOrigin { + blockchain: BlockchainCode; + changeAddress?: string; + entryId: EntryId; + utxo: InputUtxo[]; +} + export class CreateBitcoinTx implements BitcoinTx { - readonly changeAddress: string; - readonly entryId: EntryId; readonly tx: BitcoinTxDetails; + readonly blockchain: BlockchainCode; + readonly changeAddress?: string; + readonly entryId: EntryId; + readonly utxo: InputUtxo[]; + public metric: BitcoinTxMetric = new AverageTxMetric(); - public vkbPrice: BigAmount; + public vkbPrice: number; - private readonly amountDecoder: (value: string) => BigAmount; - private readonly amountFactory: CreateAmount; - private readonly blockchain: BlockchainCode; - private readonly utxo: InputUtxo[]; - private readonly zero: BigAmount; + private readonly amountDecoder: (value: string) => SatoshiAny; + private readonly amountFactory: CreateAmount; - constructor(entry: BitcoinEntry, changeAddress: string, utxo: InputUtxo[]) { - this.changeAddress = changeAddress; - this.entryId = entry.id; - this.utxo = utxo; + private readonly zero: SatoshiAny; + constructor({ blockchain, changeAddress, entryId, utxo }: BitcoinTxOrigin) { this.tx = { from: [], target: TxTarget.MANUAL }; - this.blockchain = blockchainIdToCode(entry.blockchain); + this.blockchain = blockchain; + this.changeAddress = changeAddress; + this.entryId = entryId; + this.utxo = utxo; - this.amountDecoder = amountDecoder(this.blockchain); - this.amountFactory = amountFactory(this.blockchain); + this.amountDecoder = amountDecoder(this.blockchain); + this.amountFactory = amountFactory(this.blockchain) as CreateAmount; + this.vkbPrice = DEFAULT_VKB_FEE; this.zero = this.amountFactory(0); + } + + static fromPlain(origin: BitcoinTxOrigin, plain: BitcoinPlainTx): CreateBitcoinTx { + const tx = new CreateBitcoinTx(origin); + + const decoder = amountDecoder(origin.blockchain); - this.vkbPrice = this.amountFactory(DEFAULT_VKB_FEE); + tx.amount = decoder(plain.amount); + tx.target = plain.target; + tx.to = plain.to; + tx.vkbPrice = plain.vkbPrice; + tx.rebalance(); + + return tx; } - get change(): BigAmount { + get change(): SatoshiAny { return this.tx.change ?? this.zero; } - get fees(): BigAmount { - return this.totalToSpend.minus(this.change).minus(this.requiredAmount).max(this.zero); + get fees(): SatoshiAny { + return this.totalToSpend.minus(this.change).minus(this.amount).max(this.zero); } set feePrice(price: number) { - this.vkbPrice = this.amountFactory(price); + this.vkbPrice = price; this.rebalance(); } @@ -139,7 +162,7 @@ export class CreateBitcoinTx implements BitcoinTx { if (this.change.isPositive()) { result.push({ - address: this.changeAddress, + address: this.changeAddress ?? '', amount: this.change.number.toNumber(), entryId: this.entryId, }); @@ -148,13 +171,29 @@ export class CreateBitcoinTx implements BitcoinTx { return result; } - get requiredAmount(): BigAmount { + get amount(): SatoshiAny { return this.tx.amount ?? this.zero; } - set requiredAmount(value: BigAmount) { + set amount(value: SatoshiAny | BigNumber) { + this.setAmount(value); + } + + /** + * @deprecated + * Added to make one logic for Bitcoin and Ethereum flow. + * Use setter after refactoring Ethereum create transaction class. + */ + setAmount(value: SatoshiAny | BigNumber): void { this.tx.target = TxTarget.MANUAL; - this.tx.amount = value; + + if (SatoshiAny.is(value)) { + this.tx.amount = value; + } else { + const { units } = this.amount; + + this.tx.amount = new SatoshiAny(1, units).multiply(units.top.multiplier).multiply(value); + } this.rebalance(); } @@ -165,19 +204,19 @@ export class CreateBitcoinTx implements BitcoinTx { this.rebalance(); } - set toAddress(address: string | undefined) { + set to(address: string | undefined) { this.tx.to = address; this.rebalance(); } - get totalAvailable(): BigAmount { + get totalAvailable(): SatoshiAny { return this.utxo .map((item) => this.amountDecoder(item.value)) .reduce((carry, balance) => carry.plus(balance), this.zero); } - get totalToSpend(): BigAmount { + get totalToSpend(): SatoshiAny { return this.totalUtxo(this.tx.from); } @@ -185,7 +224,7 @@ export class CreateBitcoinTx implements BitcoinTx { return { ...this.tx }; } - create(): UnsignedBitcoinTx { + build(): UnsignedBitcoinTx { return { fee: this.fees.number.toNumber(), inputs: this.tx.from.map(({ address, sequence, txid, value, vout }) => ({ @@ -200,16 +239,26 @@ export class CreateBitcoinTx implements BitcoinTx { }; } - estimateFees(price: number): BigAmount { + dump(): BitcoinPlainTx { + return { + amount: this.amount.encode(), + blockchain: this.blockchain, + target: this.tx.target, + vkbPrice: this.vkbPrice, + to: this.tx.to, + }; + } + + estimateVkbPrice(fee: SatoshiAny): number { const size = this.metric.weightOf(this.tx.from, this.outputs); - return this.amountFactory(price).multiply(convertWUToVB(size)).divide(1024); + return fee.multiply(1024).divide(convertWUToVB(size)).number.toNumber(); } - estimateVkbPrice(fee: BigAmount): number { + getFees(): SatoshiAny { const size = this.metric.weightOf(this.tx.from, this.outputs); - return fee.multiply(1024).divide(convertWUToVB(size)).number.toNumber(); + return this.amountFactory(this.vkbPrice).multiply(convertWUToVB(size)).divide(1024); } rebalance(): boolean { @@ -244,7 +293,7 @@ export class CreateBitcoinTx implements BitcoinTx { send = this.totalUtxo(from); } - let change: BigAmount = this.zero; + let change: SatoshiAny = this.zero; if (this.tx.target === TxTarget.MANUAL) { if (amount == null || amount.isZero()) { @@ -255,9 +304,9 @@ export class CreateBitcoinTx implements BitcoinTx { // sending more that receive + fees ==> keep change const changeWeight = this.metric.weightOf(from, [ ...to, - { address: this.changeAddress, amount: 0, entryId: this.entryId }, + { address: this.changeAddress ?? '', amount: 0, entryId: this.entryId }, ]); - const changeFees = this.vkbPrice.multiply(convertWUToVB(changeWeight)).divide(1024); + const changeFees = this.amountFactory(this.vkbPrice).multiply(convertWUToVB(changeWeight)).divide(1024); change = send.minus(amount).minus(changeFees); @@ -267,7 +316,7 @@ export class CreateBitcoinTx implements BitcoinTx { } } } else { - amount = send.minus(fees); + amount = send.minus(fees).max(this.zero); this.tx.amount = amount; } @@ -278,13 +327,17 @@ export class CreateBitcoinTx implements BitcoinTx { return send.isGreaterOrEqualTo(amount); } - totalUtxo(utxo: InputUtxo[]): BigAmount { + totalUtxo(utxo: InputUtxo[]): SatoshiAny { return utxo .map((item) => this.amountDecoder(item.value)) .reduce((carry, balance) => carry.plus(balance), this.zero); } validate(): ValidationResult { + if (this.changeAddress == null) { + return ValidationResult.NO_CHANGE_ADDRESS; + } + if (this.tx.amount == null || this.tx.amount.isZero()) { this.tx.from = []; @@ -314,9 +367,9 @@ export class CreateBitcoinTx implements BitcoinTx { return sequence < MAX_SEQUENCE ? sequence + 1 : MAX_SEQUENCE; } - private estimateFeesFor(inputs: InputUtxo[], outputs: BitcoinTxOutput[]): BigAmount { + private estimateFeesFor(inputs: InputUtxo[], outputs: BitcoinTxOutput[]): SatoshiAny { const weight = this.metric.weightOf(inputs, outputs); - return this.vkbPrice.multiply(convertWUToVB(weight)).divide(1024); + return this.amountFactory(this.vkbPrice).multiply(convertWUToVB(weight)).divide(1024); } } diff --git a/packages/core/src/workflow/create-tx/CreateErc20ApproveTx.spec.ts b/packages/core/src/workflow/CreateErc20ApproveTx.spec.ts similarity index 98% rename from packages/core/src/workflow/create-tx/CreateErc20ApproveTx.spec.ts rename to packages/core/src/workflow/CreateErc20ApproveTx.spec.ts index c3ca520fc..48c38676a 100644 --- a/packages/core/src/workflow/create-tx/CreateErc20ApproveTx.spec.ts +++ b/packages/core/src/workflow/CreateErc20ApproveTx.spec.ts @@ -1,6 +1,6 @@ import { Wei } from '@emeraldpay/bigamount-crypto'; -import { BlockchainCode, INFINITE_ALLOWANCE, TokenData, TokenRegistry } from '../../blockchains'; -import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransactionType } from '../../transaction/ethereum'; +import { BlockchainCode, INFINITE_ALLOWANCE, TokenData, TokenRegistry } from '../blockchains'; +import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransactionType } from '../transaction/ethereum'; import { ApproveTarget, CreateErc20ApproveTx } from './CreateErc20ApproveTx'; import { ValidationResult } from './types'; diff --git a/packages/core/src/workflow/create-tx/CreateErc20ApproveTx.ts b/packages/core/src/workflow/CreateErc20ApproveTx.ts similarity index 97% rename from packages/core/src/workflow/create-tx/CreateErc20ApproveTx.ts rename to packages/core/src/workflow/CreateErc20ApproveTx.ts index 3fe4c90c4..4b535faf4 100644 --- a/packages/core/src/workflow/create-tx/CreateErc20ApproveTx.ts +++ b/packages/core/src/workflow/CreateErc20ApproveTx.ts @@ -7,9 +7,9 @@ import { TokenData, amountFactory, tokenAbi, -} from '../../blockchains'; -import { Contract } from '../../Contract'; -import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransaction, EthereumTransactionType } from '../../transaction/ethereum'; +} from '../blockchains'; +import { Contract } from '../Contract'; +import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransaction, EthereumTransactionType } from '../transaction/ethereum'; import { ValidationResult } from './types'; export enum ApproveTarget { diff --git a/packages/core/src/workflow/create-tx/CreateErc20Tx.spec.ts b/packages/core/src/workflow/CreateErc20Tx.spec.ts similarity index 95% rename from packages/core/src/workflow/create-tx/CreateErc20Tx.spec.ts rename to packages/core/src/workflow/CreateErc20Tx.spec.ts index e93f426d7..58ee33b09 100644 --- a/packages/core/src/workflow/create-tx/CreateErc20Tx.spec.ts +++ b/packages/core/src/workflow/CreateErc20Tx.spec.ts @@ -1,7 +1,7 @@ import { Wei } from '@emeraldpay/bigamount-crypto'; -import { BlockchainCode, TokenRegistry } from '../../blockchains'; +import { BlockchainCode, TokenRegistry } from '../blockchains'; import { CreateERC20Tx } from './CreateErc20Tx'; -import { TxDetailsPlain, TxTarget, ValidationResult } from './types'; +import { EthereumPlainTx, TxTarget, ValidationResult } from './types'; describe('CreateErc20Tx', () => { const tokenRegistry = new TokenRegistry([ @@ -21,13 +21,14 @@ describe('CreateErc20Tx', () => { it('creates tx', () => { const tx = new CreateERC20Tx(tokenRegistry, '0x6B175474E89094C44Da98b954EedeAC495271d0F', BlockchainCode.ETH); - expect(tx.validate()).toBe(ValidationResult.NO_FROM); + expect(tx.validate()).toBe(ValidationResult.NO_AMOUNT); expect(tx.target).toBe(TxTarget.MANUAL); expect(tx.amount.number.toString()).toBe('0'); }); it('invalid without from', () => { const tx = new CreateERC20Tx(tokenRegistry, '0x6B175474E89094C44Da98b954EedeAC495271d0F', BlockchainCode.ETH); + tx.amount = daiToken.getAmount(1); tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', Wei.fromEther(1), daiToken.getAmount(100)); tx.from = undefined; @@ -36,6 +37,7 @@ describe('CreateErc20Tx', () => { it('invalid without balance', () => { const tx = new CreateERC20Tx(tokenRegistry, '0x6B175474E89094C44Da98b954EedeAC495271d0F', BlockchainCode.ETH); + tx.amount = daiToken.getAmount(1); tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', Wei.fromEther(1), daiToken.getAmount(100)); tx.totalBalance = undefined; @@ -44,6 +46,7 @@ describe('CreateErc20Tx', () => { it('invalid without token balance', () => { const tx = new CreateERC20Tx(tokenRegistry, '0x6B175474E89094C44Da98b954EedeAC495271d0F', BlockchainCode.ETH); + tx.amount = daiToken.getAmount(1); tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', Wei.fromEther(1), daiToken.getAmount(100)); tx.totalTokenBalance = undefined; @@ -52,6 +55,7 @@ describe('CreateErc20Tx', () => { it('invalid without to', () => { const tx = new CreateERC20Tx(tokenRegistry, '0x6B175474E89094C44Da98b954EedeAC495271d0F', BlockchainCode.ETH); + tx.amount = daiToken.getAmount(1); tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', Wei.fromEther(1), daiToken.getAmount(100)); expect(tx.validate()).toBe(ValidationResult.NO_TO); @@ -227,7 +231,7 @@ describe('CreateErc20Tx', () => { const dump = tx.dump(); expect(dump.from).toBe('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD'); - expect(dump.totalEtherBalance).toBe('1000000000000000000/WEI'); + expect(dump.totalBalance).toBe('1000000000000000000/WEI'); expect(dump.totalTokenBalance).toBe('100/DAI'); expect(dump.to).toBe('0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd'); expect(dump.target).toBe(1); @@ -238,9 +242,8 @@ describe('CreateErc20Tx', () => { }); it('reads from dumps', () => { - const dump: TxDetailsPlain = { + const dump: EthereumPlainTx = { amount: '999580000000500002/DAI', - amountDecimals: 8, asset: '0x6B175474E89094C44Da98b954EedeAC495271d0F', blockchain: BlockchainCode.ETH, from: '0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', @@ -249,7 +252,7 @@ describe('CreateErc20Tx', () => { priorityGasPrice: '5007000000/WEI', target: 1, to: '0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd', - totalEtherBalance: '1000000000057/WEI', + totalBalance: '1000000000057/WEI', totalTokenBalance: '2000000000015/DAI', type: '0x2', }; diff --git a/packages/core/src/workflow/create-tx/CreateErc20Tx.ts b/packages/core/src/workflow/CreateErc20Tx.ts similarity index 89% rename from packages/core/src/workflow/create-tx/CreateErc20Tx.ts rename to packages/core/src/workflow/CreateErc20Tx.ts index e31e9d7d8..3b839aaf6 100644 --- a/packages/core/src/workflow/create-tx/CreateErc20Tx.ts +++ b/packages/core/src/workflow/CreateErc20Tx.ts @@ -1,11 +1,10 @@ import { BigAmount } from '@emeraldpay/bigamount'; import { WeiAny } from '@emeraldpay/bigamount-crypto'; import BigNumber from 'bignumber.js'; -import { DisplayErc20Tx, DisplayTx } from '..'; -import { BlockchainCode, Token, TokenRegistry, amountDecoder, amountFactory, tokenAbi } from '../../blockchains'; -import { Contract } from '../../Contract'; -import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransaction, EthereumTransactionType } from '../../transaction/ethereum'; -import { Tx, TxDetailsPlain, TxTarget, ValidationResult, targetFromNumber } from './types'; +import { BlockchainCode, Token, TokenRegistry, amountDecoder, amountFactory, tokenAbi } from '../blockchains'; +import { Contract } from '../Contract'; +import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransaction, EthereumTransactionType } from '../transaction/ethereum'; +import { EthereumPlainTx, EthereumTx, TxTarget, ValidationResult } from './types'; export interface ERC20TxDetails { amount: BigAmount; @@ -31,7 +30,7 @@ const TxDefaults: Omit WeiAny = amountDecoder(plain.blockchain); @@ -45,19 +44,18 @@ function fromPlainDetails(tokenRegistry: TokenRegistry, plain: TxDetailsPlain): gasPrice: plain.gasPrice == null ? undefined : decoder(plain.gasPrice), maxGasPrice: plain.maxGasPrice == null ? undefined : decoder(plain.maxGasPrice), priorityGasPrice: plain.priorityGasPrice == null ? undefined : decoder(plain.priorityGasPrice), - target: targetFromNumber(plain.target), + target: plain.target, to: plain.to, - totalBalance: plain.totalEtherBalance == null ? undefined : decoder(plain.totalEtherBalance), + totalBalance: plain.totalBalance == null ? undefined : decoder(plain.totalBalance), totalTokenBalance: plain.totalTokenBalance == null ? undefined : BigAmount.decode(plain.totalTokenBalance, units), transferFrom: plain.transferFrom, type: parseInt(plain.type, 16) === 2 ? EthereumTransactionType.EIP1559 : EthereumTransactionType.LEGACY, }; } -function toPlainDetails(tx: ERC20TxDetails): TxDetailsPlain { +function toPlainDetails(tx: ERC20TxDetails): EthereumPlainTx { return { amount: tx.amount.encode(), - amountDecimals: -1, asset: tx.asset, blockchain: tx.blockchain, from: tx.from, @@ -67,14 +65,14 @@ function toPlainDetails(tx: ERC20TxDetails): TxDetailsPlain { priorityGasPrice: tx.priorityGasPrice?.encode(), target: tx.target.valueOf(), to: tx.to, - totalEtherBalance: tx.totalBalance?.encode(), + totalBalance: tx.totalBalance?.encode(), totalTokenBalance: tx.totalTokenBalance?.encode(), transferFrom: tx.transferFrom, type: `0x${tx.type.toString(16)}`, }; } -export class CreateERC20Tx implements ERC20TxDetails, Tx { +export class CreateERC20Tx implements ERC20TxDetails, EthereumTx { public amount: BigAmount; public blockchain: BlockchainCode; public asset: string; @@ -147,7 +145,7 @@ export class CreateERC20Tx implements ERC20TxDetails, Tx { this.zeroTokenAmount = tokenRegistry.byAddress(this.blockchain, this.asset).getAmount(0); } - public static fromPlain(tokenRegistry: TokenRegistry, details: TxDetailsPlain): CreateERC20Tx { + public static fromPlain(tokenRegistry: TokenRegistry, details: EthereumPlainTx): CreateERC20Tx { return new CreateERC20Tx(tokenRegistry, fromPlainDetails(tokenRegistry, details)); } @@ -234,15 +232,15 @@ export class CreateERC20Tx implements ERC20TxDetails, Tx { }; } - public display(): DisplayTx { - return new DisplayErc20Tx(this, this.tokenRegistry); - } - - public dump(): TxDetailsPlain { + public dump(): EthereumPlainTx { return toPlainDetails(this); } public validate(): ValidationResult { + if (this.amount.isZero()) { + return ValidationResult.NO_AMOUNT; + } + if (this.from == null || this.totalTokenBalance == null || this.totalBalance == null) { return ValidationResult.NO_FROM; } diff --git a/packages/core/src/workflow/create-tx/CreateErc20WrappedTx.spec.ts b/packages/core/src/workflow/CreateErc20WrappedTx.spec.ts similarity index 98% rename from packages/core/src/workflow/create-tx/CreateErc20WrappedTx.spec.ts rename to packages/core/src/workflow/CreateErc20WrappedTx.spec.ts index fca1c3213..4296a01ff 100644 --- a/packages/core/src/workflow/create-tx/CreateErc20WrappedTx.spec.ts +++ b/packages/core/src/workflow/CreateErc20WrappedTx.spec.ts @@ -1,6 +1,6 @@ import { Wei } from '@emeraldpay/bigamount-crypto'; -import { BlockchainCode, TokenData, TokenRegistry, amountFactory } from '../../blockchains'; -import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransactionType } from '../../transaction/ethereum'; +import { BlockchainCode, TokenData, TokenRegistry, amountFactory } from '../blockchains'; +import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransactionType } from '../transaction/ethereum'; import { CreateErc20WrappedTx } from './CreateErc20WrappedTx'; import { TxTarget } from './types'; diff --git a/packages/core/src/workflow/create-tx/CreateErc20WrappedTx.ts b/packages/core/src/workflow/CreateErc20WrappedTx.ts similarity index 96% rename from packages/core/src/workflow/create-tx/CreateErc20WrappedTx.ts rename to packages/core/src/workflow/CreateErc20WrappedTx.ts index 25e6367dc..83007d9ab 100644 --- a/packages/core/src/workflow/create-tx/CreateErc20WrappedTx.ts +++ b/packages/core/src/workflow/CreateErc20WrappedTx.ts @@ -1,7 +1,7 @@ import { BigAmount } from '@emeraldpay/bigamount'; -import { BlockchainCode, Token, TokenAmount, TokenData, amountFactory, wrapTokenAbi } from '../../blockchains'; -import { Contract } from '../../Contract'; -import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransaction, EthereumTransactionType } from '../../transaction/ethereum'; +import { BlockchainCode, Token, TokenAmount, TokenData, amountFactory, wrapTokenAbi } from '../blockchains'; +import { Contract } from '../Contract'; +import { DEFAULT_GAS_LIMIT_ERC20, EthereumTransaction, EthereumTransactionType } from '../transaction/ethereum'; import { TxTarget, ValidationResult } from './types'; export interface Erc20WrappedTxDetails { diff --git a/packages/core/src/workflow/create-tx/CreateEthereumTx.spec.ts b/packages/core/src/workflow/CreateEthereumTx.spec.ts similarity index 96% rename from packages/core/src/workflow/create-tx/CreateEthereumTx.spec.ts rename to packages/core/src/workflow/CreateEthereumTx.spec.ts index 8c836a661..cb49f359c 100644 --- a/packages/core/src/workflow/create-tx/CreateEthereumTx.spec.ts +++ b/packages/core/src/workflow/CreateEthereumTx.spec.ts @@ -1,27 +1,29 @@ import { Wei } from '@emeraldpay/bigamount-crypto'; import BigNumber from 'bignumber.js'; -import { BlockchainCode } from '../../blockchains'; +import { BlockchainCode } from '../blockchains'; import { CreateEthereumTx } from './CreateEthereumTx'; -import { TxDetailsPlain, TxTarget, ValidationResult } from './types'; +import { EthereumPlainTx, TxTarget, ValidationResult } from './types'; describe('CreateEthereumTx', () => { it('creates tx', () => { const tx = new CreateEthereumTx(null, BlockchainCode.ETH); - expect(tx.validate()).toBe(ValidationResult.NO_FROM); - expect(tx.target).toBe(TxTarget.MANUAL); + expect(tx.validate()).toBe(ValidationResult.NO_AMOUNT); expect(tx.amount.equals(Wei.ZERO)).toBeTruthy(); + expect(tx.target).toBe(TxTarget.MANUAL); }); it('fails to validated if set only from', () => { const tx = new CreateEthereumTx(null, BlockchainCode.ETH); - tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', new Wei(1, 'ETHER')); + tx.setAmount(new Wei(1, 'ETHER')); + tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', new Wei(2, 'ETHER')); expect(tx.validate()).toBe(ValidationResult.NO_TO); }); it('fails to validated if set only to', () => { const tx = new CreateEthereumTx(null, BlockchainCode.ETH); + tx.amount = new Wei(1, 'ETHER'); tx.to = '0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd'; expect(tx.validate()).toBe(ValidationResult.NO_FROM); @@ -29,6 +31,7 @@ describe('CreateEthereumTx', () => { it('validates is set from/to', () => { const tx = new CreateEthereumTx(null, BlockchainCode.ETH); + tx.amount = new Wei(1, 'ETHER'); tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', new Wei(1, 'ETHER')); tx.to = '0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd'; @@ -285,20 +288,18 @@ describe('CreateEthereumTx', () => { const dump = tx.dump(); expect(dump.from).toBe('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD'); - expect(dump.totalEtherBalance).toBe('1000000000057/WEI'); + expect(dump.totalBalance).toBe('1000000000057/WEI'); expect(dump.to).toBe('0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd'); expect(dump.target).toBe(1); expect(dump.amount).toBe('999580000000500002/WEI'); - expect(dump.amountDecimals).toBe(18); expect(dump.maxGasPrice).toBe('10007000000/WEI'); expect(dump.priorityGasPrice).toBe('5007000000/WEI'); expect(dump.gas).toBe(42011); }); it('reads from dumps', () => { - const dump: TxDetailsPlain = { + const dump: EthereumPlainTx = { amount: '999580000000500002/WEI', - amountDecimals: 18, asset: 'ETH', blockchain: BlockchainCode.ETH, from: '0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', @@ -307,7 +308,7 @@ describe('CreateEthereumTx', () => { priorityGasPrice: '5007000000/WEI', target: 1, to: '0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd', - totalEtherBalance: '1000000000057/WEI', + totalBalance: '1000000000057/WEI', type: '0x2', }; @@ -324,9 +325,8 @@ describe('CreateEthereumTx', () => { }); it('reads from dumps, manual tx', () => { - const dump: TxDetailsPlain = { + const dump: EthereumPlainTx = { amount: '999580000000500002/WEI', - amountDecimals: 18, asset: 'ETH', blockchain: BlockchainCode.ETH, from: '0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', @@ -335,7 +335,7 @@ describe('CreateEthereumTx', () => { priorityGasPrice: '5007000000/WEI', target: 0, to: '0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd', - totalEtherBalance: '1000000000057/WEI', + totalBalance: '1000000000057/WEI', type: '0x2', }; diff --git a/packages/core/src/workflow/create-tx/CreateEthereumTx.ts b/packages/core/src/workflow/CreateEthereumTx.ts similarity index 87% rename from packages/core/src/workflow/create-tx/CreateEthereumTx.ts rename to packages/core/src/workflow/CreateEthereumTx.ts index 16cf1fa1a..b3dec3b6c 100644 --- a/packages/core/src/workflow/create-tx/CreateEthereumTx.ts +++ b/packages/core/src/workflow/CreateEthereumTx.ts @@ -1,10 +1,8 @@ -import { BigAmount } from '@emeraldpay/bigamount'; import { WeiAny } from '@emeraldpay/bigamount-crypto'; import BigNumber from 'bignumber.js'; -import { DisplayEtherTx, DisplayTx } from '..'; -import { BlockchainCode, amountDecoder, amountFactory } from '../../blockchains'; -import { DEFAULT_GAS_LIMIT, EthereumTransaction, EthereumTransactionType } from '../../transaction/ethereum'; -import { Tx, TxDetailsPlain, TxTarget, ValidationResult, targetFromNumber } from './types'; +import { BlockchainCode, amountDecoder, amountFactory } from '../blockchains'; +import { DEFAULT_GAS_LIMIT, EthereumTransaction, EthereumTransactionType } from '../transaction/ethereum'; +import { EthereumPlainTx, EthereumTx, TxTarget, ValidationResult } from './types'; export interface TxDetails { amount: WeiAny; @@ -25,7 +23,7 @@ const TxDefaults: Omit = { target: TxTarget.MANUAL, }; -function fromPlainDetails(plain: TxDetailsPlain): TxDetails { +function fromPlainDetails(plain: EthereumPlainTx): TxDetails { const decoder: (value: string) => WeiAny = amountDecoder(plain.blockchain); return { @@ -36,17 +34,16 @@ function fromPlainDetails(plain: TxDetailsPlain): TxDetails { gasPrice: plain.gasPrice == null ? undefined : (decoder(plain.gasPrice) as WeiAny), maxGasPrice: plain.maxGasPrice == null ? undefined : decoder(plain.maxGasPrice), priorityGasPrice: plain.priorityGasPrice == null ? undefined : decoder(plain.priorityGasPrice), - target: targetFromNumber(plain.target), + target: plain.target, to: plain.to, - totalBalance: plain.totalEtherBalance == null ? undefined : decoder(plain.totalEtherBalance), + totalBalance: plain.totalBalance == null ? undefined : decoder(plain.totalBalance), type: parseInt(plain.type, 16) === 2 ? EthereumTransactionType.EIP1559 : EthereumTransactionType.LEGACY, }; } -function toPlainDetails(tx: TxDetails): TxDetailsPlain { +function toPlainDetails(tx: TxDetails): EthereumPlainTx { return { amount: tx.amount.encode(), - amountDecimals: 18, asset: tx.amount.units.top.code, blockchain: tx.blockchain, from: tx.from, @@ -56,12 +53,12 @@ function toPlainDetails(tx: TxDetails): TxDetailsPlain { priorityGasPrice: tx.priorityGasPrice?.encode(), target: tx.target.valueOf(), to: tx.to, - totalEtherBalance: tx.totalBalance?.encode(), + totalBalance: tx.totalBalance?.encode(), type: `0x${tx.type.toString(16)}`, }; } -export class CreateEthereumTx implements TxDetails, Tx { +export class CreateEthereumTx implements TxDetails, EthereumTx { public amount: WeiAny; public blockchain: BlockchainCode; public from?: string; @@ -107,7 +104,7 @@ export class CreateEthereumTx implements TxDetails, Tx { this.zeroAmount = zeroAmount; } - public static fromPlain(details: TxDetailsPlain): CreateEthereumTx { + public static fromPlain(details: EthereumPlainTx): CreateEthereumTx { return new CreateEthereumTx(fromPlainDetails(details)); } @@ -170,15 +167,15 @@ export class CreateEthereumTx implements TxDetails, Tx { return { blockchain, from, gas, gasPrice, maxGasPrice, priorityGasPrice, to, type, value }; } - public display(): DisplayTx { - return new DisplayEtherTx(this); - } - - public dump(): TxDetailsPlain { + public dump(): EthereumPlainTx { return toPlainDetails(this); } public validate(): ValidationResult { + if (this.amount.isZero()) { + return ValidationResult.NO_AMOUNT; + } + if (this.from == null || this.totalBalance == null) { return ValidationResult.NO_FROM; } diff --git a/packages/core/src/workflow/CreateTxConverter.spec.ts b/packages/core/src/workflow/CreateTxConverter.spec.ts new file mode 100644 index 000000000..68d16a0af --- /dev/null +++ b/packages/core/src/workflow/CreateTxConverter.spec.ts @@ -0,0 +1,951 @@ +import { BigAmount } from '@emeraldpay/bigamount'; +import { Satoshi, Wei } from '@emeraldpay/bigamount-crypto'; +import { BitcoinEntry, EthereumEntry } from '@emeraldpay/emerald-vault-core'; +import { InputUtxo, TokenData, TokenRegistry, amountFactory, blockchainIdToCode } from '../blockchains'; +import { DEFAULT_GAS_LIMIT, EthereumTransactionType } from '../transaction/ethereum'; +import { DEFAULT_VKB_FEE } from './CreateBitcoinTx'; +import { CreateTxConverter, FeeRange } from './CreateTxConverter'; +import { TxTarget, isBitcoinCreateTx, isErc20CreateTx, isEthereumCreateTx } from './types'; + +describe('CreateTxConverter', () => { + const tokenData: TokenData = { + name: 'Wrapped Ether', + blockchain: 100, + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + symbol: 'WETH', + decimals: 18, + type: 'ERC20', + }; + + const tokenRegistry = new TokenRegistry([tokenData]); + + const btcEntry: BitcoinEntry = { + id: '7d395b44-0bac-49b9-98de-47e88dbc5a28-0', + address: { + type: 'xpub', + value: 'tb1', + }, + key: { + type: 'hd-path', + hdPath: "m/84'", + seedId: 'c782ff2b-ba6e-43e2-9e2d-92d05cc37b03', + }, + xpub: [ + { + role: 'change', + xpub: 'zpub1', + }, + { + role: 'receive', + xpub: 'zpub2', + }, + ], + blockchain: 1, + createdAt: new Date(), + addresses: [], + }; + const ethEntry1: EthereumEntry = { + id: '50391c5d-a517-4b7a-9c42-1411e0603d30-0', + address: { + type: 'single', + value: '0x0', + }, + key: { + type: 'hd-path', + hdPath: "m/44'", + seedId: 'c782ff2b-ba6e-43e2-9e2d-92d05cc37b03', + }, + blockchain: 100, + createdAt: new Date(), + }; + const ethEntry2: EthereumEntry = { + id: '50391c5d-a517-4b7a-9c42-1411e0603d30-1', + address: { + type: 'single', + value: '0x1', + }, + key: { + type: 'hd-path', + hdPath: "m/44'", + seedId: 'c782ff2b-ba6e-43e2-9e2d-92d05cc37b03', + }, + blockchain: 100, + createdAt: new Date(), + }; + const etcEntry: EthereumEntry = { + id: '50391c5d-a517-4b7a-9c42-1411e0603d30-2', + address: { + type: 'single', + value: '0x2', + }, + key: { + type: 'hd-path', + hdPath: "m/44'", + seedId: 'c782ff2b-ba6e-43e2-9e2d-92d05cc37b03', + }, + blockchain: 101, + createdAt: new Date(), + }; + + const btcToAddress = 'tb2'; + + const btcFeeRange: FeeRange = { + std: 2048, + min: 1024, + max: 3096, + }; + + const ethToAddress = '0x3'; + const ethOwnerAddress = '0x4'; + + const ethFeeRange: FeeRange = { + stdMaxGasPrice: new Wei(50), + highMaxGasPrice: new Wei(100), + lowMaxGasPrice: new Wei(10), + stdPriorityGasPrice: new Wei(5), + highPriorityGasPrice: new Wei(10), + lowPriorityGasPrice: new Wei(1), + }; + const zeroEthFeeRange: FeeRange = { + stdMaxGasPrice: Wei.ZERO, + highMaxGasPrice: Wei.ZERO, + lowMaxGasPrice: Wei.ZERO, + stdPriorityGasPrice: Wei.ZERO, + highPriorityGasPrice: Wei.ZERO, + lowPriorityGasPrice: Wei.ZERO, + }; + + function getBalance(entry: EthereumEntry, asset: string, ownerAddress?: string): BigAmount { + const blockchain = blockchainIdToCode(entry.blockchain); + + if (tokenRegistry.hasAddress(blockchain, asset)) { + const token = tokenRegistry.byAddress(blockchain, asset); + + if (ownerAddress == null) { + switch (entry.id) { + case ethEntry1.id: + return token.getAmount(110_000000); + case ethEntry2.id: + return token.getAmount(210_000000); + case etcEntry.id: + return token.getAmount(310_000000); + default: + return token.getAmount(0); + } + } + + switch (entry.id) { + case ethEntry1.id: + return token.getAmount(111_000000); + case ethEntry2.id: + return token.getAmount(211_000000); + case etcEntry.id: + return token.getAmount(311_000000); + default: + return token.getAmount(0); + } + } + + const factory = amountFactory(blockchain); + + switch (entry.id) { + case ethEntry1.id: + return factory(100_000000); + case ethEntry2.id: + return factory(200_000000); + case etcEntry.id: + return factory(300_000000); + default: + return factory(0); + } + } + + function getUtxo(entry: BitcoinEntry): InputUtxo[] { + if (entry.id === btcEntry.id) { + return [ + { txid: '1', address: '1', vout: 0, value: new Satoshi(11_0000000).encode() }, + { txid: '2', address: '2', vout: 0, value: new Satoshi(12_0000000).encode() }, + { txid: '3', address: '3', vout: 0, value: new Satoshi(13_0000000).encode() }, + ]; + } + + return []; + } + + it('create initial BTC tx', () => { + const { createTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'BTC', + entry: btcEntry, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isBitcoinCreateTx(createTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + const factory = amountFactory(blockchainIdToCode(btcEntry.blockchain)); + + expect(createTx.amount.equals(factory(0))).toBeTruthy(); + expect(createTx.tx.target).toEqual(TxTarget.MANUAL); + expect(createTx.fees.equals(factory(0))).toBeTruthy(); + expect(createTx.totalAvailable?.equals(factory(36_0000000))).toBeTruthy(); + expect(createTx.vkbPrice).toEqual(DEFAULT_VKB_FEE); + } + }); + + it('create initial ETH tx', () => { + const { createTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(createTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + const factory = amountFactory(blockchainIdToCode(ethEntry1.blockchain)); + + expect(createTx.amount.equals(factory(0))).toBeTruthy(); + expect(createTx.from).toEqual(ethEntry1.address?.value); + expect(createTx.target).toEqual(TxTarget.MANUAL); + expect(createTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(createTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(createTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + }); + + it('create initial ERC20 tx', () => { + const { createTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: tokenData.address, + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isErc20CreateTx(createTx, tokenRegistry); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + const blockchain = blockchainIdToCode(ethEntry1.blockchain); + + const factory = amountFactory(blockchain); + + const token = tokenRegistry.byAddress(blockchain, tokenData.address); + + expect(createTx.amount.equals(token.getAmount(0))).toBeTruthy(); + expect(createTx.from).toEqual(ethEntry1.address?.value); + expect(createTx.target).toEqual(TxTarget.MANUAL); + expect(createTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(createTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); + expect(createTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(createTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + }); + + it('create initial ETC tx', () => { + const { createTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETC', + entry: etcEntry, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(createTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + const factory = amountFactory(blockchainIdToCode(etcEntry.blockchain)); + + expect(createTx.amount.equals(factory(0))).toBeTruthy(); + expect(createTx.from).toEqual(etcEntry.address?.value); + expect(createTx.target).toEqual(TxTarget.MANUAL); + expect(createTx.totalBalance?.equals(factory(300_000000))).toBeTruthy(); + expect(createTx.type).toEqual(EthereumTransactionType.LEGACY); + + expect(createTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + }); + + it('create initial ERC20 tx with allowance', () => { + const { createTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + ownerAddress: ethOwnerAddress, + asset: tokenData.address, + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isErc20CreateTx(createTx, tokenRegistry); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + const blockchain = blockchainIdToCode(ethEntry1.blockchain); + + const factory = amountFactory(blockchain); + + const token = tokenRegistry.byAddress(blockchain, tokenData.address); + + expect(createTx.amount.equals(token.getAmount(0))).toBeTruthy(); + expect(createTx.from).toEqual(ethEntry1.address?.value); + expect(createTx.target).toEqual(TxTarget.MANUAL); + expect(createTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(createTx.totalTokenBalance?.equals(token.getAmount(111_000000))).toBeTruthy(); + expect(createTx.transferFrom).toEqual(ethOwnerAddress); + expect(createTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(createTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(createTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + }); + + it('change asset from ETH to ERC20 token', () => { + const { createTx: ethCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(ethCreateTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + ethCreateTx.amount = new Wei(1); + ethCreateTx.to = ethToAddress; + + const { createTx: erc20CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: tokenData.address, + entry: ethEntry1, + transaction: ethCreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isErc20CreateTx(erc20CreateTx, tokenRegistry); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const blockchain = blockchainIdToCode(ethEntry1.blockchain); + + const factory = amountFactory(blockchain); + + const token = tokenRegistry.byAddress(blockchain, tokenData.address); + + expect(erc20CreateTx.amount.equals(token.getAmount(0))).toBeTruthy(); + expect(erc20CreateTx.from).toEqual(ethEntry1.address?.value); + expect(erc20CreateTx.to).toEqual(ethToAddress); + expect(erc20CreateTx.target).toEqual(TxTarget.MANUAL); + expect(erc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(erc20CreateTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); + expect(erc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(erc20CreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(erc20CreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(erc20CreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('change asset from ETH to ERC20 token with max amount', () => { + const { createTx: ethCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(ethCreateTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + ethCreateTx.to = ethToAddress; + + ethCreateTx.target = TxTarget.SEND_ALL; + ethCreateTx.rebalance(); + + const { createTx: erc20CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: tokenData.address, + entry: ethEntry1, + transaction: ethCreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isErc20CreateTx(erc20CreateTx, tokenRegistry); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const blockchain = blockchainIdToCode(ethEntry1.blockchain); + + const factory = amountFactory(blockchain); + + const token = tokenRegistry.byAddress(blockchain, tokenData.address); + + expect(erc20CreateTx.amount.equals(token.getAmount(110_000000))).toBeTruthy(); + expect(erc20CreateTx.from).toEqual(ethEntry1.address?.value); + expect(erc20CreateTx.to).toEqual(ethToAddress); + expect(erc20CreateTx.target).toEqual(TxTarget.SEND_ALL); + expect(erc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(erc20CreateTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); + expect(erc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(erc20CreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(erc20CreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(erc20CreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('change asset from ERC20 token to ETH with max amount', () => { + const { createTx: erc20CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: tokenData.address, + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isErc20CreateTx(erc20CreateTx, tokenRegistry); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + erc20CreateTx.to = ethToAddress; + + erc20CreateTx.target = TxTarget.SEND_ALL; + erc20CreateTx.rebalance(); + + const { createTx: ethCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + transaction: erc20CreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isEthereumCreateTx(ethCreateTx); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const blockchain = blockchainIdToCode(ethEntry1.blockchain); + + const factory = amountFactory(blockchain); + + const fee = ethFeeRange.stdMaxGasPrice.multiply(DEFAULT_GAS_LIMIT); + + expect(ethCreateTx.amount.equals(factory(100_000000).minus(fee))).toBeTruthy(); + expect(ethCreateTx.from).toEqual(ethEntry1.address?.value); + expect(ethCreateTx.to).toEqual(ethToAddress); + expect(ethCreateTx.target).toEqual(TxTarget.SEND_ALL); + expect(ethCreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(ethCreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(ethCreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(ethCreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(ethCreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('change entry from BTC to ETH', () => { + const { createTx: btcCreateTx } = new CreateTxConverter( + { + asset: 'BTC', + entry: btcEntry, + feeRange: btcFeeRange, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isBitcoinCreateTx(btcCreateTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + btcCreateTx.amount = new Satoshi(1); + btcCreateTx.to = btcToAddress; + + const { createTx: ethCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + transaction: btcCreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isEthereumCreateTx(ethCreateTx); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const factory = amountFactory(blockchainIdToCode(ethEntry1.blockchain)); + + expect(ethCreateTx.amount.equals(factory(0))).toBeTruthy(); + expect(ethCreateTx.from).toEqual(ethEntry1.address?.value); + expect(ethCreateTx.to).not.toEqual(btcToAddress); + expect(ethCreateTx.target).toEqual(TxTarget.MANUAL); + expect(ethCreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(ethCreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(ethCreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(ethCreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(ethCreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('change entry from ETH to BTC', () => { + const { createTx: ethCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(ethCreateTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + ethCreateTx.amount = new Wei(1); + ethCreateTx.to = ethToAddress; + + const { createTx: btcCreateTx } = new CreateTxConverter( + { + asset: 'BTC', + entry: btcEntry, + feeRange: btcFeeRange, + transaction: ethCreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isBitcoinCreateTx(btcCreateTx); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const factory = amountFactory(blockchainIdToCode(btcEntry.blockchain)); + + expect(btcCreateTx.amount.equals(factory(0))).toBeTruthy(); + expect(btcCreateTx.tx.to).not.toEqual(ethToAddress); + expect(btcCreateTx.tx.target).toEqual(TxTarget.MANUAL); + expect(btcCreateTx.fees.equals(factory(0))).toBeTruthy(); + expect(btcCreateTx.totalAvailable?.equals(factory(36_0000000))).toBeTruthy(); + expect(btcCreateTx.vkbPrice).toEqual(btcFeeRange.std); + } + } + }); + + it('change entry from ETH to ETC', () => { + const { createTx: ethCreateTx } = new CreateTxConverter( + { + asset: 'ETH', + entry: ethEntry1, + feeRange: zeroEthFeeRange, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(ethCreateTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + ethCreateTx.amount = new Wei(1); + ethCreateTx.to = ethToAddress; + + const { createTx: etcCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETC', + entry: etcEntry, + transaction: ethCreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isEthereumCreateTx(etcCreateTx); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const factory = amountFactory(blockchainIdToCode(etcEntry.blockchain)); + + expect(etcCreateTx.amount.equals(factory(0))).toBeTruthy(); + expect(etcCreateTx.from).toEqual(etcEntry.address?.value); + expect(etcCreateTx.to).toEqual(ethToAddress); + expect(etcCreateTx.target).toEqual(TxTarget.MANUAL); + expect(etcCreateTx.totalBalance?.equals(factory(300_000000))).toBeTruthy(); + expect(etcCreateTx.type).toEqual(EthereumTransactionType.LEGACY); + + expect(etcCreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(etcCreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(etcCreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('change entry from ETH to other ETH', () => { + const { createTx: eth1CreateTx } = new CreateTxConverter( + { + asset: 'ETH', + entry: ethEntry1, + feeRange: zeroEthFeeRange, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(eth1CreateTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + eth1CreateTx.amount = new Wei(1); + eth1CreateTx.to = ethToAddress; + + const { createTx: eth2CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry2, + transaction: eth1CreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isEthereumCreateTx(eth2CreateTx); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const factory = amountFactory(blockchainIdToCode(ethEntry2.blockchain)); + + expect(eth2CreateTx.amount.equals(factory(1))).toBeTruthy(); + expect(eth2CreateTx.from).toEqual(ethEntry2.address?.value); + expect(eth2CreateTx.to).toEqual(ethToAddress); + expect(eth2CreateTx.target).toEqual(TxTarget.MANUAL); + expect(eth2CreateTx.totalBalance?.equals(factory(200_000000))).toBeTruthy(); + expect(eth2CreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(eth2CreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(eth2CreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(eth2CreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('restore ETH tx', () => { + const { createTx: ethCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isEthereumCreateTx(ethCreateTx); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + ethCreateTx.to = ethToAddress; + + ethCreateTx.target = TxTarget.SEND_ALL; + ethCreateTx.rebalance(); + + const { createTx: restoredEthCreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: 'ETH', + entry: ethEntry1, + transaction: ethCreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isEthereumCreateTx(restoredEthCreateTx); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const factory = amountFactory(blockchainIdToCode(ethEntry1.blockchain)); + + const fee = ethFeeRange.stdMaxGasPrice.multiply(DEFAULT_GAS_LIMIT); + + expect(restoredEthCreateTx.amount.equals(factory(100_000000).minus(fee))).toBeTruthy(); + expect(restoredEthCreateTx.from).toEqual(ethEntry1.address?.value); + expect(restoredEthCreateTx.to).toEqual(ethToAddress); + expect(restoredEthCreateTx.target).toEqual(TxTarget.SEND_ALL); + expect(restoredEthCreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(restoredEthCreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(restoredEthCreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(restoredEthCreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(restoredEthCreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('restore ERC20 tx', () => { + const { createTx: erc20CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: tokenData.address, + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isErc20CreateTx(erc20CreateTx, tokenRegistry); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + erc20CreateTx.to = ethToAddress; + + erc20CreateTx.target = TxTarget.SEND_ALL; + erc20CreateTx.rebalance(); + + const { createTx: restoredErc20CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: tokenData.address, + entry: ethEntry1, + transaction: erc20CreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isErc20CreateTx(restoredErc20CreateTx, tokenRegistry); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const blockchain = blockchainIdToCode(ethEntry1.blockchain); + + const factory = amountFactory(blockchain); + + const token = tokenRegistry.byAddress(blockchain, tokenData.address); + + expect(restoredErc20CreateTx.amount.equals(token.getAmount(110_000000))).toBeTruthy(); + expect(restoredErc20CreateTx.from).toEqual(ethEntry1.address?.value); + expect(restoredErc20CreateTx.to).toEqual(ethToAddress); + expect(restoredErc20CreateTx.target).toEqual(TxTarget.SEND_ALL); + expect(restoredErc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(restoredErc20CreateTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); + expect(restoredErc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(restoredErc20CreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(restoredErc20CreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(restoredErc20CreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); + + it('restore ERC20 tx with allowance', () => { + const { createTx: erc20CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + asset: tokenData.address, + entry: ethEntry1, + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isCorrectCreateTx = isErc20CreateTx(erc20CreateTx, tokenRegistry); + + expect(isCorrectCreateTx).toBeTruthy(); + + if (isCorrectCreateTx) { + erc20CreateTx.to = ethToAddress; + + erc20CreateTx.target = TxTarget.SEND_ALL; + erc20CreateTx.rebalance(); + + const { createTx: restoredErc20CreateTx } = new CreateTxConverter( + { + feeRange: ethFeeRange, + ownerAddress: ethOwnerAddress, + asset: tokenData.address, + entry: ethEntry1, + transaction: erc20CreateTx.dump(), + }, + { + getBalance, + getUtxo, + }, + tokenRegistry, + ); + + const isConvertedCreateTx = isErc20CreateTx(restoredErc20CreateTx, tokenRegistry); + + expect(isConvertedCreateTx).toBeTruthy(); + + if (isConvertedCreateTx) { + const blockchain = blockchainIdToCode(ethEntry1.blockchain); + + const factory = amountFactory(blockchain); + + const token = tokenRegistry.byAddress(blockchain, tokenData.address); + + expect(restoredErc20CreateTx.amount.equals(token.getAmount(111_000000))).toBeTruthy(); + expect(restoredErc20CreateTx.from).toEqual(ethEntry1.address?.value); + expect(restoredErc20CreateTx.to).toEqual(ethToAddress); + expect(restoredErc20CreateTx.target).toEqual(TxTarget.SEND_ALL); + expect(restoredErc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); + expect(restoredErc20CreateTx.totalTokenBalance?.equals(token.getAmount(111_000000))).toBeTruthy(); + expect(restoredErc20CreateTx.transferFrom).toEqual(ethOwnerAddress); + expect(restoredErc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); + + expect(restoredErc20CreateTx.gasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(restoredErc20CreateTx.maxGasPrice?.equals(ethFeeRange.stdMaxGasPrice)).toBeTruthy(); + expect(restoredErc20CreateTx.priorityGasPrice?.equals(ethFeeRange.stdPriorityGasPrice)).toBeTruthy(); + } + } + }); +}); diff --git a/packages/core/src/workflow/CreateTxConverter.ts b/packages/core/src/workflow/CreateTxConverter.ts new file mode 100644 index 000000000..159ac049f --- /dev/null +++ b/packages/core/src/workflow/CreateTxConverter.ts @@ -0,0 +1,268 @@ +import { BigAmount } from '@emeraldpay/bigamount'; +import { WeiAny } from '@emeraldpay/bigamount-crypto'; +import { BitcoinEntry, WalletEntry, isBitcoinEntry, isEthereumEntry } from '@emeraldpay/emerald-vault-core'; +import { + Blockchains, + InputUtxo, + TokenRegistry, + amountFactory, + blockchainIdToCode, + isBitcoin, + isEthereum, +} from '../blockchains'; +import { EthereumTransactionType } from '../transaction/ethereum'; +import { BitcoinTxOrigin, CreateBitcoinTx } from './CreateBitcoinTx'; +import { CreateERC20Tx } from './CreateErc20Tx'; +import { CreateEthereumTx } from './CreateEthereumTx'; +import { + AnyCreateTx, + AnyEthereumCreateTx, + AnyPlainTx, + BitcoinPlainTx, + EthereumPlainTx, + TxTarget, + isBitcoinPlainTx, + isErc20CreateTx, +} from './types'; + +export interface BitcoinFeeRange { + std: number; + min: number; + max: number; +} + +export interface EthereumFeeRange { + stdMaxGasPrice: T; + lowMaxGasPrice: T; + highMaxGasPrice: T; + stdPriorityGasPrice: T; + lowPriorityGasPrice: T; + highPriorityGasPrice: T; +} + +export type FeeRange = BitcoinFeeRange | EthereumFeeRange; + +interface ConverterOrigin { + asset: string; + changeAddress?: string; + entry: WalletEntry; + feeRange: FeeRange; + ownerAddress?: string; + transaction?: BitcoinPlainTx | EthereumPlainTx; +} + +interface DataProvider { + getBalance(entry: WalletEntry, asset: string, ownerAddress?: string): BigAmount; + getUtxo(entry: BitcoinEntry): InputUtxo[]; +} + +export class CreateTxConverter implements ConverterOrigin { + readonly asset: string; + readonly changeAddress?: string; + readonly entry: WalletEntry; + readonly feeRange: FeeRange; + readonly ownerAddress?: string; + readonly transaction?: BitcoinPlainTx | EthereumPlainTx; + + private readonly dataProvider: DataProvider; + private readonly tokenRegistry: TokenRegistry; + + constructor(origin: ConverterOrigin, dataProvider: DataProvider, tokenRegistry: TokenRegistry) { + const { asset, changeAddress, entry, feeRange, ownerAddress, transaction } = origin; + + this.asset = asset; + this.changeAddress = changeAddress; + this.entry = entry; + this.feeRange = feeRange; + this.ownerAddress = ownerAddress; + this.transaction = transaction; + + this.dataProvider = dataProvider; + this.tokenRegistry = tokenRegistry; + } + + static fromPlainTx(transaction: AnyPlainTx, origin: BitcoinTxOrigin, tokenRegistry: TokenRegistry): AnyCreateTx { + if (isBitcoinPlainTx(transaction)) { + return CreateTxConverter.fromBitcoinPlainTx(origin, transaction); + } + + return CreateTxConverter.fromEthereumPlainTx(transaction, tokenRegistry); + } + + static fromBitcoinPlainTx(origin: BitcoinTxOrigin, transaction: BitcoinPlainTx): CreateBitcoinTx { + return CreateBitcoinTx.fromPlain(origin, transaction); + } + + static fromEthereumPlainTx(transaction: EthereumPlainTx, tokenRegistry: TokenRegistry): AnyEthereumCreateTx { + if (tokenRegistry.hasAddress(transaction.blockchain, transaction.asset)) { + return CreateERC20Tx.fromPlain(tokenRegistry, transaction); + } + + return CreateEthereumTx.fromPlain(transaction); + } + + static isBitcoinFeeRange(feeRange: unknown): feeRange is BitcoinFeeRange { + return feeRange != null && typeof feeRange === 'object' && 'std' in feeRange && typeof feeRange.std === 'number'; + } + + static isEthereumFeeRange(feeRange: unknown): feeRange is EthereumFeeRange { + return ( + feeRange != null && + typeof feeRange === 'object' && + 'stdMaxGasPrice' in feeRange && + feeRange.stdMaxGasPrice != null + ); + } + + get createTx(): AnyCreateTx { + const { asset, changeAddress, entry, feeRange, ownerAddress, tokenRegistry, transaction } = this; + const { getBalance, getUtxo } = this.dataProvider; + + const blockchain = blockchainIdToCode(entry.blockchain); + const { coinTicker, eip1559: supportEip1559 = false } = Blockchains[blockchain].params; + + let createTx: AnyCreateTx; + + if ( + transaction == null || + (isBitcoin(blockchain) && isEthereum(transaction.blockchain)) || + (isEthereum(blockchain) && isBitcoin(transaction.blockchain)) + ) { + if (isBitcoinEntry(entry)) { + createTx = new CreateBitcoinTx({ + blockchain, + changeAddress, + entryId: entry.id, + utxo: getUtxo(entry), + }); + + if (CreateTxConverter.isBitcoinFeeRange(feeRange)) { + createTx.feePrice = feeRange.std; + } + } else { + if (tokenRegistry.hasAddress(blockchain, asset)) { + createTx = new CreateERC20Tx(tokenRegistry, asset, blockchain); + createTx.totalBalance = getBalance(entry, coinTicker) as WeiAny; + createTx.totalTokenBalance = getBalance(entry, asset, ownerAddress); + createTx.transferFrom = ownerAddress; + } else { + createTx = new CreateEthereumTx(null, blockchain); + createTx.totalBalance = getBalance(entry, asset) as WeiAny; + } + + createTx.from = entry.address?.value; + + if (CreateTxConverter.isEthereumFeeRange(feeRange)) { + createTx.gasPrice = feeRange.stdMaxGasPrice; + createTx.maxGasPrice = feeRange.stdMaxGasPrice; + createTx.priorityGasPrice = feeRange.stdPriorityGasPrice; + } + + createTx.type = supportEip1559 ? EthereumTransactionType.EIP1559 : EthereumTransactionType.LEGACY; + } + } else { + if (isBitcoinPlainTx(transaction)) { + if (isEthereumEntry(entry)) { + throw new Error('Ethereum entry provided for Bitcoin transaction'); + } + + createTx = CreateTxConverter.fromBitcoinPlainTx( + { + blockchain, + changeAddress, + entryId: entry.id, + utxo: getUtxo(entry), + }, + transaction, + ); + } else { + if (isBitcoinEntry(entry)) { + throw new Error('Bitcoin entry provided for Ethereum transaction'); + } + + createTx = CreateTxConverter.fromEthereumPlainTx(transaction, tokenRegistry); + + if (asset !== createTx.getAsset() || blockchain !== createTx.blockchain) { + const type = supportEip1559 ? createTx.type : EthereumTransactionType.LEGACY; + + let newCreateTx: AnyEthereumCreateTx; + + if (tokenRegistry.hasAddress(blockchain, asset)) { + newCreateTx = new CreateERC20Tx(tokenRegistry, asset, blockchain, type); + newCreateTx.totalBalance = getBalance(entry, coinTicker) as WeiAny; + newCreateTx.totalTokenBalance = getBalance(entry, asset, newCreateTx.transferFrom); + + newCreateTx.transferFrom = isErc20CreateTx(createTx, tokenRegistry) + ? createTx.transferFrom ?? ownerAddress + : ownerAddress; + } else { + newCreateTx = new CreateEthereumTx(null, blockchain, type); + newCreateTx.totalBalance = getBalance(entry, asset) as WeiAny; + } + + newCreateTx.from = entry.address?.value; + newCreateTx.to = createTx.to; + + if (blockchain === createTx.blockchain && (createTx.gasPrice?.isPositive() ?? false)) { + newCreateTx.gasPrice = createTx.gasPrice; + newCreateTx.maxGasPrice = createTx.maxGasPrice; + newCreateTx.priorityGasPrice = createTx.priorityGasPrice; + } else if (CreateTxConverter.isEthereumFeeRange(feeRange)) { + newCreateTx.gasPrice = feeRange.stdMaxGasPrice; + newCreateTx.maxGasPrice = feeRange.stdMaxGasPrice; + newCreateTx.priorityGasPrice = feeRange.stdPriorityGasPrice; + } + + if (createTx.target === TxTarget.SEND_ALL) { + newCreateTx.target = TxTarget.SEND_ALL; + + if (!newCreateTx.rebalance()) { + newCreateTx.target = TxTarget.MANUAL; + + newCreateTx.amount = isErc20CreateTx(newCreateTx, tokenRegistry) + ? tokenRegistry.byAddress(blockchain, newCreateTx.getAsset()).getAmount(0) + : amountFactory(blockchain)(0); + } + } + + return newCreateTx; + } + + const gasPrice = createTx.gasPrice ?? createTx.maxGasPrice; + + if ((gasPrice?.isZero() ?? true) && CreateTxConverter.isEthereumFeeRange(feeRange)) { + createTx.gasPrice = feeRange.stdMaxGasPrice; + createTx.maxGasPrice = feeRange.stdMaxGasPrice; + createTx.priorityGasPrice = feeRange.stdPriorityGasPrice; + } + + if ( + transaction.from !== entry.address?.value || + transaction.transferFrom !== ownerAddress || + (transaction.transferFrom == null && ownerAddress != null) + ) { + createTx.from = entry.address?.value; + + if (isErc20CreateTx(createTx, tokenRegistry)) { + createTx.transferFrom = ownerAddress; + + createTx.totalBalance = getBalance(entry, coinTicker) as WeiAny; + createTx.totalTokenBalance = getBalance(entry, asset, createTx.transferFrom); + } else { + createTx.totalBalance = getBalance(entry, asset) as WeiAny; + } + + if (createTx.target === TxTarget.SEND_ALL && !createTx.rebalance()) { + createTx.target = TxTarget.MANUAL; + + createTx.amount = isErc20CreateTx(createTx, tokenRegistry) + ? tokenRegistry.byAddress(blockchain, createTx.getAsset()).getAmount(0) + : amountFactory(blockchain)(0); + } + } + } + } + + return createTx; + } +} diff --git a/packages/core/src/workflow/TxSigner.ts b/packages/core/src/workflow/TxSigner.ts new file mode 100644 index 000000000..201d5de47 --- /dev/null +++ b/packages/core/src/workflow/TxSigner.ts @@ -0,0 +1,172 @@ +import { + AddressRole, + CurrentAddress, + EntryId, + SignedTx, + UnsignedBasicEthereumTx, + UnsignedBitcoinTx, + UnsignedEIP1559EthereumTx, + UnsignedEthereumTx, + UnsignedTx, + WalletEntry, + isBitcoinEntry, + isBitcoinTx, + isEthereumTx, +} from '@emeraldpay/emerald-vault-core'; +import { Transaction as BitcoinTx } from 'bitcoinjs-lib'; +import { BlockchainCode, Blockchains } from '../blockchains'; +import { EthereumTx } from '../blockchains/ethereum'; +import { EthereumAddress } from '../blockchains/ethereum/EthereumAddress'; +import { EthereumTransaction, EthereumTransactionType } from '../transaction/ethereum'; +import { AnyCreateTx, isBitcoinCreateTx } from './types'; + +interface SignerOrigin { + createTx: AnyCreateTx; + entry: WalletEntry; + password?: string; +} + +interface DataProvider { + getNonce(blockchain: BlockchainCode, from: string): Promise; + getXPubPosition(xpub: string): Promise; + listEntryAddresses(entryId: EntryId, role: AddressRole, start: number, limit: number): Promise; +} + +interface Handler { + setXPubCurrentIndex(xpub: string, position: number): Promise; + signTx(unsiged: UnsignedTx, entryId: EntryId, password?: string): Promise; +} + +export class TxSigner implements SignerOrigin { + readonly createTx: AnyCreateTx; + readonly entry: WalletEntry; + readonly password?: string; + + private readonly dataProvider: DataProvider; + private readonly handler: Handler; + + constructor({ createTx, entry, password }: SignerOrigin, dataProvider: DataProvider, handler: Handler) { + this.createTx = createTx; + this.entry = entry; + this.password = password; + + this.dataProvider = dataProvider; + this.handler = handler; + } + + static convertEthereumTx(transaction: EthereumTransaction): UnsignedEthereumTx { + const { from, gas, gasPrice, maxGasPrice, priorityGasPrice, data, nonce, to, type, value } = transaction; + + let gasPrices: + | Pick + | Pick; + + if (type === EthereumTransactionType.EIP1559) { + gasPrices = { + maxGasPrice: maxGasPrice?.number.toString() ?? '0', + priorityGasPrice: priorityGasPrice?.number.toString() ?? '0', + }; + } else { + gasPrices = { + gasPrice: gasPrice?.number.toString() ?? '0', + }; + } + + return { + ...gasPrices, + data, + from, + gas, + to, + nonce: nonce ?? 0, + value: value.number.toString(), + }; + } + + async sign(): Promise { + const { createTx, entry, password } = this; + const { getNonce } = this.dataProvider; + const { signTx } = this.handler; + + let unsigned: UnsignedTx; + + if (isBitcoinCreateTx(createTx)) { + unsigned = createTx.build(); + } else { + unsigned = TxSigner.convertEthereumTx(createTx.build()); + } + + if (isEthereumTx(unsigned)) { + unsigned.nonce = await getNonce(createTx.blockchain, unsigned.from); + } + + const signedTx = await signTx(unsigned, entry.id, password); + + this.verifySigned(signedTx.raw); + + if (isBitcoinTx(unsigned)) { + this.updateXPubIndex(unsigned); + } + + return signedTx; + } + + private verifySigned(raw: string): void { + const { createTx } = this; + + if (isBitcoinCreateTx(createTx)) { + const transaction = BitcoinTx.fromHex(raw); + + const correctInputs = transaction.ins.every(({ hash }) => { + const txId = hash.reverse().toString('hex'); + + return createTx.utxo.some(({ txid }) => txid === txId); + }); + + if (!correctInputs) { + throw new Error('Emerald Vault returned signature from wrong Sender'); + } + } else { + const { chainId } = Blockchains[createTx.blockchain].params; + + let transaction; + + try { + transaction = EthereumTx.fromRaw(raw, chainId); + } catch (exception) { + throw new Error('Emerald Vault returned invalid signature for the transaction'); + } + + if (transaction.verifySignature()) { + const { from } = createTx; + + if (from != null && !transaction.getSenderAddress().equals(new EthereumAddress(from))) { + throw new Error('Emerald Vault returned signature from wrong Sender'); + } + } else { + throw new Error('Emerald Vault returned invalid signature for the transaction'); + } + } + } + + private async updateXPubIndex(unsigned: UnsignedBitcoinTx): Promise { + const { entry } = this; + const { getXPubPosition, listEntryAddresses } = this.dataProvider; + const { setXPubCurrentIndex } = this.handler; + + if (isBitcoinEntry(entry)) { + const changeXPub = entry.xpub.find(({ role }) => role === 'change'); + + if (changeXPub != null) { + const index = await getXPubPosition(changeXPub.xpub); + const [{ address: changeAddress }] = await listEntryAddresses(entry.id, 'change', index, 1); + + const output = unsigned.outputs.find(({ address }) => address === changeAddress); + + if (output != null) { + await setXPubCurrentIndex(changeXPub.xpub, index); + } + } + } + } +} diff --git a/packages/core/src/workflow/create-tx/CreateBitcoinTx.spec.ts b/packages/core/src/workflow/create-tx/CreateBitcoinTx.spec.ts deleted file mode 100644 index 4a231b3ba..000000000 --- a/packages/core/src/workflow/create-tx/CreateBitcoinTx.spec.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { SATOSHIS, Satoshi } from '@emeraldpay/bigamount-crypto'; -import { BitcoinEntry } from '@emeraldpay/emerald-vault-core'; -import { InputUtxo } from '../../blockchains'; -import { BitcoinTxMetric, BitcoinTxOutput, CreateBitcoinTx, convertWUToVB } from './CreateBitcoinTx'; -import { TxTarget, ValidationResult } from './types'; - -const basicEntry: BitcoinEntry = { - id: 'f76416d7-3510-4d80-85df-52e7222e56df-1', - blockchain: 1, - createdAt: new Date(), - address: undefined, - addresses: [], - key: undefined, - xpub: [], -}; - -const restoreEntry: BitcoinEntry = { - id: '2a19e023-f119-4dab-b2cb-4b3e73fa32c9-1', - blockchain: 1, - createdAt: new Date(), - address: undefined, - addresses: [], - key: undefined, - xpub: [], -}; - -class TestMetric implements BitcoinTxMetric { - readonly inputWeight: number; - readonly outputWeight: number; - - constructor(inputWeight: number, outputWeight: number) { - this.inputWeight = inputWeight; - this.outputWeight = outputWeight; - } - - weight(inputs: number, outputs: number): number { - return inputs * this.inputWeight + outputs * this.outputWeight; - } - - weightOf(inputs: InputUtxo[], outputs: BitcoinTxOutput[]): number { - return this.weight(inputs.length, outputs.length); - } - - fees(inputs: number, outputs: number, create: CreateBitcoinTx): number { - return create.vkbPrice - .multiply(convertWUToVB(this.weight(inputs, outputs))) - .number.dividedBy(SATOSHIS.top.multiplier) - .dividedBy(1024) - .toNumber(); - } -} - -const defaultMetric = new TestMetric(120, 80); - -describe('CreateBitcoinTx', () => { - const defaultBitcoin = new CreateBitcoinTx(basicEntry, 'addrchange', []); - - defaultBitcoin.metric = defaultMetric; - defaultBitcoin.feePrice = 100; - - it('create', () => { - const act = new CreateBitcoinTx(basicEntry, 'addrchange', []); - - expect(act).toBeDefined(); - expect(act.totalToSpend).toBeDefined(); - expect(act.totalToSpend.isZero()).toBeTruthy(); - expect(act.validate()).not.toBe(ValidationResult.OK); - }); - - it('total zero for empty utxo', () => expect(defaultBitcoin.totalUtxo([]).toString()).toBe(Satoshi.ZERO.toString())); - - it('total for single utxo', () => - expect( - defaultBitcoin - .totalUtxo([ - { - address: 'ADDR', - txid: '', - value: Satoshi.fromBitcoin(0.5).encode(), - vout: 0, - }, - ]) - .toString(), - ).toBe(Satoshi.fromBitcoin(0.5).toString())); - - it('total for few utxo', () => - expect( - defaultBitcoin - .totalUtxo([ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.5).encode(), address: 'ADDR' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.61).encode(), address: 'ADDR' }, - { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.756).encode(), address: 'ADDR' }, - ]) - .toString(), - ).toBe(Satoshi.fromBitcoin(0.5 + 0.61 + 0.756).toString())); - - it('rebalance when have enough', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrchange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.5).encode(), address: 'ADDR' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.61).encode(), address: 'ADDR' }, - { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.756).encode(), address: 'ADDR' }, - ]); - - create.metric = defaultMetric; - - create.toAddress = 'AAA'; - create.feePrice = 100 * 1024; - create.requiredAmount = Satoshi.fromBitcoin(0.97); - - const rebalanced = create.rebalance(); - - expect(rebalanced).toBeTruthy(); - - expect(create.transaction.from.length).toBe(2); - expect(create.transaction.from[0].txid).toBe('1'); - expect(create.transaction.from[1].txid).toBe('2'); - - // sending + change - expect(create.outputs.length).toBe(2); - - // 100 sat per wu, ((2 * 120) + (2 * 80)) * 100 / 4 - expect(create.fees.number.toNumber()).toBe(10000); - - expect(create.fees.getNumberByUnit(SATOSHIS.top).toNumber()).toBe( - defaultMetric.fees(2, create.outputs.length, create), - ); - - // 40000 / 10^8 = 0.0004 - expect(create.change.toString()).toBe(Satoshi.fromBitcoin(0.5 + 0.61 - 0.97 - 0.0001).toString()); - expect(create.totalToSpend.toString()).toBe(Satoshi.fromBitcoin(0.5 + 0.61).toString()); - - expect(create.validate()).toBe(ValidationResult.OK); - }); - - it('rebalance when less that enough', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.5).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.61).encode(), address: 'addr2' }, - { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.756).encode(), address: 'addr3' }, - ]); - - create.toAddress = 'addrTo'; - create.requiredAmount = Satoshi.fromBitcoin(2); - - const ok = create.rebalance(); - - expect(ok).toBeFalsy(); - - expect(create.transaction.from.length).toBe(3); - expect(create.transaction.from[0].txid).toBe('1'); - expect(create.transaction.from[1].txid).toBe('2'); - expect(create.transaction.from[2].txid).toBe('3'); - - expect(create.change.toString()).toBe(Satoshi.ZERO.toString()); - expect(create.totalToSpend.toString()).toBe(Satoshi.fromBitcoin(0.5 + 0.61 + 0.756).toString()); - - expect(create.validate()).toBe(ValidationResult.INSUFFICIENT_FUNDS); - }); - - it('rebalance when no change', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr2' }, - { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr3' }, - { txid: '4', vout: 0, value: Satoshi.fromBitcoin(0.005).encode(), address: 'addr4' }, - ]); - - create.metric = defaultMetric; - - create.toAddress = 'addrTo'; - create.feePrice = 65 * 1024; - create.requiredAmount = Satoshi.fromBitcoin(0.02 - 0.00008); - - const ok = create.rebalance(); - - expect(ok).toBeTruthy(); - - expect(create.transaction.from.length).toBe(4); - expect(create.change.toString()).toBe(Satoshi.ZERO.toString()); - expect(create.totalToSpend.toString()).toBe(Satoshi.fromBitcoin(0.02).toString()); - - expect(create.outputs.length).toBe(1); - expect(create.outputs[0].address).toBe('addrTo'); - expect(create.outputs[0].amount).toBe(1992000); - - // ((4 * 120) + (1 * 80)) * 65 / 4 == 9100 (or 0.000091), but it doesn't have enough change, only 0.00008 - expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.00008).toString()); - - expect(create.validate()).toBe(ValidationResult.OK); - }); - - it('rebalance with send all target', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, - ]); - - create.metric = defaultMetric; - - create.feePrice = 100 * 1024; - create.toAddress = 'addrTo'; - create.target = TxTarget.SEND_ALL; - - const ok = create.rebalance(); - - expect(ok).toBeTruthy(); - - expect(create.requiredAmount.number.toNumber()).toEqual(9992000); - }); - - it('simple fee', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, - ]); - - create.metric = defaultMetric; - - create.feePrice = 100 * 1024; - create.requiredAmount = Satoshi.fromBitcoin(0.08); - create.toAddress = 'addrTo'; - - // ((2 * 120) + (2 * 80)) * 100 / 4== 10000 - expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.0001).toString()); - }); - - it('fee when not enough', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, - ]); - - create.metric = defaultMetric; - - create.requiredAmount = Satoshi.fromBitcoin(2); - create.toAddress = 'addrTo'; - - expect(create.fees.toString()).toBe(Satoshi.ZERO.toString()); - }); - - it('update fee', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, - ]); - - create.metric = defaultMetric; - - create.feePrice = 100 * 1024; - create.requiredAmount = Satoshi.fromBitcoin(0.08); - create.toAddress = 'addrTo'; - - // ((2 * 120) + (2 * 80)) * 100 / 4 - expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.0001).toString()); - - create.feePrice = 150 * 1024; - - // ((2 * 120) + (2 * 80)) * 150 / 4 - expect(create.fees.toString()).toBe(Satoshi.fromBitcoin(0.00015).toString()); - }); - - it('estimate fees', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, - ]); - - create.metric = defaultMetric; - - create.requiredAmount = Satoshi.fromBitcoin(0.08); - create.toAddress = 'addrTo'; - - // ((2 * 120) + (2 * 80)) * 100 * 1024 / 4 == 10000 - expect(create.estimateFees(100 * 1024).toString()).toBe(Satoshi.fromBitcoin(0.0001).toString()); - expect(create.estimateFees(150 * 1024).toString()).toBe(Satoshi.fromBitcoin(0.00015).toString()); - expect(create.estimateFees(200 * 1024).toString()).toBe(Satoshi.fromBitcoin(0.0002).toString()); - expect(create.estimateFees(2000 * 1024).toString()).toBe(Satoshi.fromBitcoin(0.002).toString()); - }); - - it('estimate price', () => { - const tx = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 1, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, - ]); - - tx.metric = defaultMetric; - tx.toAddress = 'addrTo'; - tx.requiredAmount = Satoshi.fromBitcoin(0.08); - - expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.0001))).toEqual(100 * 1024); - expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.00015))).toEqual(150 * 1024); - expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.0002))).toEqual(200 * 1024); - expect(tx.estimateVkbPrice(Satoshi.fromBitcoin(0.002))).toEqual(2000 * 1024); - }); - - it('total available', () => { - let create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - ]); - - expect(create.totalAvailable.toString()).toBe(Satoshi.fromBitcoin(0.05).toString()); - - create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr2' }, - ]); - - expect(create.totalAvailable.toString()).toBe(Satoshi.fromBitcoin(0.1).toString()); - - create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: Satoshi.fromBitcoin(0.05).encode(), address: 'addr1' }, - { txid: '2', vout: 0, value: Satoshi.fromBitcoin(0.06).encode(), address: 'addr2' }, - { txid: '3', vout: 0, value: Satoshi.fromBitcoin(0.07).encode(), address: 'addr3' }, - ]); - - expect(create.totalAvailable.toString()).toBe(Satoshi.fromBitcoin(0.18).toString()); - }); - - it('creates unsigned', () => { - const create = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: new Satoshi(112233).encode(), address: 'addr1' }, - ]); - - create.metric = defaultMetric; - - create.feePrice = 100 * 1024; - create.requiredAmount = new Satoshi(80000); - create.toAddress = 'addrTo'; - - const unsigned = create.create(); - - expect(unsigned.inputs.length).toBe(1); - expect(unsigned.inputs[0]).toEqual({ - address: 'addr1', - amount: 112233, - sequence: 4294967280, - txid: '1', - vout: 0, - entryId: 'f76416d7-3510-4d80-85df-52e7222e56df-1', - }); - - // ((1 * 120) + (2 * 80)) * 100 / 4 - expect(unsigned.fee).toBe(7000); - - expect(unsigned.outputs.length).toBe(2); - expect(unsigned.outputs[0]).toEqual({ - address: 'addrTo', - amount: 80000, - }); - expect(unsigned.outputs[1]).toEqual({ - address: 'addrChange', - amount: 112233 - 80000 - 7000, - entryId: 'f76416d7-3510-4d80-85df-52e7222e56df-1', - }); - }); - - it('creates restored', () => { - const tx = new CreateBitcoinTx(restoreEntry, 'tb1q8grga8c48wa4dsevt0v0gcl6378rfljj6vrz0u', [ - { - address: 'tb1qjg445dvh6krr6gtmuh4eqgua372vxaf4q07nv9', - txid: 'fd53023c4a9627c26c5d930f3149890b2eecf4261f409bd1a340454b7dede244', - value: '1210185/SAT', - vout: 0, - }, - ]); - - tx.feePrice = 1067; - tx.toAddress = 'tb1q2h3wgjasuprzrmcljkpkcyeh69un3r0tzf9nnn'; - tx.requiredAmount = new Satoshi(1000); - - const unsigned = tx.create(); - - expect(unsigned.fee).toEqual(208); - expect(unsigned.inputs.length).toEqual(1); - expect(unsigned.outputs.length).toEqual(2); - }); - - it('creates with zero fee', () => { - const tx = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }, - ]); - - tx.feePrice = 0; - tx.toAddress = 'addrTo'; - tx.requiredAmount = new Satoshi(1000); - - const unsigned = tx.create(); - - expect(unsigned.fee).toEqual(0); - expect(unsigned.inputs.length).toEqual(1); - expect(unsigned.outputs.length).toEqual(1); - }); - - it('creates with enough inputs for fee', () => { - const tx = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }, - { txid: '2', vout: 1, value: new Satoshi(1000).encode(), address: 'addr2' }, - ]); - - tx.toAddress = 'addrTo'; - tx.requiredAmount = new Satoshi(1000); - tx.feePrice = 1024; - - const unsigned = tx.create(); - - expect(unsigned.fee).toEqual(260); - expect(unsigned.inputs.length).toEqual(2); - expect(unsigned.outputs.length).toEqual(2); - }); - - it('creates with inputs amount equals required amount and zero fee', () => { - const tx = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }, - { txid: '2', vout: 1, value: new Satoshi(1000).encode(), address: 'addr2' }, - ]); - - tx.toAddress = 'addrTo'; - tx.requiredAmount = new Satoshi(2000); - tx.feePrice = 1024; - - const unsigned = tx.create(); - - expect(unsigned.fee).toEqual(0); - expect(unsigned.inputs.length).toEqual(2); - expect(unsigned.outputs.length).toEqual(1); - }); - - it('creates cancel transaction', () => { - const tx = new CreateBitcoinTx(basicEntry, 'addrChange', [ - { txid: '1', vout: 0, value: new Satoshi(1000).encode(), address: 'addr1' }, - { txid: '2', vout: 1, value: new Satoshi(1000).encode(), address: 'addr2' }, - ]); - - tx.toAddress = 'addrTo'; - tx.requiredAmount = new Satoshi(1000); - tx.feePrice = 1024; - - const original = tx.create(); - - expect(original.inputs.length).toEqual(2); - expect(original.outputs.length).toEqual(2); - - expect(original.outputs).toEqual(expect.arrayContaining([expect.objectContaining({ address: 'addrTo' })])); - - tx.toAddress = 'addrChange'; - tx.feePrice = 1536; - - const cancel = tx.create(); - - expect(cancel.fee).toBeGreaterThan(original.fee); - expect(cancel.inputs.length).toEqual(2); - /** - * TODO Make single output - * - * @see task WALLET-251 - */ - expect(cancel.outputs.length).toEqual(2); - - const changeAddress = expect.objectContaining({ address: 'addrChange' }); - - expect(cancel.outputs).toEqual(expect.arrayContaining([changeAddress, changeAddress])); - expect(cancel.outputs).not.toEqual(expect.arrayContaining([expect.objectContaining({ address: 'addrTo' })])); - }); -}); diff --git a/packages/core/src/workflow/create-tx/CreateTxConverter.spec.ts b/packages/core/src/workflow/create-tx/CreateTxConverter.spec.ts deleted file mode 100644 index beff54244..000000000 --- a/packages/core/src/workflow/create-tx/CreateTxConverter.spec.ts +++ /dev/null @@ -1,624 +0,0 @@ -import { BigAmount } from '@emeraldpay/bigamount'; -import { Wei } from '@emeraldpay/bigamount-crypto'; -import { EthereumEntry } from '@emeraldpay/emerald-vault-core'; -import { TokenData, TokenRegistry, amountFactory, blockchainIdToCode } from '../../blockchains'; -import { DEFAULT_GAS_LIMIT, EthereumTransactionType } from '../../transaction/ethereum'; -import { CreateTxConverter, FeeRange } from './CreateTxConverter'; -import { TxTarget } from './types'; - -describe('CreateTxConverter', () => { - const tokenData: TokenData = { - name: 'Wrapped Ether', - blockchain: 100, - address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', - symbol: 'WETH', - decimals: 18, - type: 'ERC20', - }; - - const tokenRegistry = new TokenRegistry([tokenData]); - - const ethEntry1: EthereumEntry = { - id: '50391c5d-a517-4b7a-9c42-1411e0603d30-0', - address: { - type: 'single', - value: '0x0', - }, - key: { - type: 'hd-path', - hdPath: "m/44'", - seedId: 'c782ff2b-ba6e-43e2-9e2d-92d05cc37b03', - }, - blockchain: 100, - createdAt: new Date(), - }; - const ethEntry2: EthereumEntry = { - id: '50391c5d-a517-4b7a-9c42-1411e0603d30-1', - address: { - type: 'single', - value: '0x1', - }, - key: { - type: 'hd-path', - hdPath: "m/44'", - seedId: 'c782ff2b-ba6e-43e2-9e2d-92d05cc37b03', - }, - blockchain: 100, - createdAt: new Date(), - }; - const etcEntry: EthereumEntry = { - id: '50391c5d-a517-4b7a-9c42-1411e0603d30-2', - address: { - type: 'single', - value: '0x2', - }, - key: { - type: 'hd-path', - hdPath: "m/44'", - seedId: 'c782ff2b-ba6e-43e2-9e2d-92d05cc37b03', - }, - blockchain: 101, - createdAt: new Date(), - }; - - const toAddress = '0x3'; - const ownerAddress = '0x4'; - - const feeRange: FeeRange = { - stdMaxGasPrice: new Wei(50), - highMaxGasPrice: new Wei(100), - lowMaxGasPrice: new Wei(10), - stdPriorityGasPrice: new Wei(5), - highPriorityGasPrice: new Wei(10), - lowPriorityGasPrice: new Wei(1), - }; - const zeroFeeRange: FeeRange = { - stdMaxGasPrice: Wei.ZERO, - highMaxGasPrice: Wei.ZERO, - lowMaxGasPrice: Wei.ZERO, - stdPriorityGasPrice: Wei.ZERO, - highPriorityGasPrice: Wei.ZERO, - lowPriorityGasPrice: Wei.ZERO, - }; - - function getBalance(entry: EthereumEntry, asset: string, ownerAddress?: string): BigAmount { - const blockchain = blockchainIdToCode(entry.blockchain); - - if (tokenRegistry.hasAddress(blockchain, asset)) { - const token = tokenRegistry.byAddress(blockchain, asset); - - if (ownerAddress == null) { - switch (entry.id) { - case ethEntry1.id: - return token.getAmount(110_000000); - case ethEntry2.id: - return token.getAmount(210_000000); - case etcEntry.id: - return token.getAmount(310_000000); - default: - return token.getAmount(0); - } - } - - switch (entry.id) { - case ethEntry1.id: - return token.getAmount(111_000000); - case ethEntry2.id: - return token.getAmount(211_000000); - case etcEntry.id: - return token.getAmount(311_000000); - default: - return token.getAmount(0); - } - } - - const factory = amountFactory(blockchain); - - switch (entry.id) { - case ethEntry1.id: - return factory(100_000000); - case ethEntry2.id: - return factory(200_000000); - case etcEntry.id: - return factory(300_000000); - default: - return factory(0); - } - } - - it('create initial ETH tx', () => { - const { createTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETH', - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - const factory = amountFactory(blockchainIdToCode(ethEntry1.blockchain)); - - expect(createTx.amount.equals(factory(0))).toBeTruthy(); - expect(createTx.from).toEqual(ethEntry1.address?.value); - expect(createTx.target).toEqual(TxTarget.MANUAL); - expect(createTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(createTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(createTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - }); - - it('create initial ERC20 tx', () => { - const { createTx } = new CreateTxConverter( - { - feeRange, - asset: tokenData.address, - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - const isErc20 = CreateTxConverter.isErc20CreateTx(createTx, tokenRegistry); - - expect(isErc20).toBeTruthy(); - - if (isErc20) { - const blockchain = blockchainIdToCode(ethEntry1.blockchain); - - const factory = amountFactory(blockchain); - - const token = tokenRegistry.byAddress(blockchain, tokenData.address); - - expect(createTx.amount.equals(token.getAmount(0))).toBeTruthy(); - expect(createTx.from).toEqual(ethEntry1.address?.value); - expect(createTx.target).toEqual(TxTarget.MANUAL); - expect(createTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(createTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); - expect(createTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(createTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - } - }); - - it('create initial ETC tx', () => { - const { createTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETC', - entry: etcEntry, - }, - tokenRegistry, - getBalance, - ); - - const factory = amountFactory(blockchainIdToCode(etcEntry.blockchain)); - - expect(createTx.amount.equals(factory(0))).toBeTruthy(); - expect(createTx.from).toEqual(etcEntry.address?.value); - expect(createTx.target).toEqual(TxTarget.MANUAL); - expect(createTx.totalBalance?.equals(factory(300_000000))).toBeTruthy(); - expect(createTx.type).toEqual(EthereumTransactionType.LEGACY); - - expect(createTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - }); - - it('create initial ERC20 tx with allowance', () => { - const { createTx } = new CreateTxConverter( - { - feeRange, - ownerAddress, - asset: tokenData.address, - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - const isErc20 = CreateTxConverter.isErc20CreateTx(createTx, tokenRegistry); - - expect(isErc20).toBeTruthy(); - - if (isErc20) { - const blockchain = blockchainIdToCode(ethEntry1.blockchain); - - const factory = amountFactory(blockchain); - - const token = tokenRegistry.byAddress(blockchain, tokenData.address); - - expect(createTx.amount.equals(token.getAmount(0))).toBeTruthy(); - expect(createTx.from).toEqual(ethEntry1.address?.value); - expect(createTx.target).toEqual(TxTarget.MANUAL); - expect(createTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(createTx.totalTokenBalance?.equals(token.getAmount(111_000000))).toBeTruthy(); - expect(createTx.transferFrom).toEqual(ownerAddress); - expect(createTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(createTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(createTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - } - }); - - it('change asset from ETH to ERC20 token', () => { - const { createTx: ethCreateTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETH', - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - ethCreateTx.amount = new Wei(1); - ethCreateTx.to = toAddress; - - const { createTx: erc20CreateTx } = new CreateTxConverter( - { - feeRange, - asset: tokenData.address, - entry: ethEntry1, - transaction: ethCreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - const isErc20 = CreateTxConverter.isErc20CreateTx(erc20CreateTx, tokenRegistry); - - expect(isErc20).toBeTruthy(); - - if (isErc20) { - const blockchain = blockchainIdToCode(ethEntry1.blockchain); - - const factory = amountFactory(blockchain); - - const token = tokenRegistry.byAddress(blockchain, tokenData.address); - - expect(erc20CreateTx.amount.equals(token.getAmount(0))).toBeTruthy(); - expect(erc20CreateTx.from).toEqual(ethEntry1.address?.value); - expect(erc20CreateTx.to).toEqual(toAddress); - expect(erc20CreateTx.target).toEqual(TxTarget.MANUAL); - expect(erc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(erc20CreateTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); - expect(erc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(erc20CreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(erc20CreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(erc20CreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - } - }); - - it('change asset from ETH to ERC20 token with max amount', () => { - const { createTx: ethCreateTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETH', - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - ethCreateTx.to = toAddress; - - ethCreateTx.target = TxTarget.SEND_ALL; - ethCreateTx.rebalance(); - - const { createTx: erc20CreateTx } = new CreateTxConverter( - { - feeRange, - asset: tokenData.address, - entry: ethEntry1, - transaction: ethCreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - const isErc20 = CreateTxConverter.isErc20CreateTx(erc20CreateTx, tokenRegistry); - - expect(isErc20).toBeTruthy(); - - if (isErc20) { - const blockchain = blockchainIdToCode(ethEntry1.blockchain); - - const factory = amountFactory(blockchain); - - const token = tokenRegistry.byAddress(blockchain, tokenData.address); - - expect(erc20CreateTx.amount.equals(token.getAmount(110_000000))).toBeTruthy(); - expect(erc20CreateTx.from).toEqual(ethEntry1.address?.value); - expect(erc20CreateTx.to).toEqual(toAddress); - expect(erc20CreateTx.target).toEqual(TxTarget.SEND_ALL); - expect(erc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(erc20CreateTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); - expect(erc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(erc20CreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(erc20CreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(erc20CreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - } - }); - - it('change asset from ERC20 token to ETH with max amount', () => { - const { createTx: ethCreateTx } = new CreateTxConverter( - { - feeRange, - asset: tokenData.address, - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - ethCreateTx.to = toAddress; - - ethCreateTx.target = TxTarget.SEND_ALL; - ethCreateTx.rebalance(); - - const { createTx: erc20CreateTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETH', - entry: ethEntry1, - transaction: ethCreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - expect(CreateTxConverter.isErc20CreateTx(erc20CreateTx, tokenRegistry)).toBeFalsy(); - - const blockchain = blockchainIdToCode(ethEntry1.blockchain); - - const factory = amountFactory(blockchain); - - const fee = feeRange.stdMaxGasPrice.multiply(DEFAULT_GAS_LIMIT); - - expect(erc20CreateTx.amount.equals(factory(100_000000).minus(fee))).toBeTruthy(); - expect(erc20CreateTx.from).toEqual(ethEntry1.address?.value); - expect(erc20CreateTx.to).toEqual(toAddress); - expect(erc20CreateTx.target).toEqual(TxTarget.SEND_ALL); - expect(erc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(erc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(erc20CreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(erc20CreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(erc20CreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - }); - - it('change entry from ETH to ETC', () => { - const { createTx: ethCreateTx } = new CreateTxConverter( - { - asset: 'ETH', - entry: ethEntry1, - feeRange: zeroFeeRange, - }, - tokenRegistry, - getBalance, - ); - - ethCreateTx.amount = amountFactory(blockchainIdToCode(ethEntry1.blockchain))(1); - ethCreateTx.to = toAddress; - - const { createTx: etcCreateTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETC', - entry: etcEntry, - transaction: ethCreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - const factory = amountFactory(blockchainIdToCode(etcEntry.blockchain)); - - expect(etcCreateTx.amount.equals(factory(0))).toBeTruthy(); - expect(etcCreateTx.from).toEqual(etcEntry.address?.value); - expect(etcCreateTx.to).toEqual(toAddress); - expect(etcCreateTx.target).toEqual(TxTarget.MANUAL); - expect(etcCreateTx.totalBalance?.equals(factory(300_000000))).toBeTruthy(); - expect(etcCreateTx.type).toEqual(EthereumTransactionType.LEGACY); - - expect(etcCreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(etcCreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(etcCreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - }); - - it('change entry from ETH to other ETH', () => { - const { createTx: eth1CreateTx } = new CreateTxConverter( - { - asset: 'ETH', - entry: ethEntry1, - feeRange: zeroFeeRange, - }, - tokenRegistry, - getBalance, - ); - - eth1CreateTx.amount = amountFactory(blockchainIdToCode(ethEntry1.blockchain))(1); - eth1CreateTx.to = toAddress; - - const { createTx: eth2CreateTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETH', - entry: ethEntry2, - transaction: eth1CreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - const factory = amountFactory(blockchainIdToCode(ethEntry2.blockchain)); - - expect(eth2CreateTx.amount.equals(factory(1))).toBeTruthy(); - expect(eth2CreateTx.from).toEqual(ethEntry2.address?.value); - expect(eth2CreateTx.to).toEqual(toAddress); - expect(eth2CreateTx.target).toEqual(TxTarget.MANUAL); - expect(eth2CreateTx.totalBalance?.equals(factory(200_000000))).toBeTruthy(); - expect(eth2CreateTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(eth2CreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(eth2CreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(eth2CreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - }); - - it('restore ETH tx', () => { - const { createTx: ethCreateTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETH', - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - ethCreateTx.to = toAddress; - - ethCreateTx.target = TxTarget.SEND_ALL; - ethCreateTx.rebalance(); - - const { createTx: restoredEthCreateTx } = new CreateTxConverter( - { - feeRange, - asset: 'ETH', - entry: ethEntry1, - transaction: ethCreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - const factory = amountFactory(blockchainIdToCode(ethEntry1.blockchain)); - - const fee = feeRange.stdMaxGasPrice.multiply(DEFAULT_GAS_LIMIT); - - expect(restoredEthCreateTx.amount.equals(factory(100_000000).minus(fee))).toBeTruthy(); - expect(restoredEthCreateTx.from).toEqual(ethEntry1.address?.value); - expect(restoredEthCreateTx.to).toEqual(toAddress); - expect(restoredEthCreateTx.target).toEqual(TxTarget.SEND_ALL); - expect(restoredEthCreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(restoredEthCreateTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(restoredEthCreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(restoredEthCreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(restoredEthCreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - }); - - it('restore ERC20 tx', () => { - const { createTx: erc20CreateTx } = new CreateTxConverter( - { - feeRange, - asset: tokenData.address, - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - erc20CreateTx.to = toAddress; - - erc20CreateTx.target = TxTarget.SEND_ALL; - erc20CreateTx.rebalance(); - - const { createTx: restoredErc20CreateTx } = new CreateTxConverter( - { - feeRange, - asset: tokenData.address, - entry: ethEntry1, - transaction: erc20CreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - const isErc20 = CreateTxConverter.isErc20CreateTx(restoredErc20CreateTx, tokenRegistry); - - expect(isErc20).toBeTruthy(); - - if (isErc20) { - const blockchain = blockchainIdToCode(ethEntry1.blockchain); - - const factory = amountFactory(blockchain); - - const token = tokenRegistry.byAddress(blockchain, tokenData.address); - - expect(restoredErc20CreateTx.amount.equals(token.getAmount(110_000000))).toBeTruthy(); - expect(restoredErc20CreateTx.from).toEqual(ethEntry1.address?.value); - expect(restoredErc20CreateTx.to).toEqual(toAddress); - expect(restoredErc20CreateTx.target).toEqual(TxTarget.SEND_ALL); - expect(restoredErc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(restoredErc20CreateTx.totalTokenBalance?.equals(token.getAmount(110_000000))).toBeTruthy(); - expect(restoredErc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(restoredErc20CreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(restoredErc20CreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(restoredErc20CreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - } - }); - - it('restore ERC20 tx with allowance', () => { - const { createTx: erc20CreateTx } = new CreateTxConverter( - { - feeRange, - asset: tokenData.address, - entry: ethEntry1, - }, - tokenRegistry, - getBalance, - ); - - erc20CreateTx.to = toAddress; - - erc20CreateTx.target = TxTarget.SEND_ALL; - erc20CreateTx.rebalance(); - - const { createTx: restoredErc20CreateTx } = new CreateTxConverter( - { - feeRange, - ownerAddress, - asset: tokenData.address, - entry: ethEntry1, - transaction: erc20CreateTx.dump(), - }, - tokenRegistry, - getBalance, - ); - - const isErc20 = CreateTxConverter.isErc20CreateTx(restoredErc20CreateTx, tokenRegistry); - - expect(isErc20).toBeTruthy(); - - if (isErc20) { - const blockchain = blockchainIdToCode(ethEntry1.blockchain); - - const factory = amountFactory(blockchain); - - const token = tokenRegistry.byAddress(blockchain, tokenData.address); - - expect(restoredErc20CreateTx.amount.equals(token.getAmount(111_000000))).toBeTruthy(); - expect(restoredErc20CreateTx.from).toEqual(ethEntry1.address?.value); - expect(restoredErc20CreateTx.to).toEqual(toAddress); - expect(restoredErc20CreateTx.target).toEqual(TxTarget.SEND_ALL); - expect(restoredErc20CreateTx.totalBalance?.equals(factory(100_000000))).toBeTruthy(); - expect(restoredErc20CreateTx.totalTokenBalance?.equals(token.getAmount(111_000000))).toBeTruthy(); - expect(restoredErc20CreateTx.transferFrom).toEqual(ownerAddress); - expect(restoredErc20CreateTx.type).toEqual(EthereumTransactionType.EIP1559); - - expect(restoredErc20CreateTx.gasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(restoredErc20CreateTx.maxGasPrice?.equals(feeRange.stdMaxGasPrice)).toBeTruthy(); - expect(restoredErc20CreateTx.priorityGasPrice?.equals(feeRange.stdPriorityGasPrice)).toBeTruthy(); - } - }); -}); diff --git a/packages/core/src/workflow/create-tx/CreateTxConverter.ts b/packages/core/src/workflow/create-tx/CreateTxConverter.ts deleted file mode 100644 index 2e5c2fb03..000000000 --- a/packages/core/src/workflow/create-tx/CreateTxConverter.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { BigAmount } from '@emeraldpay/bigamount'; -import { WeiAny } from '@emeraldpay/bigamount-crypto'; -import { EthereumEntry } from '@emeraldpay/emerald-vault-core'; -import { Blockchains, TokenRegistry, amountFactory, blockchainIdToCode } from '../../blockchains'; -import { EthereumTransactionType } from '../../transaction/ethereum'; -import { CreateERC20Tx } from './CreateErc20Tx'; -import { CreateEthereumTx } from './CreateEthereumTx'; -import { TxDetailsPlain, TxTarget } from './types'; - -export type CreateTx = CreateEthereumTx | CreateERC20Tx; - -export interface FeeRange { - stdMaxGasPrice: T; - lowMaxGasPrice: T; - highMaxGasPrice: T; - stdPriorityGasPrice: T; - lowPriorityGasPrice: T; - highPriorityGasPrice: T; -} - -type GetBalance = (entry: EthereumEntry, asset: string, ownerAddress?: string) => BigAmount; - -interface TxOrigin { - asset: string; - entry: EthereumEntry; - feeRange: FeeRange; - ownerAddress?: string; - transaction?: TxDetailsPlain; -} - -export class CreateTxConverter { - private asset: string; - private entry: EthereumEntry; - private feeRange: FeeRange; - private ownerAddress?: string; - private transaction?: TxDetailsPlain; - - private readonly tokenRegistry: TokenRegistry; - - private readonly getBalance: GetBalance; - - constructor( - { asset, entry, feeRange, ownerAddress, transaction }: TxOrigin, - tokenRegistry: TokenRegistry, - getBalance: GetBalance, - ) { - this.asset = asset; - this.entry = entry; - this.feeRange = feeRange; - this.ownerAddress = ownerAddress; - this.transaction = transaction; - - this.tokenRegistry = tokenRegistry; - - this.getBalance = getBalance; - } - - static fromPlain(transaction: TxDetailsPlain, tokenRegistry: TokenRegistry): CreateTx { - if (tokenRegistry.hasAddress(transaction.blockchain, transaction.asset)) { - return CreateERC20Tx.fromPlain(tokenRegistry, transaction); - } - - return CreateEthereumTx.fromPlain(transaction); - } - - static isErc20CreateTx(createTx: CreateTx, tokenRegistry: TokenRegistry): createTx is CreateERC20Tx { - return tokenRegistry.hasAddress(createTx.blockchain, createTx.getAsset()); - } - - get createTx(): CreateTx { - const { asset, entry, feeRange, ownerAddress, tokenRegistry, transaction } = this; - - const blockchain = blockchainIdToCode(entry.blockchain); - const { coinTicker, eip1559: supportEip1559 = false } = Blockchains[blockchain].params; - - let createTx: CreateTx; - - if (transaction == null) { - if (tokenRegistry.hasAddress(blockchain, asset)) { - createTx = new CreateERC20Tx(tokenRegistry, asset, blockchain); - createTx.totalBalance = this.getBalance(entry, coinTicker) as WeiAny; - createTx.totalTokenBalance = this.getBalance(entry, asset, ownerAddress); - createTx.transferFrom = ownerAddress; - } else { - createTx = new CreateEthereumTx(null, blockchain); - createTx.totalBalance = this.getBalance(entry, asset) as WeiAny; - } - - createTx.from = entry.address?.value; - - createTx.gasPrice = feeRange.stdMaxGasPrice; - createTx.maxGasPrice = feeRange.stdMaxGasPrice; - createTx.priorityGasPrice = feeRange.stdPriorityGasPrice; - - createTx.type = supportEip1559 ? EthereumTransactionType.EIP1559 : EthereumTransactionType.LEGACY; - } else { - createTx = CreateTxConverter.fromPlain(transaction, tokenRegistry); - - if (asset !== createTx.getAsset() || blockchain !== createTx.blockchain) { - const type = supportEip1559 ? createTx.type : EthereumTransactionType.LEGACY; - - let newCreateTx: CreateTx; - - if (tokenRegistry.hasAddress(blockchain, asset)) { - newCreateTx = new CreateERC20Tx(tokenRegistry, asset, blockchain, type); - newCreateTx.totalBalance = this.getBalance(entry, coinTicker) as WeiAny; - newCreateTx.totalTokenBalance = this.getBalance(entry, asset, newCreateTx.transferFrom); - - newCreateTx.transferFrom = CreateTxConverter.isErc20CreateTx(createTx, tokenRegistry) - ? createTx.transferFrom ?? ownerAddress - : ownerAddress; - } else { - newCreateTx = new CreateEthereumTx(null, blockchain, type); - newCreateTx.totalBalance = this.getBalance(entry, asset) as WeiAny; - } - - newCreateTx.from = entry.address?.value; - newCreateTx.to = createTx.to; - - if (blockchain === createTx.blockchain && (createTx.gasPrice?.isPositive() ?? false)) { - newCreateTx.gasPrice = createTx.gasPrice; - newCreateTx.maxGasPrice = createTx.maxGasPrice; - newCreateTx.priorityGasPrice = createTx.priorityGasPrice; - } else { - newCreateTx.gasPrice = feeRange.stdMaxGasPrice; - newCreateTx.maxGasPrice = feeRange.stdMaxGasPrice; - newCreateTx.priorityGasPrice = feeRange.stdPriorityGasPrice; - } - - if (createTx.target === TxTarget.SEND_ALL) { - newCreateTx.target = TxTarget.SEND_ALL; - - if (!newCreateTx.rebalance()) { - newCreateTx.target = TxTarget.MANUAL; - - newCreateTx.amount = CreateTxConverter.isErc20CreateTx(newCreateTx, tokenRegistry) - ? tokenRegistry.byAddress(blockchain, newCreateTx.getAsset()).getAmount(0) - : amountFactory(blockchain)(0); - } - } - - return newCreateTx; - } - - const gasPrice = createTx.gasPrice ?? createTx.maxGasPrice; - - if (gasPrice?.isZero() ?? true) { - createTx.gasPrice = feeRange.stdMaxGasPrice; - createTx.maxGasPrice = feeRange.stdMaxGasPrice; - createTx.priorityGasPrice = feeRange.stdPriorityGasPrice; - } - - if ( - transaction.from !== entry.address?.value || - transaction.transferFrom !== ownerAddress || - (transaction.transferFrom == null && ownerAddress != null) - ) { - createTx.from = entry.address?.value; - - if (CreateTxConverter.isErc20CreateTx(createTx, tokenRegistry)) { - createTx.transferFrom = ownerAddress; - - createTx.totalBalance = this.getBalance(entry, coinTicker) as WeiAny; - createTx.totalTokenBalance = this.getBalance(entry, asset, createTx.transferFrom); - } else { - createTx.totalBalance = this.getBalance(entry, asset) as WeiAny; - } - - if (createTx.target === TxTarget.SEND_ALL && !createTx.rebalance()) { - createTx.target = TxTarget.MANUAL; - - createTx.amount = CreateTxConverter.isErc20CreateTx(createTx, tokenRegistry) - ? tokenRegistry.byAddress(blockchain, createTx.getAsset()).getAmount(0) - : amountFactory(blockchain)(0); - } - } - } - - return createTx; - } -} diff --git a/packages/core/src/workflow/create-tx/types.ts b/packages/core/src/workflow/create-tx/types.ts deleted file mode 100644 index 491b3725c..000000000 --- a/packages/core/src/workflow/create-tx/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { BigAmount } from '@emeraldpay/bigamount'; -import BigNumber from 'bignumber.js'; -import { BlockchainCode } from '../../blockchains'; - -export enum ValidationResult { - INSUFFICIENT_FEE_PRICE, - INSUFFICIENT_FUNDS, - INSUFFICIENT_TOKEN_FUNDS, - NO_AMOUNT, - NO_FROM, - NO_TO, - OK, -} - -export enum TxTarget { - MANUAL, - SEND_ALL, -} - -export interface Tx { - getAmount(): T; - getAsset(): string; - getTotalBalance(): T; - setAmount(amount: T | BigNumber, tokenSymbol?: string): void; - setTotalBalance(total: T): void; -} - -export interface TxDetailsPlain { - amount: string; - amountDecimals: number; - asset: string; - blockchain: BlockchainCode; - from?: string; - gas: number; - gasPrice?: string; - maxGasPrice?: string; - priorityGasPrice?: string; - target: number; - to?: string; - totalEtherBalance?: string; - totalTokenBalance?: string; - transferFrom?: string; - type: string; -} - -export function targetFromNumber(value: number): TxTarget { - if (value === TxTarget.SEND_ALL.valueOf()) { - return TxTarget.SEND_ALL; - } - - return TxTarget.MANUAL; -} diff --git a/packages/core/src/workflow/display/DisplayErc20Tx.ts b/packages/core/src/workflow/display/DisplayErc20Tx.ts deleted file mode 100644 index ef7f0ed45..000000000 --- a/packages/core/src/workflow/display/DisplayErc20Tx.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { FormatterBuilder, Unit } from '@emeraldpay/bigamount'; -import { Wei } from '@emeraldpay/bigamount-crypto'; -import { CreateERC20Tx } from '..'; -import { TokenRegistry } from '../../blockchains'; -import { EthereumTransactionType } from '../../transaction/ethereum'; -import { DisplayTx } from './DisplayTx'; - -const formatter = new FormatterBuilder().useTopUnit().number(6, true).build(); - -export class DisplayErc20Tx implements DisplayTx { - tokenRegistry: TokenRegistry; - transaction: CreateERC20Tx; - - constructor(transaction: CreateERC20Tx, tokenRegistry: TokenRegistry) { - this.tokenRegistry = tokenRegistry; - this.transaction = transaction; - } - - amount(): string { - return formatter.format(this.transaction.amount); - } - - amountUnit(): string { - return this.tokenRegistry.byAddress(this.transaction.blockchain, this.transaction.asset).symbol; - } - - fee(): string { - return this.transaction.gas.toString(10); - } - - feeUnit(): string { - return 'Gas'; - } - - feeCost(): string { - return formatter.format(this.transaction.getFees()); - } - - feeCostUnit(): string { - const { code } = this.topUnit(); - - return code; - } - - topUnit(): Unit { - const { transaction } = this; - - const gasPrice = - (transaction.type === EthereumTransactionType.EIP1559 ? transaction.maxGasPrice : transaction.gasPrice) ?? - Wei.ZERO; - - return gasPrice.units.top; - } -} diff --git a/packages/core/src/workflow/display/DisplayEtherTx.spec.ts b/packages/core/src/workflow/display/DisplayEtherTx.spec.ts deleted file mode 100644 index 8e73cf9df..000000000 --- a/packages/core/src/workflow/display/DisplayEtherTx.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Wei } from '@emeraldpay/bigamount-crypto'; -import { DisplayEtherTx } from './DisplayEtherTx'; -import { CreateEthereumTx, TxTarget } from '..'; -import { BlockchainCode } from '../../blockchains'; -import { EthereumTransactionType } from '../../transaction/ethereum'; - -describe('DisplayEtherTx', () => { - it('standard tx', () => { - const tx = new CreateEthereumTx(null, BlockchainCode.ETH, EthereumTransactionType.LEGACY); - tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', new Wei('1000000000057', 'WEI')); - tx.to = '0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd'; - tx.target = TxTarget.MANUAL; - tx.amount = new Wei('5999580000000000000', 'WEI'); - tx.gasPrice = new Wei(10007, 'MWEI'); - tx.gas = 21000; - - const display = new DisplayEtherTx(tx); - - expect(tx.amount.number.toFixed()).toBe('5999580000000000000'); - - expect(display.amount()).toBe('5.99958'); - expect(display.amountUnit()).toBe('Ether'); - expect(display.fee()).toBe('21000'); - expect(display.feeUnit()).toBe('Gas'); - expect(display.feeCost()).toBe('0.00021'); - expect(display.feeCostUnit()).toBe('Ether'); - }); - - it('tx with zero amount', () => { - const tx = new CreateEthereumTx(null, BlockchainCode.ETH, EthereumTransactionType.LEGACY); - tx.setFrom('0x2C80BfA8E69fdd12853Fd010A520B29cfa01E2cD', new Wei('1000000000057', 'WEI')); - tx.to = '0x2af2d8be60ca2c0f21497bb57b0037d44b8df3bd'; - tx.target = TxTarget.MANUAL; - tx.amount = new Wei('0', 'WEI'); - tx.gasPrice = new Wei(10007, 'MWEI'); - tx.gas = 21000; - - const display = new DisplayEtherTx(tx); - - expect(display.amount()).toBe('0'); - expect(display.amountUnit()).toBe('Ether'); - expect(display.fee()).toBe('21000'); - expect(display.feeUnit()).toBe('Gas'); - expect(display.feeCost()).toBe('0.00021'); - expect(display.feeCostUnit()).toBe('Ether'); - }); -}); diff --git a/packages/core/src/workflow/display/DisplayEtherTx.ts b/packages/core/src/workflow/display/DisplayEtherTx.ts deleted file mode 100644 index 2c18c4406..000000000 --- a/packages/core/src/workflow/display/DisplayEtherTx.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FormatterBuilder, Unit } from '@emeraldpay/bigamount'; -import { DisplayTx } from './DisplayTx'; -import { CreateEthereumTx } from '..'; - -const formatter = new FormatterBuilder().useTopUnit().number(5, true).build(); - -export class DisplayEtherTx implements DisplayTx { - private readonly tx: CreateEthereumTx; - - constructor(tx: CreateEthereumTx) { - this.tx = tx; - } - - amount(): string { - return formatter.format(this.tx.amount); - } - - amountUnit(): string { - const { name } = this.topUnit(); - - return name; - } - - fee(): string { - return this.tx.gas.toString(10); - } - - feeUnit(): string { - return 'Gas'; - } - - feeCost(): string { - return formatter.format(this.tx.getFees()); - } - - feeCostUnit(): string { - const { name } = this.topUnit(); - - return name; - } - - topUnit(): Unit { - return this.tx.amount.units.top; - } -} diff --git a/packages/core/src/workflow/display/DisplayTx.ts b/packages/core/src/workflow/display/DisplayTx.ts deleted file mode 100644 index 6a3b67cd7..000000000 --- a/packages/core/src/workflow/display/DisplayTx.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Unit } from '@emeraldpay/bigamount'; - -export interface DisplayTx { - amount(): string; - amountUnit(): string; - fee(): string; - feeUnit(): string; - feeCost(): string; - feeCostUnit(): string; - topUnit(): Unit; -} diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts index 4452150a5..997e32cbb 100644 --- a/packages/core/src/workflow/index.ts +++ b/packages/core/src/workflow/index.ts @@ -1,13 +1,26 @@ /* eslint sort-exports/sort-exports: error */ -export { ApproveTarget, CreateErc20ApproveTx, Erc20ApproveTxDetails } from './create-tx/CreateErc20ApproveTx'; -export { BitcoinTx, BitcoinTxDetails, CreateBitcoinTx } from './create-tx/CreateBitcoinTx'; -export { CreateBitcoinCancelTx } from './create-tx/CreateBitcoinCancelTx'; -export { CreateERC20Tx, ERC20TxDetails } from './create-tx/CreateErc20Tx'; -export { CreateErc20WrappedTx, Erc20WrappedTxDetails } from './create-tx/CreateErc20WrappedTx'; -export { CreateEthereumTx, TxDetails } from './create-tx/CreateEthereumTx'; -export { CreateTx, CreateTxConverter, FeeRange } from './create-tx/CreateTxConverter'; -export { DisplayErc20Tx } from './display/DisplayErc20Tx'; -export { DisplayEtherTx } from './display/DisplayEtherTx'; -export { DisplayTx } from './display/DisplayTx'; -export { TxDetailsPlain, TxTarget, ValidationResult } from './create-tx/types'; +export { + AnyCreateTx, + AnyEthereumCreateTx, + AnyPlainTx, + BitcoinPlainTx, + EthereumPlainTx, + TxTarget, + ValidationResult, + isAnyEthereumCreateTx, + isBitcoinCreateTx, + isBitcoinPlainTx, + isErc20CreateTx, + isEthereumCreateTx, +} from './types'; + +export { ApproveTarget, CreateErc20ApproveTx, Erc20ApproveTxDetails } from './CreateErc20ApproveTx'; +export { BitcoinFeeRange, CreateTxConverter, EthereumFeeRange, FeeRange } from './CreateTxConverter'; +export { BitcoinTx, BitcoinTxDetails, CreateBitcoinTx } from './CreateBitcoinTx'; +export { CreateBitcoinCancelTx } from './CreateBitcoinCancelTx'; +export { CreateERC20Tx, ERC20TxDetails } from './CreateErc20Tx'; +export { CreateErc20WrappedTx, Erc20WrappedTxDetails } from './CreateErc20WrappedTx'; +export { CreateEthereumTx, TxDetails } from './CreateEthereumTx'; + +export { TxSigner } from './TxSigner'; diff --git a/packages/core/src/workflow/types.ts b/packages/core/src/workflow/types.ts new file mode 100644 index 000000000..9badc4c7f --- /dev/null +++ b/packages/core/src/workflow/types.ts @@ -0,0 +1,91 @@ +import { BigAmount } from '@emeraldpay/bigamount'; +import { SatoshiAny, WeiAny } from '@emeraldpay/bigamount-crypto'; +import BigNumber from 'bignumber.js'; +import { BlockchainCode, TokenRegistry, isBitcoin } from '../blockchains'; +import { CreateBitcoinTx } from './CreateBitcoinTx'; +import { CreateERC20Tx } from './CreateErc20Tx'; +import { CreateEthereumTx } from './CreateEthereumTx'; + +export enum ValidationResult { + INSUFFICIENT_FEE_PRICE, + INSUFFICIENT_FUNDS, + INSUFFICIENT_TOKEN_FUNDS, + NO_CHANGE_ADDRESS, + NO_AMOUNT, + NO_FROM, + NO_TO, + OK, +} + +export enum TxTarget { + MANUAL, + SEND_ALL, +} + +/** + * TODO Make unified interface for all create tx classes + */ +export interface EthereumTx { + getAmount(): T; + getAsset(): string; + getTotalBalance(): T; + setAmount(amount: T | BigNumber, tokenSymbol?: string): void; + setTotalBalance(total: T): void; +} + +export type AnyEthereumCreateTx = CreateEthereumTx | CreateERC20Tx; +export type AnyCreateTx = CreateBitcoinTx | AnyEthereumCreateTx; + +export function isBitcoinCreateTx(createTx: AnyCreateTx): createTx is CreateBitcoinTx { + return 'amount' in createTx && SatoshiAny.is(createTx.amount); +} + +export function isEthereumCreateTx(createTx: AnyCreateTx): createTx is CreateEthereumTx { + return 'amount' in createTx && WeiAny.is(createTx.amount); +} + +export function isErc20CreateTx(createTx: AnyCreateTx, tokenRegistry: TokenRegistry): createTx is CreateERC20Tx { + return ( + 'getAsset' in createTx && + typeof createTx.getAsset === 'function' && + tokenRegistry.hasAddress(createTx.blockchain, createTx.getAsset()) + ); +} + +export function isAnyEthereumCreateTx( + createTx: AnyCreateTx, + tokenRegistry: TokenRegistry, +): createTx is AnyEthereumCreateTx { + return isEthereumCreateTx(createTx) || isErc20CreateTx(createTx, tokenRegistry); +} + +export interface BitcoinPlainTx { + amount: string; + blockchain: BlockchainCode; + target: number; + to?: string; + vkbPrice: number; +} + +export interface EthereumPlainTx { + amount: string; + asset: string; + blockchain: BlockchainCode; + from?: string; + gas: number; + gasPrice?: string; + maxGasPrice?: string; + priorityGasPrice?: string; + target: number; + to?: string; + totalBalance?: string; + totalTokenBalance?: string; + transferFrom?: string; + type: string; +} + +export type AnyPlainTx = BitcoinPlainTx | EthereumPlainTx; + +export function isBitcoinPlainTx(transaction: BitcoinPlainTx | EthereumPlainTx): transaction is BitcoinPlainTx { + return isBitcoin(transaction.blockchain); +} diff --git a/packages/react-app/package.json b/packages/react-app/package.json index b87628d12..aadee317f 100644 --- a/packages/react-app/package.json +++ b/packages/react-app/package.json @@ -18,8 +18,8 @@ }, "dependencies": { "@electron/remote": "^2.0.9", - "@emeraldpay/bigamount": "^0.4.1", - "@emeraldpay/bigamount-crypto": "^0.4.1", + "@emeraldpay/bigamount": "^0.4.2", + "@emeraldpay/bigamount-crypto": "^0.4.2", "@emeraldpay/emerald-vault-core": "^0.12.0", "@emeraldwallet/core": "2.11.0-dev", "@emeraldwallet/store": "2.11.0-dev", diff --git a/packages/react-app/src/app/screen/Screen/Screen.tsx b/packages/react-app/src/app/screen/Screen/Screen.tsx index a9bd3398f..bbc42a049 100644 --- a/packages/react-app/src/app/screen/Screen/Screen.tsx +++ b/packages/react-app/src/app/screen/Screen/Screen.tsx @@ -3,33 +3,28 @@ import { IState, screen } from '@emeraldwallet/store'; import { CircularProgress } from '@material-ui/core'; import * as React from 'react'; import { connect } from 'react-redux'; +import AddContact from '../../../address-book/AddContact'; +import AddressBook from '../../../address-book/ContactList'; import EditContact from '../../../address-book/EditContact'; import AddHDAddress from '../../../create-account/AddHDAddress'; import SetupBlockchains from '../../../create-account/SetupBlockchains'; import CreateWalletScreen from '../../../create-wallet/CreateWalletScreen'; -import { - AddContact, - ContactList as AddressBook, - BroadcastTx, - Home, - Settings, - TxDetails, - WalletDetails, - Welcome, -} from '../../../index'; import ShowMessage from '../../../message/ShowMessage'; import SignMessage from '../../../message/SignMessage'; import ReceiveScreen from '../../../receive/ReceiveScreen'; +import Settings from '../../../settings/Settings'; +import { BroadcastEthTx } from '../../../transaction/BroadcastEthTx'; import CreateApproveTransaction from '../../../transaction/CreateApproveTransaction'; -import CreateBitcoinTransaction from '../../../transaction/CreateBitcoinTransaction'; import CreateCancelTransaction from '../../../transaction/CreateCancelTransaction'; import CreateConvertTransaction from '../../../transaction/CreateConvertTransaction'; import CreateRecoverTransaction from '../../../transaction/CreateRecoverTransaction'; import CreateSpeedUpTransaction from '../../../transaction/CreateSpeedUpTransaction'; -import CreateTransaction from '../../../transaction/CreateTransaction'; -import SelectAccount from '../../../transaction/CreateTransaction/SelectAccount'; -import CreateTransactionNew from '../../../transaction/CreateTransactionNew'; +import { CreateTransaction } from '../../../transaction/CreateTransaction'; +import TxDetails from '../../../transactions/TxDetails'; +import WalletDetails from '../../../wallets/WalletDetails'; import WalletInfo from '../../../wallets/WalletInfo'; +import Home from '../../layout/Home'; +import Welcome from '../../onboarding/Welcome'; import GlobalKey from '../../vault/GlobalKey'; import ImportVault from '../../vault/ImportVault'; import PasswordMigration from '../../vault/PasswordMigration'; @@ -67,21 +62,15 @@ const Screen: React.FC = ({ restoreData, screenItem, term case screen.Pages.ADDRESS_BOOK: return ; case screen.Pages.BROADCAST_TX: - return ; + return ; case screen.Pages.CREATE_TX: - return ; - case screen.Pages.CREATE_TX_NEW: - return ; + return ; case screen.Pages.CREATE_TX_APPROVE: return ; case screen.Pages.CREATE_TX_CONVERT: return ; - case screen.Pages.CREATE_TX_BITCOIN: - return ; case screen.Pages.CREATE_TX_CANCEL: return ; - case screen.Pages.CREATE_TX_ETHEREUM: - return ; case screen.Pages.CREATE_TX_SPEED_UP: return ; case screen.Pages.CREATE_TX_RECOVER: diff --git a/packages/react-app/src/transaction/CreateBitcoinTransaction/Confirm.tsx b/packages/react-app/src/common/BtcConfirm/BtcConfirm.tsx similarity index 96% rename from packages/react-app/src/transaction/CreateBitcoinTransaction/Confirm.tsx rename to packages/react-app/src/common/BtcConfirm/BtcConfirm.tsx index e60b22881..ec999deca 100644 --- a/packages/react-app/src/transaction/CreateBitcoinTransaction/Confirm.tsx +++ b/packages/react-app/src/common/BtcConfirm/BtcConfirm.tsx @@ -31,7 +31,7 @@ interface DispatchProps { onCancel(): void; } -const Confirm: React.FC = ({ +const BtcConfirm: React.FC = ({ entryId, blockchain, rawTx, @@ -79,4 +79,4 @@ export default connect(null, (dispatch, ownPro dispatch(screen.actions.gotoScreen(screen.Pages.WALLET, walletId)); }, -}))(Confirm); +}))(BtcConfirm); diff --git a/packages/react-app/src/transaction/CreateBitcoinTransaction/RawTx.tsx b/packages/react-app/src/common/BtcConfirm/RawTx.tsx similarity index 100% rename from packages/react-app/src/transaction/CreateBitcoinTransaction/RawTx.tsx rename to packages/react-app/src/common/BtcConfirm/RawTx.tsx diff --git a/packages/react-app/src/transaction/CreateBitcoinTransaction/RawTxDetails.tsx b/packages/react-app/src/common/BtcConfirm/RawTxDetails.tsx similarity index 100% rename from packages/react-app/src/transaction/CreateBitcoinTransaction/RawTxDetails.tsx rename to packages/react-app/src/common/BtcConfirm/RawTxDetails.tsx diff --git a/packages/react-app/src/common/BtcConfirm/index.ts b/packages/react-app/src/common/BtcConfirm/index.ts new file mode 100644 index 000000000..6ed3da614 --- /dev/null +++ b/packages/react-app/src/common/BtcConfirm/index.ts @@ -0,0 +1 @@ +export { default as BtcConfirm } from './BtcConfirm'; diff --git a/packages/react-app/src/transaction/StoredTxView/StoredTxView.tsx b/packages/react-app/src/common/StoredTxView/StoredTxView.tsx similarity index 100% rename from packages/react-app/src/transaction/StoredTxView/StoredTxView.tsx rename to packages/react-app/src/common/StoredTxView/StoredTxView.tsx diff --git a/packages/react-app/src/common/StoredTxView/index.ts b/packages/react-app/src/common/StoredTxView/index.ts new file mode 100644 index 000000000..15c055da3 --- /dev/null +++ b/packages/react-app/src/common/StoredTxView/index.ts @@ -0,0 +1 @@ +export { default as StoredTxView } from './StoredTxView'; diff --git a/packages/react-app/src/index.ts b/packages/react-app/src/index.ts index bb0138941..ad6a16a63 100644 --- a/packages/react-app/src/index.ts +++ b/packages/react-app/src/index.ts @@ -1,41 +1,5 @@ -// address book -export { default as ContactList } from './address-book/ContactList'; -export { default as AddContact } from './address-book/AddContact'; +/* eslint sort-exports/sort-exports: error */ -// accounts -export { default as WalletList } from './wallets/WalletList'; -export { default as WalletDetails } from './wallets/WalletDetails'; - -// common -export { default as Balance } from './common/Balance'; -export { default as ErrorDialog } from './common/ErrorDialog'; - -// i18n -export { default as i18n } from './i18n'; - -// about export { default as About } from './app/AboutDialog/About'; - -// layout -export { default as ConnectionStatus } from './app/layout/ConnectionStatus'; -export { default as Notification } from './app/layout/Notification'; -export { default as Header } from './app/layout/Header'; -export { default as Home } from './app/layout/Home'; - -// tx history -export { default as TxDetails } from './transactions/TxDetails'; -export { default as TxHistory } from './transactions/TxHistory'; - -// transaction -export { default as CreateTransaction } from './transaction/CreateTransaction'; -export { default as BroadcastTx } from './transaction/BroadcastTx'; -export { default as SignTransaction } from './transaction/SignTransaction'; - -// settings -export { default as Settings } from './settings/Settings'; - -// onboarding -export { default as InitialSetup } from './app/onboarding/InitialSetup'; -export { default as Welcome } from './app/onboarding/Welcome'; - export { default as App } from './app/App'; +export { default as i18n } from './i18n'; diff --git a/packages/react-app/src/transaction/BroadcastTx/BroadcastTx.spec.tsx b/packages/react-app/src/transaction/BroadcastEthTx/BroadcastEthTx.spec.tsx similarity index 94% rename from packages/react-app/src/transaction/BroadcastTx/BroadcastTx.spec.tsx rename to packages/react-app/src/transaction/BroadcastEthTx/BroadcastEthTx.spec.tsx index 5778d5db3..555b6dfd0 100644 --- a/packages/react-app/src/transaction/BroadcastTx/BroadcastTx.spec.tsx +++ b/packages/react-app/src/transaction/BroadcastEthTx/BroadcastEthTx.spec.tsx @@ -7,7 +7,7 @@ import { render } from '@testing-library/react'; import * as React from 'react'; import { Provider } from 'react-redux'; import { createTestStore } from '../../testStore'; -import BroadcastTx from './BroadcastTx'; +import BroadcastEthTx from './BroadcastEthTx'; describe('BroadcastTx', () => { const factory = amountFactory(BlockchainCode.Goerli); @@ -46,7 +46,7 @@ describe('BroadcastTx', () => { const wrapper = render( - + , ); @@ -58,7 +58,7 @@ describe('BroadcastTx', () => { const wrapper = render( - + , ); diff --git a/packages/react-app/src/transaction/BroadcastTx/BroadcastTx.tsx b/packages/react-app/src/transaction/BroadcastEthTx/BroadcastEthTx.tsx similarity index 100% rename from packages/react-app/src/transaction/BroadcastTx/BroadcastTx.tsx rename to packages/react-app/src/transaction/BroadcastEthTx/BroadcastEthTx.tsx diff --git a/packages/react-app/src/transaction/BroadcastEthTx/index.ts b/packages/react-app/src/transaction/BroadcastEthTx/index.ts new file mode 100644 index 000000000..d009f8400 --- /dev/null +++ b/packages/react-app/src/transaction/BroadcastEthTx/index.ts @@ -0,0 +1 @@ +export { default as BroadcastEthTx } from './BroadcastEthTx'; diff --git a/packages/react-app/src/transaction/BroadcastTx/index.ts b/packages/react-app/src/transaction/BroadcastTx/index.ts deleted file mode 100644 index 9edf00d10..000000000 --- a/packages/react-app/src/transaction/BroadcastTx/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './BroadcastTx'; diff --git a/packages/react-app/src/transaction/CreateApproveTransaction/CreateApproveTransaction.tsx b/packages/react-app/src/transaction/CreateApproveTransaction/CreateApproveTransaction.tsx index 53bf5384b..83df75974 100644 --- a/packages/react-app/src/transaction/CreateApproveTransaction/CreateApproveTransaction.tsx +++ b/packages/react-app/src/transaction/CreateApproveTransaction/CreateApproveTransaction.tsx @@ -255,7 +255,7 @@ export default connect( } const signed: SignData | undefined = await dispatch( - transaction.actions.signTransaction(entry.id, tx.build(), password), + transaction.actions.signEthereumTransaction(entry.id, tx.build(), password), ); if (signed != null) { diff --git a/packages/react-app/src/transaction/CreateBitcoinTransaction/CreateBitcoinTransaction.tsx b/packages/react-app/src/transaction/CreateBitcoinTransaction/CreateBitcoinTransaction.tsx deleted file mode 100644 index 5ea6e9fd5..000000000 --- a/packages/react-app/src/transaction/CreateBitcoinTransaction/CreateBitcoinTransaction.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - AddressRole, - BitcoinEntry, - CurrentAddress, - EntryId, - UnsignedBitcoinTx, - Uuid, - isBitcoinEntry, - isSeedPkRef, -} from '@emeraldpay/emerald-vault-core'; -import { BlockchainCode, InputUtxo, blockchainIdToCode, workflow } from '@emeraldwallet/core'; -import { - BroadcastData, - DefaultFee, - FeePrices, - IState, - accounts, - application, - screen, - transaction, -} from '@emeraldwallet/store'; -import { Back, Page } from '@emeraldwallet/ui'; -import { Typography } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import * as React from 'react'; -import { connect } from 'react-redux'; -import Confirm from './Confirm'; -import SetupTx from './SetupTx'; -import Sign from './Sign'; - -type Step = 'setup' | 'sign' | 'result'; - -interface OwnProps { - source: EntryId; -} - -interface StateProps { - blockchain: BlockchainCode; - defaultFee: DefaultFee | undefined; - entry: BitcoinEntry; - feeTtl: number; - seedId: Uuid; - utxo: InputUtxo[]; -} - -interface DispatchProps { - onBroadcast(data: BroadcastData): void; - onCancel?(): void; - getFees( - blockchain: BlockchainCode, - defaultFee: DefaultFee | undefined, - feeTtl: number, - ): () => Promise>; - getXPubPositionalAddress(entryId: string, xPub: string, role: AddressRole): Promise; -} - -const CreateBitcoinTransaction: React.FC = ({ - blockchain, - defaultFee, - entry, - feeTtl, - seedId, - source, - utxo, - getFees, - getXPubPositionalAddress, - onBroadcast, - onCancel, -}) => { - const [fee, setFee] = React.useState(accounts.selectors.zeroAmountFor(blockchain)); - const [page, setPage] = React.useState('setup'); - const [signed, setSigned] = React.useState(''); - const [txId, setTxId] = React.useState(''); - - const [tx, setTx] = React.useState(null); - const [txBuilder, setTxBuilder] = React.useState(null); - - React.useEffect( - () => { - if (isBitcoinEntry(entry)) { - Promise.all( - entry.xpub - .filter(({ role }) => role === 'change') - .map(({ role, xpub }) => getXPubPositionalAddress(entry.id, xpub, role)), - ).then(([{ address }]) => { - try { - const txBuilder = new workflow.CreateBitcoinTx(entry, address, utxo); - - setTxBuilder(txBuilder); - } catch (exception) { - // Nothing - } - }); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - let content; - - if (page == 'setup') { - if (txBuilder != null) { - content = ( - { - setFee(fee); - setTx(tx); - - setPage('sign'); - }} - /> - ); - } else { - content = Initializing...; - } - } else if (page == 'sign' && typeof tx == 'object' && tx != null) { - content = ( - { - setSigned(signed); - setTxId(txId); - - setPage('result'); - }} - /> - ); - } else if (page == 'result') { - if (tx != null) { - content = ( - - onBroadcast({ - blockchain, - fee, - tx, - txId, - signed, - entryId: entry.id, - }) - } - /> - ); - } - } else { - console.error('Invalid state', page); - - content = Invalid state; - } - - return ( - }> - {content} - - ); -}; - -export default connect( - (state, ownProps) => { - const entry = accounts.selectors.findEntry(state, ownProps.source); - - if (entry == null) { - throw new Error('Entry not found: ' + ownProps.source); - } - - if (!isBitcoinEntry(entry)) { - throw new Error('Not bitcoin type of entry: ' + entry.id + ' (as ' + entry.blockchain + ')'); - } - - if (!isSeedPkRef(entry, entry.key)) { - throw new Error('Not a seed entry'); - } - - const blockchain = blockchainIdToCode(entry.blockchain); - - return { - entry, - blockchain, - defaultFee: application.selectors.getDefaultFee(state, blockchain), - feeTtl: application.selectors.getFeeTtl(state, blockchain), - seedId: entry.key.seedId, - utxo: accounts.selectors.getUtxo(state, entry.id), - }; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (dispatch: any) => ({ - getFees(blockchain, defaultFee, feeTtl) { - return async () => { - const fees: number[] = await Promise.all([ - dispatch(transaction.actions.estimateFee(blockchain, 6, 'avgLast')), - dispatch(transaction.actions.estimateFee(blockchain, 6, 'avgMiddle')), - dispatch(transaction.actions.estimateFee(blockchain, 6, 'avgTail5')), - ]); - - const [avgLast, avgTail5, avgMiddle] = fees - .map((fee) => fee ?? 0) - .sort((first, second) => { - if (first === second) { - return 0; - } - - return first > second ? 1 : -1; - }); - - if (avgMiddle === 0) { - const defaults = { - avgLast: defaultFee?.min ?? '0', - avgMiddle: defaultFee?.max ?? '0', - avgTail5: defaultFee?.std ?? '0', - }; - - const cachedFee = await dispatch(application.actions.cacheGet(`fee.${blockchain}`)); - - if (cachedFee == null) { - return defaults; - } - - try { - return JSON.parse(cachedFee); - } catch (exception) { - return defaults; - } - } - - const fee = { - avgLast, - avgMiddle, - avgTail5, - }; - - await dispatch(application.actions.cachePut(`fee.${blockchain}`, JSON.stringify(fee), feeTtl)); - - return fee; - }; - }, - getXPubPositionalAddress(entryId, xPub, role) { - return dispatch(accounts.actions.getXPubPositionalAddress(entryId, xPub, role)); - }, - onBroadcast(data) { - dispatch(transaction.actions.broadcastTx(data)); - }, - onCancel() { - dispatch(screen.actions.goBack()); - }, - }), -)(CreateBitcoinTransaction); diff --git a/packages/react-app/src/transaction/CreateBitcoinTransaction/SetupTx.tsx b/packages/react-app/src/transaction/CreateBitcoinTransaction/SetupTx.tsx deleted file mode 100644 index f952d1ed9..000000000 --- a/packages/react-app/src/transaction/CreateBitcoinTransaction/SetupTx.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { BigAmount } from '@emeraldpay/bigamount'; -import { BitcoinEntry, EntryId, UnsignedBitcoinTx } from '@emeraldpay/emerald-vault-core'; -import { blockchainIdToCode, workflow } from '@emeraldwallet/core'; -import { FeePrices, screen } from '@emeraldwallet/store'; -import { Button, ButtonGroup, FormLabel, FormRow } from '@emeraldwallet/ui'; -import { - Box, - CircularProgress, - FormControlLabel, - FormHelperText, - Slider, - Switch, - createStyles, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import validate from 'bitcoin-address-validation'; -import * as React from 'react'; -import { connect } from 'react-redux'; -import { AmountField } from '../../common/AmountField'; -import ToField from '../../common/ToField/ToField'; - -const { ValidationResult } = workflow; - -const useStyles = makeStyles( - createStyles({ - feeHelp: { - paddingLeft: 10, - position: 'initial', - }, - feeHelpBox: { - width: 500, - clear: 'left', - }, - feeMarkLabel: { - fontSize: '0.7em', - opacity: 0.8, - }, - feeSlider: { - marginBottom: 10, - paddingTop: 10, - width: 300, - }, - feeSliderBox: { - float: 'left', - width: 300, - }, - feeTypeBox: { - float: 'left', - height: 40, - width: 200, - }, - inputField: { - flexGrow: 5, - }, - buttons: { - display: 'flex', - justifyContent: 'end', - width: '100%', - }, - }), -); - -interface OwnProps { - create: workflow.CreateBitcoinTx; - entry: BitcoinEntry; - source: EntryId; - getFees(): Promise>; - onCreate(tx: UnsignedBitcoinTx, fee: BigAmount): void; -} - -interface StateProps { - onCancel?(): void; -} - -const SetupTx: React.FC = ({ create, entry, getFees, onCancel, onCreate }) => { - const styles = useStyles(); - - const [initializing, setInitializing] = React.useState(true); - - const [amount, setAmount] = React.useState(create.requiredAmount); - - const [useStdFee, setUseStdFee] = React.useState(true); - const [feePrice, setFeePrice] = React.useState(0); - const [maximalFee, setMaximalFee] = React.useState(0); - const [minimalFee, setMinimalFee] = React.useState(0); - const [standardFee, setStandardFee] = React.useState(0); - - const getTotalFee = (price: number): BigAmount => create.estimateFees(price); - - const onSetAmount = (value: BigAmount): void => { - create.requiredAmount = value; - - setAmount(value); - }; - - const onSetAmountMax = (): void => { - create.target = workflow.TxTarget.SEND_ALL; - - setAmount(create.requiredAmount); - }; - - const onSetFeePrice = (value: number): void => { - create.feePrice = value; - - setAmount(create.requiredAmount); - setFeePrice(value); - }; - - const onSetTo = (value: string | undefined): void => { - create.toAddress = value == null || !validate(value) ? '' : value; - }; - - React.useEffect( - () => { - getFees().then(({ avgLast, avgTail5, avgMiddle }) => { - setMinimalFee(avgLast); - setStandardFee(avgTail5); - setMaximalFee(avgMiddle); - - onSetFeePrice(avgTail5); - - setInitializing(false); - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - const totalFee = getTotalFee(feePrice); - const validTx = !initializing && create.validate() === ValidationResult.OK; - - return ( - <> - - To - - - - Amount - - - - Fee - - - { - const checked = event.target.checked; - - if (checked) { - onSetFeePrice(standardFee); - } - - setUseStdFee(checked); - }} - name="checkedB" - color="primary" - /> - } - label={useStdFee ? 'Standard Fee' : 'Custom Fee'} - /> - - {!useStdFee && ( - - getTotalFee(value).toString()} - aria-labelledby="discrete-slider" - valueLabelDisplay="auto" - step={1} - marks={[ - { value: minimalFee, label: 'Slow' }, - { value: maximalFee, label: 'Urgent' }, - ]} - min={minimalFee} - max={maximalFee} - onChange={(event, value) => onSetFeePrice(value as number)} - valueLabelFormat={(value) => (value / 1024).toFixed(2)} - /> - - )} - - {totalFee.toString()} - - - - - - - {initializing && ( - - - - ); - })} - - - - ); -}; - -export default connect( - (state, { walletId }) => { - const wallet = accounts.selectors.findWallet(state, walletId); - - let assets: BigAmount[] = []; - - if (wallet != null) { - assets = accounts.selectors.getWalletBalances(state, wallet); - } - - const entryBalances: EntryBalance[] = Object.values( - wallet?.entries - .filter((entry) => !entry.receiveDisabled) - .reduce>((carry, entry) => { - const blockchainCode = blockchainIdToCode(entry.blockchain); - const zeroAmount = accounts.selectors.zeroAmountFor(blockchainCode); - - const balance = accounts.selectors.getBalance(state, entry.id, zeroAmount, true) ?? zeroAmount; - - let tokenBalances: TokenAmount[] = []; - - if (isEthereumEntry(entry) && entry.address != null) { - tokenBalances = tokens.selectors.selectBalances(state, blockchainCode, entry.address.value); - } - - const accountBalance = carry[entry.blockchain]; - - if (accountBalance == null) { - return { - ...carry, - [entry.blockchain]: { - balance, - entry, - tokenBalances: tokenBalances, - }, - }; - } - - tokenBalances = tokenBalances.reduce((carry, token) => { - const index = carry.findIndex((item) => item.units.equals(token.units)); - - if (index === -1) { - return [...carry, token]; - } - - carry.splice(index, 1, carry[index].plus(token)); - - return carry; - }, accountBalance.tokenBalances); - - return { - ...carry, - [entry.blockchain]: { - tokenBalances, - entry: accountBalance.entry, - balance: accountBalance.balance.plus(balance), - }, - }; - }, {}) ?? {}, - ); - - return { - assets, - entryBalances, - wallet, - walletIcon: state.accounts.icons[walletId], - }; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (dispatch: any, { walletId }) => ({ - onSelected(entry: WalletEntry) { - if (isBitcoinEntry(entry)) { - dispatch(screen.actions.gotoScreen(screen.Pages.CREATE_TX_BITCOIN, entry.id, null, true)); - } else if (isEthereumEntry(entry)) { - dispatch(screen.actions.gotoScreen(screen.Pages.CREATE_TX_ETHEREUM, { entry }, null, true)); - } - }, - onCancel() { - dispatch(screen.actions.gotoScreen(screen.Pages.WALLET, walletId)); - }, - }), -)(SelectAccount); diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/SetupTransaction.tsx b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/SetupTransaction.tsx new file mode 100644 index 000000000..c2c91fd67 --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/SetupTransaction.tsx @@ -0,0 +1,249 @@ +import { BigAmount } from '@emeraldpay/bigamount'; +import { BitcoinEntry, EntryId, Uuid, WalletEntry, isBitcoinEntry } from '@emeraldpay/emerald-vault-core'; +import { + BlockchainCode, + Blockchains, + CurrencyAmount, + TokenRegistry, + amountFactory, + blockchainIdToCode, + workflow, +} from '@emeraldwallet/core'; +import { + Allowance, + CreateTxStage, + FeeState, + IState, + TokenBalanceBelong, + accounts, + settings, + tokens, + txStash, +} from '@emeraldwallet/store'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Asset } from '../../../common/SelectAsset'; +import { Flow } from './flow'; + +interface OwnProps { + entryId?: EntryId; + initialAllowance?: Allowance; + initialAsset?: string; + walletId: Uuid; + onCancel(): void; +} + +interface StateProps { + asset: string; + assets: Asset[]; + createTx: workflow.AnyCreateTx; + entry: WalletEntry; + entries: WalletEntry[]; + fee: FeeState; + ownerAddress?: string; + tokenRegistry: TokenRegistry; + getBalance(entry: WalletEntry, asset: string, ownerAddress?: string): BigAmount; + getFiatBalance(asset: string): CurrencyAmount | undefined; +} + +interface DispatchProps { + getChangeAddress(entry: BitcoinEntry): Promise; + getFee(blockchain: BlockchainCode): void; + setAsset(asset: string): void; + setChangeAddress(changeAddress: string): void; + setEntry(entry: WalletEntry, ownerAddress?: string): void; + setStage(stage: CreateTxStage): void; + setTransaction(tx: workflow.AnyPlainTx): void; +} + +const SetupTransaction: React.FC = ({ + asset, + assets, + createTx, + entry, + entries, + fee, + ownerAddress, + tokenRegistry, + getBalance, + getChangeAddress, + getFee, + getFiatBalance, + onCancel, + setAsset, + setChangeAddress, + setEntry, + setStage, + setTransaction, +}) => { + const mounted = React.useRef(true); + + const { flow } = new Flow( + { asset, assets, createTx, entry, entries, fee, ownerAddress, tokenRegistry }, + { getBalance, getFiatBalance }, + { onCancel, setAsset, setEntry, setTransaction, setStage }, + ); + + React.useEffect(() => { + getFee(createTx.blockchain); + }, [createTx.blockchain, getFee]); + + React.useEffect(() => { + if (isBitcoinEntry(entry)) { + getChangeAddress(entry).then((changeAddress) => { + if (mounted.current) { + setChangeAddress(changeAddress); + } + }); + } + }, [entry, getChangeAddress, setChangeAddress]); + + React.useEffect(() => { + return () => { + mounted.current = false; + }; + }, []); + + return flow.render(); +}; + +export default connect( + (state, { entryId, initialAllowance, initialAsset, walletId }) => { + const entries = accounts.selectors.findWallet(state, walletId)?.entries.filter((entry) => !entry.receiveDisabled); + + if (entries == null || entries.length === 0) { + throw new Error('Something went wrong while getting entries from wallet.'); + } + + const { entry: originEntry, ownerAddress = initialAllowance?.ownerAddress } = txStash.selectors.getEntry(state); + + let entry: WalletEntry; + + if (originEntry == null) { + [entry] = entries; + + if (entryId != null) { + entry = entries.find(({ id }) => id === entryId) ?? entry; + } + } else { + entry = originEntry; + } + + const blockchain = blockchainIdToCode(entry.blockchain); + + const tokenRegistry = new TokenRegistry(state.application.tokens); + + const getBalance = (entry: WalletEntry, asset: string, ownerAddress?: string): BigAmount => { + if (tokenRegistry.hasAddress(blockchain, asset)) { + const token = tokenRegistry.byAddress(blockchain, asset); + const tokenZeroAmount = token.getAmount(0); + + if (entry.address == null) { + return tokenZeroAmount; + } + + return ( + tokens.selectors.selectBalance(state, blockchain, entry.address.value, token.address, { + belonging: ownerAddress == null ? TokenBalanceBelong.OWN : TokenBalanceBelong.ALLOWED, + belongsTo: ownerAddress, + }) ?? tokenZeroAmount + ); + } + + return accounts.selectors.getBalance(state, entry.id, amountFactory(blockchain)(0)); + }; + + const tokenAssets = tokenRegistry.byBlockchain(blockchain).reduce((carry, { address, symbol }) => { + const balance = getBalance(entry, address, ownerAddress); + + if (balance.isPositive()) { + return [...carry, { address, balance, symbol }]; + } + + return carry; + }, []); + + const { coinTicker } = Blockchains[blockchain].params; + + const assets = [{ balance: getBalance(entry, coinTicker), symbol: coinTicker }, ...tokenAssets]; + + let asset = txStash.selectors.getAsset(state) ?? initialAllowance?.token.address ?? initialAsset ?? coinTicker; + + if (assets.find(({ address, symbol }) => address === asset || symbol === asset) == null) { + const [{ address, symbol }] = assets; + + asset = address ?? symbol; + } + + const changeAddress = txStash.selectors.getChangeAddress(state); + const fee = txStash.selectors.getFee(state, blockchain); + const tx = txStash.selectors.getTransaction(state); + + const { createTx } = new workflow.CreateTxConverter( + { asset, changeAddress, entry, ownerAddress, feeRange: fee.range, transaction: tx }, + { + getBalance, + getUtxo(entry) { + return accounts.selectors.getUtxo(state, entry.id); + }, + }, + tokenRegistry, + ); + + return { + asset, + assets, + createTx, + entry, + entries, + fee, + ownerAddress, + tokenRegistry, + getBalance, + getFiatBalance(asset) { + const balance = getBalance(entry, asset); + + const rate = settings.selectors.fiatRate(state, balance); + + if (rate == null) { + return undefined; + } + + return CurrencyAmount.create( + balance.getNumberByUnit(balance.units.top).multipliedBy(rate).toNumber(), + settings.selectors.fiatCurrency(state), + ); + }, + }; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (dispatch: any) => ({ + async getChangeAddress(entry) { + const [{ address: changeAddress }] = await Promise.all( + entry.xpub + .filter(({ role }) => role === 'change') + .map(({ role, xpub }) => dispatch(accounts.actions.getXPubPositionalAddress(entry.id, xpub, role))), + ); + + return changeAddress; + }, + getFee(blockchain) { + dispatch(txStash.actions.getFee(blockchain)); + }, + setAsset(asset) { + dispatch(txStash.actions.setAsset(asset)); + }, + setChangeAddress(changeAddress) { + dispatch(txStash.actions.setChangeAddress(changeAddress)); + }, + setEntry(entry, ownerAddress) { + dispatch(txStash.actions.setEntry(entry, ownerAddress)); + }, + setStage(stage) { + dispatch(txStash.actions.setStage(stage)); + }, + setTransaction(tx) { + dispatch(txStash.actions.setTransaction(tx)); + }, + }), +)(SetupTransaction); diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/bitcoin/index.ts b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/bitcoin/index.ts new file mode 100644 index 000000000..331a82e75 --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/bitcoin/index.ts @@ -0,0 +1,3 @@ +/* eslint sort-exports/sort-exports: error */ + +export { BitcoinTransferFlow } from './transfer'; diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/bitcoin/transfer.tsx b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/bitcoin/transfer.tsx new file mode 100644 index 000000000..486b78b2c --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/bitcoin/transfer.tsx @@ -0,0 +1,44 @@ +import { BitcoinEntry } from '@emeraldpay/emerald-vault-core'; +import { workflow } from '@emeraldwallet/core'; +import * as React from 'react'; +import { TransferFlow } from '../../common/transfer'; +import { BitcoinFee } from '../../components'; +import { Data, DataProvider, Handler } from '../../types'; + +type BitcoinData = Data; + +export class BitcoinTransferFlow extends TransferFlow { + readonly data: BitcoinData; + + constructor(data: BitcoinData, dataProvider: DataProvider, handler: Handler) { + super(data, dataProvider, handler); + + this.data = data; + } + + private renderFee(): React.ReactElement { + const { createTx, fee } = this.data; + + if (!workflow.CreateTxConverter.isBitcoinFeeRange(fee.range)) { + throw new Error('Ethereum transaction or fee provided for Bitcoin transaction'); + } + + const { setTransaction } = this.handler; + + return ( + + ); + } + + render(): React.ReactElement { + return ( + <> + {this.renderFrom()} + {this.renderTo()} + {this.renderAmount()} + {this.renderFee()} + {this.renderActions()} + + ); + } +} diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/ethereum/index.ts b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/ethereum/index.ts new file mode 100644 index 000000000..835f306f9 --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/ethereum/index.ts @@ -0,0 +1,3 @@ +/* eslint sort-exports/sort-exports: error */ + +export { EthereumTransferFlow } from './transfer'; diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/ethereum/transfer.tsx b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/ethereum/transfer.tsx new file mode 100644 index 000000000..a40e471fa --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/ethereum/transfer.tsx @@ -0,0 +1,71 @@ +import { EthereumEntry } from '@emeraldpay/emerald-vault-core'; +import { workflow } from '@emeraldwallet/core'; +import { FormLabel, FormRow } from '@emeraldwallet/ui'; +import * as React from 'react'; +import { SelectAsset } from '../../../../../../common/SelectAsset'; +import { TransferFlow } from '../../common/transfer'; +import { EthereumFee } from '../../components'; +import { Data, DataProvider, Handler } from '../../types'; + +type EthereumData = Data; + +export class EthereumTransferFlow extends TransferFlow { + readonly data: EthereumData; + + constructor(data: EthereumData, dataProvider: DataProvider, handler: Handler) { + super(data, dataProvider, handler); + + this.data = data; + } + + private renderAsset(): React.ReactElement { + const { asset, assets, entry, ownerAddress } = this.data; + const { getBalance, getFiatBalance } = this.dataProvider; + const { setAsset } = this.handler; + + return ( + + Token + + + ); + } + + private renderFee(): React.ReactElement { + const { createTx, fee } = this.data; + + if (!workflow.CreateTxConverter.isEthereumFeeRange(fee.range)) { + throw new Error('Bitcoin transaction or fee provided for Ethereum transaction'); + } + + const { setTransaction } = this.handler; + + return ( + + ); + } + + render(): React.ReactElement { + return ( + <> + {this.renderFrom()} + {this.renderAsset()} + {this.renderTo()} + {this.renderAmount()} + {this.renderFee()} + {this.renderActions()} + + ); + } +} diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/index.ts b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/index.ts new file mode 100644 index 000000000..69cfd9b2a --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/blockchain/index.ts @@ -0,0 +1,4 @@ +/* eslint sort-exports/sort-exports: error */ + +export { BitcoinTransferFlow } from './bitcoin'; +export { EthereumTransferFlow } from './ethereum'; diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/common/index.ts b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/common/index.ts new file mode 100644 index 000000000..518277a45 --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/common/index.ts @@ -0,0 +1 @@ +export { TransferFlow } from './transfer'; diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/common/transfer.tsx b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/common/transfer.tsx new file mode 100644 index 000000000..ec9d5fcc9 --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/common/transfer.tsx @@ -0,0 +1,71 @@ +import { WalletEntry } from '@emeraldpay/emerald-vault-core'; +import { workflow } from '@emeraldwallet/core'; +import { CreateTxStage } from '@emeraldwallet/store'; +import { FormLabel, FormRow } from '@emeraldwallet/ui'; +import * as React from 'react'; +import { SelectEntry } from '../../../../../common/SelectEntry'; +import { Amount, To } from '../components'; +import { Actions } from '../components/Actions'; +import { Data, DataProvider, Handler } from '../types'; + +type CommonData = Data; + +export abstract class TransferFlow { + readonly data: CommonData; + readonly dataProvider: DataProvider; + readonly handler: Handler; + + constructor(data: CommonData, dataProvider: DataProvider, handler: Handler) { + this.data = data; + this.dataProvider = dataProvider; + this.handler = handler; + } + + abstract render(): React.ReactElement; + + renderFrom(): React.ReactNode { + const { entry, entries, ownerAddress } = this.data; + const { setEntry } = this.handler; + + return ( + + From + + + ); + } + + renderTo(): React.ReactNode { + const { createTx } = this.data; + const { setTransaction } = this.handler; + + return ; + } + + renderAmount(): React.ReactNode { + const { createTx, fee } = this.data; + const { setTransaction } = this.handler; + + return ; + } + + renderActions(): React.ReactNode { + const { createTx, entry, fee } = this.data; + const { onCancel, setEntry, setStage, setTransaction } = this.handler; + + const handleCreateTx = (): void => { + setEntry(entry); + setTransaction(createTx.dump()); + + setStage(CreateTxStage.SIGN); + }; + + return ; + } +} diff --git a/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/components/Actions.tsx b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/components/Actions.tsx new file mode 100644 index 000000000..dec952188 --- /dev/null +++ b/packages/react-app/src/transaction/CreateTransaction/SetupTransaction/flow/components/Actions.tsx @@ -0,0 +1,43 @@ +import { workflow } from '@emeraldwallet/core'; +import { Button, ButtonGroup, FormRow } from '@emeraldwallet/ui'; +import { CircularProgress, FormLabel, createStyles, makeStyles } from '@material-ui/core'; +import * as React from 'react'; + +const useStyles = makeStyles( + createStyles({ + buttons: { + display: 'flex', + justifyContent: 'end', + width: '100%', + }, + }), +); + +interface OwnProps { + createTx: workflow.AnyCreateTx; + initializing: boolean; + onCancel(): void; + onCreate(): void; +} + +export const Actions: React.FC = ({ createTx, initializing, onCancel, onCreate }) => { + const styles = useStyles(); + + return ( + + + + {initializing && ( +