Skip to main content

Setup and access

The package is vaults-multichain-sdk. The client class is TrustodianSDK.
apiKey, network and shareStore are all mandatory, and the constructor throws if any is missing. Everything else has a default. See Quickstart.
Every request carries the key in the x-api-key header; the server resolves it to a user and, if the key belongs to an organization, to a role and member id. You never pass a user id or an org id yourself.A key holds a set of permissions drawn from view_balances, view_transaction_history, wallet_management, vault_management, transaction_management, team_management, policy_management, address_book_management and audit_log_access. Each operation declares which ones they accept, so a key missing the right permission gets a 403 (surfaced as PermissionDeniedError) even though the key itself is valid. Keys can also carry an IP whitelist. See Authentication.
No. It is a server-side Node.js library, and it should stay that way, for two reasons:
  1. Using it means holding an API key and handling users’ wallet passwords. Both belong on your backend and nowhere else.
  2. It is built for Node and depends on APIs a browser bundle does not supply.
Put the SDK behind your own API and let your frontend talk to that.
No. The SDK never talks to a device. It prepares each step, your frontend runs it on the user’s device, and the SDK verifies the answer before it sends anything. See Hardware Wallets.
No. The key stays on the device, and nothing is written to your shareStore for it. The exceptions are activating a Solana Vault, which also takes userRef and walletPassword because the Vault activation key is under the wallet password, and executing or activating a Vault under strict authentication.
No. The account holder connects the hardware wallet as the account root in the Trustodian app. The SDK never connects, replaces or changes the root.
The Trustodian user id that the API key resolves to. Read it with await sdk.getSdkUserId(). It is the key under which device shares are filed in your shareStore, and the handle every session and crypto call takes. Use the same value consistently; a mismatched userRef looks to the SDK like a different user with no shares.
Only for reads, and only where the SDK has no method for what you need. Anything that touches key material must go through the SDK: the share, password and seed routes are primitives it drives, and calling them by hand means reimplementing key derivation, share reconstruction and per-chain signing. Treat the raw API as a gap-filler, not as a second path running alongside the SDK.

Secrets: recovery phrase vs wallet password

These are two different secrets with two different jobs. Mixing them up is the single most common integration mistake.
A user-chosen secret that unlocks the session. It is never sent to the server: the SDK turns it into two keys locally, one that decrypts the server share and one that decrypts the recovery share.It is set during app onboarding, before an API key can be issued, so your integration only unlocks each session with unlockWalletSession(userRef, password). The session lasts keyTtlMs, 5 minutes by default, and can be dropped early with lockWalletSession(userRef). Rotate the password with changeWalletPassword(userRef, { currentPassword, newPassword }), which re-encrypts every share. See Key management.
A 12-word BIP-39 phrase that is the root of the account’s wallets, not a password reset. It is created or imported during app onboarding, before an API key can be issued, so your integration never establishes one. createWallet derives wallets from that existing seed rather than generating standalone keys. The phrase is shown to its owner once, at establish time.What your integration uses: getRecoveryPhraseStatus to read the state. Re-displaying an already-established phrase is not available over the API-key/SDK transport (as of SDK v3.0.0, revealRecoveryPhrase throws PermissionDeniedError); that is a first-party Trustodian web app operation only. See Recovery phrase.
Unlocking a session only derives the key that decrypts the server share. Anything that also touches the recovery share, which is every create, import, recover, rotate and seed operation, needs the second key, and that one is never stored: it is re-derived from the password on each call. See Key management.
No. There is no method on the SDK that resets a wallet password without the current one: changeWalletPassword requires currentPassword. Reading the phrase back would not help either, because it is gated behind the wallet password rather than the other way round — and as of SDK v3.0.0 it is not reachable from your integration at all, only from the Trustodian web app. Every stored share is encrypted under keys derived from that password, and Trustodian cannot derive them for you.What you can actually do:
  • getWalletPasswordHint() returns the hint the user set when they chose the password. Prompt for a hint at setup so this is worth something.
  • getEncTokenStatus() tells you whether a password was ever configured for the account.
Design your onboarding around this: capture a hint, and make the recovery phrase backup step non-skippable.
No. Two of three shares are needed to reconstruct a key. The device share lives in your shareStore; the other two are stored server-side but encrypted under keys derived from the user’s wallet password, which the server never sees. Holding the ciphertext without the password rebuilds nothing.

Shares, devices and sessions

Because a party holding two of three shares is custodial, and the device share has to live outside Trustodian for the model to hold. The web app keeps it in browser storage; your backend keeps it in whatever shareStore you implement.
No. InMemoryShareStore is a Map: it evaporates on restart and is not shared between processes. In production, implement IntegratorShareStore against durable, encrypted storage:
A DeviceShareRecord holds userRef, walletId, address, chainType, deviceShareHex, version and oprfKeyVersion. The last two count different things: version is the share’s own rotation lineage, oprfKeyVersion is which encryption key wrapped it. A store that copies fields by hand has to carry both, or a rotated share reads as stale and gets rebuilt on every call. deviceShareHex is key material, so encrypt it at rest and keep it out of logs. XRP addresses are matched case-insensitively in findByAddress; mirror that if your backing store is case-sensitive.
Nothing is lost. The device share is the recoverable one: with the wallet password you can rebuild it from the server and recovery shares.
  • ensureWalletDeviceShare(userRef, { address, chainType, password }) puts one wallet’s share back into your store.
  • recoverWallet(userRef, params) rebuilds a single wallet.
  • recoverWalletsBatch(userRef, params) rebuilds many at once.
  • ensureWalletReadyForCrypto(userRef, { address, chainType, walletPassword }) checks one wallet and silently recovers it if needed.
Losing the device share is survivable, including losing your whole share store: as long as the wallet password is known, recoverWalletsBatch rebuilds every device share. Losing the share store and the wallet password together is unrecoverable for an imported wallet. Wallets derived from the seed can still be rebuilt from the recovery phrase.
The key rebuilt from the available shares does not derive the address the wallet claims, almost always because of a stale device share. What you actually catch is a plain error carrying code: 'ADDRESS_MISMATCH', not the exported KeyMismatchError class, whose KEY_MISMATCH code is reserved and not thrown in practice.The fix is to rebuild the device share, and the SDK will do it for you: pass walletPassword to the crypto call so it can recover in place, or call ensureWalletReadyForCrypto / recoverWallet first. See Errors.
No, they are kept apart on purpose. Your integration and the web app hold independent sets of encrypted shares for the same wallets, and each side sets its own up on first use, which the SDK does for you. The wallet password itself is per account, and changeWalletPassword re-encrypts every side.

Networks, chains and fees

Through network: 'mainnet' | 'testnet' at construction. It selects two things at once: the default apiBaseUrl and the whole default RPC set. Chain identifiers differ too: EChainType stays the same but the chain id changes ("1" vs "11155111", sol_mainnet vs sol_devnet, tron_mainnet vs tron_shasta, ada_mainnet vs ada_preprod, xrp_mainnet vs xrp_testnet). Bitcoin is the one case where the chain type differs: BTC on mainnet, BTC_T3 on testnet.Build one SDK instance per network rather than trying to flip a live one.
Yes. rpcOverrides is a flat map keyed by chain id, merged over the defaults for the selected network. EVM chains are keyed by their numeric id as a string; the other chains use their named id. There is no nesting and no evm sub-object:
The shipped defaults are public endpoints: fine for trying things out, not for production throughput.Cardano has no default endpoint at all, but that is not a gap you have to fill: the SDK never resolves an RPC endpoint for ADA. Cardano UTXO sourcing and fee estimation go through the Trustodian backend instead. Set an ADA override only if your own code reads the SDK’s RPC config for Cardano.
EChainType has seven members: EVM, SOL, TRON, BTC, BTC_T3, ADA, XRP. On EVM the SDK ships names and RPC defaults for Ethereum, Polygon, Base, Arbitrum One, Optimism, BNB Smart Chain and Avalanche C-Chain, on both networks. The chain ids are listed in Supported chains.Passing an EVM chain id with no configured RPC throws No RPC endpoint configured for EVM chain ID <id>. Add it through rpcOverrides.
Always in the chain’s native asset, out of the account that broadcasts. The SDK’s own table of native units and the minimum balance it expects a sender to hold:For multisig, the fee comes from the executing owner’s personal wallet rather than the vault, which is why activateMultisig and executeOrder take feeQuote (and gasLimit / gasPrice on EVM). See Multisig fees.To put a number in front of a user, use estimateFee, a chain RPC, or preflightOrder.

Transactions, orders and scope

Because they are different acts by potentially different people. A multisig vault has owners and a threshold. signOrder rebuilds one owner’s key and adds that owner’s approval to a pending order; it broadcasts nothing. Once enough owners have signed to meet the threshold, executeOrder assembles the collected signatures into a transaction and broadcasts it, paying the network fee from the executor’s wallet.That is also why they are two separate calls, and why a single-owner flow still makes both: one signature is simply enough to reach the threshold. rejectOrder is the third outcome. See Orders.
Whenever the wallet or vault being operated on is owned by the organization rather than by the individual. walletScope is 'personal' | 'organization' and defaults to personal; it routes the call to the organization-owned resource and tells the share layer which partition to read.The scope of the listing method and the scope you pass to the action must agree: a wallet returned by listOrgWallets / listMyOrgWallets must be acted on with walletScope: 'organization'. Getting this wrong reads the wrong share set and fails, usually as a 403 or a key mismatch. See Organization and Organization wallets.
Four layers, cheapest first:
  1. validateAddressForChain / assertValidAddressForChain catch a malformed recipient locally, with no network call. See Transactions.
  2. preflightSend / preflightOrder check the whole thing before any key is touched. See Preflight.
  3. Run against network: 'testnet' with a separate API key, on Sepolia / devnet / Shasta / BTC_T3 / preprod / XRP testnet.
  4. Turn on the address-book whitelist so an unlisted destination is refused server-side. See Address book.
Do the first testnet run end to end (create wallet, fund, send, read history) before pointing anything at mainnet.

Operations

Yes, applied per operation rather than globally, and the tightest of them sit on the session-unlock and key-material calls, so a burst of unlocks for many users at once is the case most likely to hit one.A limited response comes back as 429 and the SDK raises RateLimitError with retryAfter parsed from the Retry-After header when the server sends one. Back off on that value; do not spin.
Every request runs under a 30 second default, configurable through timeout in the SDK config. An overrun surfaces as TimeoutError carrying timeoutMs and the operation, which is distinct from NetworkError for a failed connection, so you can retry the two differently.Chain operations legitimately take longer than an API read; if you raise timeout, raise it for the instance that does signing and execution, and keep read paths tight.
Every SDK error extends TrustodianBaseError and carries a stable code. isTrustodianError is the type guard; hasErrorCode(error, code) narrows to one specific code. The full catalogue is in Errors.
Last modified on October 27, 2026