> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rhinestone.dev/llms.txt
> Use this file to discover all available pages before exploring further.

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

<Note>
  On the `./claim` subpath. Applies to deposits in `failed` or `rejected` status. See
  [troubleshooting](/deposits/troubleshooting) for the operator-side equivalent in the
  dashboard.
</Note>

## 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";

<ClaimModal
  isOpen={isOpen}
  onClose={() => 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.

<Note>
  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.
</Note>

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)}
```

<Warning>
  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.
</Warning>

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.

<Warning>
  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.
</Warning>

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

<Warning>
  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.
</Warning>

## 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<Response>`, so it mounts in any fetch-based runtime: Next.js route handlers, Hono, `Bun.serve`, Cloudflare Workers.

<Warning>
  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.
</Warning>

<Warning>
  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.
</Warning>

## Props reference

### Required

| Prop           | Type                        | Description                                                                          |
| -------------- | --------------------------- | ------------------------------------------------------------------------------------ |
| `isOpen`       | `boolean`                   | Controls modal visibility                                                            |
| `onClose`      | `() => void`                | Called when the user closes the modal                                                |
| `signRecovery` | `(payload) => Promise<Hex>` | 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}
<ClaimModal
  // ...required props
  onLifecycle={(event) => {
    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.
