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

# Self-Custody Trading

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

<Info>
  Prerequisites: [Authentication](/recipes/authentication), and an EVM wallet
  [linked](/recipes/account-linking) to the signed-in account.
</Info>

In a managed trade AGG 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: AGG
prices the route against that wallet's on-chain balances, then parks a series of payloads for
it to sign. AGG never holds the key.

It runs on the same two endpoints as a managed trade —
`GET /orderbook/:venueMarketOutcomeId/route`, then `POST /execution/fill`. `signingAddress` is
the only opt-in: there is no app flag and no header.

## The flow

<Steps>
  <Step title="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.
  </Step>

  <Step title="Fill with the same address">
    Post the `quoteId` and the same `signingAddress`. The response returns before any signing
    happens, so it never carries `pendingSignatures`.
  </Step>

  <Step title="Sign what the run parks">
    Poll `GET /execution/status?quoteId=…` for `pendingSignatures[]`, sign each payload, and
    submit to `POST /execution/fill/:quoteId/signatures`. Repeat until the fill is terminal —
    an empty list means "keep polling", not "done".
  </Step>
</Steps>

`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 advance it, or
after five minutes. The poll interval and deadline are fixed. Everything here is plain REST, so
you can also [drive the loop yourself](#driving-the-loop-yourself).

Whichever way you drive it, the user has to stay at the keyboard: every request expires, and
an expired request cannot be revived — the fill has to be re-quoted from the beginning.

## The signing wallet

`signingAddress` is never trusted on its own. It 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.

Solana wallets do not qualify. A wallet linked with a Solana signature cannot sign for the
venues self-custody supports, so it is not a candidate signer.

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

| Message                                                             | Meaning                                                                                                                           |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `Cannot trade in self-custody: link a wallet to this account first` | The account has no linked EVM wallet at all.                                                                                      |
| `Wallet 0x… is not linked to this account`                          | The account has linked wallets, but not this one. The address is echoed lowercased — do not string-match against your own casing. |

## 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 at once.

The signing wallet still signs the venue order. Each funding wallet signs only the bridge legs
that move its own money — so `signerAddress` on a `SignatureRequest` **varies within a single
fill**, and your signer has to route each request to the wallet it names rather than to
whichever account the wallet has selected.

* Optional. Omitting it behaves exactly as before.
* 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, the same rule as `signingAddress`. Solana
  entries are refused.
* Duplicates and `signingAddress` itself normalise away, and array order carries no meaning —
  AGG decides which wallet pays what. The quote echoes the normalised set back as
  `fundingAddresses`; pin from that rather than from your own input.
* 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, though the same balance bridges
  normally to any other venue.

If you send `fundingAddresses` on the fill it must match the set the quote was priced against,
or the fill is refused with `quote_unfillable`. Leaving it off the fill skips that comparison
entirely — that is what keeps callers written before this field working, so send it if you
want the check.

## Request shape

**Quote** — `GET /orderbook/:venueMarketOutcomeId/route`

| Field              | Type                  | Notes                                                                        |
| ------------------ | --------------------- | ---------------------------------------------------------------------------- |
| `signingAddress`   | string                | Optional. Exactly 42 characters. Omit for a managed quote.                   |
| `maxSpend`         | number                | Buy budget, as on a managed quote.                                           |
| `sellShares`       | number                | Sell size. Sells are supported; only `custodyWarnings` is buy-only.          |
| `fundingAddresses` | string\[]             | Optional. Extra linked wallets to fund from. Defaults to `[signingAddress]`. |
| `mode`             | `"live"` \| `"paper"` | `signingAddress` with `mode=paper` is a `400`.                               |

**Fill** — `POST /execution/fill`

| Field              | Type                                | Notes                                                                                                                                    |
| ------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `quoteId`          | string                              | Required.                                                                                                                                |
| `signingAddress`   | string                              | Optional. **Must be the same wallet the quote was priced for.** A quote is sized against one wallet's inventory and is not transferable. |
| `fundingAddresses` | string\[]                           | Optional. If sent, must match the quote's set. Omitting it skips that check.                                                             |
| `approveMode`      | `"sponsored"` \| `"user_broadcast"` | Optional, defaults to `"sponsored"`. Applies to cross-chain fills. See [Choosing an approve mode](#choosing-an-approve-mode).            |
| `mode`             | `"live"`                            | Optional. Self-custody is live only; `"paper"` is a `400`.                                                                               |

```ts theme={null}
import { createAggClient } from "@agg-build/sdk";

const client = createAggClient({
  baseUrl: "https://api.agg.market",
  appId: "your-app-id",
  signer: mySigner,
});

type CustodyWarning = { reason: string; message: string };

// `getSmartRoute()` does not carry `signingAddress` yet — quote through `request()`,
// whose query values are strings on the wire.
const quote = await client.request<{
  quoteId: string;
  status: string;
  custodyWarnings?: CustodyWarning[];
}>("/orderbook/vmo_abc123/route", {
  query: { signingAddress: wallet, maxSpend: "25" },
});

for (const w of quote.custodyWarnings ?? []) {
  // Non-blocking on the quote, but the fill WILL be refused.
  // Surface it before the user commits.
  console.warn(w.reason, w.message);
}

const result = await client.fillSelfCustody({
  quoteId: quote.quoteId,
  signingAddress: wallet,
});
```

`custodyWarnings` is absent when there is nothing to report, so absent and empty mean the
same thing. It is only assembled on buy quotes — never read its absence as "this will fill".

## Writing the signer

Your signer receives one `SignatureRequest` at a time and returns either a `0x` hex string or
an object carrying a broadcast transaction hash.

```ts theme={null}
{
  stepId: string;        // echo back verbatim
  type: SignatureRequestType;
  venue: string;         // the venue the fill is for, on bridge legs too — switch on `type`, not this
  signerAddress: string; // this key must produce the signature
  chainId?: number;      // often absent, even on EVM bridge legs — read the chain from the payload
  payload: unknown;      // sign it verbatim
  expiresAt: string;     // ISO-8601 — always read this
}
```

| `type`                  | What to call                                                                                                                                                                                   | Return             |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `eip712`                | `signTypedData(payload)`                                                                                                                                                                       | hex string         |
| `hl_l1_action`          | `signTypedData(payload)` — EIP-712 in phantom-agent form                                                                                                                                       | hex string         |
| `personal_sign`         | `signMessage({ message: payload })` — `payload` is a plain string. Only when Relay's permit for the source token is EIP-191                                                                    | hex string         |
| `eip7702_authorization` | `signAuthorization(payload)` over `{ contractAddress, chainId, nonce }`, then viem's `serializeSignature({ r, s, yParity })`                                                                   | 65-byte hex string |
| `safe_tx`               | `signMessage({ message: { raw: hashTypedData(payload) } })` — a `personal_sign` over the raw 32-byte SafeTx digest, which is what Polymarket's relayer requires for a legacy Polymarket wallet | hex string         |
| `transaction`           | Switch the wallet to `payload.chainId`, then `sendTransaction` with `payload`'s `{ to, value, data }`                                                                                          | `{ txHash }`       |

A fill funded entirely from a legacy Polymarket Safe asks for exactly one `safe_tx`
signature and never an approve or an EIP-7702 authorization — the Safe itself never
broadcasts anything. A fill with mixed funding (Safe plus your EOA or deposit wallet on the
same lane) still asks for the usual EOA-side steps in addition to that `safe_tx`.

```ts theme={null}
import { hashTypedData, serializeSignature } from "viem";
import type { SignerFn } from "@agg-build/sdk";

const mySigner: SignerFn = async (req) => {
  switch (req.type) {
    case "eip712":
    case "hl_l1_action":
      return walletClient.signTypedData(req.payload as never);

    case "personal_sign":
      return walletClient.signMessage({ message: req.payload as string });

    case "safe_tx":
      return walletClient.signMessage({
        message: { raw: hashTypedData(req.payload as never) },
      });

    case "eip7702_authorization": {
      const auth = await walletClient.signAuthorization(req.payload as never);
      // viem types both `yParity` and `v` as optional; derive rather than default,
      // or a wrong parity recovers to some other address and the server rejects it.
      const yParity = auth.yParity ?? (auth.v === undefined ? undefined : Number(auth.v) - 27);
      if (yParity !== 0 && yParity !== 1) throw new Error("authorization has no usable parity");
      return serializeSignature({ r: auth.r, s: auth.s, yParity });
    }

    case "transaction": {
      const tx = req.payload as { chainId: string; to: string; value: string; data: string };
      // The wallet must broadcast on this chain, not whichever one it is on.
      await walletClient.switchChain({ id: Number(tx.chainId) });
      // Return the hash immediately — do NOT await the receipt.
      return {
        txHash: await walletClient.sendTransaction({
          to: tx.to as `0x${string}`,
          value: BigInt(tx.value), // decimal wei string; "0" for an approve
          data: tx.data as `0x${string}`,
        }),
      };
    }

    default:
      throw new Error(`unsupported signature request: ${req.type}`);
  }
};
```

<Warning>
  **Throwing from your signer is not a rejection.** `fillSelfCustody` rejects at once and drops
  any signatures it had already collected for that batch, but the server has no reject verb:
  the parked request simply expires and the fill dies.

  If you know a wallet cannot produce a raw-digest EIP-7702 authorization, declare
  `approveMode: "user_broadcast"` **when you call the fill**. It cannot be changed once the
  fill is in flight.
</Warning>

`transaction` appears in exactly one situation: the fill has to bridge, the Relay quote for the
source token opens with an on-chain ERC-20 approve rather than a permit signature, **and** you set
`approveMode: "user_broadcast"` on the fill. In the default `sponsored` mode that same approve is
signed as `eip712`, so `transaction` never appears. Venue orders, the Polymarket wrap, and the
Hyperliquid builder-fee approval are all signatures, never transactions.

When it does arrive, return the hash without awaiting the receipt. AGG watches for the mined
receipt itself and will not resume until it matches the request.

`solana_transaction` exists in the type union but is reserved — it cannot be submitted. Give
your signer a `default` branch that throws rather than silently returning.

## How many wallet prompts the user sees

Requests parked together arrive in one batch, but the user still approves each one. The
count is the sum of three independent parts: the venue's order cost, a Polymarket funding
step, and the funding lane.

**Venue order**

| Situation                        | Prompts                                                                                               |
| -------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Hyperliquid order                | 1                                                                                                     |
| Hyperliquid builder-fee approval | 1, only until it is granted — then 0 for that wallet. Apps with their own builder config never see it |
| Polymarket order                 | 2 — always a pair, buy or sell, first fill or hundredth                                               |

**Polymarket funding step**

| Situation                                                                 | Prompts                                                              |
| ------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Buy with USDC.e waiting in the deposit wallet and not enough settled pUSD | **1, after every bridge**                                            |
| Buy already covered by settled pUSD in the deposit wallet                 | 0                                                                    |
| First trade on a fresh deposit wallet, buy or sell                        | 1 — installs the approvals; a buy's wrap rides in the same signature |
| Any later sell                                                            | 0                                                                    |

<Warning>
  This one is **not** a first-time cost. Funding lands as USDC.e, which has to be wrapped
  before a buy can settle, so a returning user pays it on every freshly funded buy. Budget
  for it in your UX rather than promising a cheaper second trade.
</Warning>

**Funding lane, per lane** — a route funded from two chains pays this twice.

| Situation                                                                           | Prompts                                                                             |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Hyperliquid order from a Hyperliquid balance                                        | 0 — no lane                                                                         |
| Polymarket order from USDC.e or pUSD already in the deposit wallet                  | 0 — no lane                                                                         |
| Polymarket order from Polygon funds on the user's own address                       | 1 `sponsored`, 2 if the wallet is not yet delegated. Refused under `user_broadcast` |
| Cross-chain from native USDC (gasless)                                              | 1, in either mode                                                                   |
| Cross-chain from a token needing an approval, `sponsored`, wallet already delegated | 2                                                                                   |
| Cross-chain from a token needing an approval, `sponsored`, first time on that chain | 3                                                                                   |
| Cross-chain from a token needing an approval, `user_broadcast`                      | 2 — one broadcast, one signature, on **every** bridge                               |
| Funded from a Hyperliquid balance spent on another venue                            | 2                                                                                   |

Worked examples: a Hyperliquid buy from an existing Hyperliquid balance is **1** (2 if the
builder approval is still outstanding). A Polymarket buy bridged from native USDC is
**4** — one lane, one wrap, two order signatures — and stays 4 on repeat. The same buy from
a token needing an approval, on a wallet not yet delegated, is **6**. A Polymarket sell is
**2**, or **3** the first time that deposit wallet trades.

## Timeouts, and what expiry means

Every request carries `expiresAt`. **Read it; never hard-code a deadline.** Different steps in
the same fill can have different deadlines, and the shortest is tight:

| Request                          | Deadline   |
| -------------------------------- | ---------- |
| Hyperliquid order                | 2 minutes  |
| Hyperliquid builder-fee approval | 90 seconds |
| Polymarket order pair            | 90 seconds |
| Bridge-lane signatures           | 2 minutes  |
| Polymarket setup and drain       | 2 minutes  |
| `transaction`                    | 15 minutes |

When a fill dies, `GET /execution/status` reports an `overallState` of `failed` or `expired`
with an `errorReason`, and `fillSelfCustody` throws with that state in its message.

An expired request disappears from the pending list rather than appearing as expired, so an
empty list never means "done" — only a terminal state does. Submitting a signature for an
expired request returns `400` with `request for step … has expired — re-quote and retry`.
There is no recovery: quote again and start over. Note that a user has one live
execution at a time — if a request expires rather than being answered, the
replacement fill will not begin until the abandoned run has finished dying, so
prompt the user to complete or abandon deliberately rather than re-quoting on top
of a live request.

## Venue and funding support

| Venue             | Self-custody                                        |
| ----------------- | --------------------------------------------------- |
| Hyperliquid       | Yes                                                 |
| Polymarket        | Yes                                                 |
| Every other venue | No — `quote_self_custody_unsupported_venue` at fill |

The venue check runs at fill time, not quote time. A quote that routes to an unsupported
venue looks fine and then refuses.

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. Solana balances are invisible to a self-custody quote, and a
Solana-only wallet reads as insufficient balance.

## Choosing an approve mode

Cross-chain funding needs an on-chain approval, and there are two ways to get it.

* **`sponsored`** (default) — the approve costs the wallet no gas, but the wallet must sign
  an EIP-7702 authorization: a signature over a raw digest, neither EIP-191 nor EIP-712.
  Many wallets do not expose it. Where it works, the user signs once per chain and later
  bridges on that chain need no approve and no further signature.
* **`user_broadcast`** — the user broadcasts an ordinary approve from their own wallet and
  pays its gas. It never establishes the delegation, so an approve is needed on **every**
  bridge.

Pick `user_broadcast` when you know the user's wallet cannot sign a raw-digest authorization.

Neither mode makes bridging free. The approve is only one of the costs: Relay's fee is
deducted from the amount delivered on every bridge, and one-time costs — Hyperliquid's
first-deposit activation among them — apply per route. Quote with `deepEstimate: true` and
read `feeBreakdown.bridgeFees` and `feeBreakdown.setupCosts` rather than inferring cost from
the approve mode. See
[Deep cost estimate](/recipes/building-market-views#deep-cost-estimate-deepestimatetrue).

Three refusals here are terminal and surface as `errorReason` on the status endpoint rather
than as a `400` on the fill: a wallet already delegated to a different contract on the source
chain, a `user_broadcast` wallet with no native gas to send the approve, and a same-chain
conversion under `user_broadcast`, which needs the sponsored batch.

## Not supported, with the exact code

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

| `code`                                  | What it means                                                                                                                                                           | What to do                                                                                              |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `quote_self_custody_unsupported_venue`  | The route touches a venue other than Hyperliquid or Polymarket.                                                                                                         | Terminal. Re-quote without `signingAddress`, or restrict `allowedVenues`.                               |
| `quote_self_custody_app_fee`            | The market carries a positive partner app fee, which a self-custody wallet cannot pay.                                                                                  | Terminal. Re-quote without `signingAddress`, or clear the fee for this market. A fee of 0 bips is fine. |
| `quote_self_custody_redeem_unsupported` | The quote settles by redeeming a resolved position.                                                                                                                     | Terminal. Re-quote without `signingAddress`.                                                            |
| `quote_unfillable`                      | The quote was priced for a different signing wallet, or a different set of `fundingAddresses`, or the fill has no source-aware route. The `message` distinguishes them. | Re-quote for the wallets you are signing with.                                                          |

Refusals with **no** `code` field, only a `message`:

* `signingAddress is not supported in paper mode.` — self-custody is live mode only, on both
  the quote and the fill.
* The two wallet-linking messages listed above.

A partner switching only on `code` will miss these. Always render `message` too.

Three further paths do not accept a signing wallet at all:

* **Limit orders** always fund from the managed balance.
* **Direct venue orders** (`POST /execution/orders`) reject an unknown `signingAddress` field
  at schema validation.
* **Withdrawals** always act on the managed wallet. A self-custody user already holds their
  own funds and exits through the venue.

Resolved self-custody positions are likewise not claimable through AGG. The user redeems
through the venue's own interface.

## Driving the loop yourself

Driving the poll/submit loop yourself means handling these responses.

<Note>
  `pendingSignatures` is always absent on the fill response itself, including for a
  self-custody fill — the run parks asynchronously, after the response is sent. Its absence
  there never means "no signing needed".
</Note>

| Status | Message                                                               | Meaning                                                                        |
| ------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `403`  | `no such fill for this account`                                       | Unknown quote, or not this user's. The two are deliberately indistinguishable. |
| `404`  | `no pending request for step …`                                       | The step id does not match a parked request.                                   |
| `400`  | `request for step … has expired — re-quote and retry`                 | Dead request. Start over.                                                      |
| `400`  | `signature for step … is not 0x-prefixed hex`                         | Malformed signature.                                                           |
| `400`  | `txHash for step … is not a 0x-prefixed 32-byte hash`                 | Malformed transaction hash.                                                    |
| `400`  | `signature was signed by 0x…, expected 0x…`                           | The wrong key signed. Both addresses are lowercased.                           |
| `400`  | ``step … is a signature request — return `signature`, not `txHash` `` | Wrong field for the step's type. The mirror message names a transaction step.  |

Re-submitting a step that already succeeded is ignored rather than an error, so retrying a
batch after a network blip is safe. Do not send the same `stepId` twice in one body —
duplicates are de-duplicated last-wins, so one bad entry discards the good one and the whole
request fails.

<Warning>
  Do not pass `signingAddress` through `useExecuteManaged` from `@agg-build/hooks`. The call
  succeeds and creates real orders, but nothing ever signs them — the fill parks until its
  requests expire. Self-custody requires `fillSelfCustody` from the SDK.
</Warning>

## Related

<CardGroup cols={2}>
  <Card title="Fill API" icon="book-open" href="/api-reference/execution/fillmanaged">
    Full request and response schema for `POST /execution/fill`.
  </Card>

  <Card title="Submit Signatures" icon="book-open" href="/api-reference/execution/submitfillsignatures">
    The endpoint behind the signing loop.
  </Card>

  <Card title="Compute Order Route" icon="book-open" href="/api-reference/orderbook/getsmartroute">
    The quote endpoint `signingAddress` is passed to.
  </Card>

  <Card title="Account Linking" icon="link" href="/recipes/account-linking">
    Linking the EVM wallet that self-custody requires.
  </Card>
</CardGroup>
