# Add session details to account
Source: https://docs.rhinestone.dev/api-reference/deposit-service/accounts/add-session-details-to-account
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /account/{address}/session
Add session details (chain IDs, session digests, and signature) to an existing account
# Check if account is registered
Source: https://docs.rhinestone.dev/api-reference/deposit-service/accounts/check-if-account-is-registered
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /check/{address}
Check if an account is registered and return its target chain and token if registered
# Create a Solana token account (ATA) for an account
Source: https://docs.rhinestone.dev/api-reference/deposit-service/accounts/create-a-solana-token-account-ata-for-an-account
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /account/{address}/token-accounts
Create the Solana Associated Token Account (ATA) for a registered account's Solana deposit address and an SPL mint, so transfers from senders that omit a create-ATA instruction (some CEXes) do not bounce on-chain. Allowlisted clients only. Idempotent: returns the existing ATA with created=false if it already exists. Rejects native/wrapped SOL (no ATA needed).
# Fetch or backfill deposit addresses for a registered account
Source: https://docs.rhinestone.dev/api-reference/deposit-service/accounts/fetch-or-backfill-deposit-addresses-for-a-registered-account
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /account/{address}/deposit-addresses
Return the EVM, Solana, and Tron deposit addresses for an already-registered account. Missing Solana and Tron deposit addresses are backfilled on first call (derivation and Rhino.fi mint as needed) without modifying the account config (session details, target). Idempotent on repeat.
# Prepare account for registration
Source: https://docs.rhinestone.dev/api-reference/deposit-service/accounts/prepare-account-for-registration
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /setup-account
Derive the Rhinestone smart account for (ownerAddress, sessionOwnerAddress), compare against the existing registration and target, and return either the Solana deposit address (if no re-registration is needed) or the unsigned session details and factory parameters the client must sign before calling /register.
# Register a new account
Source: https://docs.rhinestone.dev/api-reference/deposit-service/accounts/register-a-new-account
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /register
Register a new account with factory, factory data, session details, and target. `target.chain` must use a CAIP-2 chain identifier (for example "eip155:8453").
# Register a server-managed account
Source: https://docs.rhinestone.dev/api-reference/deposit-service/accounts/register-a-server-managed-account
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /register-managed
Register a server-owned smart account. Provide a salt for deterministic addressing and a target chain/token.
# Configure client settings
Source: https://docs.rhinestone.dev/api-reference/deposit-service/clients/configure-client-settings
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /setup
Configure webhook URL, secret, sponsorship rules, and source-token whitelist rules for a client. Deposit whitelist keys must use CAIP-2 chain identifiers (for example "eip155:8453").
# Read client settings
Source: https://docs.rhinestone.dev/api-reference/deposit-service/clients/read-client-settings
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /setup
Returns the stored client configuration — webhook URL/secret, sponsorship rules, source-token whitelist, and price-deviation guard — for the authenticated project.
# List centralized exchanges for the Connect picker
Source: https://docs.rhinestone.dev/api-reference/deposit-service/onramp/list-centralized-exchanges-for-the-connect-picker
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /onramp/swapped/connect-exchanges
Returns the centralized exchanges offered by the Swapped Connect picker. Served from a maintained static list (Swapped exposes no enumeration API).
# List localized Swapped payment methods
Source: https://docs.rhinestone.dev/api-reference/deposit-service/onramp/list-localized-swapped-payment-methods
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /onramp/swapped/payment-methods
Returns the configured popular method (when merchant-enabled) followed by the supported subset of credit card, bank transfer, and Apple Pay for the trusted edge-resolved x-user-country. A missing/unknown country or unavailable catalog returns the null-country fallback so clients can use their built-in global defaults.
# Mint a signed Swapped Connect URL (CEX funding)
Source: https://docs.rhinestone.dev/api-reference/deposit-service/onramp/mint-a-signed-swapped-connect-url-cex-funding
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /onramp/swapped/connect-url
Returns a signed Swapped Connect URL the modal embeds in an iframe so the user can fund from a centralized exchange. Current callers send the exact picker rate as acceptedExchangeFeeBps; omission preserves legacy fee-free delivery, while a stale rate returns 409 before any URL or session state is minted. Requires a configured public webhook callback URL.
# Mint a signed Swapped widget URL (fiat on-ramp)
Source: https://docs.rhinestone.dev/api-reference/deposit-service/onramp/mint-a-signed-swapped-widget-url-fiat-on-ramp
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /onramp/swapped/widget-url
Returns a signed Swapped widget URL the modal embeds in an iframe for the fiat on-ramp. The destination asset is fixed server-side (the configured currency code); the caller supplies the smart account that receives the crypto. `methodSelectionSource: "personalized"` opts a trusted regional selection into strict merchant-catalog validation; fallback, configured, and legacy selections retain exact-method pass-through.
# Poll the latest Swapped order status for a smart account
Source: https://docs.rhinestone.dev/api-reference/deposit-service/onramp/poll-the-latest-swapped-order-status-for-a-smart-account
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /onramp/swapped/status/{smartAccount}
Returns the latest Swapped order recorded for the smart account (from notify webhooks), or `{ ok: false, reason: "no_order" }` when none exists yet.
# Preview a bridge quote before depositing
Source: https://docs.rhinestone.dev/api-reference/deposit-service/processing/preview-a-bridge-quote-before-depositing
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /quotes/preview
Returns the fees and expected output for bridging `amount` of `sourceToken` from `sourceChainId` to the account's registered target, before any funds are deposited. The orchestrator quotes against the supplied amount as if the deposit wallet already held it; the result is indicative and may differ from the quote applied once a real deposit lands. EVM source chains only. Scoped to the caller's tenant.
# Recover a failed deposit with an owner signature
Source: https://docs.rhinestone.dev/api-reference/deposit-service/processing/recover-a-failed-deposit-with-an-owner-signature
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /deposits/recover
Return a failed or rejected deposit's source-chain funds to a user-chosen recipient, authorized by the smart-account owner's EIP-712 signature (not just the API key). The server reconstructs the RecoverDeposit typed data from the stored deposit and verifies it against the account via ERC-1271/ERC-6492, then runs the same claim + transfer path as the refund endpoint. EVM Nexus accounts created by the deposit modal only. Authenticate with an `x-api-key` (write scope).
# Refund a failed deposit
Source: https://docs.rhinestone.dev/api-reference/deposit-service/processing/refund-a-failed-deposit
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /deposits/refund
Transfer the funds from a failed deposit back to a user-provided address on the same chain. Authenticate with an `x-api-key` (write scope) or a Bearer platform token; the credential must belong to the client that registered the account.
# Relay a Polymarket deposit-wallet withdrawal
Source: https://docs.rhinestone.dev/api-reference/deposit-service/processing/relay-a-polymarket-deposit-wallet-withdrawal
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /polymarket/withdraw
Relay a user-signed Polymarket deposit-wallet `WALLET` batch (approve/unwrap pUSD or transfer USDC.e) through the Polymarket relayer using server-held builder credentials. Submitted calls must target known Polymarket contracts.
# Relay a Safe withdrawal via shared Nexus relayer
Source: https://docs.rhinestone.dev/api-reference/deposit-service/processing/relay-a-safe-withdrawal-via-shared-nexus-relayer
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /safe/withdraw
Relay a signed Safe execTransaction via a shared AWS-owned Nexus account with sponsored gas
# Retry failed deposits for an account
Source: https://docs.rhinestone.dev/api-reference/deposit-service/processing/retry-failed-deposits-for-an-account
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /deposits/retry
Retry all failed deposits for a given account address. Authenticate with an `x-api-key` (write scope) or a Bearer platform token; the credential must belong to the client that registered the account.
# Aggregated deposit statistics
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/aggregated-deposit-statistics
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /deposits/stats
Returns aggregated deposit statistics (total deposits, unique depositors, and total USD volume) for the authenticated client over the requested rolling time window. Only completed deposits are counted.
# Check route liquidity for an amount
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/check-route-liquidity-for-an-amount
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /liquidity
Returns whether the given source→destination route can fulfil the requested amount, along with the underlying liquidity metadata from the orchestrator.
# Get a Solana wallet portfolio
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/get-a-solana-wallet-portfolio
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /portfolio/solana/{address}
Returns a normalised SPL portfolio for the given Solana address by paginating through Helius wallet balances.
# Get a wallet portfolio
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/get-a-wallet-portfolio
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /portfolio/{address}
Returns a normalised token portfolio for the given address. If the address is a Solana base58 public key, the Solana portfolio is returned; otherwise the EVM portfolio is aggregated from the orchestrator's `/accounts/:address/portfolio` endpoint and priced via the price service.
# Get migratable DeFi positions
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/get-migratable-defi-positions
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /positions/{address}
Returns the address's DeFi positions in one normalised shape, each carrying the largest amount that can actually be withdrawn right now. `maxWithdrawable` is capped against the venue's full withdraw validation — health factor (with a buffer above the liquidation edge), E-Mode thresholds, zero-LTV collateral, paused reserves and pool liquidity — so a client never asks a user to sign an exit the protocol would reject. Currently covers Aave v3 across every market on the deposit-enabled chains; a `failures` entry appears when a venue could not be reached, so an empty list is distinguishable from a failed lookup.
# Get USD unit prices for token symbols
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/get-usd-unit-prices-for-token-symbols
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /prices
Returns USD unit prices keyed by uppercased token symbol. Used to convert a USD amount into estimated destination-token units for tokens not present in the portfolio (e.g. a SOL or ETH destination the user does not hold).
# List candidate quotes considered for a deposit
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/list-candidate-quotes-considered-for-a-deposit
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /deposits/{id}/quotes
Returns every quote the orchestrator (or Solana bridge adapter) returned for this deposit, including which one was attempted and its terminal outcome. Scoped to the caller's tenant.
# List deposits for the authenticated client
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/list-deposits-for-the-authenticated-client
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /deposits
Returns the authenticated client's deposits, newest first. Supports filtering by account, recipient, address, status, source chain, and transaction hash. The `account` filter accepts an EVM address or a Solana public key (deposit address or swig address); `address` matches the source-chain deposit address or target-chain recipient.
# List supported chains and tokens
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/list-supported-chains-and-tokens
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /chains
Returns all chains supported by the deposit service, with their tokens and whether they support deposits, bridging destinations, or both
# List the QR/transfer token shortlist
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/list-the-qrtransfer-token-shortlist
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /qr/tokens
Returns the curated per-chain token shortlist the QR / manual-transfer flow offers as "what are you sending?". That flow has no connected wallet to read balances from, so it needs a suggested set rather than a discovered one. Filtered against the authenticated project's `depositWhitelist` when one is configured, so the flow cannot offer a token the client would reject on arrival. This is a suggestion list, not a capability list — `/chains` remains the authority on what the service supports.
# Prepare an Aave position unwind
Source: https://docs.rhinestone.dev/api-reference/deposit-service/utilities/prepare-an-aave-position-unwind
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /positions/{address}/unwind
Returns an **unsigned** `Pool.withdraw` transaction that exits an Aave position straight to the project's deposit address, so the existing deposit pipeline ingests the proceeds. Nothing is executed: the holding EOA owns the aTokens, so only it can authorise the withdraw, and the service holds no key on this path. The amount is capped server-side against Aave's full withdraw validation and is refused outright if the proceeds would fall foul of the project's deposit policy — that check happens here because this is the last point before an irreversible signature.
# List persisted webhook events for the calling client
Source: https://docs.rhinestone.dev/api-reference/deposit-service/webhooks/list-persisted-webhook-events-for-the-calling-client
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json get /webhooks/events
Returns webhook events delivered (or attempted) to the caller's webhook URL. Use for backfill or replay verification. Results are id-desc; pass the last `nextCursor` to paginate.
# Re-attempt delivery of a stored webhook event
Source: https://docs.rhinestone.dev/api-reference/deposit-service/webhooks/re-attempt-delivery-of-a-stored-webhook-event
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /webhooks/events/{id}/resend
Re-sends an existing webhook event to the configured webhook URL using its original eventId. Useful for replaying a delivery that failed permanently. Does not allocate a new event id.
# Send a test webhook to the configured URL
Source: https://docs.rhinestone.dev/api-reference/deposit-service/webhooks/send-a-test-webhook-to-the-configured-url
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/deposit-service.json post /webhooks/test
Sends a single webhook with fixture data to the client's configured URL. Uses the client's real webhook secret so signature verification works as in production. Envelope and persisted payload both carry `"test": true` so the receiver can filter and the dashboard can mark it.
# Introduction
Source: https://docs.rhinestone.dev/api-reference/introduction
Reference for the Rhinestone REST APIs.
The Rhinestone platform exposes two REST APIs:
* **Orchestrator** — quote, submit, and track cross-chain intents. Backs the core Warp flow.
* **Deposit Service** — register accounts and process cross-chain deposits with automatic bridging and gas sponsorship.
Both share the same base host, `https://v1.orchestrator.rhinestone.dev`, with the Deposit Service mounted at `/deposit-processor`.
## Authentication
All endpoints require an API key sent in the `x-api-key` header. The orchestrator additionally supports short-lived JWTs as an alternative for integrators that need bounded credentials or per-request sponsorship policies.
* [Get an API key](https://tally.so/r/wg22x4)
* [JWT authentication](/intents/configuration/jwt-authentication)
## Errors
Errors are returned with standard HTTP status codes and a JSON body. See [Error handling](/intents/guides/error-handling) for the full taxonomy and recovery patterns.
## Guides
If you're integrating from scratch, start with the workflow guides — they show how the endpoints fit together end-to-end.
Build the full intent flow: quote, sign, submit, track.
Register accounts and accept cross-chain deposits.
# Create App-Fee Withdrawal
Source: https://docs.rhinestone.dev/api-reference/orchestrator/app-fees/create-app-fee-withdrawal
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json post /app-fees/withdrawals
Withdraw the authenticated project's full app-fee balance to a whitelisted stablecoin on the chosen chain, paid to the project's registered payout address.
# Get App-Fee Balances
Source: https://docs.rhinestone.dev/api-reference/orchestrator/app-fees/get-app-fee-balances
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /app-fees/balances
The authenticated project's app-fee balance as USD totals valued at collection time. `pendingUsd` is the payout value permanently reserved by signed withdrawals whose target-chain FILL has not completed, including failed or expired payouts.
# Get App-Fee Withdrawal
Source: https://docs.rhinestone.dev/api-reference/orchestrator/app-fees/get-app-fee-withdrawal
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /app-fees/withdrawals/{nonce}
A single app-fee withdrawal owned by the authenticated project, with its status and USD value paid out.
# List App-Fee Withdrawals
Source: https://docs.rhinestone.dev/api-reference/orchestrator/app-fees/list-app-fee-withdrawals
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /app-fees/withdrawals
The authenticated project's app-fee withdrawals (newest first) with each withdrawal's status and USD value paid out.
# Create Intent
Source: https://docs.rhinestone.dev/api-reference/orchestrator/intents/create-intent
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json post /intents
Submits a quoted intent for execution. Takes the `intentId` from `POST /quotes` (`routes[].intentId`) plus signatures (origin, destination, optionally target-execution) and optional EIP-7702 authorizations.
# Get Intent
Source: https://docs.rhinestone.dev/api-reference/orchestrator/intents/get-intent
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /intents/{id}
Retrieves the status of an intent along with per-claim progress across chains.
# List Intents
Source: https://docs.rhinestone.dev/api-reference/orchestrator/intents/list-intents
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /intents
Retrieves a paginated list of the authenticated client's intents, newest first.
# Split Intent by Liquidity
Source: https://docs.rhinestone.dev/api-reference/orchestrator/intents/split-intent-by-liquidity
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json post /intents/splits
Splits token amounts into multiple intents based on available relayer liquidity. Each returned intent can be filled by a single relayer.
# Create Indicative Quote
Source: https://docs.rhinestone.dev/api-reference/orchestrator/quotes/create-indicative-quote
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json post /quotes/estimate
Computes an indicative (non-binding) quote: ranked route estimates with per-route cost breakdown and fill time. Unlike `POST /quotes`, it returns no `intentId`, `signData`, or `expiresAt` — it cannot be submitted to `POST /intents`. Use it for pre-quote UX (price previews, route discovery) without persisting an intent.
# Create Quote
Source: https://docs.rhinestone.dev/api-reference/orchestrator/quotes/create-quote
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json post /quotes
Computes a quote for an intent: token transfers, target executions, and cost breakdown. Returns a pre-ranked `routes[]` with per-route `intentId` to pass to `POST /intents` for execution.
# Get Account Portfolio
Source: https://docs.rhinestone.dev/api-reference/orchestrator/utilities/get-account-portfolio
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /accounts/{accountAddress}/portfolio
Retrieves the token balances across all supported chains for a user's abstracted account
# Route liquidity limit
Source: https://docs.rhinestone.dev/api-reference/orchestrator/utilities/route-liquidity-limit
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /liquidity
Returns the largest known fillable amount for a single deposit on the given source→destination route. Relayer-market layers return exact capacity; bridge-backed layers return a conservative probed lower bound.
# Supported chains and tokens
Source: https://docs.rhinestone.dev/api-reference/orchestrator/utilities/supported-chains-and-tokens
https://raw.githubusercontent.com/rhinestonewtf/openapi/refs/heads/main/orchestrator/blanc.json get /chains
Get supported chains and tokens with additional metadata
# API keys
Source: https://docs.rhinestone.dev/dashboard/api-keys
Create, scope, rotate, and revoke API keys
API keys authenticate your integration's requests to Rhinestone. They're managed per-project from the [Dashboard](https://dashboard.rhinestone.dev) under **API keys**.
An API key is shown in full **once**, at creation. Store it somewhere safe — you can't view it again.
## Creating a key
Give the key a name you'll recognise later (e.g. `prod`, `ci-staging`). Press "Create".
The full key is shown once. Copy it and store it securely, then press "I've saved this".
A project can have up to 10 active keys at a time.
## Scoping a key
By default a key is unrestricted. Open a key from the list to narrow what it can do under **Scopes**. There are three independent controls:
* **Enable mainnets** — when off, the key can only target testnets; mainnet requests are rejected. On by default.
* **Intents** — `None` blocks all intent endpoints, `Read` allows quotes, status, and listing, `Write` also allows submitting intents. `Write` by default.
* **Deposits** — `None` blocks all deposit endpoints, `Read` allows balance and history, `Write` also allows mutations. `Write` by default.
Adjust the controls and press "Save scopes". Only Owners and Admins can edit scopes.
## Rotating a key
There's no in-place rotation — you rotate by creating a new key and revoking the old one:
1. Create a new key (above) and deploy it to your service.
2. Once traffic is flowing on the new key, [revoke](#revoking-a-key) the old one.
Creating the new key first means there's no downtime: both keys are valid during the overlap.
## Renaming a key
Open a key from the list and press "Rename". Renaming is cosmetic — it doesn't change the key's value.
## Revoking a key
Open the key and press "Revoke". You'll be asked to type the key's name to confirm.
## Alternatives
If you need short-lived credentials, per-request sponsorship policies, or rotation without redeploying clients, consider [JWT authentication](/dashboard/jwt-keys) instead.
# JWT keys
Source: https://docs.rhinestone.dev/dashboard/jwt-keys
Register a signing key so your backend can issue JWTs
JWT authentication lets your backend mint short-lived tokens instead of using a long-lived API key. Before you can issue JWTs, you register a **public signing key** with Rhinestone from the [Dashboard](https://dashboard.rhinestone.dev) under **API keys → JWT keys**.
This page covers registering and managing keys in the dashboard. For how the SDK signs and uses JWTs — including per-request sponsorship policies — see [JWT authentication](/intents/configuration/jwt-authentication).
## Registering a key
Press "Register key". You can either generate a keypair in your browser or upload a public key you already have.
The dashboard generates a keypair in your browser. Only the public key is sent to Rhinestone — the private key never leaves your machine.
* **Integrator ID** — your organisation's identifier, emitted as the `iss` claim in the JWTs you sign. Typically your service's name.
* **Key ID** — identifier for this specific key, emitted as the `kid` header. Use a stable name you can rotate later (e.g. `prod-2026-06`).
Press "Create Key".
Download the private key file and store it securely on your backend.
If you already generated a keypair yourself, upload just the public half as a JWK.
* **Integrator ID** — emitted as the `iss` claim (see above).
* **Key ID** — emitted as the `kid` header (see above).
* **Public JWK** — paste the JWK JSON, or drag in a `.json` file.
Supported key types are RSA and EC (curve P-256). Only the **public** key is accepted — uploads containing a private key parameter are rejected.
Once registered, the key shows as **active** in the list, with its Key ID and Integrator ID.
## Rotating keys
Register a new key with a new Key ID and start signing tokens with it. Tokens signed under the old `kid` keep verifying until they expire, so there's no coordinated client deploy and no revocation race. Disable the old key once nothing signs with it.
## Disabling a key
Open the key and press "Disable".
# Getting started
Source: https://docs.rhinestone.dev/dashboard/overview
Sign in, and find your way around the dashboard
The [Rhinestone Dashboard](https://dashboard.rhinestone.dev) is where you manage your project: create API keys, register JWT keys, fund sponsorship, invite your team, and monitor intents and deposits.
## Signing in
Access is currently invite-gated.
On the sign-in screen, enter your invite code and press "Continue".
Don't have a code yet? Press "Request access" to join the waitlist.
Once your code is accepted, sign in with Google or GitHub.
## Finding your way around
After signing in you land on your project. The sidebar holds the main sections:
* **Intents** — monitor the intents your integration submits.
* **Deposits** — monitor deposits, and retry or refund failed ones.
* **API keys** — create and manage API keys and JWT keys.
* **Settings** — sponsorship balance and team management.
If your project has no API key yet, you'll be taken to a "Send your first intent" page that walks you through creating one. See [API keys](/dashboard/api-keys).
# Team management
Source: https://docs.rhinestone.dev/dashboard/team
Invite teammates and manage roles
You can invite teammates to your project and control what they can do. Team management lives in the [Dashboard](https://dashboard.rhinestone.dev) under **Settings → Team**.
## Roles
| Role | Can do |
| ---------- | --------------------------------------------------------- |
| **Owner** | Everything, including changing roles and inviting admins. |
| **Admin** | Invite and remove members, manage keys and sponsorship. |
| **Member** | Read-only access to the project. |
Members can't create API keys or invite others. If a teammate needs to set up keys, invite them as Admin.
## Inviting a member
Enter the teammate's email. Owners can also choose whether to invite them as a Member or an Admin. Press "Send invite".
The invite shows as **Pending** until the teammate accepts. You can "Resend" a pending invite from the members list.
## Changing roles and removing members
From the members list, use "Change role" to promote or demote a member, or "Remove" to revoke their access. Owners can manage anyone; Admins can manage Members.
## Troubleshooting
### "Already a member of another organization"
Each user can belong to **only one organization**. If you try to invite an email that already belongs to a different organization, the invite fails with this error.
There's no self-serve way to move a user between organizations. [Reach out to us](http://t.me/kurt_larsen) and we'll help migrate the account.
# Account registration
Source: https://docs.rhinestone.dev/deposits/api/account-registration
Register managed or user-owned smart accounts for cross-chain deposit processing.
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:
```ts theme={null}
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
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.
Use a stable, unique identifier per user — for example, an internal user ID. Hash it for privacy:
```ts theme={null}
import { keccak256, toHex } from "viem";
const salt = keccak256(toHex("user-123"));
```
```ts theme={null}
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.
```ts theme={null}
const check = await fetch(`${DEPOSIT_SERVICE_URL}/check/${evmDepositAddress}`);
const data = await check.json();
```
```json theme={null}
{
"isRegistered": true,
"targetChain": "eip155:42161",
"targetToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"sources": [
{ "chain": "eip155:8453", "depositAddress": "" },
{ "chain": "eip155:42161", "depositAddress": "" },
{
"chain": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"depositAddress": ""
}
]
}
```
`sources` lists every chain the account can receive deposits from, each with its deposit address — EVM chains share the `evmDepositAddress`, Solana carries the `solanaDepositAddress`.
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.
```bash theme={null}
npm install @rhinestone/sdk viem
```
```ts theme={null}
import { RhinestoneSDK, type RhinestoneAccountConfig } from "@rhinestone/sdk";
const rhinestone = new RhinestoneSDK();
const config: RhinestoneAccountConfig = {
owners: {
type: "ecdsa",
accounts: [userSigner],
},
sessions: { enabled: true },
};
const account = await rhinestone.createAccount(config);
const address = account.getAddress();
const { factory, factoryData } = account.getInitData();
```
The deposit service uses a session key to sign bridging transactions on the user's behalf. Build session details for every chain you accept deposits from, plus the target chain — a deposit arriving on a chain with no session can't be bridged. See [supported chains and tokens](/deposits/overview#supported-chains-and-tokens) for the current set.
```ts theme={null}
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]; // extend to every chain you accept
const sessions = sourceChains.map((chain) => ({
owners: { type: "ecdsa" as const, accounts: [sessionSigner] },
chain,
}));
const sessionDetails = await account.getSessionDetails(sessions);
const signature = await account.signEnableSession(sessionDetails);
const enableSessionDetails = {
hashesAndChainIds: sessionDetails.hashesAndChainIds,
signature,
};
```
```ts theme={null}
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.
```ts theme={null}
const check = await fetch(`${DEPOSIT_SERVICE_URL}/check/${address}`);
const data = await check.json();
// { isRegistered: true, targetChain: "eip155:42161", sources: [...] }
```
### Adding source chains
To accept deposits from chains that weren't included in the original registration, add new session details:
```ts theme={null}
import { polygon } from "viem/chains";
const newSessions = [
{
owners: { type: "ecdsa" as const, accounts: [sessionSigner] },
chain: polygon,
},
];
const newSessionDetails =
await account.getSessionDetails(newSessions);
const newSignature =
await account.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` | 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:
```ts theme={null}
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.
```ts theme={null}
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:
```ts theme={null}
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).
# Deposit processing
Source: https://docs.rhinestone.dev/deposits/api/deposit-processing
How deposits are detected, bridged, and settled — plus status tracking, retries, and error handling.
Once an account is registered, the deposit service handles everything from detection to settlement. This page explains what happens at each stage and how to monitor the process.
## Deposit lifecycle
```mermaid actions={false} theme={null}
flowchart LR
D[Detection] -- valid --> B[Bridging]
D -- whitelist/min --> X[Rejected]
B -- success --> S[Settlement]
B -- failure --> R[Retry]
R --> B
```
### Detection
The service monitors registered accounts via chain indexer webhooks. When a token transfer is detected on a registered account, it goes through validation:
* The token and amount are checked against your [deposit whitelist](/deposits/api/initial-setup#restrict-accepted-deposits) (if configured)
* The transfer is deduplicated by chain, transaction hash, account, and token
* If valid, the deposit enters the pipeline with status `processing`
* If the token isn't allowed or the amount is outside your configured min/max, the deposit is **rejected** (not bridged) and a [`deposit-rejected`](/deposits/api/status-tracking#deposit-rejected) webhook is sent after `deposit-received`
### Bridging
The service submits a bridging intent to the Rhinestone Orchestrator, which routes the funds through the optimal settlement layer (Across, Relay, or others). If the primary layer fails, the service tries alternatives automatically.
### Settlement
Tokens arrive on the target chain in the registered target token. If a recipient address was set at registration, funds are forwarded there. The service marks the deposit as `completed` and sends a `bridge-complete` [webhook](/deposits/api/status-tracking#webhooks).
## Deposit statuses
| Status | Meaning |
| ------------ | ---------------------------------------------- |
| `processing` | Deposit detected, bridging in progress |
| `completed` | Funds arrived on target chain |
| `failed` | Bridging failed — may be retried automatically |
## Retries
### Automatic
Transient errors — bridge failures, session activation issues, and internal errors — are retried automatically. Configuration errors like unsupported tokens, insufficient balance, or unregistered accounts require manual resolution.
### Manual
Force an immediate retry of all failed deposits for an account:
```ts theme={null}
const response = await fetch(`${DEPOSIT_SERVICE_URL}/deposits/retry`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
body: JSON.stringify({
address: accountAddress,
}),
});
const { deposits } = await response.json();
// [{ txHash: "0x...", chain: "eip155:8453" }, ...]
```
## Error codes
When a deposit fails, the error code indicates what went wrong and whether the service will retry automatically.
### Retryable
These are transient failures. The service retries automatically, or you can trigger a [manual retry](#manual).
| Code | Description |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SESSION-2` | Session key activation failed |
| `BRIDGE-1` | Bridge submission failed |
| `BRIDGE-2` | No bridge quote available |
| `BRIDGE-3` | Bridge timed out |
| `BRIDGE-4` | Bridge provider failed |
| `BRIDGE-5` | Price deviation exceeded tolerance |
| `BRIDGE-7` | Bridge refunded |
| `BRIDGE-8` | Bridge cancelled |
| `BRIDGE-9` | Bridge rejected |
| `SWAP-1` | Post-bridge swap failed |
| `INTERNAL-1` | Unexpected service error |
| `SPONSORSHIP-1` | A per-intent sponsorship cap (overall, gas-fee, or bridge-fee) would be exceeded; the deposit is held (`delayed`) and re-quoted automatically until the sponsored cost fits — as fees fluctuate or the cap is raised — or the hold window (\~24h) elapses. See [`deposit-delayed`](/deposits/api/status-tracking#deposit-delayed) |
### Non-retryable
These indicate a configuration or input problem. Check account registration, deposit whitelist, and token support.
| Code | Description |
| ------------ | ----------------------------------------------------------------------------------- |
| `BALANCE-1` | Account balance too low for bridging |
| `BALANCE-2` | Account balance could not be read |
| `BALANCE-3` | Deposit amount above the configured maximum |
| `BALANCE-4` | Deposit amount below the configured minimum |
| `TOKEN-1` | Token not supported on this chain |
| `TOKEN-2` | Token account (Solana ATA) creation failed |
| `TOKEN-3` | Token not in deposit whitelist |
| `ACCOUNT-1` | Account not registered |
| `ACCOUNT-2` | Account (Solana Swig wallet) creation failed |
| `SESSION-1` | Smart session not found — resolved by re-registering the account |
| `TRANSFER-1` | Transfer execution failed |
| `BRIDGE-6` | Unsupported bridge route — no route available; the deposit is rejected, not retried |
Failed deposits with non-retryable errors will not be retried automatically. Resolve the underlying issue (e.g. update your deposit whitelist or register the account) before triggering a manual retry.
Whitelist and minimum rejections (`BALANCE-4`, `BALANCE-3`, `TOKEN-3`) are deposits the service deliberately won't bridge per your configuration. These are surfaced via the [`deposit-rejected`](/deposits/api/status-tracking#deposit-rejected) webhook — not `bridge-failed` — and no bridging is attempted.
# Fees
Source: https://docs.rhinestone.dev/deposits/api/fees
Understand the fee structure for deposits.
Every deposit processed through the Deposit API may incur fees depending on the source chain, target chain, and tokens involved. Fees are deducted from the deposit amount — the user receives the deposited amount minus any applicable fees.
## Fee types
| Fee | Applies when | Covers |
| ------------ | ------------------------------ | ---------------------------------------------------- |
| **Gas** | Always | On-chain execution costs on source and target chains |
| **Bridging** | Source and target chain differ | Settlement layer fee (Across, Relay, etc.) |
| **Swap** | Source and target token differ | DEX execution fee for token conversion |
A single deposit can incur multiple fee types. For example, depositing ETH on Arbitrum to receive USDC on Base incurs all three: gas for on-chain execution, a bridging fee for the cross-chain settlement, and a swap fee for the ETH → USDC conversion. Same-chain deposits skip the bridging fee, and same-token deposits skip the swap fee.
## How fees are applied
Fees are subtracted from the deposit amount during processing. The user receives the remainder on the target chain.
For example, if a user deposits 100 USDC and total fees are 0.50 USDC, the user receives 99.50 USDC on the target chain.
To cover fees on behalf of your users so they receive the full amount, see [sponsorship](/deposits/api/sponsorship).
## Learn more
* [Fees](/home/resources/fees) — general Rhinestone fee structure and current rates
* [Sponsorship](/deposits/api/sponsorship) — cover fees on behalf of your users
* [Initial setup](/deposits/api/initial-setup) — configure deposit whitelists and price deviation tolerance
# Initial setup
Source: https://docs.rhinestone.dev/deposits/api/initial-setup
Configure webhooks, fee sponsorship, and deposit whitelists for your Deposit API client.
Call `POST /setup` to configure how the deposit service handles your deposits. All fields are optional — configure what you need. Each call performs a partial update; omitted fields keep their current value.
All examples below use these shared constants:
```ts theme={null}
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,
};
```
## Configure a webhook
Register a URL to receive notifications about deposit events. Optionally provide a secret for HMAC-SHA256 signature verification.
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers,
body: JSON.stringify({
params: {
webhookUrl: "https://your-domain.com/notify",
webhookSecret: "your-secret-key",
},
}),
});
```
When a `webhookSecret` is set, every webhook request includes an `X-Webhook-Signature` header you can verify. See [status tracking](/deposits/api/status-tracking#webhooks) for event types, payload format, and verification examples.
Once your endpoint is live, fire a single fixture event with [`POST /webhooks/test`](/api-reference/deposit-service/webhooks/send-a-test-webhook-to-the-configured-url) to confirm signature handling and parsing before real deposits arrive. The envelope carries `"test": true` so receivers can filter it.
## Sponsor fees
Cover gas, bridging, and swap fees on behalf of your users so they receive the full deposit amount. Sponsorship is configured per source chain using [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) identifiers. Chains without explicit config default to no sponsorship.
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers,
body: JSON.stringify({
params: {
sponsorship: {
"eip155:8453": { gas: "all", bridging: "all" }, // Base
"eip155:10": { gas: "all" }, // Optimism
"eip155:42161": { gas: "deployed" }, // Arbitrum
},
},
}),
});
```
See [sponsorship](/deposits/api/sponsorship) for the full list of fee types, available values, and how sponsorship is resolved at processing time.
## Restrict accepted deposits
Define a whitelist of accepted tokens per source chain. Deposits of unlisted tokens are silently ignored. You can also set minimum and maximum deposit amounts per token.
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers,
body: JSON.stringify({
params: {
depositWhitelist: {
"eip155:8453": [
{
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
minAmount: "1000000", // 1 USDC minimum
maxAmount: "5000000000", // 5,000 USDC maximum
},
],
"eip155:42161": [
{ token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" }, // USDC on Arbitrum, no limits
{
token: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
minAmount: "1000000",
}, // USDT, $1 min
],
},
},
}),
});
```
| Field | Type | Description |
| ----------- | -------- | ---------------------------------------------------------------- |
| `token` | `string` | Token address (`0x`-prefixed) |
| `minAmount` | `string` | Optional. Minimum accepted amount in raw token units (inclusive) |
| `maxAmount` | `string` | Optional. Maximum accepted amount in raw token units (inclusive) |
If no whitelist is set, all [supported tokens](/deposits/overview#supported-chains-and-tokens) are accepted with no amount restrictions.
Rejected deposits trigger a [`deposit-rejected`](/deposits/api/status-tracking#deposit-rejected) webhook — not `bridge-failed` — with error code `TOKEN-3` (token not allowed), `BALANCE-3` (amount above the configured maximum), or `BALANCE-4` (amount below the configured minimum).
## Set price deviation tolerance
For stablecoin-to-stablecoin bridges, set the maximum allowed price deviation in basis points. Deposits that exceed this threshold are rejected with error code `BRIDGE-5`.
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers,
body: JSON.stringify({
params: {
maxPriceDeviationBps: 100, // 1% max deviation
},
}),
});
```
The default is `200` (2%) if not set. Tighten this for high-value stablecoin corridors; loosen it if you see rejections during volatile periods.
## Set a minimum deposit value
Set a per-client minimum deposit value in USD, applied across all tokens and source chains. Deposits priced below it are rejected — a [`deposit-rejected`](/deposits/api/status-tracking#deposit-rejected) webhook is sent with error code `BALANCE-4` and no bridging is attempted.
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers,
body: JSON.stringify({
params: {
minDepositUsd: 1, // reject deposits worth less than $1
},
}),
});
```
`minDepositUsd` is a non-negative `number` (USD). This is a per-client override; a platform-wide minimum floor also applies and is not client-configurable.
## Clear a configuration field
Each `/setup` call is a partial update. To explicitly clear a field, pass `null` for string fields or `{}` for object fields:
| To clear | Pass |
| ---------------------- | ------ |
| `webhookUrl` | `null` |
| `webhookSecret` | `null` |
| `sponsorship` | `{}` |
| `depositWhitelist` | `{}` |
| `maxPriceDeviationBps` | `null` |
| `minDepositUsd` | `null` |
## Put it all together
A single `/setup` call configuring everything at once:
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers,
body: JSON.stringify({
params: {
webhookUrl: "https://your-domain.com/notify",
webhookSecret: "your-secret-key",
sponsorship: {
"eip155:8453": { gas: "all", bridging: "all" },
"eip155:10": { gas: "all" },
},
depositWhitelist: {
"eip155:8453": [
{
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
minAmount: "1000000",
},
],
"eip155:42161": [
{ token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" },
],
},
maxPriceDeviationBps: 150,
minDepositUsd: 1,
},
}),
});
```
# Fiat and CEX on-ramp
Source: https://docs.rhinestone.dev/deposits/api/onramp
Let users fund their account with a card, Apple Pay, a bank transfer, or a centralized exchange balance — powered by Swapped.
Users without crypto can fund their account directly inside your app. The deposit service mints a signed Swapped checkout URL for a registered account; the user pays in Swapped's hosted widget; the purchased crypto lands on the account and is processed like any other deposit — bridged to the account's target and reported through the same [status tracking](/deposits/api/status-tracking).
## How it works
1. Your backend requests a signed checkout URL for the user's account.
2. You show the URL in an iframe or in-app browser.
3. The user completes payment (and KYC, when required) with Swapped.
4. You receive [`onramp-order`](/deposits/api/status-tracking#onramp-order) webhooks as the order progresses.
5. Swapped sends the purchased crypto — currently USDC on Base — to the account. From here the standard pipeline takes over: the deposit is detected, bridged to the account's target, and reported via the regular [deposit webhooks](/deposits/api/status-tracking#webhooks).
## Prerequisites
* The account is [registered](/deposits/api/account-registration) — minting a checkout URL for an account that isn't registered to your project returns `403 Unauthorized`.
* A [webhook URL](/deposits/api/initial-setup#configure-a-webhook) is configured if you want order updates pushed rather than polled.
* Calls are made from your backend. The endpoints require your API key, which never ships in client-side code.
## Mint a checkout URL
```ts theme={null}
const DEPOSIT_SERVICE_URL =
"https://v1.orchestrator.rhinestone.dev/deposit-processor";
const API_KEY = "YOUR_RHINESTONE_API_KEY";
const response = await fetch(`${DEPOSIT_SERVICE_URL}/onramp/swapped/widget-url`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
body: JSON.stringify({
smartAccount: "0xUSER_ACCOUNT_ADDRESS",
baseCurrencyCode: "EUR",
baseCurrencyAmount: 100,
method: "apple-pay", // optional — omit to let Swapped choose the best method
}),
});
const { url, externalCustomerId, expiresAt } = await response.json();
```
Only `smartAccount` is required — every other field, including `method`, is optional and simply prefills the widget. If you omit `method`, Swapped auto-selects the best payment method for the user based on their location and other signals:
| Field | Type | Description |
| -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `smartAccount` | `string` | The registered account that receives the crypto |
| `email` | `string` | Prefills the user's email |
| `baseCountry` | `string` | User's country (ISO 3166-1 alpha-2, e.g. `"DE"`) |
| `baseCurrencyCode` | `string` | Fiat currency to charge in (e.g. `"EUR"`) |
| `baseCurrencyAmount` | `number` | Prefilled purchase amount in the fiat currency |
| `locale` | `string` | Widget language |
| `method` | `string` | **Optional.** Preselects a payment method — accepts any method Swapped supports (see the list below). Omit it to let Swapped auto-select the best method for the user. |
`method` takes a Swapped **payment group**. Swapped offers 40+ methods and the set available depends on the user's region — common values include `"creditcard"`, `"apple-pay"`, `"bank-transfer"`, `"skrill"`, `"pix"`, and `"sepa-bank-transfer"`. Pass a method's `payment_group`; the authoritative, up-to-date list for your account is Swapped's [Get Payment Methods](https://docs.swapped.com/swapped-ramp/endpoints/onramp-endpoints/get-payment-methods) endpoint. A value Swapped doesn't support for the user is ignored rather than rejected — the widget falls back to the best available method, so an unrecognized `method` never blocks checkout.
The response carries the signed URL:
| Field | Type | Description |
| -------------------- | --------- | --------------------------------------------------------------------------- |
| `url` | `string` | The checkout URL to embed |
| `currencyCode` | `string` | Asset the purchase settles in, as `ASSET_NETWORK` (currently `"USDC_BASE"`) |
| `sandbox` | `boolean` | `true` when the service runs against Swapped's sandbox |
| `externalCustomerId` | `string` | `:` — correlates the checkout with its webhooks |
| `expiresAt` | `string` | End of the order-tracking window (ISO 8601) |
The URL is signed by the service, so its parameters — destination wallet and asset — can't be modified client-side. Mint a fresh URL for each checkout session. The purchase always settles in the asset reported by `currencyCode`; when the account's target differs, the bridge hop happens automatically.
Full request and response schemas: [`POST /onramp/swapped/widget-url`](/api-reference/deposit-service/onramp/mint-a-signed-swapped-widget-url-fiat-on-ramp).
### Fund from an exchange
To let users transfer from a centralized exchange balance (Binance, Coinbase, and others) instead of paying with fiat, mint a Connect URL. The request takes a subset of the widget fields — `smartAccount` plus optional `baseCountry`, `baseCurrencyCode`, `locale`, and a `connection` preselecting the exchange. There's no amount or payment-method prefill; the user chooses those inside the exchange flow. The response shape is identical:
```ts theme={null}
const response = await fetch(`${DEPOSIT_SERVICE_URL}/onramp/swapped/connect-url`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
body: JSON.stringify({
smartAccount: "0xUSER_ACCOUNT_ADDRESS",
connection: "binance",
}),
});
```
List the supported exchanges with [`GET /onramp/swapped/connect-exchanges`](/api-reference/deposit-service/onramp/list-centralized-exchanges-for-the-connect-picker); full schema at [`POST /onramp/swapped/connect-url`](/api-reference/deposit-service/onramp/mint-a-signed-swapped-connect-url-cex-funding).
## Show the widget
Embed the URL in an iframe with payment and camera permissions — Apple Pay and camera-based KYC need them:
```html theme={null}
```
In a mobile app, open the URL in an in-app browser (`SFSafariViewController` on iOS, Chrome Custom Tabs on Android) rather than a plain WebView — Apple Pay and camera access are restricted in bare WebViews.
The URL carries no redirect or callback parameter. Detect completion through the [`onramp-order`](/deposits/api/status-tracking#onramp-order) webhook or by [polling order status](#track-the-order), then dismiss the widget from your own UI.
A user who abandons checkout produces no terminal event — the order simply
never progresses. Apply your own timeout when waiting on an order.
## Track the order
Orders move through a normalized lifecycle:
| Status | Meaning | Terminal |
| ------------ | -------------------------------------- | -------- |
| `pending` | Order created; awaiting payment or KYC | no |
| `processing` | Payment captured; crypto not yet sent | no |
| `completed` | Crypto sent on-chain | yes |
| `failed` | Order cancelled or declined | yes |
Each transition fires an [`onramp-order`](/deposits/api/status-tracking#onramp-order) webhook carrying the normalized `status`, Swapped's `rawStatus`, the fiat receipt, and — once completed — the on-chain `transactionId`.
You can also poll the account's latest order:
```ts theme={null}
const response = await fetch(
`${DEPOSIT_SERVICE_URL}/onramp/swapped/status/0xUSER_ACCOUNT_ADDRESS`,
{ headers: { "x-api-key": API_KEY } },
);
const order = await response.json();
// {
// ok: true,
// orderId: "…",
// status: "order_broadcasted",
// orderCrypto: "USDC",
// orderCryptoAmount: "98.61",
// transactionId: "0xdef456…",
// paidAmountUsd: 101.75,
// onrampFeeUsd: 1.75,
// paymentMethod: "creditcard",
// receivedAt: "2025-01-15T12:03:00.000Z"
// }
```
Polling reflects the account's most recent order and returns `{ ok: false, reason: "no_order" }` before the first order update arrives. Full schema: [`GET /onramp/swapped/status/{smartAccount}`](/api-reference/deposit-service/onramp/poll-the-latest-swapped-order-status-for-a-smart-account).
Once the order completes, the on-ramp hands off to the regular pipeline: `transactionId` reappears as the `transactionHash` on the [`deposit-received`](/deposits/api/status-tracking#deposit-received) event, followed by the usual bridge events. Card and Apple Pay orders typically complete within minutes; bank transfers can take days. Orders stay trackable for 7 days.
## Good to know
* **Session coverage** — on-ramp purchases land on Base, so the account's session set must include Base. The default [registration flow](/deposits/api/account-registration) covers every supported chain; if you restrict `sessionChainIds`, include Base or landed funds can't be bridged.
* **Deposit whitelist** — if you [restrict accepted deposits](/deposits/api/initial-setup#restrict-accepted-deposits), allow USDC on Base above your minimum. Otherwise every on-ramp purchase is rejected with [`deposit-rejected`](/deposits/api/status-tracking#deposit-rejected).
* **Same-chain target** — when the account's target is USDC on Base paid to the account itself, the purchase is already at its destination: no bridge events fire, and `onramp-order` with `status: "completed"` is your terminal signal.
* **KYC and limits** — identity verification and purchase limits are handled by Swapped inside the widget.
# API quickstart
Source: https://docs.rhinestone.dev/deposits/api/quickstart
Set up the Deposit API and process your first cross-chain deposit.
Register a managed account, deposit USDC on one chain, and receive it on another.
Building in React? The [deposit widget](/deposits/widget/quickstart) ships this flow as
a component — UI, funding methods, withdrawals and refunds included — on top of the
same API.
This runs on mainnet and moves real funds. The deposit step below sends 1 USDC —
keep it small until the flow works end to end.
## Prerequisites
* A [Rhinestone API key](https://tally.so/r/wg22x4)
* A wallet with USDC on Arbitrum
Set up gas sponsorship so deposit bridging is covered. This is a one-time call per API key.
```ts theme={null}
const DEPOSIT_SERVICE_URL =
"https://v1.orchestrator.rhinestone.dev/deposit-processor";
const API_KEY = "YOUR_RHINESTONE_API_KEY";
const response = await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
body: JSON.stringify({
params: {
sponsorship: {
"eip155:8453": { gas: "all" }, // Base
"eip155:42161": { gas: "all" }, // Arbitrum
},
},
}),
});
console.log(`Setup: ${response.status}`);
```
You should see `Setup: 200`.
Register a server-managed account with a target chain and token. The service creates a smart account deterministically from your API key and the salt you provide, and returns deposit addresses.
```ts theme={null}
import { keccak256, toHex } from "viem";
// Use any unique identifier per user (e.g., internal user ID)
const salt = keccak256(toHex("user-123"));
const RECIPIENT = "0xYOUR_RECIPIENT_ADDRESS";
const TARGET_CHAIN = "eip155:8453"; // Base
const TARGET_TOKEN = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const registerResponse = await fetch(
`${DEPOSIT_SERVICE_URL}/register-managed`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
body: JSON.stringify({
account: {
salt,
target: {
chain: TARGET_CHAIN,
token: TARGET_TOKEN,
recipient: RECIPIENT,
},
},
}),
},
);
const { evmDepositAddress, solanaDepositAddress } =
await registerResponse.json();
console.log(`EVM deposit address: ${evmDepositAddress}`);
console.log(`Solana deposit address: ${solanaDepositAddress}`);
```
You should see two deposit addresses printed. The `evmDepositAddress` is where users send tokens on any supported EVM chain.
```ts theme={null}
const check = await fetch(`${DEPOSIT_SERVICE_URL}/check/${evmDepositAddress}`, {
headers: { "x-api-key": API_KEY },
});
const checkData = await check.json();
console.log(checkData);
```
You should see:
```json theme={null}
{
"isRegistered": true,
"targetChain": "eip155:8453",
"targetToken": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"sources": [
{ "chain": "eip155:8453", "depositAddress": "" },
{ "chain": "eip155:42161", "depositAddress": "" },
{ "chain": "eip155:10", "depositAddress": "" }
]
}
```
Transfer USDC to the deposit address on Arbitrum. The deposit service detects it and bridges it to Base automatically.
```ts theme={null}
import {
createWalletClient,
http,
encodeFunctionData,
erc20Abi,
parseUnits,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arbitrum } from "viem/chains";
const funder = privateKeyToAccount("0xYOUR_FUNDING_KEY");
const walletClient = createWalletClient({
account: funder,
chain: arbitrum,
transport: http(),
});
// USDC on Arbitrum
const USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
const txHash = await walletClient.sendTransaction({
to: USDC,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [evmDepositAddress, parseUnits("1", 6)],
}),
});
console.log(`Deposit tx: ${txHash}`);
```
Poll the deposits endpoint by transaction hash to track the bridging progress. See [status tracking](/deposits/api/status-tracking#polling) for the full response schema.
```ts theme={null}
async function waitForDeposit(txHash: string) {
const url = `${DEPOSIT_SERVICE_URL}/deposits?txHash=${txHash}`;
while (true) {
const response = await fetch(url, {
headers: { "x-api-key": API_KEY },
});
const { deposits } = await response.json();
const deposit = deposits[0];
if (deposit?.status === "completed") {
console.log("Deposit completed:", deposit.destinationTxHash);
return deposit;
}
if (deposit?.status === "failed") {
console.error("Deposit failed:", deposit.errorCode);
return deposit;
}
await new Promise((r) => setTimeout(r, 1_000));
}
}
await waitForDeposit(txHash);
```
Once bridging completes, the deposit status changes to `completed` and includes the destination transaction hash. The USDC is now at the recipient address on Base.
## Next steps
Webhooks, sponsorship rules, and deposit whitelists.
User-owned accounts, session configuration, and output token rules.
Deposit lifecycle, retries, and status tracking.
Track deposits via polling or webhooks.
# Sponsorship
Source: https://docs.rhinestone.dev/deposits/api/sponsorship
Sponsor deposit fees on behalf of your users.
Sponsorship lets you absorb deposit fees so your users receive the full deposited amount on the target chain. You configure which fee types to sponsor, independently per source chain, via the [`/setup` endpoint](/deposits/api/initial-setup#sponsor-fees).
## Sponsorable fees
Each fee type is controlled independently:
| Fee | Values | Description |
| ------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Gas** | `"all"`, `"deployed"`, `"none"` | `"all"` sponsors gas for every deposit. `"deployed"` only sponsors gas when the account is already deployed on the source chain — useful to avoid covering first-time deployment costs. Default: `"none"`. |
| **Bridging** | `"all"`, `"none"` | Covers the settlement layer bridging fee. Default: `"none"`. |
| **Swap** | `"all"`, `"none"` | Covers the DEX swap fee when a token conversion is needed. Default: `"none"`. |
## Per-chain configuration
Sponsorship is configured per source chain using [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) identifiers. Each chain is configured independently — there's no global toggle. Chains without explicit config default to no sponsorship.
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/setup`, {
method: "POST",
headers,
body: JSON.stringify({
params: {
sponsorship: {
// Base: fully sponsored
"eip155:8453": { gas: "all", bridging: "all", swap: "all" },
// Optimism: only gas
"eip155:10": { gas: "all" },
// Arbitrum: gas for deployed accounts only
"eip155:42161": { gas: "deployed" },
// Ethereum, Polygon: no sponsorship (not listed)
},
},
}),
});
```
## How sponsorship is resolved
Each fee type is resolved independently when processing a deposit, based on the source chain config and the account's deployment status at that moment.
* Each fee type is resolved independently. You can sponsor bridging without sponsoring gas, or vice versa.
* Deployment status is checked at processing time, not at registration. An account registered before deployment will start receiving gas sponsorship once it's deployed (if the mode is `"deployed"`).
* Chains without explicit config default to no sponsorship. Omitting a fee type from the chain config is equivalent to `"none"`.
## Effect on deposit amount
**Sponsored fees** are covered by your sponsorship balance. The user receives the full bridged amount — if they deposit 100 USDC and fees are 0.50 USDC, they receive the full 100 USDC on the target chain.
**Unsponsored fees** are deducted from the deposit. The user receives less than they sent — if they deposit 100 USDC and fees are 0.50 USDC, they receive 99.50 USDC.
## Learn more
* [Initial setup](/deposits/api/initial-setup#sponsor-fees) — configure sponsorship via `/setup`
* [Gas & fee sponsorship](/smart-wallet/gas-sponsorship/overview) — sponsorship in the Smart Wallet SDK context
* [Fees](/deposits/api/fees) — full fee type breakdown
# Status tracking
Source: https://docs.rhinestone.dev/deposits/api/status-tracking
Track deposit status via polling or webhooks — two approaches for monitoring the deposit lifecycle.
There are two ways to track a deposit through the processing pipeline:
* **Polling** — query the `GET /deposits` endpoint filtered by transaction hash. Simple and stateless.
* **Webhooks** — receive push notifications as deposits move through each stage. Real-time and event-driven.
Use whichever fits your architecture. Many integrations combine both: webhooks for real-time updates, polling as a fallback or for on-demand status checks.
## Polling
Query the `GET /deposits` endpoint with the `txHash` parameter to look up a deposit by its source transaction hash.
```ts theme={null}
const DEPOSIT_SERVICE_URL =
"https://v1.orchestrator.rhinestone.dev/deposit-processor";
const API_KEY = "YOUR_RHINESTONE_API_KEY";
const txHash = "0xabc123...";
const response = await fetch(
`${DEPOSIT_SERVICE_URL}/deposits?txHash=${txHash}`,
{
headers: { "x-api-key": API_KEY },
},
);
const { deposits } = await response.json();
```
### Response
Each item in the `deposits` array has the following shape:
| Field | Type | Description |
| ------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | Unique deposit identifier |
| `chain` | `string` | Source chain (CAIP-2) |
| `txHash` | `string` | Source transaction hash |
| `token` | `string` | Deposit token address |
| `amount` | `string` | Deposit amount (raw token units) |
| `sender` | `string` | Sender address |
| `account` | `string` | Registered account address |
| `targetChain` | `string` | Destination chain (CAIP-2) |
| `targetToken` | `string` | Destination token address |
| `status` | `string` | In-progress: `"processing"`, `"delayed"`, `"expecting_refund"`. Terminal: `"completed"`, `"failed"`, `"rejected"`, `"refunded"`, `"ignored"` |
| `sourceTxHash` | `string \| null` | Bridge source transaction hash |
| `destinationTxHash` | `string \| null` | Bridge destination transaction hash |
| `sourceAmount` | `string \| null` | Bridge source amount |
| `destinationAmount` | `string \| null` | Bridge destination amount |
| `errorCode` | `string \| null` | [Error code](/deposits/api/deposit-processing#error-codes) if the deposit failed |
| `createdAt` | `string` | ISO 8601 timestamp of when the deposit was detected |
| `completedAt` | `string \| null` | ISO 8601 timestamp of when the deposit completed |
| `retryable` | `boolean` | Whether a failed deposit can be retried — `false` for non-retryable errors and policy rejections |
| `isSpam` | `boolean` | Flagged as spam (token has no known price); omitted from results unless you pass `includeSpam=true` |
### Polling loop
Poll until the deposit reaches a terminal status (`completed`, `failed`, `rejected`, `refunded`, or `ignored`):
```ts theme={null}
const TERMINAL = new Set(["completed", "failed", "rejected", "refunded", "ignored"]);
async function waitForDeposit(txHash: string): Promise {
const url = `${DEPOSIT_SERVICE_URL}/deposits?txHash=${txHash}`;
const headers = { "x-api-key": API_KEY };
while (true) {
const response = await fetch(url, { headers });
const { deposits } = await response.json();
const deposit = deposits[0];
if (!deposit) {
// Deposit not yet indexed — wait and retry
await new Promise((r) => setTimeout(r, 1_000));
continue;
}
if (TERMINAL.has(deposit.status)) {
// `rejected` carries an errorCode (whitelist/minimum); see deposit-rejected
console.log("Deposit settled:", deposit.status, deposit.errorCode ?? deposit.destinationTxHash);
return;
}
// Still in progress (`processing` / `expecting_refund`) — poll again
await new Promise((r) => setTimeout(r, 1_000));
}
}
```
A 1-second interval works well for most use cases. Most deposits complete
within seconds.
## Webhooks
The deposit service sends webhook notifications to your configured endpoint as deposits move through the processing pipeline. All webhooks are `POST` requests with `Content-Type: application/json`.
Configure your webhook URL and optional secret via the [`POST /setup`](/deposits/api/initial-setup#configure-a-webhook) endpoint.
### Payload envelope
Every webhook request body follows the same envelope structure:
```json theme={null}
{
"version": "1.0",
"type": "",
"eventId": "12345",
"time": "2025-01-15T12:00:00.000Z",
"data": { ... }
}
```
| Field | Type | Description |
| --------- | ---------------------- | ----------------------------------------------------------------------- |
| `version` | `string` | Protocol version. Currently `"1.0"` |
| `type` | `string` | Event type identifier |
| `eventId` | `string` | Stable identifier for this delivery. Use it as the canonical dedupe key |
| `time` | `string` | ISO 8601 timestamp of when the event was sent |
| `test` | `boolean \| undefined` | Present and `true` only for events dispatched via the test endpoint |
| `data` | `object` | Event-specific payload (see below) |
### Event types
| Type | Trigger |
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
| [`deposit-received`](#deposit-received) | Token transfer detected on a registered account |
| [`deposit-rejected`](#deposit-rejected) | Detected deposit will not be bridged — deposit whitelist or minimum violation |
| [`deposit-delayed`](#deposit-delayed) | Deposit held because a per-intent sponsorship cap would be exceeded; retried automatically |
| [`bridge-started`](#bridge-started) | Bridging intent created and submitted to the Orchestrator |
| [`bridge-complete`](#bridge-complete) | Tokens arrived on the target chain |
| [`bridge-delayed`](#bridge-delayed) | Bridge provider did not fill within the expected window; a refund is expected |
| [`bridge-failed`](#bridge-failed) | Bridging failed |
| [`deposit-refunded`](#deposit-refunded) | Deposit funds returned to a recipient on the source chain |
| [`onramp-order`](#onramp-order) | A [fiat or CEX on-ramp](/deposits/api/onramp) order changed status |
| [`error`](#error) | Unexpected error while processing a deposit (the settlement catch-all) |
#### `deposit-received`
Sent when an incoming token transfer is detected on a registered account.
| Field | Type | Description |
| ----------------- | -------- | ------------------------------------------- |
| `chain` | `string` | Source chain (CAIP-2, e.g. `"eip155:8453"`) |
| `token` | `string` | Token address |
| `amount` | `string` | Deposit amount in raw token units |
| `account` | `string` | Account address |
| `transactionHash` | `string` | Deposit transaction hash |
| `sender` | `string` | Sender address |
```json theme={null}
{
"version": "1.0",
"type": "deposit-received",
"eventId": "12345",
"time": "2025-01-15T12:00:00.000Z",
"data": {
"chain": "eip155:8453",
"token": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"amount": "1000000",
"account": "0x1234567890abcdef1234567890abcdef12345678",
"transactionHash": "0xabc123...",
"sender": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
}
```
#### `deposit-rejected`
Sent when a detected deposit will **not** be bridged because it violates the account's [deposit whitelist](/deposits/api/initial-setup#restrict-accepted-deposits) — the token isn't allowed, or the amount is outside the configured minimum/maximum. It always follows a [`deposit-received`](#deposit-received) event for the same deposit and is terminal: no bridging is attempted and there is no retry. This is a deliberate rejection, not a processing failure — use it to record the outcome on your side. See [error codes](/deposits/api/deposit-processing#error-codes) for the full list.
| Field | Type | Description |
| ------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `errorCode` | `string` | Reason — `"BALANCE-4"` (below minimum), `"BALANCE-3"` (above maximum), or `"TOKEN-3"` (token not allowed) |
| `account` | `string` | Account address |
| `message` | `string \| undefined` | Human-readable explanation |
| `limits.minAmount` | `string \| undefined` | Configured minimum in raw token units — present for `BALANCE-4` when the rejection is a per-token whitelist minimum |
| `limits.minAmountUsd` | `string \| undefined` | Configured minimum as a USD value — present for `BALANCE-4` when the rejection is your per-client USD floor (a `BALANCE-4` carries `minAmount` or `minAmountUsd`, not both) |
| `limits.maxAmount` | `string \| undefined` | Configured maximum in raw token units — present for `BALANCE-3` |
| `deposit.transactionHash` | `string` | Deposit transaction hash |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.token` | `string` | Deposit token address |
| `deposit.amount` | `string` | Actual deposited amount in raw token units |
| `deposit.sender` | `string` | Sender address |
Compare `deposit.amount` (what was deposited) against `limits` (the configured bound) to surface the shortfall or overage to your users.
```json theme={null}
{
"version": "1.0",
"type": "deposit-rejected",
"eventId": "12347",
"time": "2025-01-15T12:00:01.000Z",
"data": {
"errorCode": "BALANCE-4",
"account": "0x1234567890abcdef1234567890abcdef12345678",
"message": "Amount 500000 is below configured minimum 1000000",
"limits": {
"minAmount": "1000000"
},
"deposit": {
"transactionHash": "0xabc123...",
"chain": "eip155:8453",
"token": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"amount": "500000",
"sender": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
}
}
```
#### `deposit-delayed`
Sent when a deposit is temporarily **held** because sponsoring the bridge right now would exceed one of the per-intent sponsorship caps configured for the account — the overall sponsored amount, the sponsored gas fees, or the sponsored bridge fees. These are USD caps on how much sponsorship a single intent may consume; there are no gas-price or gas-amount limits. It follows a [`deposit-received`](#deposit-received) event and is **not** terminal: the deposit enters the `"delayed"` status and is re-quoted automatically on a backoff. Because the sponsored cost varies over time (gas and bridge fees fluctuate) and the cap can be raised, a later quote may fit — then it proceeds as normal ([`bridge-started`](#bridge-started) → [`bridge-complete`](#bridge-complete)). The deposit is held patiently through sustained congestion (up to a hold window, \~24h by default); only if it still hasn't cleared when that window elapses does it end as [`bridge-failed`](#bridge-failed) with error code `SPONSORSHIP-1`.
Fired **once**, when the deposit first enters the delayed state — not on every retry. No action is required on your side; use it to surface a "waiting to settle" state to your users, and check `limitKey` if you want to raise the relevant cap. Distinct from [`bridge-delayed`](#bridge-delayed), which means a bridge already claimed funds and a refund is pending.
| Field | Type | Description |
| ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reason` | `string` | Human-readable explanation of the hold |
| `errorCode` | `string` | Always `"SPONSORSHIP-1"` |
| `limitKey` | `string \| undefined` | Which per-intent cap was exceeded: `perIntentUSD` (overall sponsorship), `gasPerIntentUSD` (sponsored gas fees), or `bridgeFeePerIntentUSD` (sponsored bridge fees) |
| `capUsd` | `number \| undefined` | The configured cap, in USD, when reported |
| `coverageUsd` | `number \| undefined` | The sponsored amount the quote would have needed, in USD, when reported |
| `account` | `string` | Account address |
| `deposit.transactionHash` | `string` | Deposit transaction hash |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.token` | `string` | Deposit token address |
| `deposit.amount` | `string` | Deposit amount (raw token units) |
| `deposit.sender` | `string` | Sender address |
```json theme={null}
{
"version": "1.0",
"type": "deposit-delayed",
"eventId": "12348",
"time": "2025-01-15T12:00:01.000Z",
"data": {
"reason": "Sponsorship cap exceeded for this intent; deposit delayed",
"errorCode": "SPONSORSHIP-1",
"limitKey": "gasPerIntentUSD",
"capUsd": 5,
"coverageUsd": 8,
"account": "0x1234567890abcdef1234567890abcdef12345678",
"deposit": {
"transactionHash": "0xabc123...",
"chain": "eip155:1",
"token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"amount": "1000000",
"sender": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
}
}
```
#### `bridge-started`
Sent when a bridging intent is created and submitted to the Orchestrator.
| Field | Type | Description |
| --------------------------- | --------------------- | --------------------------------------------------------------- |
| `source.chain` | `string` | Source chain (CAIP-2) |
| `source.asset` | `string` | Source asset address |
| `source.amount` | `string` | Source amount |
| `destination.chain` | `string` | Destination chain (CAIP-2) |
| `destination.asset` | `string` | Destination asset address |
| `destination.amount` | `string` | Destination amount |
| `account` | `string` | Account address |
| `deposit.transactionHash` | `string` | Original deposit transaction hash |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.asset` | `string` | Deposit asset address |
| `deposit.amount` | `string` | Deposit amount |
| `deposit.sender` | `string` | Sender address |
| `settlementLayer` | `string` | Settlement layer used (e.g. `"layerzero"`) |
| `estimatedFillTime.seconds` | `number \| undefined` | Estimated time to fill, in seconds, when the route provides one |
#### `bridge-complete`
Sent when tokens have arrived on the target chain.
| Field | Type | Description |
| ----------------------------- | --------------------- | ----------------------------------------------------- |
| `deposit.transactionHash` | `string` | Original deposit transaction hash |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.asset` | `string` | Deposit asset address |
| `deposit.amount` | `string` | Deposit amount |
| `deposit.sender` | `string` | Sender address |
| `source.transactionHash` | `string` | Source chain claim transaction hash |
| `source.chain` | `string` | Source chain (CAIP-2) |
| `source.amount` | `string` | Source amount |
| `source.asset` | `string` | Source asset address |
| `destination.transactionHash` | `string` | Destination chain transaction hash |
| `destination.chain` | `string` | Destination chain (CAIP-2) |
| `destination.amount` | `string` | Destination amount |
| `destination.asset` | `string` | Destination asset address |
| `destination.logIndex` | `number \| undefined` | Log index of the destination transfer, when available |
| `account` | `string` | Account address |
| `settlementLayer` | `string \| undefined` | Settlement layer used |
```json theme={null}
{
"version": "1.0",
"type": "bridge-complete",
"eventId": "12346",
"time": "2025-01-15T12:01:30.000Z",
"data": {
"deposit": {
"transactionHash": "0xabc123...",
"chain": "eip155:8453",
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"amount": "1000000",
"sender": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
},
"source": {
"transactionHash": "0xdef456...",
"chain": "eip155:8453",
"amount": "1000000",
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
},
"destination": {
"transactionHash": "0x789ghi...",
"chain": "eip155:42161",
"amount": "990000",
"asset": "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
"logIndex": 5
},
"account": "0x1234567890abcdef1234567890abcdef12345678"
}
}
```
#### `bridge-delayed`
Sent when the bridge provider has not filled the intent within the expected window. A refund is expected on the source chain; the subsequent [`deposit-refunded`](#deposit-refunded) event confirms the funds returned.
| Field | Type | Description |
| ---------------------------- | --------------------- | -------------------------------------------------------------- |
| `reason` | `string` | Human-readable explanation of the delay |
| `estimatedDelayTime.seconds` | `number \| undefined` | Additional time the refund is expected to take, when available |
| `account` | `string` | Account address |
| `deposit.transactionHash` | `string` | Deposit transaction hash |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.token` | `string` | Deposit token address |
| `deposit.amount` | `string` | Deposit amount |
| `deposit.sender` | `string` | Sender address |
#### `bridge-failed`
Sent when a bridging operation fails. See [error codes](/deposits/api/deposit-processing#error-codes) for the full list and retry behavior.
| Field | Type | Description |
| ------------------------- | --------------------- | -------------------------------- |
| `errorCode` | `string` | Error code (e.g. `"BRIDGE-1"`) |
| `account` | `string` | Account address |
| `message` | `string \| undefined` | Human-readable error description |
| `deposit.transactionHash` | `string` | Deposit transaction hash |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.token` | `string` | Deposit token address |
| `deposit.amount` | `string` | Deposit amount |
| `deposit.sender` | `string` | Sender address |
#### `deposit-refunded`
Sent when funds from a deposit are returned to a recipient on the source chain. Typically follows a [`bridge-delayed`](#bridge-delayed) event.
| Field | Type | Description |
| ------------------------- | -------- | --------------------------------- |
| `account` | `string` | Account address |
| `deposit.transactionHash` | `string` | Original deposit transaction hash |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.asset` | `string` | Deposit asset address |
| `deposit.amount` | `string` | Deposit amount |
| `deposit.sender` | `string` | Sender address |
| `refund.transactionHash` | `string` | Refund transaction hash |
| `refund.chain` | `string` | Refund chain (CAIP-2) |
| `refund.asset` | `string` | Refund asset address |
| `refund.amount` | `string` | Refund amount |
| `refund.recipient` | `string` | Address that received the refund |
#### `onramp-order`
Sent when a [fiat or CEX on-ramp](/deposits/api/onramp) order moves through its lifecycle. Unlike the other events, it is tied to a Swapped order rather than a deposit — once the purchased crypto lands on-chain, the regular deposit events take over. Correlate `transactionId` here with `transactionHash` on the subsequent [`deposit-received`](#deposit-received).
| Field | Type | Description |
| --------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `orderId` | `string` | Swapped order identifier |
| `orderUuid` | `string` | UUID minted with the checkout URL — the suffix of `externalCustomerId` |
| `account` | `string` | Account address |
| `source` | `string` | `"onramp"` (fiat widget) or `"exchange"` (CEX transfer) |
| `status` | `string` | Normalized [order status](/deposits/api/onramp#track-the-order): `"pending"`, `"processing"`, `"completed"`, or `"failed"` |
| `rawStatus` | `string` | Swapped's raw order status |
| `terminal` | `boolean` | `true` once `status` is `"completed"` or `"failed"` |
| `token` | `string \| null` | Purchased token symbol (e.g. `"USDC"`) |
| `cryptoAmount` | `string \| null` | Purchased amount as a decimal string — **not** raw token units |
| `fiat` | `object \| null` | Fiat receipt; `null` until Swapped reports amounts |
| `fiat.currency` | `string` | Fiat currency of the payment (`"USD"` or `"EUR"`) |
| `fiat.amount` | `string` | Fiat amount before Swapped's fee (decimal string) |
| `fiat.amountPlusFees` | `string \| null` | Fiat amount including Swapped's fee |
| `fiat.feeUsd` | `string \| null` | Swapped's fee in USD, when derivable |
| `transactionId` | `string \| null` | On-chain transaction hash of the crypto transfer, present once completed |
| `method` | `string \| null` | Payment method when `source` is `"onramp"` (e.g. `"creditcard"`) |
| `exchange` | `string \| null` | Exchange slug when `source` is `"exchange"` (e.g. `"binance"`) |
```json theme={null}
{
"version": "1.0",
"type": "onramp-order",
"eventId": "12350",
"time": "2025-01-15T12:03:00.000Z",
"data": {
"orderId": "8f14e45f-ceea-4672-a1d5-6f3b2a7c9e01",
"orderUuid": "0b0e8f9c-2d4a-4e6b-9c1f-3a5d7e9b0c2d",
"account": "0x1234567890abcdef1234567890abcdef12345678",
"source": "onramp",
"status": "completed",
"rawStatus": "order_broadcasted",
"terminal": true,
"token": "USDC",
"cryptoAmount": "98.61",
"fiat": {
"currency": "USD",
"amount": "100.00",
"amountPlusFees": "101.75",
"feeUsd": "1.75"
},
"transactionId": "0xdef456...",
"method": "creditcard",
"exchange": null
}
}
```
`onramp-order` is delivered at-least-once and only ever moves forward through the order lifecycle — you may receive a duplicate for the same stage, never an earlier one. Dedupe on `eventId`.
#### `error`
Sent when an unexpected, unhandled error occurs while processing a deposit — the catch-all in the settlement pipeline. It is not part of the normal lifecycle ordering and may arrive at any point. It carries whatever deposit / account / intent context was available at the point of failure; the optional fields are present only when that context was known.
| Field | Type | Description |
| ------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
| `error.message` | `string` | Error message |
| `error.stack` | `string \| undefined` | Stack trace, when available |
| `error.cause` | `unknown \| undefined` | Underlying cause, when available |
| `intentId` | `string \| undefined` | Bridging intent id, when the failure occurred after the intent was created |
| `account` | `string \| undefined` | Account address, when known |
| `deposit.transactionHash` | `string` | Deposit transaction hash (the `deposit` object is present only when the deposit was known) |
| `deposit.chain` | `string` | Deposit chain (CAIP-2) |
| `deposit.token` | `string` | Deposit token address |
| `deposit.amount` | `string` | Deposit amount in raw token units |
| `deposit.sender` | `string` | Sender address |
Treat `error` as a signal that a deposit needs manual attention. Correlate on `deposit.transactionHash` / `intentId` and reconcile by polling `GET /deposits`.
### Signature verification
If you provided a `webhookSecret` during [setup](/deposits/api/initial-setup#configure-a-webhook), every webhook request includes an `X-Webhook-Signature` header:
```
X-Webhook-Signature: sha256=
```
The signature is an HMAC-SHA256 hash computed over the raw JSON request body using your secret.
To verify:
1. Read the raw request body as a string (before JSON parsing)
2. Compute the HMAC-SHA256 of the raw body using your webhook secret
3. Compare the result with the value in the `X-Webhook-Signature` header (strip the `sha256=` prefix)
4. Use a constant-time comparison to prevent timing attacks
```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return (
signatureHeader.length === expected.length &&
timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected))
);
}
```
Always verify against the **raw request body** string, not a re-serialized
version of the parsed JSON. Re-serialization may change key order or
whitespace, which will produce a different signature.
### Delivery behavior
* **Retries** — failed deliveries are retried multiple times before the event is marked `failed`. Events that exhaust their retries can still be replayed on demand via [`POST /webhooks/events/{id}/resend`](/api-reference/deposit-service/webhooks/re-attempt-delivery-of-a-stored-webhook-event), which reuses the original `eventId`.
* **Ordering** — events for a single deposit are sent in lifecycle order: `deposit-received` → `bridge-started` → `bridge-complete` when it proceeds, or `deposit-received` → `deposit-rejected` when it won't be bridged. A `deposit-delayed` may appear after `deposit-received` (before `bridge-started`) when the deposit is held under a sponsorship cap; it still resolves to `bridge-complete` or, on expiry, `bridge-failed`. `onramp-order` events are likewise ordered per order and only move forward. There is no global ordering guarantee across deposits or orders.
* **URL validation** — the webhook URL must use HTTPS and must not target internal or private network addresses.
* **Idempotency** — use `eventId` from the envelope as the canonical dedupe key.
### Backfilling missed events
If your receiver was offline or rejected events, replay them through the events API. Every dispatched webhook is persisted regardless of delivery outcome.
List events delivered (or attempted) to your URL, newest first:
```ts theme={null}
const response = await fetch(
`${DEPOSIT_SERVICE_URL}/webhooks/events?since=2025-01-15T00:00:00Z`,
{ headers: { "x-api-key": API_KEY } },
);
const { events, nextCursor } = await response.json();
```
Re-send a stored event to your URL — the original `eventId` is reused so dedupe still holds:
```ts theme={null}
await fetch(`${DEPOSIT_SERVICE_URL}/webhooks/events/${eventId}/resend`, {
method: "POST",
headers: { "x-api-key": API_KEY },
});
```
For more details, see the API reference for [`GET /webhooks/events`](/api-reference/deposit-service/webhooks/list-persisted-webhook-events-for-the-calling-client) and [`POST /webhooks/events/{id}/resend`](/api-reference/deposit-service/webhooks/re-attempt-delivery-of-a-stored-webhook-event).
## Conventions
* EVM addresses and token addresses are **lowercase**. Non-EVM addresses (Solana, Tron) preserve their original case.
* Token amounts are **strings in raw token units** (not human-readable). Fiat amounts are **decimal strings** (e.g. `"101.75"`). The one crossover: `onramp-order.cryptoAmount` is reported by Swapped as a decimal string, not raw units.
* Chains use [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) identifiers (e.g. `"eip155:8453"` for Base).
# Demo
Source: https://docs.rhinestone.dev/deposits/demo
# Overview
Source: https://docs.rhinestone.dev/deposits/overview
Accept deposits from any chain into your app with Rhinestone's cross-chain deposit infrastructure.
Rhinestone Deposits is a cross-chain deposit infrastructure that lets you accept tokens from users on any supported chain and deliver them to a target chain and token automatically. You don't need to build bridging logic, manage gas across chains, or handle token swaps — the service detects deposits, bridges them via [Warp](/home/introduction/rhinestone-intents), and notifies your app when funds arrive.
It's built for teams that need reliable deposit rails: neobanks, modern dapps, DeFi protocols, or any app that onboards users from multiple chains.
It supports a wide range of EVM chains and Solana — see [supported chains and tokens](#supported-chains-and-tokens) below.
## Two ways to integrate
**Start with the widget.** It ships the whole deposit UI — funding methods, chain and token selection, status screens, withdrawals and refunds — and it uses the same API underneath, so nothing is closed off later. Reach for the API directly when you're not building in React, or when you want a deposit flow that doesn't look like a modal.
A React modal that handles funding method, chain and token selection, and deposit execution out of the box. Also covers [fiat and exchange funding](/deposits/widget/deposit-modal#funding-methods), [withdrawals](/deposits/widget/withdraw-modal), [refunds](/deposits/widget/claim-modal), and [migrating balances from other apps](/deposits/widget/asset-migrations) (e.g. Polymarket).
[Get started with the Deposit Widget →](/deposits/widget/quickstart)
A headless backend service for programmatic deposit handling. You register accounts, configure webhooks, and process deposits server-side, and you build the UI. Use it for native mobile, non-React frontends, or fully custom flows.
[Get started with the Deposit API →](/deposits/api/quickstart)
## How it works
```mermaid actions={false} theme={null}
sequenceDiagram
participant App as Your app
participant DS as Deposit Service
participant Warp as Warp (Orchestrator)
participant Dest as Target chain
App->>DS: Register account with target chain/token
Note over App: User sends tokens on any source chain
DS->>DS: Detect deposit via webhook listener
DS->>Warp: Create bridging intent
Warp->>Dest: Route and settle funds
DS->>App: Webhook notification (bridge-complete)
```
1. You register a smart account with a target chain and token
2. The user transfers tokens to their smart account on any supported source chain
3. The deposit service detects the transfer, creates a bridging intent via Warp, and routes the funds to the target chain
4. Your app receives a webhook notification when the deposit completes
The user makes a single transfer. Everything else — bridging, swaps, gas — is handled automatically.
## Why Deposits
Deposits are self-custodial by design — funds are held in the user's smart account at every step, and Rhinestone never takes custody. Stablecoin swaps settle at parity and fees can be fully sponsored. The result is a deposit rail that feels like a native single-chain transfer, without the trust trade-offs of a centralized bridge.
Under the hood, Rhinestone aggregates multiple bridging providers, solvers, and quoting services, routing each deposit through the best available path. If one provider is degraded or a route is unavailable, the service falls back automatically — giving you a single integration with the reliability of several.
## Key features
* **Automatic bridging** — deposits are detected and bridged to the target chain without any user interaction beyond the initial transfer
* **Multi-chain support** — accept deposits from [any supported chain](#supported-chains-and-tokens), EVM or Solana, with more added regularly
* **1:1 stablecoin swaps** — USDC and USDT are swapped at parity
* **Swap routing** — route between tokens as part of the deposit
* **Fee sponsorship** — cover gas, bridging, and swap fees on a per-chain basis
## User experience
From the user's perspective, depositing is a simple token transfer — send tokens to an address on any supported chain. There's no bridging UI, no gas token management, and no chain switching.
With the **widget**, the user picks how to fund — connected wallet, QR transfer, card, or an exchange — then confirms, all in one modal. Withdrawals and refunds have their own modals.
With the **API**, you control the UX entirely. The user interacts with your app however you design it, and the deposit service handles everything behind the scenes.
## Supported chains and tokens
Rhinestone Deposits supports a wide range of EVM chains plus Solana. Each chain is enabled as a **source** (the user can send funds to it), a **destination** (funds can settle on it), or both.
* **Source** — users can transfer tokens on this chain and have them bridged to the target.
* **Destination** — accounts on this chain can be registered as the target where funds land.
* **Tokens** — `All` means any token routable through Warp; otherwise, only the listed tokens are accepted.
This data is also available programmatically via the [List supported chains
and tokens](/api-reference/deposit-service/utilities/list-supported-chains-and-tokens) endpoint.
## Which should you use?
| | Deposit Widget | Deposit API |
| ---------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------- |
| Frontend | React component, themeable, modal or inline | You build it |
| Backend | A [proxy holding your API key](/deposits/widget/backend) — ours or your own | Your service, holding the key |
| Deposit triggers | Any transfer to the deposit account, plus in-modal funding | Any transfer to the deposit account |
| Fiat / exchange | Card, Apple Pay, bank and CEX funding, UI included | [Endpoints](/deposits/api/onramp); you build the UI |
| Withdrawals | [Withdraw modal](/deposits/widget/withdraw-modal); your app signs the transfer | You build it |
| Refunds | [Claim modal](/deposits/widget/claim-modal) for users, plus the dashboard | Dashboard, or build your own |
| Status | Lifecycle callbacks in the browser | [Webhooks](/deposits/api/status-tracking) server-side |
Both need a backend, and both see the same deposits — a user who sends funds to the deposit address never touches your UI either way. Most widget integrations also want [webhooks](/deposits/widget/backend#webhooks), for fulfilment that can't depend on the modal being open.
# Troubleshooting
Source: https://docs.rhinestone.dev/deposits/troubleshooting
Retry or refund failed deposits from the dashboard
Most deposits process automatically. When one fails, you can retry or refund it from the [Dashboard](https://dashboard.rhinestone.dev) under **Deposits**.
A failed deposit shows a **failed** status in the deposits list, with "Retry" and "Refund" actions available.
## Retrying a deposit
Use retry when a deposit failed for a transient reason and the funds are still recoverable on-chain. Open the deposit and press "Retry".
Retry reprocesses **all failed deposits for that address**, not just the one you opened. Every failed deposit for the address is re-attempted.
If retry keeps failing, the funds may not be automatically recoverable — refund the deposit instead, or [reach out to us](http://t.me/kurt_larsen).
## Refunding a deposit
Refunds return a failed deposit's funds to an address you choose. Only Owners and Admins can refund.
To let users recover their own failed deposits without going through you, embed the
[claim modal](/deposits/widget/claim-modal) — the user pastes the deposit's
transaction hash and signs to authorize it. That signature is the authorization, so
it needs no backend of your own.
Enter the address to refund to. You can press "Use sender address" to refund the original sender. Confirm that the recipient is a self-custodial wallet, then press "Refund".
Never refund to an exchange deposit address. Exchanges don't credit arbitrary incoming transfers, so the funds **may be lost**. Refund only to a self-custodial wallet you control.
Refunds are supported on EVM chains and Solana.
# Asset migrations
Source: https://docs.rhinestone.dev/deposits/widget/asset-migrations
Fund a deposit from balances the user already holds in another app.
The modal can pull balances a user already holds in a supported third-party app
and fund the deposit from there — no manual transfer first. Each provider is
**off by default**; opt in per provider via `assetMigrations`.
```tsx theme={null}
```
When at least one provider is enabled, a **Migrate assets** row appears on the
deposit entry screen and opens a picker over that provider's positions. The user
picks one, and the modal pulls the funds into their smart account and bridges
them to `targetChain` / `targetToken` like any other source.
| Key | Status |
| ------------- | ----------- |
| `polymarket` | Live |
| `aave` | Live |
| `hyperliquid` | Coming soon |
| `morpho` | Coming soon |
Only the providers you enable are listed. Enabling one that isn't live yet shows it
as a disabled "Coming soon" row rather than hiding it.
## Polymarket
Set `assetMigrations={{ polymarket: true }}`. The modal looks up the connected
EOA's Polymarket proxy wallet on Polygon and surfaces any pUSD or USDC.e
balance. pUSD is unwrapped to USDC.e on the way out; USDC.e is what lands in the
smart account and what the orchestrator bridges from.
Polymarket exposes two wallet types and the modal handles both: some transfer
on-chain directly from the user's signed transaction, while others are relayed
through your [backend proxy](/deposits/widget/backend), which must forward
`POST /polymarket/withdraw`.
### Open directly into Polymarket
To skip the deposit-method home screen and land the user straight on the
Polymarket transfer, pass `initialAssetMigration="polymarket"` alongside
`assetMigrations={{ polymarket: true }}`. While balances load the modal shows a
skeleton; if there's no account, no balance, or no connected wallet it falls
back to the home screen.
```tsx theme={null}
```
### Headless account lookup
If you build your own UI and only need the data, the
`@rhinestone/deposit-modal/polymarket` subpath exports a headless
`getPolymarketAccount` — no React, modal, or wallet-connect code pulled into
your bundle. Give it the connected EOA and it returns the user's Polymarket
proxy wallet address and on-chain pUSD / USDC.e balances on Polygon.
```ts theme={null}
import { getPolymarketAccount } from "@rhinestone/deposit-modal/polymarket";
const account = await getPolymarketAccount({ eoa: connectedAddress });
if (account) {
account.proxyWallet; // the user's Polymarket address on Polygon
account.pusd.formatted; // pUSD balance, e.g. "12.5"
account.usdce.formatted; // USDC.e balance
account.totalUsd; // combined USD value
}
```
Returns `null` only when the EOA has no Polymarket account. A transient failure
(network, 5xx, abort) rejects instead, so an outage is never mistaken for "no
account". Pass `rpcUrl` to override the default public Polygon RPC, or a
`signal` (`AbortSignal`) to cancel the lookup.
## Aave
Set `assetMigrations={{ aave: true }}`. The modal lists the connected EOA's Aave
v3 supply positions across every chain you can take deposits from, and picking
one produces a single `Pool.withdraw` that sends the proceeds **straight to the
user's deposit address** — the funds never pass through their wallet, and the
normal deposit pipeline ingests them like any other incoming transfer.
Requires your [backend proxy](/deposits/widget/backend) to forward
`GET /positions/:address` and `POST /positions/:address/unwind`. A read-scoped
API key is enough for both.
Three behaviours worth knowing, because they are deliberate:
* **The amount offered is what can actually be withdrawn right now**, not the
full supplied balance. Aave caps a withdrawal against the user's health factor,
E-Mode thresholds, paused reserves and pool liquidity, so a position worth $100
may only permit $40 out. The modal offers the \$40 — the user is never shown a
figure the pool would reject.
* **The user's wallet signs; Rhinestone never holds a key on this path.** The
aTokens sit in the user's own EOA, so only they can authorise the withdraw. The
transaction we build is unsigned and inert until they sign it.
* **A position whose proceeds your config would refuse is not offered at all** —
under your `minDepositUsd`, or outside your source-token allowlist. Offering it
and failing afterwards would leave the user having irreversibly exited a
position for funds we then reject.
Positions are keyed by **market**, not chain: Ethereum alone hosts four Aave v3
markets with distinct pools and risk parameters, so the same token supplied to two
of them appears as two rows, each naming its market.
If a market can't be read, the positions that did load are still listed and the
screen says the list may be incomplete — an outage never silently reports a
smaller balance than the user holds.
### Open directly into Aave
As with Polymarket, `initialAssetMigration="aave"` skips the home screen:
```tsx theme={null}
```
# Backend
Source: https://docs.rhinestone.dev/deposits/widget/backend
The widget needs a proxy holding your Rhinestone API key. Write one, or deploy Rhinestone's.
The widget runs in the browser, so it can't hold your Rhinestone API key — the key authorizes writes against your project, from registering accounts to spending sponsorship. Every request the modal makes goes to a proxy **you** run, which attaches the key and forwards to the deposit processor.
All three modals take it as a required `backendUrl`. There is no default — point it at a proxy you run, on your own key.
Two ways to get one:
* **Deploy Rhinestone's.** Open source and configurable — see [deploy the Rhinestone proxy](#deploy-the-rhinestone-proxy).
* **Write your own.** A route table and a header — see [minimal proxy](#minimal-proxy).
If you have a deployed integration that never set `backendUrl`, it is running on
Rhinestone's API key rather than yours. Point it at your own proxy.
## Deploy the Rhinestone proxy
[`rhinestonewtf/deposit-widget-proxy`](https://github.com/rhinestonewtf/deposit-widget-proxy) is the proxy Rhinestone runs, packaged so you can deploy it as-is. It covers every route in the [table below](#required-routes), and adds [regional payment methods](#regional-payment-methods) — which a hand-written proxy can't do, since resolving the user's country needs the edge that actually sees them.
It needs one variable — your API key:
```bash theme={null}
git clone https://github.com/rhinestonewtf/deposit-widget-proxy
cd deposit-widget-proxy
docker build -t deposit-widget-proxy .
docker run -p 4000:4000 -e RHINESTONE_API_KEY=your-key deposit-widget-proxy
```
Point `backendUrl` at it and check `GET /health`. Everything else is optional and documented in the repository's [README](https://github.com/rhinestonewtf/deposit-widget-proxy#configuration):
| Variable | Purpose |
| --------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `RHINESTONE_API_KEY` | Attached as `x-api-key` upstream. The process exits if it's unset |
| `DEPOSIT_SERVICE_URL` | The upstream processor. Defaults to production |
| `TRUSTED_COUNTRY_HEADER`, `TRUSTED_PROXY_HOPS`, `TRUSTED_PROXY_CIDRS` | [Regional payment methods](#regional-payment-methods) |
## Minimal proxy
A proxy is an explicit route table plus a header. The allowlist is the security boundary — see the warning below.
```ts theme={null}
import { Hono } from "hono";
import { cors } from "hono/cors";
const UPSTREAM = "https://v1.orchestrator.rhinestone.dev/deposit-processor";
const MODAL_VERSION_HEADER = "x-deposit-modal-version";
// Every route the modal calls, and nothing else. `:param` segments are matched
// by Hono, so a request can only reach a shape you listed here.
const ROUTES: [method: "get" | "post", path: string][] = [
["post", "/register-managed"],
["post", "/quotes/preview"],
["get", "/check/:address"],
["get", "/portfolio/:address"],
["get", "/portfolio/solana/:address"],
["get", "/deposits"],
["get", "/liquidity"],
["get", "/prices"],
["get", "/setup"],
["get", "/qr/tokens"],
["post", "/polymarket/withdraw"],
// Only needed if you enable `assetMigrations` for a DeFi protocol (e.g. Aave).
// Both are served by a read-scoped key: `unwind` writes nothing and returns an
// unsigned transaction only the position holder's wallet can execute.
["get", "/positions/:address"],
["post", "/positions/:address/unwind"],
["post", "/onramp/swapped/widget-url"],
["post", "/onramp/swapped/connect-url"],
["get", "/onramp/swapped/connect-exchanges"],
["get", "/onramp/swapped/payment-methods"],
["get", "/onramp/swapped/status/:smartAccount"],
// Safe to forward: the user's signature in the body is the authorization, so
// your API key alone can't move funds through it. See below.
["post", "/deposits/recover"],
// Add ["post", "/safe/withdraw"] only if your onSendTransaction relays through
// it. Do NOT add /deposits/refund here — see below.
];
const app = new Hono();
app.use("*", cors());
for (const [method, path] of ROUTES) {
app[method](path, async (c) => {
const url = new URL(c.req.url);
// A fresh header set, never the incoming one: the browser must not be able
// to set `x-api-key` itself.
const headers: Record = {
"Content-Type": "application/json",
"x-api-key": process.env.RHINESTONE_API_KEY!,
};
const modalVersion = c.req.header(MODAL_VERSION_HEADER);
if (modalVersion) headers[MODAL_VERSION_HEADER] = modalVersion;
const res = await fetch(`${UPSTREAM}${url.pathname}${url.search}`, {
method: c.req.method,
headers,
body: method === "get" ? undefined : await c.req.text(),
});
return new Response(res.body, { status: res.status });
});
}
export default app;
```
Do **not** replace that loop with a wildcard passthrough (`app.all("/*", …)`). The
proxy attaches your API key to whatever reaches it, so a wildcard hands the browser
every write on the upstream — including `POST /setup`, which rotates your webhook
secret and sponsorship config. The route list is what stops that.
### Recovery can be forwarded; refunds cannot
These two look alike and differ in exactly one way: where the authorization comes
from.
`POST /deposits/recover` carries a signature from the deposit's `recipient`, covering
which deposit and which destination. The service verifies it before moving anything,
so your API key on its own achieves nothing here — which is what makes it safe to
forward like any other route. See
[claim modal](/deposits/widget/claim-modal#how-authorization-works).
`POST /deposits/refund` carries no such proof. Every route in that loop passes the
browser's body through with your API key attached, so forwarding this one would let
anyone return any of your recoverable deposits to an address they chose. A proxy
authenticates nobody, so it cannot be the thing that decides.
Most apps need only the recover route. If some of your recipients genuinely cannot
sign, authorize a refund in your own backend with
[`createRefundHandler`](/deposits/widget/claim-modal#when-the-user-cant-sign), which
checks the deposit belongs to the caller before spending the key, and call the
processor directly.
## Required routes
Missing a route doesn't degrade the flow — the request 404s and that part of the modal stops working.
| Method | Route | Used for |
| ------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/register-managed` | Registering the deposit account. **Required** |
| `POST` | `/quotes/preview` | Indicative fee/time quote on the review screen |
| `GET` | `/check/:address` | Registration + target lookup |
| `GET` | `/portfolio/:address` | EVM balances |
| `GET` | `/portfolio/solana/:address` | Solana balances |
| `GET` | `/deposits` | Deposit status + history |
| `GET` | `/liquidity` | Route liquidity check |
| `GET` | `/prices` | USD pricing |
| `GET` | `/setup` | Your client config (source-token allowlist, minimums) |
| `GET` | `/qr/tokens` | Suggested source tokens for the QR flow, filtered to what your config accepts |
| `GET` | `/onramp/swapped/payment-methods` | Fiat methods for the user's region. See [regional payment methods](#regional-payment-methods) |
| `POST` | `/deposits/recover` | [Self-service recovery](/deposits/widget/claim-modal), authorized by the user's signature |
| `GET` | `/positions/:address` | DeFi positions the user can migrate. Only for [asset migrations](/deposits/widget/asset-migrations) |
| `POST` | `/positions/:address/unwind` | Builds the unsigned exit transaction for one position. Only for asset migrations |
| `POST` | `/polymarket/withdraw` | Polymarket withdrawals |
| `POST` | `/onramp/swapped/widget-url` | Fiat on-ramp |
| `POST` | `/onramp/swapped/connect-url` | Exchange connect |
| `GET` | `/onramp/swapped/connect-exchanges` | Exchange list |
| `GET` | `/onramp/swapped/status/:smartAccount` | On-ramp order status |
| `POST` | `/safe/withdraw` | Relaying a signed Safe transfer with sponsored gas. **Not called by the modal** — proxy it only if your own [`onSendTransaction`](/deposits/widget/withdraw-modal#executing-the-transfer) relays through it |
Proxy `GET /setup` only, never `POST /setup`. The POST is an admin write — it rotates
your webhook secret and sponsorship config — and must not be reachable from a browser.
## CORS and the version header
All three modals send `x-deposit-modal-version` on every request. Browsers reject a request carrying a header the server didn't allow on the preflight, so an explicit allow-list must include it:
```ts theme={null}
allowHeaders: ["Content-Type", "x-deposit-modal-version"]
```
Bare `cors()` in Hono is fine — with no `allowHeaders` it reflects whatever the preflight asks for.
This bites on upgrade, not on first deploy. A modal version that starts sending a new
header fails the **whole request** at preflight against a proxy with a fixed
allow-list, not just the header. Deploy proxy changes before the modal that needs them.
Forwarding it upstream is optional, but it lets a support request be matched to the exact build you're running. To read the value in your own app, for a bug report:
```ts theme={null}
import { MODAL_VERSION } from "@rhinestone/deposit-modal/constants";
```
## Regional payment methods
Fiat on-ramp methods vary by country, and your proxy is the only component that can see the end user: the processor sits behind it and only ever observes your proxy's address. So `GET /onramp/swapped/payment-methods` returns the generic method set unless your proxy names the user's region.
You do **not** need a GeoIP database — the processor owns the lookup. The proxy only names what it observed, which takes one of two variables:
* `TRUSTED_COUNTRY_HEADER` — you're behind a CDN that already resolves country, so forward its header (`cf-ipcountry`, `x-vercel-ip-country`, `cloudfront-viewer-country`).
* `TRUSTED_PROXY_HOPS` — nothing resolves it for you, so relay the client IP and let the processor resolve it.
Either one also requires `TRUSTED_PROXY_CIDRS`, an allowlist of the peers permitted to set forwarding headers. Without it any browser could send `x-forwarded-for` or `cf-ipcountry` and choose its own region, so [Rhinestone's proxy](#deploy-the-rhinestone-proxy) refuses to start when you set one without the other. See its [README](https://github.com/rhinestonewtf/deposit-widget-proxy#regional-payment-methods) for the details, including why hops are counted from the right.
Every path here fails closed. A wrong setting costs you localization, not correctness: you get the generic method set rather than a region that isn't the user's. A hand-written proxy that relays nothing behaves exactly as it does today.
## Webhooks
The widget's [lifecycle callbacks](/deposits/widget/status-tracking) fire only while the modal is open, so a user who closes it mid-bridge leaves your app unaware the deposit completed. Anything that must happen regardless — crediting a balance, sending a receipt — belongs on a [webhook](/deposits/api/status-tracking) handler. Configure it once with `POST /setup`.
# Claim modal
Source: https://docs.rhinestone.dev/deposits/widget/claim-modal
Let a user recover a failed or rejected deposit by pasting its transaction hash, authorized by their own signature.
The `ClaimModal` returns a failed or rejected EVM deposit to the user, on the chain it came from. The user pastes the deposit's transaction hash, the modal looks it up, and the user signs to authorize where the funds go.
There is no source chain to pick — the hash is looked up across every chain, so a deposit that landed on a chain your deposit flow doesn't offer is still claimable.
On the `./claim` subpath. Applies to deposits in `failed` or `rejected` status. See
[troubleshooting](/deposits/troubleshooting) for the operator-side equivalent in the
dashboard.
## How authorization works
The deposit's `recipient` — the in-app wallet the funds were headed to — signs an EIP-712 struct naming the deposit and the destination. The service verifies that signature against the same `recipient` before moving anything.
That signature **is** the authorization, so this needs no backend of your own. The request goes from the browser through your [proxy](/deposits/widget/backend), which contributes only your project API key. That key cannot move funds through this route without a signature, so there is nothing for a page to borrow.
```tsx theme={null}
import { ClaimModal } from "@rhinestone/deposit-modal/claim";
import "@rhinestone/deposit-modal/styles.css";
setIsOpen(false)}
backendUrl={process.env.NEXT_PUBLIC_DEPOSIT_PROXY_URL}
signRecovery={({ typedData }) => wallet.signTypedData(typedData)}
/>
```
The modal never runs a connect step — it has no wallet UI and asks for no provider. It calls `signRecovery` and expects a signature back. For an embedded wallet that is headless, so the user sees a single confirmation at most.
The signer is the deposit's `recipient`, never the deposit account. Deposit accounts
are [service-managed](/deposits/widget/deposit-modal#account-setup) and hold no user
key, so nothing can sign for them.
Your proxy must forward `POST /deposits/recover` alongside the `GET /deposits` used for the lookup. Both are in the [reference proxy](/deposits/widget/backend).
## Signing
Return whatever the `recipient` address's own verifier accepts. `signRecovery` receives the exact typed data to sign, so you never construct it yourself.
| `recipient` is | Return |
| ----------------------------------------- | ------------------------------------------------------- |
| An embedded EOA (Privy, Turnkey, Dynamic) | `signTypedData`, the raw 65-byte result |
| A deployed smart account | A signature valid under its ERC-1271 `isValidSignature` |
| A smart account not yet deployed | The same, wrapped per ERC-6492 |
```tsx theme={null}
// Embedded EOA — the common case.
signRecovery={({ typedData }) => wallet.signTypedData(typedData)}
```
For a smart account, a raw owner signature is usually **not** enough. A Safe expects
its own message wrapping, so go through your account SDK rather than signing with the
owner key directly.
An undeployed account can still be verified, because an ERC-6492 wrapper carries the account's factory and factory data. That makes the check work without the account existing on chain yet — which matters, since a deposit can fail before the user's account is ever deployed.
```tsx theme={null}
import { SignatureErc6492 } from "ox/erc6492";
signRecovery={async ({ typedData }) => {
const signature = await account.signTypedData(typedData);
if (await account.isDeployed()) return signature;
return SignatureErc6492.wrap({
to: factory, // the factory that will deploy the account
data: factoryData, // the calldata that deploys it
signature,
});
}}
```
`ox` is already a dependency of viem, so this adds nothing to your install.
### What the user signs
```ts theme={null}
domain: { name: "Rhinestone Deposit Recovery", version: "1" }
primaryType: "RecoverDeposit"
types: { RecoverDeposit: [
{ name: "depositId", type: "uint256" },
{ name: "destination", type: "address" },
]}
```
Two fields, both meaningful to the person signing: which deposit, and where the money goes.
**The destination is inside the signature**, which is the property worth understanding. The service cannot pay anywhere other than the address the user signed for, so a compromised page cannot redirect the funds — and your success screen can state where they went without trusting a response to echo it back.
The signature is verified on the deposit's **target** chain, where the recipient wallet lives, not the source chain the funds sit on. For a smart account with different owners per chain, its target-chain owners are the ones who can authorize.
A signature stays valid until the deposit is claimed — there is no expiry. Replay is
already closed, because claiming moves the deposit out of `failed`/`rejected` and a
second attempt is rejected. But do not persist a signature: treat it as
single-use and discard it once the request returns.
## Handling failures
The response carries a machine-readable `code`. Switch on that rather than the HTTP status — the code is the contract, and the correct advice differs between codes that share a status.
| `code` | Means | Retry? |
| -------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------ |
| `DEPOSIT_NOT_RECOVERABLE` | Not in `failed`/`rejected` — often already claimed | No |
| `RECOVERY_UNSUPPORTED` | The deposit has no recipient to verify against, or a non-EVM chain on either side | No |
| `SIGNATURE_INVALID` | The recipient's verifier rejected these bytes | No — signing again the same way won't help |
| `VERIFICATION_UNAVAILABLE` | The on-chain check couldn't run | Yes, and without re-signing |
| `REFUND_RECONCILIATION_REQUIRED` | Funds may be in flight; needs an operator | **No** |
| `REFUND_FAILED` | The transfer didn't complete | Yes |
The modal maps these to copy and to whether it offers a retry, so you get this for free. Handle them yourself only if you drive your own UI.
Never retry `REFUND_RECONCILIATION_REQUIRED`. The funds may already be moving, and
the deposit needs an operator either way — [contact support](/resources/support) with
the deposit.
## When the user can't sign
Some recipients have no key to sign with: a wallet the user has lost, or a recipient your app controls rather than the user. For those, `@rhinestone/deposit-modal/server` exports `createRefundHandler`, which authorizes on your say-so instead of a signature — you decide who the caller is, and it verifies the deposit belongs to them before spending your API key.
```ts theme={null}
// app/api/refund/route.ts
import { createRefundHandler } from "@rhinestone/deposit-modal/server";
export const POST = createRefundHandler({
apiKey: process.env.RHINESTONE_API_KEY!,
async authorize(request) {
const session = await getSession(request);
return session ? { depositRecipient: session.depositRecipient } : null;
},
});
```
`authorize` returns the deposit `recipient` the caller owns, or `null` to reject with 401. Add `refundDestination` to pin where the money goes rather than letting the request choose. `depositRecipient` never decides the destination — the handler lists deposits settling to it and rejects a `txHash` that isn't among them.
It returns a `(Request) => Promise`, so it mounts in any fetch-based runtime: Next.js route handlers, Hono, `Bun.serve`, Cloudflare Workers.
Import from `@rhinestone/deposit-modal/server` only — it holds your API key and must
never reach the browser. `ClaimModal` does not call this route; drive your own UI
against it.
Never refund to an exchange deposit address. Exchanges don't credit arbitrary
incoming transfers, so the funds **may be lost**. This is why the modal never defaults
the destination to the deposit's sender.
## Props reference
### Required
| Prop | Type | Description |
| -------------- | --------------------------- | ------------------------------------------------------------------------------------ |
| `isOpen` | `boolean` | Controls modal visibility |
| `onClose` | `() => void` | Called when the user closes the modal |
| `signRecovery` | `(payload) => Promise` | Signs the authorization. Throwing is treated as the user declining, and is retryable |
| `backendUrl` | `string` | Your [proxy](/deposits/widget/backend), which holds your API key |
`signRecovery` receives:
| Field | Type | Description |
| ------------- | --------------------- | -------------------------------------------------------------------- |
| `typedData` | `TypedDataDefinition` | Sign this exactly — pass it to `signTypedData` or your account SDK |
| `signer` | `Address` | The address whose signature is verified: the deposit's `recipient` |
| `depositId` | `string` | The deposit being recovered, for your own logging or confirmation UI |
| `destination` | `Address` | Where the funds will go. Covered by the signature |
### Prefills
| Prop | Type | Default | Description |
| -------------------------- | --------- | ------- | ------------------------------------------------------------------ |
| `defaultTxHash` | `string` | — | Prefills the lookup field |
| `defaultRefundDestination` | `Address` | — | Prefills the destination as an editable seed. Left empty otherwise |
### Backend
| Prop | Type | Default | Description |
| --------- | ----------- | -------------- | --------------------------------------------- |
| `rpcUrls` | `RpcUrlMap` | Chain defaults | Per-chain RPC overrides keyed by EVM chain id |
### Display
| Prop | Type | Default | Description |
| --------------------- | ---------------------- | ------- | ------------------------------------------------------------------- |
| `inline` | `boolean` | `false` | Render without modal overlay |
| `closeOnOverlayClick` | `boolean` | `true` | Close modal on backdrop click |
| `className` | `string` | — | CSS class for the modal container |
| `theme` | `DepositModalTheme` | — | [Theme configuration](/deposits/widget/customization#theme) |
| `uiConfig` | `DepositModalUIConfig` | — | [UI configuration](/deposits/widget/customization#ui-configuration) |
| `debug` | `boolean` | `false` | Enable debug logging |
### Callbacks
| Prop | Type | Description |
| ------------- | -------------------------------------- | ------------------------------------------------------------- |
| `onReady` | `() => void` | Modal initialized |
| `onLifecycle` | `(event: ClaimLifecycleEvent) => void` | Claim lifecycle — switch on `event.type` |
| `onError` | `(data: ErrorEventData) => void` | Error at any stage |
| `onEvent` | `(event: ClaimAnalyticsEvent) => void` | [Analytics event](/deposits/widget/status-tracking#analytics) |
## Lifecycle events
| `event.type` | Payload | Fired when |
| ------------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `lookup` | `txHash`, `matches` | The pasted hash was looked up. `matches` is how many deposits it resolved to |
| `refund_requested` | `account`, `destination`, `chain` | The signed request was submitted |
| `complete` | `txHash`, `account`, `destination`, `chain`, `token`, `amount` | The recovery succeeded |
| `failed` | `status`, `error` | It failed. `status` is the HTTP status, or `0` if the service was unreachable |
```tsx theme={null}
{
if (event.type === "complete") {
trackRefund(event.txHash, event.amount);
}
}}
/>
```
`ClaimLifecycleEvent` shares no variants with the deposit or withdraw unions, so don't reuse a handler across them.
# Customization
Source: https://docs.rhinestone.dev/deposits/widget/customization
Theme, brand, and configure the deposit widget to match your app.
The deposit and withdraw modals accept `theme` and `uiConfig` props to control appearance and behavior.
## Theme
Pass a `theme` object to control the modal's visual style.
```tsx theme={null}
```
### Properties
| Property | Type | Default | Description |
| ----------------- | -------------------------------------------------- | --------- | -------------------------------- |
| `mode` | `"light"` \| `"dark"` | `"light"` | Color mode |
| `radius` | `"none"` \| `"sm"` \| `"md"` \| `"lg"` \| `"full"` | `"md"` | Border radius preset |
| `fontColor` | `string` | — | Primary text color |
| `iconColor` | `string` | — | Icon color |
| `ctaColor` | `string` | — | Call-to-action button background |
| `ctaHoverColor` | `string` | — | CTA button hover background |
| `borderColor` | `string` | — | Border color |
| `backgroundColor` | `string` | — | Modal background color |
All color values accept any CSS color string (hex, rgb, hsl, etc.). Omitted properties use the design system defaults for the selected `mode`.
## UI configuration
Toggle UI elements, set deposit constraints, and control fee display via `uiConfig`.
```tsx theme={null}
```
### Properties
| Property | Type | Default | Description |
| ------------------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `showBackButton` | `boolean` | `true` | Show the back button in the header |
| `showHistoryButton` | `boolean` | `false` | Show the deposit history button in the header |
| `minDepositUsd` | `number` | — | Minimum deposit amount in USD |
| `maxDepositUsd` | `number` | — | Maximum deposit amount in USD |
| `feeSponsored` | `boolean` | `false` | When `true`, the network/protocol fee renders struck-through on the review, processing, and result screens, with a tooltip explaining the sponsorship |
| `feeTooltip` | `string` | — | Custom copy for the fee info tooltip. Defaults to a generic message based on `feeSponsored`. |
# Deposit modal
Source: https://docs.rhinestone.dev/deposits/widget/deposit-modal
Configure the deposit modal for different wallet connection modes and deposit flows.
The `DepositModal` component handles the full deposit flow: funding method, source chain and token selection, amount input, and cross-chain bridging.
## Where the funds go
`recipient` (required) is the address that receives the bridged funds on the target chain.
The deposit account is [service-managed](#account-setup) and derived from `(recipient, targetChain, targetToken)`, so it doesn't depend on which wallet the user pays from — or on a wallet existing at all. Changing the target changes the deposit address.
## Connecting a wallet
Three options. The wallet is optional in all of them.
### The modal connects one
The user connects their own wallet via Reown (WalletConnect). The modal manages the connection UI internally. Use this when your app has no wallet infrastructure of its own.
```tsx theme={null}
import { DepositModal } from "@rhinestone/deposit-modal";
import "@rhinestone/deposit-modal/styles.css";
setIsOpen(false)}
targetChain={8453}
targetToken="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
recipient="0xYOUR_RECIPIENT_ADDRESS"
backendUrl={process.env.NEXT_PUBLIC_DEPOSIT_PROXY_URL}
reownAppId="YOUR_REOWN_PROJECT_ID"
onLifecycle={(event) => event.type === "complete" && console.log(event)}
/>
```
### You supply one
Your app already has a wallet connected (via Privy, Dynamic, Turnkey, wagmi, or anything else). Pass its viem `walletClient` and the modal reuses that session instead of opening its own connect step.
The modal reads the address off `walletClient.account`, so it cannot disagree with what your app thinks the user is connected as.
```tsx theme={null}
import { DepositModal } from "@rhinestone/deposit-modal";
import "@rhinestone/deposit-modal/styles.css";
setIsOpen(false)}
targetChain={8453}
targetToken="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
recipient="0xYOUR_RECIPIENT_ADDRESS"
backendUrl={process.env.NEXT_PUBLIC_DEPOSIT_PROXY_URL}
walletClient={walletClient}
publicClient={publicClient}
onLifecycle={(event) => event.type === "complete" && console.log(event)}
/>
```
Pass `onRequestConnect` as well if your app needs to run a login flow when the user picks the wallet row before one is connected.
To disconnect a wallet the modal connected, call the exported `disconnectWallet()`. It no-ops with a warning if `@reown/appkit` isn't installed.
`walletClient={undefined}` is not the same as omitting the prop. Passing it while your
wallet connects tells the modal one is coming, so it waits instead of deciding there
is none.
### No wallet at all
QR transfer, the fiat on-ramp and exchange connect need no wallet. Supply neither `walletClient` nor `reownAppId` and the modal opens straight into whichever [funding methods](#funding-methods) you enabled.
```tsx theme={null}
import { DepositModal } from "@rhinestone/deposit-modal";
import "@rhinestone/deposit-modal/styles.css";
setIsOpen(false)}
targetChain={8453}
targetToken="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
recipient="0xYOUR_RECIPIENT_ADDRESS"
backendUrl={process.env.NEXT_PUBLIC_DEPOSIT_PROXY_URL}
enableQrTransfer
onLifecycle={(event) => event.type === "complete" && console.log(event)}
/>
```
## Funding methods
Each method is a row on the modal's home screen. When exactly one is enabled there is nothing to choose, and the modal opens directly into it.
| Prop | Type | Default | Description |
| ----------------------- | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `enableWallet` | `boolean` | `true` | Offer the connected wallet as a source. Turn off for a flow with no wallet even when a `walletClient` or `reownAppId` is supplied |
| `enableQrTransfer` | `boolean` | `true` | Offer the "Transfer crypto" row — a deposit address and QR code the user sends to from anywhere |
| `enableFiatOnramp` | `boolean` | `false` | Offer card / bank / Apple Pay payment via Swapped's embedded iframe |
| `fiatMethods` | `FiatMethodsConfig` | regional | Restrict which Swapped payment groups the on-ramp offers. Omitting it uses [regional personalization](#regional-payment-methods) |
| `enableExchangeConnect` | `boolean` | `false` | Offer "Connect exchange" — the user picks their CEX inside Swapped's iframe and withdraws from it |
| `assetMigrations` | `AssetMigrationsConfig` | none | Offer [migrating balances or DeFi positions](/deposits/widget/asset-migrations) the user already holds in a supported third-party app |
`enableFiatOnramp` and `enableExchangeConnect` require Swapped keys on your backend.
### Regional payment methods
Leave `fiatMethods` unset and the modal personalizes the Cash options for the user's region. It renders the method list the backend resolves — provider labels, icons, limits, and one optional **Popular** badge — and hands the exact method the user picked to the signed Swapped widget URL.
This is the recommended default: which methods exist varies by country, and a hard-coded subset can only go stale.
Personalization never blocks the flow. After 500 ms the standard card, bank transfer and Apple Pay rows render; a late regional result only replaces a picker the user hasn't touched; and any failure keeps the complete default set. Resolving the country happens at your proxy — see [regional payment methods](/deposits/widget/backend#regional-payment-methods) for what it needs.
A known country with **no** available methods is authoritative merchant data, and the
modal hides the Cash option entirely. That is different from an unresolved country,
which renders the defaults.
### Restricting fiat payment methods
`fiatMethods` is a boolean map keyed by Swapped `payment_group`. Passing it pins the Cash rows to that exact subset and opts this modal instance out of regional personalization.
```tsx theme={null}
```
Valid keys are `creditcard`, `bank-transfer`, and `apple-pay`. Enabling one does not advertise the others.
An empty or all-false `fiatMethods` offers **no** payment methods, not all of them.
To offer everything, omit the prop — which also turns personalization back on.
## Transfer configuration
Control the deposit destination and optionally pre-fill source parameters.
| Prop | Type | Required | Description |
| ------------------ | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `targetChain` | `Chain \| number \| "solana"` | Yes | Destination chain (viem `Chain` object, chain ID, or `"solana"`) |
| `targetToken` | `Address \| string` | Yes | Token address on the destination chain (base58 mint for Solana) |
| `recipient` | `Address \| string` | Yes | Where funds are delivered on the target chain (base58 address for Solana) |
| `defaultAmount` | `string` | No | Pre-filled deposit amount. A USD number string (e.g. `"25"`) or the sentinel `"max"` to fill the full available balance. |
| `sourceChain` | `Chain \| number` | No | Pre-selected source chain |
| `sourceToken` | `Address` | No | Pre-selected source token |
| `outputTokenRules` | `OutputTokenRule[]` | No | Route the deposit to a different final token based on what the user deposited |
| `rejectUnmapped` | `boolean` | No | Reject deposits that don't match any routing rule instead of falling back to `targetToken` |
| `appBalanceUsd` | `number` | No | The user's current in-app balance (USD). When set, the amount screen shows a "Balance after deposit" row (`appBalanceUsd + amount`). |
For supported chains and tokens, see [supported chains](/deposits/overview#supported-chains-and-tokens).
### HyperCore destinations
HyperCore is `targetChain: 1337` (exported as `HYPERCORE_CHAIN_ID`) and accepts USDC only. The `recipient` can be an EOA or a smart account: deposits are credited through the MulticallHandler's `depositFor(recipient)`, which funds any address's HyperCore account identically and never executes on the recipient.
Earlier versions pre-screened the recipient's bytecode and blocked a contract with
an `onError` code of `HYPERCORE_RECIPIENT_NOT_EOA`. That rule never matched the
orchestrator and is gone as of v0.9.0 — the code is no longer emitted.
HyperCore is also available as a deposit **source** in the QR / transfer flow, using the account's own EVM address as the deposit address; a native Hyperliquid L1 spot transfer lands there.
### Restricting which sources you accept
Which chains and tokens you accept is your project's **deposit whitelist**, set via `POST /setup` and enforced by the processor when a deposit arrives. Configure it there rather than in the modal. To offer a restricted subset, use an API key whose whitelist matches.
Filtering the pickers client-side instead offers the user a source the processor then
rejects, as soon as the two lists drift apart. The whitelist is the only place the
restriction is enforced.
## Account setup
The modal uses **service-managed accounts**: the deposit account is owned by
Rhinestone and settles to your `recipient`, so there is no session key to configure
and the user is never asked to sign during setup.
| Prop | Type | Default | Description |
| --------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `forceRegister` | `boolean` | `false` | Re-register even if the account already exists (bypasses the local cache; the call itself is idempotent) |
## Destination token routing
Deliver a different final token depending on what the user deposits. Pass `outputTokenRules` to map source deposits — matched by chain, token address, or symbol — to the output token delivered on the target chain. Deposits that don't match any rule fall back to `targetToken`, or are rejected when `rejectUnmapped` is `true`.
```tsx theme={null}
```
When several rules match the same deposit, the most specific one wins: `chain + token` outranks `chain + symbol`, which outranks `token`, then `symbol`, then `chain` alone. See [token routing](/deposits/api/account-registration#optional-token-routing) for the full rule semantics, priority order, and additional examples.
## Migrate assets from other apps
Fund a deposit from balances the user already holds in a supported third-party app,
with no manual transfer first. Opt in per provider via `assetMigrations`.
See [asset migrations](/deposits/widget/asset-migrations) for the provider list,
Polymarket specifics, and the headless account lookup.
## Package entry points
Everything is on the root entry; the subpaths exist so you only bundle what you use.
| Import | Contains |
| -------------------------------------- | --------------------------------------------------------------------------------------------- |
| `@rhinestone/deposit-modal` | All three modals, types, chain and token helpers |
| `@rhinestone/deposit-modal/deposit` | `DepositModal` and its types |
| `@rhinestone/deposit-modal/withdraw` | `WithdrawModal` and its types |
| `@rhinestone/deposit-modal/claim` | [`ClaimModal`](/deposits/widget/claim-modal) and its types |
| `@rhinestone/deposit-modal/server` | `createRefundHandler`. **Server-only** — holds your API key |
| `@rhinestone/deposit-modal/constants` | `MODAL_VERSION`, chain registry, token and explorer helpers |
| `@rhinestone/deposit-modal/polymarket` | [`getPolymarketAccount`](/deposits/widget/asset-migrations#headless-account-lookup), headless |
| `@rhinestone/deposit-modal/styles.css` | Stylesheet, required |
## Display modes
By default, the component renders as a centered modal overlay with a backdrop. Set `inline={true}` to render it without the overlay, fitting into your page layout.
```tsx theme={null}
{}}
inline={true}
// ...other props
/>
```
Set `closeOnOverlayClick={false}` to prevent the modal from closing when the user clicks outside it.
## Props reference
### Required
| Prop | Type | Description |
| ------------- | ----------------------------- | ------------------------------------------------------------------------ |
| `isOpen` | `boolean` | Controls modal visibility |
| `onClose` | `() => void` | Called when the user closes the modal |
| `targetChain` | `Chain \| number \| "solana"` | Destination chain (viem `Chain` object, chain ID, or `"solana"`) |
| `targetToken` | `Address \| string` | Token address on the destination chain |
| `recipient` | `Address \| string` | Where funds are delivered on the target chain |
| `backendUrl` | `string` | Your [backend proxy](/deposits/widget/backend), which holds your API key |
### Wallet
| Prop | Type | Default | Description |
| ------------------ | ---------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `reownAppId` | `string` | — | Reown project ID. Lets the modal connect a wallet itself. |
| `walletClient` | `WalletClient \| null` | — | A wallet your app already has connected. The modal reuses the session and reads the address off `.account` |
| `publicClient` | `PublicClient \| null` | — | Read client paired with `walletClient`. Defaults to the modal's own |
| `enableWallet` | `boolean` | `true` | Offer the connected wallet as a funding source at all |
| `onRequestConnect` | `() => void` | — | Called when the modal needs the user to connect a wallet |
### Transfer
| Prop | Type | Default | Description |
| ------------------ | ------------------- | ------- | --------------------------------------------------------------------- |
| `defaultAmount` | `string` | — | Pre-filled deposit amount. USD number string or the sentinel `"max"`. |
| `sourceChain` | `Chain \| number` | — | Pre-selected source chain |
| `sourceToken` | `Address` | — | Pre-selected source token |
| `appBalanceUsd` | `number` | — | In-app USD balance; enables the "Balance after deposit" row |
| `outputTokenRules` | `OutputTokenRule[]` | — | Per-deposit output token routing rules |
| `rejectUnmapped` | `boolean` | `false` | Reject deposits that don't match any `outputTokenRules` entry |
### Funding
| Prop | Type | Default | Description |
| ----------------------- | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enableWallet` | `boolean` | `true` | [Offer the connected wallet](#funding-methods) as a source |
| `enableQrTransfer` | `boolean` | `true` | Offer the "Transfer crypto" deposit-address / QR row |
| `enableFiatOnramp` | `boolean` | `false` | Offer fiat payment via Swapped's iframe. Requires backend Swapped keys |
| `fiatMethods` | `FiatMethodsConfig` | regional | [Restrict payment groups](#restricting-fiat-payment-methods) — `{ creditcard?, "bank-transfer"?, "apple-pay"? }`. Empty means none; omitted means [regional](#regional-payment-methods) |
| `enableExchangeConnect` | `boolean` | `false` | Offer "Connect exchange". Requires backend Swapped keys |
| `assetMigrations` | `AssetMigrationsConfig` | — | [Migrate balances or positions](/deposits/widget/asset-migrations) from third-party apps (e.g. `{ polymarket: true, aave: true }`) |
| `initialAssetMigration` | `keyof AssetMigrationsConfig` | — | Open the modal pre-routed into a migration provider (e.g. `"aave"`), skipping the home screen. Must name an enabled `assetMigrations` key. |
### Account
| Prop | Type | Default | Description |
| --------------- | --------- | ------- | ---------------------------------------------- |
| `forceRegister` | `boolean` | `false` | Re-register even if the account already exists |
### Backend
| Prop | Type | Default | Description |
| --------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `rpcUrls` | `RpcUrlMap` | — | Per-chain RPC overrides keyed by EVM chain id or the literal `"solana"` key (e.g. `{ 8453: "https://…", solana: "https://…" }`). Applied to EVM public clients, the connected wallet, HyperEVM, and the Solana connection; chains left unset use their default RPC |
### Display
| Prop | Type | Default | Description |
| --------------------- | ---------------------- | ------- | ------------------------------------------------------------------- |
| `inline` | `boolean` | `false` | Render without modal overlay |
| `closeOnOverlayClick` | `boolean` | `true` | Close modal on backdrop click |
| `className` | `string` | — | CSS class for the modal container |
| `theme` | `DepositModalTheme` | — | [Theme configuration](/deposits/widget/customization#theme) |
| `uiConfig` | `DepositModalUIConfig` | — | [UI configuration](/deposits/widget/customization#ui-configuration) |
| `debug` | `boolean` | `false` | Enable debug logging |
### Callbacks
| Prop | Type | Description |
| ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onReady` | `() => void` | Modal initialized |
| `onLifecycle` | `(event: DepositLifecycleEvent) => void` | [Lifecycle event](/deposits/widget/status-tracking#onlifecycle) — switch on `event.type` (`connected`, `submitted`, `complete`, `failed`, `balance-changed`, `smart-account-changed`) |
| `onError` | `(data: ErrorEventData) => void` | Error at any stage |
| `onEvent` | `(event: DepositAnalyticsEvent) => void` | [Analytics event](/deposits/widget/status-tracking#analytics) |
See [status tracking](/deposits/widget/status-tracking) for lifecycle event payloads.
## Content security policy
The modal loads chain, token and exchange logos from Rhinestone's asset CDN. **If your app sets an explicit `img-src` policy, it must allow the CDN:**
```
img-src 'self' data: https://s3.rhinestone.dev;
```
A blocked image fails **silently** — the icon renders blank with no console error
naming the policy, so this is easy to misread as a modal bug. Apps with no explicit
`img-src` (or `img-src *`) need no change.
If you enable the [fiat on-ramp or exchange connect](#funding-methods), the modal embeds Swapped's widget in an iframe, which needs `frame-src`:
```
frame-src https://widget.swapped.com https://sandbox.swapped.com https://connect.swapped.com;
```
# Migration guide
Source: https://docs.rhinestone.dev/deposits/widget/migration
Upgrade @rhinestone/deposit-modal to v0.9.0 — managed accounts, the withdraw callback, and renamed props. Plus v0.1.x / v0.2.x to v0.3.0.
## v0.8.x → v0.9.0
v0.9.0 moves both modals onto service-managed accounts, hands the withdrawal
transfer to your app, and renames or removes props that no longer described what
they did. The account and withdraw changes need code; the
[prop renames](#renamed-and-removed-props) are mechanical.
### Deploy your proxy first
Four of these changes are proxy-side and take effect the moment the new modal
loads in a browser. None of them degrades — the request 404s, or the browser
blocks it at preflight.
| Change | Why it can't wait |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Forward `POST /register-managed` | Replaces `/setup-account` + `/register`. Without it registration 404s: no deposit address and no QR code |
| Forward `GET /qr/tokens` | Replaces `GET /tokens`, with **no fallback**. Without it the QR picker falls back to the modal's built-in token set, which can offer a token your deposit whitelist rejects on arrival |
| Allow `x-deposit-modal-version` in CORS | Every request now carries it. A proxy with an explicit `allowHeaders` that omits it fails the **whole request** at preflight, not just the header |
| Forward `POST /deposits/recover` | Only if you adopt the new [claim modal](/deposits/widget/claim-modal) |
See [required routes](/deposits/widget/backend#required-routes) for the full
table. `/setup-account` and `/register` are no longer called and can be dropped
once no older modal version is in use.
Bare Hono `cors()` is safe for the version header — with no `allowHeaders` it
reflects whatever the preflight asks for. An explicit allow-list is what breaks,
and it breaks on upgrade rather than on first deploy.
**Both modals** — the `signerAddress` and `sessionChainIds` props are gone,
along with the `DEFAULT_SIGNER_ADDRESS`, `EnableSessionDetails`, and
`AccountInitData` exports. Registration now goes through
`POST /register-managed`, which a self-hosted proxy **must** forward before you
ship — see [required routes](/deposits/widget/backend#required-routes).
There is no session key and no signature prompt during setup.
**``** no longer moves funds. It previously built and submitted a
Safe `execTransaction`, which only worked for apps whose funds sat in a Safe. It
now asks your app to perform one transfer:
```diff theme={null}
({
- signature: await signer.signTypedData(request.typedData),
- })}
+ onSendTransaction={async ({ chainId, token, amount, to }) => ({
+ txHash: await myWallet.sendTransfer({ chainId, token, amount, to }),
+ })}
/>
```
Send to `to` exactly, and return the **on-chain transaction hash** — not a
userOp hash. See [executing the
transfer](/deposits/widget/withdraw-modal#executing-the-transfer) for both rules
and a Safe-backed example, including how to keep gas sponsored.
Also on ``: `onRequestConnect` is removed (the modal needs no
wallet, so there is no connect step — it opens on the withdraw form), the
`SafeTransactionRequest` export is replaced by `WithdrawTransferRequest`, and
the `"submitted"` lifecycle event renames `safeAddress` to `accountAddress`.
`POST /safe/withdraw` still exists — the modal simply stopped calling it.
### Renamed and removed props
Renames, plus the removal of config the server already owns. Nothing here changes
what the modal can do.
| Before | After |
| -------------------------- | ----------------------- |
| `dappWalletClient` | `walletClient` |
| `dappPublicClient` | `publicClient` |
| `dappImports` | `assetMigrations` |
| `initialDappImport` | `initialAssetMigration` |
| `fiatOnrampMethods` | `fiatMethods` |
| `DappImportsConfig` (type) | `AssetMigrationsConfig` |
```diff theme={null}
```
**`dappAddress` is removed with no replacement.** The modal reads the address off
`walletClient.account`, which nothing previously validated it against — so the modal
could read balances for one address while the user was connected as another.
**`allowedRoutes` and the `RouteConfig` type are removed** from both modals. They
filtered the pickers client-side with nothing enforcing it, so a list that drifted
from your deposit whitelist offered the user a source the processor then rejected.
Set the whitelist via `POST /setup`; to offer a restricted subset, use an API key
whose whitelist matches.
**`enableSolana` is removed** for the same reason — Solana sources follow the deposit
whitelist.
**`uiConfig.checkLiquidity` is removed.** It cost an orchestrator round trip per
continue to compute a warning the review screen never rendered. The cap is still
checked and shown on the QR / transfer screen.
**`rhinestoneApiKey` is removed** from both modals. It was never read — the key
belongs on your [backend proxy](/deposits/widget/backend), which attaches it upstream.
Delete it; nothing consumed it.
**`FiatPaymentMethodOption` is no longer exported.** It described a row descriptor
that `fiatMethods` no longer takes.
**`backendUrl` is now required** on all three modals, and the `DEFAULT_BACKEND_URL`
export is gone. The old default pointed at a Rhinestone-internal service running on
**our** API key, so any integration that omitted the prop was silently routing its
users' deposits through it.
```diff theme={null}
```
If you already set `backendUrl`, nothing changes. If you didn't, you were on our key
and need a [proxy](/deposits/widget/backend) before upgrading. TypeScript flags the
omission; each modal also logs a `console.error` when the value is missing, empty or
whitespace, since `backendUrl={process.env.X ?? ""}` typechecks fine.
**`fiatOnrampMethods` becomes `fiatMethods`**, a boolean map keyed by Swapped
`payment_group` instead of a list of row descriptors:
```diff theme={null}
```
The old prop made you supply each row's `label`, `sublabel` and `icon`, which meant
pasting our copy and freezing a claim like `"Instant - $10,000 limit"` into your
bundle.
`fiatOnrampMethods={[]}` used to fall through to offering **every** payment method.
An empty or all-false `fiatMethods` now offers none. If you computed the list
dynamically and could produce an empty one, check which you wanted.
### Optional where it was mandatory
``'s `targetChain` and `targetToken` are now optional. They only ever
seeded the form — the user can pick any supported destination — so omitting them
opens on a same-chain, same-token withdrawal.
`@reown/appkit` and `@reown/appkit-adapter-wagmi` are now **optional** peer
dependencies. An app that passes its own `walletClient` never opens AppKit and no
longer needs it installed. See [install](/deposits/widget/quickstart#install).
### New
* **`enableWallet?: boolean`** (default `true`) on `` — turn off to
present a flow with no wallet even when a `walletClient` or `reownAppId` is supplied.
* **[``](/deposits/widget/claim-modal)** and the `./claim` subpath — a user
pastes a failed or rejected deposit's transaction hash and gets the funds returned.
The user's own signature authorizes it, so this needs no backend of your own: pass
`signRecovery` and forward `POST /deposits/recover` on your proxy. For recipients
that can't sign, `@rhinestone/deposit-modal/server` exports `createRefundHandler` to
authorize against your own session instead.
### Behavior changes worth checking
* **`connected` no longer fires for flows with no wallet** (QR, fiat, exchange). It
previously reported the declared address as though a wallet had connected. If you
used it as a "flow started" signal, switch to `onReady`.
* **A QR-only integration no longer auto-locks to the wallet.** The connect step's
auto-skip never accounted for `enableQrTransfer` or asset migrations, so it could
skip past the only funding option you had enabled.
* **Logos load from Rhinestone's asset CDN.** Apps with an explicit `img-src` CSP must
allow `https://s3.rhinestone.dev` — a blocked image fails silently. See
[content security policy](/deposits/widget/deposit-modal#content-security-policy).
* **`HYPERCORE_RECIPIENT_NOT_EOA` is no longer emitted.** [HyperCore](/deposits/widget/deposit-modal#hypercore-destinations)
deposits now accept a smart-account `recipient`, and the pre-screen that blocked one
is gone. If you branch on that `onError` code, the branch is dead.
* **A chain your deposit whitelist allows nothing on is no longer offered** in the QR
flow's chain picker, instead of appearing with built-in tokens the deposit would then
be rejected for. Chains the shortlist says nothing about keep their existing set.
* **Fiat payment methods are personalized by region** unless you pass `fiatMethods`.
See [regional payment methods](/deposits/widget/deposit-modal#regional-payment-methods).
* **The deposit review shows a single "Fees" row.** The per-category breakdown and its
tooltips are gone; `uiConfig.feeSponsored` and `uiConfig.feeTooltip` still apply on
the processing and result screens.
### Removed prop warnings
Both modals log a `console.error` naming the replacement when passed any prop removed
in this release. TypeScript already catches these; the runtime warning is for plain
JavaScript hosts, spread props, and loosely typed call sites, where several of the
removals fail silently rather than visibly.
***
## v0.1.x / v0.2.x → v0.3.0
v0.3.0 collapses each modal's per-event callbacks into a single `onLifecycle`
callback, renames the analytics event types, removes the `/reown` and `/safe`
subpath entry points, and drops `connectButtonLabel`. `` and
`` share the same callback shape, but their lifecycle payloads
are **not identical** — see [Asymmetries](#asymmetries) below.
## Callback collapse — onLifecycle
Both modals replace their individual callbacks with one `onLifecycle` that
receives a discriminated event. Switch on `event.type`; the payload fields keep
the same names as before.
| Old prop (``) | New `event.type` | Old prop (``) | New `event.type` |
| --------------------------- | ------------------------- | ---------------------------- | ---------------- |
| `onConnected` | `"connected"` | `onConnected` | `"connected"` |
| `onDepositSubmitted` | `"submitted"` | `onWithdrawSubmitted` | `"submitted"` |
| `onDepositComplete` | `"complete"` | `onWithdrawComplete` | `"complete"` |
| `onDepositFailed` | `"failed"` | `onWithdrawFailed` | `"failed"` |
| `onTotalBalanceChange` | `"balance-changed"` | — | — |
| `onSmartAccountChange` | `"smart-account-changed"` | — | — |
```diff DepositModal theme={null}
trackConnected(address, smartAccount)}
- onDepositSubmitted={({ txHash, sourceChain, amount }) => trackSubmitted(txHash, sourceChain, amount)}
- onDepositComplete={({ txHash, destinationTxHash }) => trackComplete(txHash, destinationTxHash)}
- onDepositFailed={({ txHash, error }) => trackFailed(txHash, error)}
- onTotalBalanceChange={(total) => setBalance(total)}
- onSmartAccountChange={({ evm, solana }) => setSmartAccount({ evm, solana })}
- connectButtonLabel="Connect wallet"
+ onLifecycle={(event) => {
+ switch (event.type) {
+ case "connected":
+ trackConnected(event.address, event.smartAccount);
+ break;
+ case "submitted":
+ trackSubmitted(event.txHash, event.sourceChain, event.amount);
+ break;
+ case "complete":
+ trackComplete(event.txHash, event.destinationTxHash);
+ break;
+ case "failed":
+ trackFailed(event.txHash, event.error);
+ break;
+ case "balance-changed":
+ setBalance(event.totalUsd);
+ break;
+ case "smart-account-changed":
+ setSmartAccount({ evm: event.evm, solana: event.solana });
+ break;
+ }
+ }}
/>
```
```diff WithdrawModal theme={null}
trackConnected(address, smartAccount)}
- onWithdrawSubmitted={({ txHash, sourceChain, amount, safeAddress }) => trackSubmitted(txHash, sourceChain, amount, safeAddress)}
- onWithdrawComplete={({ txHash, destinationTxHash }) => trackComplete(txHash, destinationTxHash)}
- onWithdrawFailed={({ txHash, error }) => trackFailed(txHash, error)}
- connectButtonLabel="Connect wallet"
+ onLifecycle={(event) => {
+ switch (event.type) {
+ case "connected":
+ trackConnected(event.address, event.smartAccount);
+ break;
+ case "submitted":
+ trackSubmitted(event.txHash, event.sourceChain, event.amount, event.accountAddress);
+ break;
+ case "complete":
+ trackComplete(event.txHash, event.destinationTxHash);
+ break;
+ case "failed":
+ trackFailed(event.txHash, event.error);
+ break;
+ }
+ }}
/>
```
See [status tracking](/deposits/widget/status-tracking) for the full event
payloads.
## Asymmetries
The two unions look alike but differ — don't assume one helper typechecks
against both.
| Aspect | `DepositLifecycleEvent` | `WithdrawLifecycleEvent` |
| --------------------------------- | ----------------------- | ------------------------ |
| `txHash` type | `string` | `Hex` |
| `sourceChain` on submit/complete | `ChainId \| "unknown"` | `number` |
| `sourceToken` on complete | `string`, optional | `Address`, required |
| `targetChain` on complete | `number \| "solana"` | `number` |
| `targetToken` on complete | `string` | `Address` |
| `accountAddress` on submit | not present | `Address` |
| `"balance-changed"` variant | yes | no |
| `"smart-account-changed"` variant | yes | no |
`sourceChain: "unknown"` is deposit-only. A webhook-detected deposit can arrive
without chain or token info, in which case deposit events carry
`sourceChain: "unknown"` and `sourceToken: undefined`. Handle this branch in
your deposit `onLifecycle` switch — the wrong branch picks the wrong explorer
URL. Withdraw flows always know the source chain.
## Analytics type rename
The `onEvent` prop name is unchanged on both modals, but its parameter type was
renamed. The payload shape is unchanged.
```diff theme={null}
- import type { DepositEvent, WithdrawEvent, ModalEvent } from "@rhinestone/deposit-modal";
+ import type {
+ DepositAnalyticsEvent,
+ WithdrawAnalyticsEvent,
+ ModalAnalyticsEvent,
+ } from "@rhinestone/deposit-modal";
```
## Removed
* **`connectButtonLabel`** — gone from both modals. The connect-step copy is
controlled internally; delete any consumer-side label, there is no
replacement.
* **`/reown` and `/safe` subpath imports** — they re-exported nothing that
isn't already on the root entry point.
```diff theme={null}
- import { DepositModal, disconnectWallet } from "@rhinestone/deposit-modal/reown";
+ import { DepositModal, disconnectWallet } from "@rhinestone/deposit-modal";
- import type { WithdrawModalProps } from "@rhinestone/deposit-modal/safe";
+ import type { WithdrawModalProps } from "@rhinestone/deposit-modal";
```
The `./deposit`, `./withdraw`, `./constants`, and `./styles.css` subpaths
remain for tree-shaking.
## Additive — no action required
New in v0.3.0; existing code keeps working:
* **`appBalanceUsd?: number`** on `` — renders a "Balance after
deposit" row (`appBalanceUsd + amount`) instead of fetching a portfolio
balance.
* **`dappImports?: DappImportsConfig`** on `` — pull balances from
third-party apps. See [migrating assets](/deposits/widget/asset-migrations).
* **`defaultAmount: "max"`** — defaults the input to the user's full
source-token balance.
* **Solana destinations** — `targetChain: Chain | number | "solana"`,
`targetToken: Address | string`, `recipient: Address | string`.
* **New root exports** — `DepositLifecycleEvent`, `WithdrawLifecycleEvent`,
`DappImportsConfig`, `OutputTokenRule`, plus the renamed analytics types.
## Unchanged
`onError`, `onReady`, `onRequestConnect`, the `onEvent` prop name,
`dappWalletClient` / `dappPublicClient` / `reownAppId`, ``'s
`onSignTransaction`, and the `@rhinestone/deposit-modal/styles.css` export all
keep their names and signatures.
Scoped to v0.3.0. Several of these changed again in v0.9.0 — see the v0.8.x → v0.9.0
section at the top of this page.
# Widget quickstart
Source: https://docs.rhinestone.dev/deposits/widget/quickstart
Add the deposit widget to your React app and accept cross-chain deposits in minutes.
Install the `@rhinestone/deposit-modal` package, render the modal, and handle a completed deposit.
This quickstart has the modal connect the wallet itself, via Reown. If you don't want to set up Reown, the modal also works with [a wallet your app already has connected](/deposits/widget/deposit-modal#you-supply-one) (Privy, Dynamic, Turnkey, wagmi) or [with no wallet at all](/deposits/widget/deposit-modal#no-wallet-at-all) — QR transfer, fiat on-ramp, or exchange connect.
## Prerequisites
* A React 18+ app (Next.js, Vite, or similar)
* A [Rhinestone API key](https://tally.so/r/wg22x4)
* A [Reown](https://cloud.reown.com/) project ID, if the modal is connecting the wallet
## Install
```bash theme={null}
npm install @rhinestone/deposit-modal viem wagmi @tanstack/react-query
```
If you want the modal to connect the wallet — that is, you pass `reownAppId` — add
all five of these:
```bash theme={null}
npm install @reown/appkit @reown/appkit-adapter-wagmi \
@reown/appkit-adapter-solana @solana/web3.js @solana/spl-token
```
The Solana three are **not** optional on this path, despite being marked optional
peers. Reown's connect view always registers the Solana adapter, so the chunk that
loads it imports all three statically — an EVM-only app that omits them gets a
module-resolution failure when the modal opens.
Supply your own `walletClient` instead and you need none of the five. Reown is
imported lazily, so it stays out of your bundle entirely — which is what makes them
optional peers in `package.json`.
The modal ships with its own `wagmi` and `@tanstack/react-query` providers — you do not need to wrap your app with `WagmiProvider` or `QueryClientProvider`.
The package ships no `"use client"` directive, so in the Next.js App Router put it in a component that has one.
The modal can't hold your API key, so every request it makes goes through a proxy you run. Clone Rhinestone's and start it locally:
```bash theme={null}
git clone https://github.com/rhinestonewtf/deposit-widget-proxy
cd deposit-widget-proxy
bun install
RHINESTONE_API_KEY=your-key bun run dev
```
No Bun? `docker build -t deposit-widget-proxy . && docker run -p 4000:4000 -e RHINESTONE_API_KEY=your-key deposit-widget-proxy` does the same.
Check it:
```bash theme={null}
curl localhost:4000/health
```
You should see `{"ok":true}`. That URL is your `backendUrl` below. See [backend](/deposits/widget/backend) for deploying it properly, or for writing your own instead.
Import the modal and its styles. Pass the target chain, token, and your Reown project ID.
```tsx theme={null}
import { useState } from "react";
import { DepositModal } from "@rhinestone/deposit-modal";
import "@rhinestone/deposit-modal/styles.css";
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
setIsOpen(false)}
targetChain={8453}
targetToken="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
recipient="0xYOUR_RECIPIENT_ADDRESS"
backendUrl="http://localhost:4000"
reownAppId={process.env.NEXT_PUBLIC_REOWN_PROJECT_ID!}
/>
>
);
}
```
Click the button and the modal should open on the wallet-connect screen. From there the user picks a source chain and token, enters an amount, and confirms the deposit; bridging to the target chain is handled automatically.
If the modal opens but the token list is empty, the proxy isn't reachable — check the terminal running it.
Add the `onLifecycle` callback and react to the `"complete"` event when tokens arrive on the target chain.
```tsx {8-13} theme={null}
setIsOpen(false)}
targetChain={8453}
targetToken="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
recipient="0xYOUR_RECIPIENT_ADDRESS"
backendUrl="http://localhost:4000"
reownAppId={process.env.NEXT_PUBLIC_REOWN_PROJECT_ID!}
onLifecycle={(event) => {
if (event.type === "complete") {
console.log("Deposit complete:", event.destinationTxHash);
setIsOpen(false);
}
}}
/>
```
`onLifecycle` is a single callback that emits every state transition as a discriminated event — switch on `event.type`. See [status tracking](/deposits/widget/status-tracking#onlifecycle) for all event variants and payloads.
## Before production
`localhost:4000` is fine while you build, not for your users. Deploy the proxy somewhere real and point `backendUrl` at it — see [backend](/deposits/widget/backend) for the routes it must forward.
This quickstart runs on **mainnet**, so a deposit moves real funds. Start with an amount you don't mind losing to a mistake.
You'll also want [webhooks](/deposits/widget/backend#webhooks) for anything that must happen whether or not the modal is still open.
See it running in the [live demo](https://demo.rhinestone.dev), whose [source](https://github.com/rhinestonewtf/deposit-widget-demo) is a working integration you can read.
## Next steps
Wallet options, funding methods, transfer configuration, and full props reference.
Theme and UI configuration.
Lifecycle events, callbacks, and error handling.
Withdraw tokens to any supported chain, with your app performing the transfer.
Let users recover a failed or rejected deposit from its transaction hash.
# Status tracking
Source: https://docs.rhinestone.dev/deposits/widget/status-tracking
Track deposit progress and react to state changes via the onLifecycle callback.
The modal emits every state transition through a single `onLifecycle` callback.
You switch on `event.type` to update your UI, trigger backend processes, or log
analytics. New event variants can be added without changing the prop surface.
## Deposit lifecycle
```mermaid actions={false} theme={null}
sequenceDiagram
participant App as Your app
participant Modal as Deposit modal
participant Chain as Blockchain
Modal->>App: onReady
Note over Modal: User picks a funding method
Modal->>App: onLifecycle "connected" (wallet only)
Note over Modal: User selects chain, token, amount
Modal->>Chain: Submit deposit tx
Modal->>App: onLifecycle "submitted"
Note over Chain: Bridge in progress
Chain-->>Modal: Funds arrive on target chain
Modal->>App: onLifecycle "complete"
```
1. The modal initializes and fires `onReady`
2. If the user funds from a wallet, `"connected"` fires with the EOA `address` and
the `smartAccount` the deposit lands on
3. The user selects a source chain, token, and amount, then confirms
4. The modal submits the transaction on the source chain and emits `"submitted"`
5. The bridge routes funds to the target chain. Once they arrive, the modal
emits `"complete"`
If the bridge fails after submission, `"failed"` is emitted instead of
`"complete"`.
`"connected"` fires only for wallet funding. QR transfer, fiat on-ramp and exchange
connect involve no wallet, so it never fires — use `onReady` if you need a "flow
started" signal.
## onLifecycle
`onLifecycle` receives a discriminated union — `DepositLifecycleEvent` on
``, `WithdrawLifecycleEvent` on ``. The two are
similar but not identical; see [withdraw events](#withdraw-events) for the
differences.
```tsx theme={null}
import type { DepositLifecycleEvent } from "@rhinestone/deposit-modal";
{
switch (event.type) {
case "connected":
console.log("smart account", event.smartAccount);
break;
case "submitted":
console.log("source tx", event.txHash, "on", event.sourceChain);
break;
case "complete":
console.log("done", event.destinationTxHash, event.amount);
break;
case "failed":
console.error("failed", event.txHash, event.error);
break;
case "balance-changed":
setBalance(event.totalUsd);
break;
case "smart-account-changed":
setSmartAccount({ evm: event.evm, solana: event.solana });
break;
}
}}
/>
```
### Deposit events
| `event.type` | Fields | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `"connected"` | `address: Address`, `smartAccount: Address` | A wallet was connected as the funding source |
| `"submitted"` | `txHash: string`, `sourceChain: ChainId \| "unknown"`, `amount: string`, `sourceDecimals?: number`, `amountUsd?: string` | Deposit transaction submitted on the source chain |
| `"complete"` | `txHash: string`, `destinationTxHash?: string`, `amount: string`, `sourceChain: ChainId \| "unknown"`, `sourceToken?: string`, `sourceDecimals?: number`, `amountUsd?: string`, `targetChain: number \| "solana"`, `targetToken: string` | Tokens arrived on the target chain |
| `"failed"` | `txHash: string`, `error?: string` | Bridge or transfer failed after submission |
| `"balance-changed"` | `totalUsd: number` | The user's total portfolio balance (USD) changed |
| `"smart-account-changed"` | `evm: Address \| null`, `solana: string \| null` | The resolved smart account addresses changed |
`amount` is in the source token's base units — divide by `sourceDecimals` to display
it. `sourceDecimals` is omitted when the token isn't recognised, which happens for a
QR deposit of an unlisted token.
`amountUsd` is the USD value as entered in the modal. It is omitted for flows with no
amount input: QR transfer, fiat on-ramp, and exchange connect.
`sourceChain: "unknown"` is deposit-only. When a webhook-detected deposit
arrives without chain or token information, `sourceChain` is `"unknown"` and
`sourceToken` is `undefined` — handle this branch so you don't pick the wrong
explorer URL.
### Withdraw events
`WithdrawLifecycleEvent` carries the same `type` values minus `"balance-changed"`
and `"smart-account-changed"`. Its `txHash` is `Hex`, `sourceChain` is always a
`number`, `sourceToken` / `targetToken` are `Address`, and `"submitted"` adds an
`accountAddress: Address` field. `"connected"` means the deposit account for the
chosen target is registered and fundable, not that a wallet connected.
### Claim events
`ClaimLifecycleEvent` is a separate union — `lookup`, `refund_requested`, `complete`,
`failed`. See [claim modal](/deposits/widget/claim-modal#lifecycle-events).
## onReady
Fires once when the modal is initialized and ready for interaction. No payload.
```tsx theme={null}
onReady={() => console.log("modal ready")}
```
## onError
Fires on errors at any stage — wallet connection, transaction signing, bridge
setup — that prevent the deposit from being submitted. Distinct from the
`"failed"` lifecycle event, which covers failures after the source transaction
confirms.
```tsx theme={null}
onError={(data) => console.error(`[${data.code}] ${data.message}`)}
```
| Field | Type | Description |
| --------- | --------------------- | ------------------------ |
| `message` | `string` | Error description |
| `code` | `string \| undefined` | Error code, if available |
### Codes
| `code` | Meaning |
| ----------------------------------- | ---------------------------------------------------------------- |
| `WITHDRAW_MISSING_SEND_TRANSACTION` | `` opened without an `onSendTransaction` function |
| `WITHDRAW_REGISTER_FAILED` | Registering the withdrawal's deposit account failed |
| `WITHDRAW_FLOW_ERROR` | The withdrawal failed after the form was submitted |
| `CLAIM_LOOKUP_FAILED` | `` could not look up the pasted transaction hash |
| `CLAIM_FAILED` | The refund request failed |
| `SWAPPED_CONNECT_EXCHANGES_FAILED` | The exchange list could not be fetched |
Errors without a code carry only `message`. For bridge-level codes, see [deposit processing error codes](/deposits/api/deposit-processing#error-codes).
## Error handling
| Stage | Signal | Typical causes |
| ------------------- | ------------------------ | ------------------------------------------- |
| Wallet connection | `onError` | User rejected connection, network error |
| Transaction signing | `onError` | User rejected transaction, insufficient gas |
| After submission | `onLifecycle` `"failed"` | Bridge failure, timeout, price deviation |
| Any stage | `onError` | Unexpected errors |
After the source chain transaction confirms, the deposit service may
[retry automatically](/deposits/api/deposit-processing#retries) before the
`"failed"` event fires.
## Analytics
The `onEvent` callback fires on granular user interactions for your analytics
pipeline. Its payload type is `DepositAnalyticsEvent`, `WithdrawAnalyticsEvent`, or
`ClaimAnalyticsEvent`.
```tsx theme={null}
import type { DepositAnalyticsEvent } from "@rhinestone/deposit-modal";
onEvent={(event: DepositAnalyticsEvent) => {
analytics.track(event.type, event);
}}
```
Events include modal views (`*_open`) and CTA clicks (`*_cta_click`) at each
step of the flow, with contextual properties like selected token, chain, and
amount.
# Withdraw modal
Source: https://docs.rhinestone.dev/deposits/widget/withdraw-modal
Let users withdraw tokens from any account to any supported chain. Your app executes the transfer.
The `WithdrawModal` component handles outbound transfers. The user selects a destination chain and token, enters a recipient and an amount, and the modal registers the account that routes the funds — then asks your app to perform one transfer.
The modal never holds a key and never moves funds itself, so it works with any account model: an EOA, a smart account, an embedded or in-app wallet, or a relayer.
## Basic usage
```tsx theme={null}
import { WithdrawModal } from "@rhinestone/deposit-modal/withdraw";
import "@rhinestone/deposit-modal/styles.css";
setIsOpen(false)}
accountAddress="0xACCOUNT_HOLDING_THE_FUNDS"
backendUrl={process.env.NEXT_PUBLIC_DEPOSIT_PROXY_URL}
sourceChain={8453}
sourceToken="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
targetChain={10}
targetToken="0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"
onSendTransaction={async ({ chainId, token, amount, to }) => ({
txHash: await myWallet.sendTransfer({ chainId, token, amount, to }),
})}
onLifecycle={(event) => {
if (event.type === "complete") {
console.log("Withdrawal complete:", event.destinationTxHash);
}
}}
/>
```
`accountAddress` is the account holding the funds. The modal reads its balance and stops the user sending to themselves; it never transacts from it.
## Executing the transfer
`onSendTransaction` is required. The modal calls it once, when the user confirms on the review screen, with everything needed for a single transfer:
```ts theme={null}
onSendTransaction: (request: WithdrawTransferRequest) => Promise<{ txHash: Hex }>
interface WithdrawTransferRequest {
chainId: number; // source chain to send from
token: Address; // source token; NATIVE_TOKEN_ADDRESS for the native asset
amount: bigint; // in the token's base units
to: Address; // send here
}
```
Two rules, both silent-failure modes if missed:
**Send to `to`, and nothing else.** It is the Rhinestone account that receives the funds and bridges them on to the recipient — except on a same-route withdrawal (source chain and token identical to the target), where the modal skips the bridge and `to` is the recipient directly. Substituting an address of your own either bypasses the bridge or strands the funds.
**Return the on-chain transaction hash.** Progress is tracked by looking the deposit up by that hash. An ERC-4337 wallet must await the receipt and return the bundled transaction hash, **not** the userOp hash — returning the wrong one leaves the modal waiting on a withdrawal that already succeeded.
Reject the promise to surface a failure in the modal. The user can retry from the review screen.
### If your funds are in a Safe
A 1/1 Safe signs with `personal_sign` over the `SafeTx` hash, relayed by whoever pays the gas. Build the EIP-712 `SafeTx`, sign it, and submit `execTransaction`:
```tsx theme={null}
onSendTransaction={async ({ chainId, token, amount, to }) => {
const safeTx = await buildSafeTransaction({ chainId, token, amount, to });
const signature = await provider.request({
method: "personal_sign",
params: [safeTx.safeTxHash, ownerAddress],
});
// Adjust v for Safe's eth_sign verification
const v = parseInt(signature.slice(-2), 16);
const adjusted = signature.slice(0, -2) + (v + 4).toString(16);
const { txHash } = await relaySafeTransaction(safeTx, adjusted);
return { txHash };
}}
```
Safe's `eth_sign` path requires adding 4 to the `v` value of a `personal_sign` signature. This adjustment is specific to Safe's signature verification — see the [Safe docs](https://docs.safe.global/advanced/smart-account-signatures#eth_sign-signature) for details.
To keep gas sponsored, relay the signed transaction through `POST /safe/withdraw` on your proxy rather than submitting `execTransaction` from the user's wallet. Note that a relayed transaction cannot use Safe's pre-validated (`v = 1`) signature shortcut: that only validates when `msg.sender` is the owner, and for a relayed call the sender is the relayer.
## Props reference
### Required
| Prop | Type | Description |
| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `isOpen` | `boolean` | Controls modal visibility |
| `onClose` | `() => void` | Called when the user closes the modal |
| `accountAddress` | `Address` | Account holding the funds being withdrawn |
| `sourceChain` | `Chain \| number` | Chain where the account holds funds |
| `sourceToken` | `Address` | Token to withdraw |
| `onSendTransaction` | `(request: WithdrawTransferRequest) => Promise<{ txHash: Hex }>` | Performs the transfer and returns its on-chain hash |
| `backendUrl` | `string` | Your [backend proxy](/deposits/widget/backend), which holds your API key |
### Transfer
| Prop | Type | Default | Description |
| --------------- | ----------------- | ------------- | ---------------------------------------------------- |
| `targetChain` | `Chain \| number` | `sourceChain` | Seeds the destination chain; the user can change it |
| `targetToken` | `Address` | `sourceToken` | Seeds the token to receive; the user can change it |
| `recipient` | `Address` | — | Pre-fills the delivery address; the user can edit it |
| `defaultAmount` | `string` | — | Pre-filled withdrawal amount |
`targetChain` and `targetToken` are optional — they seed the form, and the user can
pick any supported destination. Omit them to open on a same-chain, same-token
withdrawal.
### Account
| Prop | Type | Default | Description |
| --------------- | --------- | ------- | ---------------------------------------------- |
| `forceRegister` | `boolean` | `false` | Re-register even if the account already exists |
### Backend
| Prop | Type | Default | Description |
| --------- | ----------- | -------------- | -------------------------------------------------------------------------------------------------------------- |
| `rpcUrls` | `RpcUrlMap` | Chain defaults | Per-chain RPC overrides keyed by EVM chain id. Used for balance reads; chains left unset use their default RPC |
### Display
| Prop | Type | Default | Description |
| --------------------- | ---------------------- | ------- | ------------------------------------------------------------------- |
| `inline` | `boolean` | `false` | Render without modal overlay |
| `closeOnOverlayClick` | `boolean` | `true` | Close modal on backdrop click |
| `className` | `string` | — | CSS class for the modal container |
| `theme` | `DepositModalTheme` | — | [Theme configuration](/deposits/widget/customization#theme) |
| `uiConfig` | `DepositModalUIConfig` | — | [UI configuration](/deposits/widget/customization#ui-configuration) |
| `debug` | `boolean` | `false` | Enable debug logging |
### Callbacks
| Prop | Type | Description |
| ------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `onReady` | `() => void` | Modal initialized |
| `onLifecycle` | `(event: WithdrawLifecycleEvent) => void` | [Lifecycle event](/deposits/widget/status-tracking#onlifecycle) — switch on `event.type` (`connected`, `submitted`, `complete`, `failed`) |
| `onError` | `(data: ErrorEventData) => void` | Error at any stage |
| `onEvent` | `(event: WithdrawAnalyticsEvent) => void` | [Analytics event](/deposits/widget/status-tracking#analytics) |
`WithdrawLifecycleEvent` has no `balance-changed` or `smart-account-changed` variants, and its payload types differ from the deposit union: `txHash` is `Hex`, `sourceChain` is always a `number`, `sourceToken` / `targetToken` are `Address`, and `"submitted"` carries an `accountAddress`.
# Intents and ERC-4337
Source: https://docs.rhinestone.dev/home/concepts/intents-and-erc4337
How Rhinestone intents compare to ERC-4337 userops, and what makes intent-based execution different.
## What are intents?
ERC-4337 introduced userops: pseudo-transactions handled by bundlers that relay smart account transactions onchain. Bundlers front the gas and submit through the EntryPoint contract, which orchestrates validation and execution via the smart account.
**Intents extend this model to crosschain transactions.** An intent is a signed declaration of what the user wants to happen, which chains are involved, and which tokens they're spending. A solver fulfils it, fronting gas and liquidity across all chains involved. The same model as a bundler and paymaster, but capable of spanning multiple chains in a single signed operation.
## Intents vs ERC-4337
The key difference is the relayer layer. ERC-4337 routes through a single bundler/paymaster provider. Intents route through a competitive solver market where each solver manages token inventory across chains, aggregates DEX liquidity, and optimizes route and price independently.
This is what enables crosschain transactions, built-in swaps, multi-input intents, and fee sponsorship that covers bridging and swap costs — not just gas.
## ERC-4337 Userop Flow
## Rhinestone Intent Flow
Rhinestone uses intents to relay smart account transactions. The Relayer Market acts as bundler and paymaster. The Intent Executor, an ERC-7579 module installed on the smart account, handles intent signature validation and execution — without privileged execution rights on the account. Signature validation and execution are coupled onchain.
In the case of ERC-7579 smart accounts, the Intent Executor uses [the executeFromExecutor function](https://eips.ethereum.org/EIPS/eip-7579#execution-behavior).
*Intents and ERC-4337 userops are not mutually exclusive. A smart account can use both, depending on the flow.*
## Feature comparison
| Feature | ERC-4337 (userops) | Rhinestone Intents |
| :----------------------------------- | :----------------- | :----------------- |
| Gas sponsorship | Yes | Yes |
| Fee sponsorship (bridge + swap fees) | No | Yes |
| Counterfactual addresses | Yes | Yes |
| Built-in swaps | No | Yes |
| Built-in bridges | No | Yes |
| EOA support | No | Yes |
## When to use which
| Scenario | Use |
| ------------------------------------------------ | ---------------- |
| Same-chain transaction, smart account only | ERC-4337 userops |
| Crosschain transaction (any chain to any chain) | Warp intent |
| Built-in swap needed (bridge + swap in one step) | Warp intent |
| Sponsoring gas only, no bridging | Either |
| EOA user (no smart account) | Warp intent |
| Multi-input (aggregate funds across chains) | Warp intent |
| Need ERC-4337 bundler compatibility | ERC-4337 userops |
| AI agent executing across chains | Warp intent |
## Trust assumptions
ERC-4337 userops today are almost universally sent to a single private provider despite the open mempool standard. Intent systems face the same pattern — intents are propagated to a provider, and if that provider is down, the intent isn't processed.
Rhinestone's Orchestrator is trustless from the user's perspective. It can only interact with a smart account via the onchain execution paths defined by the Intent Router and Intent Executor. It poses no liveness or censorship risk. An onchain escape hatch can be activated without any dependency on Rhinestone.
# Session keys
Source: https://docs.rhinestone.dev/home/concepts/session-keys
How session keys work in Rhinestone and what they enable.
Session keys are restricted, short-lived keys that a user delegates to a third party. The delegate can sign transactions on the user's behalf, but only within the bounds the user defined. The user's main account key is never shared.
Rhinestone's [Smart Sessions](../../smart-wallet/smart-sessions/overview) module implements session keys as an ERC-7579 validator. It is multichain-compatible and works with all supported smart account implementations and smart EOAs.
## How they work
When a user creates a session, they define:
* **Owners**: who holds the session key (your app, an agent, a smart contract)
* **Actions**: which contracts and functions the key is allowed to call
* **Policies**: constraints on those actions (spending limits, time windows, usage limits)
The user signs once to approve the session. After that, the session key holder can execute permitted transactions without prompting the user again — until the session expires or its limits are reached.
## What they enable
**1-click UX**: users pre-approve a set of actions, then your app executes them instantly with no wallet prompts. Common in trading apps, games, and subscription flows.
**Automated transactions**: delegate to a bot, agent, or smart contract to execute on the user's behalf within predefined limits. The user retains full control and can revoke at any time.
**Crosschain automation**: Smart Sessions is multichain-aware. A single session approval can authorize actions across multiple chains, consistent with Warp's single-signature intent model.
## Trust model
Session keys do not grant unrestricted access to the account. They are constrained by:
* The specific contracts and function selectors allowed in the session
* Any policies applied (spending limits, timeframe, usage count)
* The session expiry
A compromised or malicious session key can only act within these bounds. The user's main key always retains the ability to revoke the session.
## Next steps
Full API reference: owners, actions, policies, and multi-session signatures.
Build a working session key integration step by step.
# Smart accounts
Source: https://docs.rhinestone.dev/home/concepts/smart-accounts
How modular smart accounts work and what they enable.
A smart account is a smart contract that holds assets and contains the logic for authenticating signatures and executing transactions. Unlike an EOA, its behaviour is programmable — you can extend it with modules to add new capabilities without changing the underlying account contract.
Rhinestone smart accounts follow the [ERC-7579](https://erc7579.com) standard for modular smart accounts.
## Modules
Modules are self-contained smart contracts that extend a smart account's feature set. There are four types:
* **Validators**: control how transactions are authenticated. Examples: ECDSA key, passkey, multisig, session key.
* **Executors**: enable executions on the account with custom logic. Examples: automated trading, scheduled transfers, stop-loss triggers.
* **Hooks**: run before or after execution to enforce conditions. Examples: spending limits, transaction guards.
* **Fallbacks**: extend the account interface to add new functionality or maintain compatibility with future standards.
## Signers
Every smart account must have at least one validator configured. The entity controlling that validator owns the account. Rhinestone supports:
* Passkeys (WebAuthn / device biometrics)
* Embedded wallets (Privy, Dynamic, Turnkey, Magic, and others)
* External wallets (MetaMask, Rabby, any WalletConnect-compatible wallet)
* ECDSA keys (server-side or agent wallets)
## Resources
The minimal standard for modular smart accounts: module types, interfaces, and interoperability.
Tools, documentation, and example code for the ERC-7579 smart account ecosystem.
Building modular accounts with ERC-7579: validators, executors, hooks, fallbacks, and social recovery.
# How Warp works
Source: https://docs.rhinestone.dev/home/introduction/rhinestone-intents
A technical overview of Warp, Rhinestone's intent routing and execution engine.
**Warp** is Rhinestone's intent routing and execution engine. It aggregates crosschain settlement layers through a unified solver market and executes intents with sub-2-second confirmation times.
## How it works
A Warp intent is a signed declaration of what the user wants to happen: which tokens to spend, on which chains, and what to execute at the destination. The user signs once — Warp handles the rest.
**A concrete example:** Alice wants to deposit 100 USDC into a Morpho vault on Arbitrum. She has 20 USDC on Sonic and 0.5 ETH on Soneium. She signs a single intent. Warp's Orchestrator finds that Eco offers the cheapest route from Sonic and Relay the cheapest from Soneium, builds the transactions, and coordinates execution across both settlement layers simultaneously. Alice's deposit lands on Arbitrum in under 2 seconds — she never touched a bridge.
## Intent flow
```mermaid theme={null}
sequenceDiagram
participant User
participant Orchestrator
participant Relayer Market
participant Destination Chain
User->>Orchestrator: Signed intent
Orchestrator->>Orchestrator: Route + encode intent operations
Orchestrator->>Relayer Market: Broadcast to solvers
Relayer Market->>Destination Chain: Solver fills (tokens + calls)
Destination Chain-->>User: Intent executed (<2s)
Relayer Market->>Relayer Market: Origin chain claims settle
```
Origin chain settlement happens asynchronously after the destination fill. The user's transaction is complete once the fill lands — they don't wait for settlement finality.
## Lifecycle and status
Once you submit a signed intent, you poll it for one of three states:
* **Pending** — the intent has been accepted and is being routed and filled.
* **Completed** — the destination fill landed. The user's action is done at this point.
* **Failed** — the intent couldn't be completed.
Internally an intent is a set of operations (origin-chain claims and a destination fill). Its overall status is derived from them: it reports **completed** only once every operation has completed, and **failed** as soon as any single operation fails.
When an intent fails, the status includes a reason:
| Reason | What it means |
| --------------- | ---------------------------------------------------------------------------------- |
| Expired | Not filled within the intent's validity window. Request a fresh quote and re-sign. |
| Reverted | An onchain execution (origin or destination) reverted. |
| Bridge timeout | The underlying settlement layer didn't deliver in time. |
| Relayer failure | No solver completed the fill. |
Fills typically confirm in under two seconds.
## Components
### Orchestrator
The Orchestrator is an offchain service that transforms user intents into executable onchain transactions. It:
* Indexes user balances across all supported chains
* Finds the optimal route for each chain-token element in the intent
* Builds the fill and claim transaction data
* Broadcasts intent operations to the Relayer Market for execution
Warp intents are broken down into **chain-token elements**, each with an origin and destination chain transaction. Each element can be assigned to a different settlement layer, enabling intents to span multiple input tokens, input chains, and settlement layers simultaneously. This is what allows Warp to find the best route and price for every component of a complex intent.
### Relayer Market
Solvers in the Relayer Market listen for intent operations from the Orchestrator and compete to execute them. Warp aggregates settlement layers (Across, Relay, Eco, and others) not by aggregating their APIs, but through direct onchain integrations via the Intent Router. Each solver interacts with the relevant settlement layer's contracts directly.
This design enables:
* Broad chain and token coverage
* Best-price routing per chain-token element
* New settlement layers to be added without protocol changes
### Intent Router
The Intent Router is the onchain entry point for Warp intents. It acts as a dynamic dispatch, routing fill and claim operations to the correct settlement layer adapter. Adapters handle the specific execution semantics of each settlement layer, so adding support for new settlement mechanisms doesn't disrupt existing flows.
## Origin and destination executions
Warp supports arbitrary operations on both the origin and destination chain as part of a single signed intent.
**Origin executions** run before funds are deposited into the settlement layer. A common use case is an origin swap — converting a token that isn't supported by the settlement layer into one that is, so it can be used to fund the intent.
**Destination executions** run after the fill transaction. They are guaranteed by the deterministic nature of Warp intents. Destination calls execute with the user's account as `msg.sender`, which matters for DeFi protocols that use `msg.sender` for access control or accounting.
## Multi-chain signatures
Warp uses a purpose-built EIP-712 structure that allows multiple origin chains to participate in a single intent without opaque calldata or blob encoding. Each chain-token element is validated independently using its component of the EIP-712 envelope plus cross-referenced hashes. The result is a legible, structured signature that wallets can display clearly, regardless of intent complexity.
# Welcome to Rhinestone
Source: https://docs.rhinestone.dev/home/introduction/welcome-to-rhinestone
Get familiar with Rhinestone's tech and figure out where to start
**Rhinestone is a smart wallet SDK and crosschain intent API.** Deploy and manage self-custodial smart accounts, sponsor fees, and route transactions across any supported chain — all through a single integration.
## Build with the SDK
The Rhinestone SDK gives you everything you need to embed smart wallets into your app: account deployment, passkeys, session keys, and crosschain transactions out of the box.
Create a smart account and send your first crosschain transaction
Enable 1-click UX and automated transactions
## Use the Intents API
Send crosschain intents directly via the REST API — no SDK required. Route tokens across chains, execute destination calls, and sponsor fees for your users.
Send your first crosschain intent via the REST API
Bridge and swap in a single transaction
Full endpoint reference for the Rhinestone API
## Learn How Rhinestone Works
ERC-7579 modular accounts, modules, and signers
How Warp routes and settles crosschain intents
How intent-based execution compares to userops
# What is Rhinestone?
Source: https://docs.rhinestone.dev/home/introduction/what-is-rhinestone
Learn more about Rhinestone
Rhinestone is a smart wallet SDK and crosschain intent API. It combines a modular smart account toolkit with an intent-based transaction engine, giving teams a single integration for deploying wallets and routing transactions across any supported chain.
## Tech Stack
Rhinestone offers three core services:
* Modular **Smart Account** toolkit, compatible with all major embedded wallet signers
* An **Intent Engine** (Warp) for single- and multi-chain transaction and swap routing
* A **Relayer Market** that aggregates solver liquidity for intent execution
## Smart Accounts
Rhinestone provides the onchain infrastructure for smart wallets, including the smart account and modules that extend the wallet with specific features. Core modules include:
* **Passkeys** for secure, user-friendly self-custodial key management
* **Session keys** for transaction automation and 1-click UX
* **Multi-sig** for high-value accounts
* **Social login** via embedded wallet integrations
* **Recovery** for heightened account security
Use these via the Rhinestone SDK to deploy, configure, and manage smart accounts for your users.
## Intent Engine (Warp)
Warp is Rhinestone's intent-based transaction service. It aggregates existing crosschain settlement layers through a single solver market, and uses resource locks for fast crosschain execution (sub-2-second fills).
Key capabilities:
* **Crosschain transactions**: transact on any chain using funds from any other chain, with no manual bridging
* **Swaps**: solver-based swaps with zero slippage and MEV protection, or inject your own DEX aggregator
* **Universal gas sponsorship**: deposit USDC on Base and sponsor gas, bridging, and swap fees on any supported chain
* **Multi-input intents**: combine any number of input chains and tokens in a single signed intent
* **Atomic and deterministic**: known output token, zero slippage, guaranteed execution or rollback
* **Human-readable signatures**: EIP-712 signing ensures a legible payload regardless of intent complexity
More on [Warp](./rhinestone-intents).
## Relayer Market
Warp routes intents through a unified Relayer Market that aggregates settlement layers like Across, Relay, and Eco. Solvers in the market supply liquidity, perform swaps, and settle directly onchain — the user intent is routed to the best-placed solver for price and speed.
More on the [Relayer Market](./rhinestone-intents#relayer-market).
## Composability
Rhinestone is designed to meet teams where they are:
* **Signer-agnostic**: works with passkeys, external wallets, and any embedded wallet provider (Turnkey, Dynamic, Magic, Privy, and more)
* **Account implementation-agnostic**: compatible with all major ERC-7579 smart account implementations and accounts using ERC-7821 for batched execution
* **Modular**: extend your smart wallet with custom modules or use existing ones from the ecosystem
* **API-first**: use Warp's intent routing via the REST API without integrating the smart account SDK
## Next Steps
Get started with the SDK
Deep dive into Warp and how the intent engine works
# Address book
Source: https://docs.rhinestone.dev/home/resources/address-book
A collection of addresses for the various contracts we have deployed or are using. If you need any of our contracts on a different chain, just [send us a message](https://t.me/konradkopp) or you can deploy any of them yourself from our repos.
## V1
### Infrastructure
| Contract | Address |
| :--------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| Safe Singleton Factory | [0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7](https://contractscan.xyz/contract/0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7) |
| Nexus | [0x000000000032dDC454C3BDcba80484Ad5A798705](https://contractscan.xyz/contract/0x000000000032dDC454C3BDcba80484Ad5A798705) |
| Nexus Account Factory | [0x0000000000679A258c64d2F20F310e12B64b7375](https://contractscan.xyz/contract/0x0000000000679A258c64d2F20F310e12B64b7375) |
| Nexus Bootstrap | [0x00000000006eFb61D8c9546FF1B500de3f244EA7](https://contractscan.xyz/contract/0x00000000006eFb61D8c9546FF1B500de3f244EA7) |
| Safe7579 Adapter | [0x7579f2AD53b01c3D8779Fe17928e0D48885B0003](https://contractscan.xyz/contract/0x7579f2AD53b01c3D8779Fe17928e0D48885B0003) |
| Safe7579 Launchpad | [0x75798463024Bda64D83c94A64Bc7D7eaB41300eF](https://contractscan.xyz/contract/0x75798463024Bda64D83c94A64Bc7D7eaB41300eF) |
| Safe Singleton | [0x29fcb43b46531bca003ddc8fcb67ffe91900c762](https://contractscan.xyz/contract/0x29fcb43b46531bca003ddc8fcb67ffe91900c762) |
| Safe Proxy Factory | [0x4e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67](https://contractscan.xyz/contract/0x4e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67) |
### Modules
| Contract | Address |
| :----------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| Smart Session Emissary | [0xad568b3f825a8d5ffc06dd3253526b64d810ae89](https://contractscan.xyz/contract/0xad568b3f825a8d5ffc06dd3253526b64d810ae89) |
| Ownable Validator | [0x000000000013fdB5234E4E3162a810F54d9f7E98](https://contractscan.xyz/contract/0x000000000013fdB5234E4E3162a810F54d9f7E98) |
| Ownable Validator (Contract Signers) | [0x2483DA3A338895199E5e538530213157e931Bf06](https://contractscan.xyz/contract/0x2483DA3A338895199E5e538530213157e931Bf06) |
| Webauthn Validator | [0x0000000000578c4cB0e472a5462da43C495C3F33](https://contractscan.xyz/contract/0x0000000000578c4cB0e472a5462da43C495C3F33) |
| Smart Session Compatibility Fallback | [0x000000000052e9685932845660777DF43C2dC496](https://contractscan.xyz/contract/0x000000000052e9685932845660777DF43C2dC496) |
### Policies
| Contract | Address |
| :-------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| ERC20 Spending Limit Policy | [0x000000000033212E272655D8a22402Db819477A6](https://contractscan.xyz/contract/0x000000000033212E272655D8a22402Db819477A6) |
| Contract Whitelist Policy | [0x0000000000d79359D85e62796104D7AE863fFAcb](https://contractscan.xyz/contract/0x0000000000d79359D85e62796104D7AE863fFAcb) |
| Usage Limit Policy | [0x00000000001d4479FA2A947026204d0283ceDe4B](https://contractscan.xyz/contract/0x00000000001d4479FA2A947026204d0283ceDe4B) |
| Uni Action Policy | [0x0000000000714Cf48FcF88A0bFBa70d313415032](https://contractscan.xyz/contract/0x0000000000714Cf48FcF88A0bFBa70d313415032) |
| Arg Policy | [0x0000000000167edE64D8751daACDdC0312565a73](https://contractscan.xyz/contract/0x0000000000167edE64D8751daACDdC0312565a73) |
| Simple Gas Policy | [0x0000000000d14A6F771494592218d8F9c2ddEbB6](https://contractscan.xyz/contract/0x0000000000d14A6F771494592218d8F9c2ddEbB6) |
| Sudo Policy | [0x0000000000FEEc8D74e3143fBaBbca515358d869](https://contractscan.xyz/contract/0x0000000000FEEc8D74e3143fBaBbca515358d869) |
| Time-Frame Policy | [0x0000000000D30f611fA3bf652ac6879428586930](https://contractscan.xyz/contract/0x0000000000D30f611fA3bf652ac6879428586930) |
| Value Limit Policy | [0x000000000021dC45451291BCDfc9f0B46d6f0278](https://contractscan.xyz/contract/0x000000000021dC45451291BCDfc9f0B46d6f0278) |
## Legacy
### Infrastructure
| Contract | Address |
| :-------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| ERC-4337 EntryPoint (v0.7) | [0x0000000071727De22E5E9d8BAf0edAc6f37da032](https://contractscan.xyz/contract/0x0000000071727De22E5E9d8BAf0edAc6f37da032) |
| Module Registry | [0x000000000069E2a187AEFFb852bF3cCdC95151B2](https://contractscan.xyz/contract/0x000000000069E2a187AEFFb852bF3cCdC95151B2) |
| Rhinestone Attester | [0x000000333034E9f539ce08819E12c1b8Cb29084d](https://contractscan.xyz/contract/0x000000333034E9f539ce08819E12c1b8Cb29084d) |
| Rhinestone Resolver | [0xF0f468571e764664c93308504642aF941d9f77F1](https://contractscan.xyz/contract/0xF0f468571e764664c93308504642aF941d9f77F1) |
| Rhinestone Schema Validator | [0x86430E19D7D204807bBb8CDa997bb57b7EE785dD](https://contractscan.xyz/contract/0x86430E19D7D204807bBb8CDa997bb57b7EE785dD) |
| Safe7579 Adapter | [0x7579EE8307284F293B1927136486880611F20002](https://contractscan.xyz/contract/0x7579EE8307284F293B1927136486880611F20002) |
| Safe7579 Launchpad | [0x7579011aB74c46090561ea277Ba79D510c6C00ff](https://contractscan.xyz/contract/0x7579011aB74c46090561ea277Ba79D510c6C00ff) |
| ERC-7579 Reference Factory | [0xDC15682AEDba36Cf3121507993b50Ef22b457053](https://contractscan.xyz/contract/0xDC15682AEDba36Cf3121507993b50Ef22b457053) |
| ERC-7579 Reference Singleton (Advanced) | [0xa951A1179bA8bd08b8140aB9dc7910AF08AE7181](https://contractscan.xyz/contract/0xa951A1179bA8bd08b8140aB9dc7910AF08AE7181) |
| ERC-7579 Reference Bootstrap | [0x1E919660050C68BFEf868945Cf5f9a26ad7E360b](https://contractscan.xyz/contract/0x1E919660050C68BFEf868945Cf5f9a26ad7E360b) |
Additional variables:
* Rhinestone Resolver UID: `0xdbca873b13c783c0c9c6ddfc4280e505580bf6cc3dac83f8a0f7b44acaafca4f`
* Rhinestone Schema UID: `0x93d46fcca4ef7d66a413c7bde08bb1ff14bacbd04c4069bb24cd7c21729d7bf1`
### Modules
| Contract | Address |
| :-------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| Smart Sessions Validator | [0x00000000002B0eCfbD0496EE71e01257dA0E37DE](https://contractscan.xyz/contract/0x00000000002B0eCfbD0496EE71e01257dA0E37DE) |
| Ownable Validator | [0x2483DA3A338895199E5e538530213157e931Bf06](https://contractscan.xyz/contract/0x2483DA3A338895199E5e538530213157e931Bf06) |
| Webauthn Validator | [0x2f167e55d42584f65e2e30a748f41ee75a311414](https://contractscan.xyz/contract/0x2f167e55d42584f65e2e30a748f41ee75a311414) |
| MultiFactor Validator | [0xf6bDf42c9BE18cEcA5C06c42A43DAf7FBbe7896b](https://contractscan.xyz/contract/0xf6bDf42c9BE18cEcA5C06c42A43DAf7FBbe7896b) |
| AutoSavings | [0x6AE48bD83B6bdc8489584Ea0814086f963d1BD95](https://contractscan.xyz/contract/0x6AE48bD83B6bdc8489584Ea0814086f963d1BD95) |
| ScheduledOrders Executor | [0x40dc90D670C89F322fa8b9f685770296428DCb6b](https://contractscan.xyz/contract/0x40dc90D670C89F322fa8b9f685770296428DCb6b) |
| ScheduledTransfers Executor | [0xA8E374779aeE60413c974b484d6509c7E4DDb6bA](https://contractscan.xyz/contract/0xA8E374779aeE60413c974b484d6509c7E4DDb6bA) |
| Ownable Executor | [0x4Fd8d57b94966982B62e9588C27B4171B55E8354](https://contractscan.xyz/contract/0x4Fd8d57b94966982B62e9588C27B4171B55E8354) |
| SocialRecovery | [0xA04D053b3C8021e8D5bF641816c42dAA75D8b597](https://contractscan.xyz/contract/0xA04D053b3C8021e8D5bF641816c42dAA75D8b597) |
| DeadmanSwitch | [0x8bAdE54bca47199B6732EB2F92318DD666bdE413](https://contractscan.xyz/contract/0x8bAdE54bca47199B6732EB2F92318DD666bdE413) |
| HookMultiPlexer | [0xF6782ed057F95f334D04F0Af1Af4D14fb84DE549](https://contractscan.xyz/contract/0xF6782ed057F95f334D04F0Af1Af4D14fb84DE549) |
### Policies
| Contract | Address |
| :-------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| ERC20 Spending Limit Policy | [0x00000088D48cF102A8Cdb0137A9b173f957c6343](https://contractscan.xyz/contract/0x00000088D48cF102A8Cdb0137A9b173f957c6343) |
| Universal Action Policy | [0x0000006DDA6c463511C4e9B05CFc34C1247fCF1F](https://contractscan.xyz/contract/0x0000006DDA6c463511C4e9B05CFc34C1247fCF1F) |
| Usage Limit Policy | [0x1F34eF8311345A3A4a4566aF321b313052F51493](https://contractscan.xyz/contract/0x1F34eF8311345A3A4a4566aF321b313052F51493) |
| Sudo Policy | [0x0000003111cD8e92337C100F22B7A9dbf8DEE301](https://contractscan.xyz/contract/0x0000003111cD8e92337C100F22B7A9dbf8DEE301) |
| Time-Frame Policy | [0x8177451511dE0577b911C254E9551D981C26dc72](https://contractscan.xyz/contract/0x8177451511dE0577b911C254E9551D981C26dc72) |
| Value Limit Policy | [0x730DA93267E7E513e932301B47F2ac7D062abC83](https://contractscan.xyz/contract/0x730DA93267E7E513e932301B47F2ac7D062abC83) |
### Mocks
| Contract | Address |
| :------------- | :------------------------------------------------------------------------------------------------------------------------- |
| Mock Validator | [0x11D02847245Df7cF19f48C8907ace59289D8aCEe](https://contractscan.xyz/contract/0x11D02847245Df7cF19f48C8907ace59289D8aCEe) |
| Mock Registry | [0x25A4b2F363678E13A0A5DB79b712dE00347a593E](https://contractscan.xyz/contract/0x25A4b2F363678E13A0A5DB79b712dE00347a593E) |
| Mock Attester | [0xaed4d8baa80948d54d33de041513d30124e1ae3f](https://contractscan.xyz/contract/0xaed4d8baa80948d54d33de041513d30124e1ae3f) |
# Audit reports
Source: https://docs.rhinestone.dev/home/resources/audit-reports
| Contract | Repository | Auditor | Report | Date |
| :---------------- | :-------------------------------------------------------------- | :--------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | ---------: |
| Module Registry | [Repository](https://github.com/rhinestonewtf/registry) | Ackee | [Report](https://github.com/rhinestonewtf/registry/blob/main/audits/ackee-blockchain-rhinestone-registry-report.pdf) | 3.07.2024 |
| Safe7579 | [Repository](https://github.com/rhinestonewtf/safe7579) | Ackee | [Report](https://github.com/rhinestonewtf/safe7579/blob/main/audits/ackee-blockchain-rhinestone-safe7579-report.pdf) | 5.07.2024 |
| Core Modules | [Repository](https://github.com/rhinestonewtf/core-modules) | Ackee | [Report](https://github.com/rhinestonewtf/core-modules/blob/main/audits/ackee-blockchain-rhinestone-core-modules-report.pdf) | 3.10.2024 |
| SentinelList | [Repository](https://github.com/rhinestonewtf/sentinellist) | Ackee | [Report](https://github.com/rhinestonewtf/sentinellist/blob/main/audits/ackee-blockchain-rhinestone-core-modules-report.pdf) | 3.10.2024 |
| CheckNSignatures | [Repository](https://github.com/rhinestonewtf/checknsignatures) | Ackee | [Report](https://github.com/rhinestonewtf/checknsignatures/blob/main/audits/ackee-blockchain-rhinestone-core-modules-report.pdf) | 3.10.2024 |
| SmartSessions | [Repository](https://github.com/erc7579/smartsessions) | Cantina | [Report](https://github.com/erc7579/smartsessions/blob/main/audits/report-cantinacode-rhinestone-0826-core.pdf) | 8.10.2024 |
| SmartSessions | [Repository](https://github.com/erc7579/smartsessions) | Cantina | [Report](https://github.com/erc7579/smartsessions/blob/main/audits/report-cantinacode-rhinestone-0826-external-policies.pdf) | 8.10.2024 |
| SmartSessions | [Repository](https://github.com/erc7579/smartsessions) | Renascence | [Report](https://github.com/erc7579/smartsessions/blob/main/audits/rhinestone_smartsessions_v1.pdf) | 9.11.2024 |
| SmartSessions | [Repository](https://github.com/erc7579/smartsessions) | Renascence | [Report](https://github.com/erc7579/smartsessions/blob/main/audits/rhinestone_smartsessions_update.pdf) | 25.12.2024 |
| Safe7579 | [Repository](https://github.com/rhinestonewtf/safe7579) | Chainlight | [Report](https://github.com/rhinestonewtf/safe7579/blob/main/audits/%5BChainLight%5D%20Rhinestone%20Safe7579%20Security%20Audit%20v1.1.pdf) | 15.04.2025 |
| SmartSessions | [Repository](https://github.com/erc7579/smartsessions) | Chainlight | [Report](https://github.com/erc7579/smartsessions/blob/main/audits/%5BChainLight%5D%20Rhinestone%20SmartSessions%20Security%20Audit%20v1.0.pdf) | 30.07.2025 |
| WebAuthnValidator | [Repository](https://github.com/rhinestonewtf/core-modules) | Chainlight | [Report](https://github.com/rhinestonewtf/core-modules/blob/main/audits/ChainLight_Rhinestone_WebAuthnValidator_Security_Audit_v1_0.pdf) | 30.07.2025 |
| Nexus | [Repository](https://github.com/rhinestonewtf/nexus/) | Chainlight | [Report](https://github.com/rhinestonewtf/nexus/blob/main/audits/%5BChainLight%5D%20Rhinestone%20Nexus%20Security%20Audit%20v1.0.pdf) | 30.07.2025 |
# Changelog
Source: https://docs.rhinestone.dev/home/resources/changelog
Major releases and updates to the Rhinestone SDK and API.
Major releases and updates to the Smart Wallet SDK and Intents API. Filter by tag, or subscribe via the RSS button.
## Smart Wallet SDK v2
A ground-up rework of the SDK, aligned with the [Blanc API](/intents/guides/api-versioning).
* **Non-EVM destinations**: settle intents to Solana and Tron.
* **Multi-route quotes**: `prepareTransaction` returns `quotes: { best, all }`; sign the recommended route or pick your own.
* **Simplified intent lifecycle**: `IntentStatus` reduces to `PENDING`, `COMPLETED`, and `FAILED`, with a flat per-chain `operations[]` shape.
* **Settlement control**: include or exclude specific bridges per intent.
[Migrating from 1.x](/smart-wallet/advanced/migration-guide#migrating-from-1-x-sdk)
## Solana and Tron destinations
Intents can now settle to Solana and Tron, alongside every supported EVM chain.
[Non-EVM destinations](/intents/features/non-evm-destinations)
## Blanc API
A major revision of the Intents API, and the recommended version for new integrations. Pin it with `x-api-version: 2026-04.blanc`.
* **Non-EVM destinations**: settle intents to Solana and Tron.
* **Destination executions on more layers**: run arbitrary calldata on the destination chain after the bridge delivers funds, now across all supported settlement layers.
* **Multi-route quotes**: `POST /quotes` returns a flat, server-ranked `routes[]`.
* **Improved cost reporting**: one `cost` object per route with `input`, `output`, and a `fees` breakdown, replacing the scattered per-field costs.
* **Server-stored intents**: quote returns an `intentId`; submit signatures by id, no more round-tripping the full `intentOp`.
* **Server-provided EIP-712**: forward `signData` directly to `wallet.signTypedData()` instead of reconstructing types client-side.
The previous `2026-01.alps` version is deprecated and will be sunset in the future.
[API versioning and migration guide](/intents/guides/api-versioning)
# ERC-7579
Source: https://docs.rhinestone.dev/home/resources/erc-7579
ERC-7579 outlines the minimally required interfaces and behavior for modular smart accounts and modules to ensure interoperability across implementations. The standard is designed to allow account builders to continue to innovate and compete on the account level, while also enabling third-party developers to build modules that can be used seamlessly across different accounts.
Our approach to the standard is the following:
* Take learnings from existing smart accounts that have been used in production and from building interoperability layers between them
* Ensure that the interfaces are as minimal and open to alternative architectures as possible
The standard is co-authored by ZeroDev, Biconomy, Rhinestone and OKX and is currently being rolled out across the ecosystem.
To find out more about ERC-7579, check out the links below:
* [ERC-7579](https://eips.ethereum.org/EIPS/eip-7579)
* [Website](https://erc7579.com/)
* [Github organization](https://github.com/erc7579)
* [Blog: Introducing: ERC-7579](https://blog.rhinestone.wtf/introducing-erc-7579-417084d7a66f)
* [Blog: ERC-7579 changes](https://blog.rhinestone.wtf/erc-7579-changes-c49259d07356)
# Fees
Source: https://docs.rhinestone.dev/home/resources/fees
An overview of the fees in Rhinestone’s system
Rhinestone’s core onchain infrastructure and SDKs are **open source and free**.
The transaction infrastructure has no API fee. The stakeholders in the transaction supply chain only make money once your users are transacting. These fees are transaction fees on the principal value of a crosschain transaction.
**Same chain intents are free**.
Solvers are incentivized to fill these transactions for two reasons:
* The solver takes a small premium on gas (see below).
* After performing same-chain intents, the solver's capital is not locked in a settlement layer. It is instantly returned to the solver on the same chain. Therefore, there is no loss in capital efficiency from filling same-chain intents.
**Crosschain fees**
Transaction fees are only charged on crosschain transfers and are currently set to 3bps for all supported chains during our private beta.
**Gas Fees**
Gas fees are a fixed unit cost that depends on the source chain and target chain executions required to perform the intent.
The client supplies gas units. SDK helpers for the estimation are coming soon.
# Modules explained
Source: https://docs.rhinestone.dev/home/resources/modules
Modules are smart contracts, usually singletons, that extend the functionality of smart accounts. Modular smart accounts allow users and developers to easily change how a smart account works. In order to understand more deeply what is possible with modules (spoiler: a lot), let’s quickly recap the transaction flow of a smart account.
## ERC-4337 UserOperation flow
While there are many different ways of implementing Account Abstraction on Ethereum, Rhinestone is built on top of ERC-4337, which is a standard for implementing Account Abstraction using an alternative mempool and without requiring protocol changes. If you want to read up more about this ERC, view the specs here and an excellent blog post here.
In order to achieve Account Abstraction, ERC-4337 specifies a new type of transaction for this alternative mempool, termed a UserOperation. On a high level, this UserOperation goes through two distinct phases: validation and execution.
### Validation Phase
During the validation phase, the ERC-4337 Entrypoint calls the `validateUserOp` function on the smart account in order for it to determine whether a UserOperation is valid and should be executed. If this function returns 0, then the Entrypoint will consider it valid, if it returns 1 or reverts then the Entrypoint will halt execution there and move on to the next UserOperation.
How exactly signature validation occurs is entirely up to the developer to decide, which enables the possibility of modular validation logic that can be added and replaced at any point.
ERC-4337 places some storage and opcode restrictions on accounts during the validation phase, which impacts how
validation modules can be built. Read more about these below.
### Execution Phase
If the validation phase was successful, then the Entrypoint will call the smart account again, this time with the calldata provided in the UserOperation. While the Entrypoint always calls the `validateUserOp` function during the validation phase, there is no such restriction during execution, meaning that accounts can implement any number of execution functions and the wallet client can choose which exact one to call depending on the transaction intent.
Similar to the validation phase, ERC-4337 does not stipulate how execution occurs, enabling the possibility to build modules for the execution phase.
## Module types
ERC-4337 breaks down the flow of a UserOperation into two distinct phases, validation and execution. The account functionality that is required in either of these two phases differs to that of the other, so there are at least two different types of modules: validators and executors. However, there are at least two more distinct types of modules that perform different functions to the aforementioned: hooks and fallback modules.
### Validators
Validators are modules that are called during the validation phase of a UserOperation. This means that their primary function is to verify the signature of a UserOperation and determine whether it is valid and should be executed. As a result, validators are the primary mechanism for enforcing access control on a smart account and are highly security critical.
### Executors
Executors are modules that are called during the execution phase of a UserOperation. They extend the execution logic of the account and thus allow for a more diverse set of actions that the account can natively perform. On top of this, Executors can also be used to automate certain actions that are triggered outside of the regular ERC-4337 execution flow on the account. For example, a user could set up an executor that automatically swaps their tokens when the price of a token reaches a certain threshold.
**Conditional Executors**
Conditional executors extend the capabilities of normal executors, by allowing transactions to be executed based on checks performed by the ComposableConditionManager. This allows for more complex logic to be implemented in executors, such as the ability to execute transactions based on the price of a token or the current gas price. Importantly, these conditions are verified on-chain meaning that a user can be sure that the transaction will be executed only if the condition is met and does not need to trust a third party.
### Hooks
Hooks are modules that are triggered either before or after execution and can be used to enforce certain behavior. Some examples of hooks include spending limits, restricting tokens that can be transferred, ensuring that contracts that are interacted with are audited, and more.
### Fallbacks
Fallbacks are modules that can add additional functionality into the account. They are called by the fallback function of the account, meaning that they are triggered when a transaction is sent to the account that does not match any of the existing functions on the account. Fallbacks can, for example, implement logic for receiving tokens, allow smart accounts to be used for flashloans or potentially even as a paymaster for sub-accounts.
# Quick reference
Source: https://docs.rhinestone.dev/home/resources/quick-reference
Key addresses, endpoints, chain IDs, and common error codes for quick lookup.
## API endpoints
| Service | Base URL |
| --------------- | ---------------------------------------------------------- |
| Orchestrator | `https://v1.orchestrator.rhinestone.dev` |
| Deposit Service | `https://v1.orchestrator.rhinestone.dev/deposit-processor` |
Authentication: pass your API key in the `x-api-key` header. [Request a key](https://tally.so/r/wg22x4). Pin a version with `x-api-version: 2026-04.blanc` — see [API versioning](/intents/guides/api-versioning).
## Key endpoints
| Endpoint | Method | Purpose |
| ------------------------------- | ------ | --------------------------------------- |
| `/quotes` | POST | Get a quote for an intent |
| `/intents` | POST | Submit a signed intent |
| `/intents/{id}` | GET | Track intent status |
| `/accounts/{address}/portfolio` | GET | Fetch user token balances across chains |
## Supported chains
See [Supported chains](/home/resources/supported-chains) for the full list with supported tokens.
## Account types
| Type | Description | SDK config |
| ----------------------- | ---------------------------------------------- | ------------------------------------------------ |
| Smart Account (default) | ERC-7579 modular account (Safe, Kernel, Nexus) | `accountType: 'safe'` (or `'kernel'`, `'nexus'`) |
| Plain EOA | Standard externally owned account | `accountType: 'eoa'` |
| Smart EOA (EIP-7702) | EOA with smart account capabilities | `accountType: 'eip7702'` |
## Transaction path
The default transaction path is **Warp** (native relay). ERC-4337 userops are opt-in.
| Scenario | Path |
| ----------------------------------- | ---------------------------------------------------- |
| Crosschain, built-in swap, bridging | Warp (default) |
| Same-chain, ERC-4337 bundler needed | Use `sendUserOperation` instead of `sendTransaction` |
## Common error codes
All non-2xx responses share the envelope `{ code, message, traceId, details? }`. Switch on `code`.
| Code | HTTP | Meaning | Fix |
| ------------------------ | ---- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `VALIDATION_ERROR` | 400 | Request didn't validate (bad chain id, missing version header, unsupported token, etc.) | Inspect `details[]` and correct the payload |
| `UNAUTHORIZED` | 401 | API key missing or invalid | Pass your key in `x-api-key` |
| `NOT_FOUND` | 404 | Quote TTL elapsed before submit | Re-quote and re-sign — don't retry the submit |
| `INSUFFICIENT_LIQUIDITY` | 422 | Account can't cover the intent given gas and fees | Check balance; see `details.availableIntents` for fallbacks |
| `TOO_MANY_REQUESTS` | 429 | Rate limited | Honour `Retry-After` |
See [Error handling](/intents/guides/error-handling) and the [versioning guide](/intents/guides/api-versioning) for the full list.
## Intent status values
| Status | Meaning |
| ----------- | ----------------------------------------------------------------- |
| `PENDING` | Submitted and in progress |
| `CLAIMED` | Source funds claimed by the solver, destination execution pending |
| `FILLED` | Executed on the destination chain, source funds not yet claimed |
| `COMPLETED` | Fully executed and settled onchain |
| `FAILED` | Execution failed |
| `EXPIRED` | Missed the execution deadline |
See [Tracking intents](/intents/guides/tracking-intents) for more details on the lifecycle.
## SDK install
```bash theme={null}
npm install @rhinestone/sdk viem
```
## Useful links
* [Dashboard](https://dashboard.rhinestone.dev) for API keys and sponsorship config
* [GitHub examples](https://github.com/rhinestonewtf/e2e-examples/) for runnable code
* [Contract addresses](/home/resources/address-book) for all deployments
# Supported chains
Source: https://docs.rhinestone.dev/home/resources/supported-chains
Learn what chains our services are deployed on
See the [Address Book](/home/resources/address-book) for the contract and
module addresses deployed on these chains.
# JWT authentication
Source: https://docs.rhinestone.dev/intents/configuration/jwt-authentication
Authenticate the SDK with JWTs instead of API keys
The SDK supports two authentication modes: **API key** and **JWT**. API keys are the default and easiest way to get started. JWTs are an alternative for integrators that need finer-grained control over token lifetime, key rotation, or per-request sponsorship policies.
JWT authentication is **experimental**. The config option is prefixed with `experimental_` and the API may change in future versions.
## Why JWTs
JWTs are short-lived, asymmetrically-signed tokens (RS256 or ES256) issued by your backend. Compared to a long-lived API key, they let you:
* **Cut latency and backend code.** Clients hit Rhinestone directly with a bounded access token instead of round-tripping through your server for every SDK call. For sponsored intents, a separate extension token binds your approval to the exact payload — so you stay in control per intent without proxying the submission.
* **Bound the blast radius of a leaked credential.** Access tokens expire on a TTL you choose. An API key stays valid until you notice and rotate it.
* **Rotate signing keys without downtime.** Register a new `kid`, start minting tokens with it, and tokens signed under the old `kid` keep verifying until they expire. No coordinated client deploy, no revocation race.
For unsponsored intents, the difference in hops looks like this:
```mermaid actions={false} theme={null}
sequenceDiagram
participant C as Client
participant S as Your server
participant R as Rhinestone
Note over C,R: API key
C->>S: get quote
S->>R: get quote (+ API key)
R-->>S: quote
S-->>C: quote
C->>S: submit signed intent
S->>R: submit signed intent (+ API key)
R-->>S: result
S-->>C: result
Note over C,R: JWT
C->>S: fetch access token (once per TTL)
S-->>C: token
C->>R: get quote (+ token)
R-->>C: quote
C->>R: submit signed intent (+ token)
R-->>C: result
```
## Dashboard setup
Before you can issue JWTs, you need to register a signing key with Rhinestone. See [JWT keys](/dashboard/jwt-keys) for how to generate or upload a key in the Dashboard. The **Integrator ID** you set there becomes the `iss` claim, and the **Key ID** becomes the `kid` header — both referenced in [Configuration](#configuration) below.
## Configuration
Signing a JWT requires five values. Three come from the Dashboard; the other two are free-form labels you pick yourself.
| Field | Where from | JWT claim |
| -------------- | ------------------------------------------------------------------------ | --------------------- |
| `privateKey` | Downloaded once when the key was generated (or supplied at registration) | n/a — signs the token |
| `integratorId` | Dashboard — set when registering the key | `iss` |
| `keyId` | Dashboard — set when registering the key | `kid` (JOSE header) |
| `projectId` | Dashboard — shown on the project overview | `sub` |
| `appId` | You choose | `app_id` |
**`integratorId`, `keyId`, and `projectId`** are verified server-side. The access token's `(iss, kid)` must resolve to a registered key, and the token's `sub` must equal the project that key was registered against. Mismatches produce a `401` or `403` at verification time — see [Troubleshooting](#troubleshooting).
**`appId`** is a free-form environment/app label — typically `prod`, `staging`, etc. It isn't registered anywhere. The only server-side check is that the access token's `app_id` matches the accompanying intent-extension token's `app_id`, so a sponsorship approval issued for one deployment can't be spent by another. Use it to correlate logs or split rate limits per deployment; if you don't need that, use the same value everywhere.
`keyId` and `appId` are independent axes. `keyId` rotates on **key rotation** (same deployment, new signing key). `appId` rotates on **environment changes** (same key, new deployment). You can reuse one signing key across multiple environments, or rotate keys within a single environment.
## SDK usage
There are two integration patterns depending on where the signing key lives.
When the SDK runs on the client (browser, mobile) and a separate backend holds the private key, fetch tokens from your backend over HTTP:
```ts theme={null}
const rhinestone = new RhinestoneSDK({
auth: {
mode: 'experimental_jwt',
accessToken: async () => {
const res = await fetch('/api/auth/access-token')
const { token } = await res.json()
return token
},
// Only required for sponsored intents:
getIntentExtensionToken: async (intentInput) => {
const res = await fetch('/api/auth/extension-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intentInput }),
})
const { token } = await res.json()
return token
},
},
})
```
Your backend is responsible for issuing the two token types. See [Sponsorship signing server](#sponsorship-signing-server) for a drop-in implementation.
When the SDK runs server-side with direct access to the private key, use `createJwtSigner` to sign tokens in-process without an HTTP round-trip:
```ts theme={null}
import { createJwtSigner } from '@rhinestone/sdk/jwt-server'
const signer = createJwtSigner({
jwt: {
privateKey: myJwk,
integratorId: 'int_abc',
projectId: 'proj_xyz',
appId: 'app_prod',
keyId: 'key_1',
},
})
const rhinestone = new RhinestoneSDK({
auth: { mode: 'experimental_jwt', ...signer },
})
```
`createJwtSigner` returns `{ accessToken, getIntentExtensionToken }` — the two callbacks the `auth` config expects, so you can spread them in next to the `mode` field.
## Sponsorship signing server
For the client-server pattern, your backend needs two endpoints: one that issues short-lived access tokens, and one that signs an intent extension token for each sponsored intent. The SDK ships ready-made handlers for both.
### Web standard handlers
The Web Standard handlers accept a `Request` and return a `Response`. They work with Next.js App Router, Hono, SvelteKit, Remix, Deno, Bun, and Cloudflare Workers:
```ts theme={null}
import {
createAccessTokenHandler,
createExtensionTokenHandler,
} from '@rhinestone/sdk/jwt-server'
const config = {
jwt: {
privateKey: myJwk,
integratorId: 'int_abc',
projectId: 'proj_xyz',
appId: 'app_prod',
keyId: 'key_1',
},
}
export const GET = createAccessTokenHandler(config)
export const POST = createExtensionTokenHandler(config)
```
### Express
For Express, use `createExpressRouter`. It mounts `GET /access-token` and `POST /extension-token`:
```ts theme={null}
import express from 'express'
import { createExpressRouter } from '@rhinestone/sdk/jwt-server'
const app = express()
app.use(express.json())
app.use('/api/auth', createExpressRouter(config))
```
### Custom sponsorship policy
By default the signer sponsors any intent your users submit. To restrict this, pass a `shouldSponsor` filter. Each predicate can be sync or async, and omitted predicates default to `true`. Filters are AND-composed — the intent must pass all of them to be signed:
```ts theme={null}
import { createJwtSigner } from '@rhinestone/sdk/jwt-server'
import { base, optimism } from 'viem/chains'
const signer = createJwtSigner({
jwt: { privateKey, integratorId, projectId, appId, keyId },
shouldSponsor: {
chain: ({ id }) => [base.id, optimism.id].includes(id),
account: async (address) => await isKnownUser(address),
calls: (calls) => calls.every((c) => ALLOWED_CONTRACTS.has(c.to)),
},
})
```
The same `shouldSponsor` config can be passed to the handler factories (`createAccessTokenHandler`, `createExtensionTokenHandler`, `createExpressRouter`).
Denied requests throw a `SponsorshipDeniedError`, which the handlers surface as a `403`. When calling the signer directly, the error is `instanceof`-checkable:
```ts theme={null}
import { SponsorshipDeniedError } from '@rhinestone/sdk/jwt-server'
try {
await signer.getIntentExtensionToken(intentInput)
} catch (error) {
if (error instanceof SponsorshipDeniedError) {
// intent was rejected by your policy
}
throw error
}
```
## Troubleshooting
Auth failures surface as HTTP 4xx responses with a `message` field. The common ones:
| Status | Message | Cause | Fix |
| ------ | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 401 | `Unknown integrator key: iss=, kid=` | No registered key matches the `(iss, kid)` pair in the token | Confirm `integratorId` and `keyId` exactly match the values shown in the Dashboard for this key |
| 401 | `access_token verification failed: ...` | Signature invalid, token expired, or wrong audience | Check that the private key matches the registered public key, that the system clock isn't skewed, and that the TTL isn't too short |
| 401 | `Invalid token type: expected "access", got "intent_extension"` | Extension token sent in the `Authorization` header (or vice versa) | Send the access token as `Authorization: Bearer`; send the extension token as `X-Intent-Extension: Bearer` |
| 403 | `access_token sub does not match the project bound to signing key` | `projectId` doesn't match the project this key was registered against | Copy the project ID from the Dashboard project page into your signer config |
| 403 | intent-extension binding failure | `iss` / `sub` / `app_id` differ between the access token and the extension token, or the `jti` has already been spent | Sign both tokens from the same config; mint a fresh extension token (new `jti`) when retrying a failed submit |
If you're hitting a 401 seconds after registering or rotating a key, the verification cache may briefly hold the old state — wait a minute and retry, or register under a fresh `kid`.
# Execute crosschain calls
Source: https://docs.rhinestone.dev/intents/features/execute-crosschain-calls
Execute any onchain action on a destination chain, with or without bridging tokens.
Warp supports two patterns for crosschain execution:
1. **Bridge and execute**: transfer tokens to a destination chain and run arbitrary calls there in the same intent.
2. **Gas relay**: execute calls on a destination chain without bridging any tokens. The relayer fronts gas on the destination and takes repayment from the user's existing balance on the source chain.
## Bridge and execute
Use this when the destination calls need tokens on that chain — for example, depositing into a vault, buying an NFT, or performing a swap.
Provide `tokenRequests` for the tokens needed on the destination, and `calls` for the actions to execute there. Warp bridges the tokens and executes the calls in a single intent.
```ts theme={null}
import { rhinestone } from '@rhinestone/sdk'
import { base, arbitrum } from 'viem/chains'
import { encodeFunctionData, parseUnits } from 'viem'
const usdcArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
const usdcAmount = parseUnits('100', 6)
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
tokenRequests: [
{
address: usdcArbitrum,
amount: usdcAmount,
},
],
calls: [
// Approve vault to spend USDC
{
to: usdcArbitrum,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [VAULT_ADDRESS, usdcAmount],
}),
},
// Deposit into vault
{
to: VAULT_ADDRESS,
data: encodeFunctionData({
abi: vaultAbi,
functionName: 'deposit',
args: [usdcAmount, rhinestoneAccount.getAddress()],
}),
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
```
The `calls` execute in the context of the user's account on the destination chain. If the vault returns receipt tokens, they are already credited to the account — no additional transfer is needed.
## Gas relay
Use this when the user wants to execute a transaction on another chain but doesn't need to bridge tokens. The user might have ETH or USDC on Base and want to call a contract on Arbitrum without holding any gas on Arbitrum.
The relayer fronts gas on the destination chain and claims repayment from the user's balance on the source chain. No `tokenRequests` needed — the repayment token is chosen automatically.
```ts theme={null}
import { arbitrum } from 'viem/chains'
const transaction = await rhinestoneAccount.prepareTransaction({
targetChain: arbitrum,
calls: [
{
to: CONTRACT_ADDRESS,
data: encodeFunctionData({
abi: contractAbi,
functionName: 'someFunction',
args: [arg1, arg2],
}),
},
],
})
```
To restrict which chain the gas repayment is taken from, provide `sourceChains`:
```ts theme={null}
import { base, arbitrum } from 'viem/chains'
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
{
to: CONTRACT_ADDRESS,
data: encodeFunctionData({
abi: contractAbi,
functionName: 'someFunction',
args: [arg1, arg2],
}),
},
],
})
```
## Source calls
You can also run executions on the source side, *before* the claim. Source calls are bundled into the intent at routing time and covered by the user's mandate signature.
Use them for pre-bridge actions on the source chain — ERC-20 approvals, ETH→WETH wraps, unstaking, or pulling funds out of a vault you need to spend.
```ts theme={null}
import { base, arbitrum } from 'viem/chains'
import { encodeFunctionData, parseUnits } from 'viem'
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
tokenRequests: [
{ address: usdcArbitrum, amount: parseUnits('100', 6) },
],
sourceCalls: {
[base.id]: [
{
to: USDC_BASE,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [VAULT_BASE, parseUnits('100', 6)],
}),
},
],
},
})
```
### Declaring tokens the source call provides
If the source call hands over tokens (an unwrap, an unstake, withdrawing from a vault), declare them via `provides`. Routing then treats those tokens as available, the same way `auxiliaryFunds` does:
```ts {6-8} theme={null}
sourceCalls: {
[base.id]: [
{
to: VAULT_BASE,
data: encodeFunctionData({ abi: vaultAbi, functionName: 'withdraw', args: [shares] }),
provides: [
{ token: USDC_BASE, amount: parseUnits('100', 6) },
],
},
],
},
```
### Caveats
* Source calls only fire when the orchestrator creates an element on that chain. Sponsored or no-op fills with no source movement skip the source element entirely, and the `sourceCalls` keyed on that chain are silently dropped.
* The chain id must appear in `sourceChains` (cross-chain intents) or equal the same-chain `chain`.
* For same-chain intents, source calls run before the destination `calls` on the same chain.
## Next steps
Let users spend their full cross-chain balance in a single intent.
Combine tokens from multiple chains into one destination transaction.
# Non-EVM destinations
Source: https://docs.rhinestone.dev/intents/features/non-evm-destinations
Send intents to Solana and Tron destination chains.
Warp intents can settle on Solana and Tron, alongside every supported EVM chain. The source side stays the same — origin chains, signing, and submission are unchanged. What differs is the destination shape: the recipient is a base58 or T-prefix string, token addresses are SPL mints or TRC-20 contracts, and there are no destination executions.
## What changes vs. EVM destinations
| | EVM destination | Non-EVM destination |
| ------------------------- | ------------------------------ | -------------------------------------- |
| `targetChain` | viem `Chain` | `solanaMainnet` / `tronMainnet` |
| `recipient` | `Address` | base58 (Solana) / Tron T-prefix string |
| `tokenRequests[].address` | EVM `Address` or `TokenSymbol` | SPL mint / TRC-20 contract string |
| Destination `calls` | supported | not supported (claims only) |
| UserOp path | supported | not supported |
| `fillTransactionHash` | `Hex` | `string` (base58 / Tron hex) |
## Example: USDC from Base to Solana
```ts theme={null}
import { solanaMainnet } from '@rhinestone/sdk'
import { base } from 'viem/chains'
const SOLANA_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
const recipient = 'mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN'
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: solanaMainnet,
recipient,
tokenRequests: [
{
address: SOLANA_USDC,
amount: 5_000_000n, // 5 USDC, 6 decimals
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const result = await rhinestoneAccount.submitTransaction(signed)
const status = await rhinestoneAccount.waitForExecution(result)
```
Swap `solanaMainnet` for `tronMainnet` and pass a Tron T-prefix recipient and a TRC-20 contract address to bridge to Tron instead.
## Constraints
* **No destination calls.** Solana and Tron destinations are claim-only — you can't pass a `calls` array on a non-EVM intent. If your flow needs onchain logic on the destination, route to an EVM chain.
* **No UserOp path.** `sendUserOperation` and the UserOp transaction shape remain EVM-only.
* **No destination-side signing.** Smart-session destination signatures are skipped on non-EVM chains — there's no validator there to verify them. Origin signing is unchanged.
* **EIP-7702 authorization is a no-op** on non-EVM destinations.
* **`fillTransactionHash` is a plain string.** Was `Hex` on EVM destinations; widened so base58 and Tron hex round-trip cleanly.
## Tracking the intent
`waitForExecution` and `getIntentStatus` work the same way as for EVM destinations. Inspect `status.operations[]` for per-chain transaction hashes — the destination entry's `fillTransactionHash` will be a base58 (Solana) or hex (Tron) string.
# API versioning
Source: https://docs.rhinestone.dev/intents/guides/api-versioning
How the Rhinestone API evolves, how to pin a version, and how to migrate between them.
The Rhinestone API is versioned. Pin a version via the `x-api-version` header and upgrade on your own schedule — new versions are additive and opt-in.
## Why versioning
The API evolves as Rhinestone adds chains, settlement layers, and new intent patterns. Versioning lets us ship those changes without forcing every integrator to migrate in lockstep.
We use dated versions (`YYYY-MM.name`) rather than semver-style major versions. Semver majors tend to hoard changes into rare, big-bang releases — each bump looks like a migration project, so teams avoid shipping them. Dated versions normalise small, frequent bumps, so each one carries a small diff.
The `.name` suffix gives each release a human handle. We name versions after mountains, one per letter — `alps`, `blanc`, `corno`, and so on. It's easier to say "switch to blanc" than "switch to 2026-04".
## Pinning a version
Pin a version by sending `x-api-version` on every request:
```ts {6} theme={null}
const res = await fetch("https://v1.orchestrator.rhinestone.dev/quotes", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
body: JSON.stringify(payload),
});
```
The format is `YYYY-MM.name`. Always send the header — requests without it fall back to the deprecated `2026-01.alps` shape, and unknown or malformed values return `400 VALIDATION_ERROR`.
## Compatibility
### What we can change without a new version
Within a version, we treat the response schema as open. These changes ship freely:
* New optional request fields
* New response fields
* New enum variants in responses (e.g. a new `settlementLayer`)
* New endpoints
* Widened request validation
* Changed default values
### What your client must do
To consume those changes safely:
* **Ignore unknown fields.** If you use a strict JSON parser (Go's `DisallowUnknownFields`, Rust `serde(deny_unknown_fields)`, Jackson's `FAIL_ON_UNKNOWN_PROPERTIES`), disable it for our responses.
* **Handle unknown enum values gracefully.** A `switch` on `settlementLayer` that throws on default will break the day we add a new one. Treat unknown values as "not supported by this client" and fall back.
* **Don't reconstruct EIP-712 types client-side.** Forward the server's typed data verbatim to `wallet.signTypedData()`. Hand-rolled type libraries break on additive schema changes.
* **Treat quote 404s as normal.** Quoted intents are stored server-side with a short TTL. If a quote expires or the quote store restarts, you'll see 404 on submit. Re-quote — don't retry the submit.
* **Take `routes[0]` unless you have your own criteria.** The array is server-ranked by a cost/speed tradeoff. Re-sorting client-side usually degrades route quality.
### What triggers a new version
* Removing or renaming any field
* Adding a required request field
* Changing a field's type
* Restructuring request or response shape
* Narrowing request validation
* Making a required response field optional or nullable
* Removing an endpoint
### Deprecation lifecycle
Fields deprecated in version N remain present and documented as deprecated; they're removed in N+1. Versions themselves are not routinely deprecated — each version is intended to stay live indefinitely so you never have to migrate on our clock.
The one exception is `2026-01.alps`, which will be sunset after integrators migrate to `blanc`. The shape difference between the two is large enough that maintaining both long-term isn't practical. Future version transitions will follow the standard per-field deprecation rule.
## Versions
### `2026-04.blanc`
The current version. Start here if you're integrating from scratch.
**What changed:**
* **Flat `routes[]` array.** Each route carries its own `intentId`, `cost`, `signData`, and `tokenRequirements` (EOAs only — smart accounts handle approvals internally). Pre-ranked; `routes[0]` is recommended.
* **CAIP-2 chain ids.** Every chain id on the wire is now an `eip155:` string — `1` becomes `"eip155:1"`. Affects scalar fields, arrays, and chain-keyed maps. Path parameters are unchanged.
* **EIP-712 typed data in the response.** Forward `signData.origin[]` and `signData.destination` directly to `wallet.signTypedData()`. No more client-side type reconstruction.
* **Server-stored intents.** `POST /quotes` returns an `intentId`; the full intent stays server-side. Submit via `POST /intents` with `{ intentId, signatures }` — no more round-tripping `intentOp`.
* **Resource-style endpoints.** `POST /quotes` (was `/intents/route`), `POST /intents` (was `/intent-operations`), `GET /intents/:id` (was `/intent-operation/:id`), `POST /intents/splits` (was `/intents/split`).
* **Flattened cost structure.** One `cost` object per route with `input`, `output`, and `fees: { total, breakdown }`. Replaces scattered `tokensSpent` / `tokensReceived` / `feeBreakdownUSD` / `gasCost` / `sponsorFee`.
* **Submit response collapsed.** `POST /intents` returns just `{ intentId }` (was `{ result: { id, status } }`). The synthesised `status: "FAILED"` is gone — simulation failures now surface as 4xx with the unified error envelope.
* **Field renames.** Notable: `destinationGasUnits` → `destinationGasLimit`, `sponsorSettings.{gas,bridgeFees,swapFees}Sponsored` → `{gas,bridgeFees,swapFees}`, `userAddress` → `accountAddress`. Portfolio token shape uses `symbol` (was `tokenName`), `chains` (was `tokenChainBalance`), and per-chain `address`/`decimals` (were `tokenAddress`/`tokenDecimals`). Timestamps move from `BigIntLike` strings to integer Unix seconds.
* **Unified error envelope.** All non-2xx responses share `{ code, message, traceId, details? }`. Switch from message-pattern matching to `code`-based dispatch.
* **Schema trimming.** `Account.mockSignature` is gone — use the per-chain `Account.mockSignatures` map. `AccountType.SMART_ACCOUNT` is gone — use `ERC7579`. The `accountAccessList` flat-array variant is gone — use the structured shape.
* **Compact / resource locks removed.** No blanc equivalent for `topupCompact`, `emissaryConfig`, `MultichainCompact` origin signatures, or the `/withdrawals` endpoints.
#### Migrating from `alps`
**Quote and submit**
```ts theme={null}
// Before (alps) — client round-trips the full intentOp
const { intentOp } = await post("/intents/route", body);
const signatures = await sign(intentOp);
await post("/intent-operations", {
signedIntentOp: { ...intentOp, ...signatures },
});
// After (blanc) — server stores the intent, client submits signatures by id
const { routes } = await post("/quotes", body);
const { intentId, signData } = routes[0];
const signatures = {
origin: await Promise.all(signData.origin.map(signTypedData)),
destination: await signTypedData(signData.destination),
};
await post("/intents", { intentId, signatures });
```
**Signing**
```ts theme={null}
// Before (alps) — client hashes intent fields with hand-built EIP-712 types
const hash = getIntentHash(intentOp);
const sig = await signer.signTypedData(types, hash);
// After (blanc) — server provides the typed data directly
const sig = await signer.signTypedData(signData.origin[0]);
```
**Submit response**
```ts theme={null}
// Before (alps) — wrapped, with synthesised status
const { result } = await post("/intent-operations", body);
const id = result.id; // result.status was "PENDING" or synthesised "FAILED"
// After (blanc) — id only; simulation failures surface as 4xx
const { intentId } = await post("/intents", body);
```
**Status polling**
```ts theme={null}
// Before (alps)
const op = await get(`/intent-operation/${id}`);
const status = op.result.status;
// After (blanc)
const op = await get(`/intents/${intentId}`);
const status = op.status;
```
The status enum is narrower in blanc — legacy values that no longer apply are removed. `PRECONFIRMED` is gone; if your client branches on it, drop that case.
**Splits endpoint**
`POST /intents/split` → `POST /intents/splits`. Request and response shapes follow the same blanc-wide rules (CAIP-2 chain ids, unified error envelope).
**Chain ids**
CAIP-2 strings (`eip155:`) replace numeric chain ids on every field that carries one — `chainId`, `sourceChainId`, `destinationChainId`, `chainIds`, `allChainIds`, and chain-keyed maps like `auxiliaryFunds`, `preClaimExecutions`, `chainTokens`, and `tokenRequirements`. Path parameters are unchanged.
```ts theme={null}
// Before (alps)
{ "chainId": 42161 }
{ "tokenRequirements": { "1": [...], "42161": [...] } }
// After (blanc)
{ "chainId": "eip155:42161" }
{ "tokenRequirements": { "eip155:1": [...], "eip155:42161": [...] } }
```
The portfolio filter switches from comma-separated lists to repeated query parameters, with `tokens` keyed as `chain:address`:
```
GET /accounts/0xabc/portfolio
?chainIds=eip155:1&chainIds=eip155:137
&tokens=eip155:1:0xa0b...&tokens=eip155:137:0xa0b...
```
**Reading costs**
```ts theme={null}
// Before (alps)
const gasUSD = response.feeBreakdownUSD.gas;
const inputAmount = response.tokensSpent[0].amount;
// After (blanc)
const gasUSD = route.cost.fees.breakdown.gas.usd;
const inputAmount = route.cost.input[0].amount;
```
**Field renames**
| Before (alps) | After (blanc) |
| --------------------- | --------------------- |
| `userAddress` | `accountAddress` |
| `destinationGasUnits` | `destinationGasLimit` |
| `tokensSpent` | `cost.input` |
| `tokensReceived` | `cost.output` |
| `feeBreakdownUSD` | `cost.fees.breakdown` |
| `tokenName` | `symbol` |
| `tokenChainBalance` | `chains` |
| `tokenAddress` | `address` |
| `tokenDecimals` | `decimals` |
Timestamps move from `BigIntLike` strings (`"1700000000"`) to integer Unix seconds (`1700000000`).
**Sponsor settings**
```ts theme={null}
// Before (alps)
{
sponsorSettings: {
gasSponsored: true,
bridgeFeesSponsored: false,
swapFeesSponsored: false,
}
}
// After (blanc)
{
sponsorSettings: {
gas: true,
bridgeFees: false,
swapFees: false,
}
}
```
**Account types and mock signatures**
`AccountType.SMART_ACCOUNT` is gone — use `ERC7579`. The flat `accountAccessList` array shape is gone — use the structured shape. Mock signatures move from a single `Account.mockSignature` to a per-chain `Account.mockSignatures` map.
```ts theme={null}
// Before (alps)
{
account: {
accountType: "SMART_ACCOUNT",
mockSignature: "0x...",
}
}
// After (blanc)
{
account: {
accountType: "ERC7579",
mockSignatures: {
"eip155:1": "0x...",
"eip155:42161": "0x...",
},
}
}
```
**Error handling**
```ts theme={null}
// Before (alps) — message-pattern matching
if (err.message === "Insufficient balance") { /* ... */ }
if (err.message.startsWith("Unsupported chain ")) { /* ... */ }
// After (blanc) — code-based dispatch
switch (err.code) {
case "INSUFFICIENT_LIQUIDITY":
// err.details: { availableIntents, unfillable }
break;
case "VALIDATION_ERROR":
// err.details: [{ message, context? }]
break;
}
```
Honour `Retry-After` on `429 TOO_MANY_REQUESTS`.
### Error codes
| Code | HTTP |
| ---------------------------- | ---- |
| `VALIDATION_ERROR` | 400 |
| `UNAUTHORIZED` | 401 |
| `FORBIDDEN` | 403 |
| `NOT_FOUND` | 404 |
| `CONFLICT` | 409 |
| `UNPROCESSABLE_CONTENT` | 422 |
| `INSUFFICIENT_LIQUIDITY` | 422 |
| `TOO_MANY_REQUESTS` | 429 |
| `INTERNAL_ERROR` | 500 |
| `SETTLEMENT_QUOTE_ERROR` | 502 |
| `SETTLEMENT_EXECUTION_ERROR` | 502 |
| `RELAYER_MARKET_UNAVAILABLE` | 503 |
| `EXTERNAL_SERVICE_TIMEOUT` | 504 |
The list is extensible — add unknown codes to a generic fallback path.
**Things you can't migrate**
The Compact / resource-lock surface has no blanc equivalent. If your integration depends on `topupCompact`, `emissaryConfig`, the `/withdrawals` endpoints, or `MultichainCompact` origin signatures, stay on `alps` until a replacement ships.
### `2026-01.alps`
The original release. Intents are round-tripped through the client, signed via client-reconstructed EIP-712, and submitted with the full `intentOp` attached to `signedIntentOp`. The Compact / resource-lock surface (`topupCompact`, `emissaryConfig`, `/withdrawals`, `MultichainCompact` signatures) is only available on this version — stay on `alps` if you depend on it.
Deprecated. Will be removed once existing integrations migrate to `blanc`. No new features will land on this version.
# Error handling
Source: https://docs.rhinestone.dev/intents/guides/error-handling
Common Orchestrator errors and how to resolve them
Every non-2xx response shares the same shape:
```json theme={null}
{
"code": "INSUFFICIENT_LIQUIDITY",
"message": "...",
"traceId": "...",
"details": { /* optional, code-specific */ }
}
```
Switch on `code`, not `message`. The full code/HTTP table lives in the [versioning guide](./api-versioning#error-codes).
```ts theme={null}
switch (err.code) {
case "INSUFFICIENT_LIQUIDITY":
// err.details: { availableIntents, unfillable }
break;
case "VALIDATION_ERROR":
// err.details: [{ message, context? }]
break;
case "TOO_MANY_REQUESTS":
// honour the Retry-After header
break;
default:
// unknown codes — fall through to a generic error
break;
}
```
The list is extensible — always include a default fallback for codes you don't recognise.
The account balance can't cover the requested tokens together with gas and fees, or the cost to claim funds (e.g. on Ethereum mainnet) is too high to fill profitably.
`details` contains `availableIntents` (alternative routes you could fall back to) and `unfillable` (the routes that couldn't be filled and why).
Make sure the account is deployed and funded. For SDK users, the [spendable amount utility](/smart-wallet/chain-abstraction/source-token-amount) gives an estimate of how much can be spent.
The request payload didn't validate. Common causes:
* Unsupported `destinationChainId` — check the [supported chains](/home/resources/supported-chains).
* Unsupported token address on the destination chain.
* Mainnet chain ids on a development API key, or testnet chain ids on a production key.
* Malformed CAIP-2 chain ids — they must be `eip155:` strings.
* Missing or unknown `x-api-version` header.
`details` is an array of `{ message, context? }` entries.
The API key is missing or invalid. Pass it in `x-api-key`. [Reach out to us](https://t.me/konradkopp) if you need a key.
Make sure you're using a development API key for testnets and a production API key for mainnets.
`POST /intents` returns 404 when the `intentId` no longer exists server-side — typically because the quote TTL elapsed or the quote store was restarted.
Re-quote and re-sign. Don't retry the submit with the same `intentId`.
The signature didn't validate against the account. Either the typed data was modified before signing, or the signer isn't an authorised owner of the account.
Forward `signData.origin[]` and `signData.destination` verbatim to `signTypedData` — don't reconstruct the types client-side. For smart accounts, make sure the validator wraps the signature using its expected encoding.
Rate limited. Honour the `Retry-After` header before retrying.
# Getting a quote
Source: https://docs.rhinestone.dev/intents/guides/getting-a-quote
Request a Quote for an Intent
To get started with Warp, request a quote for a crosschain intent.
Use the `/quotes` endpoint. You'll need the **destination chain**, the **token** and **amount** on that chain, and the **account address**:
```ts Get Quote theme={null}
const baseUrl = "https://v1.orchestrator.rhinestone.dev";
const apiKey = process.env.RHINESTONE_API_KEY;
const EOA_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // your EOA address
const payload = {
account: {
address: EOA_ADDRESS,
accountType: "EOA",
},
destinationChainId: "eip155:8453",
tokenRequests: [
{
tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "5000000",
},
],
};
const res = await fetch(`${baseUrl}/quotes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
body: JSON.stringify(payload),
});
const { routes } = await res.json();
const route = routes[0];
```
Chain ids on every field are CAIP-2 strings (`eip155:`).
## Understanding the response
The response is `{ routes }`, an array pre-ranked by a cost/speed tradeoff. Pick `routes[0]` unless you have your own criteria.
Each route carries everything you need to sign and submit:
| Field | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `intentId` | Server-stored quote handle. Pass it to `POST /intents` to submit. |
| `cost` | Cost breakdown. See below. |
| `signData` | EIP-712 typed data to sign. `signData.origin[]` has one entry per source chain; `signData.destination` is the destination signature. |
| `tokenRequirements` | Approvals or ETH wrapping the user must complete before signing. EOAs only — smart accounts handle this automatically. |
### `cost`
The `cost` object on each route describes what the user pays:
| Field | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `input` | Tokens spent. Array of `{ chainId, tokenAddress, symbol, decimals, price, amount }`. |
| `output` | Tokens delivered to the recipient on the destination chain. Same shape as `input`. |
| `fees` | `{ total: { usd }, breakdown: { gas, swap, bridge } }`. Each breakdown entry has `{ usd, sponsored }` — `sponsored` reflects what was actually covered for this route, given the request's `sponsorSettings` and the project's sponsorship balance. |
```ts theme={null}
const inputAmount = route.cost.input[0].amount;
const gasUSD = route.cost.fees.breakdown.gas.usd;
const totalFeeUSD = route.cost.fees.total.usd;
```
## Choosing a different route
`routes[]` is server-ranked, so `routes[0]` is the default pick. If you want a different one — cheaper, faster, on a specific settlement layer — iterate `routes` and submit the chosen route's `intentId`. No extra parameter is needed; the API selects the quote by `intentId`.
```ts theme={null}
// Pick the fastest route under a $1 fee cap.
const fastest = routes
.filter((r) => r.cost.fees.total.usd <= 1)
.reduce((a, b) =>
a.estimatedFillTime.seconds <= b.estimatedFillTime.seconds ? a : b
);
await fetch(`${baseUrl}/intents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
body: JSON.stringify({
intentId: fastest.intentId,
signatures: { /* signed against fastest.signData — see the signing guide */ },
}),
});
```
Each route carries its own `signData`, so always sign against the route you intend to submit — signatures from one route won't verify against another.
## Executions (calls)
You can run executions on behalf of the EOA on the destination chain.
For EOAs, executions run in the context of an intermediary contract — not the user's account. If you receive any tokens (vault shares, swap output, etc.), include an explicit `transfer` call to send them back to the EOA.
```ts theme={null}
const payload = {
// …
destinationExecutions: [
// Deposit USDC to a vault
{
to: VAULT_CONTRACT,
value: "0",
data: encodeFunctionData({
abi: vaultAbi,
functionName: 'deposit',
args: [usdcAmount],
}),
},
// Send vault receipt token back to the EOA
{
to: USDC_VAULT_CONTRACT,
value: "0",
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [EOA_ADDRESS, tokenAmount],
}),
}
],
}
```
## Sponsorship
Mark the intent as sponsored (covering gas and bridging costs for the user) by setting `sponsorSettings`:
```ts {13-19} Sponsor the Intent theme={null}
const payload = {
destinationChainId: "eip155:8453",
tokenRequests: [
{
tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "5000000",
},
],
account: {
address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
accountType: "EOA",
},
options: {
sponsorSettings: {
gas: true,
bridgeFees: true,
swapFees: false,
},
},
};
```
## Custom fill deadline
An intent's on-chain fill deadline defaults to 2 minutes. For **same-chain intents that settle without bridging**, extend it with `options.customDeadline` — an absolute unix timestamp in seconds. Use it when the user needs longer to approve, or you're fetching a quote now to submit later.
```ts {5} Custom fill deadline theme={null}
const payload = {
// …
options: {
customDeadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
},
};
```
The value must be between `now + 120s` and `now + 86400s` (24 hours); outside that range the request is rejected with a `400`. When honored, the intent's fill deadline and its claim/nonce expiry extend to match.
`customDeadline` is honored only on same-chain intents that settle without bridging. On any other route — cross-chain, or a same-chain swap that requires funding — it is silently ignored and the default 2-minute deadline applies. The only signal is a nearer expiry on the returned quote.
## Source chain / token
Select which tokens and/or chains to use as the source of funds via `accountAccessList`.
To limit the source of funds to specific chains:
```ts Select Input Chains theme={null}
const payload = {
// …
accountAccessList: {
chainIds: ["eip155:10", "eip155:8453"],
},
};
```
To limit the source of funds to specific tokens:
```ts Select Input Tokens theme={null}
const payload = {
// …
accountAccessList: {
tokens: ["USDC", "0x4200000000000000000000000000000000000006"],
},
};
```
To limit the source of funds to specific chains *and* tokens:
```ts Select Input Chains and Tokens theme={null}
const payload = {
// …
accountAccessList: {
chainIds: ["eip155:10", "eip155:8453"],
tokens: ["USDC"],
},
};
```
or:
```ts Select Input Chains and Tokens theme={null}
const payload = {
// …
accountAccessList: {
chainTokens: {
"eip155:10": ["USDC"],
"eip155:8453": ["WETH"],
},
},
};
```
## Auxiliary funds
`auxiliaryFunds` declares balances that aren't visible to the orchestrator yet but will be available by the time the intent settles. Use it to quote ahead of an inflow — a pending CEX deposit, a vault withdrawal, an unstake, or any other balance that will arrive before fill.
```ts theme={null}
const payload = {
// …
auxiliaryFunds: {
"eip155:8453": {
"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913": "5000000",
},
},
};
```
Don't list funds the account already holds — the orchestrator picks those up automatically, and adding them via `auxiliaryFunds` double-counts the balance and inflates the input amount in the quote.
For tokens produced by a [source call](/intents/features/execute-crosschain-calls#source-calls) (a vault exit, unwrap, unstake), declare them via `provides` on the call itself — not here.
`auxiliaryFunds` and `accountAccessList` solve different problems: `accountAccessList` restricts which **existing** balances the router is allowed to spend, while `auxiliaryFunds` adds **expected** balances the router otherwise can't see. They can be combined.
## Swaps
If the destination token differs from the user's source token, Warp handles the bridge and swap automatically. No additional parameters are needed — just specify the token you want on the destination chain.
In a swap quote, `cost.input` and `cost.output` show different tokens:
```json theme={null}
{
"cost": {
"input": [
{
"chainId": "eip155:8453",
"tokenAddress": "0x4200000000000000000000000000000000000006",
"symbol": "WETH",
"decimals": 18,
"price": { "usd": 2412.37 },
"amount": "959454558006521"
}
],
"output": [
{
"chainId": "eip155:10",
"tokenAddress": "0x0b2c639c533813f4aa9d7837caf62653d097ff85",
"symbol": "USDC",
"decimals": 6,
"price": { "usd": 0.99 },
"amount": "2000000"
}
],
"fees": {
"total": { "usd": 0.31 },
"breakdown": {
"gas": { "usd": 0.30, "sponsored": false },
"swap": { "usd": 0.01, "sponsored": false },
"bridge": { "usd": 0.0004, "sponsored": false }
}
}
}
}
```
Here the user spends WETH on Base (`eip155:8453`) to receive USDC on Optimism. `output.amount` matches the requested amount exactly — fees are paid in input tokens, not deducted from the output. Compare `input.amount × input.price.usd` to `output.amount × output.price.usd` to see the cost.
## Next Steps
What the intent requirements are and how to fulfill them
# Installing the intent executor
Source: https://docs.rhinestone.dev/intents/guides/installing-intent-executor
Install the Intent Executor module on your ERC-7579 smart account to use Rhinestone Warp.
The Intent Executor is an ERC-7579 executor module that must be installed on your smart account before you can use Rhinestone's Warp infrastructure. It is the onchain component that allows the Rhinestone Orchestrator and its relayers to execute crosschain intents on behalf of your account.
Without it, the Orchestrator has no permission to execute transactions through your smart account — even if you have signed a valid intent offchain.
## Contract address
```
0x00000000005aD9ce1f5035FD62CA96CEf16AdAAF
```
Deployed on all chains supported by Rhinestone.
## Installation
The Intent Executor is a standard ERC-7579 executor module (module type `2`). Install it using the SDK's `installModule` action:
```ts theme={null}
import { installModule } from '@rhinestone/sdk/actions'
const transaction = await rhinestoneAccount.prepareTransaction({
targetChain: base,
calls: [
installModule({
address: '0x00000000005aD9ce1f5035FD62CA96CEf16AdAAF',
type: 'executor',
}),
],
})
```
Install the module on every chain where you want to use Rhinestone intents.
## Verifying installation
Read the `isInitialized` function on the Intent Executor contract to check whether it has been installed on your account:
```ts theme={null}
import { createPublicClient, http } from 'viem'
import { base } from 'viem/chains'
const publicClient = createPublicClient({
chain: base,
transport: http(),
})
const isInstalled = await publicClient.readContract({
address: '0x00000000005aD9ce1f5035FD62CA96CEf16AdAAF',
abi: [{
name: 'isInitialized',
type: 'function',
inputs: [{ name: 'smartAccount', type: 'address' }],
outputs: [{ name: '', type: 'bool' }],
stateMutability: 'view',
}],
functionName: 'isInitialized',
args: [accountAddress],
})
```
Returns `true` once the module has been installed on the account.
## Uninstalling
```ts theme={null}
import { uninstallModule } from '@rhinestone/sdk/actions'
const transaction = await rhinestoneAccount.prepareTransaction({
targetChain: base,
calls: [
uninstallModule({
address: '0x00000000005aD9ce1f5035FD62CA96CEf16AdAAF',
type: 'executor',
}),
],
})
```
## Next steps
Start sending intents with the Rhinestone API once the module is installed.
# Portfolio endpoint
Source: https://docs.rhinestone.dev/intents/guides/portfolio-endpoint
Fetch a user's aggregated token balances across all supported chains.
The portfolio endpoint returns a user's token balances across all chains Rhinestone supports. Use it to display a unified balance, check which tokens are available before creating an intent, or build balance-aware routing.
## Fetch a portfolio
Pass a user address and your API key to get aggregated balances across all chains:
```ts theme={null}
const baseUrl = "https://v1.orchestrator.rhinestone.dev";
const apiKey = process.env.RHINESTONE_API_KEY;
const accountAddress = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
const res = await fetch(
`${baseUrl}/accounts/${accountAddress}/portfolio?filterEmpty=true`,
{
headers: {
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
}
);
const { portfolio } = await res.json();
```
Each entry in `portfolio` is a token aggregated across chains: `{ symbol, chains: [{ chainId, address, decimals, amount }] }`. Amounts are raw (apply `decimals` to display).
```json theme={null}
{
"portfolio": [
{
"symbol": "USDC",
"chains": [
{
"chainId": "eip155:8453",
"address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"decimals": 6,
"amount": "500000"
}
]
}
]
}
```
## Filter by chain or token
To scope results to specific chains, pass `chainIds` as a repeated query parameter using CAIP-2 ids:
```ts theme={null}
const res = await fetch(
`${baseUrl}/accounts/${accountAddress}/portfolio?chainIds=eip155:10&chainIds=eip155:8453&filterEmpty=true`,
{
headers: {
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
}
);
```
To scope to specific tokens, pass `chain:address` pairs as repeated `tokens` parameters:
```ts theme={null}
const usdcOptimism = "eip155:10:0x0b2c639c533813f4aa9d7837caf62653d097ff85";
const usdcBase = "eip155:8453:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const res = await fetch(
`${baseUrl}/accounts/${accountAddress}/portfolio?tokens=${usdcOptimism}&tokens=${usdcBase}`,
{
headers: {
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
}
);
```
## Next steps
Use the portfolio to determine which tokens to include in a quote request.
# Signing the intent
Source: https://docs.rhinestone.dev/intents/guides/signing
Sign each entry in signData using EIP-712.
The quote response includes everything needed to sign. Forward `signData.origin[]` and `signData.destination` directly to your wallet's `signTypedData()` — don't reconstruct the EIP-712 types client-side.
## Origin signatures
`signData.origin` has one entry per source chain. Sign each entry in order. The signature index must match the entry index.
```ts theme={null}
const originSignatures = await Promise.all(
signData.origin.map((typedData) =>
walletClient.signTypedData(typedData),
),
);
```
## Destination signature
`signData.destination` is a single typed-data object. It's used when the recipient differs from the sender (a smart account receiving tokens, for example, or destination executions that touch the recipient's account).
```ts theme={null}
const destinationSignature = await walletClient.signTypedData(
signData.destination,
);
```
If the destination doesn't require a signature, this slot can still be sent — the orchestrator ignores it.
## Next steps
Submit the signed intent to the Orchestrator.
# Submitting the intent
Source: https://docs.rhinestone.dev/intents/guides/submitting-the-intent
Submit the signed intent to the Orchestrator
Once you've [signed](./signing) the intent, submit it to the Orchestrator for execution.
Use the `/intents` endpoint. Pass the `intentId` from the quote and the signatures you collected:
```ts theme={null}
const baseUrl = "https://v1.orchestrator.rhinestone.dev";
const apiKey = process.env.RHINESTONE_API_KEY;
const res = await fetch(`${baseUrl}/intents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
body: JSON.stringify({
intentId,
signatures: {
origin: originSignatures,
destination: destinationSignature,
},
}),
});
const { intentId: submittedId } = await res.json();
```
The response contains only the `intentId` — use it to poll for execution status.
If the quote has expired before you submit, `POST /intents` returns 404. Re-quote and re-sign — don't retry the submit.
Poll for status and understand the full intent lifecycle.
# Setting up approvals
Source: https://docs.rhinestone.dev/intents/guides/token-requirements
Handle token approvals and ETH wrapping before signing an intent.
Before signing, check whether the quote response includes a `tokenRequirements` field. This is only relevant for EOA users — smart accounts handle approvals automatically via preclaim ops.
## Smart accounts
No action required. The Orchestrator handles token approvals internally using preclaim ops on the smart account.
## EOAs
If `tokenRequirements` is present in the quote response, the user must complete these steps before signing. There are two types:
* **`approval`**: approve the specified token to the Permit2 contract on the specified chain.
* **`wrap`**: wrap native ETH into WETH on the specified chain (required when ETH is the source token).
```json theme={null}
{
"eip155:8453": {
"0x0000000000000000000000000000000000000000": {
"type": "wrap",
"amount": "102656447694688542"
},
"0x4200000000000000000000000000000000000006": {
"type": "approval",
"amount": "102656447694688542",
"spender": "0x000000000022d473030f116ddee9f6b43ac78ba3"
}
}
}
```
In this example, the user must first wrap ETH on Base, then approve WETH to Permit2.
## ETH wrapping
```ts theme={null}
import { switchChain, writeContract } from "@wagmi/core";
await switchChain(wagmiConfig, { chainId: Number(chainId) });
await writeContract(wagmiConfig, {
address: "0x4200000000000000000000000000000000000006", // WETH on the chain
abi: [
{
name: "deposit",
type: "function",
stateMutability: "payable",
inputs: [],
outputs: [],
},
],
functionName: "deposit",
value: BigInt(requirement.amount),
});
```
## ERC-20 approvals
Approve to the Permit2 contract. We recommend using `maxUint256` to avoid repeated approval prompts on future intents:
```ts theme={null}
import { switchChain, writeContract } from "@wagmi/core";
import { maxUint256, erc20Abi } from "viem";
await switchChain(wagmiConfig, { chainId: Number(chainId) });
await writeContract(wagmiConfig, {
address: tokenAddress,
abi: erc20Abi,
functionName: "approve",
args: [requirement.spender, maxUint256],
});
```
Approvals are always to [Permit2](https://github.com/Uniswap/permit2) — one of the most widely deployed and audited contracts in the ecosystem. Setting max approval here does not grant Rhinestone any direct access to your funds.
If you prefer exact approvals, inspect `cost.input` on the route. It lists each token spent with its exact amount:
```ts theme={null}
// Use cost.input amount instead of maxUint256 for exact approvals
const entry = route.cost.input.find(
(i) => i.chainId === chainId && i.tokenAddress === tokenAddress,
);
await writeContract(wagmiConfig, {
address: tokenAddress,
abi: erc20Abi,
functionName: "approve",
args: [requirement.spender, BigInt(entry.amount)],
});
```
## Next steps
Sign each element of the intent with EIP-712.
# Tracking intents
Source: https://docs.rhinestone.dev/intents/guides/tracking-intents
Poll for intent status and understand the intent lifecycle.
After [submitting an intent](./submitting-the-intent), you receive an `intentId`. Use it to poll the Orchestrator until the intent reaches a terminal state.
## Poll for status
```ts theme={null}
const baseUrl = "https://v1.orchestrator.rhinestone.dev";
const apiKey = process.env.RHINESTONE_API_KEY;
async function getIntentStatus(intentId: string) {
const res = await fetch(`${baseUrl}/intents/${intentId}`, {
headers: {
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
},
});
if (!res.ok) {
const errorBody = await res.text().catch(() => "");
throw new Error(
`Request failed: ${res.status} ${res.statusText}${errorBody ? ` - ${errorBody}` : ""}`
);
}
return res.json();
}
// Poll every 2 seconds until terminal
async function waitForCompletion(intentId: string) {
const terminal = new Set(["COMPLETED", "FAILED", "EXPIRED"]);
while (true) {
const data = await getIntentStatus(intentId);
console.log("Status:", data.status);
if (terminal.has(data.status)) return data;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
```
## Intent lifecycle
An intent moves through the following statuses:
| Status | Meaning |
| ----------- | ----------------------------------------------------------------- |
| `PENDING` | Submitted and in progress |
| `CLAIMED` | Source funds claimed by the solver, destination execution pending |
| `FILLED` | Executed on the destination chain, source funds not yet claimed |
| `COMPLETED` | Fully executed and settled onchain |
| `FAILED` | Execution failed |
| `EXPIRED` | Missed the execution deadline |
`COMPLETED` is the only successful terminal state. `FAILED` and `EXPIRED` are error states — see [Error handling](./error-handling) for how to respond to them.
## SDK shorthand
If you're using the Rhinestone SDK, `waitForExecution` handles polling internally and resolves when the intent completes:
```ts theme={null}
const transaction = await rhinestoneAccount.submitTransaction(signed);
const status = await rhinestoneAccount.waitForExecution(transaction);
// resolves only after COMPLETED
```
Use the manual polling approach above if you need visibility into intermediate states, or if you're working directly with the REST API.
# Using the API
Source: https://docs.rhinestone.dev/intents/guides/using-the-api
How the Rhinestone Orchestrator API works and what to keep in mind when integrating.
The Rhinestone Orchestrator API follows a quote-sign-submit flow. Each step is covered in the API guides section, but there are a few rules that apply across the entire integration.
## Pin a version
Send `x-api-version: 2026-04.blanc` on every request:
```ts theme={null}
const headers = {
"Content-Type": "application/json",
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
};
```
Requests without the header fall back to the deprecated `2026-01.alps` shape. See [API versioning](./api-versioning) for the full versioning policy.
## Server-stored intents
Quote responses are stored server-side. `POST /quotes` returns an `intentId` and the typed data needed for signing — you don't round-trip the full intent through the client. Submit by id:
```ts theme={null}
const { routes } = await post("/quotes", body);
const { intentId, signData } = routes[0];
const signatures = {
origin: await Promise.all(signData.origin.map(signTypedData)),
destination: await signTypedData(signData.destination),
};
await post("/intents", { intentId, signatures });
```
Pick `routes[0]` unless you have your own ranking — the array is server-ranked by a cost/speed tradeoff.
## Quote expiry
Quotes have a short server-side TTL. If a quote expires (or the quote store restarts) before you submit, `POST /intents` returns a 404. Re-quote and re-sign — don't retry the submit.
## One signature per origin chain
`signData.origin` is one entry per source chain. Sign each one in order. If the recipient differs from the sender and the destination has executions, sign `signData.destination` too — this slot is used for ownership of any received tokens.
## Forward typed data verbatim
Don't reconstruct EIP-712 types client-side. Forward the server's typed data directly to `wallet.signTypedData()`. Hand-rolled type libraries break on additive schema changes.
## API key scopes
Each API key can be scoped to bound its blast radius. Three scopes apply: `allowMainnet`, `intents`, and `deposits`. All default to unrestricted: the keys are "allow all" by default. Edit scopes from the [Dashboard](https://dashboard.rhinestone.dev) on the key detail screen (`OWNER` or `ADMIN` only). Denials return HTTP 403 with the failed scope and the required/actual levels in the error body.
| Scope | Values | What it allows |
| -------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `allowMainnet` | `true` / `false` | When `false`, restricts the key to testnet chains. |
| `intents` | `none` / `read` / `write` | Gates the intent flow. `none` blocks it; `read` permits non-mutating calls (quoting, status); `write` adds intent submission. |
| `deposits` | `none` / `read` / `write` | Gates the deposit lifecycle. `none` blocks it; `read` permits non-mutating calls; `write` adds account setup and state mutations. |
Account introspection endpoints (portfolio, liquidity) are not gated by `intents` or `deposits` — they stay accessible at any level, which is useful for monitoring keys.
Scopes only gate the customer API. On-chain deposits into already-registered accounts continue to flow through inbound provider webhooks regardless of scope. Set `intents: 'none'` (or revoke the key) to block intent submission for existing users.
## Next steps
Handle token approvals and ETH wrapping before signing.
Sign each element of the intent with EIP-712.
# Overview
Source: https://docs.rhinestone.dev/intents/overview
Warp is Rhinestone's intent routing and execution engine for crosschain transactions.
**Warp (Rhinestone Intents)** is an intent routing and execution engine. With a single signature, your users can move funds and execute calls across any supported chain in under 2 seconds.
## Capabilities
* **Crosschain transactions**: execute calls on any destination chain using funds from any source chain, in a single signature
* **Gas abstraction**: sponsor gas, bridging, and swap fees for your users, or let them pay in any supported token — no ETH required
* **Inbuilt swaps**: bridge and swap in one intent using solver liquidity, or inject your own DEX aggregator
* **Unified balance**: aggregate a user's funds across all supported chains to fund any transaction automatically
* **Non-custodial**: assets stay in the user's account until the moment of execution
## What you can build
* **Chain-abstracted wallets**: let users transact anywhere from a single account, regardless of where their funds sit
* **Crosschain DeFi**: deposit into vaults, provide liquidity, or execute trades on any chain without manual bridging
* **Automated portfolio management**: use session keys and intents together to execute strategies on behalf of users without per-transaction prompts
* **AI agents**: give agents a crosschain account with scoped permissions to execute onchain tasks across any supported chain
## SDK or API?
Warp is accessible via the Rhinestone SDK (TypeScript) or directly via the REST API.
| Scenario | Use | Why |
| ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Building a new wallet or dapp in TypeScript | [SDK](/smart-wallet/quickstart) | Handles account creation, signing, and submission out of the box |
| Server-side automation or AI agent | [API](/intents/quickstart) | Full control, no browser dependency, any language |
| EOA users, no smart account | [API](/intents/quickstart) | API handles EOA signing natively |
| Existing smart account users (not on Rhinestone SDK) | [API](/intents/quickstart) | Install [Intent Executor](/intents/guides/installing-intent-executor) first, then use API with smart account signing |
| Fastest integration from zero | [SDK](/smart-wallet/quickstart) | One package, batteries included |
| Non-JS environment (Python, Rust, Go) | [API](/intents/quickstart) | REST API works with any HTTP client |
Already using the Rhinestone SDK? Your wallet quickstart covers intents. Start there.
Integrating via the REST API? Build the full intent flow for EOA users step by step.
Architecture deep dive: Orchestrator, Relayer Market, resource locks, and more.
See all chains and tokens supported by Warp.
You'll need a Rhinestone API key for mainnet usage. [Request one here](https://tally.so/r/wg22x4). Testnets work without a key.
# Quickstart
Source: https://docs.rhinestone.dev/intents/quickstart
Send your first crosschain intent via the Rhinestone REST API.
Send your first crosschain intent straight through the REST API. This guide walks the full flow for **EOA users**.
**Using the Rhinestone SDK?** The [wallet quickstart](/smart-wallet/quickstart) already covers crosschain intents. Come back here when you need API-specific configuration.
**Using an existing smart account (non-Rhinestone SDK)?** You'll need to [install the Intent Executor](/intents/guides/installing-intent-executor) on each account before using Warp, then follow the [smart account signing guide](/intents/guides/signing) for the signing step.
## Prerequisites
* A Rhinestone API key ([request one here](https://tally.so/r/wg22x4)) — not required for testnets
* An EOA with funds on Ethereum, Optimism, Base, or Arbitrum — the source chains this quickstart handles below (extend the `accountAccessList` and wrap map for any other [supported chain](/home/resources/supported-chains))
## Steps
Submit a meta intent to the `/quotes` endpoint. Specify your destination chain, the token and amount you want on that chain, and your account:
```ts theme={null}
import { createWalletClient, extractChain, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import * as chains from "viem/chains";
const BASE_URL = "https://v1.orchestrator.rhinestone.dev";
const API_KEY = process.env.RHINESTONE_API_KEY;
// EOA signer — swap in any viem-compatible signer.
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
const EOA_ADDRESS = account.address;
// A route can pull source funds from any chain, so token requirements can land
// on any chain. Build a wallet client for whichever chain a requirement targets.
const walletClientFor = (chainId: string) =>
createWalletClient({
account,
chain: extractChain({
chains: Object.values(chains),
id: Number(chainId.split(":")[1]),
}),
transport: http(),
});
const headers = {
"Content-Type": "application/json",
"x-api-key": API_KEY,
"x-api-version": "2026-04.blanc",
};
const res = await fetch(`${BASE_URL}/quotes`, {
method: "POST",
headers,
body: JSON.stringify({
account: {
address: EOA_ADDRESS,
accountType: "EOA",
},
destinationChainId: "eip155:8453", // Base
tokenRequests: [
{
tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
amount: "5000000", // 5 USDC (6 decimals)
},
],
// Limit source funds to the chains this quickstart handles below.
accountAccessList: {
chainIds: ["eip155:1", "eip155:10", "eip155:8453", "eip155:42161"],
},
}),
});
const { routes } = await res.json();
const route = routes[0];
const { intentId, signData, tokenRequirements } = route;
```
The response is a server-ranked `routes` array. Use `routes[0]` unless you have your own ranking. Each route carries:
* `intentId`: server-stored handle, used to submit
* `cost`: input/output amounts and fee breakdown
* `signData`: EIP-712 typed data to sign
* `tokenRequirements`: approvals or wrapping the user must complete before signing
Before signing, the user must fulfill any `tokenRequirements` returned in the quote. Keys are CAIP-2 chain ids (`eip155:8453`):
**ERC-20 approvals** — approve tokens to the Permit2 contract:
```ts theme={null}
import { erc20Abi, maxUint256 } from "viem";
for (const [chainId, tokens] of Object.entries(tokenRequirements)) {
for (const [tokenAddress, requirement] of Object.entries(tokens)) {
if (requirement.type === "approval") {
await walletClientFor(chainId).writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: "approve",
args: [requirement.spender, maxUint256],
});
}
}
}
```
**ETH wrapping** — wrap native ETH to WETH:
```ts theme={null}
// Wrapped-native token for each chain in the accountAccessList above
// (only needed when the source token is ETH).
const WETH: Record = {
"eip155:1": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // Ethereum
"eip155:10": "0x4200000000000000000000000000000000000006", // Optimism
"eip155:8453": "0x4200000000000000000000000000000000000006", // Base
"eip155:42161": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", // Arbitrum
};
const wethAbi = [
{
name: "deposit",
type: "function",
stateMutability: "payable",
inputs: [],
outputs: [],
},
] as const;
for (const [chainId, tokens] of Object.entries(tokenRequirements)) {
for (const [tokenAddress, requirement] of Object.entries(tokens)) {
if (requirement.type === "wrap") {
await walletClientFor(chainId).writeContract({
address: WETH[chainId],
abi: wethAbi,
functionName: "deposit",
value: BigInt(requirement.amount),
});
}
}
}
```
Use max approvals to the [Permit2](https://github.com/Uniswap/permit2) contract. This is the only contract you ever approve — future intents won't need a new approval.
Forward `signData.origin[]` and `signData.destination` directly to `signTypedData`. One signature per source chain, plus the destination signature:
```ts theme={null}
const originSignatures = await Promise.all(
signData.origin.map((typedData) =>
account.signTypedData(typedData),
),
);
const destinationSignature = await account.signTypedData(
signData.destination,
);
```
Smart account signing and validator wrapping.
Post the signed intent to `/intents`:
```ts theme={null}
const submitRes = await fetch(`${BASE_URL}/intents`, {
method: "POST",
headers,
body: JSON.stringify({
intentId,
signatures: {
origin: originSignatures,
destination: destinationSignature,
},
}),
});
const { intentId: submittedId } = await submitRes.json();
```
If submit returns 404, the quote TTL elapsed — re-quote and re-sign.
Track execution status using the `intentId`:
```ts theme={null}
async function pollStatus(intentId: string) {
while (true) {
const res = await fetch(`${BASE_URL}/intents/${intentId}`, { headers });
const { status } = await res.json();
if (["COMPLETED", "FAILED", "EXPIRED"].includes(status)) {
console.log("Final status:", status);
return status;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
await pollStatus(submittedId);
```
Once execution finishes you'll see the terminal status:
```txt theme={null}
Final status: COMPLETED
```
Typical execution time is under 2 seconds. See [Tracking intents](/intents/guides/tracking-intents) for the full lifecycle.
Your tokens moved across chains from a single set of signatures — no bridge, no manual settlement.
## Next steps
Advanced quote options: sponsorship, source chain filtering, destination executions.
Full details on approvals and ETH wrapping.
Common Orchestrator errors and how to fix them.
# Execute a crosschain swap
Source: https://docs.rhinestone.dev/intents/tutorial/end-to-end-intent-flow
Bridge and swap tokens across chains in a single intent using the Rhinestone API.
In this tutorial you will swap USDC on Base for ETH on Arbitrum in a single intent. Warp handles the bridge and swap automatically — one signature, one operation, one confirmation.
By the end you will have a working end-to-end implementation you can adapt for any crosschain swap.
## Prerequisites
* A Rhinestone API key ([request one here](https://tally.so/r/wg22x4))
* An EOA with USDC on Base (or another [supported chain and token](/home/resources/supported-chains))
* A viem `WalletClient` configured for signing
## Setup
```ts theme={null}
import { createWalletClient, http, erc20Abi, maxUint256, type Hex } from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const BASE_URL = "https://v1.orchestrator.rhinestone.dev";
const API_KEY = process.env.RHINESTONE_API_KEY;
const headers = {
"Content-Type": "application/json",
"x-api-key": API_KEY,
"x-api-version": "2026-04.blanc",
};
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
const walletClient = createWalletClient({
account,
chain: base,
transport: http(),
});
const EOA_ADDRESS = account.address;
```
## Steps
Request a quote for swapping USDC on Base into ETH on Arbitrum. Specify the destination chain, the token you want, and the amount:
```ts theme={null}
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ETH_ARBITRUM = "0x0000000000000000000000000000000000000000"; // native ETH
const ETH_AMOUNT = "10000000000000000"; // 0.01 ETH (18 decimals)
const quoteRes = await fetch(`${BASE_URL}/quotes`, {
method: "POST",
headers,
body: JSON.stringify({
account: {
address: EOA_ADDRESS,
accountType: "EOA",
},
destinationChainId: "eip155:42161", // Arbitrum
tokenRequests: [
{
tokenAddress: ETH_ARBITRUM,
amount: ETH_AMOUNT,
},
],
accountAccessList: {
chainTokens: {
"eip155:8453": [USDC_BASE], // Base
},
},
}),
});
const { routes } = await quoteRes.json();
const route = routes[0];
const { intentId, cost, signData, tokenRequirements } = route;
```
`accountAccessList` constrains which tokens on which chains the router can spend.
Without it, the API may route through multiple chains and tokens (including wrapped ETH),
which can lead to unexpected gas requirements. Specify the exact source token(s) you want to use.
Because the source token (USDC on Base) differs from the destination token (ETH on Arbitrum), `cost.input` and `cost.output` show different tokens. This is how you know Warp is routing through a swap.
```ts theme={null}
console.log("Spending:", cost.input);
console.log("Receiving:", cost.output);
```
Check `tokenRequirements` for any approvals needed. For a USDC source, you will typically need a Permit2 approval:
```ts theme={null}
if (tokenRequirements) {
for (const [chainId, tokens] of Object.entries(tokenRequirements)) {
for (const [tokenAddress, requirement] of Object.entries(tokens as Record)) {
if (requirement.type === "approval") {
console.log(`Approving ${tokenAddress} on ${chainId}...`);
const { request } = await walletClient.simulateContract({
address: tokenAddress as `0x${string}`,
abi: erc20Abi,
functionName: "approve",
args: [requirement.spender, maxUint256],
});
const hash = await walletClient.writeContract(request);
console.log("Approval tx:", hash);
}
}
}
}
```
This approves to the [Permit2](https://github.com/Uniswap/permit2) contract. Once approved, future intents spending the same token on the same chain will not need another approval.
Forward `signData.origin[]` and `signData.destination` directly to your wallet. One signature per source chain, plus the destination signature.
```ts theme={null}
const originSignatures = await Promise.all(
signData.origin.map((typedData) =>
walletClient.signTypedData(typedData),
),
);
const destinationSignature = await walletClient.signTypedData(
signData.destination,
);
```
Smart account signing and validator wrapping.
Post the signed intent to `/intents` using the `intentId` from the quote:
```ts theme={null}
const submitRes = await fetch(`${BASE_URL}/intents`, {
method: "POST",
headers,
body: JSON.stringify({
intentId,
signatures: {
origin: originSignatures,
destination: destinationSignature,
},
}),
});
const { intentId: submittedId } = await submitRes.json();
console.log("Intent submitted:", submittedId);
```
If the submit returns 404, the quote TTL elapsed — re-quote and re-sign.
Track the intent status until it reaches a final state:
```ts theme={null}
async function pollUntilComplete(intentId: string) {
const FINAL_STATUSES = ["COMPLETED", "FAILED", "EXPIRED"];
while (true) {
const res = await fetch(`${BASE_URL}/intents/${intentId}`, { headers });
const data = await res.json();
console.log("Status:", data.status);
if (FINAL_STATUSES.includes(data.status)) {
if (data.status === "COMPLETED") {
console.log("Swap complete!");
console.log("Fill tx:", data.fillTransactionHash);
} else {
console.error("Intent failed with status:", data.status);
}
return data.status;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
await pollUntilComplete(submittedId);
```
Typical execution time is under 2 seconds. `FILLED` is an intermediate state — the relayer has delivered funds on the destination, but the source-chain claim hasn't settled yet. `COMPLETED` means everything is settled.
## Next steps
Cover bridge and swap fees for your users using `sponsorSettings`.
Add destination chain executions to your intent — deposit into a vault, buy an NFT, and more.
# Automated zaps
Source: https://docs.rhinestone.dev/intents/use-cases/automated-zaps
Streamline crosschain deposits, withdrawals, and automated vault actions for EOA users.
With Rhinestone, you can build app-managed crosschain vault deposits and withdrawals for EOA users. The pattern uses a companion smart account owned by the user, operated by your app via scoped session keys.
There are two execution modes:
1. **Instant** — the user transfers tokens to the companion account, and the app executes the intent immediately
2. **Delayed / Automated** — the user pre-approves tokens, and the app pulls and executes later (triggered by an event or schedule)
In both cases, intents are signed by a scoped session key — no additional user interaction is needed after the initial setup.
## Companion Account
Each user gets a companion smart account owned solely by their EOA. The account has smart sessions enabled so your app can operate it via a session key.
```ts theme={null}
import { RhinestoneSDK } from "@rhinestone/sdk";
import { toViewOnlyAccount } from "@rhinestone/sdk/utils";
const rhinestone = new RhinestoneSDK({
endpointUrl: `${appBaseUrl}/api`,
});
// Read-only reference to the user's EOA (the sole owner)
const ownerAccount = toViewOnlyAccount(userEoaAddress);
const companionAccount = await rhinestone.createAccount({
owners: {
type: "ecdsa",
accounts: [ownerAccount],
},
sessions: {
enabled: true,
},
});
```
Since the user's EOA is the sole owner, the account is fully non-custodial. Your app can only perform actions authorized by the session key scope.
## Session Key Setup
Define multi-chain sessions scoped to the vault contracts your app supports. The session key is controlled by your app's service.
```ts theme={null}
import { erc20Abi } from "viem";
import { arbitrum, optimism } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
// Session key controlled by your app's service
const sessionOwnerAccount = privateKeyToAccount(appSessionKey);
const sessions = await Promise.all([
rhinestone.createSession({
chain: arbitrum,
owners: {
type: "ecdsa",
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: vaultAbi,
address: ARBITRUM_VAULT_ADDRESS,
functions: { deposit: {} },
},
// Pulling USDC from the user and returning vault shares also flow
// through this session — scope them too.
{
abi: erc20Abi,
address: USDC_ADDRESS,
functions: {
// ...
},
},
{
abi: erc20Abi,
address: ARBITRUM_VAULT_TOKEN_ADDRESS,
functions: {
// ...
},
},
],
}),
rhinestone.createSession({
chain: optimism,
owners: {
type: "ecdsa",
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: vaultAbi,
address: OPTIMISM_VAULT_A_ADDRESS,
functions: { deposit: {} },
},
{
abi: vaultAbi,
address: OPTIMISM_VAULT_B_ADDRESS,
functions: { deposit: {} },
},
// Plus the ERC-20 permissions, as above.
],
}),
]);
```
The user signs once to authorize all sessions across all chains:
```ts theme={null}
const sessionDetails =
await companionAccount.getSessionDetails(sessions);
const enableSignature =
await companionAccount.signEnableSession(sessionDetails);
```
Persist the session credentials for your service to use later:
```ts theme={null}
const sessionCredentials = {
hashesAndChainIds: sessionDetails.hashesAndChainIds,
enableSignature,
};
```
Sessions can be further scoped to specific contract functions (e.g., `deposit` and `withdraw`) and guarded with policies like spending limits or timeframes. See the [Smart Sessions](/smart-wallet/smart-sessions/overview) docs.
## Instant Execution
In the instant flow, the user makes a single token transfer and your app handles the rest.
### Funding the Account
Prompt the user to transfer tokens to the companion account:
```ts theme={null}
import { erc20Abi } from "viem";
import { writeContract, waitForTransactionReceipt } from "@wagmi/core";
const hash = await writeContract(wagmiConfig, {
address: usdcAddress,
abi: erc20Abi,
functionName: "transfer",
args: [companionAccount.address, depositAmount],
});
await waitForTransactionReceipt(wagmiConfig, { hash });
```
For native token deposits (e.g., ETH), transfer the native token directly to the companion account. The intent system handles wrapping to WETH internally.
### Executing the Intent
Once funded, the companion account executes the crosschain intent using the session key:
```ts theme={null}
import { encodeFunctionData, erc20Abi } from "viem";
// USDC on Arbitrum — token inputs are addresses, not symbols
const usdc = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
const prepared = await companionAccount.prepareTransaction({
sourceChains: [ethereum],
targetChain: arbitrum,
calls: [
{
to: VAULT_ADDRESS,
data: encodeFunctionData({
abi: vaultAbi,
functionName: "deposit",
args: [depositAmount],
}),
},
// Transfer vault shares back to the user's EOA
{
to: VAULT_TOKEN_ADDRESS,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [userEoaAddress, receiptTokenAmount],
}),
},
],
tokenRequests: [
{
address: usdc,
amount: depositAmount,
},
],
signers: {
type: "session",
session: sessions[0],
enableData: {
userSignature: enableSignature,
hashesAndChainIds: sessionDetails.hashesAndChainIds,
sessionIndex: 0,
},
},
});
const signed = await companionAccount.signTransaction(prepared);
await companionAccount.submitTransaction(signed);
```
After submitting the intent, poll the intent status until it reaches a terminal state (`COMPLETED`, `FILLED`, `FAILED`, or `EXPIRED`). This typically takes a few seconds for cross-chain settlements.
## Delayed / Automated Execution
In the delayed flow, the user pre-approves tokens and your app triggers execution later — for example, on a schedule, when a vault matures, or in response to an onchain event.
### Step 1: User Approves Tokens
The user approves the companion account to spend their tokens:
```ts theme={null}
const hash = await writeContract(wagmiConfig, {
address: usdcAddress,
abi: erc20Abi,
functionName: "approve",
args: [companionAccount.address, depositAmount],
});
```
### Step 2: Pull Funds and Execute
When your app is ready to execute, it pulls the funds from the user and executes the intent in two steps.
**Pull approved tokens into the companion account** (same-chain):
```ts theme={null}
const preparedPull = await companionAccount.prepareTransaction({
targetChain: ethereum,
calls: [
{
to: USDC_ADDRESS,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transferFrom",
args: [userEoaAddress, companionAccount.address, depositAmount],
}),
},
],
signers: {
type: "session",
session: vaultSession,
enableData: sessionEnableData,
},
});
const signedPull = await companionAccount.signTransaction(preparedPull);
await companionAccount.submitTransaction(signedPull);
```
**Execute the crosschain intent** (bridge + vault deposit):
```ts theme={null}
// USDC on Arbitrum — token inputs are addresses, not symbols
const usdc = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
const preparedExecute = await companionAccount.prepareTransaction({
sourceChains: [ethereum],
targetChain: arbitrum,
calls: [
{
to: VAULT_ADDRESS,
data: encodeFunctionData({
abi: vaultAbi,
functionName: "deposit",
args: [depositAmount],
}),
},
{
to: VAULT_TOKEN_ADDRESS,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [userEoaAddress, receiptTokenAmount],
}),
},
],
tokenRequests: [
{
address: usdc,
amount: depositAmount,
},
],
signers: {
type: "session",
session: vaultSession,
enableData: sessionEnableData,
},
});
const signedExecute = await companionAccount.signTransaction(preparedExecute);
await companionAccount.submitTransaction(signedExecute);
```
This two-step flow can potentially be simplified to a single step using source chain executions.
## Gas Sponsorship
You can sponsor the gas and bridging costs for the intent execution by passing `sponsored: true`:
```ts theme={null}
// USDC on Arbitrum — token inputs are addresses, not symbols
const usdc = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
const data = await companionAccount.prepareTransaction({
sourceChains: [ethereum],
targetChain: arbitrum,
calls: vaultDepositCalls,
tokenRequests: [{ address: usdc, amount: depositAmount }],
sponsored: true,
signers: sessionSigners,
});
```
The user still needs gas for the initial token transfer (instant flow) or approval transaction (delayed flow).
See the [Gas Sponsorship](/smart-wallet/gas-sponsorship/overview) docs for setup details.
## Event Listening Service
For the delayed execution flow, your app implements a service that listens for onchain or offchain events to trigger executions. The service has access to the session key and stored credentials.
The specific events to listen for (e.g., ERC-20 Transfer events, vault maturity events, or offchain triggers) and the service architecture are implementation-specific.
## Recovery
Funds are always non-custodial — the user's EOA is the sole owner of the companion account. If a deposit fails mid-flow (e.g., the app goes offline after funding), the user can always sign a withdrawal transaction directly using their wallet.
Persist the app's session key so you can retry failed intents without requiring the user to re-approve.
# Multi-input bridge
Source: https://docs.rhinestone.dev/intents/use-cases/multi-input-bridge
Bridge tokens from multiple source chains or tokens into a single destination transaction.
With Rhinestone, you can bridge from multiple chains and/or multiple tokens.
The API will automatically find the most optimal path, whether it's a single- or multi-input intent.
## Request
You can also influence the routing by specifying input tokens and chains via `accountAccessList`:
```json theme={null}
{
"accountAccessList": {
"chainIds": ["eip155:10", "eip155:8453"],
"tokens": ["USDC"]
}
}
```
Learn more about choosing the inputs in the ["Getting a Quote" guide](../guides/getting-a-quote#source-chain-%2F-token).
## Response
In the case of multi-input bridging, you will get a route whose `cost.input` lists multiple entries (one per source chain and/or token), and `signData.origin[]` carries one entry per source chain.
```json Multi-Input Intent theme={null}
{
"routes": [
{
"intentId": "...",
"cost": {
"input": [
{
"chainId": "eip155:8453",
"tokenAddress": "0x4200000000000000000000000000000000000006",
"symbol": "WETH",
"decimals": 18,
"price": { "usd": 2412.37 },
"amount": "22291303013436002"
},
{
"chainId": "eip155:10",
"tokenAddress": "0x0b2c639c533813f4aa9d7837caf62653d097ff85",
"symbol": "USDC",
"decimals": 6,
"price": { "usd": 0.99 },
"amount": "8370380"
}
]
},
"signData": {
"origin": [
{ /* typed data for eip155:8453 */ },
{ /* typed data for eip155:10 */ }
],
"destination": { /* typed data for the destination */ }
}
}
]
}
```
If the user doesn't have outstanding Permit2 approvals, they will need to approve multiple input tokens.
The user will also need to sign each entry in `signData.origin[]` separately — one signature per input chain.
# Crosschain vault deposit
Source: https://docs.rhinestone.dev/intents/use-cases/vault-deposit
Deposit into an ERC-4626 vault on any chain using tokens from any supported chain.
With Rhinestone intents, you can deposit into any ERC-4626 vault on a destination chain using tokens from any supported chain. This is powered by `destinationExecutions` — arbitrary calls that run on the destination chain as part of the intent settlement.
The flow works as follows:
1. Encode the vault deposit call
2. Build the meta intent with the deposit execution
3. Get a quote from Rhinestone
4. Fulfill any token requirements
5. Sign the intent
6. Submit and poll for completion
## Encoding the Vault Deposit
For EOAs, destination executions run in an intermediary contract — not in the user's account context — so any tokens the execution produces (like vault shares) would otherwise be stranded there.
ERC-4626's `deposit(assets, receiver)` sidesteps this: set `receiver` to the user's address and shares mint directly to them. First approve the vault to pull the deposit token from the intermediary, then deposit.
```ts theme={null}
import { encodeFunctionData, erc20Abi, parseUnits } from "viem";
const VAULT_ADDRESS = "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9"; // Steakhouse Prime USDC vault (ERC-4626) on Base
const DEPOSIT_TOKEN = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base (the vault's underlying asset)
const USER_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // the EOA receiving the vault shares
const depositAmount = parseUnits("100", 6); // 100 USDC (6 decimals)
const vaultAbi = [
{
name: "deposit",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "assets", type: "uint256" },
{ name: "receiver", type: "address" },
],
outputs: [{ name: "shares", type: "uint256" }],
},
] as const;
const destinationExecutions = [
// 1. Approve the vault to pull USDC from the intermediary
{
to: DEPOSIT_TOKEN,
value: "0",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [VAULT_ADDRESS, depositAmount],
}),
},
// 2. Deposit, minting shares straight to the user via the receiver arg
{
to: VAULT_ADDRESS,
value: "0",
data: encodeFunctionData({
abi: vaultAbi,
functionName: "deposit",
args: [depositAmount, USER_ADDRESS],
}),
},
];
```
Because executions run in the intermediary — not the user's account — tokens produced onchain aren't swept back automatically. ERC-4626's `receiver` argument handles this: shares mint straight to the user. For outputs from calls that don't expose a recipient (e.g. a swap), add an explicit `transfer` back to the user.
For Smart Accounts, the IntentExecutor runs destination executions within the account's own context, so shares mint directly to the account with the account as `receiver` — no post-hoc transfer needed. You still approve the vault to pull the deposit token first.
```ts theme={null}
import { encodeFunctionData, erc20Abi, parseUnits } from "viem";
const VAULT_ADDRESS = "0xbeef0e0834849aCC03f0089F01f4F1Eeb06873C9"; // Steakhouse Prime USDC vault (ERC-4626) on Base
const DEPOSIT_TOKEN = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base (the vault's underlying asset)
const ACCOUNT_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // your smart account address
const depositAmount = parseUnits("100", 6); // 100 USDC (6 decimals)
const vaultAbi = [
{
name: "deposit",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "assets", type: "uint256" },
{ name: "receiver", type: "address" },
],
outputs: [{ name: "shares", type: "uint256" }],
},
] as const;
const destinationExecutions = [
// 1. Approve the vault to pull USDC from the account
{
to: DEPOSIT_TOKEN,
value: "0",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [VAULT_ADDRESS, depositAmount],
}),
},
// 2. Deposit — shares mint directly to the account
{
to: VAULT_ADDRESS,
value: "0",
data: encodeFunctionData({
abi: vaultAbi,
functionName: "deposit",
args: [depositAmount, ACCOUNT_ADDRESS],
}),
},
];
```
## Constructing the Meta Intent
```ts theme={null}
const metaIntent = {
destinationChainId: "eip155:8453", // Base (where the vault lives)
tokenRequests: [
{
tokenAddress: DEPOSIT_TOKEN,
amount: depositAmount.toString(),
},
],
account: {
address: USER_ADDRESS,
accountType: "EOA",
},
destinationExecutions,
};
```
```ts theme={null}
const metaIntent = {
destinationChainId: "eip155:8453", // Base (where the vault lives)
tokenRequests: [
{
tokenAddress: DEPOSIT_TOKEN,
amount: depositAmount.toString(),
},
],
account: {
address: ACCOUNT_ADDRESS,
accountType: "ERC7579",
setupOps: [], // include factory args if the account is not yet deployed
},
destinationExecutions,
};
```
## Getting a Quote
Submit the meta intent to the `/quotes` endpoint:
```ts theme={null}
const baseUrl = "https://v1.orchestrator.rhinestone.dev";
const apiKey = process.env.RHINESTONE_API_KEY;
const headers = {
"Content-Type": "application/json",
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
};
const res = await fetch(`${baseUrl}/quotes`, {
method: "POST",
headers,
body: JSON.stringify(metaIntent),
});
const { routes } = await res.json();
const route = routes[0];
const { intentId, cost, signData, tokenRequirements } = route;
```
The response is a server-ranked `routes` array. Each route carries:
* `intentId`: server-stored handle, used to submit
* `cost`: input/output amounts and fee breakdown
* `signData`: EIP-712 typed data to sign
* `tokenRequirements`: prerequisite token operations (EOA only)
See the full guide for advanced options like sponsorship and source chain filtering
## Fulfilling Token Requirements
Before submitting, EOAs must fulfill the token requirements returned in the quote. There are two types:
**ERC-20 Approvals** — approve tokens to the Permit2 contract:
```ts theme={null}
import { maxUint256 } from "viem";
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
const hash = await walletClient.writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: "approve",
args: [PERMIT2, maxUint256],
});
```
**ETH Wrapping** — wrap native ETH to WETH:
```ts theme={null}
const hash = await walletClient.writeContract({
address: WETH_ADDRESS,
abi: [
{
name: "deposit",
type: "function",
stateMutability: "payable",
inputs: [],
outputs: [],
},
],
functionName: "deposit",
value: wrapAmount,
});
```
Approvals are only ever to the [Permit2](https://github.com/Uniswap/permit2) contract. We recommend using max approvals for the best UX. Alternatively, inspect `cost.input` for the exact amount needed.
Smart Accounts handle token approvals and wrapping automatically via pre-claim ops — no manual steps are needed. You can proceed directly to signing.
Full details on fulfilling token requirements
## Signing the Intent
Forward `signData.origin[]` and `signData.destination` directly to your wallet. One signature per source chain, plus the destination signature.
```ts theme={null}
const originSignatures = await Promise.all(
signData.origin.map((typedData) =>
walletClient.signTypedData(typedData),
),
);
const destinationSignature = await walletClient.signTypedData(
signData.destination,
);
```
Smart account signing wraps the signature with the installed validator's encoding. The Rhinestone SDK handles this automatically — use it for smart account flows rather than driving the raw API by hand.
Send a crosschain intent from a smart account using the SDK
## Submitting and Polling
Submit the signed intent to `/intents` using the `intentId` from the quote:
```ts theme={null}
const res = await fetch(`${baseUrl}/intents`, {
method: "POST",
headers,
body: JSON.stringify({
intentId,
signatures: {
origin: originSignatures,
destination: destinationSignature,
},
}),
});
const { intentId: submittedId } = await res.json();
```
Poll for status using the `intentId`:
```ts theme={null}
const poll = async (intentId: string) => {
const res = await fetch(`${baseUrl}/intents/${intentId}`, { headers });
return res.json();
};
```
See the [tracking intents guide](../guides/tracking-intents) for the full list of intent statuses.
# deploy
Source: https://docs.rhinestone.dev/sdk-reference/account/deployment/deploy
Deploy the account on a given chain.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const success = await account.deploy(chain)
```
## Parameters
Chain to deploy the account on
Optional deployment parameters (sponsorship)
## Returns
`true` once the deployment is submitted
# getInitData
Source: https://docs.rhinestone.dev/sdk-reference/account/deployment/get-init-data
Get the account initialization data, used to deploy the account onchain.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const initData = account.getInitData()
```
## Returns
The factory address and factory data
# isDeployed
Source: https://docs.rhinestone.dev/sdk-reference/account/deployment/is-deployed
Check whether the account is deployed on a given chain.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const deployed = await account.isDeployed(chain)
```
## Parameters
Chain to check
## Returns
`true` if the account is deployed, `false` otherwise
# setup
Source: https://docs.rhinestone.dev/sdk-reference/account/deployment/setup
Set up an existing account on a given chain by installing any missing modules.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const success = await account.setup(chain)
```
## Parameters
Chain to set the account up on
## Returns
`true` once setup is submitted
# signEip7702InitData
Source: https://docs.rhinestone.dev/sdk-reference/account/deployment/sign-eip7702-init-data
Prepare and sign the EIP-7702 account initialization data.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const signature = await account.signEip7702InitData()
```
## Returns
The init data signature
# waitForExecution
Source: https://docs.rhinestone.dev/sdk-reference/account/execution/wait-for-execution
Wait for a submitted transaction or user operation to execute onchain. Polls the orchestrator until the intent reaches a terminal state; on failure an IntentFailedError is thrown.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const output = await account.waitForExecution(result)
```
## Parameters
The result returned by a submit/send call
## Returns
The per-chain operation status (for intents) or a UserOp receipt
# getAddress
Source: https://docs.rhinestone.dev/sdk-reference/account/reads/get-address
Get the account address.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const address = account.getAddress()
```
## Returns
The smart account address
# getExecutors
Source: https://docs.rhinestone.dev/sdk-reference/account/reads/get-executors
Get the account executor modules.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const executors = await account.getExecutors(chain)
```
## Parameters
Chain to read the executors from
## Returns
The executor module addresses
# getOwners
Source: https://docs.rhinestone.dev/sdk-reference/account/reads/get-owners
Get the account owners.
Only returns ECDSA owners; owners managed by other validator types are not included.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const owners = await account.getOwners(chain)
```
## Parameters
Chain to read the owners from
## Returns
The owner addresses and threshold, or `null` if unavailable
# getPortfolio
Source: https://docs.rhinestone.dev/sdk-reference/account/reads/get-portfolio
Get the account portfolio (token balances across chains).
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const portfolio = await account.getPortfolio()
```
## Parameters
Whether to query testnet balances (default is `false`)
## Returns
The account balances
# getValidators
Source: https://docs.rhinestone.dev/sdk-reference/account/reads/get-validators
Get the account validator modules.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const validators = await account.getValidators(chain)
```
## Parameters
Chain to read the validators from
## Returns
The validator module addresses
# signMessage
Source: https://docs.rhinestone.dev/sdk-reference/account/signing/sign-message
Sign a message (EIP-191).
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const signature = await account.signMessage(message, chain, signers)
```
## Parameters
Message to sign
Chain to sign the message for
Signers to use, or `undefined` for the account default
## Returns
The signature
## See also
* [signTypedData](/sdk-reference/account/signing/sign-typed-data) to sign EIP-712 typed data
# signTypedData
Source: https://docs.rhinestone.dev/sdk-reference/account/signing/sign-typed-data
Sign typed data (EIP-712).
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const signature = await account.signTypedData(parameters, chain, signers)
```
## Parameters
Typed-data parameters
Chain to sign the typed data for
Signers to use, or `undefined` for the account default
## Returns
The signature
## See also
* [signMessage](/sdk-reference/account/signing/sign-message) to sign an EIP-191 message
# getSessionDetails
Source: https://docs.rhinestone.dev/sdk-reference/account/smart-sessions/get-session-details
Resolve the smart-session details for a set of sessions.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const sessionDetails = await account.getSessionDetails(sessions)
```
## Parameters
Sessions to resolve
## Returns
The resolved session details
# isSessionEnabled
Source: https://docs.rhinestone.dev/sdk-reference/account/smart-sessions/is-session-enabled
Check whether a smart session is enabled on the account.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const sessionEnabled = await account.isSessionEnabled(session)
```
## Parameters
Session to check
## Returns
`true` if the session is enabled
# signEnableSession
Source: https://docs.rhinestone.dev/sdk-reference/account/smart-sessions/sign-enable-session
Sign the data required to enable a smart session.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const signature = await account.signEnableSession(details)
```
## Parameters
Session details to enable
## Returns
The enable-session signature
# assembleTransaction
Source: https://docs.rhinestone.dev/sdk-reference/account/transactions/assemble-transaction
Assemble independently collected owner signatures into a signed transaction. Signatures are deduplicated and ordered according to the configured owner set. Account thresholds are read from the local configuration; an explicit transaction signer set determines active MFA IDs and contributing owners. Callers must keep these synchronized with onchain owner and threshold changes.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const result = await account.assembleTransaction(preparedTransaction, signatures)
```
## Parameters
The prepared transaction every owner signed
Owner signatures returned by `signTransaction` with an `owner` option
## Returns
Signed transaction data ready for submission
## See also
* [signTransaction](/sdk-reference/account/transactions/sign-transaction) to create each owner signature
* [submitTransaction](/sdk-reference/account/transactions/submit-transaction) to submit the result
# getTransactionMessages
Source: https://docs.rhinestone.dev/sdk-reference/account/transactions/get-transaction-messages
Get the typed-data messages to sign for a prepared transaction.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const transactionMessages = account.getTransactionMessages(preparedTransaction)
```
## Parameters
Prepared transaction data
Optional override; pass `{ intentId }` to inspect a specific quote from `preparedTransaction.quotes.all`
## Returns
The origin, destination, and (when required) target-execution typed-data messages
## See also
* [prepareTransaction](/sdk-reference/account/transactions/prepare-transaction) to prepare the transaction data for signing
# prepareTransaction
Source: https://docs.rhinestone.dev/sdk-reference/account/transactions/prepare-transaction
Prepare a transaction for signing.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const result = await account.prepareTransaction(transaction)
```
## Parameters
Transaction to prepare
## Returns
The prepared transaction data
## See also
* [signTransaction](/sdk-reference/account/transactions/sign-transaction) to sign the prepared transaction
* [submitTransaction](/sdk-reference/account/transactions/submit-transaction) to submit the signed transaction
# signAuthorizations
Source: https://docs.rhinestone.dev/sdk-reference/account/transactions/sign-authorizations
Sign the EIP-7702 authorizations required for a transaction.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const authorizations = await account.signAuthorizations(preparedTransaction)
```
## Parameters
Prepared transaction data
## Returns
The signed authorization list
## See also
* [prepareTransaction](/sdk-reference/account/transactions/prepare-transaction) to prepare the transaction data for signing
# signIntent
Source: https://docs.rhinestone.dev/sdk-reference/account/transactions/sign-intent
Sign an orchestrator intent operation. Used by headless flows that prepare the intent outside the SDK but still need the SDK-owned smart-session signature packing and target-execution signature routing.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const signatures = await account.signIntent(signData, targetChain)
```
## Parameters
Sign data returned by the orchestrator (origin/destination/targetExecution typed data)
Chain where the destination execution runs
Signers to use, or `undefined` for the account default
## Returns
The intent signatures, ready for submission
## See also
* [signTransaction](/sdk-reference/account/transactions/sign-transaction) for the canonical signing path
# signTransaction
Source: https://docs.rhinestone.dev/sdk-reference/account/transactions/sign-transaction
Sign a prepared transaction as one configured owner. The returned signature can be serialized and shared with the party coordinating submission.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const transaction = await account.signTransaction(preparedTransaction, options)
```
## Parameters
Prepared transaction data
Owner account, optional quote, and multi-factor validator ID
## Returns
This owner's signature contribution
## See also
* [prepareTransaction](/sdk-reference/account/transactions/prepare-transaction) to prepare the transaction data for signing
* [assembleTransaction](/sdk-reference/account/transactions/assemble-transaction) to combine independent owner signatures
# submitTransaction
Source: https://docs.rhinestone.dev/sdk-reference/account/transactions/submit-transaction
Submit a signed transaction.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const transaction = await account.submitTransaction(signedTransaction)
```
## Parameters
Signed transaction data
Optional submission options (e.g. EIP-7702 `authorizations`)
## Returns
The transaction result (an intent ID)
## See also
* [signTransaction](/sdk-reference/account/transactions/sign-transaction) to sign the transaction data
* [signAuthorizations](/sdk-reference/account/transactions/sign-authorizations) to sign the required EIP-7702 authorizations
* [waitForExecution](/sdk-reference/account/execution/wait-for-execution) to wait for the transaction to execute onchain
# prepareUserOperation
Source: https://docs.rhinestone.dev/sdk-reference/account/user-operations/prepare-user-operation
Prepare a user operation for signing.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const userOperation = await account.prepareUserOperation(transaction)
```
## Parameters
User operation to prepare
## Returns
The prepared user operation data
## See also
* [signUserOperation](/sdk-reference/account/user-operations/sign-user-operation) to sign the prepared user operation
* [submitUserOperation](/sdk-reference/account/user-operations/submit-user-operation) to submit the signed user operation
* [sendUserOperation](/sdk-reference/account/user-operations/send-user-operation) to prepare, sign, and submit in one call
# sendUserOperation
Source: https://docs.rhinestone.dev/sdk-reference/account/user-operations/send-user-operation
Prepare, sign, and submit a user operation in a single call.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const userOperation = await account.sendUserOperation(transaction)
```
## Parameters
User operation to send
## Returns
The user operation result (a UserOp hash)
## See also
* [waitForExecution](/sdk-reference/account/execution/wait-for-execution) to wait for the user operation to execute onchain
# signUserOperation
Source: https://docs.rhinestone.dev/sdk-reference/account/user-operations/sign-user-operation
Sign a prepared user operation.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const userOperation = await account.signUserOperation(preparedUserOperation)
```
## Parameters
Prepared user operation data
## Returns
The signed user operation data
## See also
* [prepareUserOperation](/sdk-reference/account/user-operations/prepare-user-operation) to prepare the user operation data for signing
* [submitUserOperation](/sdk-reference/account/user-operations/submit-user-operation) to submit the signed user operation
# submitUserOperation
Source: https://docs.rhinestone.dev/sdk-reference/account/user-operations/submit-user-operation
Submit a signed user operation.
Method on an account instance returned by [`createAccount`](/sdk-reference/rhinestone-sdk/create-account).
## Usage
```ts theme={null}
const userOperation = await account.submitUserOperation(signedUserOperation)
```
## Parameters
Signed user operation data
## Returns
The user operation result (a UserOp hash)
## See also
* [signUserOperation](/sdk-reference/account/user-operations/sign-user-operation) to sign the user operation data
* [waitForExecution](/sdk-reference/account/execution/wait-for-execution) to wait for the user operation to execute onchain
# addOwner
Source: https://docs.rhinestone.dev/sdk-reference/actions/ecdsa/add-owner
Add an ECDSA owner
## Import
```ts theme={null}
import { addOwner } from '@rhinestone/sdk/actions/ecdsa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [addOwner(owner)],
})
```
## Parameters
Owner address
## Returns
Call to add the owner
# changeThreshold
Source: https://docs.rhinestone.dev/sdk-reference/actions/ecdsa/change-threshold
Change an account's signer threshold (ECDSA)
## Import
```ts theme={null}
import { changeThreshold } from '@rhinestone/sdk/actions/ecdsa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [changeThreshold(newThreshold)],
})
```
## Parameters
New threshold
## Returns
Call to change the threshold
# disable
Source: https://docs.rhinestone.dev/sdk-reference/actions/ecdsa/disable
Disable ECDSA authentication
## Import
```ts theme={null}
import { disable } from '@rhinestone/sdk/actions/ecdsa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [disable()],
})
```
## Returns
Calls to disable ECDSA authentication
# enable
Source: https://docs.rhinestone.dev/sdk-reference/actions/ecdsa/enable
Enable ECDSA authentication
## Import
```ts theme={null}
import { enable } from '@rhinestone/sdk/actions/ecdsa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [enable(owners, threshold)],
})
```
## Parameters
Owners to use for authentication
Threshold for the owners
## Returns
Calls to enable ECDSA authentication
# removeOwner
Source: https://docs.rhinestone.dev/sdk-reference/actions/ecdsa/remove-owner
Remove an ECDSA owner
## Import
```ts theme={null}
import { removeOwner } from '@rhinestone/sdk/actions/ecdsa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [removeOwner(prevOwner, ownerToRemove)],
})
```
## Parameters
Previous owner address
Owner to remove
## Returns
Call to remove the owner
# changeThreshold
Source: https://docs.rhinestone.dev/sdk-reference/actions/mfa/change-threshold
Change the multi-factor threshold
## Import
```ts theme={null}
import { changeThreshold } from '@rhinestone/sdk/actions/mfa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [changeThreshold(newThreshold)],
})
```
## Parameters
New threshold
## Returns
Call to change the threshold
# disable
Source: https://docs.rhinestone.dev/sdk-reference/actions/mfa/disable
Disable multi-factor authentication
## Import
```ts theme={null}
import { disable } from '@rhinestone/sdk/actions/mfa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [disable()],
})
```
## Returns
Calls to disable multi-factor authentication
# enable
Source: https://docs.rhinestone.dev/sdk-reference/actions/mfa/enable
Enable multi-factor authentication
## Import
```ts theme={null}
import { enable } from '@rhinestone/sdk/actions/mfa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [enable(validators, threshold)],
})
```
## Parameters
List of validators to use
Threshold for the validators
## Returns
Calls to enable multi-factor authentication
# removeSubValidator
Source: https://docs.rhinestone.dev/sdk-reference/actions/mfa/remove-sub-validator
Remove a sub-validator (multi-factor)
## Import
```ts theme={null}
import { removeSubValidator } from '@rhinestone/sdk/actions/mfa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [removeSubValidator(id, validator)],
})
```
## Parameters
Validator ID
Validator module
## Returns
Call to remove the sub-validator
# setSubValidator
Source: https://docs.rhinestone.dev/sdk-reference/actions/mfa/set-sub-validator
Set a sub-validator (multi-factor)
## Import
```ts theme={null}
import { setSubValidator } from '@rhinestone/sdk/actions/mfa'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [setSubValidator(id, validator)],
})
```
## Parameters
Validator ID
Validator module
## Returns
Call to set the sub-validator
# deploy
Source: https://docs.rhinestone.dev/sdk-reference/actions/modules/deploy
## Import
```ts theme={null}
import { deploy } from '@rhinestone/sdk/actions'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [deploy(account)],
})
```
## Parameters
## Returns
# installModule
Source: https://docs.rhinestone.dev/sdk-reference/actions/modules/install-module
## Import
```ts theme={null}
import { installModule } from '@rhinestone/sdk/actions'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [installModule(module)],
})
```
## Parameters
## Returns
# uninstallModule
Source: https://docs.rhinestone.dev/sdk-reference/actions/modules/uninstall-module
## Import
```ts theme={null}
import { uninstallModule } from '@rhinestone/sdk/actions'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [uninstallModule(module)],
})
```
## Parameters
## Returns
# addOwner
Source: https://docs.rhinestone.dev/sdk-reference/actions/passkeys/add-owner
Add a passkey owner
## Import
```ts theme={null}
import { addOwner } from '@rhinestone/sdk/actions/passkeys'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [addOwner(pubKeyX, pubKeyY, requireUserVerification)],
})
```
## Parameters
Public key X
Public key Y
Whether to require user verification
## Returns
Call to add the passkey owner
# changeThreshold
Source: https://docs.rhinestone.dev/sdk-reference/actions/passkeys/change-threshold
Change an account's signer threshold (passkey)
## Import
```ts theme={null}
import { changeThreshold } from '@rhinestone/sdk/actions/passkeys'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [changeThreshold(newThreshold)],
})
```
## Parameters
New threshold
## Returns
Call to change the threshold
# disable
Source: https://docs.rhinestone.dev/sdk-reference/actions/passkeys/disable
Disable passkeys (WebAuthn) authentication
## Import
```ts theme={null}
import { disable } from '@rhinestone/sdk/actions/passkeys'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [disable()],
})
```
## Returns
Calls to disable passkeys authentication
# enable
Source: https://docs.rhinestone.dev/sdk-reference/actions/passkeys/enable
Enable passkeys authentication
## Import
```ts theme={null}
import { enable } from '@rhinestone/sdk/actions/passkeys'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [enable(credential)],
})
```
## Parameters
## Returns
Calls to enable passkeys authentication
# removeOwner
Source: https://docs.rhinestone.dev/sdk-reference/actions/passkeys/remove-owner
Remove a passkey owner
## Import
```ts theme={null}
import { removeOwner } from '@rhinestone/sdk/actions/passkeys'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [removeOwner(pubKeyX, pubKeyY)],
})
```
## Parameters
Public key X
Public key Y
## Returns
Call to remove the passkey owner
# disable
Source: https://docs.rhinestone.dev/sdk-reference/actions/smart-sessions/disable
Disable smart sessions
## Import
```ts theme={null}
import { disable } from '@rhinestone/sdk/actions/smart-sessions'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [disable()],
})
```
## Returns
Calls to disable smart sessions
# disableSession
Source: https://docs.rhinestone.dev/sdk-reference/actions/smart-sessions/disable-session
Disable a smart session
Removes a single session from the smart-session emissary via `removeConfig`.
The account executes the call itself, so the emissary skips the disable
user-signature — the user authorizes it by signing the outer transaction as
usual (no separate, blind session-digest signature). The `session` must be a
resolved `Session` (the return value of `toSession(...)`) on the chain where
the session is being disabled.
## Import
```ts theme={null}
import { disableSession } from '@rhinestone/sdk/actions/smart-sessions'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [disableSession(session)],
})
```
## Parameters
resolved session to disable
optional deadline after which this disable call is no longer
valid; must be in the future. Omit for no expiry.
## Returns
Calls to disable the smart session
# enable
Source: https://docs.rhinestone.dev/sdk-reference/actions/smart-sessions/enable
Enable smart sessions
## Import
```ts theme={null}
import { enable } from '@rhinestone/sdk/actions/smart-sessions'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [enable()],
})
```
## Returns
Calls to enable smart sessions
# enableSession
Source: https://docs.rhinestone.dev/sdk-reference/actions/smart-sessions/enable-session
Enable a smart session
The `session` must be a resolved `Session` (the return value of
`toSession(...)`). Re-resolving it here would drop the explicit
`permissions` — a `Session` only carries the derived `actions`, not the
original `SessionDefinition.permissions` — which makes the on-chain digest
computed by `SmartSessionLens.getAndVerifyDigest` diverge from the one
signed in `getSessionDetails`, causing the emissary to reject the enable.
## Import
```ts theme={null}
import { enableSession } from '@rhinestone/sdk/actions/smart-sessions'
```
## Usage
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [enableSession(session, enableSessionSignature, hashesAndChainIds, sessionToEnableIndex)],
})
```
## Parameters
resolved session to enable
## Returns
Calls to enable the smart session
# createAccount
Source: https://docs.rhinestone.dev/sdk-reference/rhinestone-sdk/create-account
Create an account using this instance's shared configuration.
Method on a [`RhinestoneSDK`](/sdk-reference/rhinestone-sdk/rhinestone-sdk) instance.
## Usage
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { privateKeyToAccount } from 'viem/accounts'
const owner = privateKeyToAccount('0x...')
const sdk = new RhinestoneSDK({
auth: { mode: 'apiKey', apiKey: process.env.RHINESTONE_API_KEY! },
})
const account = await sdk.createAccount({
owners: { type: 'ecdsa', accounts: [owner] },
})
```
## Parameters
Per-account configuration (owners, account type, modules, sessions)
## Returns
The account instance
# createSession
Source: https://docs.rhinestone.dev/sdk-reference/rhinestone-sdk/create-session
Create a smart session, resolving the chain's wrapped-native token from the orchestrator's chain catalog (GET /chains) so native-token wrapping is permitted automatically. Project-scoped — needs the API key but no account. For a fully offline build, use the standalone toSession and pass wrappedNativeToken yourself.
Method on a [`RhinestoneSDK`](/sdk-reference/rhinestone-sdk/rhinestone-sdk) instance.
## Usage
```ts theme={null}
const session = await sdk.createSession(definition)
```
## Parameters
The session definition
## Returns
The resolved session
# getAppFeeBalances
Source: https://docs.rhinestone.dev/sdk-reference/rhinestone-sdk/get-app-fee-balances
Get the integrator's accrued app-fee balance, as USD totals.
App fees are earned by the integrator identified by this instance's API key
(project-scoped, not tied to any account) and valued in USD at the moment
each fee is collected, so the balance is not affected by later price
movements of the collected tokens.
Method on a [`RhinestoneSDK`](/sdk-reference/rhinestone-sdk/rhinestone-sdk) instance.
## Usage
```ts theme={null}
const appFeeBalances = await sdk.getAppFeeBalances()
```
## Returns
The withdrawable and pending app-fee balances in USD
# getIntentStatus
Source: https://docs.rhinestone.dev/sdk-reference/rhinestone-sdk/get-intent-status
Get the current status of a submitted intent.
Method on a [`RhinestoneSDK`](/sdk-reference/rhinestone-sdk/rhinestone-sdk) instance.
## Usage
```ts theme={null}
const intentStatus = await sdk.getIntentStatus(intentId)
```
## Parameters
The intent ID returned when the transaction was submitted
## Returns
The intent status
# RhinestoneSDK
Source: https://docs.rhinestone.dev/sdk-reference/rhinestone-sdk/rhinestone-sdk
Stateful entry point that holds shared configuration (auth, provider, bundler, paymaster) and creates accounts from it, delegating all work to the SDK-core composition.
## Import
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
```
## Usage
```ts theme={null}
const sdk = new RhinestoneSDK({
// ...config
})
```
## Parameters
Shared configuration applied to every account created by this instance
# splitIntents
Source: https://docs.rhinestone.dev/sdk-reference/rhinestone-sdk/split-intents
Split a transaction into multiple intents across chains.
Method on a [`RhinestoneSDK`](/sdk-reference/rhinestone-sdk/rhinestone-sdk) instance.
## Usage
```ts theme={null}
const intents = await sdk.splitIntents(input)
```
## Parameters
The intents to split
## Returns
The split-intents result
# experimental_getRhinestoneInitData
Source: https://docs.rhinestone.dev/sdk-reference/utils/experimental-get-rhinestone-init-data
Compute the Rhinestone initialization data for an account configuration.
This API is experimental and may change in a future release.
Use this to reconstruct the `initData` for an account originally created with
the Rhinestone SDK, then pass it back into `createAccount` instead of
providing the factory and factory data manually.
## Import
```ts theme={null}
import { experimental_getRhinestoneInitData } from '@rhinestone/sdk/utils'
```
## Usage
```ts theme={null}
import { experimental_getRhinestoneInitData } from '@rhinestone/sdk/utils'
const initData = experimental_getRhinestoneInitData({
owners: { type: 'ecdsa', accounts: [owner] },
})
const account = await sdk.createAccount({
owners: { type: 'ecdsa', accounts: [owner] },
initData,
})
```
## Parameters
Account configuration
## Returns
The account address, plus factory data when the account is not yet deployed
# experimental_getV0InitData
Source: https://docs.rhinestone.dev/sdk-reference/utils/experimental-get-v0-init-data
Compute the v0 (legacy) initialization data for an account configuration.
This API is experimental and may change in a future release.
Use this to reconstruct the `initData` for an account originally created with
the Rhinestone SDK v0, then pass it back into `createAccount`.
## Import
```ts theme={null}
import { experimental_getV0InitData } from '@rhinestone/sdk/utils'
```
## Usage
```ts theme={null}
import { experimental_getV0InitData } from '@rhinestone/sdk/utils'
const initData = experimental_getV0InitData({
owners: { type: 'ecdsa', accounts: [owner] },
})
const account = await sdk.createAccount({
owners: { type: 'ecdsa', accounts: [owner] },
initData,
})
```
## Parameters
Account configuration
## Returns
The account address, factory, factory data, and whether the intent executor is installed
# toViewOnlyAccount
Source: https://docs.rhinestone.dev/sdk-reference/utils/to-view-only-account
Create a view-only viem Account for an address. Any signing operation throws.
Useful as an account owner when signing happens elsewhere (e.g. a server-held
session signer), so the SDK can read from the account without holding a key.
## Import
```ts theme={null}
import { toViewOnlyAccount } from '@rhinestone/sdk/utils'
```
## Usage
```ts theme={null}
import { toViewOnlyAccount } from '@rhinestone/sdk/utils'
const account = await sdk.createAccount({
owners: { type: 'ecdsa', accounts: [toViewOnlyAccount(userAddress)] },
})
```
## Parameters
Address to wrap
## Returns
A viem account that can be read from but not signed with
# Batch transactions
Source: https://docs.rhinestone.dev/smart-wallet/advanced/batch-transactions
You can make multiple calls in a single transaction:
```ts theme={null}
import { baseSepolia } from 'viem/chains'
const chain = baseSepolia
const usdc = '0x036CbD53842c5426634e7929541eC2318f3dCF7e' // USDC on Base Sepolia
const usdcAmount = 1n
const ethAmount = 2n
const receiver = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
const transaction = await rhinestoneAccount.prepareTransaction({
chain,
calls: [
{
to: usdc,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [receiver, usdcAmount],
}),
},
{
to: receiver,
value: ethAmount,
},
]
})
```
# Custom modules
Source: https://docs.rhinestone.dev/smart-wallet/advanced/custom-modules
Extend account's capabilities with bespoke modules
Custom modules enable you to tailor the functionality of your smart wallet.
With custom *validators*, you can introduce novel ways to authorize user transactions. Custom *executors* enable automated transaction flows against the user account. And with custom *hooks*, you can add custom logic that runs before or after any transaction executed on the account.
To create a custom module, use the [ModuleKit](https://github.com/rhinestonewtf/modulekit).
## Deployment
You can install custom modules during account deployment by passing them in the `modules` field when creating the account:
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
const rhinestone = new RhinestoneSDK({ apiKey })
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [owner],
},
modules: [
{
type: 'executor',
address: MODULE_ADDRESS,
// Optional
initData: MODULE_DATA,
},
],
})
```
This is useful when you want your account to have specific modules available from the start, without needing a separate installation transaction after deployment.
## Installing a Module
You can install and use custom validators, executors, and hooks.
```ts theme={null}
import { installModule, uninstallModule } from '@rhinestone/sdk/actions'
const transaction = await rhinestoneAccount.prepareTransaction({
targetChain: base,
calls: [
installModule({
address: MODULE_ADDRESS,
type: 'executor',
// Optional
initData: MODULE_DATA,
}),
],
})
```
## Uninstalling a Module
You can also remove any module from the account:
```ts theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
targetChain: base,
calls: [
uninstallModule({
address: MODULE_ADDRESS,
type: 'executor',
}),
],
})
```
Be careful when uninstalling validator modules, as you can lock the account forever.
# Custom signing
Source: https://docs.rhinestone.dev/smart-wallet/advanced/custom-signing
Inspect and manually sign intent messages
Use `getTransactionMessages` to extract the raw typed data messages from a prepared transaction. This gives you full control over the signing process.
Custom signing is useful when you need to implement custom signing flows (e.g. sign async with each owner, use a custom signing method, etc).
## Example
```ts theme={null}
// 1. Prepare the transaction
const transactionData = await rhinestoneAccount.prepareTransaction({
// …
});
// 2. Extract messages
const { origin, destination } =
rhinestoneAccount.getTransactionMessages(transactionData);
// 3. Sign each message
const originSignatures = await Promise.all(
origin.map((message) => sign(message)),
);
const destinationSignature = await sign(destination);
// 4. Submit the transaction
const transaction = await rhinestoneAccount.submitTransaction({
...transactionData,
quote: transactionData.quotes.best,
originSignatures,
destinationSignature,
targetExecutionSignature: undefined,
});
```
# ERC-4337
Source: https://docs.rhinestone.dev/smart-wallet/advanced/erc4337
Configure and use ERC-4337 with the Rhinestone SDK.
Rhinestone uses its own intent infrastructure for most transactions, but some modules require ERC-4337 UserOperations: [Social Recovery](./recovery) and legacy [Smart Sessions](../smart-sessions/overview). This guide covers how to configure and use ERC-4337 with the SDK.
## Configuration
### Bundler
Pass a `bundler` config when initialising the SDK:
```ts theme={null}
const rhinestone = new RhinestoneSDK({
apiKey: rhinestoneApiKey,
bundler: {
type: 'pimlico',
apiKey: pimlicoApiKey,
},
})
```
```ts theme={null}
const rhinestone = new RhinestoneSDK({
apiKey: rhinestoneApiKey,
bundler: {
type: 'biconomy',
apiKey: biconomyApiKey,
},
})
```
### Paymaster
Optionally add a `paymaster` to sponsor gas on UserOperations:
```ts theme={null}
const rhinestone = new RhinestoneSDK({
apiKey: rhinestoneApiKey,
bundler: { type: 'pimlico', apiKey: pimlicoApiKey },
paymaster: { type: 'pimlico', apiKey: pimlicoApiKey },
})
```
```ts theme={null}
const rhinestone = new RhinestoneSDK({
apiKey: rhinestoneApiKey,
bundler: { type: 'biconomy', apiKey: biconomyApiKey },
paymaster: { type: 'biconomy', apiKey: biconomyApiKey },
})
```
Need support for another ERC-4337 provider? [Open an issue](https://github.com/rhinestonewtf/sdk/issues).
## Usage
ERC-4337 has a separate transaction API. Use it when a module requires the user-op flow — intent flows continue to use `prepareTransaction`.
```ts theme={null}
const prepared = await rhinestoneAccount.prepareUserOperation({
chain: base,
calls: [
{
to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
value: 0n,
data: '0xdeadbeef',
},
],
})
const signed = await rhinestoneAccount.signUserOperation(prepared)
const result = await rhinestoneAccount.submitUserOperation(signed)
const status = await rhinestoneAccount.waitForExecution(result)
```
# Message signing
Source: https://docs.rhinestone.dev/smart-wallet/advanced/message-signing
You can sign messages with your smart account. This can be helpful for [Permit2](https://github.com/Uniswap/permit2), [SIWE](https://eips.ethereum.org/EIPS/eip-4361), or signing the offchain orders.
Signing works for both deployed and undeployed smart accounts (via [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)).
## Ethereum Signed Message (EIP-191)
To sign a message:
```ts theme={null}
const signature = await rhinestoneAccount.signMessage(
'Hello, world!',
chain,
)
```
You can also sign hex values directly:
```ts theme={null}
const signature = await rhinestoneAccount.signMessage(
{
raw: '0x1234',
},
chain,
)
```
## Typed Data (EIP-712)
```ts theme={null}
const signature = await rhinestoneAccount.signTypedData(
{
domain: {
name: 'Ether Mail',
version: '1',
chainId: 1,
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
},
types: {
Person: [
{ name: 'name', type: 'string' },
{ name: 'wallet', type: 'address' },
],
Mail: [
{ name: 'from', type: 'Person' },
{ name: 'to', type: 'Person' },
{ name: 'contents', type: 'string' },
],
},
primaryType: 'Mail',
message: {
from: {
name: 'Cow',
wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
},
to: {
name: 'Bob',
wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
},
contents: 'Hello, Bob!',
},
},
chain,
)
```
## Configuration
### Chain
Signatures are chain-specific (they will vary based on whether the account is deployed or not).
### Custom signers
You can also choose which signers to sign the message with. This is helpful when you have multiple validators, use a multisig, or session keys.
# Migration guide
Source: https://docs.rhinestone.dev/smart-wallet/advanced/migration-guide
## Migrating from 1.x SDK
To use the latest version of the SDK:
```bash theme={null}
npm i @rhinestone/sdk
```
### ESM-only build
The SDK is now ESM-only. `require('@rhinestone/sdk')` no longer works — use ESM `import` syntax. Internal subpath imports were also removed; use the curated entry points (`./actions/*`, `./errors`, `./utils`, `./smart-sessions`, `./jwt-server`).
### `sendTransaction` removed
The `account.sendTransaction(transaction)` shortcut is gone. Use the explicit `prepareTransaction → signTransaction → submitTransaction` flow:
```ts theme={null}
// Before
const result = await rhinestoneAccount.sendTransaction({
targetChain,
calls,
tokenRequests,
})
// After
const prepared = await rhinestoneAccount.prepareTransaction({
targetChain,
calls,
tokenRequests,
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const result = await rhinestoneAccount.submitTransaction(signed)
```
`sendUserOperation` for ERC-4337 flows is unchanged.
### Session permissions are ABI-driven
`Session.actions` is gone. Build sessions with `rhinestone.createSession({ chain, owners, permissions })` instead — an ABI-driven definition the SDK resolves into a low-level `Session`. Each permission is an `{ abi, address, functions }` entry; function selectors and param calldata offsets are derived from the ABI, and param value types are checked against ABI input types:
```ts theme={null}
// Before
const session: Session = {
chain: base,
owners: { type: 'ecdsa', accounts: [sessionOwner] },
actions: [
{
target: usdcAddress,
selector: toFunctionSelector(
getAbiItem({ abi: erc20Abi, name: 'transfer' }),
),
policies: [
{
type: 'universal-action',
rules: [
{
condition: 'equal',
calldataOffset: 0n,
referenceValue: recipient,
},
],
},
],
},
],
}
// After
const session = await rhinestone.createSession({
chain: base,
owners: { type: 'ecdsa', accounts: [sessionOwner] },
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
params: {
recipient: { condition: 'equal', value: recipient },
},
},
},
},
],
})
```
The hand-written shape is now `SessionDefinition`; `Session` is the resolved output of `createSession`.
### Session creation resolves wrapped-native tokens
`toSession` no longer reads bundled chain data. Calling `toSession(definition)` without options omits the wrapped-native `deposit()` permission, so a session that later needs to wrap native tokens can fail.
Use the project-scoped `RhinestoneSDK.createSession` method for the normal online flow. It resolves the chain's wrapped-native token from the orchestrator and fails if the chain is unsupported or does not advertise one:
```ts theme={null}
const session = await rhinestone.createSession(definition)
```
For a fully offline flow, keep using `toSession` and pass the wrapped-native token explicitly:
```ts theme={null}
import { toSession } from '@rhinestone/sdk/smart-sessions'
const session = toSession(definition, {
wrappedNativeToken,
})
```
For normal online usage, prefer [`RhinestoneSDK.createSession`](/sdk-reference/rhinestone-sdk/create-session) so the SDK resolves the wrapped-native token for you.
### Session policies are declarative
In 1.x, each `action` carried a raw `policies` array that you assembled by hand. Those arrays are gone — policies are now expressed through fields on the permission's function config, and `createSession` compiles them into the right on-chain policies:
* `params: { x: { anyOf: [a, b] } }` — allowlist a parameter against several values (compiles to an arg-policy OR chain). A single `{ condition, value }` stays a cheaper universal-action.
* `maxUses` — cap how many times the function may be called.
* `validUntil` / `validAfter` — restrict the function to a time window (`Date`).
* `valueLimit` — cap cumulative ETH value (payable functions only; rejected at compile time otherwise).
* `spendingLimit: { token, amount }` — cap cumulative ERC-20 spend (only on `transfer` / `transferFrom` / `approve` / `increaseAllowance`-shaped functions).
```ts theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: { type: 'ecdsa', accounts: [sessionOwner] },
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
params: { recipient: { anyOf: [alice, bob] } },
spendingLimit: { token: usdcAddress, amount: 1_000_000n },
maxUses: 10n,
},
},
},
],
})
```
Passing a raw `policies` array on a permission now throws instead of being silently dropped. See [Policies](../smart-sessions/policies/sudo) for the full set.
### Session policy addresses
The smart session policies were redeployed, and `createSession` now bakes the new addresses into the session digest by default. If an account already enabled sessions against the previous deployments, trying to use the existing session with the new policies results in an onchain revert due to the digest mismatch.
Pin the affected policies back to the addresses the account was enabled against via `SessionDefinition.policyAddresses`:
```ts theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: { type: 'ecdsa', accounts: [sessionOwner] },
permissions: [/* … */],
policyAddresses: {
// legacy V1 sudo policy
sudo: '0x0000003111cD8e92337C100F22B7A9dbf8DEE301',
},
})
```
The singletons that changed between V1 and V2 — pin the ones your account's sessions were enabled against:
| Policy | Key | V1 address | V2 address (default) |
| ----------------------------------------------------------- | ----------------- | -------------------------------------------- | -------------------------------------------- |
| [Sudo](../smart-sessions/policies/sudo) | `sudo` | `0x0000003111cD8e92337C100F22B7A9dbf8DEE301` | `0x0000000000FEEc8D74e3143fBaBbca515358d869` |
| [Call](../smart-sessions/policies/call) | `universalAction` | `0x0000006DDA6c463511C4e9B05CFc34C1247fCF1F` | `0x0000000000714Cf48FcF88A0bFBa70d313415032` |
| Arg policy | `argPolicy` | — (new in V2) | `0x0000000000167edE64D8751daACDdC0312565a73` |
| [Spending limit](../smart-sessions/policies/spending-limit) | `spendingLimits` | `0x00000088D48cF102A8Cdb0137A9b173f957c6343` | `0x000000000033212E272655D8a22402Db819477A6` |
| [Timeframe](../smart-sessions/policies/timeframe) | `timeFrame` | `0x8177451511dE0577b911C254E9551D981C26dc72` | `0x0000000000D30f611fA3bf652ac6879428586930` |
| [Usage limit](../smart-sessions/policies/usage-limit) | `usageLimit` | `0x1F34eF8311345A3A4a4566aF321b313052F51493` | `0x00000000001d4479FA2A947026204d0283ceDe4B` |
| Value limit | `valueLimit` | `0x730DA93267E7E513e932301B47F2ac7D062abC83` | `0x000000000021dC45451291BCDfc9f0B46d6f0278` |
### Quote selection
`prepareTransaction` now returns `quotes: { best, all }` instead of a single `quote`. Existing `prepare → sign → submit` code keeps working — `signTransaction` defaults to `quotes.best`:
```ts theme={null}
const prepared = await rhinestoneAccount.prepareTransaction({
// …
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const result = await rhinestoneAccount.submitTransaction(signed)
```
To sign a non-default route, pass an `intentId` from `prepared.quotes.all`:
```ts theme={null}
const prepared = await rhinestoneAccount.prepareTransaction({
// …
})
const chosen =
prepared.quotes.all.find((q) => q.settlementLayer === 'across') ??
prepared.quotes.best
const signed = await rhinestoneAccount.signTransaction(prepared, {
intentId: chosen.intentId,
})
```
`getTransactionMessages(prepared, { intentId })` accepts the same selection so external signers see the route `signTransaction` will sign.
### Settlement layer filter
`settlementLayers` is no longer a bare array. It's a discriminated union with `include` / `exclude` so you can blacklist a single layer without listing all the others:
```ts theme={null}
// Before
await rhinestoneAccount.prepareTransaction({
// …
settlementLayers: ['ACROSS', 'ECO'],
})
// After
await rhinestoneAccount.prepareTransaction({
// …
settlementLayers: { include: ['ACROSS', 'ECO'] },
})
```
The same shape applies to `splitIntents`. See [Settlement Layers](../chain-abstraction/multi-chain-intent#settlement-layers) for usage patterns.
### `submitTransaction` options bag
`submitTransaction` now takes an options object instead of positional arguments:
```ts theme={null}
// Before
await rhinestoneAccount.submitTransaction(signed, authorizations)
// After
await rhinestoneAccount.submitTransaction(signed, { authorizations })
```
### `waitForExecution` no longer accepts preconfirmations
The `acceptsPreconfirmations` parameter is removed. `waitForExecution` always waits for `FILLED` / `COMPLETED` and never treats `PRECONFIRMED` as terminal:
```ts theme={null}
// Before
await rhinestoneAccount.waitForExecution(result, false)
// After
await rhinestoneAccount.waitForExecution(result)
```
### `experimental_` prefix dropped from the smart session API
The smart session API is now stable, so the `experimental_` prefix is gone. Drop it across your integration:
| Area | Before | After |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Account config | `experimental_sessions` | `sessions` |
| Signer set | `type: 'experimental_session'` | `type: 'session'` |
| Account methods | `experimental_getSessionDetails`, `experimental_isSessionEnabled`, `experimental_signEnableSession` | `getSessionDetails`, `isSessionEnabled`, `signEnableSession` |
| `@rhinestone/sdk/actions/smart-sessions` | `experimental_enable`, `experimental_disable`, `experimental_enableSession`, `experimental_disableSession` | `enable`, `disable`, `enableSession`, `disableSession` |
### `verifyExecutions` removed from session signers
`SingleSessionSignerSet`, `PerChainSessionSignerSet`, and `ChainSessionConfig` no longer accept `verifyExecutions`. The SDK now derives it from the session shape — sessions with `permissions` use emissary execution validation, claim-only sessions use the EIP-1271 path — so the flag is redundant. Drop it from your signer set:
```ts theme={null}
// Before
const signers = {
type: 'experimental_session',
session,
verifyExecutions: true,
enableData,
}
// After
const signers = {
type: 'session',
session,
enableData,
}
```
### Passport account removed
`account.type: 'passport'` is no longer accepted. The `PassportAccount` type and the `passport` member of `AccountType` / `AccountProviderConfig` are removed.
### Older account versions removed
The selectable `account.version` values are trimmed to the versions we actively support:
* **Nexus** keeps `1.2.0` and `1.2.1` (default `1.2.1`). Removed `1.0.2`, `rhinestone-1.0.0-beta`, and `rhinestone-1.0.0`.
* **Kernel** keeps `3.3` only. Removed `3.1` and `3.2`.
If you pinned a removed version, omit `version` to use the default, or pin a supported one.
### Intent status by ID
`getIntentStatus` now takes a `string` instead of a `bigint`. If you persist intent IDs across runs, switch the storage type to string.
### Portfolio shape
`PortfolioToken` no longer carries a token-level `decimals` or aggregate `balances`. `decimals` now lives on each per-chain `chains[]` entry alongside `address` and `amount`, since the same logical token can have different decimals across chains (e.g., USDC is 6 on Ethereum, 18 on BSC). Read the per-chain entry directly when rendering balances.
### Alchemy provider config removed
The SDK no longer builds Alchemy URLs from an API key. Supply the RPC URLs through the custom provider config, or omit `provider` to use viem's default transport:
```ts theme={null}
// Before
const rhinestone = new RhinestoneSDK({
apiKey,
provider: {
type: 'alchemy',
apiKey: alchemyApiKey,
},
})
// After
const rhinestone = new RhinestoneSDK({
apiKey,
provider: {
type: 'custom',
urls: {
[base.id]: baseRpcUrl,
[arbitrum.id]: arbitrumRpcUrl,
},
},
})
```
### Permit2 claim policy renames
If you constructed `Permit2ClaimPolicy` values directly, the type tag and field names changed to be chain-aware:
```ts theme={null}
// Before
const policy: Permit2ClaimPolicy = {
type: 'permit2-claim',
arbiters: [spender],
tokensIn: [{ chainId: base.id, token: usdcAddress }],
tokensOut: [{ chainId: base.id, token: outputToken }],
recipients: [{ chainId: base.id, recipient: 'any' }],
recipientIsSponsor: true,
expiryBounds: { min: 1n, max: 100n },
fillExpiryBounds: [{ chainId: base.id, min: 1n, max: 100n }],
}
// After
const policy: Permit2ClaimPolicy = {
type: 'permit2',
spenders: [spender],
sourceTokens: [{ chain: base, address: usdcAddress }],
destinationTokens: [{ chain: base, address: outputToken }],
recipients: [{ chain: base, address: 'any' }],
recipientIsAccount: true,
permitDeadline: { min: 1n, max: 100n },
fillDeadline: [{ chain: base, min: 1n, max: 100n }],
}
```
### Token registry helpers removed
The SDK no longer bundles the supported-chain and token registry. `SupportedChain` is now `number`, so code must not assume that the installed SDK contains an exhaustive union of chain IDs.
`getSupportedTokens`, `getTokenAddress`, `getTokenDecimals`, `getAllSupportedChainsAndTokens`, `getWethAddress`, `getTokenSymbol`, and `isTokenAddressSupported` are removed. Fetch the supported chains, token metadata, and `wrappedNativeToken` from the orchestrator's `/chains` endpoint:
```ts theme={null}
const response = await fetch('https://v1.orchestrator.rhinestone.dev/chains', {
headers: { 'x-api-key': apiKey },
})
const chains = await response.json()
```
### Token inputs are addresses
SDK token inputs no longer accept symbols like `'USDC'` — pass the token's **address** for the relevant chain. This applies to `calls[].to`, `tokenRequests[].address`, `sourceAssets` (both the token-list and per-chain-map forms), and cross-chain permit legs (`from` / `to`).
```ts theme={null}
// Before
tokenRequests: [{ address: 'USDC', amount }]
// After — use the token's address on the target chain
const usdc = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // USDC on Base
tokenRequests: [{ address: usdc, amount }]
```
Look up token addresses per chain from the orchestrator's `/chains` endpoint (see [Token registry helpers removed](#token-registry-helpers-removed)).
### `deployAccountsForOwners` removed
Create a backend deployer account, take a view-only reference to each user account, and submit a sponsored intent that calls `deploy(userAccount)`. Pass multiple `deploy(...)` calls in one intent to batch deployments.
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { deploy } from '@rhinestone/sdk/actions'
import { toViewOnlyAccount } from '@rhinestone/sdk/utils'
const rhinestone = new RhinestoneSDK({ apiKey })
const deployerAccount = await rhinestone.createAccount({
owners: { type: 'ecdsa', accounts: [deployerSigner] },
})
const userAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [toViewOnlyAccount(userAddress)],
},
})
const prepared = await deployerAccount.prepareTransaction({
chain,
calls: [deploy(userAccount)],
sponsored: true,
})
const signed = await deployerAccount.signTransaction(prepared)
await deployerAccount.submitTransaction(signed)
```
### `checkERC20AllowanceDirect` removed
Read allowances directly with viem's `readContract`:
```ts theme={null}
const allowance = await publicClient.readContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'allowance',
args: [owner, spender],
})
```
### ENS validator owner set
`ENSValidatorConfig` couples each owner with its expiry instead of using parallel arrays. Omit `expiration` for an owner that never expires:
```ts theme={null}
// Before — parallel arrays, expirations in unix seconds
const validator = {
type: 'ens',
accounts: [alice, bob],
ownerExpirations: [1893456000, 1893456000],
}
// After — each owner carries its own expiry (omit `expiration` to never expire)
const validator = {
type: 'ens',
owners: [
{ account: alice, expiration: new Date('2030-01-01') },
{ account: bob, expiration: new Date('2030-01-01') },
],
}
```
### Removed and relocated helpers
* **`createRhinestoneAccount`** is removed. Use `new RhinestoneSDK({ apiKey }).createAccount(config)`.
* **Account recovery.** The `@rhinestone/sdk/actions/recovery` subpackage (`enable`, `recoverEcdsaOwnership`, `recoverPasskeyOwnership`), the `recovery` field on the account config, and the guardian signer set are removed.
* **`account.deploy()` session param.** The unused `session` option on `account.deploy()` is removed (it was a no-op).
* **Compact-bound surface.** The `@rhinestone/sdk/actions/compact` subpackage, the `lockFunds` transaction option, and `Account.emissaryConfig` are removed alongside the orchestrator's compact-based deposit/withdrawal flow.
* **Permit2 signing helpers.** `signPermit2Batch`, `signPermit2Sequential`, and the related `MultiChainPermit2Config` / `MultiChainPermit2Result` / `BatchPermit2Result` types are removed. Signing now uses orchestrator-provided EIP-712 typed data internally.
* **`getPermit2Address`** is removed. Permit2 lives at `0x000000000022D473030F116dDEE9F6B43aC78BA3` on every supported chain — hardcode the constant.
* **`walletClientToAccount` and `wrapParaAccount`** moved from the package root to `@rhinestone/sdk/utils`.
## Migrating from 1.x Alpha SDK
### New entry point
`RhinestoneSDK` is now the main entry point to the SDK functionality.
To migrate, change the account creation code:
```ts theme={null}
const rhinestoneAccount = await createRhinestoneAccount({
// Optional
account: {
'type': 'nexus'
},
owners: {
type: "ecdsa" as const,
accounts: [owner],
},
// …
// Optional
rhinestoneApiKey,
// Optional
orchestratorUrl,
});
```
to this:
```ts theme={null}
const rhinestone = new RhinestoneSDK({
apiKey: rhinestoneApiKey,
// Optional
endpointUrl: orchestratorUrl,
})
const rhinestoneAccount = await rhinestone.createAccount({
// Optional
account: {
'type': 'nexus'
},
owners: {
type: "ecdsa" as const,
accounts: [owner],
},
// …
});
```
### Transaction utilities (actions)
Action utilities related to using modules and resource locking (e.g., `installModule`, `addOwner`, `recoverEcdsaOwnership`) were moved to separate subpackages:
```ts theme={null}
// Before
import { addPasskeyOwner } from '@rhinestone/sdk'
// After
import { addOwner as addPasskeyOwner } from '@rhinestone/sdk/actions/passkeys'
```
Additionally, you don't need to pass `rhinestoneAccount`, `address`, `chain`, and `provider` params anymore when using actions:
```ts theme={null}
// Before
await rhinestoneAccount.sendTransaction({
calls: [
...installModule({
rhinestoneAccount,
module,
})
],
// …
})
// After
await rhinestoneAccount.sendTransaction({
calls: [
// Note that you don't need to spread the actions anymore
installModule(module)
],
// …
})
```
* `/actions`:
* `installModule` to install a module
* `uninstallModule` to uninstall a module
* `/actions/compact` (resource locking with TheCompact):
* `depositEther` to deposit ETH into TheCompact
* `enableEtherWithdrawal` to enable permissionless ETH withdrawal (starts reset period)
* `disableEtherWithdrawal` to cancel permissionless ETH withdrawal
* `withdrawEther` to withdraw ETH after the reset period
* `approveErc20` to approve an ERC-20 token for deposit
* `depositErc20` to deposit ERC-20 into TheCompact
* `enableErc20Withdrawal` to enable permissionless ERC-20 withdrawal (starts reset period)
* `disableErc20Withdrawal` to cancel permissionless ERC-20 withdrawal
* `withdrawErc20` to withdraw ERC-20 after the reset period
* `/actions/ecdsa` (ECDSA validator):
* `enable` to enable the validator
* `disable` to disable the validator
* `addOwner` to add an owner
* `removeOwner` to remove an owner
* `changeThreshold` to change the signature threshold
* `/actions/mfa` (multi-factor authorization):
* `enable` to enable the validator
* `disable` to disable the validator
* `setSubValidator` to add a sub-validator to the MFA set
* `removeSubValidator` to remove a sub-validator from the MFA set
* `changeThreshold` to change the MFA signature threshold
* `/actions/passkeys` (passkey validator):
* `enable` to enable the validator
* `disable` to disable the validator
* `addOwner` to add an owner
* `removeOwner` to remove an owner
* `changeThreshold` to change the signature threshold
* `/actions/recovery` (social recovery):
* `enable` to enable the validator
* `recoverEcdsaOwnership` to recover ownership to a new ECDSA owner
* `recoverPasskeyOwnership` to recover ownership to a new passkey owner
### Errors
Error classes were moved to a separate subpackage:
```ts theme={null}
// Before
import { isAccountError, AccountError, SigningNotSupportedForAccountError } from '@rhinestone/sdk'
// After
import { isAccountError, AccountError, SigningNotSupportedForAccountError } from '@rhinestone/sdk/errors'
```
### Using ERC-4337 flow
All transactions executed with `sendTransaction` and `prepareTransaction` now use Rhinestone intents.
Using the ERC-4337 user operations (for example, when using a social recovery) now requires a separate flow.
This change lets us improve type-safety and DX around using intents.
To keep using user operations for specific flows, change your code from:
```ts theme={null}
const result = await rhinestoneAccount.sendTransaction(
// …
)
```
and
```ts theme={null}
const data = await rhinestoneAccount.prepareTransaction({
// …
})
const signedData = await rhinestoneAccount.signTransaction(data)
const result = await rhinestoneAccount.submitTransaction(signedData)
```
to:
```ts theme={null}
const result = await rhinestoneAccount.sendUserOperation(
// …
)
```
and:
```ts theme={null}
const data = await rhinestoneAccount.prepareUserOperation({
// …
})
const signedData = await rhinestoneAccount.signUserOperation(data)
const result = await rhinestoneAccount.submitUserOperation(signedData)
```
## Migrating from 0.x SDK
To use the latest version of the SDK, install it with the `alpha` tag:
```bash theme={null}
npm i @rhinestone/sdk@alpha
```
Note that the `deployerAccount` parameter has been removed, as all deployments are now handled via [the Orchestrator](../../home/introduction/rhinestone-intents#the-orchestrator).
Also, `sourceChains` now accepts a list of chains instead of a single chain.
Due to the module address changes, you'd need to redeploy and refund the accounts.
## Migrating from Orchestrator SDK
This guide provides a detailed breakdown of the changes between the Orchestrator SDK and the new SDK. If you're looking for a fresh start, see our [Quickstart](../quickstart).
### Installation
Previously:
```bash theme={null}
npm i @rhinestone/module-sdk @rhinestone/orchestrator-sdk permissionless viem
```
Now:
```bash theme={null}
npm i @rhinestone/sdk viem
```
### Account Creation
#### Choosing an account implementation
Before, you'd need to construct a smart account client with `permissionless`:
```ts theme={null}
const sourceSafeAccount = await toSafeSmartAccount({
version: "1.4.1",
entryPoint: {
address: entryPoint07Address,
version: "0.7",
},
// …
});
const smartAccountClient = createSmartAccountClient({
account: sourceSafeAccount,
chain: sourceChain,
// …
}).extend(erc7579Actions());
```
Now:
```ts theme={null}
const account = await createRhinestoneAccount({
account: {
type: 'safe'
}
})
```
The `account` object is multi-chain; you don't need to create separate instances for each chain.
See [Smart Account Providers](../customize/smart-account-providers) for details on choosing the account implementation.
#### Choosing a validator
Before, you'd specify the validator config in your smart account setup:
```ts theme={null}
const owner = privateKeyToAccount(generatePrivateKey());
const ownableValidator = getOwnableValidator({
owners: [owner.address],
threshold: 1,
});
const sourceSafeAccount = await toSafeSmartAccount({
validators: [
{
address: ownableValidator.address,
context: ownableValidator.initData,
},
],
// …
});
const sourceSmartAccountClient = createSmartAccountClient({
account: sourceSafeAccount,
chain: sourceChain,
bundlerTransport: http(
`https://api.pimlico.io/v2/${sourceChain.id}/rpc?apikey=${pimlicoApiKey}`,
),
paymaster: sourcePimlicoClient,
userOperation: {
estimateFeesPerGas: async () => {
return (await sourcePimlicoClient.getUserOperationGasPrice()).fast;
},
},
}).extend(erc7579Actions());
```
Now, you can use `owners` when creating the account:
```ts theme={null}
const owner = privateKeyToAccount(generatePrivateKey());
const account = await createRhinestoneAccount({
owners: {
type: 'ecdsa',
accounts: [owner],
},
})
```
Learn more about using the [ECDSA](../core/ecdsa-signer) and [passkey](../core/passkeys) validators as the account owner.
If you are using Smart Sessions, see the relevant [guide](../smart-sessions/overview).
#### Setting up Omni Account modules
Before, you'd need to provide the module configurations for the Omni Account manually:
```ts theme={null}
const sourceSafeAccount = await toSafeSmartAccount({
// …
executors: [
{
address: getSameChainModuleAddress(targetChain.id),
context: "0x",
},
{
address: getTargetModuleAddress(targetChain.id),
context: "0x",
},
{
address: getHookAddress(targetChain.id),
context: "0x",
},
],
hooks: [
{
address: getHookAddress(targetChain.id),
context: encodeAbiParameters(
[
{ name: "hookType", type: "uint256" },
{ name: "hookId", type: "bytes4" },
{ name: "data", type: "bytes" },
],
[
0n,
"0x00000000",
encodeAbiParameters([{ name: "value", type: "bool" }], [true]),
],
),
},
],
fallbacks: [
{
address: getTargetModuleAddress(targetChain.id),
context: encodeAbiParameters(
[
{ name: "selector", type: "bytes4" },
{ name: "flags", type: "bytes1" },
{ name: "data", type: "bytes" },
],
["0x3a5be8cb", "0x00", "0x"],
),
},
],
});
```
Now, that is handled automatically for you.
Under the hood, the SDK installs a single executor module that handles chain abstraction operations.
### Initializing the Orchestrator Client
Before, you'd initialize an Orchestrator API client:
```ts theme={null}
const orchestrator = getOrchestrator(orchestratorApiKey);
```
Now, you need to pass the API key directly to the account instance:
```ts theme={null}
const account = privateKeyToAccount(generatePrivateKey());
const account = await createRhinestoneAccount({
rhinestoneApiKey: orchestratorApiKey,
// …
})
```
### Funding
As before, you can send the tokens or ETH directly to the account to fund it.
### Deploying
Before, you'd deploy the smart account using an ERC-4337 bundler:
```ts theme={null}
const opHash = await sourceSmartAccountClient.sendTransaction({
to: zeroAddress,
data: "0x11111111",
});
await sourcePublicClient.waitForTransactionReceipt({
hash: opHash,
});
```
Now, you can use the `deploy` method:
```ts theme={null}
await rhinestoneAccount.deploy(chain)
```
### Fetching the Order Path
Before, you define the intent and use `getOrderPath` to get the path.
```ts theme={null}
const usdcAddress = getTokenAddress("USDC", targetChain.id);
const usdcAmount = 2n;
const recipient = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045';
const metaIntent: MetaIntent = {
targetChainId: targetChain.id,
tokenRequests: [
{
tokenAddress: usdcAddress,
amount: usdcAmount,
},
],
targetAccount: targetSafeAccount.address,
targetExecutions: [
{
to: usdcAddress,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [recipient, usdcAmount],
}),
},
],
};
const orderPath = await orchestrator.getOrderPath(
metaIntent,
targetAccount.address,
);
```
Now, you can call `prepareTransaction`:
```ts theme={null}
const usdcAmount = 2n;
const recipient = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045';
// Token inputs are addresses now (symbols are no longer accepted).
// Look up the token address for your target chain via GET /chains.
const usdcAddress = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base
const transactionData = await rhinestoneAccount.prepareTransaction({
targetChain,
calls: [
{
to: usdcAddress,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipient, usdcAmount],
}),
},
],
tokenRequests: [
{
address: usdcAddress,
amount: usdcAmount,
},
],
})
```
### Signing the Intent
Before, you'd craft the packed signature and pass that to the bundle structure:
```ts theme={null}
const orderBundleHash = getOrderBundleHash(orderPath[0].orderBundle);
const bundleSignature = await owner.signMessage({
message: { raw: orderBundleHash },
});
const packedSig = encodePacked(
["address", "bytes"],
[ownableValidator.address, bundleSignature],
);
const signedOrderBundle: SignedMultiChainCompact = {
...orderPath[0].orderBundle,
originSignatures: Array(orderPath[0].orderBundle.segments.length).fill(
packedSig,
),
targetSignature: packedSig,
};
```
Now, you can use the `signTransaction` method:
```ts theme={null}
const signedTransactionData =
await rhinestoneAccount.signTransaction(transactionData)
```
### Sending the Intent
Before, you'd use the `postSignedOrderBundle` to submit the intent to the orchestrator:
```ts theme={null}
const bundleResults: PostOrderBundleResult = await orchestrator.postSignedOrderBundle([
{
signedOrderBundle,
},
]);
```
Now, you can use the `submitTransaction` method:
```ts theme={null}
const result = await rhinestoneAccount.submitTransaction(signedTransactionData)
```
### Getting the Intent Status
Before, you'd poll the `getBundleStatus` method to get bundle status updates:
```ts theme={null}
const bundleStatus = await orchestrator.getBundleStatus(
bundleResults[0].bundleId,
);
```
Now, you can use the `waitForExecution` method:
```ts theme={null}
const status = await rhinestoneAccount.waitForExecution(result)
```
### Using with Existing Accounts
For now, using the SDK with existing accounts is not possible. Users would need to create a new smart account.
We're working on making it possible to use the SDK with existing (deployed) smart accounts.
[Reach out](http://t.me/kurt_larsen) if you need this.
# Multi-factor authentication
Source: https://docs.rhinestone.dev/smart-wallet/advanced/multi-factor-authentication
Using multiple validators in tandem
MFA (multi-factor authorization) validator lets you use multiple validators for a single transaction.
MFA works as a layer on top of other validator modules. It works as a multiplexer for the validator modules, calling the underlying validator implementations to validate the transaction. It also lets you have multiple configurations for the same validator module.
For example, you can set up an MFA validator so that the user needs to sign a transaction with both an EOA and a passkey.
## Subvalidators
*Subvalidators* are the validator modules that are used under the hood to validate the signature. Any ERC-7579 validator module can serve as a subvalidator.
Subvalidator ID is the index of the validator in the validator list. For example, if you installed the ECDSA and the passkey modules as subvalidators, the ECDSA validator will have index 0 and the passkey validator will have index 1.
## Initialization
To create an account with MFA:
```ts theme={null}
const rhinestone = new RhinestoneSDK()
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'multi-factor',
// Require a valid signature from both an EOA and a passkey signer
threshold: 2,
// List of subvalidators to use; multiple validators can have the same type
validators: [
{
type: 'ecdsa',
accounts: [accountA, accountB],
},
{
type: 'passkey',
accounts: [passkeyAccount],
},
],
},
})
```
You can also install the MFA module on an existing account:
```ts theme={null}
const rhinestone = new RhinestoneSDK()
const rhinestoneAccount = await rhinestone.createAccount({
// …
});
const transaction = await rhinestoneAccount.prepareTransaction({
chain: sourceChain,
calls: [
enableMultiFactor([
{
type: 'ecdsa',
accounts: [accountA, accountB],
},
{
type: 'passkey',
accounts: [passkeyAccount],
},
]),
],
})
```
## Usage
### Signer Selection
```ts {10-26} theme={null}
const transactionData = await rhinestoneAccount.prepareTransaction({
sourceChains: [sourceChain],
targetChain,
calls: [
{
to: zeroAddress,
data: '0xdeadbeef',
},
],
signers: {
type: 'owner',
kind: 'multi-factor',
validators: [
{
type: 'ecdsa',
id: 0,
// Since we didn't set the "threshold" for the ECDSA validator, we can sign with a single account here
accounts: [accountB],
},
{
type: 'ecdsa',
id: 1,
accounts: [passkeyAccount],
},
],
},
})
```
## Management
### Adding a validator
You can also use this to update the config of the existing subvalidator.
```ts theme={null}
const validatorId = 2;
const transaction = await rhinestoneAccount.prepareTransaction({
chain: sourceChain,
calls: [
setSubValidator(validatorId, {
type: 'ecdsa',
accounts: [accountC],
}),
],
})
```
### Removing a validator
```ts theme={null}
const validatorId = 2;
const transaction = await rhinestoneAccount.prepareTransaction({
chain: sourceChain,
calls: [
removeSubValidator(validatorId, {
type: 'ecdsa',
accounts: [accountC],
}),
],
})
```
## Example: EOA + passkey (2-of-2)
End to end: create an account that requires both an EOA and a passkey, then approve a transaction with both factors.
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { toWebAuthnAccount } from 'viem/account-abstraction'
import { base, arbitrum } from 'viem/chains'
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
const eoaAccount = privateKeyToAccount(generatePrivateKey())
const passkeyAccount = toWebAuthnAccount({ credential })
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
// Require a signature from both the EOA and the passkey
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'multi-factor',
threshold: 2,
validators: [
{ type: 'ecdsa', accounts: [eoaAccount] },
{ type: 'passkey', accounts: [passkeyAccount] },
],
},
})
const usdcAmount = parseUnits('0.1', 6)
const usdc = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' // USDC on Arbitrum
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
{
to: usdc,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: ['0xd8da6bf26964af9d7eed9e03e53415d37aa96045', usdcAmount],
}),
},
],
tokenRequests: [{ address: usdc, amount: usdcAmount }],
// Provide both factors. `id` is each validator's index in the list above.
signers: {
type: 'owner',
kind: 'multi-factor',
validators: [
{ type: 'ecdsa', id: 0, accounts: [eoaAccount] },
{ type: 'passkey', id: 1, accounts: [passkeyAccount] },
],
},
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
const result = await rhinestoneAccount.waitForExecution(transaction)
```
# Recovery
Source: https://docs.rhinestone.dev/smart-wallet/advanced/recovery
Recover an account using guardians
## Overview
Social recovery allows users to set one or multiple accounts as *guardians*. Guardians can recover access to the account by approving a change to the validator configuration.
For example, if a user loses access to their key, guardians are able to rotate the signer to a new ECDSA key. Or if a smart account has a multisig configuration, guardians can be used to recover a signer of the multisig or change the threshold.
Guardians can only update the validator configuration, they are not permitted to make any other transactions.
Set guardians carefully! They can change the ownership of a smart account without any approval from the previous owner. There is no timelock for the recovery transaction. Prefer setting multiple trusted guardians with a higher signature threshold.Recovery runs over [ERC-4337](../advanced/erc4337), so it needs a bundler. Guardians can only sign user operations — passing them to `prepareTransaction`, `signMessage`, or `signTypedData` throws, since the recovery module cannot verify signatures on those paths.Recovery produces multiple account calls. Send each one as its own user operation, in the order returned — the recovery module authorizes a single call per user operation, so batching them is rejected onchain.
## Initialization
To install a social recovery module during account deployment:
```ts {6-8} theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [oldAccount],
},
recovery: {
guardians: [guardianAccount],
},
})
```
To install the module separately (i.e., when the account is already deployed):
```ts {5} theme={null}
import { enable as enableRecovery } from '@rhinestone/sdk/actions/recovery'
const setUpTransaction = await rhinestoneAccount.sendUserOperation({
chain,
calls: [enableRecovery([guardianAccount])],
})
await rhinestoneAccount.waitForExecution(setUpTransaction)
```
### Multiple guardians
You can also set multiple guardians for a single account, and use a custom signature threshold:
```ts {6-9} theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [oldAccount],
},
recovery: {
guardians: [guardianAccountA, guardianAccountB, guardianAccountC],
threshold: 2,
},
})
```
Every guardian you pass to `signers` must sign, and you need at least as many as the configured `threshold`.
## Usage
To recover access to the account:
```ts {7-10,17-20} theme={null}
import { recoverEcdsaOwnership } from '@rhinestone/sdk/actions/recovery'
const recoveryCalls = await recoverEcdsaOwnership({
accountAddress: rhinestoneAccount.getAddress(),
chain,
config: rhinestoneAccount.config,
newOwners: {
type: 'ecdsa',
accounts: [newOwnerAccount],
},
})
for (const call of recoveryCalls) {
const transaction = await rhinestoneAccount.sendUserOperation({
chain,
calls: [call],
signers: {
type: 'guardians',
guardians: [guardianAccountA],
},
})
await rhinestoneAccount.waitForExecution(transaction)
}
```
This prompts a signature from each guardian account and submits a transaction on their behalf to update the ownership. Existing owners not listed in `newOwners` are removed.
To recover access to the account:
```ts {6-7,24-27} theme={null}
import { recoverPasskeyOwnership } from '@rhinestone/sdk/actions/recovery'
import { parsePublicKey } from '@rhinestone/sdk/signing/passkeys'
// The account's complete current credential set. The validator stores
// credentials hashed, so the set cannot be read back onchain.
const { x, y } = parsePublicKey(passkeyAccountA.publicKey)
const currentCredentials = [{ pubKeyX: x, pubKeyY: y }]
const recoveryCalls = await recoverPasskeyOwnership({
accountAddress: rhinestoneAccount.getAddress(),
chain,
config: rhinestoneAccount.config,
currentCredentials,
newOwners: {
type: 'passkey',
accounts: [passkeyAccountB],
},
})
for (const call of recoveryCalls) {
const transaction = await rhinestoneAccount.sendUserOperation({
chain,
calls: [call],
signers: {
type: 'guardians',
guardians: [guardianAccountA],
},
})
await rhinestoneAccount.waitForExecution(transaction)
}
```
This prompts a signature from each guardian account and submits a transaction on their behalf to update the ownership.
Pass the account's **complete** current credential set as `currentCredentials`, not just the ones you are replacing. Credentials missing from `newOwners` are removed, and anything already listed is not added again — a partial set makes the recovery re-add an installed credential, which reverts.Ownership is only fully rotated once every call has landed. New owners are added before the old ones are removed, so until the final call executes both remain valid.
## Nexus accounts
Nexus accounts must use Ownable V0 as their owner module for social recovery. Pass the same module address in both the account configuration and `newOwners` when recovering the account. Ownable V0 does not support legible EIP-712 signing; typed data and intents remain supported through the SDK's personal-sign fallback.
```ts {1,9,23} theme={null}
const ownableV0Address =
'0x2483da3a338895199e5e538530213157e931bf06'
const rhinestoneAccount = await rhinestone.createAccount({
account: { type: 'nexus' },
owners: {
type: 'ecdsa',
accounts: [oldAccount],
module: ownableV0Address,
},
recovery: {
guardians: [guardianAccount],
},
})
const recoveryCalls = await recoverEcdsaOwnership({
accountAddress: rhinestoneAccount.getAddress(),
chain,
config: rhinestoneAccount.config,
newOwners: {
type: 'ecdsa',
accounts: [newOwnerAccount],
module: ownableV0Address,
},
})
```
Adding `recovery` to an existing Nexus account that uses the default owner validator is not sufficient. The current owner must migrate the account before access is lost. An account whose owner is already unavailable cannot be migrated through guardian recovery.This flow does not support EIP-7702 Nexus accounts. Recovery modules cannot revoke the EOA's authority over its delegated account.
# Security
Source: https://docs.rhinestone.dev/smart-wallet/advanced/security
Protect your API key and safely sponsor transactions via a backend proxy
## Overview
When you obtain an API key from the Rhinestone dashboard and fund sponsorship there, any SDK client that embeds this key directly will leak this secret to the end user's client (browser/wallet): an attacker could exfiltrate the key and use the Orchestrator to sponsor their own transactions.
To prevent this, never expose your Rhinestone API key in the browser. Instead, proxy Orchestrator requests through a trusted server. The simplest option in a Next.js app is to use a Next Route Handler that forwards requests to the Orchestrator while enforcing your own allow/deny logic.
Do not store `RHINESTONE_API_KEY` in public client-side variables like `NEXT_PUBLIC_*`. Keep it server-only and validate/whitelist what gets proxied.
## Approach
* Use a server-side proxy that adds the `x-api-key` header.
* Bypass the default SDK endpoint with your proxy URL.
* Enforce contract and method-level allowlists as appropriate.
* For this example, we whitelist ERC-20 transfer contracts on Base.
## Next.js Route Handler (Proxy)
Create a dynamic API route at `app/api/orchestrator/[...path]/route.ts`:
```ts theme={null}
import { NextRequest, NextResponse } from "next/server";
const ORCHESTRATOR_URL = "https://v1.orchestrator.rhinestone.dev";
// If you don't need validation, set this to true
const ALLOW_ALL_CONTRACTS = false;
// Whitelisted contracts when allow all is disabled
const WHITELISTED_CONTRACTS = new Set([
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // Base USDC
"0x4200000000000000000000000000000000000006", // Base WETH
]);
const getApiKey = () => {
const apiKey = process.env.RHINESTONE_API_KEY;
if (!apiKey) throw new Error("RHINESTONE_API_KEY is not configured");
return apiKey;
};
// Validate contract addresses in destinationExecutions
const validateDestinationOps = (body: any): boolean => {
if (ALLOW_ALL_CONTRACTS) return true;
const destinationOps = body?.destinationExecutions;
if (!destinationOps) return true; // nothing to validate
for (const op of destinationOps) {
const address = op?.to?.toLowerCase();
if (!address || !WHITELISTED_CONTRACTS.has(address)) {
console.log(`Blocked non-whitelisted contract: ${address}`);
return false;
}
}
return true;
};
async function handleRequest(request: NextRequest, params: { path: string[] }) {
try {
const apiKey = getApiKey();
const path = params.path.join("/");
const url = new URL(request.url);
const targetUrl = new URL(`${ORCHESTRATOR_URL}/${path}`);
targetUrl.search = url.search;
const headers: HeadersInit = {
"Content-Type": "application/json",
"x-api-key": apiKey,
"x-api-version": "2026-04.blanc",
};
const fetchOptions: RequestInit = {
method: request.method,
headers,
};
if (request.method !== "GET" && request.method !== "HEAD") {
const body = await request.text();
if (body) {
// Validate destinationExecutions on quote requests, before the intent
// is stored server-side and bound to an intentId.
if (path === "quotes") {
const parsedBody = JSON.parse(body);
if (!validateDestinationOps(parsedBody)) {
return NextResponse.json(
{ error: "Contract not whitelisted" },
{ status: 403 }
);
}
}
fetchOptions.body = body;
}
}
const response = await fetch(targetUrl.toString(), fetchOptions);
const responseBody = await response.text();
return new NextResponse(responseBody, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": response.headers.get("Content-Type") || "application/json",
},
});
} catch (error) {
console.error("Proxy error:", error);
return NextResponse.json(
{
error: "Internal proxy error",
message: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return handleRequest(request, await params);
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return handleRequest(request, await params);
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return handleRequest(request, await params);
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return handleRequest(request, await params);
}
```
Set `RHINESTONE_API_KEY` only on the server (e.g., in `.env` without `NEXT_PUBLIC_`).
## Client Usage
Point the SDK to your proxy URL so user traffic never touches the Orchestrator directly:
```ts theme={null}
import { RhinestoneSDK } from "@rhinestone/sdk";
const baseUrl = typeof window !== "undefined" ? window.location.origin : process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const rhinestone = new RhinestoneSDK({
apiKey: "proxy", // placeholder; the server adds the real key
endpointUrl: `${baseUrl}/api/orchestrator`,
});
```
You can then create an account and perform transactions as usual. For sponsored flows, set `sponsored: true` when sending a transaction.
## Additional Hardening
* Validate function selectors, token addresses, and chain IDs.
* Add rate limiting and bot protection on the proxy route.
## Example Code
* **See it in action**: [External Wallet + Rhinestone Example](https://github.com/rhinestonewtf/e2e-examples/tree/main/reown)
# Account deployment
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/account-deployment
The SDK handles the account deployments for you. When transacting on a new chain for the first time, the account will be deployed as part of the intent. The Orchestrator automatically detects if the account needs to be deployed, then passes the init data to the relayer market as part of the intent, which is then executed by a relayer.
## How deployment works
Deployment is per-chain. When you submit a transaction targeting a chain where the account is not deployed yet, the Orchestrator bundles the deployment into the intent automatically. The account is only deployed on that specific chain, not on all supported chains at once.
Receiving tokens at the counterfactual address does not trigger deployment. Only an outbound transaction submitted through the SDK does.
## Who pays for deployment?
| Scenario | Who pays | Source |
| --------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------- |
| Sponsorship enabled (`sponsored: true`) | You (the developer) | Your USDC sponsorship deposit on Base |
| No sponsorship | The user | Tokens in the user's account on the target chain, or on another chain where they are already deployed |
Developer sponsorship and the user's account balance are separate. Sponsorship is a developer-funded deposit that subsidises fees on behalf of users. The user's account balance is whatever tokens the user holds. See [Gas and fee sponsorship](../core/gas-fee-sponsorship) for setup details.
You can make deployment completely gasless for the user with [sponsorships](../gas-sponsorship/overview).
Otherwise, the user needs to have some tokens on either the chain you're deploying to, or any other [supported chain](../../home/resources/supported-chains) (provided the account is already deployed there).
## Check Status
You can check if the account is deployed on a specific chain:
```ts theme={null}
await rhinestoneAccount.isDeployed(chain)
```
## Manual Deployments
You can manually trigger an account deployment on any chain:
```ts theme={null}
await rhinestoneAccount.deploy(chain)
```
You can also sponsor the gas:
```ts theme={null}
await rhinestoneAccount.deploy(
chain,
{
sponsored: true
}
)
```
## Deploy from Another Account
You can deploy an account from another smart account using the `deploy` action. This can be useful when you want to pay for a deployment with another account:
```ts theme={null}
import { deploy } from '@rhinestone/sdk/actions'
const newAccount = await rhinestone.createAccount({
owners: { type: 'ecdsa', accounts: [newOwner] },
})
const transaction = await sponsorAccount.prepareTransaction({
chain,
calls: [deploy(newAccount)],
})
```
# Custom recipient
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/custom-recipient
Send funds to a different address than the sender
By default, the destination funds are sent to the sender's account. You can override this with the `recipient` parameter to send funds to a different address.
## Address Recipient
Pass a plain address to send funds to an arbitrary address:
```ts {4} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [optimism],
targetChain: base,
recipient: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
calls: [
// …
],
tokenRequests: [
// …
],
});
```
## Smart Account Recipient
You can also pass a `RhinestoneAccountConfig` to send funds to a smart account:
```ts {4-9} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [optimism],
targetChain: base,
recipient: {
owners: {
type: "ecdsa",
accounts: [recipientOwner],
},
},
calls: [
// …
],
tokenRequests: [
// …
],
});
```
# Error handling
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/error-handling
All errors are exported from `@rhinestone/sdk/errors`. Each category has a base class
and a matching type guard — `isAccountError`, `isExecutionError`, and
`isOrchestratorError` — so you can catch a whole category at once. Orchestrator errors
also expose `isRetryable`, `isRateLimited`, `isSimulationFailed`, `isAuthError`, and
`isValidationError`.
## Account errors
* `AccountError`: base class for the errors below — catch it (or use `isAccountError`) to handle any account error.
* `AccountConfigurationNotSupportedError`: your account setup is not currently supported. Try a different account configuration or [submit](https://github.com/rhinestonewtf/sdk/issues) a feature request.
* `DefaultValidatorAlreadyInitializedError`: ECDSA is already enabled on this account. Use `addOwner` / `removeOwner` / `changeThreshold` to manage owners instead of `enable`.
* `Eip712DomainNotAvailableError`: the account doesn't expose an EIP-712 domain, which is required for typed-data signing.
* `Eip7702AccountMustHaveEoaError`: when using EIP-7702, you need to provide an EOA account by passing the `eoa` param.
* `Eip7702NotSupportedForAccountError`: this account type does not support EIP-7702. Try a different account implementation or [submit](https://github.com/rhinestonewtf/sdk/issues) a feature request.
* `EoaAccountMustHaveAccountError`: EOA accounts need a signer — pass it via the `account` param.
* `EoaSigningMethodNotConfiguredError`: the EOA account provider is missing a signing method required for this operation.
* `EoaSigningNotSupportedError`: the EOA account provider does not support the requested signing method.
* `FactoryArgsNotAvailableError`: factory data is not available for this account type. Most likely an issue with the SDK itself. Please [submit](https://github.com/rhinestonewtf/sdk/issues) a bug report.
* `ModuleInstallationNotSupportedError`: this account type does not support installing modules.
* `OwnersFieldRequiredError`: smart accounts require an `owners` field.
* `SigningNotSupportedForAccountError`: the account you provided does not support message signing.
* `WalletClientNoConnectedAccountError`: the `WalletClient` instance you provided is missing a default account. Ensure the wallet is connected and the client has an account.
## Execution errors
* `ExecutionError`: base class for the errors below — catch it (or use `isExecutionError`) to handle any execution error.
* `Eip7702InitSignatureRequiredError`: an EIP-7702 initialization signature is required when preparing a transaction for a 7702 account — even if the account is already deployed on all chains. Generate it with `signEip7702InitData()`.
* `IntentFailedError`: the intent failed (reverted onchain). A common reason is that a swap failed due to price action.
* `InvalidSourceCallsError`: `sourceCalls` includes a chain that isn't in `sourceChains` (or the target chain for same-chain transactions).
* `OrderPathRequiredForIntentsError`: when sending an intent you need the order path first. When using `submitTransaction`, make sure to call `prepareTransaction` first.
* `QuoteNotInPreparedTransactionError`: the selected quote doesn't belong to the prepared transaction. Re-prepare and try again.
## Orchestrator errors
Server-side errors from the orchestrator API. Each carries a `code`, `traceId`, and `statusCode`.
* `OrchestratorError`: base class for the errors below — catch it (or use `isOrchestratorError`) to handle any orchestrator error.
* `ValidationError`: the request failed validation. Inspect `issues` for the offending fields.
* `InsufficientLiquidityError`: there isn't enough liquidity to fill the intent. Inspect `unfillable` and `availableIntents`.
* `NotFoundError`: the requested resource (e.g. an intent) couldn't be found.
* `UnauthorizedError`: authentication failed. Make sure you're passing a valid API key.
* `ForbiddenError`: the request is not allowed.
* `KeyScopeDeniedError`: the API key's scope denies this request. Widen the key's scope — `scope`, `required`, and `actual` describe what's missing.
* `ConflictError`: the request conflicts with the current state.
* `UnprocessableContentError`: the request was well-formed but couldn't be processed.
* `RateLimitedError`: you're being rate-limited. Honor `retryAfter` and retry later.
* `SettlementQuoteError`: the settlement layer failed to return a quote.
* `SettlementExecutionError`: the settlement layer failed to execute the intent.
* `SimulationFailedError`: intent simulation failed. Inspect `category`, `errorName`, and `simulations`; `retryable` indicates whether a retry may help.
* `ExternalServiceTimeoutError`: an upstream service timed out. Safe to retry later.
* `RelayerMarketUnavailableError`: no relayer is currently available. Safe to retry later.
* `InternalServerError`: an internal orchestrator error. Safe to retry later.
## Registry errors
Thrown by the SDK's local token/chain registry — not the orchestrator API, so you can catch them separately.
* `UnsupportedChainError`: the chain isn't supported. Make sure you're only using supported chains.
* `UnsupportedTokenError`: the token isn't supported on the given chain. Make sure you're only using supported tokens when setting `tokenRequests`.
# Local testing (Docker)
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/local-orchestrator-docker
Run the Rhinestone Orchestrator locally with Docker and connect the SDK to forked chains.
## Overview
Spin up a local test environment that includes:
* **Two Anvil forks**: Ethereum Sepolia and Base Sepolia
* **Rhinestone Orchestrator** (mockestrator) wired to those forks via `rpcs.json`
This setup lets you develop and test intents locally with full control over RPC state and fast iteration.
## Prerequisites
* Docker and Docker Compose installed
* An Alchemy API key in your environment: `export ALCHEMY_API_KEY=...`
```json theme={null}
{
"11155111": {
"rpc": "http://sepolia_fork:8545"
},
"84532": {
"rpc": "http://base_sepolia_fork:8545"
}
}
```
```yaml theme={null}
services:
base_sepolia_fork:
image: ghcr.io/foundry-rs/foundry
command:
- anvil --fork-url https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY:?alchemy key missing for base-sepolia} --host 0.0.0.0 --hardfork prague --no-priority-fee --gas-price 12000000 --base-fee 12000000
ports:
- '30005:8545'
healthcheck:
test: ['CMD', 'sh', '-c', 'cast rpc eth_blockNumber > /dev/null 2>&1']
interval: 10s
timeout: 5s
retries: 3
sepolia_fork:
image: ghcr.io/foundry-rs/foundry
command:
- anvil --fork-url https://eth-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY:?alchemy key missing for sepolia} --host 0.0.0.0 --hardfork prague --no-priority-fee --gas-price 12000000 --base-fee 12000000
ports:
- '30006:8545'
healthcheck:
test: ['CMD', 'sh', '-c', 'cast rpc eth_blockNumber > /dev/null 2>&1']
interval: 10s
timeout: 5s
retries: 3
mockestrator:
image: public.ecr.aws/rhinestone/mockestrator:latest
ports: ['3000:3000']
depends_on:
sepolia_fork:
condition: service_healthy
base_sepolia_fork:
condition: service_healthy
volumes:
- ./rpcs.json:/app/rpcs.json
```
Start the stack:
```bash theme={null}
docker compose up -d
```
Stop it:
```bash theme={null}
docker compose down
```
* Sepolia fork RPC: `http://localhost:30006`
* Base Sepolia fork RPC: `http://localhost:30005`
* Orchestrator: `http://localhost:3000`
Note: If your app also uses port 3000, change the left-hand port for `mockestrator` (e.g., `3007:3000`) and update the examples accordingly.
## SDK Configuration
Point the SDK at the local Orchestrator and local RPCs.
```ts theme={null}
import { RhinestoneSDK } from "@rhinestone/sdk";
import { sepolia, baseSepolia } from "viem/chains";
const rhinestone = new RhinestoneSDK({
endpointUrl: "http://localhost:3000",
provider: {
type: "custom",
urls: {
[sepolia.id]: "http://localhost:30006",
[baseSepolia.id]: "http://localhost:30005",
},
},
useDevContracts: true,
});
```
## Troubleshooting
* Ensure `ALCHEMY_API_KEY` is exported in your shell before starting Docker.
* If containers appear unhealthy, check logs:
* `docker compose logs -f sepolia_fork`
* `docker compose logs -f base_sepolia_fork`
* `docker compose logs -f mockestrator`
* For port conflicts, adjust the left-hand port mappings in `docker-compose.yaml`.
# Crosschain
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/multi-chain-intent
Make cross-chain transactions with intents
Multi-chain intents let you make cross-chain transactions.
Those intents specify both the target (destination) and the source chain(s). The actual transaction (*fill*) happens on the target chain, while the funds are taken on the source chains (*claims*). Specifying source chains is optional. Rhinestone finds the optimal path automatically.
Multi-chain intents work similarly to single-chain intents, but with the introduction of settlement layers. From your perspective, making cross-chain transactions is as simple as making normal Ethereum transactions. The Rhinestone Orchestrator handles path finding, liquidity management, and intent execution for you.
Learn more about how Rhinestone intents work [here](../../home/introduction/rhinestone-intents).
## Example
We will make a cross-chain token transfer using multi-chain intents.
```tsx theme={null}
const usdcOnArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' // Arbitrum
const amount = parseUnits('100', 6) // 100 USDC
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
{
to: usdcOnArbitrum,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipient, amount],
}),
},
],
tokenRequests: [
{
address: usdcOnArbitrum,
amount,
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transferTransaction = await rhinestoneAccount.submitTransaction(signed)
```
## Source Chain
Providing the source chain deploys the account on that chain, as well as uses the funds on that chain to fulfill the intent.
If you already have an account deployed on one or more source chains, you can omit the `sourceChain`. In that case, the orchestrator will use the best chain(s) to source funds.
## Token Requests
`tokenRequests` is a list of token assets and their amounts that are required on the target chain to make the transaction. It tells the solvers to ensure those assets are present before executing the transaction `calls`. If you don't need any assets on the target chain, you can omit this.
## Gas Limit
You can override the default gas limit for the target chain execution with `gasLimit`. Doing this will make the intent better priced, because we can more accurately calculate the fee that a solver needs to be reimbursed with for paying the gas. If this is not provided, we calculate using a gas limit of `1_000_000`.
```ts {10} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [
// …
],
gasLimit: 200_000n,
})
```
## Source Assets
You can specify what token (or tokens) to use as an input asset:
```ts {13} theme={null}
const usdc = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base
const eth = '0x0000000000000000000000000000000000000000' // Native ETH
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [
// …
],
sourceAssets: [usdc, eth]
})
```
You can also specify source assets on a per-chain basis:
```ts {11-14} theme={null}
const usdc = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base
const transaction = await rhinestoneAccount.prepareTransaction({
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [
// …
],
sourceAssets: {
[base.id]: [usdc],
[optimism.id]: ['0x4200000000000000000000000000000000000006'], // WETH
},
})
```
Chain-specific `sourceAssets` take precendence over `sourceChain` parameter.
## Settlement Layers
Rhinestone routes intents through a settlement layer. By default, the orchestrator picks the best one. Pass `settlementLayers` to constrain the choice — either include a specific set, or exclude one you want to avoid.
Pin to a specific layer (or set of layers):
```ts {10} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [
// …
],
settlementLayers: { include: ['ACROSS'] },
})
```
Exclude a layer (e.g. while one is degraded) and let the orchestrator pick from the rest:
```ts {10} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [
// …
],
settlementLayers: { exclude: ['RELAY'] },
})
```
The same filter works on `splitIntents` (see [Liquidity Splitting](#liquidity-splitting)).
## Choosing a different quote
`prepareTransaction` returns multiple candidate routes via `prepared.quotes`. By default, `signTransaction` picks `prepared.quotes.best` — the orchestrator's recommended route. To pick a different one, iterate `prepared.quotes.all` and pass the chosen quote's `intentId` to `signTransaction`:
```ts theme={null}
const prepared = await rhinestoneAccount.prepareTransaction({ /* ... */ })
// e.g. pick the fastest route under a $1 fee cap
const fastest = prepared.quotes.all
.filter((q) => q.cost.fees.total.usd <= 1)
.reduce((a, b) =>
a.estimatedFillTime.seconds <= b.estimatedFillTime.seconds ? a : b
)
const signed = await rhinestoneAccount.signTransaction(prepared, {
intentId: fastest.intentId,
})
const transaction = await rhinestoneAccount.submitTransaction(signed)
```
Each quote has its own `signData` — signatures from one quote won't verify against another. Passing `{ intentId }` tells the SDK to sign the matching quote.
## Auxiliary Funds
`auxiliaryFunds` declares balances that aren't visible to the orchestrator yet but will be available by the time the intent settles. Use it to quote ahead of an inflow — a pending CEX deposit, a vault withdrawal, an unstake, or any other balance that will arrive before fill.
```ts {9-14} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [
// …
],
auxiliaryFunds: {
[base.id]: {
[USDC_ADDRESS]: parseUnits('100', 6),
[WETH_ADDRESS]: parseUnits('1', 18)
}
}
})
```
Don't list funds the account already holds — the orchestrator picks those up automatically, and adding them via `auxiliaryFunds` double-counts the balance and inflates the input amount in the quote.
For tokens produced by a [source call](/intents/features/execute-crosschain-calls#source-calls) (a vault exit, unwrap, unstake), declare them via `provides` on the call itself — not here.
## Wait for Execution
`submitTransaction` returns once the intent has been accepted by the orchestrator. To wait until it actually settles onchain, use `waitForExecution`:
```ts theme={null}
const transaction = await rhinestoneAccount.submitTransaction(signed)
const transactionResult = await rhinestoneAccount.waitForExecution(transaction)
```
## Get Intent Status
You can also fetch the intent status directly to implement a custom polling logic:
```ts theme={null}
const transaction = await rhinestoneAccount.submitTransaction(signed)
const transactionResult = await rhinestone.getIntentStatus(transaction.id)
```
## Liquidity Splitting
When transferring large token amounts, a single settlement layer may not have enough liquidity to fill the intent efficiently. Use `splitIntents` to break the amount into smaller portions that can be routed through different settlement layers in parallel, improving execution speed and pricing.
First, compute the splits:
```ts theme={null}
const splits = await rhinestone.splitIntents({
chain: base,
tokens: {
[USDC_ADDRESS]: parseUnits('100000', 6), // 100k USDC
},
})
```
Then, send each split as a separate transaction:
```ts theme={null}
async function sendIntent(split) {
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [optimism],
targetChain: base,
tokenRequests: Object.entries(split.tokens).map(([address, amount]) => ({
address,
amount,
})),
calls: [
// …
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
return rhinestoneAccount.submitTransaction(signed)
}
const transactions = await Promise.all(splits.map(sendIntent))
```
You can optionally filter which settlement layers to use:
```ts {6} theme={null}
const splits = await rhinestone.splitIntents({
chain: base,
tokens: {
[USDC_ADDRESS]: parseUnits('100000', 6),
},
settlementLayers: { include: ['ACROSS', 'ECO'] },
})
```
# Single-chain
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/single-chain-intent
Send same-chain transactions via Warp intents
Single-chain intents let you make transactions on a chain of your choice.
These intents are similar to ERC-4337 User Operations. See our [concepts section on intents vs userops](../../home/concepts/intents-and-erc4337) to better understand the difference.
Relayers execute single-chain intents through the intent executor module. For a breakdown of the high-level flow, please take a look at the [Rhinestone Intents concepts section](../../home/introduction/rhinestone-intents).
## Example
Here's how you can make a token transfer:
```ts theme={null}
const usdc = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base
const prepared = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [
{
to: usdc,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipient, parseUnits('5', 6)],
}),
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
```
## Gas Limit
You can override the default gas limit for the target chain execution with `gasLimit`. Doing this will make the intent better priced, because we can more accurately calculate the fee that a solver needs to be reimbursed with for paying the gas. If this is not provided, we calculate using a gas limit of `1_000_000`.
```ts {9} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [
// …
],
tokenRequests: [
// …
],
gasLimit: 200_000n,
})
```
## Source Assets
You can specify what token (or tokens) to use as an input asset:
```ts {12} theme={null}
const usdc = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base
const eth = '0x0000000000000000000000000000000000000000' // Native ETH
const transaction = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [
// …
],
tokenRequests: [
// …
],
sourceAssets: [usdc, eth]
})
```
## Auxiliary Funds
`auxiliaryFunds` declares balances that aren't visible to the orchestrator yet but will be available by the time the intent settles. Use it to quote ahead of an inflow — a pending CEX deposit, a vault withdrawal, an unstake, or any other balance that will arrive before fill.
```ts {9-14} theme={null}
const transaction = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [
// …
],
tokenRequests: [
// …
],
auxiliaryFunds: {
[base.id]: {
[USDC_ADDRESS]: parseUnits('100', 6),
[WETH_ADDRESS]: parseUnits('1', 18)
}
}
})
```
Don't list funds the account already holds — the orchestrator picks those up automatically, and adding them via `auxiliaryFunds` double-counts the balance and inflates the input amount in the quote.
## Wait for Execution
`submitTransaction` returns once the intent has been accepted by the orchestrator. To wait until it actually settles onchain, use `waitForExecution`:
```ts theme={null}
const transaction = await rhinestoneAccount.submitTransaction(signed)
const transactionResult = await rhinestoneAccount.waitForExecution(transaction)
```
## Get Intent Status
You can also fetch the intent status directly to implement a custom polling logic:
```ts theme={null}
const transaction = await rhinestoneAccount.submitTransaction(signed)
const transactionResult = await rhinestone.getIntentStatus(transaction.id)
```
# Spend max tokens
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/source-token-amount
You can specify the exact amount of source (input) tokens to spend for the bridging.
This can be helpful if you want to limit the amount of tokens to bridge, or if you want to bridge the entire token balance.
To bridge a specific amount of tokens:
```ts {7-13} theme={null}
const usdcOnOptimism = '0x0b2c639c533813F4AA9D7837CAF62653d097Ff85' // Optimism
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [optimism],
targetChain: base,
sourceAssets: [
{
chain: optimism,
address: usdcOnOptimism,
amount: parseUnits('10', 6),
}
],
tokenRequests: [
{
// Note: no amount specified for the target token
address: usdcOnBase,
},
],
})
```
This will bridge 10 USDC from Optimism to Base.
## Entire Balance
To bridge the entire balance of a token:
```ts {10-15,24} theme={null}
const sourceChain = optimism
const targetChain = base
const usdcOnOptimism = '0x0b2c639c533813F4AA9D7837CAF62653d097Ff85' // Optimism
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base
const publicClient = createPublicClient({
chain: sourceChain,
transport: http(),
})
const usdcBalance = await publicClient.readContract({
address: usdcOnOptimism,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address],
})
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [sourceChain],
targetChain,
sourceAssets: [
{
chain: sourceChain,
address: usdcOnOptimism,
amount: usdcBalance,
},
],
tokenRequests: [
{
address: usdcOnBase,
},
],
})
```
This will sweep the user's USDC balance on Optimism to Base.
## Multichain
You can specify exact source amounts from multiple source chains:
```ts {8-19} theme={null}
const usdcOnOptimism = '0x0b2c639c533813F4AA9D7837CAF62653d097Ff85' // Optimism
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base
const usdcOnArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' // Arbitrum
const transaction = await rhinestoneAccount.prepareTransaction({
sourceChains: [optimism, base],
targetChain: arbitrum,
sourceAssets: [
{
chain: optimism,
address: usdcOnOptimism,
amount: parseUnits('50', 6),
},
{
chain: base,
address: usdcOnBase,
amount: parseUnits('30', 6),
},
],
tokenRequests: [
{
address: usdcOnArbitrum,
},
],
})
```
This will bridge 50 USDC from Optimism and 30 USDC from Base to Arbitrum, combining them into a single balance on the target chain.
# Swaps
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/swaps
Bridge and swap in a single transaction using Warp's built-in solver liquidity.
Warp can bridge and swap in a single transaction. Specify the token you want on the destination chain, and if it differs from what the user holds, Warp routes through solver liquidity to swap and bridge automatically.
Request ETH on the target chain via `tokenRequests`. If the user only holds another token there (or on the source chain), Warp swaps and bridges to cover it:
```ts theme={null}
import { baseSepolia, arbitrumSepolia } from 'viem/chains'
const eth = '0x0000000000000000000000000000000000000000' // Native ETH
const ethAmount = 2n
const receiver = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [baseSepolia],
targetChain: arbitrumSepolia,
calls: [
{
to: receiver,
value: ethAmount,
},
],
tokenRequests: [
{
address: eth,
amount: ethAmount,
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
```
If the user has USDC on Base Sepolia, Warp will swap it to ETH, bridge to Arbitrum Sepolia, and execute the transfer.
The token address in `calls` and `tokenRequests` must correspond to the *target* chain.
When you request a token on the destination chain that differs from the user's source token, Warp routes through the solver market to bridge and swap in a single operation. No additional parameters are required — the quote response tells you what will be spent and what will be received.
In a swap, `cost.input` and `cost.output` show different tokens:
```json theme={null}
{
"cost": {
"input": [
{
"chainId": "eip155:8453",
"tokenAddress": "0x4200000000000000000000000000000000000006",
"symbol": "WETH",
"decimals": 18,
"price": { "usd": 2412.37 },
"amount": "959454558006521"
}
],
"output": [
{
"chainId": "eip155:10",
"tokenAddress": "0x0b2c639c533813f4aa9d7837caf62653d097ff85",
"symbol": "USDC",
"decimals": 6,
"price": { "usd": 0.99 },
"amount": "2000000"
}
],
"fees": {
"total": { "usd": 0.31 },
"breakdown": {
"gas": { "usd": 0.30, "sponsored": false },
"swap": { "usd": 0.01, "sponsored": false },
"bridge": { "usd": 0.0004, "sponsored": false }
}
}
}
}
```
Here the user spends WETH on Base (`eip155:8453`) to receive USDC on Optimism. `output.amount` matches the requested amount exactly — fees are paid in input tokens and surfaced via `cost.fees.breakdown`.
For the full quote and submission flow, see the [API Quickstart](../../intents/quickstart).
# Unified balance
Source: https://docs.rhinestone.dev/smart-wallet/chain-abstraction/unified-balance
With Rhinestone, users' aggregated balances across all chains are always available to sponsor any intent.
Rhinestone uses the aggregated balance of the user's funds across all supported chains. When calculating the user's portfolio, Rhinestone takes any outstanding intents (where funds have been allocated to a relayer as repayment but not yet processed) into account to prevent double-spending.
When making a multi-chain intent, the Orchestrator finds the most optimal source of funds, taking the gas and bridging costs into account. It prioritizes same-chain and equivalent (e.g., ETH/WETH, USDC/USDT) assets, but can use other assets when needed.
## How it Works
When choosing which asset to use, the Orchestrator uses the following priority system:
* Destination token on the destination chain
* Same token on another chain
* Equivalent token on another chain
* Different token on the destination chain
* Token with the highest balance
Sometimes, a single token is not enough to cover the transaction costs. In that case, the Orchestrator can use multiple source assets, sometimes on multiple source chains.
The system splits tokens/chains in these cases:
1. Insufficient single token: when one token cannot cover the full payment
2. Multi-token requests: when multiple tokens are required for the transaction
3. Cost optimization: when using multiple smaller amounts is cheaper than bridging large amounts
## Get Portfolio
To get the aggregated portfolio of tokens:
```ts theme={null}
const portfolio = await rhinestoneAccount.getPortfolio()
```
To get the aggregated portfolio of testnet tokens:
```ts theme={null}
const onTestnets = true
const portfolio = await rhinestoneAccount.getPortfolio(onTestnets)
```
This will return the list of *spendable* tokens (i.e., not part of any pending transaction).
# Create a smart account
Source: https://docs.rhinestone.dev/smart-wallet/core/create-account
Create a Rhinestone smart account and configure its owners.
A Rhinestone smart account is a smart contract wallet. It doesn't hold keys. Instead, you configure one or more signers that are authorised to control it. The account is deployed on the first transaction, not at creation time.
## Create an account
Pass the owner signer when calling `createAccount`. Here's an example using an external wallet (MetaMask) via Viem:
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { walletClientToAccount } from '@rhinestone/sdk/utils'
import { createWalletClient, custom } from 'viem'
import { sepolia } from 'viem/chains'
const walletClient = createWalletClient({
chain: sepolia,
transport: custom(window.ethereum!),
})
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
const signer = walletClientToAccount(walletClient)
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [signer],
},
})
const accountAddress = account.getAddress()
```
The SDK derives a deterministic account address from the owner configuration. The same owners always produce the same address, with no deployment transaction required upfront.
Creating an account only computes a counterfactual address. The smart contract is not deployed until you send the first outbound transaction on a given chain. Receiving tokens (funding the address) does not trigger deployment.The smart account address is not the same as the signer address. If you look up your signer (EOA) on a block explorer, it will appear as a regular account. The smart account is a separate contract address derived from the owner configuration.
## Custom nonce
By default, the SDK uses a nonce of `0` to derive the account address. Pass a custom nonce to get a different address for the same set of owners. This is useful when you need multiple accounts per signer.
```ts theme={null}
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [signer],
},
account: {
nonce: 1n,
},
})
```
Different nonce = different account address, even with identical owners.
## Restore an existing account
There is no `importAccount` method. The SDK derives the account address deterministically from the owner configuration, so calling `createAccount` with the same owners (and nonce) always returns the same account. To restore an account in a new session or on a different device, just call `createAccount` again with the original signer:
```ts theme={null}
// Same signer + same nonce = same account address, every time
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [signer],
},
})
```
No state is lost. The account's onchain history, balances, and modules are all tied to the deterministic address, not to the SDK instance.
## Choose a signer type
The example above uses an ECDSA signer (external wallet). Rhinestone supports several signer types: passkeys, embedded wallets, external wallets, and multi-factor combinations.
Choose the right signer for your use case: native keys, embedded wallets, or external wallets.
Add smart account features to an existing EOA without changing its address.
# Send a transaction
Source: https://docs.rhinestone.dev/smart-wallet/core/create-first-transaction
Send single-chain and cross-chain transactions from a Rhinestone smart account.
Sending a transaction is a three-step flow: `prepareTransaction` fetches a quote, `signTransaction` signs it, `submitTransaction` sends it to the orchestrator. Splitting the steps lets you show the user a quote before they approve, fetch on a backend and sign on a mobile frontend, or pick a non-default route.
## Send a single-chain transaction
Pass a `chain` and a list of `calls`. The example below transfers USDC on Base Sepolia:
```ts theme={null}
import { encodeFunctionData, erc20Abi } from 'viem'
import { baseSepolia } from 'viem/chains'
const receiver = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
const usdc = '0x036CbD53842c5426634e7929541eC2318f3dCF7e' // USDC on Base Sepolia
const usdcAmount = 1n
const prepared = await rhinestoneAccount.prepareTransaction({
chain: baseSepolia,
calls: [
{
to: usdc,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [receiver, usdcAmount],
}),
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
const result = await rhinestoneAccount.waitForExecution(transaction)
console.log('Result', result)
```
## Send a cross-chain transaction
Swap `chain` for `sourceChains` + `targetChain`. The SDK handles bridging and routing with no separate bridge interaction required:
```ts theme={null}
import { baseSepolia, arbitrumSepolia } from 'viem/chains'
const usdc = '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' // USDC on Arbitrum Sepolia
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [baseSepolia],
targetChain: arbitrumSepolia,
calls: [
{
to: usdc,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [receiver, usdcAmount],
}),
},
],
tokenRequests: [
{
address: usdc,
amount: usdcAmount,
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
const result = await rhinestoneAccount.waitForExecution(transaction)
```
To deploy the account on a specific chain before transacting, call `await rhinestoneAccount.deploy(chain)` first. Otherwise, any transaction triggers deployment as part of the intent — token transfers, contract calls, approvals, anything.
## Custom fill deadline
A transaction's on-chain fill deadline defaults to 2 minutes. For a **same-chain transaction that settles without bridging**, extend it with `customDeadline` — an absolute unix timestamp in seconds. Useful when a user needs longer to approve, or you prepare a quote now and submit it later.
```ts theme={null}
const prepared = await rhinestoneAccount.prepareTransaction({
chain: baseSepolia,
calls: [
/* … */
],
customDeadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
})
```
The value must be between `now + 120s` and `now + 86400s` (24 hours); outside that range the request is rejected.
`customDeadline` is honored only on same-chain transactions that settle without bridging. On cross-chain transactions it is ignored and the default 2-minute deadline applies.
## Next steps
Send funds across chains with a single signature. No bridging required.
Aggregate user balances across chains to fund any intent.
Cover gas, bridging, and swap fees for your users.
# ECDSA signer
Source: https://docs.rhinestone.dev/smart-wallet/core/ecdsa-signer
Use an EOA as the owner of a smart account. Supports single key or n/m multisig.
The ECDSA signer uses the Ownable Validator module to authorise transactions with standard Ethereum key pairs. It supports both single-owner and multisig (n/m threshold) configurations on the same account.
## Single owner
To create an account owned by a single ECDSA key:
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { privateKeyToAccount } from 'viem/accounts'
const signer = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [signer],
},
})
```
## Multisig
The Ownable Validator supports n/m multisig with multiple owners and a configurable signature threshold. This is useful for shared accounts, treasury management, or any scenario where unilateral control is a risk.
### Setup
Pass multiple accounts and set a threshold. The threshold defines how many signatures are required to authorise a transaction.
```ts theme={null}
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [signerA, signerB, signerC],
threshold: 2, // 2-of-3
},
})
```
By default, `threshold` is `1`, meaning any single owner can sign.
### Signing with a subset of owners
For m-of-n setups, specify which signers to use when sending a transaction:
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [
// …
],
signers: {
type: 'owner',
kind: 'ecdsa',
accounts: [signerA, signerB],
},
})
```
Provide at least as many signers as the threshold requires, or the transaction will fail validation.
### Add a signer
```ts theme={null}
import { addOwner } from '@rhinestone/sdk/actions/ecdsa'
const transaction = await account.prepareTransaction({
chain,
calls: [addOwner(newSigner.address)],
})
```
### Remove a signer
```ts theme={null}
import { removeOwner } from '@rhinestone/sdk/actions/ecdsa'
const transaction = await account.prepareTransaction({
chain,
calls: [removeOwner(signerC.address)],
})
```
### Change threshold
```ts theme={null}
import { changeThreshold } from '@rhinestone/sdk/actions/ecdsa'
const transaction = await account.prepareTransaction({
chain,
calls: [changeThreshold(2)],
})
```
### Get current owners
```ts theme={null}
const owners = await account.getOwners(chain)
// → { accounts: ['0xaaa…', '0xbbb…', '0xccc…'], threshold: 2 }
```
## Enable in a separate transaction
If you need to enable the ECDSA module on an existing account rather than at creation time:
```ts theme={null}
import { enable as enableEcdsa } from '@rhinestone/sdk/actions/ecdsa'
const transaction = await account.prepareTransaction({
chain,
calls: [enableEcdsa([signer.address])],
tokenRequests: [],
})
```
## Validator versions
The SDK ships with two Ownable Validator implementations. The default is the latest version, which supports EIP-712 legible signing — wallet prompts show structured typed data instead of an opaque hash — but accepts only raw 65-byte ECDSA signatures. The legacy validator (`0x2483da3a338895199e5e538530213157e931bf06`) does not support EIP-712 legibility, but it accepts ERC-1271 contract signatures, so you can use a smart account (e.g., a Safe) as an owner.
To use the legacy validator, pass its address as `module`:
```ts {5} theme={null}
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [smartAccountSigner],
module: '0x2483da3a338895199e5e538530213157e931bf06',
},
})
```
# EIP-7702
Source: https://docs.rhinestone.dev/smart-wallet/core/eip-7702
Add smart account capabilities to an existing EOA without changing its address
EIP-7702 lets an existing EOA act as a smart account. It works by delegating the EOA to a smart account implementation, giving it full smart account capabilities while keeping the same address and onchain history.
Use this when your users already have an EOA with assets or history they want to preserve, and you want to unlock modules, session keys, and gas sponsorship without migrating to a new address.
EIP-7702 requires your app to control the delegation target. It is not supported for external wallets (MetaMask, Coinbase, etc.) since those wallets do not allow delegating to an arbitrary contract. For external wallet users, use a [plain EOA](./eoa) or [create a separate smart account](./create-account).
See [What is EIP-7702?](../../home/concepts/smart-eoas-eip-7702) for a conceptual overview.
## When to use EIP-7702
* Your users have an embedded wallet (Privy, Dynamic, etc.) with existing assets
* You want smart account features — session keys, gas sponsorship, modules — without migrating funds
* You need the account address to stay the same as the EOA
## Limitations
* **No key rotation**: the EOA is always the root owner. If it's lost or compromised, the account is unrecoverable.
* **No multisig**: the EOA acts as a root key that overrides any other owner.
* **Embedded wallets only**: not supported for external wallets.
## Send an intent
Sending a transaction with an EIP-7702 account requires two additional signing steps compared to a standard smart account.
Pass the EOA as both the owner and the `eoa` field. The presence of `eoa` tells the SDK to use the EIP-7702 flow.
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { privateKeyToAccount } from 'viem/accounts'
import { arbitrum, base } from 'viem/chains'
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
const eoaAccount = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [eoaAccount],
},
eoa: eoaAccount,
})
```
This signature authorises the smart account setup on the EOA. It only needs to be signed once — cache it and reuse it on subsequent transactions.
```ts theme={null}
const eip7702InitSignature = await account.signEip7702InitData()
```
The init signature is valid cross-chain, so you can cache it and reuse it. Always provide it when preparing a transaction — the account may need to be initialised on any of the source or target chains.
Pass `eip7702InitSignature` when preparing. The SDK includes it in the transaction so the account is initialised on any chain it hasn't been set up on yet.
```ts theme={null}
const recipientAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const preparedTx = await account.prepareTransaction({
sourceChains: [arbitrum],
targetChain: base,
calls: [
{
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipientAddress, parseUnits('10', 6)],
}),
},
],
tokenRequests: [
{
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: parseUnits('10', 6),
},
],
eip7702InitSignature,
})
```
Sign both the transaction payload and the EIP-7702 authorisation.
```ts theme={null}
const signedTx = await account.signTransaction(preparedTx)
const authorizations = await account.signAuthorizations(preparedTx)
```
```ts theme={null}
const result = await account.submitTransaction(signedTx, { authorizations })
const status = await account.waitForExecution(result)
```
## Next steps
Use a plain EOA with intents, no delegation required.
Start fresh with a full smart account: modules, session keys, gas sponsorship.
# EOA
Source: https://docs.rhinestone.dev/smart-wallet/core/eoa
Use a plain EOA with the Rhinestone SDK to send intents without deploying a smart account
If your users already have an EOA, you can connect it directly to the Rhinestone SDK and start sending intents straight away. No deployment, no migration, no smart account setup required.
The SDK handles the orchestration layer for you — the transaction API is identical to any other account type, and the Rhinestone Orchestrator takes care of routing, bridging, and settlement. The result is significantly better DX than calling the raw API yourself.
This is the right choice when:
* Your users have existing EOAs with assets they want to keep at their current address
* You want intent support without requiring users to migrate to a smart account
* You need the simplest possible integration path
Plain EOAs do not support modules, session keys, gas sponsorship, or multisig. If you need those features, use [EIP-7702](./eip-7702) to add smart account capabilities to an existing EOA, or [create a new smart account](./create-account).
## Setup
Pass `type: 'eoa'` in the account config and provide the EOA as the signer. The EOA address is used directly — no separate smart account address is derived.
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { privateKeyToAccount } from 'viem/accounts'
import { arbitrum, base } from 'viem/chains'
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
const eoaAccount = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
const account = await rhinestone.createAccount({
account: { type: 'eoa' },
eoa: eoaAccount,
})
```
## Send an intent
Once the account is created, the transaction API is identical to any other account type.
```ts theme={null}
const recipientAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const prepared = await account.prepareTransaction({
sourceChains: [arbitrum],
targetChain: base,
calls: [
{
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipientAddress, parseUnits('10', 6)],
}),
},
],
tokenRequests: [
{
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: parseUnits('10', 6),
},
],
})
const signed = await account.signTransaction(prepared)
const result = await account.submitTransaction(signed)
const status = await account.waitForExecution(result)
```
The Orchestrator handles token sourcing, bridging, and gas — your code stays the same regardless of which chain the user's funds are on.
## Next steps
Add smart account features to an existing EOA without changing its address.
Start fresh with a full smart account: modules, session keys, gas sponsorship.
# Multisig
Source: https://docs.rhinestone.dev/smart-wallet/core/multisig
Set up and use m-of-n ECDSA or passkey multisig accounts.
A multisig account requires signatures from multiple owners to authorize a transaction. It distributes control so no single party can act unilaterally — useful for treasuries and shared accounts — and can also improve UX (e.g. a passkey per device, so a user can sign from any of them).
Both the [ECDSA signer](/smart-wallet/core/ecdsa-signer) (Ownable Validator) and the [passkey signer](/smart-wallet/core/passkeys) (WebAuthn Validator) support multisig. The validator verifies each signature against the stored owner set, and the account enforces the threshold (e.g. 2-of-3).
## Setup
Create a multisig by passing multiple owners and a `threshold` — the number of signatures required to authorize a transaction.
```ts {5} theme={null}
const account = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [signerA, signerB, signerC],
threshold: 2, // 2-of-3
},
})
```
```ts {5} theme={null}
const account = await rhinestone.createAccount({
owners: {
type: 'passkey',
accounts: [passkeyAccountA, passkeyAccountB],
threshold: 2, // 2-of-2
},
})
```
By default, `threshold` is `1` (any single owner can sign).
Managing owners after creation — adding or removing signers, changing the threshold, reading the current set — is covered on the signer pages: [ECDSA](/smart-wallet/core/ecdsa-signer#multisig) and [passkeys](/smart-wallet/core/passkeys#multisig).
To combine *different* signer types (e.g. require both an EOA and a passkey), use [multi-factor authentication](/smart-wallet/advanced/multi-factor-authentication) instead.
## 1-of-n: sign from any owner
With a 1-of-n threshold, any single owner can authorize a transaction on their own. Pick which owner signs with `signers`. A common use is a passkey per device, so the user can sign from any device without migrating keys.
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { toWebAuthnAccount } from 'viem/account-abstraction'
import { base, arbitrum } from 'viem/chains'
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
// One passkey per device, each registered via WebAuthn on that device
const passkeyAccountA = toWebAuthnAccount({ credential: credentialFromDeviceA })
const passkeyAccountB = toWebAuthnAccount({ credential: credentialFromDeviceB })
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
// Any single registered device can sign
const account = await rhinestone.createAccount({
owners: {
type: 'passkey',
accounts: [passkeyAccountA, passkeyAccountB],
threshold: 1,
},
})
const usdcAmount = parseUnits('0.1', 6)
const usdc = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' // USDC on Arbitrum
const prepared = await account.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
{
to: usdc,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: ['0xd8da6bf26964af9d7eed9e03e53415d37aa96045', usdcAmount],
}),
},
],
tokenRequests: [{ address: usdc, amount: usdcAmount }],
// Sign from a single device
signers: {
type: 'owner',
kind: 'passkey',
accounts: [passkeyAccountA],
},
})
const signed = await account.signTransaction(prepared)
const transaction = await account.submitTransaction(signed)
const result = await account.waitForExecution(transaction)
```
ECDSA works the same way — set `threshold: 1` and pass a single account under `signers`.
## m-of-n: collect multiple signatures
When the threshold is greater than one, you need signatures from several owners. There are two ways to collect them.
### Co-located signing
If every key is available in the same environment, sign with a subset of owners in a single call. List the signing owners under `signers` and call `signTransaction` once — the SDK requests a signature from each account in the list in parallel and packs them into the account's multisig signature. Every listed key must be reachable in this environment; if the owners sign from separate devices, use [independent signing](#independent-signing) instead.
```ts theme={null}
const usdc = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' // USDC on Arbitrum
const prepared = await account.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [{ address: usdc, amount: usdcAmount }],
// Two of the three owners sign to meet the 2-of-3 threshold
signers: {
type: 'owner',
kind: 'ecdsa',
accounts: [signerA, signerB],
},
})
const signed = await account.signTransaction(prepared)
const transaction = await account.submitTransaction(signed)
```
### Independent signing
When owners are separate parties on different devices, each signs the same prepared transaction independently, and a coordinator merges the results. Prepare the transaction once and share it with each owner:
```ts theme={null}
// Coordinator: prepare once, then share `prepared` with each owner
const usdc = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' // USDC on Arbitrum
const prepared = await account.prepareTransaction({
sourceChains: [base],
targetChain: arbitrum,
calls: [
// …
],
tokenRequests: [{ address: usdc, amount: usdcAmount }],
})
// Each owner signs in their own environment
const signatureA = await account.signTransaction(prepared, { owner: signerA })
const signatureB = await account.signTransaction(prepared, { owner: signerB })
// Coordinator: assemble the collected signatures and submit
const signed = await account.assembleTransaction(prepared, [signatureA, signatureB])
const transaction = await account.submitTransaction(signed)
const result = await account.waitForExecution(transaction)
```
Passkey owners follow the same flow — pass the passkey account as `owner`. The signature returned by `signTransaction({ owner })` is JSON-serializable, so each owner can return it over the wire:
```ts theme={null}
// On the owner's side
const payload = JSON.stringify(await account.signTransaction(prepared, { owner: signerA }))
// On the coordinator's side
const signatureA = JSON.parse(payload)
```
Signatures can be collected in any order — `assembleTransaction` deduplicates them and orders them to match the account's owner set. Provide enough to meet the threshold, or assembly throws `InsufficientOwnerSignaturesError`. Every owner must sign the same prepared transaction, otherwise assembly throws `MismatchedOwnerSignaturesError`.
Independent signing is supported for ECDSA, passkey, and multi-factor owners. For [multi-factor](/smart-wallet/advanced/multi-factor-authentication) accounts, also pass the `validatorId` of the factor the owner belongs to: `signTransaction(prepared, { owner, validatorId })`.
# Passkey
Source: https://docs.rhinestone.dev/smart-wallet/core/passkeys
Use device biometrics as the owner of a smart account. Supports single key or n/m multisig.
The Passkey signer uses the WebAuthn Validator module to authorise transactions with device-native biometrics: Face ID, Touch ID, Windows Hello. No seed phrases, no browser extensions. Keys are generated and stored in the device secure enclave and can sync across devices via iCloud Keychain or Google Password Manager.
Passkeys are supported across iOS, Android, macOS, and modern browsers (Chrome, Safari, Edge).
## Single owner
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { toWebAuthnAccount } from 'viem/account-abstraction'
const passkeyAccount = toWebAuthnAccount({ credential })
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
const account = await rhinestone.createAccount({
owners: {
type: 'passkey',
accounts: [passkeyAccount],
},
})
```
## Multisig
The WebAuthn Validator supports n/m multisig with multiple passkey owners and a configurable signature threshold.
A common use case: users register a passkey per device. You set up a 1-of-n account so the user can sign from any of their devices without needing to migrate keys.
### Setup
```ts theme={null}
const account = await rhinestone.createAccount({
owners: {
type: 'passkey',
accounts: [passkeyAccountA, passkeyAccountB],
threshold: 2, // 2-of-2: both devices must sign
},
})
```
By default, `threshold` is `1`, meaning any single passkey can sign.
### Signing with a subset of owners
For m-of-n setups, specify which passkeys to use when sending a transaction:
```ts theme={null}
const transaction = await account.prepareTransaction({
chain,
calls: [
// …
],
signers: {
type: 'owner',
kind: 'passkey',
accounts: [passkeyAccountA],
},
})
```
Provide at least as many signers as the threshold requires, or the transaction will fail validation.
### Add a passkey (new device)
```ts theme={null}
import { addOwner } from '@rhinestone/sdk/actions/passkeys'
import { slice } from 'viem'
const pubKeyX = BigInt(slice(newPasskeyAccount.publicKey, 0, 32))
const pubKeyY = BigInt(slice(newPasskeyAccount.publicKey, 32))
const requiresUV = false
const transaction = await account.prepareTransaction({
chain,
calls: [addOwner(pubKeyX, pubKeyY, requiresUV)],
})
```
### Remove a passkey
```ts theme={null}
import { removeOwner } from '@rhinestone/sdk/actions/passkeys'
const transaction = await account.prepareTransaction({
chain,
calls: [removeOwner(pubKeyX, pubKeyY)],
})
```
### Change threshold
```ts theme={null}
import { changeThreshold } from '@rhinestone/sdk/actions/passkeys'
const transaction = await account.prepareTransaction({
chain,
calls: [changeThreshold(2)],
})
```
### Get current owners
```ts theme={null}
const owners = await account.getOwners(chain)
// → { accounts: ['0xaaa…', '0xbbb…'], threshold: 1 }
```
## Enable in a separate transaction
If you need to enable the passkey module on an existing account rather than at creation time:
```ts theme={null}
import { enable as enablePasskeys } from '@rhinestone/sdk/actions/passkeys'
const transaction = await account.prepareTransaction({
chain,
calls: [enablePasskeys(passkeyAccount.publicKey, passkeyAccount.id)],
tokenRequests: [],
})
```
## Example: multi-device 1-of-n
For a full multi-device 1-of-n walkthrough, see the [multisig guide](/smart-wallet/core/multisig#1-of-n-sign-from-any-owner).
**See it in action:** [passkey example](https://github.com/rhinestonewtf/e2e-examples/tree/main/passkey) — a passkey-only account with no third-party auth provider.
# Signer types
Source: https://docs.rhinestone.dev/smart-wallet/core/setup-signer
Choose how users authenticate and sign transactions in your Rhinestone smart account.
Your choice of signer determines how users authenticate. The right one depends on who your users are and whether they already have a wallet.
## Native signers
Cryptographic primitives built into the SDK. No third-party provider required.
Standard Ethereum key pairs. Supports single-owner and multisig (n/m threshold). Best for server-side and agent wallets.
Device biometrics via WebAuthn (Face ID, Touch ID). No seed phrases. Supports single-owner and multisig. Best for consumer apps.
Combine multiple validators, e.g. require both an ECDSA key and a passkey. Best for high-security accounts.
## Embedded wallet signers
Keys managed inside your app via secure enclave. Users authenticate with social login or email, with no browser extension or seed phrase needed. Use these when onboarding users who don't already have a crypto wallet.
Social and email login with embedded wallets.
Multi-chain embedded wallets with social and email login.
Embedded wallets with MPC key management.
Email and social login with non-custodial embedded wallets.
Programmatic wallet infrastructure for server-side and agent use cases.
Embedded wallets with a gaming-first developer experience.
## External wallet signers
Browser extensions and mobile wallets users already own, such as MetaMask, Coinbase Wallet, and any WalletConnect-compatible wallet. Use these for dApps serving existing Web3 users.
Connect MetaMask, Coinbase Wallet, and WalletConnect-compatible wallets via Reown AppKit.
# Dynamic
Source: https://docs.rhinestone.dev/smart-wallet/core/signers/dynamic
Integrate Dynamic signers with Rhinestone smart accounts.
## Overview
Dynamic provides embedded wallet infrastructure that enables seamless user onboarding and wallet management. This guide shows you how to integrate Dynamic signers with Rhinestone smart accounts to create a unified, cross-chain wallet experience.
**How it works:** Dynamic handles user authentication and wallet connections, providing wagmi-compatible clients. We use wagmi hooks to access these clients and pass them to Rhinestone's SDK, which wraps them with cross-chain capabilities.
## Prerequisites
* A Dynamic account and project
* Dynamic Environment ID
* React application setup
Install the required dependencies:
```bash npm theme={null}
npm install @dynamic-labs/sdk-react-core @dynamic-labs/ethereum @dynamic-labs/wagmi-connector @tanstack/react-query @rhinestone/sdk viem wagmi
```
```bash pnpm theme={null}
pnpm add @dynamic-labs/sdk-react-core @dynamic-labs/ethereum @dynamic-labs/wagmi-connector @tanstack/react-query @rhinestone/sdk viem wagmi
```
```bash bun theme={null}
bun install @dynamic-labs/sdk-react-core @dynamic-labs/ethereum @dynamic-labs/wagmi-connector @tanstack/react-query @rhinestone/sdk viem wagmi
```
Set up the Dynamic provider with wagmi and TanStack Query in your React application:
```tsx theme={null}
import { DynamicContextProvider } from '@dynamic-labs/sdk-react-core'
import { EthereumWalletConnectors } from '@dynamic-labs/ethereum'
import { DynamicWagmiConnector } from '@dynamic-labs/wagmi-connector'
import { createConfig, WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http } from 'viem'
import { mainnet, polygon, arbitrum, base } from 'viem/chains'
const wagmiConfig = createConfig({
chains: [mainnet, polygon, arbitrum, base],
multiInjectedProviderDiscovery: false,
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
[arbitrum.id]: http(),
[base.id]: http(),
},
})
const queryClient = new QueryClient()
function App() {
return (
{/* Your app components */}
)
}
```
Create a production-ready hook that integrates Dynamic wallets with Rhinestone accounts. This demonstrates the core pattern: **get the wallet client from wagmi, then pass it to Rhinestone**.
Dynamic automatically populates wagmi's `useAccount()` and `useWalletClient()` hooks when users connect their wallets.
```tsx theme={null}
import { useState, useEffect } from 'react'
import { useAccount, useWalletClient } from "wagmi"
import { RhinestoneSDK } from "@rhinestone/sdk"
import { walletClientToAccount } from "@rhinestone/sdk/utils"
interface GlobalWalletState {
rhinestoneAccount: any | null
address: string | null
isLoading: boolean
error: string | null
isConnected: boolean
}
export function useGlobalWallet(): GlobalWalletState & {
reconnect: () => Promise
} {
const { address, isConnected } = useAccount()
const { data: walletClient } = useWalletClient()
const [state, setState] = useState({
rhinestoneAccount: null,
address: null,
isLoading: false,
error: null,
isConnected: false,
})
const initializeAccount = async () => {
if (!isConnected || !address || !walletClient) {
setState(prev => ({
...prev,
rhinestoneAccount: null,
address: null,
isConnected: false,
error: null,
}))
return
}
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
// Ensure wallet client has address property for Rhinestone compatibility
const wrappedWalletClient = walletClientToAccount(walletClient)
const apiKey = process.env.NEXT_PUBLIC_RHINESTONE_API_KEY
if (!apiKey) {
throw new Error('NEXT_PUBLIC_RHINESTONE_API_KEY is not configured')
}
// Create Rhinestone account using Dynamic's wallet client
const rhinestone = new RhinestoneSDK({
apiKey,
})
const account = await rhinestone.createAccount({
owners: {
type: "ecdsa",
accounts: [wrappedWalletClient],
},
})
setState(prev => ({
...prev,
rhinestoneAccount: account,
address,
isConnected: true,
isLoading: false,
}))
} catch (error) {
console.error('Failed to initialize Rhinestone account:', error)
setState(prev => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to initialize account',
isLoading: false,
isConnected: false,
}))
}
}
useEffect(() => {
initializeAccount()
}, [isConnected, address, walletClient])
return {
...state,
reconnect: initializeAccount,
}
}
```
## Usage
### Basic Component Integration
Use the enhanced hook in your React components with proper loading and error states:
```tsx theme={null}
import { useGlobalWallet } from './hooks/useGlobalWallet'
import { useDynamicContext } from '@dynamic-labs/sdk-react-core'
function WalletDashboard() {
const { setShowAuthFlow } = useDynamicContext()
const {
rhinestoneAccount,
address,
isLoading,
error,
isConnected,
reconnect
} = useGlobalWallet()
// Handle loading state
if (isLoading) {
return (
Setting up your global wallet...
)
}
// Handle error state
if (error) {
return (
Wallet Setup Error
{error}
)
}
// Handle disconnected state
if (!isConnected || !rhinestoneAccount) {
return (
Connect Your Wallet
Connect with Dynamic to access cross-chain functionality
)
}
```
## The Integration Pattern
All embedded signer integrations follow the same pattern:
1. **Provider Setup**: Configure the embedded wallet provider (Openfort, Dynamic, Privy, etc.)
2. **Get Wallet Client**: Use the provider's SDK to access the authenticated wallet and create a viem wallet client
3. **Pass to Rhinestone**: Create a Rhinestone account using the wallet client as a signer
4. **Use Cross-Chain Features**: The resulting account has all of Rhinestone's capabilities
This pattern means you can easily switch between providers or support multiple providers in the same app.
## Authentication Methods
Openfort supports multiple authentication methods out of the box:
* **Email**: Users can sign in with their email address
* **Social**: Google and other OAuth providers
* **Guest**: Anonymous authentication for quick onboarding
The `` component automatically handles the authentication flow with all configured methods.
## Shield Security
Openfort's Shield provides additional security for embedded wallets:
* **Key Management**: Secure key storage and recovery
* **Transaction Protection**: Advanced fraud detection
* **Recovery Options**: User-friendly account recovery flows
Configure Shield in your `OpenfortProvider` with the `shieldPublishableKey`.
## Environment Variables
Make sure to set the following environment variables:
```bash theme={null}
NEXT_PUBLIC_OPENFORT_PUBLISHABLE_KEY=your_openfort_publishable_key
NEXT_PUBLIC_SHIELD_PUBLISHABLE_KEY=your_shield_publishable_key
NEXT_PUBLIC_RHINESTONE_API_KEY=your_rhinestone_api_key
```
## Next Steps
* Learn more about [Openfort's embedded wallets](https://www.openfort.io/docs/products/embedded-wallet/react)
* Explore [chain abstraction](../../chain-abstraction/single-chain-intent) capabilities
* Check out [creating an account](../create-account) for more details
# Para
Source: https://docs.rhinestone.dev/smart-wallet/core/signers/para
Integrate Para MPC wallets with Rhinestone smart accounts.
## Overview
[Para](https://developer.getpara.com) is an MPC wallet infrastructure that provides secure, non-custodial embedded wallets. This guide shows you how to integrate Para with Rhinestone smart accounts.
Para uses a different signature format (0/1 v-byte) than standard EVM wallets (27/28 v-byte). You **must use** `wrapParaAccount` to wrap Para signers before passing them to Rhinestone.
## Prerequisites
* A Para account and API key from [Para Dashboard](https://developer.getpara.com)
* Rhinestone API key
Install the required dependencies:
```bash npm theme={null}
npm install @getpara/react-sdk @getpara/viem @rhinestone/sdk viem @tanstack/react-query
```
```bash pnpm theme={null}
pnpm add @getpara/react-sdk @getpara/viem @rhinestone/sdk viem @tanstack/react-query
```
```bash bun theme={null}
bun install @getpara/react-sdk @getpara/viem @rhinestone/sdk viem @tanstack/react-query
```
Create a context provider that wraps your application with Para and React Query. This setup enables Para's authentication modal, wallet management, and proper React state handling.
```tsx theme={null}
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ParaProvider } from "@getpara/react-sdk";
import "@getpara/react-sdk/styles.css";
import React, { type ReactNode } from "react";
// Set up queryClient
const queryClient = new QueryClient();
function ContextProvider({
children,
}: {
children: ReactNode;
}) {
return (
{children}
);
}
export default ContextProvider;
```
Then wrap your application with this provider:
```tsx theme={null}
// app/layout.tsx (Next.js App Router)
import ContextProvider from './context-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
```
The key is to wrap the Para viem account with `wrapParaAccount` before passing it to Rhinestone:
```tsx theme={null}
import { useEffect } from "react"
import { useWallet } from "@getpara/react-sdk"
import { useViemAccount } from "@getpara/react-sdk/evm/hooks"
import { RhinestoneSDK } from "@rhinestone/sdk"
import { wrapParaAccount } from "@rhinestone/sdk/utils"
import type { Account } from "viem"
export function useRhinestoneAccount() {
const { data: wallet } = useWallet()
const { viemAccount } = useViemAccount()
useEffect(() => {
async function init() {
if (!viemAccount || !wallet?.id) return
const rhinestone = new RhinestoneSDK({
apiKey: "proxy",
endpointUrl: `${window.location.origin}/api/orchestrator`,
})
// Wrap Para account for signature compatibility
const wrappedAccount = wrapParaAccount(viemAccount, wallet.id)
// Create Rhinestone account
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: "ecdsa",
accounts: [wrappedAccount as Account],
},
})
console.log("Smart Account:", rhinestoneAccount.getAddress())
}
init()
}, [viemAccount, wallet?.id])
}
```
Use a [backend proxy](../../advanced/security) for your Rhinestone API key in production.
## Send Transactions
Once initialized, use the Rhinestone account for cross-chain transactions:
```tsx theme={null}
import { encodeFunctionData, parseUnits, erc20Abi } from 'viem'
import { baseSepolia, arbitrumSepolia } from 'viem/chains'
const usdc = '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' // USDC on Arbitrum Sepolia
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [baseSepolia],
targetChain: arbitrumSepolia,
calls: [{
to: usdc,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xrecipient", parseUnits("10", 6)],
}),
}],
tokenRequests: [{
address: usdc,
amount: parseUnits("10", 6),
}],
sponsored: true,
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
const result = await rhinestoneAccount.waitForExecution(transaction)
```
## Complete Example
See the full working example with Para + Rhinestone:
* [Para (viem) example](https://github.com/rhinestonewtf/e2e-examples/tree/main/para-viem)
* [Para (wagmi) example](https://github.com/rhinestonewtf/e2e-examples/tree/main/para-wagmi)
## Next Steps
* [Create an Account](../create-account) - Learn more about Rhinestone accounts
* [Security Best Practices](../../advanced/security) - Set up backend proxy for API keys
* [Multi-Chain Intents](../../chain-abstraction/multi-chain-intent) - Execute cross-chain transactions
# Privy
Source: https://docs.rhinestone.dev/smart-wallet/core/signers/privy
Integrate Privy signers with Rhinestone smart accounts.
## Overview
Privy provides simple, secure wallet infrastructure that enables seamless user authentication and wallet management. This guide shows you how to integrate Privy signers with Rhinestone smart accounts to create a smooth, cross-chain wallet experience.
**How it works:** Privy handles user authentication and wallet creation, then provides a wagmi-compatible client that we wrap with Rhinestone's smart account functionality. You get the best of both worlds - Privy's seamless onboarding and Rhinestone's cross-chain capabilities.
## Prerequisites
* A Privy account and application
* Privy App ID
* React application setup
Install the required dependencies:
```bash npm theme={null}
npm install @privy-io/react-auth @privy-io/wagmi @tanstack/react-query @rhinestone/sdk viem wagmi
```
```bash pnpm theme={null}
pnpm add @privy-io/react-auth @privy-io/wagmi @tanstack/react-query @rhinestone/sdk viem wagmi
```
```bash bun theme={null}
bun install @privy-io/react-auth @privy-io/wagmi @tanstack/react-query @rhinestone/sdk viem wagmi
```
Make sure to import `createConfig` and `WagmiProvider` from `@privy-io/wagmi` rather than from `wagmi` directly. Privy's wrappers ensure the two libraries stay in sync.
Set up the Privy, wagmi, and TanStack Query providers in your React application:
```tsx theme={null}
import { PrivyProvider } from '@privy-io/react-auth'
import { createConfig, WagmiProvider } from '@privy-io/wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http } from 'wagmi'
import { mainnet, polygon, arbitrum, base } from 'viem/chains'
const queryClient = new QueryClient()
const wagmiConfig = createConfig({
chains: [mainnet, polygon, arbitrum, base],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
[arbitrum.id]: http(),
[base.id]: http(),
},
})
function App() {
return (
{/* Your app components */}
)
}
```
Create a custom hook that integrates Privy authentication with Rhinestone accounts. This hook demonstrates the key pattern: **use wagmi to get the wallet client from Privy, then pass it to Rhinestone as a signer**.
The `useWalletClient()` hook from wagmi automatically connects to Privy's embedded wallets and external wallet connections.
```tsx theme={null}
import { useState, useEffect } from 'react'
import { usePrivy, useWallets } from "@privy-io/react-auth"
import { useWalletClient, useAccount } from "wagmi"
import { RhinestoneSDK } from "@rhinestone/sdk"
import { walletClientToAccount } from "@rhinestone/sdk/utils"
interface PrivyWalletState {
rhinestoneAccount: any | null
isLoading: boolean
error: string | null
}
export function usePrivyWallet() {
const { ready, authenticated, login, logout } = usePrivy()
const { wallets } = useWallets()
const { address } = useAccount()
const { data: walletClient } = useWalletClient()
const [state, setState] = useState({
rhinestoneAccount: null,
isLoading: false,
error: null,
})
const initializeAccount = async () => {
if (!ready || !authenticated || !address || !walletClient) {
setState({ rhinestoneAccount: null, isLoading: false, error: null })
return
}
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
// wrap the wagmi client for the sdk
const wrappedWalletClient = walletClientToAccount(walletClient)
// create the rhinestone account
const rhinestone = new RhinestoneSDK({
apiKey: process.env.NEXT_PUBLIC_RHINESTONE_API_KEY,
})
const account = await rhinestone.createAccount({
owners: {
type: "ecdsa",
accounts: [wrappedWalletClient],
},
})
setState({ rhinestoneAccount: account, isLoading: false, error: null })
} catch (error) {
console.error('Failed to initialize Rhinestone account:', error)
setState({
rhinestoneAccount: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to initialize account',
})
}
}
useEffect(() => {
initializeAccount()
}, [ready, authenticated, address, walletClient])
return {
rhinestoneAccount: state.rhinestoneAccount,
isLoading: state.isLoading,
error: state.error,
authenticated,
login,
logout,
reconnect: initializeAccount,
}
}
```
## Usage
### Basic Authentication Flow
Use the hook to handle Privy authentication and smart account creation:
```tsx theme={null}
import { usePrivyWallet } from './hooks/usePrivyWallet'
function PrivyWalletDashboard() {
const { authenticated, login, logout, rhinestoneAccount, isLoading, error, reconnect } = usePrivyWallet()
if (isLoading) {
return
Setting up your wallet...
}
if (error) {
return (
Error: {error}
)
}
if (!authenticated) {
return (
Welcome to Rhinestone
)
}
return (
Connected!
Smart Account: {rhinestoneAccount?.getAddress()}
)
}
```
### Cross-Chain Transactions
Send transactions using the Privy-connected wallet:
```tsx theme={null}
import { encodeFunctionData, parseUnits } from 'viem'
import { erc20Abi } from 'viem'
import { baseSepolia, arbitrumSepolia } from 'viem/chains'
const usdc = '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' // USDC on Arbitrum Sepolia
async function handleCrossChainTransfer(rhinestoneAccount) {
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [baseSepolia],
targetChain: arbitrumSepolia,
calls: [
{
to: usdc,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xrecipient", parseUnits("10", 6)],
}),
},
],
tokenRequests: [
{
address: usdc,
amount: parseUnits("10", 6),
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
}
```
## The Integration Pattern
All embedded signer integrations follow the same pattern:
1. **Provider Setup**: Configure the embedded wallet provider (Privy, Dynamic, Turnkey)
2. **Get Wallet Client**: Use wagmi hooks (or viem accounts for Turnkey) to access the authenticated wallet
3. **Pass to Rhinestone**: Create a Rhinestone account using the wallet client as a signer
4. **Use Cross-Chain Features**: The resulting account has all of Rhinestone's capabilities
This pattern means you can easily switch between providers or support multiple providers in the same app.
## Authentication Methods
Privy supports multiple authentication methods out of the box:
* **Email**: Users can sign in with their email address
* **Social**: Google, Twitter, Discord, and other OAuth providers
* **Wallet**: Connect external wallets like MetaMask
* **SMS**: Phone number verification
The `login()` function will show Privy's authentication modal with all configured methods.
## Environment Variables
Make sure to set the following environment variables:
```bash theme={null}
NEXT_PUBLIC_PRIVY_APP_ID=your_privy_app_id
NEXT_PUBLIC_RHINESTONE_API_KEY=your_rhinestone_api_key
```
## Complete Example
Try the full integration in our example repository:
```bash theme={null}
git clone https://github.com/rhinestonewtf/e2e-examples.git
cd e2e-examples
pnpm install
cd privy && pnpm dev
```
The example demonstrates:
* Privy authentication flows (email, social, wallet)
* Embedded wallet creation and management
* Rhinestone smart account integration
* Cross-chain transaction execution
## Next Steps
* **See it in action**: [Privy + Rhinestone Example](https://github.com/rhinestonewtf/e2e-examples/tree/main/privy)
* Learn more about [Privy's authentication flows](https://docs.privy.io/)
* Explore [embedded wallet features](https://docs.privy.io/guide/react/wallets/embedded/creation)
* Explore [chain abstraction](../../chain-abstraction/unified-balance) capabilities
* Check out [creating an account](../create-account) for more details
# Turnkey
Source: https://docs.rhinestone.dev/smart-wallet/core/signers/turnkey
Integrate Turnkey signers with Rhinestone smart accounts.
## Overview
Turnkey provides secure key management infrastructure that enables you to create and manage signing keys for your users. This guide shows you how to integrate Turnkey signers with Rhinestone smart accounts for secure, non-custodial wallet experiences.
**How it works:** Unlike Privy and Dynamic, Turnkey operates at a lower level. You create a viem-compatible account using `@turnkey/viem`, then pass that account to Rhinestone. Turnkey handles the secure key management while Rhinestone adds cross-chain capabilities.
## Prerequisites
* A Turnkey account and organization
* Turnkey API credentials (API public/private key pair)
* Your organization ID from Turnkey
Install the required dependencies:
```bash npm theme={null}
npm install @turnkey/http @turnkey/api-key-stamper @turnkey/viem @rhinestone/sdk viem
```
```bash pnpm theme={null}
pnpm add @turnkey/http @turnkey/api-key-stamper @turnkey/viem @rhinestone/sdk viem
```
```bash bun theme={null}
bun install @turnkey/http @turnkey/api-key-stamper @turnkey/viem @rhinestone/sdk viem
```
Using the Turnkey dashboard or API, create a new Ethereum (EVM) wallet. This wallet will be the owner of the Rhinestone smart account.
1. Log into your Turnkey dashboard
2. Navigate to the Wallets section
3. Create a new Ethereum wallet
4. Note the wallet address for the next step
Use `@turnkey/viem` to create a viem-compatible account backed by Turnkey's signing infrastructure:
The `createAccount` function from `@turnkey/viem` returns a full viem `LocalAccount` with `signMessage`, `signTransaction`, and `signTypedData` already implemented.
```tsx theme={null}
import { TurnkeyClient } from "@turnkey/http"
import { ApiKeyStamper } from "@turnkey/api-key-stamper"
import { createAccount } from "@turnkey/viem"
const turnkeyClient = new TurnkeyClient(
{ baseUrl: "https://api.turnkey.com" },
new ApiKeyStamper({
apiPublicKey: process.env.API_PUBLIC_KEY,
apiPrivateKey: process.env.API_PRIVATE_KEY,
})
)
const turnkeySigner = await createAccount({
client: turnkeyClient,
organizationId: process.env.ORGANIZATION_ID,
signWith: "0x...", // Your Turnkey wallet address
})
```
Create a new Rhinestone account using the Turnkey signer. This is the same pattern as with other providers - pass the signer to Rhinestone, and it wraps it with cross-chain functionality:
```tsx theme={null}
import { RhinestoneSDK } from "@rhinestone/sdk"
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY,
})
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: "ecdsa",
accounts: [turnkeySigner],
},
})
```
## Usage
### Send a Cross-chain Transaction
The Rhinestone account will automatically use the Turnkey signer for all transactions:
```tsx theme={null}
import { encodeFunctionData, parseUnits, erc20Abi } from 'viem'
import { baseSepolia, arbitrumSepolia } from 'viem/chains'
const usdc = '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' // USDC on Arbitrum Sepolia
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [baseSepolia],
targetChain: arbitrumSepolia,
calls: [
{
to: usdc,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xrecipient", parseUnits("10", 6)],
}),
},
],
tokenRequests: [
{
address: usdc,
amount: parseUnits("10", 6),
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
```
Don't forget to fund the account before making any transactions.
## Environment Variables
Make sure to set the following environment variables:
```bash theme={null}
API_PRIVATE_KEY=your_turnkey_api_private_key
API_PUBLIC_KEY=your_turnkey_api_public_key
ORGANIZATION_ID=your_turnkey_organization_id
RHINESTONE_API_KEY=your_rhinestone_api_key
```
## Next Steps
* Learn about [sending cross-chain transactions](../create-first-transaction) for more transaction details
* Explore [chain abstraction](../../chain-abstraction/unified-balance) capabilities
* Check out [creating an account](../create-account) for more details
# Bring your own account
Source: https://docs.rhinestone.dev/smart-wallet/customize/bring-your-own-account
Use Rhinestone with an existing account
By default, the Rhinestone SDK deploys a new smart account for each. However, you can use a smart account you've already created for a user (for example, using another SDK like permissionless.js) by providing custom deployment data.
This feature is **experimental**. Reach out to us if you have any issues or feature requests.
## Account Creation
When creating an account instance, you will need to provide the factory data you've used to deploy the account.
### Init Data
Pass the factory data from the SDK you used to deploy the account. [Reach out](https://t.me/kurt_larsen) if you need help generating it.
### Account Address
You will also need to provide the address of the account you're using. The SDK uses this for cross-reference to make sure it understands your account configuration.
### Intent Executor
Unless you were using the Rhinestone SDK V0 before, the accounts you have won't have the Rhinestone Intent Executor installed.
### Example
To reuse an existing account, pass an `initData` when creating an account:
```ts {10-15} theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
account: {
type: 'kernel',
},
owners: {
type: 'ecdsa',
accounts: [accountA],
module: '0x2483DA3A338895199E5e538530213157e931Bf06',
},
initData: {
address: accountAddress,
factory,
factoryData,
intentExecutorInstalled: false,
},
})
```
## Reusing Rhinestone Accounts
If the account was originally created with the Rhinestone SDK, you can use `experimental_getRhinestoneInitData` to compute the `initData` automatically instead of providing the factory and factory data manually.
```ts theme={null}
import { experimental_getRhinestoneInitData } from '@rhinestone/sdk'
const initData = await experimental_getRhinestoneInitData({
address: accountAddress,
})
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [accountA],
},
initData,
})
```
This API is **experimental** and may change in future versions.
## Legacy V0 Accounts
For accounts created with the Rhinestone SDK V0, use `experimental_getV0InitData`:
```ts theme={null}
import { experimental_getV0InitData } from '@rhinestone/sdk'
const initData = await experimental_getV0InitData({
address: accountAddress,
})
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [accountA],
},
initData,
})
```
V0 accounts may use [legacy modules](#legacy-modules). Make sure to specify the correct module address in the `owners` configuration.
## Legacy Modules
In most cases, an existing account would probably have the [legacy modules](../../home/resources/address-book#legacy) installed. In this case, you need to explicitly specify the module address when setting the `owners`.
```ts {5-6} theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [accountA],
// Legacy ownable validator module
module: '0x2483DA3A338895199E5e538530213157e931Bf06',
},
})
```
## Deployments
On the chains where the account is not yet deployed, you will need to explicitly call the `deploy` function. This will use the provided init data and deploy the account to the same address.
This is done once per chain, per account.
This will be done using an ERC-4337 UserOp (unless you have the intent executor as part of the init data).
To deploy an account:
```ts theme={null}
await rhinestoneAccount.deploy(chain)
```
## Account Setup
To be able to use an existing account with the SDK, you will need to set it up.
This is done once per chain, per account.
Specifically, this installs the intent executor and the necessary validators (if needed).
This will be done using an ERC-4337 UserOp (unless you have the intent executor as part of the init data).
```ts theme={null}
await rhinestoneAccount.setup(chain)
```
# JSON-RPC providers
Source: https://docs.rhinestone.dev/smart-wallet/customize/json-rpc-providers
By default, the SDK uses public providers to make EVM read calls.
Many public providers are heavily throttled and are not suitable for high traffic.
For production use, we recommend setting up a custom JSON-RPC provider with your own API key.
Provide a mapping of chain IDs to full RPC URLs from your provider:
```ts {2-9} theme={null}
const rhinestone = new RhinestoneSDK({
provider: {
type: 'custom',
urls: {
1: 'https://my-rpc.example.com/eth',
8453: 'https://my-rpc.example.com/base',
42161: 'https://my-rpc.example.com/arb',
},
},
})
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [signer],
},
})
```
The SDK does not construct provider-specific URLs from an API key. Include the
API key in each URL when your provider requires one.
# Smart account providers
Source: https://docs.rhinestone.dev/smart-wallet/customize/smart-account-providers
Choose which smart account implementation to use with the Rhinestone SDK.
By default, the Rhinestone SDK uses a Safe smart account. You can override this by passing an `account` field to `createAccount` with the `type` set to your preferred implementation.
```ts Nexus theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
account: {
type: 'nexus',
},
owners: {
type: 'ecdsa',
accounts: [account],
},
})
```
```ts Safe theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
account: {
type: 'safe',
},
owners: {
// ECDSA/ENS owners become the Safe's native owner. Passkey/multi-factor
// owners have no EOA, so the native owner is set to the NoSafeOwner
// contract (0xbabe99…5e72), which rejects every signature — the native
// owner path is dead and auth runs solely through the 7579 validators.
type: 'ecdsa',
accounts: [account],
},
})
```
```ts Startale theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
account: {
type: 'startale',
},
owners: {
type: 'ecdsa',
accounts: [account],
module: '0x00000072f286204bb934ed49d8969e86f7dec7b1',
},
})
```
```ts Kernel theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
account: {
type: 'kernel',
},
owners: {
type: 'ecdsa',
accounts: [account],
},
})
```
The account type determines the smart contract implementation deployed for the user. All supported types are fully compatible with Rhinestone's intent infrastructure and ERC-7579 module ecosystem.
Need support for a different account implementation? [Open an issue](https://github.com/rhinestonewtf/sdk/issues) and we'll take a look.
# Overview
Source: https://docs.rhinestone.dev/smart-wallet/gas-sponsorship/overview
Sponsor gas, bridging, and swap costs for your users
Rhinestone lets you cover the fees for your users with a single onchain deposit.
You can deposit USDC on Base, and subsidise gas, bridge fees, and swap fees across all chains.
## Fee Types
You can sponsor user fees in three ways:
1. Cover the transaction gas
2. Cover the bridging fee
3. Cover the swap fee (when using [swaps](../chain-abstraction/swaps))
Sponsorship can be applied to all transactions or handled on a case-by-case basis.
You can sponsor both cross-chain and same-chain transactions.
## How it Works
When your users make transactions, you can select which (if any) fees you are willing to sponsor. Rather than charging the user these costs, the Orchestrator instead applies them against your sponsorship balance. You can sponsor up to their deposited amount, after which point, fees will be applied to your users' intents, ensuring continuity should you run out of funds.
The sponsored amount is calculated in USD at the time of the intent, based on the cumulative fees that would otherwise be charged to the user.
## Setup
You can test fee sponsorship on testnets out of the box.
[Reach out to us](http://t.me/kurt_larsen) if you need to set this up for production use. We will provide you with a wallet address to deposit to. Once you deposit, your sponsorship balance will automatically increase.
Currently, we accept USDC deposits on Base. [Reach out to us](http://t.me/kurt_larsen) if you want to use fiat deposits.
## Usage
To sponsor a transaction:
```ts {11} theme={null}
const transactionData = await rhinestoneAccount.prepareTransaction({
sourceChains: [sourceChain],
targetChain,
calls: [
{
to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
value: 0n,
data: '0xdeadbeef',
},
],
sponsored: true,
})
```
You can also choose which fee types to sponsor individually:
```ts {11-15} theme={null}
const transactionData = await rhinestoneAccount.prepareTransaction({
sourceChains: [sourceChain],
targetChain,
calls: [
{
to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
value: 0n,
data: '0xdeadbeef',
},
],
sponsored: {
gas: true,
bridging: true,
swaps: false,
},
})
```
## Sponsorship Policies
If you use [JWT authentication](/intents/configuration/jwt-authentication), you can restrict sponsorship declaratively with the built-in `shouldSponsor` filter — match by chain, account, or calls, evaluated at signing time on your backend.
With API keys, you'll need to enforce the policy yourself. Here's one example, where we only sponsor transactions that interact with a specific contract:
```ts theme={null}
// Sponsors a transaction only if it interacts with our app
function isSponsored(
sourceChains: Chain[],
targetChain: Chain,
calls: {
to: string
value: bigint
data: string
}[],
): boolean {
const appContractAddress = '0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef'
return calls.some((call) => call.to === appContractAddress)
}
```
You can then use that function when handling the user's transaction request:
```ts {12} theme={null}
const calls = [
{
to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' as Address,
value: 0n,
data: '0xdeadbeef' as Hex,
},
]
const transactionData = await rhinestoneAccount.prepareTransaction({
sourceChains: [sourceChain],
targetChain,
calls,
sponsored: isSponsored([sourceChain], targetChain, calls),
})
```
# Set up sponsorship
Source: https://docs.rhinestone.dev/smart-wallet/gas-sponsorship/set-up-sponsorship
Monitor and top up your sponsorship balance
Rhinestone lets you sponsor your users' activity by topping up a single onchain account ("gas tank").
You manage your sponsorship balance in the [Dashboard](https://dashboard.rhinestone.dev) under **Settings → Sponsorship**.
We're moving sponsorship balances from projects to organizations. Your up-to-date balance and new deposit address will reappear here shortly. Do not send funds to or automate deposits against your current address. [Reach out to us](http://t.me/kurt_larsen) if you have questions.
## Viewing your balance
The Sponsorship tab shows your available balance and the deposit address that funds it.
## Topping up
There are two ways to top up:
1. Using the deposit modal with a connected wallet
2. Sending a token transfer manually from any wallet
Only ever send **USDC on Base**. Any other token will not be credited towards your sponsorship balance.
### Using the modal
The easiest way to top up is the deposit modal. Press "Deposit", enter the amount of USDC, and press "Deposit" again.
Approve the transaction in your connected wallet. Once it confirms, your balance updates automatically.
### Manual deposits
If you'd rather send from an external wallet (e.g. a Safe or mobile wallet), press "Copy" next to the deposit address and transfer USDC on Base to it directly. Once the transfer confirms, your balance updates automatically.
# Module SDK
Source: https://docs.rhinestone.dev/smart-wallet/module-sdk
TypeScript library for using smart account modules
The Module SDK documentation has moved to [erc7579.com](https://erc7579.com/tooling/module-sdk).
The Module SDK is a TypeScript library for using smart account modules in applications. It provides utilities for encoding module parameters, getting module install data, and interacting with installed modules.
View the full Module SDK documentation on erc7579.com
# ModuleKit
Source: https://docs.rhinestone.dev/smart-wallet/modulekit
Development kit for building smart account modules
The ModuleKit documentation has moved to [erc7579.com](https://erc7579.com/tooling/modulekit).
ModuleKit is a development kit for building, testing, and deploying smart account modules. It provides a comprehensive toolkit for module developers including testing utilities, deployment helpers, and integration tools.
View the full ModuleKit documentation on erc7579.com
# Overview
Source: https://docs.rhinestone.dev/smart-wallet/overview
A high-level introduction to the Rhinestone SDK for wallet developers
## What is the Rhinestone SDK?
The Rhinestone SDK is a TypeScript library that gives wallet developers a unified interface for deploying and managing self-custodial smart accounts, and connecting those accounts to Rhinestone's crosschain intent infrastructure (Warp).
In one integration, you get:
* **Smart account deployment and management**: create, configure, and interact with ERC-7579 smart accounts (or EIP-7702 upgraded EOAs)
* **Crosschain transaction routing**: send intents that move funds and execute calls across any supported chain, without bridging
* **Fee sponsorship**: sponsor gas, bridging, and swap fees for your users across any supported chain
* **Modular wallet features**: add passkeys, session keys, multisig, social recovery, and more via onchain modules
The SDK is built on [viem](https://viem.sh), the lightweight TypeScript Ethereum library. If your stack already uses viem, the Rhinestone SDK slots in naturally.
## Core Concepts
### Smart Accounts
Rhinestone smart accounts are [ERC-7579](https://erc7579.com) modular smart accounts. ERC-7579 defines a standard interface for pluggable modules (validators, executors, hooks, and fallbacks), so you can extend account behaviour without being locked into one implementation. Rhinestone's core modules include passkeys, session keys, multisig, and social recovery.
Rhinestone also supports [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702), which lets you upgrade an existing EOA into a smart account without migrating assets. This is useful for onboarding users who already have a wallet.
When you create an account, the SDK installs the appropriate modules onchain and derives a deterministic address. The same address is used across all supported chains.
### Rhinestone Intents (Warp)
Warp is Rhinestone's intent routing and execution engine. Instead of sending raw transactions, your users sign *intents*: declarations of what they want to happen (e.g. "move 100 USDC from Optimism and call contract X on Base"). Rhinestone's infrastructure handles the rest.
**How it works:**
Your app calls the SDK to get a quote for the intent, including fees, routes, and estimated time.
The SDK constructs an intent payload and passes it to the user's signer (wallet) for a single signature.
The signed intent is submitted to the Orchestrator, Rhinestone's offchain routing service.
A solver executes the intent on the destination chain, typically in under 2 seconds.
**Key capabilities:**
* **Single- and multi-chain transactions**: send from one chain, execute on another, or sweep across many chains in a single signed intent
* **Crosschain swaps**: solver-based swaps with zero slippage and MEV protection, or inject your own DEX aggregator
* **Universal gas sponsorship**: deposit USDC on Base and sponsor gas, bridging, and swap fees on any supported chain for your users
* **Multi-input intents**: aggregate tokens from multiple source chains in a single UX interaction
### How the SDK, Smart Accounts, and Intents Fit Together
The smart account is the **identity and authorization layer**. It owns the assets and validates that transactions were properly signed by the user's signer. Warp is the **execution layer** that routes and settles transactions across chains as efficiently as possible.
When a user transacts, the SDK:
1. Encodes the intent against their smart account
2. Passes it to their signer (wallet) for approval
3. Sends the signed intent to Warp for routing and execution
### Wallet/Signer Integration
The SDK is signer-agnostic. Your wallet just needs to produce a valid signature. The SDK handles account configuration, intent construction, and submission.
| Signer Type | Examples |
| -------------------------- | ---------------------------------------------- |
| External browser wallet | MetaMask, Coinbase Wallet, WalletConnect |
| Embedded wallet | Privy, Dynamic, Magic, Turnkey, Para, Openfort |
| Server-side / agent wallet | Any EOA private key |
| Passkey (WebAuthn) | Device biometrics (Face ID, Touch ID) |
| Multisig | m-of-n ECDSA or passkey combinations |
## Tech Stack
| Layer | Technology |
| ---------------- | ----------------------- |
| Language | TypeScript |
| Ethereum client | [viem](https://viem.sh) |
| Account standard | ERC-7579 + EIP-7702 |
## Installation
```bash npm theme={null}
npm install viem @rhinestone/sdk
```
```bash pnpm theme={null}
pnpm add viem @rhinestone/sdk
```
```bash bun theme={null}
bun add viem @rhinestone/sdk
```
## What's Next
Set up a smart account and send your first crosschain intent in minutes.
Deeper dive into account abstraction, ERC-4337, and EIP-7702.
Enable 1-click UX and automated transactions for your users.
See all chains supported by Rhinestone's intent infrastructure.
# Quickstart
Source: https://docs.rhinestone.dev/smart-wallet/quickstart
Create a smart account and send a crosschain transaction
Create a smart account and send a crosschain transaction.
By the end you'll have an account deployed on two chains and a USDC transfer that crossed between them, funded entirely with ETH.
## Prerequisites
You'll need a funding account with some testnet ETH on Base Sepolia. Get testnet ETH from [QuickNode](https://faucet.quicknode.com/drip) or [Alchemy](https://www.alchemy.com/faucets).
Install the SDK:
```bash npm theme={null}
npm install viem @rhinestone/sdk
```
```bash pnpm theme={null}
pnpm add viem @rhinestone/sdk
```
```bash bun theme={null}
bun install viem @rhinestone/sdk
```
Create a smart account with a single ECDSA owner:
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { baseSepolia, arbitrumSepolia } from 'viem/chains'
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
erc20Abi,
type Hex,
http,
parseEther,
parseUnits,
} from 'viem'
const fundingPrivateKey = process.env.FUNDING_PRIVATE_KEY
if (!fundingPrivateKey) {
throw new Error('FUNDING_PRIVATE_KEY is not set')
}
const sourceChain = baseSepolia
const targetChain = arbitrumSepolia
// You can use an existing PK here
const privateKey = generatePrivateKey()
console.log(`Owner private key: ${privateKey}`)
const account = privateKeyToAccount(privateKey)
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY,
})
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [account],
},
})
const address = rhinestoneAccount.getAddress()
console.log(`Smart account address: ${address}`)
```
You'll see a deterministic address printed:
```txt theme={null}
Smart account address: 0x...
```
Nothing is onchain yet: the account is deployed lazily on first use, on each chain it touches.
Send ETH to the smart account on the source chain. This is the only token you fund:
```ts theme={null}
const publicClient = createPublicClient({
chain: sourceChain,
transport: http(),
})
const fundingAccount = privateKeyToAccount(fundingPrivateKey as Hex)
const fundingClient = createWalletClient({
account: fundingAccount,
chain: sourceChain,
transport: http(),
})
const txHash = await fundingClient.sendTransaction({
to: address,
value: parseEther('0.001'),
})
await publicClient.waitForTransactionReceipt({ hash: txHash })
```
Transfer USDC on the target chain, sourced from the ETH you funded on the source chain:
```ts theme={null}
const usdcAmount = parseUnits('0.1', 6)
const usdc = '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' // USDC on Arbitrum Sepolia
const prepared = await rhinestoneAccount.prepareTransaction({
sourceChains: [sourceChain],
targetChain,
calls: [
{
to: usdc,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: ['0xd8da6bf26964af9d7eed9e03e53415d37aa96045', usdcAmount],
}),
},
],
tokenRequests: [
{
address: usdc,
amount: usdcAmount,
},
],
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transaction = await rhinestoneAccount.submitTransaction(signed)
console.log('Transaction', transaction)
const transactionResult = await rhinestoneAccount.waitForExecution(transaction)
console.log('Result', transactionResult)
```
`submitTransaction` returns an intent handle; `waitForExecution` polls until every chain's operation reaches a terminal state:
```txt theme={null}
Transaction {
type: 'intent',
id: '0x9c…',
traceId: '…',
sourceChains: [ 84532 ],
targetChain: 421614
}
Result {
status: 'COMPLETED',
accountAddress: '0x5fA3…42c1',
operations: [
{ chain: 84532, status: 'COMPLETED', txHash: '0x…', timestamp: 1750000000 },
{ chain: 421614, status: 'COMPLETED', txHash: '0x…', timestamp: 1750000002 }
]
}
```
Your ETH on Base Sepolia landed as USDC on Arbitrum Sepolia in a single atomic operation: no bridging, swapping, or gas tokens to manage.
**Building a browser app?** Use the [Reown + Rhinestone example](https://github.com/rhinestonewtf/e2e-examples/tree/main/reown) to get wallet connection working with MetaMask or any WalletConnect-compatible wallet.
## Next steps
Cover gas, bridging, and swap fees for your users across any chain.
Configure signers: passkeys, embedded wallets, multisig, and more.
Add session keys for one-click UX and automated transactions.
# Multi-chain session
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/multi-session-signature
Reuse the session signature across multiple chains
## Overview
Multi-chain session keys allow you to create and manage session keys that work across multiple blockchains with a single signature.
* **Sign once, deploy everywhere**: Create multiple sessions for different chains and sign them all at once
* **Flexible installation**: Install sessions in any order across different chains as needed
* **Reduce user friction**: Users only need to sign once instead of multiple times for each chain
* **Cross-chain automation**: Enable seamless automation across multiple chains with the same session key
The key innovation is the "enable mode", where you prepare all your sessions upfront, get a single signature from the user, and enable specific sessions on specific chains as needed.
While this guide focuses on multi-chain usage, you can also use this pattern on a single chain to pre-sign multiple different sessions and enable them over time as your application needs to evolve.
First, define your sessions for different chains. Each session can have different owners, policies, and permissions depending on your use case:
```ts theme={null}
import { baseSepolia, optimismSepolia } from 'viem/chains'
const sessions = await Promise.all([
rhinestone.createSession({
chain: baseSepolia,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccountA],
},
// Add specific policies and permissions as needed
}),
rhinestone.createSession({
chain: baseSepolia,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccountB],
},
}),
rhinestone.createSession({
chain: optimismSepolia,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccountB],
},
}),
])
```
To enable a session, you need to get the session details and enable data. This step prepares the session for installation on the target chain:
```ts theme={null}
const sessionDetails =
await rhinestoneAccount.getSessionDetails(sessions)
const enableSignature =
await rhinestoneAccount.signEnableSession(sessionDetails)
const sessionIndex = 0 // Which session to enable
```
The method returns the data needed to install the session on-chain, including the signature required to enable sessions.
The account owner will be prompted to sign the session installation request.
### Reusing the Signature
One of the most powerful features is the ability to reuse an existing signature across multiple session installations. Once you have the signature to enable a session, you can use it for future installations. For this, store the `sessionDetails` and `enableSignature` for later use.
This pattern allows you to:
* Pre-sign all your sessions during user onboarding
* Enable specific sessions on-demand without additional signatures
* Batch multiple session installations with the same signature
Once your sessions are enabled, you can use them to sign transactions on any supported chain:
```ts theme={null}
const data = await rhinestoneAccount.prepareTransaction({
chain,
calls: [
{
to: targetAddress,
data: transactionData,
},
],
signers: {
type: 'session',
session: sessions[sessionIndex],
enableData: {
userSignature: enableSignature,
hashesAndChainIds: sessionDetails.hashesAndChainIds,
sessionToEnableIndex: sessionIndex,
},
},
})
// Sign with the session key
const signedData = await rhinestoneAccount.signTransaction(data)
await rhinestoneAccount.submitTransaction(signedData)
```
## Complete Example
Here's a complete working example that demonstrates the full multi-chain session workflow:
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
import { zeroAddress } from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { baseSepolia, optimismSepolia } from 'viem/chains'
const ownerAccount = privateKeyToAccount('0x…')
const sessionOwnerAccount = privateKeyToAccount(generatePrivateKey())
const rhinestone = new RhinestoneSDK({
apiKey: rhinestoneApiKey,
})
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [ownerAccount],
},
})
// Define the sessions, one per chain
const sessions = await Promise.all([
rhinestone.createSession({
chain: baseSepolia,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
}),
rhinestone.createSession({
chain: optimismSepolia,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
}),
])
// Get the session details and sign once
const sessionDetails =
await rhinestoneAccount.getSessionDetails(sessions)
const enableSignature =
await rhinestoneAccount.signEnableSession(sessionDetails)
// Build the per-chain sessions map with enable data
// The SDK resolves the right session and enable data automatically per chain
const signers = {
type: 'session' as const,
sessions: {
[baseSepolia.id]: {
session: sessions[0],
enableData: {
userSignature: enableSignature,
hashesAndChainIds: sessionDetails.hashesAndChainIds,
sessionToEnableIndex: 0,
},
},
[optimismSepolia.id]: {
session: sessions[1],
enableData: {
userSignature: enableSignature,
hashesAndChainIds: sessionDetails.hashesAndChainIds,
sessionToEnableIndex: 1,
},
},
},
}
// Submit on Base
const dataBase = await rhinestoneAccount.prepareTransaction({
chain: baseSepolia,
calls: [{ to: zeroAddress, data: '0xdeadbeef' }],
tokenRequests: [],
signers,
})
const signedDataBase = await rhinestoneAccount.signTransaction(dataBase)
await rhinestoneAccount.submitTransaction(signedDataBase)
// Submit on Optimism (reusing the same signature and signers map)
const dataOptimism = await rhinestoneAccount.prepareTransaction({
chain: optimismSepolia,
calls: [{ to: zeroAddress, data: '0xdeadbeef' }],
tokenRequests: [],
signers,
})
const signedDataOptimism = await rhinestoneAccount.signTransaction(dataOptimism)
await rhinestoneAccount.submitTransaction(signedDataOptimism)
```
# Overview
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/overview
Rhinestone’s framework for creating and managing session keys. It is a powerful system for creating onchain permissions.
## Introduction
Session keys are cryptographically signed keys generated by a user’s master key (passkey, ECDSA, or multisig). Smart Sessions enables session keys to be created and used with all major smart account implementations (via ERC-7579) and is fully compatible with Rhinestone’s Warp transaction infrastructure.
Examples of the onchain permissions that can be tailored with Smart Sessions include:
* Interacting only with a specific DeFi protocol (Aave or Uniswap)
* Spending limits on ERC20s or ETH
* Timeframes for expiry after a pre-determined period
* Combining permissions (e.g., Uniswap-only, 1000 USDC limit, 3-day expiry)
Key example use cases include:
* **Skipping confirmations**: Store a session key locally for “one-click trading,” allowing seamless decentralized application (dapp) interactions without repeated signing prompts.
* **Automating transactions**: Users share a scoped key for server-side execution, enabling:
* Subscription payments
* Limit orders or stop orders
* Auto-repaying loans to prevent liquidation
* This granular control enhances security, streamlines dapp interactions, and makes Web3 more user-friendly.
## How it works
[Smart Sessions](https://github.com/erc7579/smartsessions) is built around three concepts: **owners** (who can sign), **permissions** (what they can do), and **policies** (under what conditions).
## Owners
Smart Sessions support a wide range of signing mechanisms out of the box:
* [Single ECDSA key](./signature-validators/ecdsa)
* [Multiple ECDSA keys](./signature-validators/multisig) (i.e. multisig)
* [Passkeys](./signature-validators/passkey)
You can also use custom validators to validate sessions, as long as they are [ERC-7780](https://eips.ethereum.org/EIPS/eip-7780) compatible.
## Permissions
Permissions define what calls a session is allowed to make. A permission is defined by an *ABI* and a target *address*, and lists the *functions* on that contract the session can call. The SDK derives selectors and parameter offsets from the ABI and checks parameter value types against ABI input types.
When defining multiple permissions within a session, a transaction that matches **any** specified permission is considered valid. If no permissions are specified, **any** transaction will pass.
When using smart contracts directly, you need to explicitly provide a list of valid permissions.
## Policies
Policies let you restrict the session to hit specific conditions. You can define policies at the *session* (affects the entire session) or *function* (affects a single function within a permission) level.
Supported policies include:
* [Sudo](./policies/sudo): allows any transaction
* [Call](./policies/call): allows transactions with the specified calldata
* [Spending limit](./policies/spending-limit): allows a limited value of ERC20 tokens to be transferred and approved
* [Timeframe](./policies/timeframe): allows transactions within the specified time frame
* [Usage limit](./policies/usage-limit): allows a limited number of transactions
* Value limit: allows a limited ETH value transferred
When defining multiple policies within a function, a transaction that passes **every** specified policy is considered valid. If no policies are specified, **any** transaction will pass (i.e., the sudo policy is applied).
Policies work like a logical AND. If a function has two policies, the transaction must pass both policies to be valid.
## Usage
### Installing the validation
You can install the validator during account deployment:
```ts {6-8} theme={null}
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [ownerAccount],
},
sessions: {
enabled: true,
},
})
```
You can also install it when the account is already deployed:
```ts theme={null}
import { enable } from '@rhinestone/sdk/actions/smart-sessions'
const transaction = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [enable()],
})
```
To uninstall the validator:
```ts theme={null}
import { disable } from '@rhinestone/sdk/actions/smart-sessions'
const transaction = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [disable()],
})
```
### Creating Sessions
To create a session, use `RhinestoneSDK.createSession`:
```ts theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
})
```
You can also limit the session to specific allowed permissions:
```ts {7-13} theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: { transfer: {} },
},
],
})
```
Finally, you can constrain function parameters. The SDK derives the selector and parameter offsets from the ABI, so you reference parameters by name:
```ts {11-20} theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
params: {
recipient: {
condition: 'equal',
value: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
},
},
},
},
},
],
})
```
### Cross-chain permits
Use `crossChainPermits` to scope which routes, tokens, amounts, and recipients it may bridge:
```ts {10-15} theme={null}
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
const usdcOnArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
crossChainPermits: [
{
from: [{ chain: base, token: usdcOnBase }],
to: [{ chain: arbitrum, token: usdcOnArbitrum }],
},
],
})
```
See [Cross-chain permits](./policies/cross-chain) for guardrails, recipient safety, and settlement-layer options.
### Installing sessions
To enable a session:
```ts theme={null}
import { enableSession } from '@rhinestone/sdk/actions/smart-sessions'
const sessions = [session]
const sessionDetails =
await rhinestoneAccount.getSessionDetails(sessions)
const enableSignature =
await rhinestoneAccount.signEnableSession(sessionDetails)
const sessionIndex = 0
const transaction = await rhinestoneAccount.prepareTransaction({
chain,
calls: [
enableSession(
session,
enableSignature,
sessionDetails.hashesAndChainIds,
sessionIndex,
),
],
})
```
You can also enable session with a signature. See [Multi-Session Signature](./multi-session-signature) for more details.
### Checking session status
To check if a session is enabled:
```ts theme={null}
const isEnabled = await rhinestoneAccount.isSessionEnabled(session)
```
### Using sessions
To authorize a transaction with a session key you've enabled before:
```ts {13-16} theme={null}
const prepared = await rhinestoneAccount.prepareTransaction({
chain,
calls: [
{
to: usdcAddress,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: ['0xd8da6bf26964af9d7eed9e03e53415d37aa96045', 1n],
}),
},
],
signers: {
type: 'session',
session,
},
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const transactionResult = await rhinestoneAccount.submitTransaction(signed)
```
This will prompt the signature request from the session owner(s) and submit the transaction on their behalf.
You can also enable and use the smart session in one transaction using the ["enable mode"](./multi-session-signature).
### Disabling a session
To disable a single enabled session, use `disableSession`. This only removes a single session: to uninstall the validator entirely (and every session with it), use [`disable`](#installing-the-validation) instead.
```ts theme={null}
import { disableSession } from '@rhinestone/sdk/actions/smart-sessions'
const transaction = await rhinestoneAccount.prepareTransaction({
chain,
calls: [disableSession(session)],
})
```
## Security
Smart Sessions is a powerful tool that unlocks a bunch of new opportunities and use cases. To keep your users secure when using sessions, follow these guidelines:
* Store the session key securely. Depending on the use case, you can opt to store it in the browser or on your backend. Consider key management solutions like [KMS](https://aws.amazon.com/kms/) or [Lit Protocol](https://www.litprotocol.com).
* Stick to the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege): do not request more actions than you need.
* Guard your smart session with granular policies (e.g., restrict the amount of ETH that can be transacted through the session)
* If possible, timebox your session (e.g., make it valid for only 1 week)
By default, the SDK creates a session that allows any transaction. Make sure you restrict it with relevant permissions and policies.
[Reach out to us](http://t.me/kurt_larsen) if you need any help!
# Call
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/policies/call
The call policy filters transactions by calldata and ETH value. In v2 sessions, you express it as ABI-driven *parameter rules* on a permission's function — the SDK reads the function selector and parameter offsets from the ABI for you.
## Conditions
Available conditions:
* equal ($x = A$)
* greater than ($x > A$)
* less than ($x < A$)
* greater than or equal ($x \geq A$)
* less than or equal ($x \leq A$)
* not equal ($x \neq A$)
* in range ($A \leq x \leq B$)
## Usage
To restrict an ERC20 transfer to a single recipient:
```ts {15-17} theme={null}
const receiver = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
params: {
recipient: { condition: 'equal', value: receiver },
},
},
},
},
],
})
```
Reference parameters by name — the SDK looks them up in the ABI. Only static types are supported (`address`, `bool`, `uint*`, `int*`, `bytes1`–`bytes32`).
### Param accumulator
Cap the total accumulated value of a param across all session uses with `usageLimit`. Useful for capping cumulative spend across calls.
```ts {13-19} theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
params: {
amount: {
condition: 'lessThan',
value: parseUnits('10', 6),
usageLimit: parseUnits('50', 6),
},
},
},
},
},
],
})
```
This caps each transfer at 10 USDC and the total across the session lifetime at 50 USDC.
### Value limit
To cap the ETH value sent on a single call, use `valueLimitPerUse` at the function level:
```ts {13} theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: vaultAbi,
address: vaultAddress,
functions: {
depositEth: {
valueLimitPerUse: parseUnits('0.1', 18),
},
},
},
],
})
```
This limits the value per call to 0.1 ETH.
# Cross-chain permits
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/policies/cross-chain
Cross-chain permits let you define the scope of the cross-chain bridging allowed by a session.
You set them at the session level via `crossChainPermits`, not per function. Each entry whitelists a source/destination pair and the limits the session has to stay within.
## Basic usage
Restrict a session to only bridge USDC from Base to Arbitrum:
```ts {10-15} theme={null}
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
const usdcOnArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
crossChainPermits: [
{
from: [{ chain: base, token: usdcOnBase }],
to: [{ chain: arbitrum, token: usdcOnArbitrum }],
},
],
})
```
Pass each token's address on its corresponding chain. Pass a single leg or an array, and omit a side entirely to leave it unrestricted — the settlement-layer allowlist, deadlines, and recipient rules still apply.
| Field | Description |
| ------ | -------------------------------------------------------------------------------------------------- |
| `from` | Source chain + token (+ optional `maxAmount` cap). Omit for no source-token restriction. |
| `to` | Destination chain + token (+ optional `recipient` pin). Omit for no destination-token restriction. |
## Guardrails
Tighten a permit with optional bounds:
```ts {11-17} theme={null}
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
const usdcOnArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
crossChainPermits: [
{
from: [{ chain: base, token: usdcOnBase, maxAmount: parseUnits('100', 6) }],
to: [{ chain: arbitrum, token: usdcOnArbitrum }],
validAfter: new Date('2026-01-01'),
validUntil: new Date('2027-01-01'),
fillDeadline: [{ chain: arbitrum, max: new Date('2027-01-01') }],
},
],
})
```
* `maxAmount` on a source leg caps how much of that token the session can pull (a spending limit).
* `validAfter` / `validUntil` bound the permit deadline. Both accept a `Date`.
* `fillDeadline` bounds the fill window per destination chain.
## Recipient safety
By default a cross-chain permit enforces **bridge-to-self** on-chain: the destination recipient must be the smart account itself. This stops a leaked session key from routing funds to an attacker-controlled address. Opt out explicitly only when you need to bridge to a different recipient:
```ts {13-14} theme={null}
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
const usdcOnArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
crossChainPermits: [
{
from: [{ chain: base, token: usdcOnBase }],
to: [{ chain: arbitrum, token: usdcOnArbitrum, recipient: payoutAddress }],
allowRecipientNotAccount: true,
},
],
})
```
## Settlement layers
A permit allows any supported settlement layer by default. Pass `settlementLayers` to narrow it to a subset:
```ts {14} theme={null}
const usdcOnBase = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
const usdcOnArbitrum = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
crossChainPermits: [
{
from: [{ chain: base, token: usdcOnBase }],
to: [{ chain: arbitrum, token: usdcOnArbitrum }],
settlementLayers: ['ECO'],
},
],
})
```
# Gas limit
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/policies/gas-limit
Limit the total gas a session key can consume.
Coming soon.
# Spending limit
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/policies/spending-limit
You can limit the amount of ERC20 tokens transferable through a session function. Attach the policy at the function level — it caps the cumulative amount across all uses of that function.
```ts {13-16} theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
spendingLimit: {
token: usdcAddress,
amount: parseUnits('10', 6),
},
},
},
},
],
})
```
This caps total USDC transfers within the session to 10 USDC.
# Sudo
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/policies/sudo
Sudo allows any transaction to pass. It's the default — a session with no `permissions` accepts everything.
```ts theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
})
```
Always restrict sessions to the minimum permissions you need. A sudo session has the same authority as the account owner.
# Timeframe
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/policies/timeframe
Restrict when a session function can be called. Attach the policy at the function level.
```ts {13-14} theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
validAfter: new Date(),
validUntil: new Date(Date.now() + 1000 * 60 * 60 * 24),
},
},
},
],
})
```
This limits the function to a 24-hour window.
# Usage limit
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/policies/usage-limit
Limit how many times a session function can be called.
```ts {13} theme={null}
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
maxUses: 10n,
},
},
},
],
})
```
This caps the function at 10 calls.
# ECDSA
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/signature-validators/ecdsa
To make an EOA account the session owner:
```ts theme={null}
const session: Session = {
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
}
```
# Multi-sig
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/signature-validators/multisig
To create a multisig session:
```ts {4} theme={null}
const session: Session = {
owners: {
type: 'ecdsa',
accounts: [accountA, accountB, accountC],
},
}
```
You can also specify the signature threshold (i.e., how many signatures are required to authorize the transaction):
```ts {5} theme={null}
const session: Session = {
owners: {
type: 'ecdsa',
accounts: [accountA, accountB, accountC],
threshold: 2,
},
}
```
By default, the `threshold` is 1 (any session owner account can authorize any transaction within the session).
# Passkey
Source: https://docs.rhinestone.dev/smart-wallet/smart-sessions/signature-validators/passkey
To make a passkey account the session owner:
```ts theme={null}
const session: Session = {
owners: {
type: 'passkey',
accounts: [sessionOwnerAccount],
},
}
```
# Add session keys
Source: https://docs.rhinestone.dev/smart-wallet/tutorials/session-keys
Enable one-click UX by letting users grant scoped permissions to your app.
By default, every transaction requires the user to sign. Session keys let your app hold a scoped key that can sign on the user's behalf, within limits you define. The user approves once and your app executes freely until the session expires or limits are hit.
This tutorial walks through a one-click trading scenario: the user grants your app permission to execute USDC transfers up to a spending limit, without prompting for every trade.
This tutorial builds on the [Quickstart](../quickstart). You'll need a working smart account before continuing.
## Prerequisites
* Completed the [Quickstart](../quickstart)
* A [Rhinestone dashboard](https://dashboard.rhinestone.dev) API key
## Steps
Enable the Smart Sessions module when creating the account:
```ts theme={null}
import { RhinestoneSDK } from '@rhinestone/sdk'
const rhinestone = new RhinestoneSDK({
apiKey: process.env.RHINESTONE_API_KEY as string,
})
const rhinestoneAccount = await rhinestone.createAccount({
owners: {
type: 'ecdsa',
accounts: [ownerAccount],
},
sessions: {
enabled: true,
},
})
```
If the account is already deployed, you can install the module in a separate transaction:
```ts theme={null}
import { enable } from '@rhinestone/sdk/actions/smart-sessions'
const transaction = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [enable()],
})
const signed = await rhinestoneAccount.signTransaction(transaction)
const result = await rhinestoneAccount.submitTransaction(signed)
await rhinestoneAccount.waitForExecution(result)
```
Create an ephemeral key pair that your app will use to sign on the user's behalf. In production, store this key securely. Use `localStorage` for client-side sessions, or a KMS/secrets manager for server-side automation.
```ts theme={null}
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
const sessionPrivateKey = generatePrivateKey()
const sessionOwnerAccount = privateKeyToAccount(sessionPrivateKey)
```
Specify what the session key is allowed to do. Here we restrict it to USDC transfers only, with a 100 USDC spending limit:
```ts theme={null}
import { erc20Abi, parseUnits } from 'viem'
import { base } from 'viem/chains'
const usdcAddress = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // USDC on Base
const session = await rhinestone.createSession({
chain: base,
owners: {
type: 'ecdsa',
accounts: [sessionOwnerAccount],
},
permissions: [
{
abi: erc20Abi,
address: usdcAddress,
functions: {
transfer: {
spendingLimit: {
token: usdcAddress,
amount: parseUnits('100', 6), // 100 USDC
},
},
},
},
],
})
```
By default, a session with no permissions allows **any** transaction. Always restrict sessions to the minimum necessary permissions.
The account owner signs to approve the session. This is the one-time approval the user sees:
```ts theme={null}
import { enableSession } from '@rhinestone/sdk/actions/smart-sessions'
const sessions = [session]
const sessionDetails = await rhinestoneAccount.getSessionDetails(sessions)
const enableSignature = await rhinestoneAccount.signEnableSession(sessionDetails)
const sessionIndex = 0
const transaction = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [
enableSession(
session,
enableSignature,
sessionDetails.hashesAndChainIds,
sessionIndex,
),
],
})
const signed = await rhinestoneAccount.signTransaction(transaction)
const result = await rhinestoneAccount.submitTransaction(signed)
await rhinestoneAccount.waitForExecution(result)
```
Now your app can execute USDC transfers without prompting the user. The session key signs instead of the owner:
```ts theme={null}
import { encodeFunctionData, erc20Abi } from 'viem'
const recipient = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const amount = parseUnits('10', 6) // 10 USDC
const prepared = await rhinestoneAccount.prepareTransaction({
chain: base,
calls: [
{
to: usdcAddress,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipient, amount],
}),
},
],
signers: {
type: 'session',
session,
},
})
const signed = await rhinestoneAccount.signTransaction(prepared)
const result = await rhinestoneAccount.submitTransaction(signed)
const status = await rhinestoneAccount.waitForExecution(result)
console.log('Executed without user prompt:', status)
```
The user's MetaMask (or other wallet) is never involved. Your app signed with the session key, within the spending limit the user approved.
## What you built
* A smart account with Smart Sessions installed
* A scoped session key (USDC transfers only, 100 USDC limit)
* One-time user approval flow
* App-signed transactions with no user prompts
## Security checklist
Before shipping session keys to production:
* **Store the session key securely.** Use `localStorage` for browser-side sessions, a KMS or secrets manager for server-side automation.
* **Apply the principle of least privilege.** Only request the actions your app actually needs.
* **Set a timeframe policy.** Add an expiry so sessions don't live forever.
* **Set spending limits.** Cap ERC20 transfers to a sensible amount.
**See it in action:** [session-keys example](https://github.com/rhinestonewtf/e2e-examples/tree/main/session-keys) — approve a scoped session once, then execute with the session key.
## Next steps
Full details on owners, actions, policies, and multi-session signatures.
Explore all available policies: timeframe, usage limit, call restrictions.
Combine session keys with fee sponsorship for a fully frictionless UX.
# Sponsor fees for your users
Source: https://docs.rhinestone.dev/smart-wallet/tutorials/sponsor-fees
Cover gas, bridging, and swap fees for your users with a single deposit.
Transaction fees are one of the biggest UX barriers in crypto. With Rhinestone's fee sponsorship, you deposit USDC once and the SDK covers gas, bridging, and swap fees for your users across any supported chain. No per-chain setup required.
This tutorial builds on the [Quickstart](../quickstart). You'll need a working smart account setup before continuing.
## Prerequisites
* Completed the [Quickstart](../quickstart)
* A [Rhinestone dashboard](https://dashboard.rhinestone.dev) account with an API key
* USDC on Base (testnet USDC from [Circle Faucet](https://faucet.circle.com/) for testing)
## Steps
Open the [Rhinestone dashboard](https://dashboard.rhinestone.dev) and go to the **Sponsorship** tab.
Press **Deposit**, enter an amount of USDC, and confirm the transaction in your wallet. Your balance updates automatically once the transaction is confirmed.
On testnets, sponsorship works out of the box with no deposit required. For production, [reach out](https://t.me/kurt_larsen) to get your deposit address set up.
Add `sponsored: true` when preparing the transaction:
```ts theme={null}
const prepared = await account.prepareTransaction({
sourceChains: [arbitrum],
targetChain: base,
calls: [
{
to: recipientAddress,
value: 0n,
data: '0x',
},
],
sponsored: true,
})
const signed = await account.signTransaction(prepared)
const result = await account.submitTransaction(signed)
const status = await account.waitForExecution(result)
console.log('Transaction settled:', status)
```
Your user signs once. The Rhinestone orchestrator deducts the fees from your sponsorship balance and the user pays nothing.
You can choose which fee types to cover:
```ts theme={null}
const transaction = await account.prepareTransaction({
sourceChains: [arbitrum],
targetChain: base,
calls: [...],
sponsored: {
gas: true,
bridging: true,
swaps: false,
},
})
```
Or apply sponsorship conditionally on your backend. For example, only sponsor transactions that interact with your app's contract:
```ts theme={null}
function shouldSponsor(calls: { to: string }[]): boolean {
const appContract = '0xYourContractAddress'
return calls.some((call) => call.to.toLowerCase() === appContract.toLowerCase())
}
const calls = [{ to: '0xYourContractAddress', value: 0n, data: encodedCalldata }]
const transaction = await account.prepareTransaction({
sourceChains: [arbitrum],
targetChain: base,
calls,
sponsored: shouldSponsor(calls),
})
```
## Next steps
Full details on fee types, policies, and how sponsorship is calculated.
Customise your signer: passkeys, embedded wallets, multi-factor auth.
Give users one-click UX with scoped session keys and onchain permissions.