Migration from @ledgerhq/hw-app-solana to @ledgerhq/device-signer-kit-solana
This guide walks you through migrating from the legacy LedgerJS Solana package (@ledgerhq/hw-app-solana) to the new Device Management Kit (DMK) Solana Signer (@ledgerhq/device-signer-kit-solana).
Why migrate?
| Feature | hw-app-solana (legacy) | device-signer-kit-solana (DMK) |
|---|---|---|
| API style | Promise-based, imperative | Observable-based, reactive |
| Transport management | Manual — you create and manage the Transport | Automatic — DMK handles discovery, connection, reconnection |
| App lifecycle | Manual — you must open the Solana app yourself | Automatic — the signer opens the Solana app for you |
| Clear signing | Not built-in — you must call provideTrustedName / provideTrustedDynamicDescriptor manually | Built-in — the context module resolves token metadata automatically |
| Device state | No visibility | Full observable state machine (pending, user interaction, etc.) |
| Cancellation | Not supported | Built-in cancel() on every operation |
| Off-chain messages | Single method (signOffchainMessage) | Unified signMessage with version support (V0, V1, Legacy, Raw) |
| Multi-device | One transport = one device | Session-based — multiple devices on a single DMK instance |
| Error handling | Raw status codes / thrown errors | Typed error states per operation |
Step 1: Update your dependencies
- npm install @ledgerhq/hw-app-solana @ledgerhq/hw-transport-webhid
+ npm install @ledgerhq/device-management-kit @ledgerhq/device-signer-kit-solana @ledgerhq/device-transport-kit-web-hidStep 2: Update initialisation
The DMK replaces manual transport creation with a builder pattern that manages device discovery and connection for you.
Before (hw-app-solana)
import TransportWebHID from "@ledgerhq/hw-transport-webhid";
import Solana from "@ledgerhq/hw-app-solana";
const transport = await TransportWebHID.create();
const solana = new Solana(transport);After (DMK Solana Signer)
import { DeviceManagementKitBuilder } from "@ledgerhq/device-management-kit";
import { webHidTransportFactory } from "@ledgerhq/device-transport-kit-web-hid";
import { SignerSolanaBuilder } from "@ledgerhq/device-signer-kit-solana";
// 1. Build the DMK (once, at app startup)
const dmk = new DeviceManagementKitBuilder()
.addTransport(webHidTransportFactory)
.build();
// 2. Discover and connect (replaces Transport.create())
const sessionId = await new Promise<string>((resolve) => {
const sub = dmk.startDiscovering({}).subscribe({
next: async (device) => {
const id = await dmk.connect({ device });
sub.unsubscribe();
resolve(id);
},
});
});
// 3. Create the signer (replaces `new Solana(transport)`)
const signer = new SignerSolanaBuilder({ dmk, sessionId }).build();Step 3: Migrate API calls
Each method now returns { observable, cancel } instead of a Promise. Subscribe to the observable to receive DeviceActionState updates.
import { DeviceActionStatus } from "@ledgerhq/device-management-kit";getAddress
Before
const { address } = await solana.getAddress("44'/501'/0'", true);
// address is a Buffer
const hexAddress = address.toString("hex");
// or encode as base58: const base58Address = bs58.encode(address);After
const { observable } = signer.getAddress("44'/501'/0'", {
checkOnDevice: true,
});
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const base58Address = state.output; // already base58
}
},
});Key differences:
- The
display: booleanparameter becomes{ checkOnDevice: boolean }. - The address is returned as a base58
stringdirectly. - The signer automatically opens the Solana app if needed.
signTransaction
Before
const { signature } = await solana.signTransaction("44'/501'/0'", txBuffer);
// signature is a BufferAfter
const { observable } = signer.signTransaction("44'/501'/0'", txBytes, {
// optional clear-signing context
transactionResolutionContext: {
tokenAddress: "EPjFWdd5...",
},
});
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const signature = state.output; // Uint8Array
}
},
});Key differences:
- Input is
Uint8Arrayinstead ofBuffer(thoughBufferextendsUint8Array, so existing buffers work). - The
userInputTypeparameter ("ata" | "sol") is replaced by the richertransactionResolutionContextin the options object. - Clear-signing metadata (trusted names, descriptors) is resolved automatically by the built-in context module — you no longer need to call
provideTrustedNameorprovideTrustedDynamicDescriptormanually.
signOffchainMessage → signMessage
Before
const { signature } = await solana.signOffchainMessage(
"44'/501'/0'",
msgBuffer,
);
// signature is a BufferAfter
import { SignMessageVersion } from "@ledgerhq/device-signer-kit-solana";
const { observable } = signer.signMessage(
"44'/501'/0'",
"Hello World", // string or Uint8Array
{
version: SignMessageVersion.V0,
appDomain: "myapp.xyz",
},
);
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const signature = state.output.signature; // base58 string
}
},
});Key differences:
- Method renamed from
signOffchainMessagetosignMessage. - Accepts a
string(auto-encoded as UTF-8) orUint8Array. - Supports multiple signing versions (V0, V1, Legacy, Raw) with automatic fallback.
- Signature is returned as a base58 string, not a
Buffer.
getAppConfiguration
Before
const config = await solana.getAppConfiguration();
// { blindSigningEnabled: boolean, pubKeyDisplayMode: number, version: string }After
const { observable } = signer.getAppConfiguration();
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const { blindSigningEnabled, pubKeyDisplayMode, version } = state.output;
}
},
});Key differences:
- Same data shape; only the invocation pattern changes from promise to observable.
getChallenge / provideTrustedName / provideTrustedDynamicDescriptor
These low-level methods do not have direct equivalents in the DMK signer. Their functionality is handled automatically by the context module during signTransaction. The context module resolves token metadata, trusted names, and descriptors transparently.
If you need to send raw APDUs for advanced use cases, the DMK provides dmk.sendCommand() and dmk.sendApdu() on the core DeviceManagementKit instance.
Step 4: Converting from Promises to Observables
If your existing codebase is heavily promise-based, you can create a helper to convert the observable pattern back to promises:
import { type Observable } from "rxjs";
import { DeviceActionStatus } from "@ledgerhq/device-management-kit";
function toPromise<Output>(action: {
observable: Observable<{
status: string;
output?: Output;
error?: unknown;
}>;
}): Promise<Output> {
return new Promise((resolve, reject) => {
const subscription = action.observable.subscribe({
next: (state: any) => {
if (state.status === DeviceActionStatus.Completed) {
subscription.unsubscribe();
resolve(state.output);
} else if (state.status === DeviceActionStatus.Error) {
subscription.unsubscribe();
reject(state.error);
} else if (state.status === DeviceActionStatus.Stopped) {
subscription.unsubscribe();
reject(new Error("Action cancelled"));
}
},
error: (err: unknown) => {
subscription.unsubscribe();
reject(err);
},
});
});
}
// Usage — feels like the old API:
const publicKey = await toPromise<string>(signer.getAddress("44'/501'/0'"));
const signature = await toPromise(
signer.signTransaction("44'/501'/0'", txBytes),
);Note: While this helper works for simple integrations, subscribing to the full observable gives you access to pending states and
requiredUserInteractionvalues, which are essential for building responsive wallet UIs.
Quick reference: API mapping
hw-app-solana | device-signer-kit-solana | Notes |
|---|---|---|
new Solana(transport) | new SignerSolanaBuilder({ dmk, sessionId }).build() | DMK manages the transport |
getAddress(path, display) | getAddress(path, { checkOnDevice }) | Returns base58 string, not Buffer |
signTransaction(path, buf, userInputType?) | signTransaction(path, bytes, options?) | Clear-signing context replaces userInputType |
signOffchainMessage(path, buf) | signMessage(path, msg, options?) | Supports string input and version selection |
getAppConfiguration() | getAppConfiguration() | Same output, observable wrapper |
getChallenge() | Handled by context module | No direct equivalent needed |
provideTrustedName(data) | Handled by context module | Automatic during signTransaction |
provideTrustedDynamicDescriptor(data) | Handled by context module | Automatic during signTransaction |
Troubleshooting
| Issue | Solution |
|---|---|
6808 error during signing | Enable blind signing in the Solana app settings on the device |
| Observable never completes | Ensure the device is unlocked and the Solana app is accessible |
Buffer type errors | Replace Buffer with Uint8Array — or use Buffer.from(...) since Buffer extends Uint8Array |
Missing rxjs | Install it: npm install rxjs (it is a peer dependency of the DMK) |
| Discovery finds no devices | Check that the browser supports WebHID and the device is connected via USB |