Migrate from hw-app-eth to device-signer-kit-ethereum
This guide walks you through migrating from the LedgerJS Ethereum package (@ledgerhq/hw-app-eth) to the DMK Ethereum signer (@ledgerhq/device-signer-kit-ethereum).
Warning:
hw-app-ethwill stop working with the Ethereum app after September 2026. Complete this migration before that date.
Before reading this guide, complete the general LedgerJS → DMK migration to set up the DMK, register a transport, and create a session.
Why migrate?
| Feature | hw-app-eth (LedgerJS) | device-signer-kit-ethereum (DMK) |
|---|---|---|
| EIP-712 support | signEIP712HashedMessage (hashed data) and signEIP712Message (structured data) | signTypedData (full structured-data signing) |
| API style | Promise-based | Observable-based, with cancellation |
| Transport management | Manual: you create and manage the Transport | Automatic: DMK manages discovery, connection, reconnection |
| App lifecycle | Manual: you open the Ethereum app yourself | Automatic: the signer opens the Ethereum app for you |
| Clear signing | Manual: ledgerService.resolveTransaction() before every signTransaction | Automatic: the context module resolves metadata during signing |
| Device state | No visibility | Observable state machine with requiredUserInteraction |
| Cancellation | Not supported | Built-in cancel() on every operation |
| EIP-7702 | Not supported | signDelegationAuthorization |
| Safe address | Not supported | verifySafeAddress |
Step 1: Update your dependencies
- npm install @ledgerhq/hw-app-eth @ledgerhq/hw-transport-webhid
+ npm install @ledgerhq/device-signer-kit-ethereum @ledgerhq/device-management-kit @ledgerhq/device-transport-kit-web-hidrxjs is a peer dependency; install it if it is not already in your project:
npm install rxjsStep 2: Update initialization
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 Ethereum signer:
Before
import Eth from "@ledgerhq/hw-app-eth";
const eth = new Eth(transport);After
import { SignerEthBuilder } from "@ledgerhq/device-signer-kit-ethereum";
const signerEth = new SignerEthBuilder({ dmk, sessionId }).build();With an origin token (recommended, required for full clear signing)
const signerEth = new SignerEthBuilder({
dmk,
sessionId,
originToken: "your-origin-token",
}).build();originToken enables Ledger’s Transaction Checks service, which shows users whether a transaction is potentially malicious. It also unlocks full clear signing: without it, only basic clear signing (e.g. ERC-20 transfers) is available, and transactions calling more complex contract selectors fall back to blind signing. Contact Ledger to obtain a token for your application.
Step 3: Migrate API calls
All methods now return { observable, cancel } instead of a Promise.
import { DeviceActionStatus } from "@ledgerhq/device-management-kit";getAddress
Before
// boolDisplay: show address on device for verification
// boolChaincode: return the chain code
// chainId: optional, displayed on Stax/Flex
const result = await eth.getAddress(
"44'/60'/0'/0/0",
boolDisplay,
boolChaincode,
chainId,
);
// result.publicKey: string (hex, no 0x prefix)
// result.address: string ('0x...')
// result.chainCode?: stringAfter
const { observable, cancel } = signerEth.getAddress("44'/60'/0'/0/0", {
checkOnDevice: boolDisplay, // was boolDisplay
returnChainCode: boolChaincode, // was boolChaincode
chainId: 1, // was chainId (number, not string)
});
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const { publicKey, address, chainCode } = state.output;
// publicKey: string
// address: `0x${string}`
// chainCode?: string
}
},
});Key differences:
- Parameters move from positional arguments to a named options object.
chainIdis now anumber(was astringin LedgerJS).- A new
skipOpenAppboolean option lets you skip the automatic app-open step if the Ethereum app is already running.
signTransaction
Before
import { ledgerService } from "@ledgerhq/hw-app-eth";
// rawTxHex is the hex-encoded RLP of the unsigned transaction (no 0x prefix)
const resolution = await ledgerService.resolveTransaction(rawTxHex, {}, {});
const sig = await eth.signTransaction("44'/60'/0'/0/0", rawTxHex, resolution);
// sig.r: string (hex, no 0x prefix)
// sig.s: string (hex, no 0x prefix)
// sig.v: string (decimal)After
// The DMK expects a Uint8Array, not a hex string
const txBytes = Buffer.from(rawTxHex, "hex"); // Buffer extends Uint8Array
const { observable, cancel } = signerEth.signTransaction(
"44'/60'/0'/0/0",
txBytes,
);
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const { r, s, v } = state.output;
// r: `0x${string}`
// s: `0x${string}`
// v: number
}
},
});Key differences:
ledgerService.resolveTransactionis gone. The context module resolves ERC-20 tokens, NFT metadata, and plugin data automatically during the signing flow.- The transaction input changes from a hex string to a
Uint8Array(useBuffer.from(rawTxHex, "hex")to convert). - The signature’s
vfield changes type: LedgerJS returns it as a decimal string, the DMK returns it as anumber. - The signature’s
randsfields now include the0xprefix.
signPersonalMessage → signMessage
Before
// messageHex is the hex-encoded message bytes
const sig = await eth.signPersonalMessage(
"44'/60'/0'/0/0",
Buffer.from("Hello, Ledger!").toString("hex"),
);
// sig.v: number
// sig.r: string (hex, no 0x prefix)
// sig.s: string (hex, no 0x prefix)After
// Pass the message as a plain string or Uint8Array
const { observable, cancel } = signerEth.signMessage(
"44'/60'/0'/0/0",
"Hello, Ledger!",
);
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const { r, s, v } = state.output;
// r: `0x${string}`
// s: `0x${string}`
// v: number
}
},
});Key differences:
- Method renamed from
signPersonalMessagetosignMessage. - Input changes from a hex-encoded string to a plain
stringorUint8Array. If you were passingBuffer.from(text).toString("hex"), passtextdirectly. - The signature’s
randsfields now include the0xprefix.
signEIP712Message → signTypedData
Before
const sig = await eth.signEIP712Message("44'/60'/0'/0/0", {
domain: {
name: "My App",
version: "1",
chainId: 1,
verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
},
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Mail: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "contents", type: "string" },
],
},
primaryType: "Mail",
message: { from: "0x...", to: "0x...", contents: "Hello!" },
});
// sig.v: number
// sig.r: string (hex, no 0x prefix)
// sig.s: string (hex, no 0x prefix)After
const { observable, cancel } = signerEth.signTypedData("44'/60'/0'/0/0", {
domain: {
name: "My App",
version: "1",
chainId: 1,
verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
},
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Mail: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "contents", type: "string" },
],
},
primaryType: "Mail",
message: { from: "0x...", to: "0x...", contents: "Hello!" },
});
observable.subscribe({
next: (state) => {
if (state.status === DeviceActionStatus.Completed) {
const { r, s, v } = state.output;
// r: `0x${string}`
// s: `0x${string}`
// v: number
}
},
});Key differences:
- Method renamed from
signEIP712MessagetosignTypedData. - The
TypedDatastructure (domain,types,primaryType,message) is identical; no changes to the data you pass. - The
fullImplemboolean parameter is gone; the DMK always uses the full implementation. - The signature’s
randsfields now include the0xprefix.
signEIP712HashedMessage → signTypedData
The SignerEth interface does not have a signEIP712HashedMessage method. The only EIP-712 signing method in the DMK is signTypedData, which takes the full structured data, not pre-computed hashes. When migrating, you must move to signTypedData.
This is an upgrade: instead of computing domainHash and messageHash yourself and doing blind signing, you pass the original typed data and the DMK handles the hashing. It also tries clear signing first (so the device can display human-readable field values), and automatically falls back to the hash-based APDU if clear signing is unavailable.
// Before: manually computed hashes, blind-signed
await eth.signEIP712HashedMessage(
path,
domainSeparatorHex,
hashStructMessageHex,
);
// After: pass the structured data — the DMK hashes and signs
const { observable } = signerEth.signTypedData(path, {
domain: {
/* original domain object */
},
types: {
/* original types */
},
primaryType: "...",
message: {
/* original message */
},
});If your code only holds the pre-computed hashes and no longer has access to the original TypedData, you will need to retrieve or reconstruct it before you can migrate.
getChallenge: no equivalent
getChallenge was a low-level method used internally as part of some clear-signing flows. It has no public equivalent in the DMK signer. The context module handles this internally.
getAppConfiguration: no equivalent
getAppConfiguration returned the Ethereum app version and feature flags (arbitrary data, Stark support, etc.). There is no equivalent method on the DMK SignerEth interface.
If you need the app version, you can send the underlying APDU directly using dmk.sendCommand(). See Build a custom command.
Step 4: Converting from promises to observables
If you want to migrate incrementally, 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.
Quick reference: API mapping
hw-app-eth | device-signer-kit-ethereum | Notes |
|---|---|---|
new Eth(transport) | new SignerEthBuilder({ dmk, sessionId, originToken? }).build() | |
getAddress(path, display?, chaincode?, chainId?) | getAddress(path, { checkOnDevice?, returnChainCode?, skipOpenApp?, chainId? }) | chainId is now a number |
signTransaction(path, rawTxHex, resolution?) | signTransaction(path, txBytes: Uint8Array, { skipOpenApp? }?) | Input: hex string → Uint8Array; resolution removed |
clearSignTransaction(path, rawTxHex, config) | signTransaction(path, txBytes) | Resolution is automatic |
signPersonalMessage(path, messageHex) | signMessage(path, message: string | Uint8Array, { skipOpenApp? }?) | Input: hex string → plain string |
signEIP712Message(path, typedData, fullImplem?) | signTypedData(path, typedData: TypedData, { skipOpenApp? }?) | Same data structure |
signEIP712HashedMessage(path, domainHex, msgHex) | Use signTypedData when structured data is available | EIP-712 APDU refactor in progress |
getChallenge() | No equivalent | Handled internally |
getAppConfiguration() | No equivalent | Use dmk.sendCommand() for raw access |
| (none) | signDelegationAuthorization(path, chainId, contractAddress, nonce) | New in DMK (EIP-7702) |
| (none) | verifySafeAddress(safeAddress, { chainId, skipOpenApp? }) | New in DMK |
Troubleshooting
| Issue | Solution |
|---|---|
Observable never reaches Completed | The device may be waiting for user confirmation. Check state.intermediateValue.requiredUserInteraction in the Pending state. |
rxjs not found | Run npm install rxjs (it is a peer dependency of the DMK). |
| Transaction bytes rejected | Ensure you are passing a Uint8Array (or Buffer), not a hex string. Buffer.from(rawTxHex, "hex") converts correctly. |
signEIP712HashedMessage not available in DMK | The DMK does not expose a hashed-message signing API. Use signTypedData if you have the full structured data. |
| No Transaction Checks shown on device | Pass an originToken to SignerEthBuilder. Contact Ledger to obtain one. |
| Device shows “blind signing” warning | Without an originToken, only basic clear signing (e.g. ERC-20 transfers) is supported; complex contract selectors fall back to blind signing. Pass an originToken to unlock full clear signing. |