# Use Domino with Privy (/docs/build/privy)



If your app signs users in with [Privy](https://privy.io), they can use Domino's quests, points and leaderboards without a second login. Your server checks the user's Privy identity token and tells Domino who they are and which wallets they hold. Their embedded wallet and every wallet they linked in Privy join their Domino wallets, so wallet quests count them straight away.

This is [app login](/docs/build/app-login) with Privy's token as the session. It needs a server route, such as a [Domino-hosted app's Worker](/docs/build/hosted-app) or your own backend.

```mermaid
sequenceDiagram
    actor User
    participant Browser as Your app
    participant Server as Your server
    participant Domino
    User->>Browser: Log in with Privy
    Browser->>Server: Token on /relay
    Server->>Server: Verify it
    Server->>Domino: User ID, wallets
    Domino-->>Browser: Quests
```

## Before you start [#before-you-start]

* **An app login key that vouches for accounts.** An organization admin [creates the key](/docs/build/app-login#1-create-an-integration-key) for your app's address and [turns on **Allow vouching**](/docs/build/app-login#turn-it-on). Your server reads it as `APP_IDENTITY_KEY`.
* **Identity tokens in Privy.** In the Privy Dashboard, open **User management → Authentication → Advanced** and turn on **Return user data in an identity token**. The wallets your server vouches for come from this token.
* **Your Privy app ID and verification key.** Both are in the Privy Dashboard. Save them as `PRIVY_APP_ID` and `PRIVY_VERIFICATION_KEY` next to `APP_IDENTITY_KEY`.

## Connect Privy [#connect-privy]

### 1. Add the server route [#1-add-the-server-route]

Mount this module on `/relay/*` in your server, with your app's address as `origin`. It accepts the identity token as an `Authorization: Bearer` header or as Privy's `privy-id-token` cookie.

<!-- example-source: apps/docs/examples/login-providers/privy.ts -->

```ts
import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy"
import { errors, importSPKI, jwtVerify } from "jose"

// Your app's Cosmos chain: Privy's token names no chain ID, and Domino needs one.
const cosmos = { network: "cosmos:cosmoshub-4", prefix: "cosmos" } as const

type LinkedAccount = {
  type: string
  address?: string
  chain_type?: string
  wallet_client_type?: string
  connector_type?: string
}

/** A wallet Domino can link, or null for a chain it doesn't take. */
function wallet(chainType: string | undefined, address: string) {
  if (chainType === "ethereum") {
    return { kind: "wallet", chain: "eip155", address } as const
  }

  if (chainType === "solana") {
    return { kind: "wallet", chain: "solana", address } as const
  }

  // Only your chain's addresses: another prefix is another Cosmos chain.
  if (
    chainType === "cosmos" &&
    address.slice(0, address.lastIndexOf("1")) === cosmos.prefix
  ) {
    return {
      kind: "wallet",
      chain: "cosmos",
      network: cosmos.network,
      address,
    } as const
  }

  return null
}

/** The wallets in an identity token's `linked_accounts`, embedded wallets first, at most 20. */
export function privyWallets(linkedAccounts: string) {
  const embedded = (account: LinkedAccount) =>
    account.wallet_client_type === "privy" ||
    account.connector_type === "embedded"

  return (JSON.parse(linkedAccounts) as LinkedAccount[])
    .filter((account) => account.type === "wallet")
    .sort((a, b) => Number(embedded(b)) - Number(embedded(a)))
    .map((account) => wallet(account.chain_type, account.address ?? ""))
    .filter((account) => account !== null)
    .slice(0, 20)
}

/** Verifies the Privy identity token the browser sends, as a header or Privy's cookie. */
export function privyAuthenticate(config: {
  appId: string
  verificationKey: string
}) {
  const key = importSPKI(config.verificationKey, "ES256")

  return async (request: Request) => {
    const token =
      request.headers.get("Authorization")?.match(/^Bearer (\S+)$/)?.[1] ??
      request.headers
        .get("Cookie")
        ?.match(/(?:^|;\s*)privy-id-token=([^;]+)/)?.[1]

    if (!token) {
      return null
    }

    try {
      const { payload } = await jwtVerify<{ linked_accounts?: string }>(
        token,
        await key,
        { issuer: "privy.io", audience: config.appId, algorithms: ["ES256"] }
      )

      return payload.sub
        ? {
            subject: payload.sub,
            accounts: privyWallets(payload.linked_accounts ?? "[]"),
          }
        : null
    } catch (error) {
      // A forged, expired or misaddressed token is a signed-out visitor. Anything else, such as
      // the provider's keys failing to load, is a fault.
      if (
        [
          errors.JWTClaimValidationFailed,
          errors.JWTExpired,
          errors.JWSSignatureVerificationFailed,
          errors.JWSInvalid,
          errors.JWTInvalid,
          errors.JWKSNoMatchingKey,
          errors.JOSEAlgNotAllowed,
        ].some((type) => error instanceof type)
      ) {
        return null
      }

      throw error
    }
  }
}

let backend: ReturnType<typeof createParticipantBackend> | undefined

/** The `/relay/*` handler, built once so the verifier keeps its keys between requests. */
export function participant(env: {
  APP_IDENTITY_KEY: string
  PRIVY_APP_ID: string
  PRIVY_VERIFICATION_KEY: string
}) {
  backend ??= createParticipantBackend({
    baseUrl: "https://relay.domino.run",
    origin: "https://app.example.com",
    integrationKey: env.APP_IDENTITY_KEY,
    authenticate: privyAuthenticate({
      appId: env.PRIVY_APP_ID,
      verificationKey: env.PRIVY_VERIFICATION_KEY,
    }),
  })

  return backend
}
```

The route signs nobody in for a missing, expired or forged token, or one issued to another Privy app. Domino gets the Privy user ID (`did:privy:…`) as the member's app identity.

> [!DEV] What the route sends
> - **Wallets only.** Ethereum and Solana wallets, plus Cosmos wallets on the chain you name in `cosmos`. Privy's token names no Cosmos chain, so the route vouches only addresses with your chain's prefix. Other chain types, such as Bitcoin, are left out, because Domino refuses the whole exchange when one account is invalid.
> - **Embedded wallet first, at most 20.** Domino attaches up to 5 wallets per chain family and refuses the rest with `limit-reached`, and an exchange carries at most 20 accounts. Sending the embedded wallet first keeps it from being the one refused.
> - **Privy's own check.** `@privy-io/node` can verify the token instead of jose: `privy.users().get({ id_token })` returns the user, with `linked_accounts` already parsed.
>
> On a Domino-hosted Worker, call it from your `fetch` handler: `if (new URL(request.url).pathname.startsWith("/relay/")) return participant(env)(request)`. `participant` builds the handler on the first request and reuses it, so the verification key is imported once, not on every request.

### 2. Send the token from the browser [#2-send-the-token-from-the-browser]

Give Domino's browser client a `getToken` that returns Privy's identity token, and pass the client to `RelayProvider`. Domino's client sends it with every request to `/relay`.

```tsx
import { createBrowserRelayClient } from "@domino-sdk/relay/browser"
import { RelayProvider } from "@domino-sdk/relay/react"
import type { ReactNode } from "react"

// From @privy-io/react-auth: the identity token, or null when nobody is logged in.
declare function getIdentityToken(): Promise<string | null>

const relay = createBrowserRelayClient({
  baseUrl: "/relay",
  getToken: getIdentityToken,
})

export function Community({ children }: { children: ReactNode }) {
  return <RelayProvider client={relay}>{children}</RelayProvider>
}
```

When Privy's login or logout finishes, read the Domino session again, as [app login](/docs/build/app-login#3-connect-the-browser) explains:

```tsx
import { useSession } from "@domino-sdk/relay/react"

// From @privy-io/react-auth.
declare function useLogin(callbacks: { onComplete: () => void }): { login: () => void }
declare function useLogout(callbacks: { onSuccess: () => void }): { logout: () => Promise<void> }

export function usePrivyLogin() {
  const { refresh } = useSession()
  const { login } = useLogin({ onComplete: () => void refresh() })
  const { logout } = useLogout({ onSuccess: () => void refresh() })

  return { login, logout }
}
```

### 3. Link the embedded wallet for payouts [#3-link-the-embedded-wallet-for-payouts]

A leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves out vouched wallets, because the member never proved them to Domino. To pay prizes to the embedded wallet, ask the member to sign Domino's message with it once. The signature proves the wallet, and it stays linked as if the member had linked it themselves.

```tsx
import { evmSigner, type Eip1193Provider } from "@domino-sdk/relay/browser"
import { useWallets } from "@domino-sdk/relay/react"

// From @privy-io/react-auth's useWallets(): the wallet whose walletClientType is "privy".
declare const embeddedWallet: {
  getEthereumProvider(): Promise<Eip1193Provider>
}

export function useProveEmbeddedWallet() {
  const { link } = useWallets()

  return async () =>
    link(evmSigner(await embeddedWallet.getEthereumProvider(), { name: "Privy" }))
}
```

Both libraries export a `useWallets`; import Privy's under another name. The message states that it authorizes no transaction or transfer. See [Bring your own wallet provider](/docs/build/wallets#bring-your-own-wallet-provider) for Solana wallets.

### 4. Check it [#4-check-it]

* Log in with Privy and check that the app shows the member's Domino quests.
* In Console, open the member's page: **Wallets** lists the Privy wallets, marked **Vouched by** your key's name.
* Prove the embedded wallet and check that it is no longer marked **Vouched by**.
* Log out of Privy and check that the member's data disappears.

> [!NOTE] Checked against Privy's documentation
> The route is tested against Privy's documented identity token (ES256, issuer `privy.io`, your app ID as audience, `linked_accounts` as a string) with a throwaway key. It has not yet been run against a live Privy app, and the embedded-wallet test (`wallet_client_type` `privy` or `connector_type` `embedded`) follows Privy's user object documentation.

## Where next [#where-next]

- [Use your app's login](/docs/build/app-login) — keys, refusals and how to test the login in a browser.
- [Wallets](/docs/build/wallets) — wallet quests, limits and what operators see.
