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
If you haven’t already, follow Steps 2–3 of the general migration guide to build the DMK instance and obtain a sessionId. Then create the Solana signer:
Before (hw-app-solana)
import Solana from "@ledgerhq/hw-app-solana";
const solana = new Solana(transport);After (DMK Solana Signer)
import { SignerSolanaBuilder } from "@ledgerhq/device-signer-kit-solana";
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 — provide mintAddress for SPL basic clear signing;
// if omitted the signer resolves it automatically, degrading to "???"
// on the device if resolution fails
transactionResolutionContext: {
mintAddress: "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, use the toPromise helper from the general migration guide. This lets you keep await call sites unchanged while the rest of your code catches up.
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 |