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
signingAddressmust 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
signingAddressthen returns400Self-custody withdrawals are not enabled. - Send the same
signingAddressand the sameapproveMode(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
409until 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) |
| 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). 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 it | Yes |
Wallets named in fundingAddresses on a trade | No |
| 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 balances | No |
| A linked Solana wallet | No |
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_signover 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
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 largestamountRawa 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 chosenapproveModecannot 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 thanmaxDeliverableRaw.
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.
2. Preview
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.
3. Withdraw
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"
}
]
}expectedis this withdrawal's quote:outputRawis the estimated delivery andfeeRawthe estimated fee taken from the amount. The status endpoint returns the actual fee once the withdrawal is final.pendingSignaturesis present only when the wallet already has something to sign. Its absence does not mean "nothing to sign": poll the status endpoint.legs[].typeistransferfor same chain and same token, andbridgefor another chain or another token on the same chain.
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.
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
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.
41250000is 41.25 USDC. - The minimum is 1 whole token after any same-chain network fee.
- Supported tokens are
USDC,USDC.eandUSDT, 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:
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 or proxy wallet pUSD | Any Polygon token | ✅ | ✅ |
| Other chain, or HyperCore | ✅ | ✅ |
- A HyperCore destination is chain
1337, with an EVM-format address and tokenUSDC. - A HyperEVM destination is chain
999, with tokenUSDCorUSDT(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.
| 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 |
| Legacy proxy wallet pUSD, any destination it allows | 1 personal_sign whose payload is { raw }, a 32-byte relay hash | Same |
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.
| 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, legacy Safe or proxy wallet, 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. |
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…). 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 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, Safe or proxy wallet swap does not settle | errorMessage 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 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.
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.tradingCustodyis"both"(see Prebuilt trading UI),- an
AggWalletSignerProviderwith 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.