Perps DEX Aggregator

Self-custody

Why your funds never touch our servers, and the wallet boundaries that make that true.

Self-custody means one thing here: we cannot move your money, because we never hold the keys that would let us. This page explains how that holds up, from the login screen down to the exact thing our server is allowed to sign.

What it means for you

When you sign in, you are not creating an account on our servers in the usual sense. You authenticate with a wallet through Privy. That wallet stays under your control the whole time. Your trading collateral lives on your own wallet at the exchange, not in a balance we keep for you. There is no "deposit to our platform" step, because there is no platform balance.

There is exactly one wallet we provision for you, called the Agent Wallet. Its only job is to pay for AI trade suggestions. It holds a small amount of USDC for that purpose and nothing else. It never holds trading collateral, and it can never send money anywhere except the one AI provider you approve, up to a cap and an expiry date you set.

So there are two promises, and both are enforced by design rather than by policy:

  1. We cannot touch your trading funds. They are on your wallet at the venue, signed by your keys.
  2. The one wallet we help manage can only spend on AI calls, only to the approved recipient, only up to your cap, only until your expiry.

How sign-in works

Authentication runs through Privy in local-storage session mode. You can sign in two ways, and the difference matters for what gets signed where:

  • An external wallet, like MetaMask, that you already control. You linked it; you sign with it.
  • An embedded wallet that Privy provisions for you on email or passkey login. The private key is held by Privy in a way that never exposes it to our servers or our frontend.

Your identity across the whole system is your Privy DID, a string that looks like did:privy:.... We store it as the primary key of the users table and we do not mint our own user id on top of it. Every request from the browser to our server carries a fresh Privy JWT in an Authorization: Bearer header. The token is fetched per request, never cached across calls.

The server never trusts the client's word on identity

Our server verifies every JWT against Privy's public keys with jose, loaded once when the auth module starts. It does not call a Privy REST endpoint per request, and it never reads a user id out of the request body. The acting user always comes from the verified token.

The Agent Wallet, and why it is safe

The Agent Wallet is a self-custodial Privy embedded wallet, one per user. We provision it the first time you use the AI features. It is recorded in an agent_wallets table keyed by your Privy DID, storing the Privy wallet id and the wallet address. The private key stays inside Privy's signing boundary. Our server can ask Privy to sign a specific, narrow thing on your behalf, but it cannot read the key or sign anything outside the scope you granted.

That scope is the important part. When you turn on paid AI suggestions, you grant a delegation: a scoped, revocable permission for our server to trigger payments from your Agent Wallet without asking you to sign every single call. The delegation is stored in an agent_wallet_delegations table and it is deliberately tiny:

FieldValueWhy it is safe
actionusdc-transfer-with-authorizationThe only representable action. There is no free-form transfer.
recipientthe configured AI provider addressAny other recipient is rejected with InvalidDelegationScope.
capUsda number you chooseThe total it may ever spend.
expiresAta date you chooseAfter this, the delegation is dead.

You can revoke it at any time. Even while it is active, think about the worst case: if our server's delegated signer were fully compromised, the attacker could send your Agent Wallet's USDC to the one approved AI provider, up to your cap, until your expiry. They could not reach your trading collateral (it is on a different wallet entirely), and they could not redirect funds to themselves (the recipient is fixed). That bounded blast radius is the whole point of the design.

Where signing happens, and where it does not

The cleanest way to understand self-custody here is to look at who signs each kind of action. Notice that our server only appears on one row, and only for a single, scoped action.

ActionSigned byWhereKey held by
Approve a Hyperliquid agent / builder feeyour master walletin your browseryou
Place or cancel an orderyour wallet at the venuein your browseryou
Move funds between spot and perpyour master walletin your browseryou
Pay an AI provider (USDC authorization)the delegated signeron our server, via PrivyPrivy, scoped to your delegation

Order flow and fund movement never go through our backend. Your browser talks straight to the venue and signs with your keys. The only thing our server signs is a USDC payment authorization to the approved AI provider, and only because you granted a scoped delegation that says it may.

Funding a venue, the first time

Trading at a real venue like Hyperliquid needs a one-time readiness flow we call Venue Onboarding. It is client-side and it is signed by you. For Hyperliquid it is an ordered set of steps: a first deposit, then approving an agent wallet for signing, then approving the builder fee. Each approval is a separate action you sign with your own wallet.

Two of those steps protect signing and routing setup, so they re-derive live from the chain and can reopen if you tear the setup down (for example, if you revoke the agent). That is correct behaviour: you genuinely cannot route orders without that setup in place. The first deposit, by contrast, is a one-time milestone. Withdrawing all your funds later does not reopen it, because "have you ever funded this account" and "can this account trade right now" are two different questions. The second one, called Tradeable Funds, is a live check at the order button, not part of onboarding.

Under the hood

The pieces that implement all of the above:

  • Client auth lives only in apps/client/src/modules/account/. It is the single module allowed to import the Privy SDK; every other module reads connection state through useIsWalletConnected() and gets the JWT through a shared API client that attaches the token per call. The account module is openly coupled to Privy on purpose, with no port or adapter wrapping it.
  • The HTTP transport in apps/client/src/modules/shared/http/ is the one place that calls fetch. It attaches the Bearer token, retries once with a fresh token on a 401, and surfaces a typed HttpError union. Endpoint wrappers never touch the token themselves.
  • The Agent Wallet and delegation live in apps/server/src/agent-treasury/. It provisions the embedded wallet, owns the agent_wallets and agent_wallet_delegations tables, and exposes the routes below. The grant body is validated by a Zod schema that pins action to the single literal and lowercases and checks the recipient.

Routes

Method and pathWhat it does
GET /api/agent-treasury/walletRead your Agent Wallet (provisioning is idempotent).
POST /api/agent-treasury/walletProvision your Agent Wallet if it does not exist yet.
GET /api/agent-treasury/delegationRead the current delegation status.
POST /api/agent-treasury/delegationGrant a scoped, capped, expiring delegation.
POST /api/agent-treasury/delegation/revokeRevoke it immediately.

The grant contract

// apps/server/src/agent-treasury/agent-treasury.dto.ts
export const grantDelegationSchema = z.object({
  action: z.literal('usdc-transfer-with-authorization'),
  recipient: z
    .string()
    .regex(/^0x[a-fA-F0-9]{40}$/)
    .transform((address) => address.toLowerCase()),
  capUsd: z.string().regex(/^\d+(\.\d+)?$/),
  expiresAt: z.string().datetime(),
});

The schema is the boundary. A request that names any action other than the USDC authorization cannot be represented, and the service rejects any recipient other than the configured AI provider before a signer is ever asked to sign.

Design records

The decisions behind this are written up as ADRs in the repo: the agent-wallet signing boundary (ADR-0012), x402 payments via a self-custodial delegated wallet (ADR-0044), the venue onboarding port (ADR-0026), and the split between First Deposit and Tradeable Funds (ADR-0027).