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.
GET /orderbook/{venueMarketOutcomeId}/route, then
POST /execution/fill. signingAddress is the only opt-in. There is no app flag or header.
The flow
1
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.2
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.3
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:
Funding from more than one wallet
By default a trade is funded fromsigningAddress 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
signingAddressitself are removed, and order does not matter: AGG decides which wallet pays what. The quote echoes the final set back asfundingAddresses. - 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.
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.
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: AGG’s fee payer co-signs and pays the network fee, and the server broadcasts.1
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.2
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.3
Handle solana_transaction in your signer
One request per bridge, addressed to the Solana wallet (
signerAddress is base58).payload carries:
0x + 128 hex chars: the 64-byte ed25519 signature over the decoded message. With a wallet
adapter:
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. This fee collection is off by default. Until it is enabled, a fee-bearing buy funded
from Solana is refused with quote_self_custody_app_fee and reason non_evm_source (reason
disabled when the deployment has no self-custody fee collection at all), and a fee-free buy
carries only the bridge deposit. A user-paid referral is refused on a Solana-funded buy too
(quote_self_custody_app_fee, reason non_evm_source).
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:
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 is refused when its fee cannot be collected, with
quote_self_custody_app_fee and a reason in the message (see
Not supported). A fee of 0 bips is always fine. 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.
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: AGG checks on-chain state first, so an approval already granted is not asked for again.
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 AGG, 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 AGG.
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. AGG 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 AGG 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. AGG 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
eip712signature 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
eip712transfer that moves the pUSD from the deposit wallet to the EOA. The bridge then asks for the usual cross-chain signatures for that token.
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 aneip7702_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 astype: "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.
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.
Not supported, with the exact code
Every refusal below is400 on POST /execution/fill with { message, code }.
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.: a503with{ retryable: true }. Not a refusal; retry the same fill.
code misses these. Always show message too.
These paths do not accept a signing wallet:
- Direct orders (
POST /execution/orders) rejectsigningAddressat validation. - Withdrawals always act on the managed wallet. A self-custody user already holds their funds.
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
AGG 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.
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 AGG. The user redeems on the venue.
Self-custody limit orders
Pass the linkedsigningAddress 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.
AGG 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 }. AGG cannot cancel a
self-custody order without a new signature from the wallet. GTD expiry is enforced by the venue.
AGG 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.
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:
"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.
Related
Self-custody signing reference
Request types, the signer, prompt counts, and the raw signing loop.
Execute quote
Request and response schema for
POST /execution/fill.Submit signatures
The endpoint behind the signing loop.
Account linking
Link the EVM wallet self-custody needs.