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
type | What to call | Submit |
|---|---|---|
eip712 | signTypedData(payload) | signature |
hl_l1_action | signTypedData(payload), EIP-712 for a Hyperliquid action | signature |
personal_sign | signMessage({ 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_authorization | signAuthorization(payload) over { contractAddress, chainId, nonce }, then serialize to a 65-byte signature | signature |
safe_tx | signMessage({ message: { raw: payload.safeTxHash } }), a personal_sign over the raw 32-byte SafeTx hash. Used for a legacy Polymarket Safe. | signature |
transaction | Switch the wallet to payload.chainId, then send { to, value, data } from signerAddress | txHash |
solana_transaction | Sign 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:
| Request | Deadline |
|---|---|
| Hyperliquid order | 2 minutes |
| Hyperliquid builder-fee approval | 90 seconds |
| Polymarket order pair | 90 seconds |
| Limitless registration and order | 1 minute |
| Predict auth and order | 1 minute |
| Myriad sign-in and order | 1 minute |
| TipRun registration, deposit permit and order | 1 minute |
| Bridge signatures | 2 minutes |
| Polymarket setup | 5 minutes |
| Polymarket fund transfer | 2 minutes |
Polymarket fee after the fill (app_fee) | 30 minutes |
transaction | 15 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
| Situation | Prompts |
|---|---|
| Hyperliquid order | 1 |
| Hyperliquid builder-fee approval | 1, until granted for that wallet. A saved builder address is approved the same way. See Builder codes. |
| Polymarket order | 2, always a pair, buy or sell |
| Limitless order | 1, plus 1 personal_sign registration the first time that wallet trades on Limitless |
| Predict order | 2: a personal_sign auth message, then the eip712 order |
| Myriad order | 2: 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 order | 1 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 approvals | 1 transaction per missing approval, once per wallet and exchange contract. See Venues. |
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 new deposit wallet, buy or sell | 1. Sets up approvals; a buy's wrap rides in the same signature. |
| Any later sell | 0 |
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.
| Situation | Prompts |
|---|---|
| Hyperliquid order paid from a Hyperliquid balance | 0 |
| Polymarket order paid from USDC.e or pUSD already in the deposit wallet | 0, 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 address | 1 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 signed | 2 |
| Cross-chain from a token needing an approval, default mode, first time on that chain | 3 |
Cross-chain from Polygon USDT or USDC.e, user_broadcast | First trade: 2 (one transaction, one signature). Later trades: 1 (permit signature only) |
Cross-chain from another token needing an approval, user_broadcast | First 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 venue | 2 |
| Hyperliquid order paid only from pUSD in the Polymarket deposit wallet | 1 |
| Hyperliquid order paid from the deposit wallet together with another source | 1 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 enabled | 0 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.
| Situation | Prompts |
|---|---|
| Same chain and token from the wallet, default mode | 1 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 mode | 1 permit |
| To another chain from a token needing an approval, default mode | 2, or 3 before the first authorization |
| HyperCore to HyperCore | 1 eip712 sendAsset |
| HyperCore to another chain | 2 eip712 |
| From the deposit wallet, to another chain or Polygon token, where enabled | 1 eip712 |
| From the deposit wallet, otherwise | 1 eip712 that moves the funds into the wallet, then the wallet's own row above |
| From a legacy Safe | 1 safe_tx |
| From a legacy proxy wallet | 1 personal_sign ({ raw }) |
Driving the loop yourself
Without fillSelfCustody, run this loop:
POST /execution/fillwithquoteIdandsigningAddress. The response never carriespendingSignatures, even for self-custody.- Poll
GET /execution/status?quoteId=, waitingpollAfterMsbetween calls. - For each new
pendingSignatures[]entry, have the wallet named bysignerAddresssign or send the payload. - Submit the results with the call below. Up to 32 entries. Send
signatureortxHash, not both. The response has the same shape as the fill response. - Repeat from step 2 until
terminalistrue.
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:
| Status | Message | Meaning |
|---|---|---|
403 | no such fill for this account | Unknown quote, or not this user's. The two look the same on purpose. |
404 | no pending request for step … | The stepId does not match a waiting request. |
400 | request for step … has expired — re-quote and retry | The request is dead. 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 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.