# Privy Wallet Architecture

A deep-dive into how **Privy** wallets work — both **embedded (self-custodial)** and **server (custodial)** wallets — including architecture diagrams, key-management model, signing/authorization flows, and how each is implemented. The server-wallet sections map directly to what this repo builds (`backend/` + `frontend/`).

* * *

## 1\. TL;DR — the two custody models

|  | **Embedded wallet** | **Server wallet** |
| --- | --- | --- |
| Who can sign | The **end user** (authenticated session) | Your **backend** (authorization key) |
| Custody | **Self-custodial** (non-custodial) | **Custodial** (you control) |
| Key material | Split / secured so only the user can authorize | Held in Privy TEEs; actions authorized by *your* key |
| SDK | `@privy-io/react-auth` (client) | `@privy-io/server-auth` (backend) |
| Created | On user login, in the browser | Programmatically from your server |
| User needed per tx? | **Yes** — user authorizes | **No** — server signs unilaterally |
| Best for | Consumer self-custody UX | Platform-managed wallets, automation, "wallet-as-a-feature" |

**Rule of thumb:** if *your platform* must be able to move funds without the user present, you need **server wallets owned by an authorization key**. That is the custodial model this project implements.

* * *

## 2\. Where wallets live: the trust boundary

Both wallet types keep the raw private key **out of your app and out of your database**. Keys are generated and used inside Privy's secure infrastructure (TEEs — Trusted Execution Environments). What differs is **who is allowed to authorize a signature**.

```markdown
flowchart TB
    subgraph Client["🖥️ Browser / Your Frontend"]
        FE["React app<br/>@privy-io/react-auth"]
    end
    subgraph YourBackend["🏢 Your Backend (trusted)"]
        BE["NestJS API<br/>@privy-io/server-auth<br/>APP_SECRET + AUTH_KEY"]
    end
    subgraph Privy["🔐 Privy Infrastructure"]
        API["Privy API"]
        TEE["TEE / secure enclave<br/>(raw private keys never leave)"]
        API --- TEE
    end
    subgraph Chain["⛓️ Blockchain"]
        RPC["RPC / network<br/>(e.g. Hoodi eip155:560048)"]
    end

    FE -- "embedded: user authorizes" --> API
    BE -- "server: authorization-key signs" --> API
    TEE -- "broadcasts signed tx" --> RPC
```

The private key is **never** exposed to your servers, your database, or the browser in plaintext — you interact with the wallet through authorized API calls.

* * *

## 3\. Embedded wallets (self-custodial)

### 3.1 What they are

A wallet provisioned **for the user, controlled by the user**. Privy secures the key so that only the authenticated user (their session/device/passkey) can authorize its use. Your app — and Privy — cannot unilaterally move funds. This is the model behind "sign in and you magically have a wallet, no seed phrase."

### 3.2 Key-management model

Privy protects the key with a combination of **secure enclaves (TEEs)** and **key-splitting** so that no single party holds a usable key. Conceptually the authority to use the key is gated by the user's authenticated session.

```mermaid
flowchart LR
    subgraph User["Authenticated user"]
        S["Session / passkey / device"]
    end
    subgraph PrivyTEE["Privy TEE"]
        K["Private key<br/>(usable only when the user authorizes)"]
    end
    S -- "authorizes signature" --> K
    K -- "returns signature" --> S
    style K fill:#eef,stroke:#66f
```

*   **Non-custodial:** the user is the owner; the key is bound to their auth.
    
*   **Recoverable:** via the login method + optional recovery (passkey, password).
    
*   **Exportable:** the user can export their private key (they truly own it).
    

### 3.3 Login → wallet → sign flow

```mermaid
sequenceDiagram
    autonumber
    participant U as User
    participant FE as Frontend (react-auth)
    participant P as Privy

    U->>FE: Click "Log in" (email / Google / passkey)
    FE->>P: Authenticate
    P-->>FE: Authenticated (user DID, session)
    Note over FE,P: createOnLogin provisions an embedded wallet
    P-->>FE: Embedded wallet ready (address)

    U->>FE: Action needs a signature (sign / send)
    FE->>P: Request signature (user session authorizes)
    P-->>FE: Signature / tx hash
    FE-->>U: Done
```

### 3.4 Implementation (client-side)

Enable creation on login and let the SDK manage everything in the browser:

```tsx
// main.tsx
<PrivyProvider
  appId={appId}
  config={{
    loginMethods: ['email', 'google', 'passkey'],
    embeddedWallets: {
      ethereum: { createOnLogin: 'users-without-wallets' }, // auto-provision
    },
  }}
>
  <App />
</PrivyProvider>
```

```tsx
// Using the wallet in a component
import { usePrivy, useWallets } from '@privy-io/react-auth';

const { wallets } = useWallets();
const wallet = wallets[0];
const provider = await wallet.getEthereumProvider();
await provider.request({
  method: 'eth_sendTransaction',
  params: [{ to, value }], // user is prompted / authorizes
});
```

> In **this repo** embedded wallet creation is deliberately **disabled** (`createOnLogin: 'off'`) because we provision *server* wallets instead — see §4.

* * *

## 4\. Server wallets (custodial) — what this repo builds

### 4.1 What they are

Wallets your **backend** creates and controls. The wallet's **owner** is a **server authorization key** (a P-256 key held by you). Your backend signs actions with the authorization private key — no user signature required per transaction. That is what makes it **custodial**.

### 4.2 The three owner options (critical)

When creating a wallet you choose its **owner** — this decides who can sign:

```mermaid
flowchart TB
    W["New wallet"] --> Q{"owner = ?"}
    Q -->|"owner.user_id"| U["👤 User-owned<br/>only the USER can sign<br/>(needs user JWT / session)"]
    Q -->|"owner.public_key"| PK["🔑 secp256k1 chain key<br/>(external signer)"]
    Q -->|"owner_id (key quorum id)"| AK["🏢 Authorization-key owned<br/>your SERVER signs<br/>✅ custodial"]
    style AK fill:#e8f8ee,stroke:#0a7d3b
    style U fill:#fdecec,stroke:#c33
```

> **This was the crux of the debugging in this project.** A wallet created with `owner: { userId }` can only be signed by the *user* → server sends fail with `401 No valid authorization keys or user signing keys available`. The fix is to own the wallet with your authorization key via `ownerId` (the key-quorum id).

### 4.3 Authorization keys & key quorums

*   An **authorization key** is a P-256 keypair. Its **id** (a key-quorum id) is used as `ownerId`; its **private key** signs requests (configured on the SDK client).
    
*   A **key quorum** can hold **multiple keys with a threshold** (m-of-n) — e.g. require 2 of 3 backend keys, or (user key OR server key) as 1-of-2. This is how you harden custody.
    

```mermaid
flowchart LR
    subgraph Quorum["Owner = Key Quorum (m-of-n)"]
        A["Auth key A (server)"]
        B["Auth key B (server)"]
        C["User key"]
    end
    Quorum -->|"threshold met"| Sign["✅ Signature authorized"]
```

### 4.4 Register → provision → send flow (custodial)

```mermaid
sequenceDiagram
    autonumber
    participant U as User
    participant FE as Frontend
    participant BE as Your Backend
    participant P as Privy
    participant C as Chain (Hoodi)

    U->>FE: Log in (Google / email OTP)
    FE->>P: Authenticate
    P-->>FE: access token (user DID)

    FE->>BE: POST /auth/sync  (Bearer access token)
    BE->>P: verifyAuthToken(token)
    P-->>BE: { userId }
    BE->>P: walletApi.createWallet({ chainType, ownerId: AUTH_KEY })
    P-->>BE: { id, address }  (owned by your authorization key)
    BE-->>FE: { user, wallet, chain }

    U->>FE: Send 0.01 ETH to 0x…
    FE->>BE: POST /wallet/send { to, amount }
    BE->>P: walletApi.ethereum.sendTransaction({ walletId, caip2, transaction })
    Note over BE,P: signed with AUTHORIZATION_PRIVATE_KEY (no user needed)
    P->>C: broadcast
    C-->>P: tx hash
    P-->>BE: { hash }
    BE-->>FE: { hash, explorerUrl }
```

### 4.5 Implementation (backend)

**Client init — load the authorization private key so the SDK can sign:**

```ts
// privy.service.ts
this.client = new PrivyClient(appId, appSecret, {
  timeout: 60000,
  walletApi: { authorizationPrivateKey: process.env.PRIVY_AUTHORIZATION_PRIVATE_KEY },
});
```

**Create a custodial wallet owned by the authorization key (key quorum id):**

```ts
const ownerId = process.env.PRIVY_AUTHORIZATION_KEY_ID; // key-quorum id
const wallet = await this.client.walletApi.createWallet({ chainType, ownerId });
// wallet.id, wallet.address  → store mapping user -> wallet in YOUR db
```

**Send a transaction — server signs, no user in the loop:**

```ts
const { hash } = await this.client.walletApi.ethereum.sendTransaction({
  walletId,
  caip2: 'eip155:560048',                 // Hoodi testnet
  transaction: { to, value: `0x${wei.toString(16)}` },
});
```

**Read balance (via your own RPC, not Privy):**

```ts
const wei = await publicClient.getBalance({ address }); // viem + Hoodi RPC
```

### 4.6 Guardrails: policies

Attach a **policy** to a wallet (`policyIds`) to constrain what the authorization key may do — recipient allowlists, max amount, contract/method restrictions. A policy does **not** grant signing authority (that's the owner/authorization key); it *limits* an already-authorized signer.

```ts
await this.client.walletApi.createWallet({
  chainType,
  ownerId,
  policyIds: ['<policy-id>'], // e.g. cap amount, allowlist recipients
});
```

* * *

## 5\. Side-by-side: request authorization

```mermaid
flowchart TB
    subgraph E["Embedded (self-custodial)"]
        direction TB
        e1["User session"] --> e2["authorizes"] --> e3["Privy signs"]
    end
    subgraph S["Server (custodial)"]
        direction TB
        s1["Backend"] --> s2["authorization key signs request"] --> s3["Privy signs"]
    end
```

| Dimension | Embedded | Server |
| --- | --- | --- |
| Signing authority | User's authenticated session | Your authorization key (P-256) |
| Where signing is triggered | Browser | Backend |
| Can your platform act alone? | ❌ No | ✅ Yes |
| Where the secret lives | Nothing sensitive in your app | `AUTHORIZATION_PRIVATE_KEY` on server (guard like a root secret) |
| Regulatory weight | Lower (user holds funds) | Higher (you hold custody) |
| Typical use | Consumer self-custody | Fleets, automation, backend-driven payments |

* * *

## 6\. Security model & production hardening

**Embedded**

*   Key bound to user auth; recoverable via login method + recovery factor (passkey/password).
    
*   Compromise scope is a single user, gated by their session.
    

**Server (custodial)**

*   Your **authorization private key is the master control** — anyone with it can move funds in every wallet it owns. Treat it like a root credential:
    
    *   Store in a **KMS/HSM/secrets manager**, never a committed `.env`.
        
    *   Prefer a **key quorum (m-of-n)** so one leaked key isn't enough.
        
    *   Enforce **policies** (amount caps, allowlists) as a second line of defense.
        
    *   Rotate keys; log and monitor every signing request; add rate limits.
        
*   **Custody = legal weight.** Holding user assets may trigger money-transmitter / VASP obligations depending on jurisdiction — validate before launch.
    

* * *

## 7\. This project's architecture (as built)

Server (custodial) wallets, Google + email-OTP login, Hoodi testnet.

```mermaid
flowchart LR
    subgraph FE["frontend/ (Vite + React 19)"]
        L["LoginScreen<br/>Google / email OTP"]
        WA["WalletActions<br/>balance + send"]
    end
    subgraph BE["backend/ (NestJS 11)"]
        G["PrivyAuthGuard<br/>verifyAuthToken"]
        AUTH["AuthService<br/>sync + provision"]
        WAL["WalletService<br/>balance / send"]
        US["UsersService<br/>data/users.json"]
    end
    subgraph P["Privy (server-auth)"]
        WAPI["walletApi<br/>ownerId = auth key"]
    end
    RPC["Hoodi RPC<br/>eip155:560048"]

    L -->|access token| G
    WA -->|Bearer token| G
    G --> AUTH --> WAPI
    AUTH --> US
    WA --> WAL
    WAL -->|sendTransaction| WAPI
    WAL -->|getBalance| RPC
    WAPI -->|broadcast| RPC
```

**Key components**

*   `backend/src/privy/privy.service.ts` — Privy client, `createWallet({ ownerId })`, `sendTransaction`.
    
*   `backend/src/auth/*` — verify token, sync user, idempotent wallet provisioning.
    
*   `backend/src/wallet/*` — balance (viem RPC) + send endpoints.
    
*   `backend/src/users/users.service.ts` — persistent user→wallet mapping (`data/users.json`).
    
*   `frontend/src/LoginScreen.tsx`, `WalletActions.tsx` — auth + balance/send UI.
    

**Custody config (env)**

| Var | Role |
| --- | --- |
| `PRIVY_APP_ID` / `PRIVY_APP_SECRET` | App identity (server-only secret) |
| `PRIVY_AUTHORIZATION_KEY_ID` | Key-quorum id → wallet `ownerId` |
| `PRIVY_AUTHORIZATION_PRIVATE_KEY` | Signs wallet RPC requests |
| `EVM_CHAIN_*` | Active network (Hoodi `560048`) |

* * *

## 8\. Decision guide

```mermaid
flowchart TD
    Q1{"Must your platform move<br/>funds without the user?"}
    Q1 -->|No| E["Embedded wallets<br/>(self-custodial)"]
    Q1 -->|Yes| Q2{"One shared treasury<br/>or one wallet per user?"}
    Q2 -->|Per user| SV["Server wallets, ownerId = auth key<br/>+ policies + quorum"]
    Q2 -->|Treasury| TR["Server wallet(s)<br/>owned by a key quorum"]
    style E fill:#eef,stroke:#66f
    style SV fill:#e8f8ee,stroke:#0a7d3b
```

*   **Want users to truly own their wallet, no custody liability?** → Embedded.
    
*   **Building a platform that manages wallets for a set of users (this project)?** → Server wallets owned by an authorization key, hardened with a quorum + policies.
    

* * *

## 9\. References

*   Privy wallets overview — [https://docs.privy.io/wallets/overview](https://docs.privy.io/wallets/overview)
    
*   Create a wallet (API) — [https://docs.privy.io/api-reference/wallets/create](https://docs.privy.io/api-reference/wallets/create)
    
*   Owners, authorization keys & key quorums — [https://docs.privy.io/controls/authorization-keys/owners/types](https://docs.privy.io/controls/authorization-keys/owners/types)
    
*   Signing on the server — [https://docs.privy.io/controls/authorization-keys/using-owners/sign/signing-on-the-server](https://docs.privy.io/controls/authorization-keys/using-owners/sign/signing-on-the-server)
    
*   Gas sponsorship — [https://docs.privy.io/wallets/gas-and-asset-management/gas/overview](https://docs.privy.io/wallets/gas-and-asset-management/gas/overview)
