> ## Documentation Index
> Fetch the complete documentation index at: https://sdk-guide.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Hardware Wallets

> Ledger and Trezor accounts: what you build, what the SDK gives you

> [!NOTE]
>
> **The hardware wallet is connected in the Trustodian app, never through the SDK.** The account holder
> sets it up as the account root in the web app, the extension or the mobile app. Your integration
> only works with an account that already has it. Requires SDK v3.3.0 or newer.

## What a hardware wallet account is

An account root is either a [recovery phrase](https://sdk-guide.mintlify.site/sdk-guide/recovery-phrase.md) or a hardware wallet (Ledger or Trezor) that holds the seed. With a hardware root, every wallet derives on the device and the private key never leaves it. The SDK holds no seed, asks for no wallet password, and writes nothing to your `shareStore` for these wallets.

A hardware root is for individual accounts only. Organization vaults take no hardware wallet owners. A hardware account has no recovery phrase, so `hasRecoveryPhrase` and `recoveryPhraseVerified` stay false.

### Detecting it

```typescript theme={null}
const user = await sdk.getSdkUser();
user.rootType;                    // 'phrase' | 'hardware' | null

const seed = await sdk.getSeedStatus();
seed.personal.rootType;           // 'phrase' | 'hardware' | null
seed.personal.hardwareVendor;     // 'ledger' | 'trezor' | null (last vendor connected)

const config = await sdk.getAppConfig();
config.hardwareLedgerEnabled;     // false when absent
config.hardwareTrezorEnabled;
config.hardwareAvailability;      // 'enabled' | 'closed' | 'disabled'; 'closed' when absent
```

Each wallet carries `seedRootType: 'phrase' | 'hardware' | null`. `'hardware'` means the key lives on the device and is used only through the `prepareHardware…` methods below.

## How it works

The SDK never talks to the device. It plans the action, your frontend runs it on the device, and the SDK verifies the answer and finishes the job.

```mermaid theme={null}
sequenceDiagram
    participant B as Your backend (SDK)
    participant F as Your frontend
    participant D as Device
    B->>B: prepareHardware… returns a request
    B->>F: request.calls and request.display
    F->>D: run the calls in order
    D-->>F: raw answers
    F->>B: result keyed by call id
    B->>B: submitHardwareStep verifies
    B-->>F: next step, or done
```

**1. Prepare**

Call a `prepareHardware…` method. It returns a `HardwareRequest`, plain JSON.

**2. Store and forward**

Keep the whole request on your server, for example by `request.id`. Send your frontend only `request.calls` and `request.display`.

**3. Run on the device**

The frontend runs the calls in order in one device session and posts back the raw answer of each call, keyed by call `id`, or an error.

**4. Submit**

Call `sdk.submitHardwareStep(request, result)`. You get `{ status: 'next', request }` (repeat from step 2 with the new request) or `{ status: 'done', result }`.

Before it broadcasts or posts anything, the SDK checks every device answer against the account's own key and against exactly the transaction it prepared. A device with a different seed or passphrase ends in `HardwareSignatureMismatchError` and nothing is sent.

The request carries an integrity code (`mac`) tied to your API key, so a request changed anywhere is refused. Each step has an `expiresAt`; after it `submitHardwareStep` throws `HardwareRequestExpiredError` and you prepare again. Adding an account and reading Vault keys expire 15 minutes after prepare.

## Who does what

| The Trustodian SDK gives you | You build |
| :- | :- |
| `prepareHardware…` methods that plan each action | Backend storage for the `HardwareRequest` between steps |
| `calls` that name exactly what the device must do | Two endpoints for your frontend: current step, and submit result |
| `display` text to show next to the device prompt | Frontend code that runs each call on the device and returns the raw answer |
| Verification of every answer before anything is sent | A screen that shows `display` and tells the user what to do on the device |
| Broadcasting or posting the finished result | Mapping vendor errors to `HardwareDeviceErrorCode` |

## Example

Backend, a send:

```typescript theme={null}
import {
  EChainType,
  type HardwareDeviceResult,
  type HardwareRequest,
} from 'vaults-multichain-sdk';

const pending = new Map<string, HardwareRequest>(); // use your own store

async function startSend() {
  const request = await sdk.prepareHardwareSend({
    chain: EChainType.EVM,
    from: senderAddress,
    to: recipientAddress,
    amount: '0.1',
    chainId: 1,
  });
  pending.set(request.id, request);
  return { id: request.id, calls: request.calls, display: request.display };
}

async function submit(id: string, result: HardwareDeviceResult) {
  const request = pending.get(id)!;
  const outcome = await sdk.submitHardwareStep(request, result);

  if (outcome.status === 'next') {
    pending.set(id, outcome.request);
    return { calls: outcome.request.calls, display: outcome.request.display };
  }
  pending.delete(id);
  return outcome.result; // { txHash } for a send
}
```

Frontend, a sketch for Trezor (a Ledger version runs `call.method` through the Ledger signers instead):

```typescript theme={null}
async function runStep(step: { id: string; calls: HardwareDeviceCall[] }, requestId: string, stepNo: number) {
  const responses: Record<string, unknown> = {};
  for (const call of step.calls) {
    const res = await TrezorConnect[call.method](call.params);
    if (!res.success) {
      // mapTrezorError is yours: it turns a Connect failure into a HardwareDeviceErrorCode
      return { requestId, step: stepNo, error: { code: mapTrezorError(res.payload), message: res.payload.error } };
    }
    responses[call.id] = res.payload;
  }
  return { requestId, step: stepNo, responses };
}
```

`HardwareRequest`, `HardwareDeviceCall`, `HardwareDeviceResult` and `HardwareDeviceErrorCode` are exported from the package. `HardwareDeviceResult.step` must be the `step` of the request you are answering.

`HardwareActionResult` depends on the action:

| Action | `done` result |
| :- | :- |
| `add-account` | `{ wallet, vaultKeysAdded }` |
| `vault-keys` | `{ added }` |
| `send`, `trust-line` | `{ txHash }` |
| `order-sign`, `order-create`, `order-reject` | `{ orderId, response }` |
| `order-execute` | `{ orderId, txHash?, response }` |
| `vault-activate` | `{ multisigId, response }` |

## Showing the request to the user

Show `display.summary` and `display.details` next to the device prompt, for example "Send 0.1 ETH to 0xab…cd".

* **`confirmOnDevice`** is true on a call when the user must check and approve on the device screen. For the second step of adding an account, the user compares the address in `display.summary` with the one on the device.
* **`blindSigning`** is present when the device cannot show the request in full. Show `warning`, and `hint` (for example "turn on Blind signing in the Ethereum app"), before the user approves.

## Method map

Calling the password method on a hardware wallet throws `HardwareSignerRequiredError` before anything is authorized, fetched or signed. Its `prepareMethod` field names the method to use.

| Instead of | Use | Chains |
| :- | :- | :- |
| `createWallet` | `prepareHardwareAddAccount({ chain, vendor? })` | EVM, SOL, TRON, XRP, ADA, BTC (`BTC_T3` on testnet) |
| (refill the Vault key pool) | `prepareHardwareVaultKeys({ chain, vendor? })` | BTC / `BTC_T3`, ADA |
| `sendTransaction` | `prepareHardwareSend({ chain, from, to, amount, … })`, same fields as `sendTransaction` | All |
| `activateXrpTrustLine` | `prepareHardwareTrustLine({ address, currency, issuer, limit })` | XRP, Ledger only |
| `signOrder` | `prepareHardwareOrderSignature({ chain, order, walletAddress, multisigId? })` | All |
| `createOrder`, `createSendOrder`, `createSettingsOrder` | `prepareHardwareOrderCreate({ chain: 'SOL', multisigId, walletAddress, method, instructions, … })` | SOL. On other chains use the normal call, the initiator does not sign |
| `executeOrder` | `prepareHardwareOrderExecute({ chain, order, multisig, walletAddress, feeQuote? })` | EVM, SOL. Elsewhere the server executes without the caller's signature, use the normal call |
| `rejectOrder` | `prepareHardwareOrderReject({ chain: 'SOL', order, multisig, walletAddress })` | SOL. Elsewhere use the normal call |
| `activateMultisig` | `prepareHardwareVaultActivation({ chain, multisigId, walletAddress, … })` | EVM, SOL. TRON and XRP activate with the activation key, use the normal call |
| `revokeAllowance`, `reduceAllowance` | `prepareHardwareAllowanceChange({ allowance, mode, amountAtomic? })` | EVM, SOL, TRON. Revoke, and a reduce that needs one transaction. See [Token approvals](https://sdk-guide.mintlify.site/sdk-guide/token-approvals.md#hardware-wallets) |

Every `prepareHardware…` input takes an optional `vendor`. The default is the vendor that last connected the root, and a device of the other vendor holding the same seed also works.

Solana Vault activation also takes `userRef` and `walletPassword`, because the Vault activation key is under the wallet password. Under [strict authentication](https://sdk-guide.mintlify.site/sdk-guide/strict-authentication.md#hardware-wallets), executing or activating a Vault needs `walletPassword` on the prepare and again on `submitHardwareStep(request, result, { walletPassword })`. Nothing else in the hardware flow needs it.

`establishRecoveryPhrase` and import for the personal phrase throw `HardwareAccountRefusedError` (`SEED_ROOT_IS_HARDWARE`) on a hardware root.

## Ledger and Trezor

**Ledger**

**Browser.** WebHID, so Chrome or Edge on desktop. The page must be a secure context: HTTPS, or localhost.

**Calls.** `method` names a call such as `eth.signTransaction`, `btc.signPsbt` or `ada.signTransaction`, and `app` is the Ledger app to open first. Bytes are lowercase hex both ways. Paths are `m/…` strings: drop the `m/` for the Device Management Kit signers, and convert to `number[]` for the Cardano library. Some calls name a `contextModule` (`LedgerContextModule`: `evm-token-only`, `evm-basic-typed-data`, `sol-offline`, `tron-none`).

The `LedgerMethod` type exported from the package is the catalog: every method with its params and expected response.

**Libraries** (tested versions):

| Package | Used for | Tested |
| :- | :- | :- |
| `@ledgerhq/device-management-kit` | Connection and sessions | 1.11.0 |
| `@ledgerhq/device-transport-kit-web-hid` | WebHID transport | 1.2.4 |
| `@ledgerhq/device-signer-kit-ethereum` | EVM | 1.18.1 |
| `@ledgerhq/device-signer-kit-bitcoin` | BTC | 1.3.3 |
| `@ledgerhq/device-signer-kit-solana` | SOL | 1.13.3 |
| `@ledgerhq/device-signer-kit-tron` | TRON | 0.3.0 |
| `@ledgerhq/device-signer-kit-xrp` | XRP | 0.2.0 |
| `@ledgerhq/context-module` | Context modules for EVM | 2.6.0 |
| `@cardano-foundation/ledgerjs-hw-app-cardano` and `@ledgerhq/hw-transport` | ADA | 8.1.0 and 6.36.0 |

**Apps on the device.** Bitcoin (Bitcoin Test on testnet), Ethereum, Solana, Tron, XRP and Cardano ADA. The first account on any network also needs the Bitcoin app, because the root is read there.

Opening an app asks the user to confirm on the device. Refusing there is `app-not-open`, not `rejected`. Ledger runs every call of a step with one click.

See the [Ledger device interaction docs](https://developers.ledger.com/docs/device-interaction/getting-started).

**Trezor**

**Library.** `@trezor/connect-web` 10 (tested 10.0.1). Initialize it with a manifest. Trezor requires a contact e-mail and your app URL:

```typescript theme={null}
TrezorConnect.init({
  manifest: { email: 'dev@example.com', appUrl: 'https://app.example.com', appName: 'Your app' },
});
```

**Calls.** `method` is a Trezor Connect 10 method and `params` go in unchanged: `TrezorConnect[method](params)`. Return the `payload`: unwrap `{ success, payload }`, and on `success: false` map the failure to an error code. The `TrezorMethod` type exported from the package lists the methods.

**User.** Needs Trezor Suite, either the desktop app or the Suite web window that Connect opens.

**Gestures.** Each call opens one Trezor Suite / Connect window and needs a fresh user gesture, such as a click, in the browser. Reads are bundled, so most steps are one window.

**Cardano.** Must be enabled in Trezor Suite (Settings, Crypto). Otherwise Connect answers `Method_UnsupportedCoinForHost`.

See the [Trezor Connect docs](https://docs.trezor.io/trezor-suite/packages/connect/index.html).

## Device errors

When the device fails, your frontend sends `{ requestId, step, error: { code, message } }` instead of responses. The SDK throws `HardwareDeviceError` with that `deviceCode`. The request stays valid, so the user can retry the same step until it expires.

| Code | Meaning |
| :- | :- |
| `rejected` | The user declined on the device |
| `locked` | The device is locked |
| `app-not-open` | The required Ledger app is not open, or the user refused to open it |
| `disconnected` | The device was unplugged or the connection dropped |
| `unsupported-client` | This device cannot be connected from this browser or computer |
| `firmware-outdated` | The device firmware or app is too old; the user updates it |
| `blind-signing-required` | The user must turn on blind signing in the device app |
| `sign-data-required` | The user must turn on Sign data in the Tron app |
| `unknown` | Anything else |

## Vendor limits

* Trezor cannot sign Vault transactions on TRON or XRP. The SDK throws `HardwareVendorUnsupportedError` before the device is asked. Creating a Vault with such an owner is refused by the server as `VAULT_SIGNER_HARDWARE_UNSUPPORTED`.
* Trezor signs only plain XRP payments: no issued tokens and no trust lines.
* Some requests cannot be shown in full on the device, so the SDK sets `display.blindSigning`. On Ledger these are EVM token transfers, Safe/Vault typed data and contract calls, and SOL/TRON token transfers and contract calls. On Trezor they are contract calls. BTC, ADA and XRP are always shown in full.

> [!NOTE]
>
> Hardware wallets are available per vendor on each platform. If `getAppConfig()` reports a vendor as
> disabled, adding accounts with it throws `HardwareAccountRefusedError` (`HARDWARE_WALLET_DISABLED`).

## Platform availability

`config.hardwareAvailability` is the platform-wide switch for hardware wallets. Requires SDK v3.3.1 or newer.

| Value | Effect in the SDK |
| :- | :- |
| `enabled` | Everything works |
| `closed` | The platform takes no new hardware wallet connections. The SDK has no connection flow, so nothing changes for it |
| `disabled` | Every `prepareHardware…` method throws `HardwareAccountRefusedError` with `code` `HARDWARE_WALLETS_DISABLED` before any device step. Balances, history and receiving funds are not affected |

`getAppConfig()` keeps its result for 60 seconds, so a change made by the platform reaches a running `TrustodianSDK` instance within a minute. A failed request is never kept: the last good result is returned, or the defaults (`hardwareAvailability: 'closed'`) when there is none, and the next call asks again. A missing or unknown value is read as `closed`, which blocks nothing.

`submit` is not checked by the SDK. For Vault actions and adding accounts the server refuses a submitted request with the same error code; personal sends are refused only by `prepareHardware…`.

The per-vendor flags above are separate. A switched-off vendor still refuses adding accounts with `HARDWARE_WALLET_DISABLED`.

## Errors

| Error | When | What to do |
| :- | :- | :- |
| `HardwareSignerRequiredError` | A password method was called for a hardware wallet | Call the method in `prepareMethod`. Nothing was signed |
| `HardwareSignatureMismatchError` | The device answered with a key or signature of another seed, passphrase or device | Ask the user to connect the device, and passphrase, that holds this account. Nothing was sent |
| `HardwareRequestExpiredError` | The step outlived `expiresAt` | Prepare again |
| `HardwareRequestInvalidError` | The request was altered, issued under another API key, answered out of order, or a response is missing or malformed | Do not edit requests. Send back the stored one with every call's raw response |
| `HardwareVendorUnsupportedError` | The chosen device cannot do this on this network | Offer the other vendor or another signer |
| `HardwareDeviceError` | Your frontend reported a device error | Show a message for `deviceCode` and retry the same step |
| `HardwareAccountRefusedError` | The server refused because of a hardware account | Read `code`. See [the code table](https://sdk-guide.mintlify.site/sdk-guide/errors.md#hardwareaccountrefusederror-codes) |

All of them are listed with the rest in [Hardware wallet errors](https://sdk-guide.mintlify.site/sdk-guide/errors.md#hardware-wallet-errors).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.