Trading

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

You'll need: a signed-in user (Authentication & sessions) with an EVM wallet linked 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 sufwan-local-test balance (Funding & withdrawals). A self-custody withdrawal moves funds out of a wallet the user linked to the account. sufwan-local-test never holds that wallet's key: the user signs every step, and sufwan-local-test 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) 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

SourceWithdrawable
The wallet's own balance on Ethereum, Polygon, Arbitrum, Base and BNB ChainYes (USDC, USDC.e, USDT and pUSD; see the matrix)
The wallet's HyperCore spot balanceYes. Only the part free to transfer counts.
The Polymarket deposit wallet of that walletYes. pUSD and USDC.e have their own routes (see the matrix). 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)Yes
pUSD in a legacy Polymarket proxy wallet pinned to that wallet and owned by itYes
Wallets named in fundingAddresses on a tradeNo
Any token other than pUSD in a legacy Safe or proxy wallet, or a proxy wallet owned by another key (for example the key behind a Polymarket email login)No
Venue accounts and Predict balancesNo
A linked Solana walletNo

Legacy Polymarket wallets: one per linked wallet, and only one the user owns.

  • sufwan-local-test reads one legacy wallet per linked wallet: the Safe or proxy wallet Polymarket reports for that address. If funds sit in a second legacy wallet (for example tokens someone sent to the other address), that balance is not counted and cannot be withdrawn here.
  • A legacy proxy wallet is a source only when the signing wallet owns it. Polymarket accounts created with an email login use a proxy owned by the email login's key, not by any wallet the user links. That balance is not shown as withdrawable; the user withdraws it on Polymarket.
  • Only pUSD in a legacy Safe or proxy wallet counts. USDC or USDC.e sent to either address is not offered as a source.
  • A proxy wallet's prompt is a personal_sign over a 32-byte hash, so the wallet (MetaMask included) shows a hex string, not readable fields. That is expected; tell users in your UI what they are approving.

The flow

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.

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.

Withdraw

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

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.

Poll to a final state

Stop when status is completed, partial or failed.

1. Quote Max

SDK
const max = await client.getWithdrawalQuote({
  tokenSymbol: "USDC",
  destinationChainId: 8453,
  signingAddress: "0x8d2c1f4a7b3e9d6c0a5f2e8b1d4c7a9e3f6b0d25",
  approveMode: "user_broadcast",
});
// { tokenSymbol: "USDC", destinationChainId: 8453,
//   maxDeliverableRaw: "41250000", rawBalanceRaw: "41730000", decimals: 6 }
  • maxDeliverableRaw: the largest amountRaw a withdrawal from this wallet accepts. It is an amount debited, not an amount delivered (see 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 or proxy wallet 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. The preview and the withdrawal do.

Quote withdrawable amount.

2. Preview

SDK
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);

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 for the reasons.

Preview a withdrawal.

3. Withdraw

SDK
const withdrawal = await client.withdrawManaged({
  requestId: crypto.randomUUID(),
  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.

A shortened response:

{
  "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.

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.

SDK
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);
}

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.

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

Submit signatures for a pending self-custody withdrawal · Get withdrawal status.

Statuses

statusTerminalMeaning
pendingNoAccepted. The submit response returns this (a replayed requestId returns the current status).
bridgingNoMoving funds across chains.
transferringNoSame-chain transfer in progress.
completedYesEvery source delivered. completedAmountRaw is the estimated total delivered.
partialYesSome sources delivered and some did not. completedAmountRaw is what arrived; errorMessage says why the rest failed.
failedYesNo 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.

approveModeForEffect on a withdrawal
"sponsored" (default when omitted)Wallets that can sign an EIP-7702 authorizationEvery source is available.
"user_broadcast"Wallets that cannot, such as MetaMask and other browser wallets connected through wagmiSources 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:

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.

SourceDestinationsponsoreduser_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 balanceHyperCore, same token, another address✅✅
HyperCore, other token❌ Not offered.❌
Another chain✅✅
Deposit wallet pUSDPolygon USDC or USDT✅✅ where enabled
Polygon USDC.e❌ Not offered.❌
Other chain, or HyperCore✅✅ where enabled
Deposit wallet USDC.ePolygon USDC.e✅❌ The token needs a batch.
Polygon USDC or USDT✅✅ where enabled
Other chain, or HyperCore✅✅ where enabled
Legacy Safe or proxy wallet pUSDAny Polygon token✅✅
Other chain, or HyperCore✅✅
  • A HyperCore destination is chain 1337, with an EVM-format address and token USDC.
  • A HyperEVM destination is chain 999, with token USDC or USDT (paid out as USD₮0). It is reached by a bridge from any source the mode allows, and is never a source or a deposit chain.
  • 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 says how to answer each type.

Routesponsoreduser_broadcast
Wallet, same chain and token1 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 swap1 eip712 batch, plus the first-time eip7702_authorization.Not offered.
Wallet, to another chain1 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 HyperCore1 eip712 Hyperliquid sendAsset (domain HyperliquidSignTransaction)Same
HyperCore to another chain2 eip712: a bridge authorization and a sendAssetSame
Deposit wallet pUSD or USDC.e, to another chain or another Polygon token1 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.e1 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 allows1 safe_txSame
Legacy proxy wallet pUSD, any destination it allows1 personal_sign whose payload is { raw }, a 32-byte relay hashSame

A legacy Safe or proxy wallet never needs a gas token: Polymarket's relayer submits the signed request. Answer the proxy wallet's request like any other personal_sign with a { raw } payload (see the signing reference).

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.

RouteFee
Same chain and token, from the wallet, or from the deposit wallet to another addressA network fee, taken from the amount
Another chainThe bridge fee, taken from the amount
Same-chain swap from the walletThe swap fee
Deposit wallet, legacy Safe or proxy wallet, to another Polygon tokenThe quoted swap fee only, with no separate network fee
Deposit wallet USDC.e to the wallet itself on PolygonNone
HyperCore to HyperCoreNone, 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.

MessageCause
Self-custody withdrawals are not enabledThe feature is off on this environment.
max is not supported for self-custody withdrawals; send the exact amountmax: true was sent with signingAddress.
Self-custody withdrawals need an EVM signing walletsigningAddress 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 firstThe account has no linked wallet.
Wallet 0x… is not linked to this accountsigningAddress 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 withdrawalNothing 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 unavailableNot 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 requestA 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.

Reported on a failed or partial withdrawal

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

SituationWhat happens
The wallet is EIP-7702 delegated to another application on that chainRefused before the wallet is asked to sign the batch. errorMessage contains This wallet is already delegated to another application (0x…). sufwan-local-test 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 requestThe 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, Safe or proxy wallet swap does not settleerrorMessage names the wallet and the transaction, for example The Safe transaction 0x… did not succeed. A proxy wallet whose calls reverted names the reason, for example RelayedCallFailed.
Only some sources deliveredpartial, 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.

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),
  • an AggWalletSignerProvider with a signer is above it,
  • the user has a linked EVM wallet, and the app is not in paper mode.
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 sufwan-local-test balance and each linked EVM wallet. The sufwan-local-test 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 or proxy wallet, 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, legacy Safe or proxy wallet 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.

Why isn't my user's Polymarket proxy wallet balance withdrawable?

One of these applies (see the warning under What counts as the wallet's balance):

  • The proxy wallet belongs to a Polymarket email login, so the linked wallet cannot sign for it. The user withdraws on Polymarket instead.
  • The balance is not pUSD.
  • sufwan-local-test reads a different legacy wallet (a Safe) for that address, so the proxy wallet is not read.

On this page