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

# Recovering assets from a dashboard wallet

> Move ERC-20 and SPL tokens out of a wallet when the Privy Dashboard does not support sending the asset, chain, or wallet type.

The Privy Dashboard supports manually initiating transfers from wallets for
[supported assets](/wallets/actions/transfer/overview#supported-assets) on
[supported chains](/wallets/actions/transfer/overview#supported-chains). A wallet can therefore hold a
token that the dashboard displays but does not automatically support sending: an unlisted ERC-20, an SPL
token on a network the dashboard does not offer, or a balance in a wallet the dashboard cannot send from.

Every one of those balances is still recoverable. The wallet's keys are unchanged, and Privy's wallet API
supports a far wider range of assets, chains, and wallet types than the dashboard UI exposes. This recipe
covers the three recovery paths, in order of increasing effort, and how to choose between them.

<Info>
  Recovery needs no support ticket and no new wallet. Refer to the [chains
  overview](/wallets/overview/chains) for the support tier of the chain in question, since the tier
  determines whether Privy broadcasts the transaction or the transaction must be broadcast
  independently.
</Info>

## Why the dashboard cannot send an asset

The dashboard displays balances for a broader set of assets than it can transfer, so a balance appearing
in the UI is not a guarantee that the UI can move it. Identify the cause first, because it determines the
cheapest recovery path.

| Cause                                                                            | Recovery path                                                                               |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| The token is an ERC-20 or SPL token that is not one of Privy's well-known assets | [Option 1: Token watchlist](#option-1-token-watchlist)                                      |
| The wallet is an end-user or embedded wallet rather than a server wallet         | [Option 2: Transfer action API](#option-2-transfer-action-api)                              |
| The wallet is owned by a key quorum of team members                              | [Propose a transfer intent](/controls/dashboard/intents#transfer-funds) for manual approval |
| The network is outside the transfer API's supported chains                       | [Option 3: Low-level RPC](#option-3-low-level-rpc)                                          |
| The chain type is neither EVM nor Solana, such as Tron or Sui                    | [Tier 2 chains recipe](/recipes/use-tier-2)                                                 |

## Choosing a recovery path

<CardGroup cols={2}>
  <Card title="Token watchlist" icon="list-check" href="#option-1-token-watchlist">
    No code. Registers the token so the dashboard transfer flow can send it. Covers ERC-20, SPL, and
    Token-2022 on supported networks.
  </Card>

  <Card title="Transfer action API" icon="code" href="#option-2-transfer-action-api">
    Recommended. One request moves any ERC-20 or SPL token on a supported chain, from any wallet,
    with no transaction construction.
  </Card>

  <Card title="Low-level RPC" icon="key" href="#option-3-low-level-rpc">
    For chains the transfer API does not cover. Build the transaction, have Privy sign it, and
    broadcast it if Privy does not.
  </Card>

  <Card title="Transfer intent" icon="user-check" href="/controls/dashboard/intents#transfer-funds">
    For a wallet owned by a key quorum of team members. Propose the transfer, then approve it from
    the dashboard.
  </Card>
</CardGroup>

Every option keeps the wallet's private key inside Privy's secure enclave. Recovery never requires
exporting the key.

## Prerequisites

* The wallet's ID, from the [Wallets](https://dashboard.privy.io/apps?page=wallets) page of the dashboard
* The app's ID and app secret, from the [Basics](https://dashboard.privy.io/apps?page=settings\&tab=basics) tab of the app's settings
* The token's contract address (EVM) or mint address (Solana)
* A destination address that can hold the asset
* Enough native currency in the wallet to pay gas, unless the app has [gas sponsorship](/wallets/gas-and-asset-management/gas/overview) configured

<Warning>
  A token transfer is a contract call, and the wallet pays its own fees. Fund the wallet with a
  small amount of ETH, SOL, or the chain's native gas token before attempting any transfer path.
  Transfers made through the [transfer action API](#option-2-transfer-action-api) are gas-sponsored
  by default when the app has sponsorship configured.
</Warning>

### Check whether the wallet has an owner

Whether the wallet has an owner determines how each request is authorized, so establish it before
starting. Fetch the wallet and check `owner_id`, which is `null` for an ownerless wallet:

```bash theme={"system"}
curl https://api.privy.io/v1/wallets/{wallet_id} \
  --header 'Authorization: Basic <base64-encoded app-id:app-secret>' \
  --header 'privy-app-id: <privy-app-id>'
```

| Wallet                                              | How to recover                                                                       |
| --------------------------------------------------- | ------------------------------------------------------------------------------------ |
| No owner, the default for dashboard-created wallets | Call the API directly, as shown below. The app secret alone authorizes each request  |
| Owned by a key quorum of team members               | [Propose an intent](/controls/dashboard/intents), then approve it from the dashboard |

<Note>
  A wallet owned by a key quorum cannot be recovered with a direct API call, because the request
  carries no authorization signatures from the quorum. Propose the transfer as an
  [intent](/controls/dashboard/intents#transfer-funds) instead, then have the quorum's reviewers
  approve it in the dashboard. Intents expire 72 hours after creation.
</Note>

## Option 1: Token watchlist

Privy's transfer flow sends [well-known assets](/wallets/actions/transfer/overview#well-known-assets)
such as USDC, USDT, EURC, ETH, and SOL out of the box. Any other ERC-20 or SPL token becomes sendable
once it is registered as a custom asset for the app. This is the only recovery path that requires no
code, so it is worth attempting first.

<Steps>
  <Step title="Register the token">
    Open the [**Token
    watchlist**](https://dashboard.privy.io/apps?page=settings\&tab=token-watchlist) tab of the
    **Wallets** page, then add the token's contract or mint address on the network holding the
    balance.
  </Step>

  <Step title="Confirm the balance appears">
    Return to the wallet's page. The token now appears as a selectable asset in the transfer flow
    rather than a display-only balance.
  </Step>

  <Step title="Send the balance">
    Start a transfer from the wallet, select the newly registered token, and enter the destination
    address.
  </Step>
</Steps>

If the token still cannot be selected, the blocker is the wallet or the network rather than the asset.
Continue to option 2.

## Option 2: Transfer action API

The [transfer action API](/wallets/actions/transfer/overview) accepts a human-readable asset, amount,
chain, and destination, and handles the onchain construction. It works from any wallet the caller is
authorized to use, including end-user and embedded wallets that the dashboard cannot send from, and it
resolves token decimals so the amount is a plain decimal string.

Register the token in the [token watchlist](#option-1-token-watchlist) first, then pass its address as
`source.asset_address`:

```bash theme={"system"}
curl --request POST https://api.privy.io/v1/wallets/{wallet_id}/transfer \
  --header 'Authorization: Basic <base64-encoded app-id:app-secret>' \
  --header 'privy-app-id: <privy-app-id>' \
  --header 'Content-Type: application/json' \
  --data '{
    "source": {
      "asset_address": "0x...",
      "amount": "1234.56",
      "chain": "base"
    },
    "destination": {
      "address": "0x..."
    }
  }'
```

For a well-known asset, pass `source.asset` instead of `source.asset_address`, using the asset's API
value such as `usdc`. Provide exactly one of the two fields; sending both is rejected. Refer to
[transfer usage](/wallets/actions/transfer/usage) for the full parameter list and response shape.

The response is an action rather than a transaction, returning an `id` and a `status` of `pending`. Poll
the action until it reports a terminal status to confirm the recovery landed:

```bash theme={"system"}
curl https://api.privy.io/v1/wallets/{wallet_id}/actions/{action_id} \
  --header 'Authorization: Basic <base64-encoded app-id:app-secret>' \
  --header 'privy-app-id: <privy-app-id>'
```

To recover the entire position, read the wallet's balance for the asset first and pass it as
`source.amount`. Recovering the wallet's native balance is the same request with `asset` set to the
native token, run last so the earlier transfers still have gas available.

<Tip>
  Confirm that no [policy](/controls/policies/overview) on the wallet denies the transfer before
  debugging further. A policy that allowlists specific contracts will reject a recovery transaction
  against an unlisted token contract.
</Tip>

## Option 3: Low-level RPC

The transfer action API covers a
[fixed set of chains](/wallets/actions/transfer/overview#supported-chains). Outside that set, build the
transaction and use Privy's signing RPCs directly. Which RPC applies depends on the wallet's
`chain_type`, not on the token.

<Tabs>
  <Tab title="ERC-20 on any EVM chain">
    For an EVM chain that Privy broadcasts to, encode a `transfer` call and send it as a normal transaction.
    The `to` field is the token contract; the destination is an argument to `transfer`.

    ```typescript {skip-check} theme={"system"}
    import {PrivyClient} from '@privy-io/node';
    import {createPublicClient, http, encodeFunctionData, erc20Abi} from 'viem';
    import {base} from 'viem/chains';

    const privy = new PrivyClient({
      appId: process.env.PRIVY_APP_ID!,
      appSecret: process.env.PRIVY_APP_SECRET!
    });

    const walletId = 'insert-wallet-id';
    const walletAddress = '0x...'; // the wallet's address
    const tokenAddress = '0x...'; // the ERC-20 contract to recover
    const destination = '0x...'; // where the balance should end up

    // Read the full balance so the entire position is recovered
    const publicClient = createPublicClient({chain: base, transport: http()});
    const balance = await publicClient.readContract({
      address: tokenAddress,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [walletAddress]
    });

    if (balance === 0n) throw new Error('Nothing to recover');

    const data = encodeFunctionData({
      abi: erc20Abi,
      functionName: 'transfer',
      args: [destination, balance]
    });

    const {hash} = await privy
      .wallets()
      .ethereum()
      .sendTransaction(walletId, {
        caip2: 'eip155:8453',
        params: {transaction: {to: tokenAddress, data, chain_id: 8453}}
      });

    console.log('Recovery transaction:', hash);
    ```

    If Privy does not broadcast to the chain, sign the transaction and submit it independently. Use
    [`eth_signTransaction`](/api-reference/wallets/ethereum/eth-sign-transaction), which returns an
    RLP-encoded signed transaction ready for `eth_sendRawTransaction`. It takes an explicit `chain_id` and no
    `caip2`, so it works on EVM chains Privy does not broadcast to:

    <View title="Typescript" icon="terminal">
      ```typescript {skip-check} theme={"system"}
      const {signed_transaction} = await privy
        .wallets()
        .ethereum()
        .signTransaction(walletId, {
          params: {
            transaction: {
              to: tokenAddress,
              data,
              chain_id: 8453,
              nonce: 0,
              gas_limit: '0x...',
              max_fee_per_gas: '0x...',
              max_priority_fee_per_gas: '0x...',
              type: 2
            }
          }
        });

      // Broadcast through any RPC provider for the chain
      const hash = await publicClient.request({
        method: 'eth_sendRawTransaction',
        params: [signed_transaction as `0x${string}`]
      });
      ```
    </View>

    Signing this way means populating `nonce`, `gas_limit`, and the fee fields, since Privy is not
    estimating them.
  </Tab>

  <Tab title="SPL on Solana">
    Build the transfer instruction between the sender's and recipient's associated token accounts, then hand
    the serialized transaction to Privy. Privy fills in the recent blockhash when the transaction carries the
    dummy value `11111111111111111111111111111111`.

    ```typescript {skip-check} theme={"system"}
    import {PrivyClient} from '@privy-io/node';
    import {Connection, PublicKey, Transaction} from '@solana/web3.js';
    import {
      getAssociatedTokenAddress,
      getAccount,
      createTransferInstruction,
      createAssociatedTokenAccountInstruction
    } from '@solana/spl-token';

    const privy = new PrivyClient({
      appId: process.env.PRIVY_APP_ID!,
      appSecret: process.env.PRIVY_APP_SECRET!
    });

    const walletId = 'insert-wallet-id';
    const connection = new Connection('insert-your-rpc-url', 'confirmed');

    const from = new PublicKey('...'); // the wallet's address
    const to = new PublicKey('...'); // the destination address
    const mint = new PublicKey('...'); // the SPL mint to recover

    const fromTokenAccount = await getAssociatedTokenAddress(mint, from);
    const toTokenAccount = await getAssociatedTokenAddress(mint, to);

    // Read the full balance so the entire position is recovered
    const {amount} = await getAccount(connection, fromTokenAccount);
    if (amount === 0n) throw new Error('Nothing to recover');

    const transaction = new Transaction();

    // The destination needs a token account for this mint before it can receive the transfer
    if (!(await connection.getAccountInfo(toTokenAccount))) {
      transaction.add(createAssociatedTokenAccountInstruction(from, toTokenAccount, to, mint));
    }

    transaction.add(createTransferInstruction(fromTokenAccount, toTokenAccount, from, amount));

    transaction.feePayer = from;
    // Privy populates the real blockhash when given this dummy value
    transaction.recentBlockhash = '11111111111111111111111111111111';

    const {hash} = await privy
      .wallets()
      .solana()
      .signAndSendTransaction(walletId, {
        caip2: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
        transaction: transaction
          .serialize({requireAllSignatures: false, verifySignatures: false})
          .toString('base64')
      });

    console.log('Recovery transaction:', hash);
    ```

    Creating the destination's associated token account costs rent, paid by the wallet being recovered, so
    the wallet needs a small SOL balance beyond the transaction fee. For Token-2022 mints, pass the
    Token-2022 program ID to each of the `@solana/spl-token` helpers.
  </Tab>
</Tabs>

<Note>
  Recovering a token on a chain type other than EVM or Solana, such as a TRC-20 balance on Tron,
  uses that chain's own signing RPC rather than the methods above. Refer to the [Tier 2 chains
  recipe](/recipes/use-tier-2) for per-chain examples, and adapt the transaction being built from a
  native transfer to the chain's token transfer.
</Note>

## Verify the recovery

Confirm the transaction onchain, then re-check the wallet's balances in the dashboard. A recovered wallet
shows a zero balance for the asset, plus whatever native currency was left behind for gas. Repeat per
asset and per network, since each transfer moves one asset on one chain.

## Related resources

<CardGroup cols={2}>
  <Card title="Transfer action API" icon="code" href="/wallets/actions/transfer/overview" arrow>
    Supported assets and chains, custom assets, and native bridging.
  </Card>

  <Card title="Propose intents" icon="user-check" href="/controls/dashboard/intents" arrow>
    Recovering from a wallet owned by a key quorum of team members.
  </Card>

  <Card title="Chain support" icon="link" href="/wallets/overview/chains" arrow>
    Which chains Privy signs for, and which it also broadcasts for.
  </Card>

  <Card title="Sending ERC-20s" icon="ethereum" href="/recipes/send-usdc" arrow>
    Encoding an ERC-20 transfer across Privy's SDKs.
  </Card>

  <Card title="Sending SPL tokens" icon="coins" href="/recipes/solana/send-spl-tokens" arrow>
    Building SPL transfers and handling associated token accounts.
  </Card>

  <Card title="Using Tier 2 chains" icon="key" href="/recipes/use-tier-2" arrow>
    Signing and broadcasting examples for chain types beyond EVM and Solana.
  </Card>
</CardGroup>
