-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ (signer-solana): Add solana SignDataTask and utils
- Loading branch information
1 parent
f3d03c8
commit 754f2e7
Showing
5 changed files
with
351 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@ledgerhq/device-signer-kit-solana": patch | ||
--- | ||
|
||
Add solana SignDataTask and utils |
17 changes: 17 additions & 0 deletions
17
...ner/signer-solana/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { type InternalApi } from "@ledgerhq/device-management-kit"; | ||
|
||
const sendCommandMock = jest.fn(); | ||
const apiGetDeviceSessionStateMock = jest.fn(); | ||
const apiGetDeviceSessionStateObservableMock = jest.fn(); | ||
const setDeviceSessionStateMock = jest.fn(); | ||
const getMetadataForAppHashesMock = jest.fn(); | ||
|
||
export function makeDeviceActionInternalApiMock(): jest.Mocked<InternalApi> { | ||
return { | ||
sendCommand: sendCommandMock, | ||
getDeviceSessionState: apiGetDeviceSessionStateMock, | ||
getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, | ||
setDeviceSessionState: setDeviceSessionStateMock, | ||
getMetadataForAppHashes: getMetadataForAppHashesMock, | ||
}; | ||
} |
65 changes: 65 additions & 0 deletions
65
packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { | ||
APDU_MAX_PAYLOAD, | ||
ByteArrayBuilder, | ||
type Command, | ||
type CommandResult, | ||
CommandResultFactory, | ||
type InternalApi, | ||
InvalidStatusWordError, | ||
isSuccessCommandResult, | ||
} from "@ledgerhq/device-management-kit"; | ||
|
||
export type SendCommandInChunksTaskArgs<T> = { | ||
data: Uint8Array; | ||
commandFactory: CommandFactory<T>; | ||
}; | ||
|
||
export type CommandFactory<T> = < | ||
V extends ChunkableCommandArgs & Record<string, unknown>, | ||
>( | ||
args: ChunkableCommandArgs, | ||
) => Command<T, V>; | ||
|
||
export type ChunkableCommandArgs = { | ||
chunkedData: Uint8Array; | ||
}; | ||
|
||
export class SendCommandInChunksTask<T> { | ||
constructor( | ||
private api: InternalApi, | ||
private args: SendCommandInChunksTaskArgs<T>, | ||
) {} | ||
|
||
async run(): Promise<CommandResult<T, void>> { | ||
const { data: fullPayload, commandFactory } = this.args; | ||
|
||
const dataBuffer = new ByteArrayBuilder(fullPayload.length) | ||
.addBufferToData(fullPayload) | ||
.build(); | ||
|
||
for ( | ||
let offset = 0; | ||
offset < dataBuffer.length; | ||
offset += APDU_MAX_PAYLOAD | ||
) { | ||
const isLastChunk = offset + APDU_MAX_PAYLOAD >= dataBuffer.length; | ||
const result = await this.api.sendCommand( | ||
commandFactory({ | ||
chunkedData: dataBuffer.slice(offset, offset + APDU_MAX_PAYLOAD), | ||
}), | ||
); | ||
|
||
if (!isSuccessCommandResult(result)) { | ||
return result; | ||
} | ||
|
||
if (isLastChunk) { | ||
return CommandResultFactory({ | ||
data: result.data, | ||
}); | ||
} | ||
} | ||
|
||
throw new InvalidStatusWordError("No result after processing all chunks"); | ||
} | ||
} |
54 changes: 54 additions & 0 deletions
54
packages/signer/signer-solana/src/internal/app-binder/task/SignDataTask.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { | ||
ByteArrayBuilder, | ||
type CommandResult, | ||
CommandResultFactory, | ||
type InternalApi, | ||
isSuccessCommandResult, | ||
} from "@ledgerhq/device-management-kit"; | ||
import { DerivationPathUtils } from "@ledgerhq/signer-utils"; | ||
|
||
import { | ||
CommandFactory, | ||
SendCommandInChunksTask, | ||
} from "./SendCommandInChunksTask"; | ||
|
||
const PATH_SIZE = 4; | ||
|
||
type SignDataTaskArgs<T> = { | ||
sendingData: Uint8Array; | ||
derivationPath: string; | ||
commandFactory: CommandFactory<T>; | ||
}; | ||
|
||
export class SignDataTask<T> { | ||
constructor( | ||
private api: InternalApi, | ||
private args: SignDataTaskArgs<T>, | ||
) {} | ||
|
||
async run(): Promise<CommandResult<Uint8Array, void>> { | ||
const { sendingData, derivationPath, commandFactory } = this.args; | ||
|
||
const paths = DerivationPathUtils.splitPath(derivationPath); | ||
const builder = new ByteArrayBuilder( | ||
sendingData.length + 1 + paths.length * PATH_SIZE, | ||
); | ||
builder.add8BitUIntToData(paths.length); | ||
paths.forEach((path) => builder.add32BitUIntToData(path)); | ||
builder.addBufferToData(sendingData); | ||
const buffer = builder.build(); | ||
|
||
const result = await new SendCommandInChunksTask<T>(this.api, { | ||
data: buffer, | ||
commandFactory: (args) => commandFactory(args), | ||
}).run(); | ||
|
||
if (!isSuccessCommandResult(result)) { | ||
return result; | ||
} | ||
|
||
return CommandResultFactory<Uint8Array, void>({ | ||
data: result.data as Uint8Array, | ||
}); | ||
} | ||
} |
210 changes: 210 additions & 0 deletions
210
packages/signer/signer-solana/src/internal/app-binder/task/SignMessageTask.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
import { | ||
APDU_MAX_PAYLOAD, | ||
ByteArrayBuilder, | ||
CommandResultFactory, | ||
InvalidStatusWordError, | ||
isSuccessCommandResult, | ||
} from "@ledgerhq/device-management-kit"; | ||
import { DerivationPathUtils } from "@ledgerhq/signer-utils"; | ||
import { Just, Nothing } from "purify-ts"; | ||
|
||
import { SignOffChainMessageCommand } from "@internal/app-binder/command/SignOffChainMessageCommand"; | ||
import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; | ||
import { SignDataTask } from "@internal/app-binder/task/SignDataTask"; | ||
|
||
const DERIVATION_PATH = "44'/501'/0'/0'"; | ||
const PATH_SIZE = 4; | ||
|
||
describe("SignDataTask", () => { | ||
const apiMock = makeDeviceActionInternalApiMock(); | ||
const signature = new Uint8Array([ | ||
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, | ||
]); | ||
const resultOk = CommandResultFactory({ data: Just(signature) }); | ||
const resultNothing = CommandResultFactory({ data: Nothing }); | ||
|
||
beforeEach(() => { | ||
jest.resetAllMocks(); | ||
}); | ||
|
||
describe("run with SignOffChainMessageCommand", () => { | ||
const SIMPLE_MESSAGE = new Uint8Array([0x01, 0x02, 0x03, 0x04]); | ||
const EXPECTED_SIMPLE_MESSAGE = new Uint8Array([ | ||
0x04, | ||
// first path element: 44' => 0x8000002C | ||
0x80, | ||
0x00, | ||
0x00, | ||
0x2c, | ||
// second path element: 501' => 0x800001F5 | ||
0x80, | ||
0x00, | ||
0x01, | ||
0xf5, | ||
// third path element: 0' => 0x80000000 | ||
0x80, | ||
0x00, | ||
0x00, | ||
0x00, | ||
// fourth path element: 0' => 0x80000000 | ||
0x80, | ||
0x00, | ||
0x00, | ||
0x00, | ||
// message | ||
...SIMPLE_MESSAGE, | ||
]); | ||
const BIG_MESSAGE = new Uint8Array(new Array(345).fill(0x01)); | ||
|
||
it("should send the message in a single command", async () => { | ||
// GIVEN------------------------------- | ||
//------------------------------------- | ||
const args = { | ||
derivationPath: DERIVATION_PATH, | ||
sendingData: SIMPLE_MESSAGE, | ||
commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => | ||
new SignOffChainMessageCommand({ | ||
message: chunkArgs.chunkedData, | ||
}), | ||
}; | ||
apiMock.sendCommand.mockResolvedValueOnce(resultOk); | ||
|
||
// WHEN-------------------------------- | ||
//------------------------------------- | ||
const result = await new SignDataTask(apiMock, args).run(); | ||
|
||
// THEN-------------------------------- | ||
//------------------------------------- | ||
expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); | ||
expect( | ||
Array.from( | ||
(apiMock.sendCommand.mock.calls[0]?.[0] as SignOffChainMessageCommand) | ||
?.args?.message || [], | ||
), | ||
).toEqual(Array.from(EXPECTED_SIMPLE_MESSAGE)); | ||
|
||
if (isSuccessCommandResult(result)) { | ||
expect(result.data).toEqual(Just(signature)); | ||
} else { | ||
fail(`Expected a successful result, but got an error: ${result.error}`); | ||
} | ||
}); | ||
|
||
it("should send the message in chunks", async () => { | ||
// GIVEN------------------------------- | ||
//------------------------------------- | ||
const args = { | ||
derivationPath: DERIVATION_PATH, | ||
sendingData: BIG_MESSAGE, | ||
commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => | ||
new SignOffChainMessageCommand({ | ||
message: chunkArgs.chunkedData, | ||
}), | ||
}; | ||
apiMock.sendCommand | ||
.mockResolvedValueOnce(resultNothing) | ||
.mockResolvedValueOnce(resultOk); | ||
|
||
const paths = DerivationPathUtils.splitPath(DERIVATION_PATH); | ||
const builder = new ByteArrayBuilder( | ||
BIG_MESSAGE.length + 1 + paths.length * PATH_SIZE, | ||
); | ||
builder.add8BitUIntToData(paths.length); | ||
paths.forEach((path) => builder.add32BitUIntToData(path)); | ||
builder.addBufferToData(BIG_MESSAGE); | ||
const dataBuffer = builder.build(); | ||
|
||
const EXPECTED_BIG_MESSAGE_CHUNK_1 = dataBuffer.slice( | ||
0, | ||
APDU_MAX_PAYLOAD, | ||
); | ||
const EXPECTED_BIG_MESSAGE_CHUNK_2 = dataBuffer.slice(APDU_MAX_PAYLOAD); | ||
|
||
// WHEN-------------------------------- | ||
//------------------------------------- | ||
const result = await new SignDataTask(apiMock, args).run(); | ||
|
||
// THEN-------------------------------- | ||
//------------------------------------- | ||
expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); | ||
expect(apiMock.sendCommand).toHaveBeenNthCalledWith( | ||
1, | ||
expect.objectContaining({ | ||
args: { message: EXPECTED_BIG_MESSAGE_CHUNK_1 }, | ||
}), | ||
); | ||
expect(apiMock.sendCommand).toHaveBeenNthCalledWith( | ||
2, | ||
expect.objectContaining({ | ||
args: { message: EXPECTED_BIG_MESSAGE_CHUNK_2 }, | ||
}), | ||
); | ||
|
||
if (isSuccessCommandResult(result)) { | ||
expect(result.data).toEqual(Just(signature)); | ||
} else { | ||
fail(`Expected a successful result, but got an error: ${result.error}`); | ||
} | ||
}); | ||
|
||
it("should return an error if the command fails", async () => { | ||
// GIVEN------------------------------- | ||
//------------------------------------- | ||
const args = { | ||
derivationPath: DERIVATION_PATH, | ||
sendingData: SIMPLE_MESSAGE, | ||
commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => | ||
new SignOffChainMessageCommand({ | ||
message: chunkArgs.chunkedData, | ||
}), | ||
}; | ||
apiMock.sendCommand.mockResolvedValueOnce( | ||
CommandResultFactory({ | ||
error: new InvalidStatusWordError("no signature returned"), | ||
}), | ||
); | ||
|
||
// WHEN-------------------------------- | ||
//------------------------------------- | ||
const result = await new SignDataTask(apiMock, args).run(); | ||
|
||
// THEN-------------------------------- | ||
//------------------------------------- | ||
expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); | ||
expect(result).toMatchObject({ | ||
error: new InvalidStatusWordError("no signature returned"), | ||
}); | ||
}); | ||
|
||
it("should return an error if a chunk command fails", async () => { | ||
// GIVEN------------------------------- | ||
//------------------------------------- | ||
const args = { | ||
derivationPath: DERIVATION_PATH, | ||
sendingData: BIG_MESSAGE, | ||
commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => | ||
new SignOffChainMessageCommand({ | ||
message: chunkArgs.chunkedData, | ||
}), | ||
}; | ||
apiMock.sendCommand | ||
.mockResolvedValueOnce(resultNothing) | ||
.mockResolvedValueOnce( | ||
CommandResultFactory({ | ||
error: new InvalidStatusWordError("An error"), | ||
}), | ||
); | ||
|
||
// WHEN-------------------------------- | ||
//------------------------------------- | ||
const result = await new SignDataTask(apiMock, args).run(); | ||
|
||
// THEN-------------------------------- | ||
//------------------------------------- | ||
expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); | ||
expect(result).toMatchObject({ | ||
error: new InvalidStatusWordError("An error"), | ||
}); | ||
}); | ||
}); | ||
}); |