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:
- We cannot touch your trading funds. They are on your wallet at the venue, signed by your keys.
- 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:
| Field | Value | Why it is safe |
|---|---|---|
action | usdc-transfer-with-authorization | The only representable action. There is no free-form transfer. |
recipient | the configured AI provider address | Any other recipient is rejected with InvalidDelegationScope. |
capUsd | a number you choose | The total it may ever spend. |
expiresAt | a date you choose | After 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.
| Action | Signed by | Where | Key held by |
|---|---|---|---|
| Approve a Hyperliquid agent / builder fee | your master wallet | in your browser | you |
| Place or cancel an order | your wallet at the venue | in your browser | you |
| Move funds between spot and perp | your master wallet | in your browser | you |
| Pay an AI provider (USDC authorization) | the delegated signer | on our server, via Privy | Privy, 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 throughuseIsWalletConnected()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 callsfetch. It attaches the Bearer token, retries once with a fresh token on a401, and surfaces a typedHttpErrorunion. 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 theagent_walletsandagent_wallet_delegationstables, and exposes the routes below. The grant body is validated by a Zod schema that pinsactionto the single literal and lowercases and checks the recipient.
Routes
| Method and path | What it does |
|---|---|
GET /api/agent-treasury/wallet | Read your Agent Wallet (provisioning is idempotent). |
POST /api/agent-treasury/wallet | Provision your Agent Wallet if it does not exist yet. |
GET /api/agent-treasury/delegation | Read the current delegation status. |
POST /api/agent-treasury/delegation | Grant a scoped, capped, expiring delegation. |
POST /api/agent-treasury/delegation/revoke | Revoke 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).