> ## 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.

# Errors

> TrustodianBaseError, every error class, error codes, and how to recover

## The base error

Every typed SDK error extends `TrustodianBaseError`, an abstract subclass of `Error`.

```typescript theme={null}
abstract class TrustodianBaseError extends Error {
  readonly code: SDKErrorCode;
  readonly chain?: ChainType;
  readonly operation?: string;
  readonly details?: Record<string, unknown>;
  readonly cause?: Error;

  toJSON(): SDKError;
  toString(): string;
}
```

| Field | Meaning |
| :- | :- |
| `code` | Stable machine-readable code. This is the field to branch on |
| `chain` | Chain the operation targeted, when the failure is chain-specific |
| `operation` | Human label for the failing operation. For HTTP failures this is the request method and path |
| `details` | Free-form context. HTTP failures put `statusCode` and `responseBody` here |
| `cause` | The underlying `Error`, when one was wrapped |

`name` is set to the concrete class name, and the prototype chain is fixed so `instanceof` works across bundlers and transpilation targets.

`toString()` renders `[CODE] message`, appending ` (chain: …)` and ` (operation: …)` when present. `toJSON()` returns `{ code, message, chain, operation, details, cause }`; subclasses that carry extra fields override it to add them.

> [!WARNING]
>
> `toJSON()` serializes `details` wholesale, and most structured loggers call it. Never put key
> material in `details`: no wallet password, device share, private key or recovery phrase. Redact
> `details.responseBody` before logging if you cannot vouch for what the server put there.

## Type guard

```typescript theme={null}
import { isTrustodianError } from 'vaults-multichain-sdk';

try {
  await sdk.sendTransaction(input);
} catch (error) {
  if (isTrustodianError(error)) {
    logger.error({ code: error.code, operation: error.operation }, error.message);
  }
  throw error;
}
```

`isTrustodianError(error)` narrows to `TrustodianBaseError`, so `error.code` is typed afterwards. Anything that is *not* a `TrustodianBaseError` is either a plain `Error` from the session layer (see [Session-layer errors](#session-layer-errors) below) or something outside the SDK.

## Error classes

`Importable` marks the classes you can import and use with `instanceof`. The others are real errors the SDK throws but does not export, so match those on `error.code`.

| Class | `code` | Importable | Thrown when | Recovery |
| :- | :- | :-: | :- | :- |
| `ApiError` | `API_ERROR` | ✅ | Any non-2xx HTTP response not mapped to a more specific class | Inspect `statusCode` / `responseBody`; retry only idempotent calls |
| `NetworkError` | `NETWORK_ERROR` | ✅ | The request never completed: DNS, TLS, connection reset. Also the fallback for unrecognised failures | Retry with backoff |
| `TimeoutError` | `TIMEOUT` | | The request aborted after `timeout` ms (default 30 000) | Raise `timeout` or retry; carries `timeoutMs` |
| `UnauthorizedError` | `UNAUTHORIZED` | ✅ | HTTP 401. The `x-api-key` header was absent, or a valid key resolved to no user account. An *invalid* or disabled key returns 403, not 401 | Fix the key; do not retry |
| `PermissionDeniedError` | `PERMISSION_DENIED` | ✅ | HTTP 403 that is not a recovery-phrase signal: role, ownership, or Master-only operations | Do not retry. Typical messages: *"User is not an owner of the multisig or missing permission"*, *"Forbidden, not a Master Account"* |
| `RateLimitError` | `RATE_LIMITED` | | HTTP 429 | Wait `retryAfter` seconds (parsed from the `Retry-After` header), then retry |
| `OrgPolicyRejectedError` | `ORG_POLICY_REJECTED` | ✅ | An org security policy rejected the request | Surface the policy reason. See [the policy table](#organization-policy-codes) |
| `InvalidInputError` | `INVALID_INPUT` | ✅ | Client-side validation failed | Fix the input; carries `field` and `value` |
| `ChainNotSupportedError` | `CHAIN_NOT_SUPPORTED` | ✅ | The operation does not support this chain | Carries `requestedChain` and `supportedChains` |
| `InsufficientBalanceError` | `INSUFFICIENT_BALANCE` | ✅ | Balance is below what the transfer plus fees requires | Carries `requiredAmount`, `availableAmount`, `currency` |
| `NotFoundError` | `ORDER_NOT_FOUND` / `MULTISIG_NOT_FOUND` / `WALLET_NOT_FOUND` | ✅ | The referenced resource does not exist. The code is chosen from `resourceType` | Carries `resourceType` and `resourceId` |
| `KeyMismatchError` | `KEY_MISMATCH` | ✅ | Reserved. See [the note below](#keymismatcherror-extras) | The mismatch you will actually catch carries `ADDRESS_MISMATCH` |
| `KeyReconstructionError` | `KEY_RECONSTRUCTION_FAILED` | | Reserved. Not thrown by the current release | Carries `address` |
| `SignatureError` | `SIGNATURE_FAILED` | | Reserved. Not thrown by the current release | |
| `SignatureIntentMismatchError` | `SIGNATURE_INTENT_MISMATCH` | ✅ | The payload about to be signed disagrees with the declared intent | Never retry blindly. See [Intent mismatch](#intent-mismatch) |
| `TransactionError` | `TRANSACTION_FAILED` | | A chain operation failed. The most frequently thrown class after `InvalidInputError` | Carries `txHash` when one exists |
| `BroadcastError` | `BROADCAST_FAILED` | | Broadcast to the network was rejected | Carries `signedTx` |
| `ThresholdNotMetError` | `THRESHOLD_NOT_MET` | | Reserved. Not thrown by the current release | Carries `required` and `current` |
| `RecoveryPhraseRequiredError` | `RECOVERY_PHRASE_REQUIRED` | ✅ | The account has no recovery phrase | The account never completed onboarding. Send the holder back to the app rather than establishing one from your integration |
| `RecoveryPhraseUnverifiedError` | `RECOVERY_PHRASE_UNVERIFIED` | ✅ | The phrase exists but was never verified | The account holder completes verification in the app. Re-establishing is not the fix |
| `VaultKeyNotProvisionedError` | `VAULT_KEY_NOT_PROVISIONED` | ✅ | Signing a Bitcoin or Cardano vault order from a seat that was never granted, or granted and not yet accepted | The Master grants the seat and the employee accepts it. See [Vault keys for Bitcoin and Cardano](https://sdk-guide.mintlify.site/sdk-guide/organization-vaults.md#vault-keys-for-bitcoin-and-cardano) |
| `SeedAlreadyEstablishedError` | `SEED_ALREADY_ESTABLISHED` | ✅ | A phrase already exists on the account, or the organization already has its own | The normal state for any account with an API key |
| `SeedAwaitingPhraseError` | `SEED_AWAITING_PHRASE` | ✅ | The phrase is still current but locked until it is entered again in the web app. Extends `PermissionDeniedError`; `ownerKind` says which phrase when known | Send the user to the web app. Retrying or unlocking does not help |
| `RecoveryPhraseReentryRequiredError` | `RECOVERY_PHRASE_REENTRY_REQUIRED` | ✅ | The phrase of `ownerKind` has to be entered again in the web app. Extends `PermissionDeniedError` | Same as `SeedAwaitingPhraseError` |
| `OrgRecoveryPhraseRequiredError` | `ORG_RECOVERY_PHRASE_REQUIRED` | ✅ | The organization has no phrase of its own yet. Extends `PermissionDeniedError` | The Master creates it. See [Organization recovery phrase](https://sdk-guide.mintlify.site/sdk-guide/recovery-phrase.md#organization-recovery-phrase) |
| `OrgSeedChoiceRequiredError` | `ORG_SEED_CHOICE_REQUIRED` | ✅ | The Master has not yet chosen between the personal phrase and a new organization phrase, or creating a new one would move wallets to the Archive. Extends `OrgRecoveryPhraseRequiredError` | The Master chooses in the web app, or pass `acknowledgeArchive: true` to `establishRecoveryPhrase` after showing the consequence |
| `OrgRecoveryPhraseUnverifiedError` | `ORG_RECOVERY_PHRASE_UNVERIFIED` | ✅ | The organization's phrase was never confirmed. Extends `PermissionDeniedError` | The Master confirms the backup |
| `OrgSeedMasterOnlyError` | `ORG_SEED_MASTER_ONLY` | ✅ | Only the Master can use or change the organization's phrase. Extends `PermissionDeniedError` | Do not retry |
| `OrgWalletRequiresOrgSeedError` | `ORG_WALLET_REQUIRES_ORG_SEED` | ✅ | The organization has its own phrase, so a new organization wallet or a new vault owner has to come from it. Extends `ApiError` | Use a wallet of the organization's phrase |
| `OrgUsesPersonalSeedError` | `ORG_USES_PERSONAL_SEED` | ✅ | The organization is linked to the Master's personal phrase. Extends `ApiError` | `createOrgWallet` retries on its own when you did not pass `seedOwnerKind` |
| `SeedScopeConflictError` | see below | ✅ | A request would mix the personal and the organization phrase. Extends `ApiError` | Branch on `code` |
| `WalletPasswordProofMismatchError` | `WALLET_PASSWORD_PROOF_MISMATCH` | ✅ | `changeWalletPassword`: the server refused the proof of the current password (HTTP 403). Extends `PermissionDeniedError` | Counts as a failed wallet password attempt. Ask the user again; do not loop |
| `WalletPasswordProofMissingError` | `WALLET_PASSWORD_PROOF_MISSING` | ✅ | `changeWalletPassword`: the server holds no proof of the wallet password yet (HTTP 400). Extends `ApiError` | Unlock the wallet password once, then change it |
| `WalletPasswordProofVersionMismatchError` | `WALLET_PASSWORD_PROOF_VERSION_MISMATCH` | ✅ | `changeWalletPassword`: the encryption key version sent with the proof is not the stored one (HTTP 409 on `/sdk/users/wallet-password/rotate`). Extends `ApiError` | Nothing was changed. Call `changeWalletPassword` again |
| `SeedEchoMismatchError` | `SEED_ECHO_MISMATCH` | ✅ | The server did not confirm which phrase it answered for. Nothing was signed or derived from the answer | From `establishRecoveryPhrase`: the phrase was saved but not confirmed for the requested owner and is not returned. The user checks their phrases in the web app |
| `ClientUpgradeRequiredError` | `CLIENT_UPGRADE_REQUIRED` | ✅ | The request needs a newer SDK, or the SDK could not tell which phrase or vault seat to use. Extends `ApiError`; raised client-side without `statusCode` | Upgrade the SDK. For a vault seat, pass its `cosignerKeyId` |
| `HardwareSignerRequiredError` | `HARDWARE_SIGNER_REQUIRED` | ✅ | A password method was called for a hardware wallet | Call the method in `prepareMethod`. See [Hardware wallet errors](#hardware-wallet-errors) |
| `HardwareSignatureMismatchError` | `HARDWARE_SIGNATURE_MISMATCH` | ✅ | The device answered with the key of another seed, passphrase or device | Ask for the right device. Nothing was sent |
| `HardwareRequestExpiredError` | `HARDWARE_REQUEST_EXPIRED` | ✅ | A hardware step outlived `expiresAt` | Prepare again |
| `HardwareRequestInvalidError` | `HARDWARE_REQUEST_INVALID` | ✅ | A hardware request was altered, out of order or incomplete | Send back the stored request with every raw response |
| `HardwareVendorUnsupportedError` | `HARDWARE_VENDOR_UNSUPPORTED` | ✅ | The chosen device cannot do this on this network | Offer the other vendor |
| `HardwareDeviceError` | `HARDWARE_DEVICE_ERROR` | ✅ | Your frontend reported a device error | Show a message for `deviceCode`, retry the step |
| `HardwareAccountRefusedError` | see [below](#hardware-wallet-errors) | ✅ | The server refused because of a hardware account | Branch on `code` |
| `StrictAuthPasswordRequiredError` | `STRICT_AUTH_PASSWORD_REQUIRED` | ✅ | Strict authentication is on for the target and the signing call had no `walletPassword` | Ask for the password and call again. See [Strict authentication](https://sdk-guide.mintlify.site/sdk-guide/strict-authentication.md) |
| `StepUpGrantRejectedError` | `STEP_UP_GRANT_REJECTED` | ✅ | The strict grant of the running action was refused. `reason` is `spent` or `invalid` | Start the action again with the password. Never retried by the SDK |
| `AllowanceChangePartialError` | `ALLOWANCE_CHANGE_PARTIAL` | ✅ | A two-transaction allowance reduce stopped after the first transaction. Carries `txHashes` | Read the allowance back before trying again. See [Token approvals](https://sdk-guide.mintlify.site/sdk-guide/token-approvals.md#tokens-that-need-two-transactions) |
| `HardwareAllowanceZeroFirstUnsupportedError` | `HARDWARE_ALLOWANCE_ZERO_FIRST_UNSUPPORTED` | ✅ | A hardware reduce would need two transactions. Raised before the device is asked | Offer a revoke instead |

> [!NOTE]
>
> `SDKErrorCode` also defines `UNKNOWN`, which no error class currently uses. Treat an unrecognised
> code as unhandled rather than assuming the set is closed.

### ApiError extras

```typescript theme={null}
class ApiError extends TrustodianBaseError {
  readonly statusCode?: number;
  readonly responseBody?: unknown;
}
```

`ApiError` is the catch-all for HTTP failures, so `statusCode` is how you tell them apart. Its `toJSON()` adds `statusCode` (but not `responseBody`). `responseBody` is the parsed JSON when the response had a JSON content type, otherwise the raw text, otherwise `null`.

### SeedScopeConflictError codes

`SeedScopeConflictError` carries one of these codes, also exported as the type `SeedScopeConflictCode`:

| `code` | Meaning |
| :- | :- |
| `VAULT_OWNER_NOT_ALLOWED` | An organization wallet was offered as the owner of a personal vault |
| `SEED_FINGERPRINT_IN_USE` | This phrase is already used on the account |
| `PERSONAL_FINGERPRINT_REQUIRED` | The personal phrase has to be opened once in the web app before the organization's phrase can be created |
| `ORG_SEED_SWITCH_PENDING_ORDERS` | The organization has open orders that add vault owners |
| `SEED_GENERATION_NOT_ACTIVE` | A replaced phrase cannot back a new vault seat |

### KeyMismatchError extras

```typescript theme={null}
class KeyMismatchError extends TrustodianBaseError {
  readonly expectedAddress: string;
  readonly derivedAddress?: string;
}
```

Both are added by its `toJSON()`. Semantically it means the reconstruction produced a key deriving `derivedAddress` rather than the wallet's `expectedAddress`.

> [!WARNING]
>
> The current release never throws this class. Every address-match failure arrives instead as a
> plain error carrying `code: 'ADDRESS_MISMATCH'`, for which `isTrustodianError` returns `false` and
> `instanceof KeyMismatchError` does not match. Detect it with `isDeviceShareRecoveryError(error)` or
> by checking `error.code === 'ADDRESS_MISMATCH'`.

The address check runs before every signature, so a mismatch never reaches a signer. For a seed wallet it means the derivation is wrong, and retrying will not fix it. Where a device share is involved, an imported wallet or a vault activation secret, it usually means that share is stale, and the SDK discards it before rebuilding from the server and recovery shares.

## HTTP status mapping

Only these statuses get a dedicated class; everything else becomes `ApiError`.

| Status | Class | Notes |
| :- | :- | :- |
| 400 carrying `VAULT_OWNER_NOT_CURRENT_GENERATION` | `PermissionDeniedError` | Creating a vault whose owner set includes a wallet of a replaced recovery phrase. `details.walletAddresses` names the offending owners and the message is user-ready. See [Wallet generations](https://sdk-guide.mintlify.site/sdk-guide/wallets.md#wallet-generations) |
| 401 | `UnauthorizedError` | Header missing only. An invalid or disabled key is 403 |
| 400 carrying `WALLET_PASSWORD_PROOF_MISSING` | `WalletPasswordProofMissingError` | Password change before the server stored a proof. Mapped by the code, whatever the status |
| 403 | `RecoveryPhraseRequiredError` / `RecoveryPhraseUnverifiedError` / `WalletPasswordProofMismatchError` / `PermissionDeniedError` | The body's code discriminator decides; `PermissionDeniedError` is the default |
| 409 | `SeedAlreadyEstablishedError` from `establishRecoveryPhrase`, `WalletPasswordProofVersionMismatchError` from `changeWalletPassword`; otherwise `ApiError` | Mapped by the calling code, not by the HTTP layer |
| any status carrying a recovery-phrase code | The class for that code in the table above | Mapped by the code, whatever the status. `error.code` is the specific code, and `instanceof PermissionDeniedError` or `instanceof ApiError` still holds |
| 422 | `ApiError` | `recover-batch` reads per-wallet results out of `responseBody` instead of failing |
| 423 | `ApiError`, or `WalletPasswordLockedError` on wallet-password operations | See [Session-layer errors](#session-layer-errors) |
| 429 | `RateLimitError` | `retryAfter` comes from the `Retry-After` header |
| any other non-2xx | `ApiError` | `statusCode` + `responseBody` |
| request aborted | `TimeoutError` | |
| transport failure | `NetworkError` | |

The message is extracted from the body in this order: a string body as-is, then `message` (string, or the first element of an array, expanding `constraints` for class-validator payloads), then `error`, then the first entry of `errors`, and finally `response.statusText`.

## Organization policy codes

`OrgPolicyRejectedError` is discriminated by `platformCode`, one of four strings also exported at runtime as `ORG_POLICY_CODES`. The mapping that throws this error is not switched on yet, so read [Organization policies](https://sdk-guide.mintlify.site/sdk-guide/organization-policies.md) before you branch on it.

| `platformCode` | Meaning |
| :- | :- |
| `ERROR_KYC_REQUIRED` | The caller or the organization has not completed KYC/KYB |
| `ERROR_BUSINESS_HOURS_VIOLATION` | Outside the organization's allowed window |
| `ERROR_ADDRESS_NOT_WHITELISTED` | `forceAddressBook` is on and the destination is not a verified Corporate Address Book entry |
| `ERROR_DAILY_LIMIT_EXCEEDED` | `dailySpendingLimitUsd` would be exceeded by this transfer |

```typescript theme={null}
import { OrgPolicyRejectedError, ORG_POLICY_CODES } from 'vaults-multichain-sdk';
import type { OrgPolicyCode } from 'vaults-multichain-sdk';
```

The class also carries `statusCode` and `responseBody`, and its `toJSON()` adds `platformCode` and `statusCode`. Policy checks apply to non-Master organization members only: Master accounts are exempt server-side, and individuals never have policies attached. See [Organization policies](https://sdk-guide.mintlify.site/sdk-guide/organization-policies.md).

> [!WARNING]
>
> Typed mapping of policy rejections is currently disabled in the SDK while the response shape is
> being finalised. Until it is enabled, a policy rejection arrives as an ordinary `ApiError` and the
> code is in `responseBody`. Check both paths when you catch it.

## Hardware wallet errors

These come from accounts whose root is a hardware wallet. The classes are in the [table above](#error-classes); see [Hardware Wallets](https://sdk-guide.mintlify.site/sdk-guide/hardware-wallets.md) for the flow and what to do with each.

### HardwareAccountRefusedError codes

`SDKErrorCode` is not exported from the package, so compare `error.code` as a string, for example `error.code === 'HARDWARE_WALLET_DISABLED'`.

| `code` | Meaning |
| :- | :- |
| `SEED_ROOT_IS_HARDWARE` | A recovery-phrase or standalone-key operation on a hardware root |
| `HARDWARE_WALLET_DISABLED` | The vendor is switched off on the platform |
| `HARDWARE_WALLETS_DISABLED` | The platform disabled hardware wallets for everyone. Only viewing balances and receiving funds is left. Thrown by `prepareHardware…` before any device step, and returned by the server for a submitted Vault action or added account. The message is `Hardware wallets are disabled. You can only view balances and receive funds.` |
| `HARDWARE_WALLET_CONNECT_CLOSED` | The platform takes no new hardware wallet connections. The message is `You cannot connect a new hardware wallet now.` |
| `HARDWARE_ACCOUNT_REQUIRED` | The account root is not a hardware wallet |
| `HARDWARE_ACCOUNT_ARCHIVED` | The wallet belongs to a device that is no longer the root. View-only until it is reconnected in the app |
| `HARDWARE_ROOT_MISMATCH` | The device holds a different seed than the root |
| `HARDWARE_ROOT_INDIVIDUAL_ONLY` | A hardware root is for individual accounts only |
| `HARDWARE_NETWORK_UNAVAILABLE` | The network is not available for hardware accounts on this deployment |
| `HARDWARE_VAULT_KEYS_ACCOUNT_REQUIRED` | Add an account on that network first |
| `HARDWARE_VAULT_KEYS_MISMATCH` | The Vault keys come from another seed |
| `VAULT_SIGNER_HARDWARE_UNSUPPORTED` | The owner's device cannot sign this Vault's network |
| `VAULT_OWNER_HARDWARE_ORG_UNAVAILABLE` | Organization Vaults take no hardware owners |
| `HARDWARE_ACCOUNT_INVALID`, `BTC_VAULT_POLICY_MISMATCH`, `BTC_VAULT_POLICY_UNSUPPORTED` | Contact support |

## Intent mismatch

`SignatureIntentMismatchError` is raised before any private key is rebuilt, so a tampered or misdescribed payload never reaches a signer. It fires when the payload about to be signed does not reproduce the digest the server handed over, or does not pay what the caller declared.

The recipient is always compared. The amount is compared only when the asset's scale is known; an unknown scale skips that half rather than guessing and failing an honest order.

| Situation | Message shape |
| :- | :- |
| Recipient disagrees | `This order is declared as paying {declared}, but the transaction pays {actual}.` |
| Amount disagrees | `This order is declared as sending {declared} base units, but the transaction sends {actual}.` |
| Amount is unparseable | `Cannot read the order amount "{value}".` |
| Amount is more precise than the asset allows | `The order amount "{value}" is more precise than this asset allows.` |
| A Bitcoin input does not signal Replace-by-Fee | `The order transaction does not signal Replace-by-Fee on every input and cannot be signed.` |

With no intent of your own, the SDK falls back to the order's own declared recipient and value, the same figures you showed the user. Every chain raises the same error from its own payload check.

> [!WARNING]
>
> Treat this error as a security event, not a transient failure. It means the payload and the
> declared intent disagree. Do not retry, and do not fall back to signing without the check. Surface
> it, log it and investigate. Running the same check ahead of time is what
> [preflight](https://sdk-guide.mintlify.site/sdk-guide/preflight.md) is for.

## Session-layer errors

The session and share layer predates the typed hierarchy and throws plain errors carrying well-known message strings. They are matched by message, by `error.name`, or with the exported helper below.

| Signal | Where it comes from |
| :- | :- |
| `SESSION_LOCKED` | No unlocked session, or it expired. Call `unlockWalletSession`. Also raised when a share sits on an encryption key version the session does not hold; unlocking again does not fix that, `rotateOprfKeyVersion` does. Signing with a seed wallet and `recoverWalletsBatch` are not affected: they derive that version's key from the wallet password |
| `SESSION_REQUIRES_UNLOCK` | The flow needs an unlocked session |
| `ENC_TOKEN_MISSING` | No wallet password is configured and the flow was told not to create one |
| `SHARE_DECRYPT_FAILED` | A share could not be decrypted, usually the wrong password or a stale share |
| `ADDRESS_MISMATCH` | The rebuilt key does not derive the wallet's address |
| `Device share not found on this device` | No device share stored for the wallet |
| `Device share not on device; wallet password required for recovery` | The device share is missing or stale and no `walletPassword` was supplied. A Bitcoin or Cardano vault seat whose local half this device never held raises it too |
| `WalletPasswordLockedError` (`SESSION_WALLET_PASSWORD_LOCKED`) | Too many wrong password attempts. Carries `lockedUntil` and `retryAfterMs` |
| `WalletPasswordAttemptError` | A wrong wallet password. Carries `lockInfo` with `failedAttempts`, `lockedUntil`, `retryAfterMs`, `forceLogout`. A batch call checks the password once: a wrong one fails every item with the same error and spends one attempt |

### isDeviceShareRecoveryError

```typescript theme={null}
import { isDeviceShareRecoveryError } from 'vaults-multichain-sdk';
```

Returns `true` for exactly the four signals the wallet password can repair: device share not on device, device share requires password, `SHARE_DECRYPT_FAILED`, and `ADDRESS_MISMATCH`. Use it to decide whether to run recovery and retry:

```typescript theme={null}
async function withDeviceShareRecovery<T>(
  fn: () => Promise<T>,
  repair: () => Promise<unknown>,
): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    if (!isDeviceShareRecoveryError(error)) {
      throw error;
    }
    await repair();
    return fn();
  }
}
```

> [!TIP]
>
> You usually do not need this. Passing `walletPassword` on the crypto call lets the gate recover
> silently before signing. Reach for the guard when the password is not available at the call site.

## Putting it together

```typescript theme={null}
import {
  isTrustodianError,
  isDeviceShareRecoveryError,
  ApiError,
  UnauthorizedError,
  PermissionDeniedError,
  OrgPolicyRejectedError,
  SignatureIntentMismatchError,
  RecoveryPhraseRequiredError,
  RecoveryPhraseUnverifiedError,
} from 'vaults-multichain-sdk';

async function send(input: SendTransactionInput, walletPassword: string) {
  try {
    return await sdk.sendTransaction({ ...input, walletPassword });
  } catch (error) {
    if (error instanceof SignatureIntentMismatchError) {
      securityAlert('order intent mismatch', { operation: error.operation });
      throw error;
    }

    if (error instanceof RecoveryPhraseRequiredError) {
      return promptEstablishRecoveryPhrase();
    }

    if (error instanceof RecoveryPhraseUnverifiedError) {
      return promptRecoveryPhraseQuiz();
    }

    if (error instanceof OrgPolicyRejectedError) {
      return showPolicyRejection(error.platformCode);
    }

    if (error instanceof UnauthorizedError || error instanceof PermissionDeniedError) {
      throw error;
    }

    if (isDeviceShareRecoveryError(error)) {
      await sdk.ensureWalletDeviceShare(input.userRef!, {
        address: input.from,
        chainType: input.chain,
        password: walletPassword,
      });
      return sdk.sendTransaction({ ...input, walletPassword });
    }

    if (error instanceof ApiError && error.statusCode && error.statusCode >= 500) {
      return retryWithBackoff(() => sdk.sendTransaction({ ...input, walletPassword }));
    }

    if (isTrustodianError(error)) {
      logger.error({ code: error.code, operation: error.operation }, error.message);
    }
    throw error;
  }
}
```

Ordering matters: check the specific, non-retryable classes first, then the recoverable device-share cases, then generic HTTP retries, and log whatever is left.

## Related

> **[Key management](https://sdk-guide.mintlify.site/sdk-guide/key-management.md)**
>
> Sessions, shares, and what the crypto gate does before it throws

> **[Recovery phrase](https://sdk-guide.mintlify.site/sdk-guide/recovery-phrase.md)**
>
> The three recovery-phrase errors in their lifecycle context

> **[Preflight](https://sdk-guide.mintlify.site/sdk-guide/preflight.md)**
>
> Catch intent mismatches before you reach a signer

> **[Organization policies](https://sdk-guide.mintlify.site/sdk-guide/organization-policies.md)**
>
> What each policy code means and how policies are configured


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