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

> ## Agent Instructions
> To integrate AGG, start with Quickstart: REST (https://docs.agg.market/quickstart/rest), then Order lifecycle & statuses (https://docs.agg.market/concepts/order-lifecycle).
> Track every trade until it reaches a terminal status. Before retrying a failed or timed-out call, read Errors, retries & idempotency (https://docs.agg.market/concepts/errors).
> The API reference is generated from https://docs.agg.market/openapi/openapi.json.

# Self-Custody Withdrawals

> Withdraw from a user's own linked EVM wallet: Max, preview, what the wallet signs per source, fees, refusals, and the React hooks and modal

> [!NOTE]
>
> **You'll need:** a signed-in user ([Authentication & sessions](https://docs.agg.market/concepts/authentication.md)) with an
> EVM wallet [linked](https://docs.agg.market/recipes/account-linking.md) to the account, and client code that can ask that
> wallet to sign. `@agg-build/sdk`, `@agg-build/hooks` and `@agg-build/ui` 4.33.0 or later.
> **Result:** funds moved out of the user's own wallet, signed by that wallet, polled to a final
> state.

A managed withdrawal moves funds out of the user's AGG balance ([Funding & withdrawals](https://docs.agg.market/concepts/funding.md)).
A self-custody withdrawal moves funds out of a wallet the user linked to the account. AGG never holds
that wallet's key: the user signs every step, and AGG prices the route and submits what was signed.
The user never needs a gas token.

It uses the same endpoints as a managed withdrawal, plus
`POST /execution/withdrawals/{withdrawalId}/signatures` for the wallet's signatures. Sending
`signingAddress` is the only opt-in.

## Before you start

* `signingAddress` must be an **EVM** wallet already linked to the signed-in account. A Solana wallet
  cannot be the signer, though a Solana address can be the recipient.
* The feature can be off on an environment. A Max quote, preview or withdrawal with `signingAddress`
  then returns `400` `Self-custody withdrawals are not enabled`.
* Send the same `signingAddress` and the same `approveMode` (see [Approve mode](#approve-mode)) on
  the Max quote, the preview and the withdrawal. All three pick sources by the same rules, so they
  agree only when they are given the same inputs.
* A user has one withdrawal in progress at a time, managed or self-custody. A second one returns
  `409` until the first is final.

### What counts as the wallet's balance

| Source | Withdrawable |
| - | - |
| The wallet's own balance on Ethereum, Polygon, Arbitrum, Base and BNB Chain | Yes (USDC, USDC.e, USDT and pUSD; see the [matrix](#sources-and-destinations)) |
| The wallet's HyperCore spot balance | Yes. Only the part free to transfer counts. |
| The Polymarket deposit wallet of that wallet | Yes. pUSD and USDC.e have their own routes (see the [matrix](#sources-and-destinations)). Other tokens it holds are first moved into the wallet, then follow the wallet's rows. |
| pUSD in a legacy Polymarket Safe pinned to that wallet ([Accounts and wallets](https://docs.agg.market/concepts/accounts-and-wallets.md)) | Yes |
| Wallets named in `fundingAddresses` on a trade | No |
| A legacy Polymarket proxy wallet, or any token other than pUSD in a legacy Safe | No |
| Venue accounts and Predict balances | No |
| A linked Solana wallet | No |

## The flow

**1. Quote Max for the destination**

`POST /execution/withdrawable/quote` with `signingAddress`. `maxDeliverableRaw` is the largest
`amountRaw` the withdrawal accepts for that token and destination chain.

**2. Preview the exact amount**

`POST /execution/withdraw/preview` with the full withdrawal body. It prices the route from the
wallet and returns `unviable`, with the reason, for anything the withdrawal would refuse.
Optional, but it is the only call that checks recipient rules before you submit.

**3. Withdraw**

`POST /execution/withdraw` with the same body. The response may already carry the first
`pendingSignatures[]`.

**4. Sign what the withdrawal asks for**

Sign each request with the wallet and submit the results to
`POST /execution/withdrawals/{withdrawalId}/signatures`. Keep polling
`GET /execution/withdrawals/{withdrawalId}`: it returns any further `pendingSignatures[]`, and
later steps can ask for more after earlier ones finish.

**5. Poll to a final state**

Stop when `status` is `completed`, `partial` or `failed`.

### 1. Quote Max

```ts SDK theme={null}
const max = await client.getWithdrawalQuote({
  tokenSymbol: "USDC",
  destinationChainId: 8453,
  signingAddress: "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
  approveMode: "user_broadcast",
});
// { tokenSymbol: "USDC", destinationChainId: 8453,
//   maxDeliverableRaw: "41250000", rawBalanceRaw: "41730000", decimals: 6 }
```

```bash cURL theme={null}
curl -X POST https://api.agg.market/execution/withdrawable/quote \
  -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "tokenSymbol": "USDC",
    "destinationChainId": 8453,
    "signingAddress": "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
    "approveMode": "user_broadcast"
  }'
```

* `maxDeliverableRaw`: the largest `amountRaw` a withdrawal from this wallet accepts. It is an amount
  **debited**, not an amount delivered (see [Amounts](#amounts)). It leaves out balances too small to
  move and sources the chosen `approveMode` cannot spend.
* `rawBalanceRaw`: everything the wallet, its deposit wallet and its legacy Safe hold in the
  accepted tokens, before those exclusions. It can be larger than `maxDeliverableRaw`.

The API reference describes `amountRaw` and `maxDeliverableRaw` for a managed withdrawal. With
`signingAddress`, both are amounts debited, as above.

* Both are in the destination token's decimals (6, or 18 on BNB Chain).

The quote does not know the recipient, so it cannot apply recipient rules such as
[sending to the wallet itself](#sending-to-the-signing-wallet-itself). The preview and the withdrawal
do.

[Quote withdrawable amount](https://docs.agg.market/api-reference/funding/quote-withdrawable-amount.md).

### 2. Preview

```ts SDK theme={null}
const preview = await client.withdrawPreview({
  amountRaw: "41250000",
  tokenSymbol: "USDC",
  destinationAddress: "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52",
  destinationChainId: 8453,
  signingAddress: "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
  approveMode: "user_broadcast",
});
if (preview.pricingStatus === "unviable") showError(preview.unviableReason);
```

```bash cURL theme={null}
curl -X POST https://api.agg.market/execution/withdraw/preview \
  -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "amountRaw": "41250000",
    "tokenSymbol": "USDC",
    "destinationAddress": "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52",
    "destinationChainId": 8453,
    "signingAddress": "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
    "approveMode": "user_broadcast"
  }'
```

A quoted preview returns `receiveAmountRaw` (the estimated amount delivered), `feeRaw`, and
`quoteExpiresAt` (60 seconds out). An unviable one returns `pricingStatus: "unviable"` and
`unviableReason`; when no combination of sources covers the amount it also sets `maxDeliverableRaw`. The
preview creates nothing. See [Errors and refusals](#errors-and-refusals) for the reasons.

[Preview a withdrawal](https://docs.agg.market/api-reference/funding/preview-a-withdrawal.md).

### 3. Withdraw

```ts SDK theme={null}
const withdrawal = await client.withdrawManaged({
  requestId: crypto.randomUUID(),
  amountRaw: "41250000",
  tokenSymbol: "USDC",
  destinationAddress: "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52",
  destinationChainId: 8453,
  signingAddress: "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
  approveMode: "user_broadcast",
});
```

```bash cURL theme={null}
curl -X POST https://api.agg.market/execution/withdraw \
  -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "requestId": "8a3f5c2e-1b7d-4e9a-9c6f-2d4b8e1a7c30",
    "amountRaw": "41250000",
    "tokenSymbol": "USDC",
    "destinationAddress": "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52",
    "destinationChainId": 8453,
    "signingAddress": "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
    "approveMode": "user_broadcast"
  }'
```

The method is named `withdrawManaged` for history; with `signingAddress` it withdraws from the
wallet. `max: true` is refused with `signingAddress`: send the exact amount, for example the
`maxDeliverableRaw` from step 1. `requestId` works as on a [managed withdrawal](https://docs.agg.market/concepts/funding.md).

A shortened response:

```json theme={null}
{
  "withdrawalId": "cmh8w2k1f0003qk0l7v9d4x2a",
  "status": "pending",
  "requested": { "tokenSymbol": "USDC", "amountRaw": "41250000" },
  "destination": {
    "chainId": 8453,
    "address": "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52",
    "tokenSymbol": "USDC"
  },
  "legs": [
    {
      "type": "bridge",
      "status": "planned",
      "sourceChainId": 42161,
      "destChainId": 8453,
      "amountRaw": "41250000",
      "txHash": null,
      "bridgeOperationId": null
    }
  ],
  "expected": { "outputRaw": "41210000", "feeRaw": "40000", "etaSeconds": null },
  "pricingStatus": "quoted",
  "completedAmountRaw": null,
  "errorMessage": null,
  "sources": [
    {
      "sourceId": "…",
      "chainId": 42161,
      "tokenSymbol": "USDC",
      "tokenAddress": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
      "decimals": 6,
      "amountRaw": "41250000",
      "status": "pending",
      "txHash": null,
      "bridgeOperationId": null
    }
  ],
  "pendingSignatures": [
    {
      "stepId": "…",
      "type": "eip712",
      "venue": "relay",
      "signerAddress": "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
      "payload": { "domain": {}, "types": {}, "primaryType": "…", "message": {} },
      "expiresAt": "2026-11-02T14:03:10.000Z"
    }
  ]
}
```

* `expected` is this withdrawal's quote: `outputRaw` is the estimated delivery and `feeRaw` the
  estimated fee taken from the amount. The status endpoint returns the actual fee once the
  withdrawal is final.
* `pendingSignatures` is present only when the wallet already has something to sign. Its absence
  does not mean "nothing to sign": poll the status endpoint.
* `legs[].type` is `transfer` for same chain and same token, and `bridge` for another chain or
  another token on the same chain.

[Withdraw funds](https://docs.agg.market/api-reference/funding/withdraw-funds.md).

### 4. Sign and poll

Each request has the same shape as a trade's, so the signer you wrote for fills works unchanged. See
[A complete signer](https://docs.agg.market/recipes/self-custody-signing.md#a-complete-signer).

```ts SDK theme={null}
const terminal = new Set(["completed", "partial", "failed"]);
const answered = new Set<string>();
let w = withdrawal;

while (!terminal.has(w.status)) {
  const fresh = (w.pendingSignatures ?? []).filter((req) => !answered.has(req.stepId));
  if (fresh.length > 0) {
    const signatures = [];
    for (const req of fresh) {
      const result = await mySigner(req); // hex signature, or { txHash }
      signatures.push(
        typeof result === "string"
          ? { stepId: req.stepId, signature: result }
          : { stepId: req.stepId, txHash: result.txHash },
      );
      answered.add(req.stepId);
    }
    w = await client.submitWithdrawalSignatures(w.withdrawalId, signatures);
    continue;
  }
  await new Promise((r) => setTimeout(r, 3000));
  w = await client.getWithdrawalStatus(w.withdrawalId);
}
```

```bash cURL theme={null}
curl -X POST https://api.agg.market/execution/withdrawals/$WITHDRAWAL_ID/signatures \
  -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  -d '{"signatures":[{"stepId":"'"$STEP_ID"'","signature":"'"$SIGNATURE"'"}]}'

curl https://api.agg.market/execution/withdrawals/$WITHDRAWAL_ID \
  -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN"
```

The signatures endpoint takes the same body as fill signatures (up to 32 entries; `signature`, or
`txHash` for a `transaction`) and returns the withdrawal with whatever is still outstanding in
`pendingSignatures`. An unknown withdrawal, or one that is not this user's, returns `404`
`Withdrawal not found`. Per-step errors (expired, malformed, wrong signer) are the same as for
fills: see [Driving the loop yourself](https://docs.agg.market/recipes/self-custody-signing.md#driving-the-loop-yourself).

Every request expires at its `expiresAt`. Read it; never hard-code a deadline.

[Submit signatures for a pending self-custody withdrawal](https://docs.agg.market/api-reference/funding/submit-signatures-for-a-pending-self-custody-withdrawal.md)
· [Get withdrawal status](https://docs.agg.market/api-reference/funding/get-withdrawal-status.md).

### Statuses

| `status` | Terminal | Meaning |
| - | - | - |
| `pending` | No | Accepted. The submit response returns this (a replayed `requestId` returns the current status). |
| `bridging` | No | Moving funds across chains. |
| `transferring` | No | Same-chain transfer in progress. |
| `completed` | Yes | Every source delivered. `completedAmountRaw` is the estimated total delivered. |
| `partial` | Yes | Some sources delivered and some did not. `completedAmountRaw` is what arrived; `errorMessage` says why the rest failed. |
| `failed` | Yes | No source delivered. See `errorMessage`. |

A withdrawal can draw from several sources, for example the wallet's Arbitrum USDC and its HyperCore
balance. Each source moves on its own signatures. That is why `partial` exists: one source can
deliver while another expires or fails. `sources[]` and `legs[]` show each one's status and
`txHash`.

## Amounts

`amountRaw` is the amount **debited** from the wallet. The recipient gets `amountRaw` minus fees.
This is the opposite of a managed withdrawal, where `amountRaw` is the amount to deliver.

* Integer string in the token's decimals: 6, or 18 on BNB Chain. `41250000` is 41.25 USDC.
* The minimum is 1 whole token after any same-chain network fee.
* Supported tokens are `USDC`, `USDC.e` and `USDT`, on the chains where each exists.

## Approve mode

`approveMode` declares what the wallet can sign. It follows the same rule as fills, with one
difference: a withdrawal never asks the wallet to send a transaction or hold gas, in either mode.

| `approveMode` | For | Effect on a withdrawal |
| - | - | - |
| `"sponsored"` (default when omitted) | Wallets that can sign an EIP-7702 authorization | Every source is available. |
| `"user_broadcast"` | Wallets that cannot, such as MetaMask and other browser wallets connected through wagmi | Sources that would need a token approval or a batch are left out of Max, preview and selection. The user sees less, never a transaction prompt. |

In React, pick the mode with the same helper as fills:

```ts theme={null}
import { selfCustodyApproveMode, useAggWalletSigner } from "@agg-build/hooks";

const walletSigner = useAggWalletSigner();
const approveMode = selfCustodyApproveMode(walletSigner, [signingAddress]);
```

It returns `"sponsored"` only when your signer's `canSignEip7702Authorization(address)` returns
`true`. `useWagmiSolanaWalletSigner` from `@agg-build/hooks/self-custody` does not declare it, so a
wallet connected through it gets `"user_broadcast"`. `useWithdrawFlow` and `WithdrawModal` do this
for you.

## Sources and destinations

✅ available, ❌ not available. "Other token" means a different token on the same chain, for example
Polygon USDT to Polygon USDC.

| Source | Destination | `sponsored` | `user_broadcast` |
| - | - | - | - |
| Wallet, native USDC (Ethereum, Polygon, Arbitrum, Base) | Same chain, same token | ✅ | ✅ |
| | Same chain, other token | ✅ | ❌ The swap needs an approval. |
| | Other chain, or HyperCore | ✅ | ✅ |
| Wallet, USDC.e, USDT, or BNB Chain USDC (pUSD: other token or chain only) | Same chain, same token | ✅ | ❌ The token needs an approval or a batch. |
| | Same chain, other token | ✅ | ❌ Same reason. |
| | Other chain, or HyperCore | ✅ | ❌ Same reason. |
| HyperCore spot balance | HyperCore, same token, another address | ✅ | ✅ |
| | HyperCore, other token | ❌ Not offered. | ❌ |
| | Another chain | ✅ | ✅ |
| Deposit wallet pUSD | Polygon USDC or USDT | ✅ | ✅ where enabled |
| | Polygon USDC.e | ❌ Not offered. | ❌ |
| | Other chain, or HyperCore | ✅ | ✅ where enabled |
| Deposit wallet USDC.e | Polygon USDC.e | ✅ | ❌ The token needs a batch. |
| | Polygon USDC or USDT | ✅ | ✅ where enabled |
| | Other chain, or HyperCore | ✅ | ✅ where enabled |
| Legacy Safe pUSD | Any Polygon token | ✅ | ✅ |
| | Other chain, or HyperCore | ✅ | ✅ |

* A HyperCore destination is chain `1337`, with an EVM-format address and token `USDC`.
* A Solana destination (chain `792703809`, base58 address, `USDC`) is reached by a bridge from any
  source the mode allows.
* When a source is not available, Max, preview and the withdrawal use the wallet's other balances
  instead. They never pick a source the mode cannot sign.

## What the wallet signs

Read `type` on each request. The [signing reference](https://docs.agg.market/recipes/self-custody-signing.md#what-each-type-asks-for)
says how to answer each type.

| Route | `sponsored` | `user_broadcast` |
| - | - | - |
| Wallet, same chain and token | 1 `eip712` batch. Plus 1 `eip7702_authorization` while the wallet has none on that chain yet. | Native USDC only: 2 `eip712` `TransferWithAuthorization` (EIP-3009) signatures, one for the payout and one for the network fee. |
| Wallet, same-chain swap | 1 `eip712` batch, plus the first-time `eip7702_authorization`. | Not offered. |
| Wallet, to another chain | 1 bridge permit (`eip712`, or `personal_sign` when the permit is EIP-191). A token that needs an approval adds 1 `eip712` batch, plus the first-time `eip7702_authorization`. | Native USDC only: 1 bridge permit. |
| HyperCore to HyperCore | 1 `eip712` Hyperliquid `sendAsset` (domain `HyperliquidSignTransaction`) | Same |
| HyperCore to another chain | 2 `eip712`: a bridge authorization and a `sendAsset` | Same |
| Deposit wallet pUSD or USDC.e, to another chain or another Polygon token | 1 `eip712`, where enabled. Otherwise 1 `eip712` that moves the funds to the wallet, then the wallet's own row above. | 1 `eip712`, where enabled |
| Deposit wallet USDC.e to Polygon USDC.e | 1 `eip712` that moves the funds to the wallet. To the wallet itself, that is all. To another address, the wallet's same-chain row follows. | Not offered |
| Legacy Safe pUSD, any destination it allows | 1 `safe_tx` | Same |

Each source adds its own requests. Sources on the same chain run one after another, so the second
one's requests arrive only after the first finishes.

## Fees

Every fee comes out of the withdrawn amount. The user never needs a gas token, and your app is never
billed for it.

| Route | Fee |
| - | - |
| Same chain and token, from the wallet, or from the deposit wallet to another address | A network fee, taken from the amount |
| Another chain | The bridge fee, taken from the amount |
| Same-chain swap from the wallet | The swap fee |
| Deposit wallet or legacy Safe, to another Polygon token | The quoted swap fee only, with no separate network fee |
| Deposit wallet USDC.e to the wallet itself on Polygon | None |
| HyperCore to HyperCore | None, with one exception below |

Hyperliquid charges the sender 1 USDC the first time anything is sent to an address with no
HyperCore account, on top of the amount. The wallet has to hold that extra dollar. A Max withdrawal
to a new HyperCore address therefore fails at Hyperliquid and nothing moves. Leave at least 1 USDC
behind for a first send.

The preview's `feeRaw` and the withdrawal's `expected.feeRaw` are the estimates; the status endpoint
returns the actual fee once the withdrawal is final.

## Max and small balances

Max leaves out any source too small to move economically. Today the floors per source are about
$0.05 for a same-chain transfer ($0.10 on BNB Chain, $0.20 on Ethereum), $0.25 for a same-chain
swap, and $0.50 for a bridge ($3 into Ethereum, \$2.50 into Solana).

An amount below Max can still be refused when splitting it across sources would leave one part
under its floor. The withdrawal returns `400` `Insufficient balance for withdrawal`. The preview
returns `unviable` with `Requested amount exceeds withdrawable balance. Maximum deliverable is …`
and sets `maxDeliverableRaw`, even though the amount is under that maximum. Try a slightly
different amount, or Max.

## Errors and refusals

### Returned on the call

These come back from the quote, preview or withdrawal as `400` (or `403`, `409`) with `{ message }`. The
preview returns the reasons marked "also preview" as `pricingStatus: "unviable"` with
`unviableReason` instead. A withdrawal refused while pricing it (the fee and route rows below) is
recorded as `failed` and moves nothing.

| Message | Cause |
| - | - |
| `Self-custody withdrawals are not enabled` | The feature is off on this environment. |
| `max is not supported for self-custody withdrawals; send the exact amount` | `max: true` was sent with `signingAddress`. |
| `Self-custody withdrawals need an EVM signing wallet` | `signingAddress` is a linked Solana wallet. Most Solana addresses are refused earlier by request validation, with a `400` naming `signingAddress`. |
| `Cannot trade in self-custody: link a wallet to this account first` | The account has no linked wallet. |
| `Wallet 0x… is not linked to this account` | `signingAddress` is not linked to this account. |
| `Funds are already in this wallet` (also preview) | A same-chain, same-token withdrawal from the wallet (or HyperCore to HyperCore) back to the wallet itself. |
| `Deposit wallet is not deployed yet` (also preview) | The route draws on a deposit wallet that does not exist on chain yet. |
| `Requested withdrawal amount exceeds withdrawable balance. Maximum deliverable amount is …` | Over Max. |
| `Insufficient balance for withdrawal` | Nothing the mode can spend covers the amount, or splitting an amount under Max would leave a part under its floor. |
| Preview `unviable`: `Requested amount exceeds withdrawable balance. Maximum deliverable is ….` | No combination of sources covers the amount, whether it is over Max or under Max but cannot be split. Sets `maxDeliverableRaw`. When Max is `0`, the preview says `Insufficient balance for withdrawal` instead. |
| `Minimum withdrawal is 1 USDC`, or `… after fees` (also preview) | Under one whole token, or under it once the same-chain network fee is taken. |
| `network fee exceeds the amount for this source` (also preview) | A source is too small to pay its own network fee. |
| `Withdrawal source amount … on chain … is too small for Relay fees` (also preview) | A source is too small for the bridge fee. |
| `Not enough bridge liquidity …` or `No safe route is available …` (also preview) | No bridge route right now. Try again shortly. |
| `Self-custody withdrawals are unavailable`, `Same-chain self-custody withdrawals are unavailable` | Not available on this environment. |
| `Withdrawals to chain … are not supported`, `Token … is not supported for withdrawal on chain …`, `destinationAddress format does not match destinationChainId …` | Destination problems, as on managed withdrawals. |
| `409` `You already have a withdrawal in progress. Please wait for it to complete before starting another.` | Another withdrawal is not final yet. |
| `409` `Balance maintenance is finishing. Please retry the withdrawal shortly.` | A balance top-up for this user is still running. Retry shortly. |
| `409` `Transfer requestId already belongs to a different request` | A `requestId` was reused with a different body. |
| `403` `Trading is not available in your region` (`code: "geo_blocked"`) | The user's region is restricted, as on managed withdrawals. See [Errors](https://docs.agg.market/concepts/errors.md). |

### Reported on a `failed` or `partial` withdrawal

These come back in `errorMessage` on the status endpoint after the withdrawal started.

| Situation | What happens |
| - | - |
| The wallet is EIP-7702 delegated to another application on that chain | Refused before the wallet is asked to sign the batch. `errorMessage` contains `This wallet is already delegated to another application (0x…)`. AGG never replaces another app's delegation. The user removes it in that app, or withdraws with a wallet or source that needs no batch. |
| The user declines or ignores a request | The request expires at `expiresAt`. That source does not deliver, and the withdrawal ends `failed`, or `partial` if another source delivered. Funds that an earlier step already moved from the deposit wallet into the wallet itself stay there. |
| A deposit wallet or Safe swap does not settle | `errorMessage` names the wallet and the transaction, for example `The Safe transaction 0x… did not succeed.` |
| Only some sources delivered | `partial`, with `errorMessage` such as `Withdrawal partially delivered (1/2 sources confirmed)` when no more specific reason exists. |

Always show `errorMessage`. A declined prompt cannot be revived: start a new withdrawal.

## React

### Hooks

`useWithdrawFlow` drives the whole form (balances, Max, preview, submit). Pass `signingAddress` to
withdraw from that wallet. It then reads Max from the server quote, sends `signingAddress` and the
wallet's `approveMode`, and never sends `max`. `useWithdrawalSignatures` answers the requests until
the withdrawal is final. Both need an `AggWalletSignerProvider` above them.

```tsx theme={null}
import { useState } from "react";
import { useWithdrawFlow, useWithdrawalSignatures } from "@agg-build/hooks";

function WalletWithdraw({ signingAddress }: { signingAddress: string }) {
  const [open, setOpen] = useState(true);
  const flow = useWithdrawFlow({ open, onOpenChange: setOpen, signingAddress });
  const { withdrawFlow } = flow;

  // Sign with the wallet the withdrawal was submitted with, not a later selection.
  const signing = useWithdrawalSignatures({
    withdrawalId: withdrawFlow.withdrawalId,
    signingAddress: withdrawFlow.submittedSigningAddress ?? null,
    pendingSignatures: withdrawFlow.pendingSignatures,
  });

  return (
    <form onSubmit={(e) => { e.preventDefault(); void flow.onSelectWithdrawProvider("wallet"); }}>
      <p>Available: {withdrawFlow.withdrawable ?? "…"} {withdrawFlow.selectedToken}</p>
      <input value={withdrawFlow.amount} onChange={(e) => flow.onWithdrawAmountChange(e.target.value)} />
      <button type="button" onClick={flow.onMaxClick}>Max</button>
      <input
        value={withdrawFlow.destinationWallet}
        onChange={(e) => flow.onWithdrawDestinationChange(e.target.value)}
      />
      <button type="submit" disabled={withdrawFlow.availability?.loading}>Withdraw</button>
      {signing.awaiting && <p>Confirm in your wallet ({signing.awaiting.type})</p>}
      {signing.needsConnect && <p>Connect {signing.needsConnect} to continue</p>}
      {signing.error && <p>{signing.error.message}</p>}
    </form>
  );
}
```

`withdrawFlow.withdrawable` is the wallet's Max for the chosen token and network, in token units.
`withdrawFlow.availability` reports whether that quote is loading or failed. `useWithdrawalSignatures`
stops on a declined request, which it leaves to expire, and on a `4xx` it cannot retry, both shown as
`error`.

### Prebuilt modal

`WithdrawModal` from `@agg-build/ui/modals` adds a "Withdraw from" selector when all of these hold:

* `features.enableSelfCustodyWithdraw: true` (off by default),
* `features.tradingCustody` is `"both"` (see [Prebuilt trading UI](https://docs.agg.market/recipes/self-custody.md#prebuilt-trading-ui)),
* an `AggWalletSignerProvider` with a signer is above it,
* the user has a linked EVM wallet, and the app is not in paper mode.

```tsx theme={null}
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider, type Config } from "wagmi";
import { WalletProvider } from "@solana/wallet-adapter-react";
import type { AggClient } from "@agg-build/sdk";
import { AggProvider, AggWalletSignerProvider } from "@agg-build/hooks";
import { useWagmiSolanaWalletSigner } from "@agg-build/hooks/self-custody";
import { WithdrawModal } from "@agg-build/ui/modals";

const queryClient = new QueryClient();

// The signer hook reads the wagmi and Solana wallet contexts, so it runs inside both providers.
function AggWithSigner({ client, children }: { client: AggClient; children: ReactNode }) {
  const signer = useWagmiSolanaWalletSigner();
  return (
    <AggProvider
      client={client}
      config={{ features: { tradingCustody: "both", enableSelfCustodyWithdraw: true } }}
    >
      <AggWalletSignerProvider value={signer}>{children}</AggWalletSignerProvider>
    </AggProvider>
  );
}

export function Providers({
  client,
  wagmiConfig,
  children,
}: {
  client: AggClient;
  wagmiConfig: Config;
  children: ReactNode;
}) {
  return (
    <WagmiProvider config={wagmiConfig}>
      <QueryClientProvider client={queryClient}>
        <WalletProvider wallets={[]} autoConnect>
          <AggWithSigner client={client}>{children}</AggWithSigner>
        </WalletProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

// Anywhere below Providers:
<WithdrawModal open={open} onOpenChange={setOpen} />;
```

`useWagmiSolanaWalletSigner` reads the wagmi and Solana wallet-adapter contexts, so it must render
inside `WagmiProvider` (with its `QueryClientProvider`) and the Solana `WalletProvider`. Any other
`AggWalletSigner` works in its place.

The selector lists the AGG balance and each linked EVM wallet. The AGG balance stays selected by
default, so the modal behaves as before until the user picks a wallet. The selected wallet's row
shows what it can withdraw to the chosen token and network, the same server Max the Max button fills
in. The modal then signs through the provider's signer and shows each prompt until the withdrawal
is final. With the flag off, or any condition missing, the modal is unchanged.

## FAQ

### Why does Max change when I change the destination?

Max is per token and destination chain. The floors depend on the route (a bridge into Ethereum
needs more than a same-chain transfer), a same-chain withdrawal of a different token is a swap with
its own floor, and some sources only reach some destinations, for example deposit-wallet pUSD never
goes to Polygon USDC.e. Quote again whenever the token, chain or wallet changes.

### Why does a MetaMask user see less than a wallet that can sign batches?

MetaMask cannot sign an EIP-7702 authorization, so it withdraws with `approveMode: "user_broadcast"`.
That mode never asks for a transaction, so anything that would need a token approval or a batch is
left out: the wallet's USDC.e, USDT and pUSD, and any same-chain swap. Native USDC, HyperCore, the
legacy Safe, and (where enabled) the deposit wallet remain. `rawBalanceRaw` still counts everything,
which is why it can be larger than `maxDeliverableRaw`.

### Sending to the signing wallet itself

A same-chain, same-token withdrawal from the wallet's own balance back to the same wallet is refused
with `Funds are already in this wallet`, and so is HyperCore to HyperCore to itself. Moving funds
to the wallet itself from its deposit wallet or legacy Safe is allowed, and so is sending to the
wallet's own address on another chain. Because the Max quote does not know the recipient, this is
caught by the preview and the withdrawal, not by the quote.

### What happens if the user declines a prompt?

Nothing is retried. The request expires, that source fails, and the withdrawal ends `failed` or
`partial`. Funds already moved from the deposit wallet into the wallet itself stay in the wallet. Start a new withdrawal to try again.

### Can the deposit wallet pay out Polygon USDC.e?

Its USDC.e can, with `approveMode: "sponsored"` (or omitted). Its pUSD cannot, in either mode:
withdraw it to Polygon USDC or USDT, or to another chain, instead. Under `"user_broadcast"` neither
of the deposit wallet's tokens is a source for Polygon USDC.e, so Max, preview and the withdrawal
use the wallet's other balances.

## Related

> [card] [Self-custody trading](https://docs.agg.market/recipes/self-custody.md)
>
> Quote and fill from the user's own wallet.

> [card] [Self-custody signing reference](https://docs.agg.market/recipes/self-custody-signing.md)
>
> Request types, a complete signer, and the per-step errors.

> [card] [Funding & withdrawals](https://docs.agg.market/concepts/funding.md)
>
> Managed withdrawals from the AGG balance.

> [card] [Submit withdrawal signatures](https://docs.agg.market/api-reference/funding/submit-signatures-for-a-pending-self-custody-withdrawal.md)
>
> The endpoint behind the signing loop.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.