diff --git a/src/app/modules/catapultOptin/catapultOptin/catapultOptin.controller.js b/src/app/modules/catapultOptin/catapultOptin/catapultOptin.controller.js index 54d7deea..93bf888b 100755 --- a/src/app/modules/catapultOptin/catapultOptin/catapultOptin.controller.js +++ b/src/app/modules/catapultOptin/catapultOptin/catapultOptin.controller.js @@ -100,6 +100,26 @@ class NormalOptInCtrl { this.statusLoading = true; this.isOptedIn = false; this.optinStopped = false; + + this.loadingSymbolLedgerInfo = false; + } + + exportSymbolAddress() { + this.loadingSymbolLedgerInfo = true; + this._CatapultOptin.getSymbolOptInAddress(DEFAULT_ACCOUNT_PATH).then( publicAccount => { + this.loadingSymbolLedgerInfo = false; + this.formData.optinAddress = publicAccount.address.pretty(); + this.formData.optinAccount = { + address: publicAccount.address, + keyPair: { + publicKey: publicAccount.publicKey + }, + publicAccount, + }; + }).catch(e => { + this.loadingSymbolLedgerInfo = false; + console.log(e); + }); } /** @@ -304,6 +324,7 @@ class NormalOptInCtrl { getEntropy() { // Prepare + if (this._Wallet.algo) return; let elem = document.getElementById("pBarOptIn"); this.formData.entropyWidth = 0; this.formData.entropy = ""; diff --git a/src/app/modules/catapultOptin/catapultOptin/catapultOptin.html b/src/app/modules/catapultOptin/catapultOptin/catapultOptin.html index c36c4a8c..078d487b 100755 --- a/src/app/modules/catapultOptin/catapultOptin/catapultOptin.html +++ b/src/app/modules/catapultOptin/catapultOptin/catapultOptin.html @@ -38,13 +38,13 @@

-
+

{{'CREATE_SYMBOL_ACCOUNT' | translate}}

-
+
@@ -77,6 +77,49 @@

{{'CREATE_SYMBOL_ACCOUNT' | translate}}

+ +
+
+ +

{{'CREATE_SYMBOL_ACCOUNT' | translate}}

+
+
+
+
+ +
+ +
+ +
+

{{'LEDGER_CREATE_AND_EXPORT_SYMBOL_ADDRESS' | translate}}

+

{{'SWAP_TO_SYMBOL_BOLOS_APP' | translate}}

+
+
+
+

{{ 'EXPORTING_ADDRESS_FOLLOW_INSTRUCTIONS' | translate }}

+ +
+

{{ 'YOUR_SYMBOL_ADDRESS' | translate }}

+ + +
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
@@ -92,7 +135,7 @@

{{'OPTIN_SYMBOL_READY' | translate}}

-
+

{{ 'YOUR_SYMBOL_MNEMONIC' | translate }}

@@ -104,7 +147,6 @@

{{'OPTIN_SYMBOL_READY' | translate}}

-
@@ -268,7 +310,7 @@

- +
-
+

{{ 'YOUR_SYMBOL_MNEMONIC' | translate }}

diff --git a/src/app/modules/languages/en.js b/src/app/modules/languages/en.js index fb021b02..eb912dc0 100644 --- a/src/app/modules/languages/en.js +++ b/src/app/modules/languages/en.js @@ -992,8 +992,11 @@ function EnglishProvider($translateProvider) { CREATE_SYMBOL_ACCOUNT: 'Create your Symbol account', OPTIN_SYMBOL_READY: 'Your Symbol account is ready', OPTIN_COPY_SUCCESS: 'Copied!', - CATAPULT_OPT_IN_ERROR_TOO_MUCH_COSIGNATORIES: 'This account has more than 8 cosignatories. Opt In protocol only allows multisig accounts with less than 9 cosignatories' + CATAPULT_OPT_IN_ERROR_TOO_MUCH_COSIGNATORIES: 'This account has more than 8 cosignatories. Opt In protocol only allows multisig accounts with less than 9 cosignatories', + LEDGER_CREATE_AND_EXPORT_SYMBOL_ADDRESS: 'You must create a symbol account on ledger to receive the funds', + EXPORTING_ADDRESS_FOLLOW_INSTRUCTIONS: 'Exporting address, follow instructions on your ledger device', + SWAP_TO_SYMBOL_BOLOS_APP: 'Open the Symbol app on ledger', }); } diff --git a/src/app/modules/ledger/hw-app-symbol.js b/src/app/modules/ledger/hw-app-symbol.js new file mode 100644 index 00000000..2ee0cf18 --- /dev/null +++ b/src/app/modules/ledger/hw-app-symbol.js @@ -0,0 +1,219 @@ +// internal dependencies +import * as BIPPath from 'bip32-path'; +// configuration +import { Transaction, SignedTransaction, Convert, CosignatureSignedTransaction, AggregateTransaction } from 'symbol-sdk'; + +const SUPPORT_VERSION = { LEDGER_MAJOR_VERSION: '0', LEDGER_MINOR_VERSION: '0', LEDGER_PATCH_VERSION: '4' }; +const CLA_FIELD = 0xe0; +/** + * Symbol's API + * + * @example + * import { SymbolLedger } from '@/core/utils/Ledger' + * const sym = new SymbolLedger(); + "44'/4343'/account'/change/accountIndex" + */ + +export default class SymbolHw { + + constructor(transport, scrambleKey = "NEM") { + this.transport = transport; + transport.decorateAppAPIMethods(this, + ['isAppSupported', 'getAccount', 'signTransaction', 'signCosignatureTransaction'], + scrambleKey); + } + + /** + * Return true if app version is above the supported Symbol BOLOS app version + * @return {boolean} + */ + async isAppSupported() { + const appVersion = await this.getAppVersion(); + if (appVersion.majorVersion < SUPPORT_VERSION.LEDGER_MAJOR_VERSION) { + return false; + } else if (appVersion.minorVersion < SUPPORT_VERSION.LEDGER_MINOR_VERSION) { + return false; + } else if (appVersion.patchVersion < SUPPORT_VERSION.LEDGER_PATCH_VERSION) { + return false; + } else { + return true; + } + } + + /** + * Get Symbol BOLOS app version + * + * @return an object contain major, minor, patch version of the Symbol BOLOS app + */ + async getAppVersion() { + // APDU fields configuration + const apdu = { + cla: 0xe0, + ins: 0x06, + p1: 0x00, + p2: 0x00, + data: Buffer.alloc(1, 0x00, 'hex'), + }; + // Response from Ledger + const response = await this.transport.send(apdu.cla, apdu.ins, apdu.p1, apdu.p2, apdu.data); + const result = { + majorVersion: '', + minorVersion: '', + patchVersion: '', + }; + result.majorVersion = response[1]; + result.minorVersion = response[2]; + result.patchVersion = response[3]; + return result; + } + + /** + * get Symbol's address for a given BIP 44 path from the Ledger + * + * @param path a path in BIP 44 format + * @param display optionally enable or not the display + * @param chainCode optionally enable or not the chainCode request + * @param ed25519 + * @return an object with a publicKey, address and (optionally) chainCode + * @example + * const result = await Ledger.getAccount(bip44path); + * const { publicKey } = result; + */ + async getAccount(path, networkType, display) { + const GET_ACCOUNT_INS_FIELD = 0x02; + const chainCode = false; + + const bipPath = BIPPath.fromString(path).toPathArray(); + const curveMask = 0x80; // use Curve25519 + + // APDU fields configuration + const apdu = { + cla: CLA_FIELD, + ins: GET_ACCOUNT_INS_FIELD, + p1: display ? 0x01 : 0x00, + p2: curveMask | (chainCode ? 0x01 : 0x00), + data: Buffer.alloc(1 + bipPath.length * 4 + 1), + }; + + apdu.data.writeInt8(bipPath.length, 0); + bipPath.forEach((segment, index) => { + apdu.data.writeUInt32BE(segment, 1 + index * 4); + }); + apdu.data.writeUInt8(networkType, 1 + bipPath.length * 4); + + // Response from Ledger + const response = await this.transport.send(apdu.cla, apdu.ins, apdu.p1, apdu.p2, apdu.data); + const result = { + publicKey: '', + }; + + const publicKeyLength = response[0]; + result.publicKey = response.slice(1, 1 + publicKeyLength).toString('hex'); + return result; + } + + /** + * TODO: sign a Symbol transaction by account on Ledger at given BIP 44 path + * + * @param path a path in BIP 44 format + * @param transferTransaction a transfer transaction needs to be signed + * @param networkGenerationHash the network generation hash of block 1 + * @param signerPublicKey the public key of signer + * @return a signed Transaction which is signed by account at path on Ledger + */ + async signTransaction(path, transferTransaction, networkGenerationHash, signerPublicKey) { + const rawPayload = transferTransaction.serialize(); + const signingBytes = networkGenerationHash + rawPayload.slice(216); + const rawTx = Buffer.from(signingBytes, 'hex'); + const response = await this.ledgerMessageHandler(path, rawTx); + // Response from Ledger + const h = response.toString('hex'); + const signature = h.slice(0, 128); + const payload = rawPayload.slice(0, 16) + signature + signerPublicKey + rawPayload.slice(16 + 128 + 64, rawPayload.length); + const generationHashBytes = Array.from(Convert.hexToUint8(networkGenerationHash)); + const transactionHash = Transaction.createTransactionHash(payload, generationHashBytes); + const signedTransaction = new SignedTransaction( + payload, + transactionHash, + signerPublicKey, + transferTransaction.type, + transferTransaction.networkType, + ); + return signedTransaction; + } + + /** + * TODO: sign a Symbol Cosignature transaction with a given BIP 44 path + * + * @param path a path in BIP 44 format + * @param transferTransaction a transfer transaction needs to be signed + * @param signerPublicKey the public key of signer + * @return a Signed Cosignature Transaction + */ + async signCosignatureTransaction(path, cosignatureTransaction, signerPublicKey) { + const rawPayload = cosignatureTransaction.serialize(); + const signingBytes = cosignatureTransaction.transactionInfo.hash + rawPayload.slice(216); + const rawTx = Buffer.from(signingBytes, 'hex'); + const response = await this.ledgerMessageHandler(path, rawTx); + // Response from Ledger + const h = response.toString('hex'); + const signature = h.slice(0, 128); + const cosignatureSignedTransaction = new CosignatureSignedTransaction( + cosignatureTransaction.transactionInfo.hash, + signature, + signerPublicKey, + ); + return cosignatureSignedTransaction; + } + /** + * handle sending and receiving packages between Ledger and Wallet + * @param path a path in BIP 44 format + * @param rawTx a raw payload transaction hex string + * @returns respond package from Ledger + */ + async ledgerMessageHandler(path, rawTx) { + const TX_INS_FIELD = 0x04; + const MAX_CHUNK_SIZE = 255; + const CONTINUE_SENDING = '0x9000'; + + const chainCode = false; + + const curveMask = 0x80; // use Curve25519 + const bipPath = BIPPath.fromString(path).toPathArray(); + const apduArray = []; + let offset = 0; + + while (offset !== rawTx.length) { + const maxChunkSize = offset === 0 ? MAX_CHUNK_SIZE - 1 - bipPath.length * 4 : MAX_CHUNK_SIZE; + const chunkSize = offset + maxChunkSize > rawTx.length ? rawTx.length - offset : maxChunkSize; + // APDU fields configuration + const apdu = { + cla: CLA_FIELD, + ins: TX_INS_FIELD, + p1: offset === 0 ? (chunkSize < maxChunkSize ? 0x00 : 0x80) : chunkSize < maxChunkSize ? 0x01 : 0x81, + p2: curveMask | (chainCode ? 0x01 : 0x00), + data: offset === 0 ? Buffer.alloc(1 + bipPath.length * 4 + chunkSize) : Buffer.alloc(chunkSize), + }; + + if (offset === 0) { + apdu.data.writeInt8(bipPath.length, 0); + bipPath.forEach((segment, index) => { + apdu.data.writeUInt32BE(segment, 1 + index * 4); + }); + rawTx.copy(apdu.data, 1 + bipPath.length * 4, offset, offset + chunkSize); + } else { + rawTx.copy(apdu.data, 0, offset, offset + chunkSize); + } + apduArray.push(apdu); + offset += chunkSize; + } + let response = Buffer.alloc(0); + for (const apdu of apduArray) { + response = await this.transport.send(apdu.cla, apdu.ins, apdu.p1, apdu.p2, apdu.data); + } + + if (response.toString() != CONTINUE_SENDING) { + return response; + } + } +} diff --git a/src/app/modules/ledger/ledger.service.js b/src/app/modules/ledger/ledger.service.js index 89f5fe84..562e8c68 100644 --- a/src/app/modules/ledger/ledger.service.js +++ b/src/app/modules/ledger/ledger.service.js @@ -1,6 +1,7 @@ import nem from "nem-sdk"; const TransportNodeHid = window['TransportNodeHid'] && window['TransportNodeHid'].default; import NemH from "./hw-app-nem"; +import SymbolH from "./hw-app-symbol"; const SUPPORT_VERSION = { LEDGER_MAJOR_VERSION: 0, LEDGER_MINOR_VERSION: 0, @@ -37,7 +38,7 @@ class Ledger { /** * Pop-up alert handler */ - alertHandler(inputErrorCode, isTxSigning, txStatusText) { + alertHandler(inputErrorCode, isTxSigning, txStatusText, isSymbol) { switch (inputErrorCode) { case 'NoDevice': this._Alert.ledgerDeviceNotFound(); @@ -49,7 +50,11 @@ class Ledger { this._Alert.ledgerNotOpenApp(); break; case 27264: - this._Alert.ledgerNotUsingNemApp(); + if (isSymbol) { + this._Alert.ledgerNotUsingSymbolApp(); + } else { + this._Alert.ledgerNotUsingNemApp(); + } break; case 27013: isTxSigning ? this._Alert.ledgerTransactionCancelByUser() : this._Alert.ledgerRequestCancelByUser(); @@ -135,6 +140,21 @@ class Ledger { }); } + showSymbolAccount(account) { + alert("Please check your Ledger device!"); + this._Alert.ledgerFollowInstruction(); + return new Promise((resolve, reject) => { + this.getSymbolAccount(account.hdKeypath, account.network).then((result) => { + resolve(result.publicKey); + }).catch(e => { + this._$timeout(() => { + this.alertHandler(e, undefined, undefined, true); + reject(e); + }); + }); + }); + } + async getAccount(hdKeypath, network, label) { try { const transport = await TransportNodeHid.open(""); @@ -171,6 +191,28 @@ class Ledger { } } + async getSymbolAccount(hdKeypath, network) { + try { + const transport = await TransportNodeHid.open(""); + const symbolH = new SymbolH(transport); + try { + return await symbolH.getAccount(hdKeypath, network, true); + } catch (err) { + throw err + } finally { + transport.close(); + } + } catch (err) { + if (err.statusCode != null) { + return Promise.reject(err.statusCode); + } else if (err.id != null) { + return Promise.reject(err.id); + } else { + return Promise.reject(err); + } + } + } + async getRemoteAccount(hdKeypath) { try { const transport = await TransportNodeHid.open(""); diff --git a/src/app/services/alert.service.js b/src/app/services/alert.service.js index d08bbb8b..ac6cfc87 100644 --- a/src/app/services/alert.service.js +++ b/src/app/services/alert.service.js @@ -588,6 +588,13 @@ export default class Alert { }); } + ledgerNotUsingSymbolApp() { + this._ngToast.create({ + content: this._$filter('translate')('NANO_LEDGER_NOT_USING_SYMBOL_APP'), + className: 'danger' + }); + } + ledgerNotSupportApp() { this._ngToast.create({ content: this._$filter('translate')('NANO_LEDGER_NOT_SUPPORTED_APP'), diff --git a/src/app/services/catapultOptin.service.js b/src/app/services/catapultOptin.service.js index 0bc3e252..b4a1b38d 100644 --- a/src/app/services/catapultOptin.service.js +++ b/src/app/services/catapultOptin.service.js @@ -72,6 +72,20 @@ class CatapultOptin { }; } + getSymbolOptInAddress(hdPath) { + const config = this.getOptinConfig(); + return new Promise((resolve, reject) => { + this._Ledger.showSymbolAccount({ hdKeypath: hdPath, network: config.CATNetwork }) + .then(publicKey => { + const publicAccount = PublicAccount.createFromPublicKey(publicKey, config.CATNetwork); + resolve(publicAccount); + }).catch(e => { + console.log(e); + reject(e); + }) + }); + } + getNormalCache(account, forceRefresh = false) { return new Promise(resolve => { if (!this.normalCaches[account.account.address] || forceRefresh) {