Skip to content

Commit

Permalink
Merge pull request #1739 from lubej/bluetooth-state
Browse files Browse the repository at this point in the history
Add Bluetooth Ledger state
  • Loading branch information
lubej authored Nov 21, 2023
2 parents 398e1a5 + 46c8061 commit d5c9e2c
Show file tree
Hide file tree
Showing 23 changed files with 321 additions and 42 deletions.
1 change: 1 addition & 0 deletions .changelog/1739.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Bluetooth Ledger state
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"report-dir": "cypress-coverage"
},
"dependencies": {
"@capacitor-community/bluetooth-le": "3.0.0",
"@capacitor/core": "5.0.4",
"@ethereumjs/util": "9.0.0",
"@ledgerhq/hw-transport-webusb": "6.27.20",
"@metamask/jazzicon": "2.0.0",
Expand All @@ -73,6 +75,7 @@
"grommet-icons": "4.11.0",
"i18next": "23.6.0",
"i18next-browser-languagedetector": "7.1.0",
"@oasisprotocol/ionic-ledger-hw-transport-ble": "1.0.1-beta",
"lodash": "4.17.21",
"qrcode.react": "3.1.0",
"react": "18.2.0",
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/ErrorFormatter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ export function ErrorFormatter(props: Props) {
message,
},
),
[WalletErrors.BluetoothTransportNotSupported]: t(
'errors.bluetoothTransportNotSupported',
'Your device does not support Bluetooth.',
),
}

const error = errorMap[props.code]
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/ImportAccountsStepFormatter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ export const ImportAccountsStepFormatter = memo((props: Props) => {

const stepMap: { [code in Step]: string } = {
[Step.Idle]: t('ledger.steps.idle', 'Idle'),
[Step.OpeningUSB]: t('ledger.steps.openingUsb', 'Opening Ledger through USB'),
[Step.AccessingLedger]: t('ledger.steps.openingUsb', 'Opening Ledger through USB'),
[Step.LoadingAccounts]: t('ledger.steps.loadingAccounts', 'Loading account details'),
[Step.LoadingBalances]: t('ledger.steps.loadingBalances', 'Loading balance details'),
[Step.LoadingBleDevices]: t('ledger.steps.loadingBluetoothDevices', 'Loading bluetooth devices'),
}

const message = stepMap[step]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export interface DerivationFormatterProps {
export const DerivationFormatter = (props: DerivationFormatterProps) => {
const { t } = useTranslation()
const walletTypes: { [type in WalletType]: string } = {
[WalletType.Ledger]: t('toolbar.wallets.type.ledger', 'Ledger'),
[WalletType.Ledger]: t('toolbar.wallets.type.usbLedger', 'USB Ledger'),
[WalletType.BleLedger]: t('toolbar.wallets.type.bluetoothLedger', 'BLE Ledger'),
[WalletType.Mnemonic]: t('toolbar.wallets.type.mnemonic', 'Mnemonic'),
[WalletType.PrivateKey]: t('toolbar.wallets.type.privateKey', 'Private key'),
}
Expand Down
30 changes: 29 additions & 1 deletion src/app/lib/__tests__/ledger.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Ledger, LedgerSigner, requestDevice } from '../ledger'
import { canAccessBle, Ledger, LedgerSigner, requestDevice } from '../ledger'
import OasisApp from '@oasisprotocol/ledger'
import { WalletError, WalletErrors } from 'types/errors'
import { Wallet, WalletType } from 'app/state/wallet/types'
import { isSupported, requestLedgerDevice } from '@ledgerhq/hw-transport-webusb/lib-es/webusb'
import BleTransport from '@oasisprotocol/ionic-ledger-hw-transport-ble/lib'

jest.mock('@ledgerhq/hw-transport-webusb/lib-es/webusb')
jest.mock('@oasisprotocol/ionic-ledger-hw-transport-ble/lib', () => {
return {
isEnabled: jest.fn(),
}
})

jest.mock('@oasisprotocol/ledger', () => ({
...(jest.createMockFromModule('@oasisprotocol/ledger') as any),
Expand All @@ -31,6 +37,28 @@ describe('Ledger Library', () => {
jest.resetAllMocks()
})

describe('BLE Ledger', () => {
it('should support Bluetooth', async () => {
jest.mocked(BleTransport.isEnabled).mockResolvedValue(true)
Object.defineProperty(window.navigator, 'bluetooth', {
writable: true,
value: {
requestLEScan: jest.fn(),
},
})

const canAccessBluetooth = await canAccessBle()
expect(canAccessBluetooth).toBe(true)
})

it('should not throw if platform does not support Bluetooth', async () => {
jest.mocked(BleTransport.isEnabled).mockRejectedValue(new Error('Platform does not support Bluetooth'))

const canAccessBluetooth = await canAccessBle()
expect(canAccessBluetooth).toBe(false)
})
})

describe('Ledger', () => {
it('enumerateAccounts should pass when Oasis App is open', async () => {
mockAppIsOpen('Oasis')
Expand Down
15 changes: 13 additions & 2 deletions src/app/lib/ledger.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ContextSigner } from '@oasisprotocol/client/dist/signature'
import OasisApp, { successOrThrow } from '@oasisprotocol/ledger'
import { Response } from '@oasisprotocol/ledger/dist/types'
import { Wallet, WalletType } from 'app/state/wallet/types'
import { LedgerWalletType, Wallet, WalletType } from 'app/state/wallet/types'
import { WalletError, WalletErrors } from 'types/errors'
import { hex2uint, publicKeyToAddress } from './helpers'
import type Transport from '@ledgerhq/hw-transport'
import { isSupported, requestLedgerDevice } from '@ledgerhq/hw-transport-webusb/lib-es/webusb'
import BleTransport from '@oasisprotocol/ionic-ledger-hw-transport-ble/lib'
import { Capacitor } from '@capacitor/core'

interface LedgerAccount {
publicKey: Uint8Array
Expand All @@ -17,6 +19,13 @@ export async function canAccessNavigatorUsb(): Promise<boolean> {
return await isSupported()
}

export async function canAccessBle(): Promise<boolean> {
const hasBLE = await BleTransport.isEnabled().catch(() => false)
// Scan depends on requestLEScan method, which is not available on the web(feature flag)
const hasLEScan = Capacitor.isNativePlatform() || !!navigator?.bluetooth?.requestLEScan
return hasBLE && hasLEScan
}

export async function requestDevice(): Promise<USBDevice | undefined> {
if (await isSupported()) {
return await requestLedgerDevice()
Expand Down Expand Up @@ -99,13 +108,15 @@ export class LedgerSigner implements ContextSigner {
protected transport?: Transport
protected path: number[]
protected publicKey: Uint8Array
transportType: LedgerWalletType

constructor(wallet: Wallet) {
if (!wallet.path || wallet.type !== WalletType.Ledger) {
if (!wallet.path || (wallet.type !== WalletType.Ledger && wallet.type !== WalletType.BleLedger)) {
throw new Error('Given wallet is not a ledger wallet')
}
this.path = wallet.path
this.publicKey = hex2uint(wallet.publicKey)
this.transportType = wallet.type
}

public setTransport(transport: Transport) {
Expand Down
3 changes: 2 additions & 1 deletion src/app/pages/ConnectDevicePage/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
import { requestDevice } from 'app/lib/ledger'
import { importAccountsActions } from 'app/state/importaccounts'
import { ConnectDevicePage } from '..'
import { WalletType } from '../../../state/wallet/types'

jest.mock('app/lib/ledger')

Expand Down Expand Up @@ -31,7 +32,7 @@ describe('<ConnectDevicePage />', () => {
expect(screen.getByLabelText('Status is okay')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(mockDispatch).toHaveBeenCalledWith({
payload: undefined,
payload: WalletType.Ledger,
type: importAccountsActions.enumerateAccountsFromLedger.type,
})
})
Expand Down
3 changes: 2 additions & 1 deletion src/app/pages/ConnectDevicePage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { WalletErrors } from 'types/errors'
import { importAccountsActions } from 'app/state/importaccounts'
import { requestDevice } from 'app/lib/ledger'
import logotype from '../../../../public/logo192.png'
import { WalletType } from '../../state/wallet/types'

type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'error'
type ConnectionStatusIconPros = {
Expand Down Expand Up @@ -53,7 +54,7 @@ export function ConnectDevicePage() {
const device = await requestDevice()
if (device) {
setConnection('connected')
dispatch(importAccountsActions.enumerateAccountsFromLedger())
dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.Ledger))
}
} catch {
setConnection('error')
Expand Down
2 changes: 1 addition & 1 deletion src/app/pages/OpenWalletPage/Features/FromLedger/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function FromLedger() {
type="submit"
label={t('openWallet.importAccounts.selectWallets', 'Select accounts to open')}
onClick={() => {
dispatch(importAccountsActions.enumerateAccountsFromLedger())
dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.Ledger))
}}
primary
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function ImportAccountsSelectionModal(props: ImportAccountsSelectionModal
const onNext = () => {
dispatch(importAccountsActions.setPage(pageNum + 1))
if (props.type === 'ledger') {
dispatch(importAccountsActions.enumerateMoreAccountsFromLedger())
dispatch(importAccountsActions.enumerateMoreAccountsFromLedger(WalletType.Ledger))
}
}

Expand Down
23 changes: 21 additions & 2 deletions src/app/state/importaccounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { PayloadAction } from '@reduxjs/toolkit'
import { ErrorPayload } from 'types/errors'
import { createSlice } from 'utils/@reduxjs/toolkit'
import { ImportAccountsListAccount, ImportAccountsState, ImportAccountsStep } from './types'
import { ScanResult } from '@capacitor-community/bluetooth-le'
import { LedgerWalletType } from '../wallet/types'

export const initialState: ImportAccountsState = {
accounts: [],
showAccountsSelectionModal: false,
accountsSelectionPageNumber: 0,
step: ImportAccountsStep.Idle,
bleDevices: [],
showBleLedgerDevicesModal: false,
}

const slice = createSlice({
Expand All @@ -19,14 +23,23 @@ const slice = createSlice({
state.error = undefined
state.step = ImportAccountsStep.Idle
state.showAccountsSelectionModal = false
state.bleDevices = []
state.showBleLedgerDevicesModal = false
},
enumerateAccountsFromLedger(state) {
enumerateDevicesFromBleLedger(state) {
state.bleDevices = []
state.step = ImportAccountsStep.Idle
state.showBleLedgerDevicesModal = true
state.bleDevices = []
state.selectedBleDevice = undefined
},
enumerateAccountsFromLedger(state, _action: PayloadAction<LedgerWalletType>) {
state.accounts = []
state.accountsSelectionPageNumber = 0
state.showAccountsSelectionModal = true
state.step = ImportAccountsStep.Idle
},
enumerateMoreAccountsFromLedger(state) {
enumerateMoreAccountsFromLedger(state, _action: PayloadAction<LedgerWalletType>) {
state.step = ImportAccountsStep.Idle
},
enumerateAccountsFromMnemonic(state, _action: PayloadAction<string>) {
Expand Down Expand Up @@ -63,6 +76,12 @@ const slice = createSlice({
state.error = action.payload
state.step = ImportAccountsStep.Idle
},
setBleDevices(state, { payload }: PayloadAction<ScanResult[]>) {
state.bleDevices = payload
},
setSelectedBleDevice(state, { payload }: PayloadAction<ScanResult>) {
state.selectedBleDevice = payload
},
},
})

Expand Down
67 changes: 59 additions & 8 deletions src/app/state/importaccounts/saga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { OasisTransaction } from 'app/lib/transaction'
import { WalletType } from 'app/state/wallet/types'
import delayP from '@redux-saga/delay-p'
import { getAccountBalanceWithFallback } from '../../lib/getAccountBalanceWithFallback'
import { ScanResult } from '@capacitor-community/bluetooth-le'
import BleTransport from '@oasisprotocol/ionic-ledger-hw-transport-ble/lib'

describe('importAccounts Sagas', () => {
describe('enumerateAccountsFromLedger', () => {
Expand All @@ -26,12 +28,17 @@ describe('importAccounts Sagas', () => {
.withState({})
.provide([
[matchers.call.fn(TransportWebUSB.isSupported), true],
[matchers.call.fn(TransportWebUSB.create), { close: () => {} }],
[
matchers.call.fn(TransportWebUSB.create),
{
close: () => {},
},
],
[matchers.call.fn(Ledger.getOasisApp), undefined],
[matchers.call.fn(Ledger.deriveAccountUsingOasisApp), validAccount],
[matchers.call.fn(getAccountBalanceWithFallback), {}],
])
.dispatch(importAccountsActions.enumerateAccountsFromLedger())
.dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.Ledger))
.put.actionType(importAccountsActions.accountGenerated.type)
.put.actionType(importAccountsActions.accountGenerated.type)
.put.actionType(importAccountsActions.accountGenerated.type)
Expand All @@ -43,14 +50,53 @@ describe('importAccounts Sagas', () => {
.silentRun(50)
})

it('should list ble devices', async () => {
const bleDevices: ScanResult[] = []
for (let i = 0; i < 3; i++) {
bleDevices.push({
device: {
deviceId: `${i}${i}:${i}${i}:${i}${i}:${i}${i}:${i}${i}:${i}${i}`,
name: `Nano X ABC${i}`,
},
localName: `Nano X ABC${i}`,
rssi: -50,
txPower: 100,
})
}

return expectSaga(importAccountsSaga)
.withState({})
.provide([
[matchers.call.fn(BleTransport.isSupported), true],
[matchers.call.fn(BleTransport.list), bleDevices],
])
.dispatch(importAccountsActions.enumerateDevicesFromBleLedger)
.put.like({ action: { payload: bleDevices } })
.silentRun(50)
})

it('should handle unsupported ble', async () => {
return expectSaga(importAccountsSaga)
.withState({})
.provide([[matchers.call.fn(BleTransport.isSupported), false]])
.dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.BleLedger))
.put.like({ action: { payload: { code: WalletErrors.BluetoothTransportNotSupported } } })
.silentRun(50)
})

it('should handle unsupported browsers', async () => {
return expectSaga(importAccountsSaga)
.withState({})
.provide([
[matchers.call.fn(TransportWebUSB.isSupported), false],
[matchers.call.fn(TransportWebUSB.create), { close: () => {} }],
[
matchers.call.fn(TransportWebUSB.create),
{
close: () => {},
},
],
])
.dispatch(importAccountsActions.enumerateAccountsFromLedger())
.dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.Ledger))
.put.like({ action: { payload: { code: WalletErrors.USBTransportNotSupported } } })
.silentRun(50)
})
Expand All @@ -62,7 +108,7 @@ describe('importAccounts Sagas', () => {
[matchers.call.fn(TransportWebUSB.isSupported), true],
[matchers.call.fn(TransportWebUSB.create), Promise.reject(new Error('No device selected'))],
])
.dispatch(importAccountsActions.enumerateAccountsFromLedger())
.dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.Ledger))
.put.like({ action: { payload: { code: WalletErrors.LedgerNoDeviceSelected } } })
.silentRun(50)
})
Expand All @@ -74,7 +120,7 @@ describe('importAccounts Sagas', () => {
[matchers.call.fn(TransportWebUSB.isSupported), true],
[matchers.call.fn(TransportWebUSB.create), Promise.reject(new Error('Dummy error'))],
])
.dispatch(importAccountsActions.enumerateAccountsFromLedger())
.dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.Ledger))
.put.like({ action: { payload: { code: WalletErrors.USBTransportError, message: 'Dummy error' } } })
.silentRun(50)
})
Expand All @@ -84,11 +130,16 @@ describe('importAccounts Sagas', () => {
.withState({})
.provide([
[matchers.call.fn(TransportWebUSB.isSupported), true],
[matchers.call.fn(TransportWebUSB.create), { close: () => {} }],
[
matchers.call.fn(TransportWebUSB.create),
{
close: () => {},
},
],
[matchers.call.fn(Ledger.getOasisApp), undefined],
[matchers.call.fn(Ledger.deriveAccountUsingOasisApp), Promise.reject(new Error('Dummy error'))],
])
.dispatch(importAccountsActions.enumerateAccountsFromLedger())
.dispatch(importAccountsActions.enumerateAccountsFromLedger(WalletType.Ledger))
.put.like({ action: { payload: { code: WalletErrors.UnknownError, message: 'Dummy error' } } })
.silentRun(50)
})
Expand Down
Loading

0 comments on commit d5c9e2c

Please sign in to comment.