Trading

Self-Custody Trading

Let users fund and sign trades with their own wallet instead of a managed balance

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. Result: a fill signed by the user's wallet, polled to a terminal state.

Where this fits: Choose your integration. Kalshi uses the user's own key from Venue keys. What to sign is on Self-custody signing.

In a managed trade sufwan-local-test signs the venue order and funds it from the user's managed balance. In a self-custody trade the user's own wallet is both the funding source and the signer. sufwan-local-test prices the route against that wallet's on-chain balances, then asks it to sign each step. sufwan-local-test never holds the key.

It uses the same endpoints as a managed trade: GET /orderbook/{venueMarketOutcomeId}/route, then POST /execution/fill. signingAddress is the only opt-in. There is no app flag or header.

The flow

Quote with signingAddress

Prices the route against that wallet's balances. Read custodyWarnings before the user commits: the quote still succeeds, but the fill will not. The one exception is self_custody_app_fee, which only says the partner fee will be waived; the trade still fills.

Fill with the same address

Post the quoteId and the same signingAddress. When the trade is ready to sign straight away, the response already carries the first pendingSignatures[]: sign those immediately, without polling first. Trades that need funding first return none.

Sign what the trade asks for

Sign each pendingSignatures payload and submit them to POST /execution/fill/{quoteId}/signatures. Then poll GET /execution/status?quoteId= for any further requests. Repeat until terminal is true. An empty list means "keep polling", not "done".

fillSelfCustody in @agg-build/sdk does steps 2 and 3 for you. Pass a signer to createAggClient, and it posts the fill, polls, calls your signer for each request, and submits the results. It returns once the fill is filled or partially_filled, and throws if the fill ends failed, cancelled, or expired, if a round of signing did not move it forward, or after five minutes. You can also drive the loop yourself.

The user has to stay present: every request expires, and an expired request cannot be revived. The trade has to be quoted again from the start. See Timeouts.

How to write the signer, what each request type asks for, and how many prompts to expect are in the Self-custody signing reference.

The signing wallet

signingAddress must match a wallet already linked to the signed-in account through POST /users/me/link-account/start and POST /users/me/link-account/confirm. Sign the message from the start response verbatim; the confirm step compares it byte for byte.

A Solana wallet cannot sign: the venues self-custody supports take an EVM signature. It can fund a trade, though. See Solana.

Two refusals appear here, both 400 with a message and no code:

MessageMeaning
Cannot trade in self-custody: link a wallet to this account firstThe account has no linked EVM wallet.
Wallet 0x… is not linked to this accountThe account has linked wallets, but not this one. The address is lowercased in the message.

Funding from more than one wallet

By default a trade is funded from signingAddress alone. fundingAddresses adds other wallets the same user has linked, so one trade can draw on several balances.

The signing wallet still signs the venue order. Each funding wallet signs only the transfers that move its own money, so signerAddress on a request varies within one fill. Route each request to the wallet it names.

  • Optional. Omitting it keeps the default.
  • Repeat the key on the quote query (?fundingAddresses=0x…&fundingAddresses=0x…); send an array in the fill body.
  • Every entry must be linked to the same account. An entry is an EVM address (0x + 40 hex) or a Solana public key (base58); see Solana.
  • Duplicates and signingAddress itself are removed, and order does not matter: sufwan-local-test decides which wallet pays what. The quote echoes the final set back as fundingAddresses.
  • On the venue's own chain only the signing wallet's balance is usable. A funding wallet's Hyperliquid balance cannot pay for a Hyperliquid trade, but it can move to other venues.
  • Self-custody limit buys accept the same field, checked when the order is placed.

If you send fundingAddresses on the fill it must match the quote's set, or the fill is refused with quote_unfillable. Leaving it off the fill skips that check.

Quote and fill

getSmartRoute() does not send signingAddress or fundingAddresses, so use client.request for the quote. Configure your authenticated client with the signer from the signing reference, then call fillSelfCustody to execute and answer the signing requests. The cURL flow requires the manual signing loop after the fill.

signingAddress on the fill must match the quoted wallet. Paper mode does not support it. Referral fields are on Fees. Omit approveMode, or send approveMode: "sponsored", unless you need the other approval.

SDK
import type { SmartRouteResponse } from "@agg-build/sdk";

type SelfCustodyQuote = SmartRouteResponse & {
  custodyWarnings?: { reason: string; message: string }[];
};

// client is authenticated and was created with { signer: mySigner }.
const quote = await client.request<SelfCustodyQuote>(`/orderbook/${encodeURIComponent(outcomeId)}/route`, {
  query: { signingAddress: wallet, side: "buy", maxSpend: "25" },
});

if (quote.status !== "ok") {
  throw new Error(quote.error ?? `Quote unavailable: ${quote.status}`);
}
// `self_custody_app_fee` is informational: the fee is waived and the trade still fills.
const blocking = (quote.custodyWarnings ?? []).filter((w) => w.reason !== "self_custody_app_fee");
if (blocking.length) {
  throw new Error(blocking.map((warning) => warning.message).join("; "));
}

// Show the quote, fees, and warnings and obtain the user's confirmation before filling.
await client.fillSelfCustody({
  quoteId: quote.quoteId,
  signingAddress: wallet,
});

Get a quote and Execute quote.

custodyWarnings is absent when there is nothing to report, and is only built on buy quotes. Never read its absence as "this will fill". A sell uses sellShares instead of maxSpend, not both.

Solana

A Solana wallet linked to the same account can fund a trade on an EVM venue. The EVM wallet still signs the order; the Solana wallet signs one transaction per bridge that moves its USDC across the Relay bridge. The wallet needs no SOL: sufwan-local-test's fee payer co-signs and pays the network fee, and the server broadcasts.

Link the Solana wallet

Same flow as an EVM wallet, with provider: "siws" (the app must have SIWS enabled as a sign-in provider). POST /users/me/link-account/start with { provider: "siws", address } returns a wallet_challenge; have the wallet sign the message verbatim (signMessage over its UTF-8 bytes, signature base58-encoded) and post { kind: "wallet_signature", message, signature } to POST /users/me/link-account/confirm. client.linkAccount / client.linkAccountConfirm in @agg-build/sdk wrap both calls.

Name it in fundingAddresses

Quote with signingAddress (the EVM wallet) and fundingAddresses=<base58 pubkey>; send the same array on the fill. The quote reads the wallet's Solana USDC balance and plans the bridge from there. A base58 signingAddress is rejected by the schema.

Handle solana_transaction in your signer

One request per bridge, addressed to the Solana wallet (signerAddress is base58).

payload carries:

{
  messageBase64: string;       // the serialized transaction MESSAGE; this is what gets signed
  transactionBase64: string;   // the full unsigned VersionedTransaction, for wallets that
                               // need one to display or simulate
  chainId: 792703809;
  blockhash: string;
  lastValidBlockHeight: number;
  summary: string[];           // one line per instruction group, for your UI
}

Return 0x + 128 hex chars: the 64-byte ed25519 signature over the decoded message. With a wallet adapter:

import { VersionedTransaction } from "@solana/web3.js";

case "solana_transaction": {
  const { transactionBase64 } = req.payload as { transactionBase64: string };
  const tx = VersionedTransaction.deserialize(Buffer.from(transactionBase64, "base64"));
  const signed = await wallet.signTransaction(tx);
  const index = signed.message.staticAccountKeys.findIndex(
    (key) => key.toBase58() === req.signerAddress,
  );
  if (index === -1) throw new Error(`signer ${req.signerAddress} is not a signer of this tx`);
  return `0x${Buffer.from(signed.signatures[index]).toString("hex")}`;
}

Do not send it: the server co-signs as fee payer and broadcasts. The signature must be over the unmodified message. The server verifies it against the bytes it persisted, so a wallet that inserts instructions before signing (priority fee, guards, Lighthouse) gets a 400. Disable such features for this request or sign the raw message.

Blockhashes expire. A solana_transaction request has a short deadline, and if the wallet takes too long the fill ends failed with solana_blockhash_expired and has to be quoted again.

Fees. When the partner charges a fee and has configured a Solana fee recipient, the fee is collected as USDC transfers appended to the same signed transaction: one prompt, atomic with the deposit. Where that collection is not available, a buy funded from Solana fills without the app fee or a user-paid referral, and carries only the bridge deposit.

Solana venues. Clearinghouse orders on Novig and ProphetX, where the order itself is a Solana transaction, are not yet enabled for self-custody. A self-custody route with a clearinghouse leg on either is refused with quote_self_custody_unsupported_venue. The direct backend works as described in the venue table below.

Fees

App and referral fees work as on a managed trade (Fees), but they are collected differently. Before charging a fee on self-custody trades:

  • Set your fee recipient wallets in the admin dashboard under Fees: the EVM address for EVM-funded buys, the HyperCore address for Hyperliquid trades paid from a HyperCore balance, and the Solana address for Solana-funded buys. A trade whose recipient is missing fills without the fee. Nothing else is needed on your side: the rate is the one you already set, and sufwan-local-test adds the fee prompts to the fill.
  • A fee that cannot be collected is waived, never refused. The trade fills without the app fee or a user-paid referral; an app-paid referral is unaffected. The quote says so in advance with a custodyWarnings entry, reason self_custody_app_fee, whose message names why the fee cannot be collected. It is informational: nothing blocks the trade, and the prebuilt UI does not show it. The quote's fee fields still include the fee, so the user spends slightly less than quoted. The cases below describe when this happens.
  • Wallets that cannot sign an EIP-7702 authorization (MetaMask and other browser wallets through wagmi, and the prebuilt UI unless your signer says otherwise) fill with approveMode: "user_broadcast". There a buy paid from the wallet's own EVM balance collects the app fee only when it crosses chains, inside the bridge, and the fee accrues at the bridge provider for you to claim (see Claiming bridge-collected fees). A same-chain buy from that balance, or one with a user-paid referral, fills without the fee. Buys paid from the Polymarket deposit wallet or a HyperCore balance collect as usual in either mode. See Choosing an approve mode.
  • Polymarket deposit wallet funds carry a fee only into Polymarket or Hyperliquid. A fee-bearing buy on another venue paid from the deposit wallet fills without the fee; paying from the user's own wallet balance collects it.
  • One funding source per fee. A fee-bearing buy whose funding comes from more than one wallet balance fills without the fee, except a Polymarket or Hyperliquid buy that pays its fee from one of them.
  • Fees taken before the fill are not refunded by sufwan-local-test. That includes an order that fills partially, one the venue cancels with nothing filled, and a fill that ends because the user never sent a venue approval. The refund is yours to make.
  • Some fees are the user's to decline. A fee requested after the fill can be declined; the trade stands and the fee is not collected. The Polymarket pre-sign is optional, and declining it moves the fee to an after-fill request.
  • Kalshi, Novig and ProphetX fills that use the user's own venue account cannot carry a fee and fill without it. A route with an EVM leg collects the fee there.

How each funding shape collects the fee:

FundingCollectedPartial fill
EVM walletBefore the fill, on the quoted notional, inside the same batch the user signs for fundingOver-collects. The refund is yours to make.
EVM wallet already holding the venue's own token on the venue's chain (Limitless USDC on Base, Predict USDT on BNB, TipRun USDC on Arbitrum)Before the fill, on the quoted notional, inside a funding batch that routes the trade amount through the bridge provider back to the same wallet. The provider's fee (a few cents) comes out of that amount, so the order is sized down by itOver-collects. The refund is yours to make.
Myriad buy funded in USD1 on BNB, where enabledBefore the fill, on the quoted notional, as up to three transaction requests (purpose: "app_fee": your fee, the platform fee, a user-paid referral) the wallet sends itself, paying BNB gas. The order waits for all of them. A wallet without enough BNB for that gas is refused at fillOver-collects. The refund is yours to make.
EVM wallet, approveMode: "user_broadcast", cross-chain buy, where enabledBefore the fill, on the quoted notional, inside the bridge: the bridge provider takes it from the amount bridged, so the user signs nothing extra. Each recipient's share accrues as a balance at the bridge provider, which the recipient claims there. It is not paid out to your wallet automaticallyOver-collects. A bridge that is refunded collects no fee.
HyperCore balance (Hyperliquid)After the trade settles, on the realized notional, as extra signaturesCollects nothing
Polymarket buy paid from the deposit wallet or a legacy Polymarket walletOne extra signature (purpose: "app_fee"). Deposit wallet or Safe: signed before the order on 99% of the quoted notional, submitted after the fill. Proxy wallet: after the fill, on the realized notional. The deposit wallet pays from its pUSD or USDC.e: a trade funded from USDC.e is admitted, and when too little pUSD is left after the fill, the after-fill signature pays the rest in USDC.e. A Hyperliquid buy funded from the deposit wallet's USDC.e signs its fee in USDC.e before the orderThe signed amount is collected as long as it is at most 10% above the fee on the realized notional, so a partial fill can over-collect by up to 10%. Beyond that, the signed amount is discarded and one exact signature is requested after the fill
Sell (Polymarket, Hyperliquid)Signed before the sell on worst-case proceeds (shares × floor price × 99%), submitted after the sell settlesIf the sell fills below that, a Polymarket sell still collects the signed amount when it is at most 10% above the realized fee. Otherwise (and on any Hyperliquid shortfall) the signed amount is discarded and one exact signature is requested after the fill; declining it leaves the fee uncollected

The Polymarket buy pre-sign is optional: its request carries purpose: "app_fee" and optional: true, and the order never waits for it. The SDK submits the order signatures first, then allows up to 55 seconds for the fee signature. Declining it, or not answering, keeps the order; about a minute after the fill the request expires and one exact fee signature is requested after the fill instead. Sell fee pre-signs remain required.

On a HyperCore-funded Hyperliquid trade, the fee asks for up to three extra signatures (your fee, the platform fee, and a user-paid referral), parked next to the order signature. Only non-zero legs are asked for, and the trade is not submitted until all are returned.

Self-custody sell fees are available on Polymarket and Hyperliquid only, and only when the platform has enabled them. Otherwise the sell is quoted fee-free with a self_custody_app_fee warning (in warnings[], not custodyWarnings) and is never refused. Polymarket proxy wallets skip the pre-sign and use only the exact post-fill signature.

An after-fill fee request arrives once the order already reads filled. Until it is answered, the fill stays at overallState: "confirming" with terminal: false, so keep polling and prompting until terminal is true. The request expires after 30 minutes. Declining it never changes the trade, but the fee is not collected.

A fee-bearing self-custody buy whose fee cannot be collected fills without it, and its quote carries the self_custody_app_fee custody warning. A batch that would only collect a very small fee is skipped, and the trade still fills.

For an app-paid referral, your backend answers one more request after the fill. Its signerAddress is your payer wallet, not the trader's. A client that prompts the connected wallet for every pending request must skip requests whose signerAddress is not its own.

Claiming bridge-collected fees

A fee collected inside the bridge under approveMode: "user_broadcast" is not sent to your wallet per trade, and it is never paid out automatically. Your share accrues as a USDC balance on Base at the bridge provider (Relay), held under the EVM fee recipient address set in the admin dashboard under Fees. sufwan-local-test's share accrues separately under sufwan-local-test's own address; neither side can claim the other's.

To withdraw it, connect that fee recipient wallet at relay.link/claim-app-fees and sign the claim. No Relay account or API key is needed. You can send the claimed USDC to any address. The same claim works through the Relay SDK's claimAppFees or its API: check the balance with GET https://api.relay.link/app-fees/<your-fee-recipient>/balances, and a Safe can claim through EIP-1271. The claim's network gas is deducted from the amount claimed, so let small balances build up first. Changing your fee recipient later does not move a balance already accrued; the old address still claims it.

Venues

What the wallet is asked for, in the order the requests arrive, after any funding prompts. "Once" means once per wallet and exchange contract: sufwan-local-test checks on-chain state first, so an approval already granted is not asked for again.

VenueSelf-custodyPrompts, in orderApprovals
HyperliquidYeseip712 builder-fee approval (once), then the hl_l1_action orderNone on-chain
PolymarketYesFunding and setup batch, then the eip712 order pairInstalled by the setup batch, as a signature
LimitlessYes, market fills only. Self-custody limit orders are refused, and positions sufwan-local-test did not create are not offered for sale. Some older negRisk markets can fail at the venue (see below).transaction approvals, then personal_sign registration (once, only while the wallet has no Limitless profile), then the eip712 order (domain Limitless CTF Exchange)Buy: an unlimited USDC approve to the exchange. Sell: CTF setApprovalForAll to the exchange, plus one for the adapter on a negRisk market. Each once.
PredictYes, market fills only, with the same limits as Limitless. A buy must be at least $1.transaction approvals, then personal_sign over Predict's auth message (every fill), then the eip712 orderAn unlimited USDT approve to the exchange, CTF setApprovalForAll to the exchange, and one for the neg-risk adapter where the market needs it. Each once, only when missing.
MyriadYes, market fills only, with the same limits. A wallet delegated on BNB is revoked first (see below).transaction approvals, then personal_sign over Myriad's sign-in message (every fill), then the eip712 order (domain MyriadCTFExchange)An unlimited USD1 approve to the exchange (buys) and CTF setApprovalForAll to the exchange. Each once, only when missing.
TipRunYes, market fills only, with the same limits as Limitless. Funded from Arbitrum USDC in the wallet (bridged in when needed): a buy deposits only the shortfall into the wallet's TipRun account, since cash already in that account is used first. Partners cannot allocate TipRun account cash as a separate funding source. Two fills running at once on the same wallet may both count on the same account cash. The later one then usually fails before its order is placed (credited cash below the order requirement), after its own shortfall deposit has already landed: that money is not lost, it stays as cash in the wallet's TipRun account. An existing TipRun account is adopted; a wallet with more than one is refused (see below), including a wallet that later gains a second account, for buys and sells alike: trade or withdraw it in TipRun's own app. Withdrawals are not offered: withdraw in TipRun's own app.eip712 RegisterAccount (once, only while the wallet has no TipRun account), then an eip712 Permit for each buy that deposits, then the eip712 LimitOrderNone on-chain. The deposit is a Permit signature.
KalshiYes. Funded from the user's own Kalshi cash and signed with their own Kalshi API key (Venue keys), with no wallet prompt. Without a stored key the fill is refused as quote_insufficient_balance. With a partner fee or user-paid referral, a Kalshi-only fill goes through without the fee; routes with an EVM leg collect the fee there.NoneNone
Novig, ProphetXOnly on the app's direct backend, the same model as Kalshi: the leg trades the user's own connected Novig or ProphetX account and cash, with no wallet prompt. A clearinghouse leg is refused as quote_self_custody_unsupported_venue. A linked Solana wallet can still fund the EVM venues above; see Solana. With a partner fee or user-paid referral, a direct-only fill goes through without the fee.NoneNone
Every other venueNo. quote_self_custody_unsupported_venue at fill.——

The venue check runs at fill time, not quote time.

Venue approvals are transaction requests the wallet sends and pays gas for (Limitless on Base, Predict and Myriad on BNB). The order signature is not asked for until the approve has mined. An approve that is never sent expires after 15 minutes and ends the fill with no order placed; funds already bridged stay in the wallet, and a partner fee already collected is not refunded.

Predict funds from the wallet's own USDT on BNB and Myriad from USD1, or bridges in. USD1 is priced only when Myriad is the only venue the quote can land on.

Limitless only lets a partner app trade for Limitless accounts that app created. A wallet that already has its own Limitless account (made on limitless.exchange or through another app) cannot be traded through sufwan-local-test, and neither can one whose account is set to smart-wallet trading, which rejects orders the wallet signs itself. A self-custody quote for such a wallet leaves Limitless out of the route and carries a self_custody_limitless_foreign_profile or self_custody_limitless_smart_wallet warning. A wallet with no Limitless account yet is registered on its first Limitless trade.

On some older negRisk Limitless markets the venue can reject the self-custody order (a buy with Invalid signature, a sell with Conditional token allowance not set); the fill fails with nothing placed. A buy can be re-quoted without signingAddress; such a position cannot currently be sold through sufwan-local-test.

A wallet that owns more than one TipRun account cannot be traded: there is no single account to use without guessing. A self-custody quote for such a wallet leaves TipRun out of the route and carries a self_custody_tiprun_ambiguous_account warning, and a fill is refused with quote_self_custody_tiprun_ambiguous_account.

Myriad's order book silently drops orders from an EIP-7702-delegated trader. A self-custody Myriad fill therefore checks the wallet on BNB right before the order: when it is delegated — by an earlier default-mode approval, or by this fill's own funding batch, such as the USDT → USD1 conversion on BNB — the user is asked for one more eip7702_authorization with contractAddress set to the zero address, which revokes the delegation. sufwan-local-test broadcasts that transaction and waits until the wallet reads as a plain EOA before posting the order. After a revoke, the rest of the fill pauses briefly, usually under ten seconds and never more than about thirty, until the revoke is visible across the network, so Myriad does not still see the wallet as delegated. A plain wallet sees no extra prompt and no wait. approveMode works on Myriad as on any other venue.

Funding is read from the user's linked wallet across the EVM chains sufwan-local-test routes on, their Hyperliquid balance, their Polymarket deposit wallet on Polygon, and a legacy Polymarket proxy wallet where one exists. A linked Solana wallet's USDC is read only when it is named in fundingAddresses; see Solana. A Solana-only account with no EVM wallet cannot sign and reads as unlinked.

Funding sources

A Hyperliquid buy can be paid from pUSD in the user's Polymarket deposit wallet. sufwan-local-test bridges it from Polygon to the EOA's HyperCore balance before the order:

  • When the deposit wallet is the only source for that amount, it is one eip712 signature from the EOA. The pUSD is unwrapped and bridged from the deposit wallet in a single transaction, and nothing passes through the EOA.
  • Otherwise, for example when the EOA's own funds pay part of it, the EOA first signs one eip712 transfer that moves the pUSD from the deposit wallet to the EOA. The bridge then asks for the usual cross-chain signatures for that token.

The bridge provider's fee and Hyperliquid's first-deposit activation apply as on any bridge into HyperCore. A legacy proxy wallet cannot fund a Hyperliquid buy. A legacy Safe can; see Accounts and wallets.

Choosing an approve mode

Cross-chain funding needs a token approval. There are two ways to give it. It cannot change once the fill is running.

  • Omit the field, or send approveMode: "sponsored". The user does not send an approve transaction. The wallet signs an eip7702_authorization: a signature over a raw digest, neither EIP-191 nor EIP-712. Many wallets cannot produce it. Where it works, the user signs once per chain, and later bridges on that chain need no further approve signature.
  • approveMode: "user_broadcast": the user sends an ordinary approve as type: "transaction" and pays that transaction's network fee. It sets up no delegation. A token that needs an approval needs it once per token per chain, after which trades use a permit signature only.

Under user_broadcast, a first trade from Polygon USDT or USDC.e is one approve transaction plus one permit signature. Later trades are a permit signature only. Native USDC is one signature.

Pick approveMode: "user_broadcast" when the wallet cannot sign a raw-digest authorization. A permit, when the route uses one, stays eip712. That is not the EIP-7702 step. The same split is on Self-custody signing.

Neither mode makes bridging free. The bridge provider's fee is taken from the amount delivered on every bridge, and one-time costs such as Hyperliquid's first-deposit activation apply per route. Quote with deepEstimate=true and read feeBreakdown.bridgeFees and feeBreakdown.setupCosts. See Deep cost estimate.

Four refusals here are terminal and show as errorReason on the status endpoint, not as a 400 on the fill: a wallet already delegated to a different contract on the source chain, a approveMode: "user_broadcast" wallet with no native fee to send the approve; a same-chain conversion under approveMode: "user_broadcast", which needs approveMode: "sponsored" (or the field omitted); and relay_self_custody_app_fee_not_priced: under approveMode: "user_broadcast" the bridge provider did not price the partner fee into the route. It ends the fill before any wallet prompt, so nothing is signed or sent. Re-quote, or clear the fee.

Withdrawals

Send signingAddress on POST /execution/withdraw to withdraw from the user's own linked EVM wallet instead of the managed balance. The wallet signs every step, and the user never needs a gas token. amountRaw is the amount debited (fees come out of it), and max: true is not supported: quote the wallet's Max with getWithdrawalQuote and send that exact amount. Answer pendingSignatures on POST /execution/withdrawals/{withdrawalId}/signatures and poll the status endpoint to a final state.

Sources are the wallet's EVM and HyperCore balances, its Polymarket deposit wallet and pUSD in its legacy Safe or proxy wallet (a proxy wallet only when the signing wallet owns it). Which of them a withdrawal can use depends on approveMode and the destination. The full guide, with the source and destination matrix, what the wallet signs, fees, refusals, and the React hooks and modal, is Self-custody withdrawals.

Not supported, with the exact code

Every refusal below is 400 on POST /execution/fill with { message, code }.

codeMeaningWhat to do
quote_self_custody_unsupported_venueThe route touches a venue without self-custody support (anything except Polymarket, Hyperliquid, Kalshi, Limitless, Predict, Myriad, TipRun, and Novig or ProphetX on the direct backend).Re-quote without signingAddress, or restrict allowedVenues.
quote_self_custody_app_feeA Myriad buy funded in USD1 on BNB collects its fee with transfers the wallet sends itself, and the wallet has too little BNB for their gas. A fee that cannot be collected at all is waived instead (see Fees).Top up BNB on the signing wallet, or fund from another chain or token.
quote_self_custody_redeem_unsupportedThe quote settles by redeeming a resolved position on a venue without self-custody redeem (anything except Polymarket, Limitless, Predict, Myriad), or self-custody redeem is not enabled.Re-quote without signingAddress.
quote_self_custody_limitless_foreign_profileThe route lands on Limitless and the wallet already has its own Limitless account, which partner apps cannot trade for.Trade it on limitless.exchange, use another wallet, or re-quote without signingAddress.
quote_self_custody_limitless_smart_walletThe route lands on Limitless and the wallet's Limitless account uses smart-wallet trading, which rejects self-signed orders.Switch the account to wallet (EOA) trading on limitless.exchange, then re-quote.
quote_self_custody_tiprun_ambiguous_accountThe route lands on TipRun and the wallet owns more than one TipRun account.Use a wallet with a single TipRun account, or re-quote without signingAddress.
quote_unfillableThe quote was priced for a different signing wallet or fundingAddresses set, or has no route for these sources. The message says which.Re-quote for the wallets you sign with.

Refusals with no code, only a message:

  • signingAddress is not supported in paper mode.
  • The two wallet-linking messages above.
  • approveMode "sponsored" is not valid for a self-custody Myriad fill …: omit the field or pass "user_broadcast". The quote stays fillable.
  • Could not verify the signing wallet on BNB right now. Retry the fill in a moment.: a 503 with { retryable: true }. Not a refusal; retry the same fill.

Code that switches only on code misses these. Always show message too.

These paths do not accept a signing wallet:

  • Direct orders (POST /execution/orders) reject signingAddress at validation.
  • Withdrawals act on the managed wallet unless you send signingAddress; see Withdrawals.

Self-custody: redeemSelfCustody — Limitless/Predict/Myriad prompt a transaction (user pays gas), Polymarket prompts one typed-data signature and the user sends no transaction. Self-custody redeem becomes available once sufwan-local-test enables it on the platform; until then the fill is refused with quote_self_custody_redeem_unsupported. On Polymarket it works only for shares in the user's Polymarket deposit wallet. Shares on the wallet itself (the EOA), or in a legacy Safe or proxy wallet, fail with self-custody redeem needs the shares in your Polymarket deposit wallet.

await client.redeemSelfCustody({ venueMarketOutcomeId, signingAddress });
// A signer that lives outside the client (a React provider) can be passed per call:
await client.redeemSelfCustody({ venueMarketOutcomeId, signingAddress }, { signer });

It requests a sell quote with no size, which the API sizes to exactly the resolved, redeemable shares (a redeem is never partial and never sells open shares), and throws Nothing to redeem: quote is not redeem-only unless the quote settles entirely by redeeming. Each self-custody leg in GET /execution/positions carries signerAddress, the wallet that signs its redeem. In React, pass computeClosedPositionTotals(group).signerAddressByOutcome to useRedeem().mutateAsync({ venueMarketOutcomeIds, signerAddressByOutcome }): those outcomes are redeemed one wallet prompt at a time with the AggWalletSignerProvider signer, the rest through the managed redeem. Resolved self-custody positions on other venues are not claimable through sufwan-local-test. The user redeems on the venue.

Self-custody limit orders

Pass the linked signingAddress to POST /execution/limit-orders for Polymarket or Hyperliquid. Use the returned quoteId to poll GET /execution/status, sign each pendingSignatures batch, and submit it to POST /execution/fill/{quoteId}/signatures. An empty batch can mean the next step is still running. Placement succeeds when the order is open with a venueOrderId. A resting order is not terminal: track it with GET /execution/orders?orderId=.

Polymarket asks for fresh authentication for each new order. The credentials derived from it are used in memory during submission and are not stored.

sufwan-local-test tracks fills in the background. An empty fill history does not prove an order was cancelled or expired, and an unfilled remainder that the venue cancelled or expired can show as active until a fresh signed read confirms it.

Cancel with POST /execution/orders/cancel and { orderId, cancelSignature }. sufwan-local-test cannot cancel a self-custody order without a new signature from the wallet. GTD expiry is enforced by the venue. sufwan-local-test does not cancel self-custody GTC orders when a market closes.

The prebuilt trading UI cannot yet cancel a Hyperliquid order placed from the user's own wallet: its Cancel button is disabled. Sign and submit the cancel yourself as described in Limit-order cancel signatures.

Do not pass signingAddress through useExecuteManaged from @agg-build/hooks. The call creates real orders, but nothing signs them, so they wait until their requests expire. Use fillSelfCustody from the SDK. For React, wrap your app in AggWalletSignerProvider with a signer such as useWagmiSolanaWalletSigner (from @agg-build/hooks/self-custody), set features.tradingCustody (see Prebuilt trading UI), and let PlaceOrder sign through useSelfCustodySigning.

Prebuilt trading UI

features.tradingCustody on the AggProvider config chooses, for the whole app, which balances users trade from in the @agg-build/ui components:

ValueWhat users see
"managed" (default)The sufwan-local-test balance only. No "Trade from" selector and no wallet signing.
"both"A "Trade from" selector on PlaceOrder with the sufwan-local-test balance and each linked EVM wallet. A wallet trade is signed in the wallet.
"self-custody"Linked wallets only. PlaceOrder trades from the connected linked EVM wallet, or the first one linked. The sufwan-local-test balance, Deposit, and Withdraw are hidden in PlaceOrder, the connect button, and the profile page.
<AggProvider client={client} config={{ features: { tradingCustody: "self-custody" } }}>
  <AggWalletSignerProvider value={signer}>{children}</AggWalletSignerProvider>
</AggProvider>

Any other value is treated as "managed". "both" and "self-custody" need an AggWalletSignerProvider above the trading UI. Paper apps always trade from the paper balance.

In "self-custody" mode, the "Trade from" selector lists only wallets. With one linked EVM wallet and features.enableMultiWalletAccountLink off, it shows that wallet as a plain label. If the user has no linked EVM wallet, the trade button becomes "Link an Ethereum wallet to trade". It opens the same wallet-link flow as sign-in, so mount WalletLinkBridge from @agg-build/auth. This prompt does not depend on enableMultiWalletAccountLink, which only controls linking extra wallets and funding a trade from several of them. A user who signed in with an Ethereum wallet already has that wallet linked.

Kalshi trades with an API key, not a wallet, so its route is dimmed for a wallet trade.

On this page