diff --git a/common/changes/@cityofzion/blockchain-service/CU-86dtu8ra6_2024-07-11-23-11.json b/common/changes/@cityofzion/blockchain-service/CU-86dtu8ra6_2024-07-11-23-11.json new file mode 100644 index 0000000..2306c2e --- /dev/null +++ b/common/changes/@cityofzion/blockchain-service/CU-86dtu8ra6_2024-07-11-23-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cityofzion/blockchain-service", + "comment": "Adapt interfaces to support ledger multi account", + "type": "minor" + } + ], + "packageName": "@cityofzion/blockchain-service" +} \ No newline at end of file diff --git a/common/changes/@cityofzion/bs-ethereum/CU-86dtu8ra6_2024-07-11-23-11.json b/common/changes/@cityofzion/bs-ethereum/CU-86dtu8ra6_2024-07-11-23-11.json new file mode 100644 index 0000000..e821f7d --- /dev/null +++ b/common/changes/@cityofzion/bs-ethereum/CU-86dtu8ra6_2024-07-11-23-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cityofzion/bs-ethereum", + "comment": "Add support to ledger multi account", + "type": "minor" + } + ], + "packageName": "@cityofzion/bs-ethereum" +} \ No newline at end of file diff --git a/common/changes/@cityofzion/bs-neo-legacy/CU-86dtu8ra6_2024-07-11-23-11.json b/common/changes/@cityofzion/bs-neo-legacy/CU-86dtu8ra6_2024-07-11-23-11.json new file mode 100644 index 0000000..689dbcc --- /dev/null +++ b/common/changes/@cityofzion/bs-neo-legacy/CU-86dtu8ra6_2024-07-11-23-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cityofzion/bs-neo-legacy", + "comment": "Remove AccountWithDerivationPath type", + "type": "patch" + } + ], + "packageName": "@cityofzion/bs-neo-legacy" +} \ No newline at end of file diff --git a/common/changes/@cityofzion/bs-neo3/CU-86dtu8ra6_2024-07-11-23-11.json b/common/changes/@cityofzion/bs-neo3/CU-86dtu8ra6_2024-07-11-23-11.json new file mode 100644 index 0000000..67e4d7e --- /dev/null +++ b/common/changes/@cityofzion/bs-neo3/CU-86dtu8ra6_2024-07-11-23-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cityofzion/bs-neo3", + "comment": "Add support to ledger multi account", + "type": "minor" + } + ], + "packageName": "@cityofzion/bs-neo3" +} \ No newline at end of file diff --git a/packages/blockchain-service/src/BSAggregator.ts b/packages/blockchain-service/src/BSAggregator.ts index 6117d62..66848ac 100644 --- a/packages/blockchain-service/src/BSAggregator.ts +++ b/packages/blockchain-service/src/BSAggregator.ts @@ -1,4 +1,4 @@ -import { AccountWithDerivationPath, BlockchainService, PartialNetwork } from './interfaces' +import { Account, BlockchainService, PartialNetwork } from './interfaces' export class BSAggregator< BSCustomName extends string = string, @@ -55,12 +55,12 @@ export class BSAggregator< async generateAccountFromMnemonicAllBlockchains( mnemonic: string, skippedAddresses?: string[] - ): Promise> { - const mnemonicAccounts = new Map() + ): Promise> { + const mnemonicAccounts = new Map() const promises = this.#blockchainServices.map(async service => { let index = 0 - const accounts: AccountWithDerivationPath[] = [] + const accounts: Account[] = [] let hasError = false while (!hasError) { diff --git a/packages/blockchain-service/src/interfaces.ts b/packages/blockchain-service/src/interfaces.ts index 4f0ffdc..9f9412c 100644 --- a/packages/blockchain-service/src/interfaces.ts +++ b/packages/blockchain-service/src/interfaces.ts @@ -5,10 +5,9 @@ export type Account = { key: string type: 'wif' | 'privateKey' | 'publicKey' address: string + derivationIndex?: number } -export type AccountWithDerivationPath = Account & { - derivationPath: string -} + export interface Token { symbol: string name: string @@ -52,7 +51,7 @@ export interface BlockchainService setNetwork: (partialNetwork: PartialNetwork) => void - generateAccountFromMnemonic(mnemonic: string | string, index: number): AccountWithDerivationPath + generateAccountFromMnemonic(mnemonic: string | string, index: number): Account generateAccountFromKey(key: string): Account decrypt(keyOrJson: string, password: string): Promise encrypt(key: string, password: string): Promise @@ -243,8 +242,8 @@ export type LedgerServiceEmitter = TypedEmitter<{ export interface LedgerService { emitter: LedgerServiceEmitter getLedgerTransport?: (account: Account) => Promise - getAddress(transport: Transport): Promise - getPublicKey(transport: Transport): Promise + getAccounts(transport: Transport): Promise + getAccount(transport: Transport, index: number): Promise } export type SwapRoute = { diff --git a/packages/bs-ethereum/package.json b/packages/bs-ethereum/package.json index 323a50b..a91d2cf 100644 --- a/packages/bs-ethereum/package.json +++ b/packages/bs-ethereum/package.json @@ -11,7 +11,7 @@ ], "scripts": { "build": "tsc --project tsconfig.build.json", - "test": "jest --config jest.config.ts", + "test": "jest --verbose --config jest.config.ts", "lint": "eslint .", "format": "eslint --fix" }, diff --git a/packages/bs-ethereum/src/BSEthereum.ts b/packages/bs-ethereum/src/BSEthereum.ts index e212896..6f14901 100644 --- a/packages/bs-ethereum/src/BSEthereum.ts +++ b/packages/bs-ethereum/src/BSEthereum.ts @@ -1,6 +1,5 @@ import { Account, - AccountWithDerivationPath, BSCalculableFee, BSWithLedger, BSWithNameService, @@ -58,7 +57,7 @@ export class BSEthereum getLedgerTransport?: (account: Account) => Promise ) { this.blockchainName = blockchainName - this.ledgerService = new LedgerServiceEthereum(getLedgerTransport) + this.ledgerService = new LedgerServiceEthereum(this.blockchainDataService, getLedgerTransport) this.derivationPath = DERIVATION_PATH this.tokens = [NATIVE_ASSET_BY_NETWORK_ID[network.id]] this.feeToken = NATIVE_ASSET_BY_NETWORK_ID[network.id] @@ -107,7 +106,7 @@ export class BSEthereum return true } - generateAccountFromMnemonic(mnemonic: string[] | string, index: number): AccountWithDerivationPath { + generateAccountFromMnemonic(mnemonic: string[] | string, index: number): Account { const path = this.derivationPath.replace('?', index.toString()) const wallet = ethers.Wallet.fromMnemonic(Array.isArray(mnemonic) ? mnemonic.join(' ') : mnemonic, path) @@ -115,7 +114,7 @@ export class BSEthereum address: wallet.address, key: wallet.privateKey, type: 'privateKey', - derivationPath: path, + derivationIndex: index, } } @@ -153,22 +152,7 @@ export class BSEthereum } async transfer(param: TransferParam): Promise { - const provider = new ethers.providers.JsonRpcProvider(this.network.url) - - let ledgerTransport: Transport | undefined - - if (param.isLedger) { - if (!this.ledgerService.getLedgerTransport) - throw new Error('You must provide getLedgerTransport function to use Ledger') - ledgerTransport = await this.ledgerService.getLedgerTransport(param.senderAccount) - } - - let signer: ethers.Signer - if (ledgerTransport) { - signer = new LedgerSigner(ledgerTransport, provider) - } else { - signer = new ethers.Wallet(param.senderAccount.key, provider) - } + const signer = await this.#generateSigner(param.senderAccount, param.isLedger) const decimals = param.intent.tokenDecimals ?? 18 const amount = ethersBigNumber.parseFixed(param.intent.amount, decimals) @@ -196,20 +180,7 @@ export class BSEthereum async calculateTransferFee(param: TransferParam): Promise { const provider = new ethers.providers.JsonRpcProvider(this.network.url) - let ledgerTransport: Transport | undefined - - if (param.isLedger) { - if (!this.ledgerService.getLedgerTransport) - throw new Error('You must provide getLedgerTransport function to use Ledger') - ledgerTransport = await this.ledgerService.getLedgerTransport(param.senderAccount) - } - - let signer: ethers.Signer - if (ledgerTransport) { - signer = new LedgerSigner(ledgerTransport, provider) - } else { - signer = new ethers.Wallet(param.senderAccount.key, provider) - } + const signer = await this.#generateSigner(param.senderAccount, param.isLedger) const gasPrice = await provider.getGasPrice() @@ -243,4 +214,21 @@ export class BSEthereum if (!address) throw new Error('No address found for domain name') return address } + + async #generateSigner(account: Account, isLedger?: boolean): Promise { + const provider = new ethers.providers.JsonRpcProvider(this.network.url) + + if (isLedger) { + if (!this.ledgerService.getLedgerTransport) + throw new Error('You must provide getLedgerTransport function to use Ledger') + + if (typeof account.derivationIndex !== 'number') + throw new Error('Your account must have derivationIndex to use Ledger') + + const ledgerTransport = await this.ledgerService.getLedgerTransport(account) + return new LedgerSigner(ledgerTransport, account.derivationIndex, provider) + } + + return new ethers.Wallet(account.key, provider) + } } diff --git a/packages/bs-ethereum/src/LedgerServiceEthereum.ts b/packages/bs-ethereum/src/LedgerServiceEthereum.ts index 7025cba..49026a9 100644 --- a/packages/bs-ethereum/src/LedgerServiceEthereum.ts +++ b/packages/bs-ethereum/src/LedgerServiceEthereum.ts @@ -1,60 +1,43 @@ -import { Account, LedgerService, LedgerServiceEmitter } from '@cityofzion/blockchain-service' +import { Account, BlockchainDataService, LedgerService, LedgerServiceEmitter } from '@cityofzion/blockchain-service' import Transport from '@ledgerhq/hw-transport' import LedgerEthereumApp, { ledgerService as LedgerEthereumAppService } from '@ledgerhq/hw-app-eth' import { ethers, Signer } from 'ethers' import { TypedDataSigner } from '@ethersproject/abstract-signer' import { defineReadOnly } from '@ethersproject/properties' -import { DEFAULT_PATH } from './constants' +import { DERIVATION_PATH, PUBLIC_KEY_PREFIX } from './constants' import EventEmitter from 'events' +import { retry } from './utils' export class LedgerSigner extends Signer implements TypedDataSigner { #transport: Transport #emitter?: LedgerServiceEmitter #path: string - - constructor(transport: Transport, provider?: ethers.providers.Provider, emitter?: LedgerServiceEmitter) { + #derivationIndex: number + #ledgerApp: LedgerEthereumApp + + constructor( + transport: Transport, + derivationIndex: number, + provider?: ethers.providers.Provider, + emitter?: LedgerServiceEmitter + ) { super() - this.#path = DEFAULT_PATH + this.#derivationIndex = derivationIndex + this.#path = DERIVATION_PATH.replace('?', derivationIndex.toString()) this.#transport = transport this.#emitter = emitter + this.#ledgerApp = new LedgerEthereumApp(transport) defineReadOnly(this, 'provider', provider) } connect(provider: ethers.providers.Provider): LedgerSigner { - return new LedgerSigner(this.#transport, provider, this.#emitter) - } - - #retry(callback: () => Promise): Promise { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - // Wait up to 5 seconds - for (let i = 0; i < 50; i++) { - try { - const result = await callback() - return resolve(result) - } catch (error: any) { - if (error.id !== 'TransportLocked') { - return reject(error) - } - } - await wait(100) - } - - return reject(new Error('timeout')) - }) + return new LedgerSigner(this.#transport, this.#derivationIndex, provider, this.#emitter) } async getAddress(): Promise { - const ledgerApp = new LedgerEthereumApp(this.#transport) - const { address } = await this.#retry(() => ledgerApp.getAddress(this.#path)) - return ethers.utils.getAddress(address) - } - - async getPublicKey(): Promise { - const ledgerApp = new LedgerEthereumApp(this.#transport) - const { publicKey } = await this.#retry(() => ledgerApp.getAddress(this.#path)) - return '0x' + publicKey + const { address } = await this.#ledgerApp.getAddress(this.#path) + return address } async signMessage(message: string | ethers.utils.Bytes): Promise { @@ -63,12 +46,10 @@ export class LedgerSigner extends Signer implements TypedDataSigner { message = ethers.utils.toUtf8Bytes(message) } - const ledgerApp = new LedgerEthereumApp(this.#transport) - this.#emitter?.emit('getSignatureStart') - const obj = await this.#retry(() => - ledgerApp.signPersonalMessage(this.#path, ethers.utils.hexlify(message).substring(2)) + const obj = await retry(() => + this.#ledgerApp.signPersonalMessage(this.#path, ethers.utils.hexlify(message).substring(2)) ) this.#emitter?.emit('getSignatureEnd') @@ -86,8 +67,6 @@ export class LedgerSigner extends Signer implements TypedDataSigner { async signTransaction(transaction: ethers.utils.Deferrable): Promise { try { - const ledgerApp = new LedgerEthereumApp(this.#transport) - const tx = await ethers.utils.resolveProperties(transaction) const unsignedTransaction: ethers.utils.UnsignedTransaction = { chainId: tx.chainId ?? undefined, @@ -105,8 +84,8 @@ export class LedgerSigner extends Signer implements TypedDataSigner { this.#emitter?.emit('getSignatureStart') - const signature = await this.#retry(() => - ledgerApp.signTransaction(this.#path, serializedUnsignedTransaction, resolution) + const signature = await retry(() => + this.#ledgerApp.signTransaction(this.#path, serializedUnsignedTransaction, resolution) ) this.#emitter?.emit('getSignatureEnd') @@ -142,14 +121,12 @@ export class LedgerSigner extends Signer implements TypedDataSigner { const payload = ethers.utils._TypedDataEncoder.getPayload(populated.domain, types, populated.value) - const ledgerApp = new LedgerEthereumApp(this.#transport) - this.#emitter?.emit('getSignatureStart') let obj: { v: number; s: string; r: string } try { - obj = await this.#retry(() => ledgerApp.signEIP712Message(this.#path, payload)) + obj = await retry(() => this.#ledgerApp.signEIP712Message(this.#path, payload)) } catch { const domainSeparatorHex = ethers.utils._TypedDataEncoder.hashDomain(payload.domain) const hashStructMessageHex = ethers.utils._TypedDataEncoder.hashStruct( @@ -157,8 +134,8 @@ export class LedgerSigner extends Signer implements TypedDataSigner { types, payload.message ) - obj = await this.#retry(() => - ledgerApp.signEIP712HashedMessage(this.#path, domainSeparatorHex, hashStructMessageHex) + obj = await retry(() => + this.#ledgerApp.signEIP712HashedMessage(this.#path, domainSeparatorHex, hashStructMessageHex) ) } @@ -177,27 +154,63 @@ export class LedgerSigner extends Signer implements TypedDataSigner { } export class LedgerServiceEthereum implements LedgerService { + #blockchainDataService: BlockchainDataService + emitter: LedgerServiceEmitter = new EventEmitter() as LedgerServiceEmitter + getLedgerTransport?: (account: Account) => Promise + + constructor( + blockchainDataService: BlockchainDataService, + getLedgerTransport?: (account: Account) => Promise + ) { + this.#blockchainDataService = blockchainDataService + this.getLedgerTransport = getLedgerTransport + } - constructor(public getLedgerTransport?: (account: Account) => Promise) {} + async getAccounts(transport: Transport): Promise { + const accounts: Account[] = [] + let shouldBreak = false + let index = 0 - async getAddress(transport: Transport): Promise { - const signer = new LedgerSigner(transport) - return await signer.getAddress() - } + while (!shouldBreak) { + const account = await this.getAccount(transport, index) + + if (index !== 0) { + try { + const { totalCount } = await this.#blockchainDataService.getTransactionsByAddress({ + address: account.address, + }) - async getPublicKey(transport: Transport): Promise { - const signer = new LedgerSigner(transport) - return await signer.getPublicKey() + if (!totalCount || totalCount <= 0) shouldBreak = true + } catch { + shouldBreak = true + } + } + + accounts.push(account) + index++ + } + + return accounts } - getSigner(transport: Transport): LedgerSigner { - return new LedgerSigner(transport, undefined, this.emitter) + async getAccount(transport: Transport, index: number): Promise { + const ledgerApp = new LedgerEthereumApp(transport) + const { publicKey, address } = await retry(() => + ledgerApp.getAddress(DERIVATION_PATH.replace('?', index.toString())) + ) + + const publicKeyWithPrefix = PUBLIC_KEY_PREFIX + publicKey + + return { + address, + key: publicKeyWithPrefix, + type: 'publicKey', + derivationIndex: index, + } } -} -function wait(duration: number): Promise { - return new Promise(resolve => { - setTimeout(resolve, duration) - }) + getSigner(transport: Transport, derivationIndex: number): LedgerSigner { + return new LedgerSigner(transport, derivationIndex, undefined, this.emitter) + } } diff --git a/packages/bs-ethereum/src/__tests__/BSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/BSEthereum.spec.ts index 3e12d9b..c021e0b 100644 --- a/packages/bs-ethereum/src/__tests__/BSEthereum.spec.ts +++ b/packages/bs-ethereum/src/__tests__/BSEthereum.spec.ts @@ -128,9 +128,7 @@ describe('BSEthereum', () => { it.skip('Should be able to transfer a native token with ledger', async () => { const transport = await TransportNodeHid.create() const service = new BSEthereum('neo3', { id: '11155111' }, async () => transport) - const publicKey = await service.ledgerService.getPublicKey(transport) - - const account = service.generateAccountFromPublicKey(publicKey) + const [account] = await service.ledgerService.getAccounts(transport) const transactionHash = await service.transfer({ senderAccount: account, @@ -150,8 +148,7 @@ describe('BSEthereum', () => { const transport = await TransportNodeHid.create() const service = new BSEthereum('neo3', { id: '11155111' }, async () => transport) - const publicKey = await service.ledgerService.getPublicKey(transport) - const account = service.generateAccountFromPublicKey(publicKey) + const [account] = await service.ledgerService.getAccounts(transport) const transactionHash = await service.transfer({ senderAccount: account, diff --git a/packages/bs-ethereum/src/__tests__/BitqueryBDSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/BitqueryBDSEthereum.spec.ts index 605a855..dd28f79 100644 --- a/packages/bs-ethereum/src/__tests__/BitqueryBDSEthereum.spec.ts +++ b/packages/bs-ethereum/src/__tests__/BitqueryBDSEthereum.spec.ts @@ -7,7 +7,7 @@ const bitqueryBDSEthereum = new BitqueryBDSEthereum({ name: NETWORK_NAME_BY_NETWORK_ID['1'], }) -describe('BitqueryBDSEthereum', () => { +describe.skip('BitqueryBDSEthereum', () => { it('Should be able to get transaction - %s', async () => { const hash = '0x12f994e6cecbe4495b4fdef08a2db8551943813b21f3434aa5c2356f8686fa8b' const transaction = await bitqueryBDSEthereum.getTransaction(hash) diff --git a/packages/bs-ethereum/src/__tests__/BitqueryEDSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/BitqueryEDSEthereum.spec.ts index 7acefe8..84ab511 100644 --- a/packages/bs-ethereum/src/__tests__/BitqueryEDSEthereum.spec.ts +++ b/packages/bs-ethereum/src/__tests__/BitqueryEDSEthereum.spec.ts @@ -2,7 +2,7 @@ import { BitqueryEDSEthereum } from '../BitqueryEDSEthereum' let bitqueryEDSEthereum: BitqueryEDSEthereum -describe('FlamingoEDSNeo3', () => { +describe.skip('FlamingoEDSNeo3', () => { beforeAll(() => { bitqueryEDSEthereum = new BitqueryEDSEthereum('1') }) diff --git a/packages/bs-ethereum/src/__tests__/LedgerServiceEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/LedgerServiceEthereum.spec.ts index 8f42c1a..274c728 100644 --- a/packages/bs-ethereum/src/__tests__/LedgerServiceEthereum.spec.ts +++ b/packages/bs-ethereum/src/__tests__/LedgerServiceEthereum.spec.ts @@ -1,13 +1,26 @@ import TransportNodeHid from '@ledgerhq/hw-transport-node-hid' -import { LedgerSigner } from '../LedgerServiceEthereum' +import { LedgerServiceEthereum, LedgerSigner } from '../LedgerServiceEthereum' import { ethers } from 'ethers' +import { RpcBDSEthereum } from '../RpcBDSEthereum' +import { DEFAULT_URL_BY_NETWORK_ID, NETWORK_NAME_BY_NETWORK_ID } from '../constants' +import Transport from '@ledgerhq/hw-transport' let ledgerSigner: LedgerSigner +let ledgerService: LedgerServiceEthereum +let transport: Transport describe.skip('LedgerServiceEthereum', () => { beforeAll(async () => { - const transport = await TransportNodeHid.create() - ledgerSigner = new LedgerSigner(transport) + transport = await TransportNodeHid.create() + ledgerSigner = new LedgerSigner(transport, 0) + + const blockchainDataService = new RpcBDSEthereum({ + id: '1', + name: NETWORK_NAME_BY_NETWORK_ID['1'], + url: DEFAULT_URL_BY_NETWORK_ID['1'], + }) + + ledgerService = new LedgerServiceEthereum(blockchainDataService, async () => transport) }, 60000) it('Should be able to get address', async () => { @@ -15,11 +28,6 @@ describe.skip('LedgerServiceEthereum', () => { expect(address).toBeDefined() }) - it('Should be able to get public key', async () => { - const publicKey = await ledgerSigner.getPublicKey() - expect(publicKey).toBeDefined() - }) - it('Should be able to sign a message', async () => { const message = 'Hello, World!' const signedMessage = await ledgerSigner.signMessage(message) @@ -40,9 +48,9 @@ describe.skip('LedgerServiceEthereum', () => { const signedTransaction = await ledgerSigner.signTransaction(transaction) expect(signedTransaction).toBeDefined() - }) + }, 60000) - it.only('Should be able to sign a typed data', async () => { + it('Should be able to sign a typed data', async () => { const typedData = { types: { Person: [ @@ -104,4 +112,32 @@ describe.skip('LedgerServiceEthereum', () => { expect(signatureAddress).toEqual(address) }, 60000) + + it('Should be able to get all accounts', async () => { + const accounts = await ledgerService.getAccounts(transport) + expect(accounts.length).toBeGreaterThan(1) + + accounts.forEach((account, index) => { + expect(account).toEqual( + expect.objectContaining({ + address: expect.any(String), + key: expect.any(String), + type: 'publicKey', + derivationIndex: index, + }) + ) + }) + }, 60000) + + it('Should be able to get account', async () => { + const account = await ledgerService.getAccount(transport, 0) + expect(account).toEqual( + expect.objectContaining({ + address: expect.any(String), + key: expect.any(String), + type: 'publicKey', + derivationIndex: 0, + }) + ) + }, 60000) }) diff --git a/packages/bs-ethereum/src/__tests__/RpcBDSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/RpcBDSEthereum.spec.ts index 3c130c3..9468cc1 100644 --- a/packages/bs-ethereum/src/__tests__/RpcBDSEthereum.spec.ts +++ b/packages/bs-ethereum/src/__tests__/RpcBDSEthereum.spec.ts @@ -39,7 +39,7 @@ describe('RpcBDSEthereum', () => { expect(token).toEqual({ symbol: 'ETH', - name: 'Ethereum', + name: 'ETH', hash: '-', decimals: 18, }) diff --git a/packages/bs-ethereum/src/constants.ts b/packages/bs-ethereum/src/constants.ts index a69d6dc..e7519ad 100644 --- a/packages/bs-ethereum/src/constants.ts +++ b/packages/bs-ethereum/src/constants.ts @@ -19,7 +19,8 @@ export type AvailableNetworkIds = | '12227331' export const DERIVATION_PATH = "m/44'/60'/0'/0/?" -export const DEFAULT_PATH = "44'/60'/0'/0/0" + +export const PUBLIC_KEY_PREFIX = '0x' export const NATIVE_ASSET_BY_NETWORK_ID: Record = { '1': ETH, diff --git a/packages/bs-ethereum/src/utils.ts b/packages/bs-ethereum/src/utils.ts new file mode 100644 index 0000000..c1ab793 --- /dev/null +++ b/packages/bs-ethereum/src/utils.ts @@ -0,0 +1,25 @@ +export function wait(duration: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, duration) + }) +} + +export function retry(callback: () => Promise): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + // Wait up to 5 seconds + for (let i = 0; i < 50; i++) { + try { + const result = await callback() + return resolve(result) + } catch (error: any) { + if (error.id !== 'TransportLocked') { + return reject(error) + } + } + await wait(100) + } + + return reject(new Error('timeout')) + }) +} diff --git a/packages/bs-neo-legacy/src/BSNeoLegacy.ts b/packages/bs-neo-legacy/src/BSNeoLegacy.ts index 4629f27..72c4ef3 100644 --- a/packages/bs-neo-legacy/src/BSNeoLegacy.ts +++ b/packages/bs-neo-legacy/src/BSNeoLegacy.ts @@ -8,7 +8,6 @@ import { Token, Network, TransferParam, - AccountWithDerivationPath, BSWithExplorerService, ExplorerService, PartialNetwork, @@ -78,13 +77,13 @@ export class BSNeoLegacy return wallet.isWIF(key) || wallet.isPrivateKey(key) } - generateAccountFromMnemonic(mnemonic: string[] | string, index: number): AccountWithDerivationPath { + generateAccountFromMnemonic(mnemonic: string[] | string, index: number): Account { keychain.importMnemonic(Array.isArray(mnemonic) ? mnemonic.join(' ') : mnemonic) const path = this.derivationPath.replace('?', index.toString()) const childKey = keychain.generateChildKey('neo', path) const key = childKey.getWIF() const { address } = new wallet.Account(key) - return { address, key, type: 'wif', derivationPath: path } + return { address, key, type: 'wif', derivationIndex: index } } generateAccountFromKey(key: string): Account { diff --git a/packages/bs-neo3/src/BSNeo3.ts b/packages/bs-neo3/src/BSNeo3.ts index 4baf50f..8482292 100644 --- a/packages/bs-neo3/src/BSNeo3.ts +++ b/packages/bs-neo3/src/BSNeo3.ts @@ -12,7 +12,6 @@ import { BSCalculableFee, NftDataService, BSWithNft, - AccountWithDerivationPath, BSWithExplorerService, ExplorerService, BSWithLedger, @@ -62,7 +61,7 @@ export class BSNeo3 getLedgerTransport?: (account: Account) => Promise ) { this.blockchainName = blockchainName - this.ledgerService = new LedgerServiceNeo3(getLedgerTransport) + this.ledgerService = new LedgerServiceNeo3(this.blockchainDataService, getLedgerTransport) this.tokens = TOKENS[network.id] this.derivationPath = DERIVATION_PATH this.feeToken = this.tokens.find(token => token.symbol === 'GAS')! @@ -107,13 +106,13 @@ export class BSNeo3 return true } - generateAccountFromMnemonic(mnemonic: string[] | string, index: number): AccountWithDerivationPath { + generateAccountFromMnemonic(mnemonic: string[] | string, index: number): Account { keychain.importMnemonic(Array.isArray(mnemonic) ? mnemonic.join(' ') : mnemonic) const path = this.derivationPath.replace('?', index.toString()) const childKey = keychain.generateChildKey('neo', path) const key = childKey.getWIF() const { address } = new wallet.Account(key) - return { address, key, type: 'wif', derivationPath: path } + return { address, key, type: 'wif', derivationIndex: index } } generateAccountFromPublicKey(publicKey: string): Account { @@ -147,14 +146,14 @@ export class BSNeo3 } async calculateTransferFee(param: TransferParam): Promise { - const account = new wallet.Account(param.senderAccount.key) + const { neonJsAccount } = await this.#generateSigningCallback(param.senderAccount, param.isLedger) const invoker = await NeonInvoker.init({ rpcAddress: this.network.url, - account, + account: neonJsAccount, }) - const invocations = this.buildTransferInvocation(param, account) + const invocations = this.#buildTransferInvocation(param, neonJsAccount) const { total } = await invoker.calculateFee({ invocations, @@ -165,22 +164,15 @@ export class BSNeo3 } async transfer(param: TransferParam): Promise { - let ledgerTransport: Transport | undefined - if (param.isLedger) { - if (!this.ledgerService.getLedgerTransport) - throw new Error('You must provide a getLedgerTransport function to use Ledger') - ledgerTransport = await this.ledgerService.getLedgerTransport(param.senderAccount) - } - - const account = new wallet.Account(param.senderAccount.key) + const { neonJsAccount, signingCallback } = await this.#generateSigningCallback(param.senderAccount, param.isLedger) const invoker = await NeonInvoker.init({ rpcAddress: this.network.url, - account, - signingCallback: ledgerTransport ? this.ledgerService.getSigningCallback(ledgerTransport) : undefined, + account: neonJsAccount, + signingCallback, }) - const invocations = this.buildTransferInvocation(param, account) + const invocations = this.#buildTransferInvocation(param, neonJsAccount) const transactionHash = await invoker.invokeFunction({ invocations, @@ -191,20 +183,12 @@ export class BSNeo3 } async claim(account: Account, isLedger?: boolean): Promise { - let ledgerTransport: Transport | undefined - if (isLedger) { - if (!this.ledgerService.getLedgerTransport) - throw new Error('You must provide a getLedgerTransport function to use Ledger') - ledgerTransport = await this.ledgerService.getLedgerTransport(account) - } + const { neonJsAccount, signingCallback } = await this.#generateSigningCallback(account, isLedger) - const neoAccount = new wallet.Account(account.key) const facade = await api.NetworkFacade.fromConfig({ node: this.network.url }) - const transactionHash = await facade.claimGas(neoAccount, { - signingCallback: ledgerTransport - ? this.ledgerService.getSigningCallback(ledgerTransport) - : api.signWithAccount(neoAccount), + const transactionHash = await facade.claimGas(neonJsAccount, { + signingCallback, }) return transactionHash @@ -234,10 +218,7 @@ export class BSNeo3 return address } - private buildTransferInvocation( - { intent, tipIntent }: TransferParam, - account: Neon.wallet.Account - ): ContractInvocation[] { + #buildTransferInvocation({ intent, tipIntent }: TransferParam, account: Neon.wallet.Account): ContractInvocation[] { const intents = [intent, ...(tipIntent ? [tipIntent] : [])] const invocations: ContractInvocation[] = intents.map(intent => { @@ -260,4 +241,28 @@ export class BSNeo3 return invocations } + + async #generateSigningCallback(account: Account, isLedger?: boolean) { + const neonJsAccount = new wallet.Account(account.key) + + if (isLedger) { + if (!this.ledgerService.getLedgerTransport) + throw new Error('You must provide a getLedgerTransport function to use Ledger') + + if (typeof account.derivationIndex !== 'number') + throw new Error('Your account must have derivationIndex to use Ledger') + + const ledgerTransport = await this.ledgerService.getLedgerTransport(account) + + return { + neonJsAccount, + signingCallback: this.ledgerService.getSigningCallback(ledgerTransport, account), + } + } + + return { + neonJsAccount, + signingCallback: api.signWithAccount(neonJsAccount), + } + } } diff --git a/packages/bs-neo3/src/LedgerServiceNeo3.ts b/packages/bs-neo3/src/LedgerServiceNeo3.ts index d48cf3c..4509955 100644 --- a/packages/bs-neo3/src/LedgerServiceNeo3.ts +++ b/packages/bs-neo3/src/LedgerServiceNeo3.ts @@ -1,31 +1,31 @@ -import { Account, LedgerService, LedgerServiceEmitter } from '@cityofzion/blockchain-service' +import { Account, BlockchainDataService, LedgerService, LedgerServiceEmitter } from '@cityofzion/blockchain-service' import Transport from '@ledgerhq/hw-transport' import { wallet, api, u } from '@cityofzion/neon-js' import { NeonParser } from '@cityofzion/neon-dappkit' import EventEmitter from 'events' export class LedgerServiceNeo3 implements LedgerService { + #blockchainDataService: BlockchainDataService emitter: LedgerServiceEmitter = new EventEmitter() as LedgerServiceEmitter - - constructor(public getLedgerTransport?: (account: Account) => Promise) {} - - async getAddress(transport: Transport): Promise { - const publicKey = await this.getPublicKey(transport) - const { address } = new wallet.Account(publicKey) - - return address + getLedgerTransport?: (account: Account) => Promise + + constructor( + blockchainDataService: BlockchainDataService, + getLedgerTransport?: (account: Account) => Promise + ) { + this.#blockchainDataService = blockchainDataService + this.getLedgerTransport = getLedgerTransport } - getSigningCallback(transport: Transport): api.SigningFunction { + getSigningCallback(transport: Transport, account: Account): api.SigningFunction { return async (transaction, { witnessIndex, network }) => { - const publicKey = await this.getPublicKey(transport) - const account = new wallet.Account(publicKey) + const neonJsAccount = new wallet.Account(account.key) const witnessScriptHash = wallet.getScriptHashFromVerificationScript( transaction.witnesses[witnessIndex].verificationScript.toString() ) - if (account.scriptHash !== witnessScriptHash) { + if (neonJsAccount.scriptHash !== witnessScriptHash) { throw new Error('Invalid witness script hash') } @@ -39,7 +39,7 @@ export class LedgerServiceNeo3 implements LedgerService { try { this.emitter.emit('getSignatureStart') - const bip44Buffer = this.toBip44Buffer(addressIndex) + const bip44Buffer = this.#toBip44Buffer(addressIndex) await transport.send(0x80, 0x02, 0, 0x80, bip44Buffer, [0x9000]) await transport.send(0x80, 0x02, 1, 0x80, Buffer.from(NeonParser.numToHex(networkMagic, 4, true), 'hex'), [ 0x9000, @@ -64,7 +64,7 @@ export class LedgerServiceNeo3 implements LedgerService { throw new Error(`No more data but Ledger did not return signature!`) } - const signature = this.derSignatureToHex(response.toString('hex')) + const signature = this.#derSignatureToHex(response.toString('hex')) return signature } finally { @@ -72,29 +72,61 @@ export class LedgerServiceNeo3 implements LedgerService { } } - async getPublicKey(transport: Transport, addressIndex = 0): Promise { - const bip44Buffer = this.toBip44Buffer(addressIndex) + async getAccounts(transport: Transport): Promise { + const accounts: Account[] = [] + let shouldBreak = false + let index = 0 + + while (!shouldBreak) { + const account = await this.getAccount(transport, index) + + if (index !== 0) { + try { + const { totalCount } = await this.#blockchainDataService.getTransactionsByAddress({ + address: account.address, + }) + + if (!totalCount || totalCount <= 0) shouldBreak = true + } catch { + shouldBreak = true + } + } + + accounts.push(account) + index++ + } + return accounts + } + + async getAccount(transport: Transport, index: number): Promise { + const bip44Buffer = this.#toBip44Buffer(index) const result = await transport.send(0x80, 0x04, 0x00, 0x00, bip44Buffer, [0x9000]) const publicKey = result.toString('hex').substring(0, 130) + const { address } = new wallet.Account(publicKey) - return publicKey + return { + address, + key: publicKey, + type: 'publicKey', + derivationIndex: index, + } } - private toBip44Buffer(addressIndex = 0, changeIndex = 0, accountIndex = 0) { - const accountHex = this.to8BitHex(accountIndex + 0x80000000) - const changeHex = this.to8BitHex(changeIndex) - const addressHex = this.to8BitHex(addressIndex) + #toBip44Buffer(addressIndex = 0, changeIndex = 0, accountIndex = 0) { + const accountHex = this.#to8BitHex(accountIndex + 0x80000000) + const changeHex = this.#to8BitHex(changeIndex) + const addressHex = this.#to8BitHex(addressIndex) return Buffer.from('8000002C' + '80000378' + accountHex + changeHex + addressHex, 'hex') } - private to8BitHex(num: number): string { + #to8BitHex(num: number): string { const hex = num.toString(16) return '0'.repeat(8 - hex.length) + hex } - private derSignatureToHex(response: string): string { + #derSignatureToHex(response: string): string { const ss = new u.StringStream(response) // The first byte is format. It is usually 0x30 (SEQ) or 0x31 (SET) // The second byte represents the total length of the DER module. diff --git a/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts b/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts index 0402303..8f1f85d 100644 --- a/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts +++ b/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts @@ -83,7 +83,7 @@ describe('BSNeo3', () => { expect(encryptedKey).toEqual(expect.any(String)) }) - it.skip('Should be able to calculate transfer fee', async () => { + it('Should be able to calculate transfer fee', async () => { const account = bsNeo3.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) const fee = await bsNeo3.calculateTransferFee({ @@ -96,11 +96,7 @@ describe('BSNeo3', () => { }, }) - expect(fee).toEqual({ - total: expect.any(Number), - networkFee: expect.any(Number), - systemFee: expect.any(Number), - }) + expect(Number(fee)).toEqual(expect.any(Number)) }) it.skip('Should be able to transfer', async () => { @@ -130,14 +126,14 @@ describe('BSNeo3', () => { async () => transport ) - const publicKey = await service.ledgerService.getPublicKey(transport) - - const account = service.generateAccountFromPublicKey(publicKey) + const account = await service.ledgerService.getAccount(transport, 0) const balance = await service.blockchainDataService.getBalance(account.address) const gasBalance = balance.find(b => b.token.symbol === service.feeToken.symbol) expect(Number(gasBalance?.amount)).toBeGreaterThan(0.00000001) + console.log({ account }) + const transactionHash = await service.transfer({ senderAccount: account, intent: { diff --git a/packages/bs-neo3/src/__tests__/GhostMarketNDSNeo3.spec.ts b/packages/bs-neo3/src/__tests__/GhostMarketNDSNeo3.spec.ts index 897cfac..c670851 100644 --- a/packages/bs-neo3/src/__tests__/GhostMarketNDSNeo3.spec.ts +++ b/packages/bs-neo3/src/__tests__/GhostMarketNDSNeo3.spec.ts @@ -62,5 +62,5 @@ describe('GhostMarketNDSNeo3', () => { }) expect(hasToken).toBeTruthy() } - }) + }, 10000) }) diff --git a/packages/bs-neo3/src/__tests__/LedgerServiceNeo3.spec.ts b/packages/bs-neo3/src/__tests__/LedgerServiceNeo3.spec.ts new file mode 100644 index 0000000..27a01f8 --- /dev/null +++ b/packages/bs-neo3/src/__tests__/LedgerServiceNeo3.spec.ts @@ -0,0 +1,49 @@ +import TransportNodeHid from '@ledgerhq/hw-transport-node-hid' +import { LedgerServiceNeo3 } from '../LedgerServiceNeo3' +import { DoraBDSNeo3 } from '../DoraBDSNeo3' +import { DEFAULT_URL_BY_NETWORK_TYPE, TOKENS } from '../constants' +import Transport from '@ledgerhq/hw-transport' + +let ledgerService: LedgerServiceNeo3 +let transport: Transport + +describe.skip('LedgerServiceNeo3', () => { + beforeAll(async () => { + transport = await TransportNodeHid.create() + const gasToken = TOKENS.mainnet.find(token => token.symbol === 'GAS')! + const blockchainDataService = new DoraBDSNeo3( + { id: 'mainnet', name: 'mainnet', url: DEFAULT_URL_BY_NETWORK_TYPE.mainnet }, + gasToken, + gasToken + ) + + ledgerService = new LedgerServiceNeo3(blockchainDataService, async () => transport) + }, 60000) + + it('Should be able to get all accounts', async () => { + const accounts = await ledgerService.getAccounts(transport) + expect(accounts.length).toBeGreaterThan(1) + accounts.forEach((account, index) => { + expect(account).toEqual( + expect.objectContaining({ + address: expect.any(String), + key: expect.any(String), + type: 'publicKey', + derivationIndex: index, + }) + ) + }) + }) + + it('Should be able to get account', async () => { + const account = await ledgerService.getAccount(transport, 0) + expect(account).toEqual( + expect.objectContaining({ + address: expect.any(String), + key: expect.any(String), + type: 'publicKey', + derivationIndex: 0, + }) + ) + }) +})