Skip to content

Commit 4e904cb

Browse files
authored
Improve error handling + dx (#19)
* clean up how we install bun * improve dx
1 parent 5856c85 commit 4e904cb

File tree

7 files changed

+260
-135
lines changed

7 files changed

+260
-135
lines changed

.github/workflows/publish.yaml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,23 @@ jobs:
1919

2020
publish_npm_package:
2121
runs-on: zondax-runners
22+
container:
23+
image: node:20-bookworm
24+
env:
25+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
26+
HEAD_BRANCH_NAME: ${{ github.head_ref }}
2227
steps:
2328
- name: Checkout
2429
uses: actions/checkout@v4
2530

26-
- uses: oven-sh/setup-bun@v1
27-
with:
28-
bun-version: latest
29-
3031
- name: Install node
3132
uses: actions/setup-node@v3
3233
with:
3334
registry-url: 'https://registry.npmjs.org'
3435
scope: '@zondax'
3536

37+
- uses: oven-sh/setup-bun@v1
38+
3639
- run: bun install
3740

3841
- run: bun run build

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zondax/ledger-js",
3-
"version": "0.3.0",
3+
"version": "0.0.0",
44
"description": "TS / Node API for apps running on Ledger devices",
55
"keywords": [
66
"Zondax",

src/app.test.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,6 @@ describe('BaseApp', () => {
8282
minor: 2,
8383
patch: 3,
8484
deviceLocked: false,
85-
errorMessage: 'No errors',
86-
returnCode: 36864,
8785
targetId: '0',
8886
testMode: false,
8987
})
@@ -93,9 +91,11 @@ describe('BaseApp', () => {
9391
const transport = new MockTransport(Buffer.alloc(0))
9492
transport.exchange = jest.fn().mockRejectedValue(new Error('Unknown error'))
9593
const app = new BaseApp(transport, params)
96-
const version = await app.getVersion()
97-
expect(version.returnCode).toBe(LedgerError.UnknownTransportError)
98-
expect(version.errorMessage).toBe('Unknown transport error')
94+
95+
await expect(app.getVersion()).rejects.toEqual({
96+
returnCode: LedgerError.UnknownTransportError,
97+
errorMessage: 'Unknown transport error',
98+
})
9999
})
100100
})
101101

@@ -122,9 +122,11 @@ describe('BaseApp', () => {
122122
const transport = new MockTransport(Buffer.alloc(0))
123123
transport.exchange = jest.fn().mockRejectedValue(new Error('App does not seem to be open'))
124124
const app = new BaseApp(transport, params)
125-
const appInfo = await app.appInfo()
126-
expect(appInfo.returnCode).toBe(LedgerError.UnknownTransportError)
127-
expect(appInfo.errorMessage).toBe('Unknown transport error')
125+
126+
await expect(app.appInfo()).rejects.toEqual({
127+
returnCode: LedgerError.UnknownTransportError,
128+
errorMessage: 'Unknown transport error',
129+
})
128130
})
129131
})
130132

@@ -154,9 +156,10 @@ describe('BaseApp', () => {
154156
transport.exchange = jest.fn().mockRejectedValue(new Error('Device is busy'))
155157

156158
const app = new BaseApp(transport, params)
157-
const deviceInfo = await app.deviceInfo()
158-
expect(deviceInfo.returnCode).toBe(LedgerError.UnknownTransportError)
159-
expect(deviceInfo.errorMessage).toBe('Unknown transport error')
159+
await expect(app.deviceInfo()).rejects.toEqual({
160+
returnCode: LedgerError.UnknownTransportError,
161+
errorMessage: 'Unknown transport error',
162+
})
160163
})
161164
})
162165
})

src/app.ts

Lines changed: 87 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
*****************************************************************************/
1616
import type Transport from '@ledgerhq/hw-transport'
1717

18-
import { errorCodeToString, processErrorResponse } from './common'
19-
import { HARDENED, LedgerError, PAYLOAD_TYPE } from './consts'
18+
import { processErrorResponse, processResponse } from './common'
19+
import { HARDENED, LEDGER_DASHBOARD_CLA, LedgerError, PAYLOAD_TYPE } from './consts'
2020
import {
2121
type ConstructorParams,
2222
type INSGeneric,
2323
type P1_VALUESGeneric,
2424
type ResponseAppInfo,
2525
type ResponseDeviceInfo,
26+
ResponseError,
2627
type ResponseVersion,
2728
} from './types'
2829

@@ -31,7 +32,7 @@ export default class BaseApp {
3132
readonly CLA: number
3233
readonly INS: INSGeneric
3334
readonly P1_VALUES: P1_VALUESGeneric
34-
readonly acceptedPathLengths?: number[]
35+
readonly ACCEPTED_PATH_LENGTHS?: number[]
3536
readonly CHUNK_SIZE: number
3637

3738
constructor(transport: Transport, params: ConstructorParams) {
@@ -40,7 +41,7 @@ export default class BaseApp {
4041
this.INS = params.ins
4142
this.P1_VALUES = params.p1Values
4243
this.CHUNK_SIZE = params.chunkSize
43-
this.acceptedPathLengths = params.acceptedPathLengths
44+
this.ACCEPTED_PATH_LENGTHS = params.acceptedPathLengths
4445
}
4546

4647
/**
@@ -61,13 +62,13 @@ export default class BaseApp {
6162
const pathArray = path.split('/')
6263
pathArray.shift() // remove "m"
6364

64-
if (this.acceptedPathLengths && !this.acceptedPathLengths.includes(pathArray.length)) {
65+
if (this.ACCEPTED_PATH_LENGTHS && !this.ACCEPTED_PATH_LENGTHS.includes(pathArray.length)) {
6566
throw new Error("Invalid path. (e.g \"m/44'/5757'/5'/0/3\")")
6667
}
6768

6869
const buf = Buffer.alloc(4 * pathArray.length)
6970

70-
pathArray.forEach((child, i) => {
71+
pathArray.forEach((child: string, i: number) => {
7172
let value = 0
7273

7374
if (child.endsWith("'")) {
@@ -115,7 +116,19 @@ export default class BaseApp {
115116
return chunks
116117
}
117118

118-
async signSendChunk(ins: number, chunkIdx: number, chunkNum: number, chunk: Buffer) {
119+
/**
120+
* Sends a chunk of data to the device and handles the response.
121+
* This method determines the payload type based on the chunk index and sends the chunk to the device.
122+
* It then processes the response from the device.
123+
*
124+
* @param ins - The instruction byte.
125+
* @param chunkIdx - The current chunk index.
126+
* @param chunkNum - The total number of chunks.
127+
* @param chunk - The chunk data as a buffer.
128+
* @returns A promise that resolves to the processed response from the device.
129+
* @throws {ResponseError} If the response from the device indicates an error.
130+
*/
131+
async signSendChunk(ins: number, chunkIdx: number, chunkNum: number, chunk: Buffer): Promise<Buffer> {
119132
let payloadType = PAYLOAD_TYPE.ADD
120133

121134
if (chunkIdx === 1) {
@@ -126,65 +139,61 @@ export default class BaseApp {
126139
payloadType = PAYLOAD_TYPE.LAST
127140
}
128141

129-
return this.transport.send(this.CLA, ins, payloadType, 0, chunk, [0x9000, 0x6984, 0x6a80]).then(response => {
130-
const errorCodeData = response.subarray(-2)
131-
const returnCode = errorCodeData[0] * 256 + errorCodeData[1]
142+
const statusList = [LedgerError.NoErrors, LedgerError.DataIsInvalid, LedgerError.BadKeyHandle]
132143

133-
let errorMessage = errorCodeToString(returnCode)
144+
const responseBuffer = await this.transport.send(this.CLA, ins, payloadType, 0, chunk, statusList)
145+
const response = processResponse(responseBuffer)
134146

135-
if (returnCode === LedgerError.BadKeyHandle || returnCode === LedgerError.DataIsInvalid) {
136-
errorMessage = `${errorMessage} : ${response.subarray(0, response.length - 2).toString('ascii')}`
137-
}
138-
139-
return response
140-
}, processErrorResponse)
147+
return response
141148
}
142-
143149
/**
144150
* Retrieves the version information from the device.
145151
* @returns A promise that resolves to the version information.
152+
* @throws {ResponseError} If the response from the device indicates an error.
146153
*/
147154
async getVersion(): Promise<ResponseVersion> {
148-
const versionResponse: ResponseVersion = await this.transport.send(this.CLA, this.INS.GET_VERSION, 0, 0).then((res: Buffer) => {
149-
const errorCodeData = res.subarray(-2)
150-
const returnCode = errorCodeData[0] * 256 + errorCodeData[1]
155+
try {
156+
const responseBuffer = await this.transport.send(this.CLA, this.INS.GET_VERSION, 0, 0)
157+
const response = processResponse(responseBuffer)
151158

152159
let targetId = 0
153160

154-
if (res.length >= 9) {
155-
targetId = res.readUInt32BE(5)
161+
if (response.length >= 9) {
162+
targetId = response.readUInt32BE(5)
156163
}
157164

165+
// FIXME: Add support for devices with multibyte version numbers
166+
158167
return {
159-
returnCode,
160-
errorMessage: errorCodeToString(returnCode),
161-
testMode: res[0] !== 0,
162-
major: res[1],
163-
minor: res[2],
164-
patch: res[3],
165-
deviceLocked: res[4] === 1,
168+
testMode: response[0] !== 0,
169+
major: response[1],
170+
minor: response[2],
171+
patch: response[3],
172+
deviceLocked: response[4] === 1,
166173
targetId: targetId.toString(16),
167174
}
168-
}, processErrorResponse)
169-
return versionResponse
175+
} catch (error) {
176+
throw processErrorResponse(error)
177+
}
170178
}
171179

172180
/**
173181
* Retrieves application information from the device.
174182
* @returns A promise that resolves to the application information.
183+
* @throws {ResponseError} If the response from the device indicates an error.
175184
*/
176185
async appInfo(): Promise<ResponseAppInfo> {
177-
const response: ResponseAppInfo = await this.transport.send(0xb0, 0x01, 0, 0).then((response: Buffer) => {
178-
const errorCodeData = response.subarray(response.length - 2)
179-
const returnCode: number = errorCodeData[0] * 256 + errorCodeData[1]
186+
try {
187+
const responseBuffer = await this.transport.send(LEDGER_DASHBOARD_CLA, 0x01, 0, 0)
188+
const response = processResponse(responseBuffer)
180189

181190
if (response[0] !== 1) {
182-
// Ledger responds with format ID 1. There is no spec for any format != 1
183-
return {
191+
throw {
184192
returnCode: 0x9001,
185193
errorMessage: 'Format ID not recognized',
186-
}
194+
} as ResponseError
187195
}
196+
188197
const appNameLen = response[1]
189198
const appName = response.subarray(2, 2 + appNameLen).toString('ascii')
190199
let idx = 2 + appNameLen
@@ -195,9 +204,8 @@ export default class BaseApp {
195204
const flagLen = response[idx]
196205
idx += 1
197206
const flagsValue = response[idx]
207+
198208
return {
199-
returnCode,
200-
errorMessage: errorCodeToString(returnCode),
201209
appName,
202210
appVersion,
203211
flagLen,
@@ -207,60 +215,51 @@ export default class BaseApp {
207215
flagOnboarded: (flagsValue & 4) !== 0,
208216
flagPINValidated: (flagsValue & 128) !== 0,
209217
}
210-
}, processErrorResponse)
211-
return response
218+
} catch (error) {
219+
throw processErrorResponse(error)
220+
}
212221
}
213222

214223
/**
215224
* Retrieves device information from the device.
216225
* @returns A promise that resolves to the device information.
226+
* @throws {ResponseError} If the response from the device indicates an error.
217227
*/
218228
async deviceInfo(): Promise<ResponseDeviceInfo> {
219-
const response: ResponseDeviceInfo = await this.transport
220-
.send(0xe0, 0x01, 0, 0, Buffer.from([]), [LedgerError.NoErrors, 0x6e00])
221-
.then((response: Buffer) => {
222-
const errorCodeData = response.subarray(-2)
223-
const returnCode = errorCodeData[0] * 256 + errorCodeData[1]
224-
225-
if (returnCode === 0x6e00) {
226-
const res: ResponseDeviceInfo = {
227-
returnCode,
228-
errorMessage: 'This command is only available in the Dashboard',
229-
}
230-
return res
231-
}
232-
233-
const targetId = response.subarray(0, 4).toString('hex')
234-
235-
let pos = 4
236-
const secureElementVersionLen = response[pos]
237-
pos += 1
238-
const seVersion = response.subarray(pos, pos + secureElementVersionLen).toString()
239-
pos += secureElementVersionLen
240-
241-
const flagsLen = response[pos]
242-
pos += 1
243-
const flag = response.subarray(pos, pos + flagsLen).toString('hex')
244-
pos += flagsLen
245-
246-
const mcuVersionLen = response[pos]
247-
pos += 1
248-
// Patch issue in mcu version
249-
let tmp = response.subarray(pos, pos + mcuVersionLen)
250-
if (tmp[mcuVersionLen - 1] === 0) {
251-
tmp = response.subarray(pos, pos + mcuVersionLen - 1)
252-
}
253-
const mcuVersion = tmp.toString()
254-
255-
return {
256-
returnCode,
257-
errorMessage: errorCodeToString(returnCode),
258-
targetId,
259-
seVersion,
260-
flag,
261-
mcuVersion,
262-
}
263-
}, processErrorResponse)
264-
return response
229+
try {
230+
const responseBuffer = await this.transport.send(0xe0, 0x01, 0, 0, Buffer.from([]), [LedgerError.NoErrors, 0x6e00])
231+
const response = processResponse(responseBuffer)
232+
233+
const targetId = response.subarray(0, 4).toString('hex')
234+
235+
let pos = 4
236+
const secureElementVersionLen = response[pos]
237+
pos += 1
238+
const seVersion = response.subarray(pos, pos + secureElementVersionLen).toString()
239+
pos += secureElementVersionLen
240+
241+
const flagsLen = response[pos]
242+
pos += 1
243+
const flag = response.subarray(pos, pos + flagsLen).toString('hex')
244+
pos += flagsLen
245+
246+
const mcuVersionLen = response[pos]
247+
pos += 1
248+
// Patch issue in mcu version
249+
let tmp = response.subarray(pos, pos + mcuVersionLen)
250+
if (tmp[mcuVersionLen - 1] === 0) {
251+
tmp = response.subarray(pos, pos + mcuVersionLen - 1)
252+
}
253+
const mcuVersion = tmp.toString()
254+
255+
return {
256+
targetId,
257+
seVersion,
258+
flag,
259+
mcuVersion,
260+
}
261+
} catch (error) {
262+
throw processErrorResponse(error)
263+
}
265264
}
266265
}

0 commit comments

Comments
 (0)