Skip to content

Commit 781f8ea

Browse files
✨ (solana-signer): Add support for OCMS v1
1 parent 9d245a4 commit 781f8ea

17 files changed

Lines changed: 1363 additions & 542 deletions

File tree

.changeset/whole-doodles-invite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/device-signer-kit-solana": minor
3+
---
4+
5+
Add support for OCMS v1 in signMessage

apps/sample/src/components/SignerSolanaView/index.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type SignMessageDAError,
1616
type SignMessageDAIntermediateValue,
1717
type SignMessageDAOutput,
18+
SignMessageVersion,
1819
type SignTransactionDAError,
1920
type SignTransactionDAIntermediateValue,
2021
type SignTransactionDAOutput,
@@ -35,6 +36,13 @@ import { useDmk } from "@/providers/DeviceManagementKitProvider";
3536

3637
const DEFAULT_DERIVATION_PATH = "44'/501'/0'/0'";
3738

39+
const signMessageVersionOptions = Object.values(SignMessageVersion).map(
40+
(value) => ({
41+
label: value,
42+
value,
43+
}),
44+
);
45+
3846
export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
3947
sessionId,
4048
}) => {
@@ -116,21 +124,56 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
116124
title: "Sign off chain message",
117125
description:
118126
"Perform all the actions necessary to sign a solana off-chain message from the device",
119-
executeDeviceAction: ({ derivationPath, message, skipOpenApp }) => {
127+
executeDeviceAction: ({
128+
derivationPath,
129+
message,
130+
version,
131+
skipOpenApp,
132+
}) => {
120133
if (!signer) {
121134
throw new Error("Signer not initialized");
122135
}
123-
return signer.signMessage(derivationPath, message, { skipOpenApp });
136+
let payload: string | Uint8Array = message;
137+
if (version === SignMessageVersion.Raw) {
138+
const hex = message.replace(/\s/g, "");
139+
if (!/^([0-9a-fA-F]{2})*$/.test(hex)) {
140+
throw new Error(
141+
"Raw mode requires a valid hex string (pairs of hex digits)",
142+
);
143+
}
144+
payload = Uint8Array.from(
145+
hex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? [],
146+
);
147+
}
148+
return signer.signMessage(derivationPath, payload, {
149+
version,
150+
skipOpenApp,
151+
});
124152
},
125153
initialValues: {
126154
derivationPath: DEFAULT_DERIVATION_PATH,
127155
message: "Hello World",
156+
version: SignMessageVersion.V0,
128157
skipOpenApp: false,
129158
},
159+
valueSelector: {
160+
version: signMessageVersionOptions,
161+
},
162+
labelSelector: {
163+
derivationPath: "Derivation path",
164+
message: "Message (hex bytes for Raw mode)",
165+
version: "Signing mode",
166+
skipOpenApp: "Skip open app",
167+
},
130168
deviceModelId,
131169
} satisfies DeviceActionProps<
132170
SignMessageDAOutput,
133-
{ derivationPath: string; message: string; skipOpenApp: boolean },
171+
{
172+
derivationPath: string;
173+
message: string;
174+
version: SignMessageVersion;
175+
skipOpenApp: boolean;
176+
},
134177
SignMessageDAError,
135178
SignMessageDAIntermediateValue
136179
>,

packages/signer/signer-solana/README.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,13 @@ const subscription = observable.subscribe({
234234
235235
### Use Case 3: Sign Message
236236
237-
This method allows users to sign a text string that is displayed on Ledger devices.
237+
This method allows users to sign an off-chain message displayed on Ledger devices, following the [Solana off-chain message signing specification](https://docs.anza.xyz/proposals/off-chain-message-signing).
238238
239239
```typescript
240240
const { observable, cancel } = signerSolana.signMessage(
241241
derivationPath,
242242
message,
243+
options,
243244
);
244245
```
245246
@@ -254,15 +255,47 @@ const { observable, cancel } = signerSolana.signMessage(
254255
- `message`
255256
256257
- **Required**
257-
- **Type:** `string`
258-
- The message to be signed, which will be displayed on the Ledger device.
258+
- **Type:** `string | Uint8Array`
259+
- The message to sign. Pass a `string` for V0/V1/Legacy (UTF-8 encoded automatically). Pass a `Uint8Array` for Raw mode when you have an already-formatted binary payload.
260+
261+
- `options`
262+
263+
- Optional
264+
- Type: `MessageOptions`
265+
266+
```typescript
267+
import { SignMessageVersion } from "@ledgerhq/device-signer-kit-solana";
268+
269+
enum SignMessageVersion {
270+
Raw = "raw",
271+
Legacy = "legacy",
272+
V0 = "v0",
273+
V1 = "v1",
274+
}
275+
276+
type MessageOptions = {
277+
skipOpenApp?: boolean;
278+
version?: SignMessageVersion; // defaults to V0
279+
appDomain?: string; // V0 only
280+
};
281+
```
282+
283+
- `skipOpenApp`: Skip the automatic open-app step.
284+
- `version`: The off-chain message signing mode. Defaults to `SignMessageVersion.V0`.
285+
- **V0** (default) — original off-chain message header with `appDomain`, format detection, and up to 65 515 bytes. Falls back to Legacy on `6a81`.
286+
- **V1** — simplified header: no `appDomain`, no format byte. Up to 65 535 bytes. Falls back to V0 -> Legacy on `6a81`. Not yet supported by released firmware.
287+
- **Legacy** — compact header for backward compatibility with old Solana app firmware. Current firmware will reject it with `6a81`.
288+
- **Raw** — pass-through mode: sends the caller-provided `Uint8Array` payload directly with no header wrapping. Use when you have already built a valid off-chain message. Returns a plain base58 signature (no envelope).
289+
- `appDomain`: Application domain string for V0 headers. Encoded as UTF-8 and padded/truncated to 32 bytes. Ignored for V1, Legacy, and Raw.
259290
260291
#### **Returns**
261292
262293
- `observable` Emits DeviceActionState updates, including the following details:
263294
264295
```typescript
265-
type Signature = Uint8Array; // Signed message bytes
296+
type SignMessageOutput = {
297+
signature: string; // base58 envelope (V1/V0/Legacy) or raw base58 signature (Raw)
298+
};
266299
```
267300
268301
- `cancel` A function to cancel the action on the Ledger device.

packages/signer/signer-solana/src/api/SignerSolana.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface SignerSolana {
1717

1818
signMessage: (
1919
derivationPath: string,
20-
message: string,
20+
message: string | Uint8Array,
2121
options?: MessageOptions,
2222
) => SignMessageDAReturnType;
2323

packages/signer/signer-solana/src/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export type {
2020
SignTransactionDAOutput,
2121
SignTransactionDAReturnType,
2222
} from "@api/app-binder/SignTransactionDeviceActionTypes";
23+
export { SignMessageVersion } from "@api/model/MessageOptions";
24+
export type { MessageOptions } from "@api/model/MessageOptions";
2325
export type { Signature } from "@api/model/Signature";
2426
export type { SolanaTransactionOptionalConfig } from "@api/model/SolanaTransactionOptionalConfig";
2527
export type { Transaction } from "@api/model/Transaction";

packages/signer/signer-solana/src/api/model/MessageOptions.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,46 @@
1+
export enum SignMessageVersion {
2+
/**
3+
* Pass-through mode: sends `sendingData` directly to the device with no
4+
* header wrapping. Use this when the caller has already built a valid
5+
* off-chain message payload (e.g. a correctly formatted V0 or V1 OCM).
6+
* Returns a plain base58 signature (no envelope).
7+
*/
8+
Raw = "raw",
9+
/**
10+
* Compact V0 header without `appDomain` or signer fields.
11+
* Only recognised by older Solana app firmware versions.
12+
* Current firmware (>= 1.x) requires the full V0 header and will
13+
* reject this format with `6a81`.
14+
*/
15+
Legacy = "legacy",
16+
V0 = "v0",
17+
V1 = "v1",
18+
}
19+
120
export type MessageOptions = {
221
skipOpenApp?: boolean;
322
/**
4-
* The application domain to include in the off-chain message header
5-
* (per the Anza off-chain message signing spec).
23+
* Off-chain message signing mode. Defaults to `V0`.
24+
*
25+
* - `V0` (default) — supported on all firmware with off-chain signing.
26+
* Falls back to Legacy on `6a81`.
27+
* - `V1` — supported on firmware with V1 off-chain signing (not yet
28+
* released). Falls back to V0 on `6a81`.
29+
* - `Legacy` — for backward compatibility with very old Solana app
30+
* firmware. Current firmware will reject it.
31+
* - `Raw` — pass-through: the caller provides the fully formatted
32+
* payload (as `Uint8Array`) and the SDK sends it as-is.
33+
*
34+
* Fallback cascade on `6a81` (invalid header):
35+
* V1 -> V0 -> Legacy
36+
* V0 -> Legacy
37+
* Legacy / Raw -> no fallback
38+
*/
39+
version?: SignMessageVersion;
40+
/**
41+
* V0 only: the application domain to include in the off-chain message header.
642
* Encoded as UTF-8 and padded/truncated to 32 bytes.
7-
* If omitted, defaults to 32 zero bytes.
43+
* If omitted, defaults to 32 zero bytes. Ignored for V1, Legacy, and Raw.
844
*
945
* @see https://docs.anza.xyz/proposals/off-chain-message-signing
1046
*/

packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe("DefaultSignerSolana", () => {
5757
expect(dmk.executeDeviceAction).toHaveBeenCalled();
5858
});
5959

60-
it("should call signMessage", () => {
60+
it("should call signMessage with a string", () => {
6161
const dmk = {
6262
executeDeviceAction: vi.fn(),
6363
getLoggerFactory: vi.fn().mockReturnValue(mockLoggerFactory),
@@ -72,6 +72,24 @@ describe("DefaultSignerSolana", () => {
7272
expect(dmk.executeDeviceAction).toHaveBeenCalled();
7373
});
7474

75+
it("should call signMessage with a Uint8Array for Raw pass-through", () => {
76+
const dmk = {
77+
executeDeviceAction: vi.fn(),
78+
getLoggerFactory: vi.fn().mockReturnValue(mockLoggerFactory),
79+
} as unknown as DeviceManagementKit;
80+
const sessionId = {} as DeviceSessionId;
81+
const signer = new DefaultSignerSolana({
82+
dmk,
83+
sessionId,
84+
contextModule: contextModuleStub,
85+
});
86+
signer.signMessage(
87+
"44'/501'/0'/0'",
88+
new Uint8Array([0xff, 0x01, 0x02]),
89+
);
90+
expect(dmk.executeDeviceAction).toHaveBeenCalled();
91+
});
92+
7593
it("should call getAppConfiguration", () => {
7694
const dmk = {
7795
executeDeviceAction: vi.fn(),

packages/signer/signer-solana/src/internal/DefaultSignerSolana.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,21 +151,35 @@ export class DefaultSignerSolana implements SignerSolana {
151151

152152
/**
153153
* ## signMessage
154-
* #### Securely sign an arbitrary message on Ledger devices.
154+
* #### Sign a Solana off-chain message on Ledger devices.
155+
*
156+
* Supports multiple signing modes via `SignMessageVersion`:
157+
* - **V0** (default) — original header with `appDomain`, up to 65 515 bytes. Falls back to Legacy on `6a81`.
158+
* - **V1** — simplified header, up to 65 535 bytes. Falls back to V0 -> Legacy on `6a81`. Not yet supported by released firmware.
159+
* - **Legacy** — compact header for backward compatibility with old Solana app firmware.
160+
* - **Raw** — pass-through: sends a caller-formatted `Uint8Array` payload as-is, no header wrapping.
161+
*
155162
* ---
156163
* ### Parameters
157164
*
158165
* **Required**
159166
* - **derivationPath** `string`
160167
* The derivation path used for signing.
161168
*
162-
* - **message** `string (hex-encoded)`
163-
* The message to sign, provided as a hex string.
169+
* - **message** `string | Uint8Array`
170+
* The message to sign. Pass a `string` for V0/V1/Legacy (UTF-8 encoded
171+
* automatically). Pass a `Uint8Array` for Raw mode when you have an
172+
* already-formatted binary payload.
164173
*
165174
* **Optional**
166175
* - **options** `MessageOptions`
167176
* - **skipOpenApp** `boolean`
168177
* If `true`, skips opening the Solana app on the device.
178+
* - **version** `SignMessageVersion`
179+
* Off-chain message signing mode. Defaults to `SignMessageVersion.V0`.
180+
* - **appDomain** `string`
181+
* V0 only: application domain included in the header (padded/truncated to 32 bytes).
182+
* Ignored for V1, Legacy, and Raw.
169183
*
170184
* ---
171185
* ### Returns
@@ -177,7 +191,7 @@ export class DefaultSignerSolana implements SignerSolana {
177191
* ### Internal Flow
178192
*
179193
* Under the hood, this method subscribes to an
180-
* `Observable<DeviceActionState<Uint8Array, SignMessageDAError, IntermediateValue>>`.
194+
* `Observable<DeviceActionState<{ signature: string }, SignMessageDAError, IntermediateValue>>`.
181195
*
182196
* #### DeviceActionState
183197
* Represents the lifecycle of a device action:
@@ -203,24 +217,28 @@ export class DefaultSignerSolana implements SignerSolana {
203217
* - **Pending** → Waiting for user confirmation on the device.
204218
* Includes an `intermediateValue` of type `IntermediateValue`.
205219
* - **Stopped** → Action was cancelled before completion.
206-
* - **Completed** → Provides the signed message bytes (`Uint8Array`).
220+
* - **Completed** → Provides `{ signature: string }` — a base58 envelope
221+
* (V1/V0/Legacy) or a raw base58 signature (Raw).
207222
* - **Error** → The device or signing operation failed (`SignMessageDAError`).
208223
*
209224
* ---
210225
* ### Example
211226
*
212227
* ```ts
228+
* import { SignMessageVersion } from "@ledgerhq/device-signer-kit-solana";
229+
*
213230
* const { observable } = signerSolana.signMessage(
214231
* "m/44'/501'/0'/0'",
215-
* "48656c6c6f20576f726c64" // hex string
232+
* "Hello World",
233+
* { version: SignMessageVersion.V0 },
216234
* );
217235
* observable.subscribe({
218236
* next: state => {
219237
* if (state.status === DeviceActionStatus.Pending) {
220238
* console.log("Waiting for user action...", state.intermediateValue);
221239
* }
222240
* if (state.status === DeviceActionStatus.Completed) {
223-
* console.log("Signature:", state.output);
241+
* console.log("Signature:", state.output.signature);
224242
* }
225243
* },
226244
* error: err => console.error("Error:", err),
@@ -229,7 +247,7 @@ export class DefaultSignerSolana implements SignerSolana {
229247
*/
230248
signMessage(
231249
derivationPath: string,
232-
message: string,
250+
message: string | Uint8Array,
233251
options?: MessageOptions,
234252
): SignMessageDAReturnType {
235253
return this._container

0 commit comments

Comments
 (0)