Every depositing user needs a registered smart account. You can let the service create one (managed) or bring your own (user-owned).
Choose an account type
| Managed account | User-owned account |
|---|
| Who creates the account | Deposit service | You, via SDK |
| Session key setup | Automatic | You sign session authorization |
| User-facing address | Service returns deposit address | Your existing smart account address |
| Recipient | Required (you specify where funds go) | Optional (defaults to the account itself) |
| Best for | Existing apps, EOA users (e.g. browser wallets) | Non-custodial flows, users with existing smart accounts |
Managed accounts are recommended for most integrations — simpler setup, no SDK
dependency for registration. User-owned accounts are for cases where deposits
should go to an existing smart account the user already controls.
All examples below use these shared constants:
const DEPOSIT_SERVICE_URL =
"https://v1.orchestrator.rhinestone.dev/deposit-processor";
const API_KEY = "YOUR_RHINESTONE_API_KEY";
const headers = {
"Content-Type": "application/json",
"x-api-key": API_KEY,
};
Register an account
Managed account
User-owned account
The service creates a Nexus smart account deterministically from your API key and a salt you provide. The same API key + salt always produces the same deposit address, so you can safely re-register if needed.Pick a salt
Use a stable, unique identifier per user — for example, an internal user ID. Hash it for privacy:import { keccak256, toHex } from "viem";
const salt = keccak256(toHex("user-123"));
Call /register-managed
const response = await fetch(`${DEPOSIT_SERVICE_URL}/register-managed`, {
method: "POST",
headers,
body: JSON.stringify({
account: {
salt,
target: {
chain: "eip155:42161", // Arbitrum
token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC
recipient: "0xYOUR_RECIPIENT_ADDRESS",
},
},
}),
});
const { evmDepositAddress, solanaDepositAddress } = await response.json();
The response includes two deposit addresses:| Field | Description |
|---|
evmDepositAddress | Accepts deposits on any supported EVM chain |
solanaDepositAddress | Accepts deposits on Solana |
Both addresses route to the same target chain and token.Verify registration
const check = await fetch(`${DEPOSIT_SERVICE_URL}/check/${evmDepositAddress}`);
const data = await check.json();
{
"isRegistered": true,
"targetChain": "eip155:42161",
"targetToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"sourceChains": ["eip155:8453", "eip155:10", "eip155:42161"]
}
For this flow, you create a Rhinestone smart account via the SDK, configure session keys so the deposit service can sign bridging transactions, and register the account with its factory data and session details.Install the SDK
npm install @rhinestone/sdk viem
Create the account
import { RhinestoneSDK, type RhinestoneAccountConfig } from "@rhinestone/sdk";
const rhinestone = new RhinestoneSDK();
const config: RhinestoneAccountConfig = {
owners: {
type: "ecdsa",
accounts: [userSigner],
},
experimental_sessions: { enabled: true },
};
const account = await rhinestone.createAccount(config);
const address = account.getAddress();
const { factory, factoryData } = account.getInitData();
Build session details
The deposit service uses a session key to sign bridging transactions on the user’s behalf. You need to build session details for every chain the user can deposit from, plus the target chain.import { toViewOnlyAccount } from "@rhinestone/sdk/utils";
import { base, optimism, arbitrum } from "viem/chains";
const RHINESTONE_SIGNER_ADDRESS = "0x177bfcdd15bc01e99013dcc5d2b09cd87a18ce9c";
const sessionSigner = toViewOnlyAccount(RHINESTONE_SIGNER_ADDRESS);
const sourceChains = [base, optimism, arbitrum];
const sessions = sourceChains.map((chain) => ({
owners: { type: "ecdsa" as const, accounts: [sessionSigner] },
chain,
}));
const sessionDetails = await account.experimental_getSessionDetails(sessions);
const signature = await account.experimental_signEnableSession(sessionDetails);
const enableSessionDetails = {
hashesAndChainIds: sessionDetails.hashesAndChainIds,
signature,
};
Call /register
const response = await fetch(`${DEPOSIT_SERVICE_URL}/register`, {
method: "POST",
headers,
body: JSON.stringify(
{
account: {
address,
accountParams: {
factory,
factoryData,
sessionDetails: enableSessionDetails,
},
target: {
chain: "eip155:42161", // Arbitrum
token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC
},
},
},
(_, v) => (typeof v === "bigint" ? v.toString() : v),
),
});
const { evmDepositAddress, solanaDepositAddress } = await response.json();
The JSON replacer (_, v) => typeof v === "bigint" ? v.toString() : v is needed because sessionDetails.hashesAndChainIds contains bigint chain IDs that JSON.stringify can’t serialize by default.
Verify registration
const check = await fetch(`${DEPOSIT_SERVICE_URL}/check/${address}`);
const data = await check.json();
// { isRegistered: true, targetChain: "eip155:42161", ... }
Adding source chains
To accept deposits from chains that weren’t included in the original registration, add new session details:import { polygon } from "viem/chains";
const newSessions = [
{
owners: { type: "ecdsa" as const, accounts: [sessionSigner] },
chain: polygon,
},
];
const newSessionDetails =
await account.experimental_getSessionDetails(newSessions);
const newSignature =
await account.experimental_signEnableSession(newSessionDetails);
await fetch(`${DEPOSIT_SERVICE_URL}/account/${address}/session`, {
method: "POST",
headers,
body: JSON.stringify(
{
sessionDetails: {
hashesAndChainIds: newSessionDetails.hashesAndChainIds,
signature: newSignature,
},
},
(_, v) => (typeof v === "bigint" ? v.toString() : v),
),
});
Managed accounts automatically support all available source chains — this
step is only needed for user-owned accounts.
Optional: token routing
By default, all deposits are bridged to the single target token you set at registration. Token routing rules let you select the output token based on what the user deposited.
When registering an account, the target object accepts two optional fields:
| Field | Type | Description |
|---|
outputTokenRules | Array<Rule> | Routing rules evaluated by match specificity |
rejectUnmapped | boolean | If true, deposits that don’t match any rule are rejected instead of falling back to target.token |
Each rule has:
| Field | Type | Description |
|---|
match.chain | string (CAIP-2) | Match deposits from this source chain (e.g. "eip155:1") |
match.token | string (address) | Match deposits of this source token address |
match.symbol | string | Match deposits by token symbol (case-insensitive, e.g. "USDC") |
outputToken | string (address) | The final token to deliver on the target chain |
A rule’s match must specify at least one of chain, token, or symbol. You can combine them for more specific matches.
Rule priority
When multiple rules match a deposit, the most specific rule wins. Declaration order only matters when two rules share the same specificity.
| Match type | Example |
|---|
chain + token | Specific token from a specific chain |
chain + symbol | Any token with symbol X from chain Y |
token (only) | Specific token from any chain |
symbol (only) | Any token with symbol X from any chain |
chain (only) | Any token from a specific chain |
| No match | Falls back to target.token (or rejected if rejectUnmapped: true) |
Example: USDC and ETH passthrough
Route USDC deposits to USDC.e and ETH deposits to WETH on Optimism, while defaulting other tokens to a fallback:
await fetch(`${DEPOSIT_SERVICE_URL}/register-managed`, {
method: "POST",
headers,
body: JSON.stringify({
account: {
salt,
target: {
chain: "eip155:10", // Optimism
token: "0xYOUR_DEFAULT_FALLBACK_TOKEN",
recipient: "0xYOUR_RECIPIENT_ADDRESS",
outputTokenRules: [
{
match: { symbol: "USDC" },
outputToken: "0x7f5c764cbc14f9669b88837ca1490cca17c31607", // USDC.e on Optimism
},
{
match: { symbol: "ETH" },
outputToken: "0x4200000000000000000000000000000000000006", // WETH on Optimism
},
],
},
},
}),
});
With this configuration:
- User deposits USDC (from any chain) → receives USDC.e on Optimism
- User deposits ETH (from any chain) → receives WETH on Optimism
- User deposits any other token → receives
target.token (default fallback)
Example: chain-specific overrides
Combine chain and symbol for chain-specific routing. The chain-specific rule takes priority because chain + symbol outranks symbol alone.
outputTokenRules: [
{
match: { chain: "eip155:1", symbol: "USDC" },
outputToken: "0xUSCD_BRIDGED_ADDRESS",
},
{
match: { symbol: "USDC" },
outputToken: "0xUSDC_NATIVE_ADDRESS",
},
];
Example: reject unknown tokens
Only accept specific tokens and reject everything else:
await fetch(`${DEPOSIT_SERVICE_URL}/register-managed`, {
method: "POST",
headers,
body: JSON.stringify({
account: {
salt,
target: {
chain: "eip155:10",
token: "0xNOT_USED_AS_FALLBACK",
recipient: "0xYOUR_RECIPIENT_ADDRESS",
outputTokenRules: [
{ match: { symbol: "USDC" }, outputToken: "0xUSDC_ADDRESS" },
{ match: { symbol: "ETH" }, outputToken: "0xWETH_ADDRESS" },
],
rejectUnmapped: true,
},
},
}),
});
Deposits that don’t match USDC or ETH are ignored (not bridged).
Optional: configure post-bridge actions
Post-bridge actions execute after the bridged tokens arrive on the target chain — for example, swapping into a protocol-specific token.
Set postBridgeActions in the target object at registration time. When present, target.token is the intermediate token bridged into the account, and the last action’s outputToken is the final token delivered to the recipient.
await fetch(`${DEPOSIT_SERVICE_URL}/register-managed`, {
method: "POST",
headers,
body: JSON.stringify({
account: {
salt,
target: {
chain: "eip155:8453",
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC bridged first
recipient: "0xYOUR_RECIPIENT_ADDRESS",
postBridgeActions: [
{
type: "orderbook-swap",
contract: "0xSWAP_CONTRACT_ADDRESS",
outputToken: "0xFINAL_TOKEN_ADDRESS",
},
],
},
},
}),
});
| Field | Type | Description |
|---|
type | "orderbook-swap" | The action type |
contract | Address | Swap contract address |
outputToken | Address | Final token delivered to the recipient |