Use Domino with Privy

Sign members in to Domino with the Privy login your app already has, link their Privy wallets automatically, and make the embedded wallet count for payouts.

If your app signs users in with Privy, 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 with Privy's token as the session. It needs a server route, such as a Domino-hosted app's Worker or your own backend.

Before you start

  • An app login key that vouches for accounts. An organization admin creates the key for your app's address and turns on Allow vouching. 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

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.

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.

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.

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 explains:

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 }
}

A leaderboard's 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.

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 for Solana wallets.

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.

Where next

On this page