Trading

Self-Custody Signing Reference

Signature request types, a complete signer, how many prompts a user sees, and the raw signing loop

This page is the reference for the signing half of Self-custody trading: what each request asks the wallet to do, how to answer it, and what errors mean.

The request

Each entry in pendingSignatures[] looks like this:

{
  stepId: string;        // send it back verbatim
  type: SignatureRequestType;
  venue: string;         // who the request is for; switch on `type`, not this
  signerAddress: string; // the wallet that must sign or send
  chainId?: number;      // often absent; read the chain from the payload
  payload: unknown;      // sign or send it verbatim
  expiresAt: string;     // ISO-8601; always read it
  purpose?: string;      // funding, funding_and_fee, fee, app_fee, referral_payout, or "hl_referral"
  optional?: boolean;    // true on purpose: "hl_referral"; the trade proceeds if you skip it
}

What each type asks for

typeWhat to callSubmit
eip712signTypedData(payload)signature
hl_l1_actionsignTypedData(payload), EIP-712 for a Hyperliquid actionsignature
personal_signsignMessage({ message: payload }) when payload is a string, or signMessage({ message: { raw } }) when it is { raw }. Used for a Relay EIP-191 permit, the Limitless registration message, the Predict auth message, the Myriad sign-in, and a legacy Polymarket proxy wallet ({ raw }, a 32-byte relay hash).signature
eip7702_authorizationsignAuthorization(payload) over { contractAddress, chainId, nonce }, then serialize to a 65-byte signaturesignature
safe_txsignMessage({ message: { raw: payload.safeTxHash } }), a personal_sign over the raw 32-byte SafeTx hash. Used for a legacy Polymarket Safe.signature
transactionSwitch the wallet to payload.chainId, then send { to, value, data } from signerAddresstxHash
solana_transactionSign the transaction in payload.transactionBase64 with the Solana wallet, without sending it. Issued only when a linked Solana wallet is named in fundingAddresses; see Solana.signature (0x + 128 hex, the ed25519 signature)

Send exactly one of signature and txHash per step. Signatures are 0x-prefixed hex.

A fill funded entirely from a legacy Polymarket Safe asks for one safe_tx signature and never an approve or an EIP-7702 authorization. A fill funded from a Safe plus another wallet on the same chain also asks for the usual steps for that other wallet.

A complete signer

fillSelfCustody calls your signer once per request. Return a hex signature, or { txHash } for a transaction.

import { 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": {
      const p = req.payload as string | { raw: `0x${string}` };
      return walletClient.signMessage({ message: typeof p === "string" ? p : { raw: p.raw } });
    }

    case "safe_tx": {
      const { safeTxHash } = req.payload as { safeTxHash: `0x${string}` };
      return walletClient.signMessage({ message: { raw: safeTxHash } });
    }

    case "eip7702_authorization": {
      const auth = await walletClient.signAuthorization(req.payload as never);
      // viem types `yParity` and `v` as optional. Derive rather than default:
      // a wrong parity recovers to another 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 };
      // Broadcast on the request's chain, not whichever one the wallet is on.
      await walletClient.switchChain({ id: Number(tx.chainId) });
      // Return the hash right away. Do NOT wait for 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}`);
  }
};

Throwing from your signer is not a rejection. fillSelfCustody stops at once and drops the signatures it had collected for that batch, but the server has no reject call: the request simply expires and the fill fails. If you know a wallet cannot produce a raw-digest EIP-7702 authorization, fill with approveMode: "user_broadcast" instead.

When transaction reaches the trader. In two cases. On a bridge: the source token needs an on-chain approve rather than a permit signature, and the fill set approveMode: "user_broadcast". Omitting approveMode, or sending approveMode: "sponsored", asks for that same approval as eip7702_authorization: a raw digest, not EIP-712 and not EIP-191. A permit stays eip712. That split matches Choosing an approve mode.

Choosing the mode in PlaceOrder. A signer may implement the optional canSignEip7702Authorization(address). PlaceOrder sends approveMode: "sponsored" only when it returns true for the signing address and, on a buy, for every funding wallet, because the authorization is signed by the wallet that pays for the bridge. It sends approveMode: "user_broadcast" otherwise, including when the method is absent. The bundled wagmi signer does not implement it (viem cannot sign an authorization for a JSON-RPC wallet), so it fills with user_broadcast.

On a Limitless, Predict or Myriad fill: the venue's own approvals (see Venues) are always sent by the wallet, whatever the approve mode, and always arrive before the order signature. Venue orders, the Polymarket wrap, and the Hyperliquid builder-fee approval are always signatures.

The other transaction you may see is an app-paid referral payout addressed to your payer wallet. Check signerAddress before you prompt. See Fees.

After a transaction, return the hash without waiting. sufwan-local-test watches for the mined receipt and does not continue until it matches the request.

Timeouts, and what expiry means

Every request carries expiresAt. Read it; never hard-code a deadline. Steps in one fill can have different deadlines, and the shortest is tight:

RequestDeadline
Hyperliquid order2 minutes
Hyperliquid builder-fee approval90 seconds
Polymarket order pair90 seconds
Limitless registration and order1 minute
Predict auth and order1 minute
Myriad sign-in and order1 minute
TipRun registration, deposit permit and order1 minute
Bridge signatures2 minutes
Polymarket setup5 minutes
Polymarket fund transfer2 minutes
Polymarket fee after the fill (app_fee)30 minutes
transaction15 minutes

When a fill dies, GET /execution/status reports overallState 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 showing as expired, so an empty list never means "done". Only terminal: true does. Submitting a signature for an expired request returns 400. There is no recovery: quote again. A user has one live execution at a time. If a request expires instead of being answered, a new fill does not start until the abandoned one has finished failing, so ask the user to complete or abandon deliberately.

How many wallet prompts the user sees

Requests parked together arrive in one batch, but the user approves each one. The total is the sum of three parts: the venue order, a Polymarket funding step, and the funding transfers.

Venue order

SituationPrompts
Hyperliquid order1
Hyperliquid builder-fee approval1, until granted for that wallet. A saved builder address is approved the same way. See Builder codes.
Polymarket order2, always a pair, buy or sell
Limitless order1, plus 1 personal_sign registration the first time that wallet trades on Limitless
Predict order2: a personal_sign auth message, then the eip712 order
Myriad order2: a personal_sign sign-in, then the eip712 order. One more eip7702_authorization (delegate 0x0) first when the wallet is EIP-7702 delegated on BNB.
TipRun order1 eip712 LimitOrder, plus 1 eip712 Permit when the buy deposits (only the shortfall the TipRun account cash does not cover), plus 1 eip712 RegisterAccount the first time a wallet with no TipRun account trades. An existing TipRun account is adopted with no prompt.
Limitless, Predict or Myriad approvals1 transaction per missing approval, once per wallet and exchange contract. See Venues.

Polymarket funding step

SituationPrompts
Buy with USDC.e waiting in the deposit wallet and not enough settled pUSD1, after every bridge
Buy already covered by settled pUSD in the deposit wallet0
First trade on a new deposit wallet, buy or sell1. Sets up approvals; a buy's wrap rides in the same signature.
Any later sell0

The wrap prompt is not one-time. Funding arrives as USDC.e, which must be wrapped before a buy settles, so a returning user sees it on every freshly funded buy.

In the rows below, "default mode" means approveMode omitted or approveMode: "sponsored": the extra approval is eip7702_authorization, not EIP-712. user_broadcast is approveMode: "user_broadcast".

Funding transfers, per funding source. A route funded from two chains pays this twice.

SituationPrompts
Hyperliquid order paid from a Hyperliquid balance0
Polymarket order paid from USDC.e or pUSD already in the deposit wallet0, plus 1 when the buy carries a fee (purpose: "app_fee"): before the order, or after the fill from a proxy wallet or when under 99% fills
Polymarket order paid from Polygon funds on the user's own address1 in the default mode, 2 if the wallet has not signed an EIP-7702 authorization yet. Refused under user_broadcast.
Cross-chain from native USDC (signature only)1 in either mode. In the default mode, with a partner fee or user-paid referral it becomes approve plus deposit so the fee rides that batch: 1, or 2 before the first authorization. Under user_broadcast, where enabled, the bridge provider takes a partner fee inside the bridge, so it stays 1 signature.
Cross-chain from a token needing an approval, default mode, authorization already signed2
Cross-chain from a token needing an approval, default mode, first time on that chain3
Cross-chain from Polygon USDT or USDC.e, user_broadcastFirst trade: 2 (one transaction, one signature). Later trades: 1 (permit signature only)
Cross-chain from another token needing an approval, user_broadcastFirst trade: 2 (one transaction, one signature). Later trades: 1 (permit signature only), since the approval is needed once per token per chain
Paid from a Hyperliquid balance for another venue2
Hyperliquid order paid only from pUSD in the Polymarket deposit wallet1
Hyperliquid order paid from the deposit wallet together with another source1 to move the pUSD to the EOA, plus the cross-chain row for that token
Myriad order paid from USD1 already on BNB, with a partner fee or user-paid referral, where enabled0 funding transfers, plus 1 transaction (purpose: "app_fee") per fee leg: the partner fee, the platform fee and a user-paid referral, so up to 3. No referral request when the referral is app-paid or absent. The wallet sends them itself, paying BNB gas, in either mode, and the order waits for all of them. See Fees.

In the default mode, with a partner fee on an EVM-funded buy, the funding batch also carries the fee transfers. A user-paid referral is one more transfer in the same batch.

Worked examples: a Hyperliquid buy from an existing Hyperliquid balance is 1 prompt (2 while the builder approval is outstanding). A Polymarket buy bridged from native USDC is 4 (one funding transfer, one wrap, two order signatures), and stays 4 on repeat. The same buy from a token needing an approval, before the first authorization, is 6. A Polymarket sell is 2, or 3 the first time that deposit wallet trades.

Withdrawals, per source. A withdrawal never asks for a transaction. Details and the sources each mode allows are on Self-custody withdrawals.

SituationPrompts
Same chain and token from the wallet, default mode1 eip712 batch, 2 while the wallet has no eip7702_authorization on that chain
Same chain and token from the wallet, user_broadcast (native USDC only)2 eip712 (EIP-3009 payout and fee)
To another chain from native USDC, either mode1 permit
To another chain from a token needing an approval, default mode2, or 3 before the first authorization
HyperCore to HyperCore1 eip712 sendAsset
HyperCore to another chain2 eip712
From the deposit wallet, to another chain or Polygon token, where enabled1 eip712
From the deposit wallet, otherwise1 eip712 that moves the funds into the wallet, then the wallet's own row above
From a legacy Safe1 safe_tx
From a legacy proxy wallet1 personal_sign ({ raw })

Driving the loop yourself

Without fillSelfCustody, run this loop:

  1. POST /execution/fill with quoteId and signingAddress. The response never carries pendingSignatures, even for self-custody.
  2. Poll GET /execution/status?quoteId=, waiting pollAfterMs between calls.
  3. For each new pendingSignatures[] entry, have the wallet named by signerAddress sign or send the payload.
  4. Submit the results with the call below. Up to 32 entries. Send signature or txHash, not both. The response has the same shape as the fill response.
  5. Repeat from step 2 until terminal is true.
SDK
await client.submitFillSignatures(quoteId, [{ stepId, signature }]);
// A transaction step is { stepId, txHash } and no signature.

Submit signatures for a pending self-custody fill.

Errors from the signatures endpoint:

StatusMessageMeaning
403no such fill for this accountUnknown quote, or not this user's. The two look the same on purpose.
404no pending request for step …The stepId does not match a waiting request.
400request for step … has expired — re-quote and retryThe request is dead. Start over.
400signature for step … is not 0x-prefixed hexMalformed signature.
400txHash for step … is not a 0x-prefixed 32-byte hashMalformed transaction hash.
400signature was signed by 0x…, expected 0x…The wrong key signed. Both addresses are lowercased.
400step … is a signature request — return `signature`, not `txHash` Wrong field for the step's type. The reverse message names a transaction step.

Resubmitting a step that already succeeded is ignored, so retrying a batch after a network error is safe. Do not send the same stepId twice in one body: duplicates collapse to the last entry, so one bad entry discards the good one and the request fails.

purpose: "hl_referral" with optional: true is the one-time Hyperliquid referral on the user's account. If the signer skips it or it expires, submit the rest. The trade still proceeds. See Builder codes.

Limit-order cancel signatures

Managed cancel without a signature is on Limit orders. A self-custody cancel is the same POST /execution/orders/cancel, plus cancelSignature. Cancel an order.

sufwan-local-test does not store the Polymarket L2 credentials it derives from a cancel, and it never starts or retries a self-custody cancel on its own. A stale read after you submit returns cancel_pending. Keep polling until the order is cancelled, or filled / partial_fill if a fill won the race.

Polymarket

Sign Polymarket's EIP-712 ClobAuth with the order's signing wallet. Domain { name: "ClobAuthDomain", version: "1", chainId: 137 }. Primary type ClobAuth with address (address), timestamp (string), nonce (uint256), and message (string). address is the signing wallet. timestamp is the current Unix time in seconds, as a decimal string. nonce is 0. message is This message attests that I control the given wallet.

await client.cancelManagedOrder(orderId, {
  cancelSignature: { signature, timestamp }, // timestamp is Unix seconds as a number
});

// Same signature, for a Polymarket order placed outside sufwan-local-test.
await client.cancelVenueOrder({
  venue: "polymarket",
  venueOrderId,
  signerAddress: linkedWallet,
  cancelSignature: { signature, timestamp },
});

signerAddress must be linked to the signed-in user and must be the wallet that produced cancelSignature. An address that is not linked returns 400. sufwan-local-test reads the venue order first. If it is not open and owned by that wallet, the call returns 409. That covers not found, already filled, already cancelled, and not yours. A success can still come back with an empty orderIds when the venue cancelled the order and sufwan-local-test could not record it. The cancel on the venue still happened. Fills that happened before the cancel are not recorded. This does not work for a polymarket.com email or Magic wallet: that flow trades from a different wallet than the one linked here. If venueOrderId is an order sufwan-local-test placed, it is cancelled the same way as cancelManagedOrder.

Hyperliquid

Build one cancel action with the venue's numeric asset id and the order's venueOrderId. Sign it with the EOA or its currently approved agg agent. Use a fresh millisecond nonce. The signature is { r, s, v }.

import { signL1Action } from "@nktkas/hyperliquid/signing";

const action = {
  type: "cancel" as const,
  cancels: [{ a: assetId, o: Number(venueOrderId) }],
};
const nonce = Date.now();
const signature = await signL1Action({
  wallet: clientHeldSigner,
  action,
  nonce,
  isTestnet,
});

await client.cancelManagedOrder(orderId, {
  cancelSignature: { venue: "hyperliquid", action, nonce, signature },
});

await client.cancelVenueOrder({
  venue: "hyperliquid",
  venueOrderId,
  signerAddress: linkedWallet,
  cancelSignature: { venue: "hyperliquid", action, nonce, signature },
});

The API checks ownership, the exact asset and order, and the signer. An agent must still be approved when cancelling. A rejected request can be retried with a fresh signature. An order placed outside sufwan-local-test through the user's own agent, not the approved sufwan-local-test agent, must be cancelled with a signature from the wallet itself. Only the wallet or its approved sufwan-local-test agent is accepted. Open self-custody Hyperliquid orders are not cancelled by sufwan-local-test. The client signs the cancellation. Native venue termination is still reconciled.

On this page