# Use Domino with Dynamic (/docs/build/dynamic)



If your app signs users in with [Dynamic](https://www.dynamic.xyz), they can use Domino's quests, points and leaderboards without a second login. Your server checks the user's Dynamic JWT and tells Domino who they are and which wallets Dynamic verified for them. Their embedded wallet and the wallets they connected join their Domino wallets, so wallet quests count them straight away.

This is [app login](/docs/build/app-login) with Dynamic's JWT 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 Dynamic
    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`.
* **Your Dynamic environment ID.** It is in the Dynamic dashboard, next to your API keys. Save it as `DYNAMIC_ENVIRONMENT_ID`. Use the sandbox environment's ID with Domino's Test environment and the live one with Live.

## Connect Dynamic [#connect-dynamic]

### 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 fetches the environment's public keys from Dynamic and accepts the JWT as an `Authorization: Bearer` header.

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

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

type Credential = {
  format?: string
  address?: string
  chain?: string
  wallet_provider?: string
}

const chains: Record<string, "eip155" | "solana"> = {
  eip155: "eip155",
  evm: "eip155",
  solana: "solana",
}

/** The wallets in a Dynamic JWT's `verified_credentials`, embedded wallets first, at most 20. */
export function dynamicWallets(credentials: Credential[]) {
  const embedded = (credential: Credential) =>
    credential.wallet_provider === "embeddedWallet"

  return credentials
    .filter(
      (credential) =>
        credential.format === "blockchain" &&
        credential.address &&
        chains[credential.chain ?? ""]
    )
    .sort((a, b) => Number(embedded(b)) - Number(embedded(a)))
    .slice(0, 20)
    .map((credential) => ({
      kind: "wallet" as const,
      chain: chains[credential.chain ?? ""],
      address: credential.address ?? "",
    }))
}

/** Verifies the Dynamic JWT the browser sends against the environment's public keys. */
export function dynamicAuthenticate(config: { environmentId: string }) {
  const jwks = createRemoteJWKSet(
    new URL(
      `https://app.dynamicauth.com/api/v0/sdk/${config.environmentId}/.well-known/jwks`
    )
  )

  return async (request: Request) => {
    const token = request.headers
      .get("Authorization")
      ?.match(/^Bearer (\S+)$/)?.[1]

    if (!token) {
      return null
    }

    try {
      const { payload } = await jwtVerify<{
        scope?: string
        verified_credentials?: Credential[]
      }>(token, jwks, {
        issuer: `app.dynamicauth.com/${config.environmentId}`,
        algorithms: ["RS256"],
      })

      // A session still waiting on MFA or a required form has no `user:basic` scope yet.
      if (!payload.sub || !payload.scope?.split(" ").includes("user:basic")) {
        return null
      }

      return {
        subject: payload.sub,
        accounts: dynamicWallets(payload.verified_credentials ?? []),
      }
    } 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
  DYNAMIC_ENVIRONMENT_ID: string
}) {
  backend ??= createParticipantBackend({
    baseUrl: "https://relay.domino.run",
    origin: "https://app.example.com",
    integrationKey: env.APP_IDENTITY_KEY,
    authenticate: dynamicAuthenticate({
      environmentId: env.DYNAMIC_ENVIRONMENT_ID,
    }),
  })

  return backend
}
```

The route signs nobody in for a missing, expired or forged token, one from another Dynamic environment, or one whose `scope` lacks `user:basic`: Dynamic issues that while the user still owes a multi-factor check or a required onboarding field.

> [!DEV] What the route sends
> - **Wallets only.** EVM and Solana wallets from `verified_credentials` entries with `format` `blockchain`. Email and social credentials, and wallets on chains Domino doesn't link, are left out, because Domino refuses the whole exchange when one account is invalid. Dynamic documents `eip155` as an EVM wallet's `chain`, and its SDK objects say `evm`, so the route accepts both.
> - **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 wallet whose `wallet_provider` is `embeddedWallet` first keeps it from being the one refused.
>
> 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 provider's keys are fetched once, not on every request.

### 2. Send the JWT from the browser [#2-send-the-jwt-from-the-browser]

Give Domino's browser client a `getToken` that returns Dynamic's JWT, and pass the client to `RelayProvider`. Use `getAuthToken()`, the full JWT: `getMinAuthToken()` returns a minified JWT that stays the same size however many wallets the user adds, so it cannot list them all.

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

// From @dynamic-labs/sdk-react-core: the JWT, or undefined when nobody is logged in.
declare function getAuthToken(): string | undefined

const relay = createBrowserRelayClient({
  baseUrl: "/relay",
  getToken: async () => getAuthToken() ?? null,
})

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

When the user logs in or out with Dynamic, for example in `DynamicContextProvider`'s `events.onAuthSuccess` and `events.onLogout`, call `useSession().refresh()` so the page reads the Domino session again, as [app login](/docs/build/app-login#3-connect-the-browser) explains.

### 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.

Dynamic doesn't hand out its embedded wallet as an EIP-1193 provider, so `evmSigner` doesn't fit. Wrap the wallet's own `signMessage` in a signer instead. Pick the embedded wallet from `useUserWallets()`, not `primaryWallet`, which is whichever wallet the user chose, such as MetaMask:

```tsx
import type { WalletSigner } from "@domino-sdk/relay"
import { useWallets } from "@domino-sdk/relay/react"

// From @dynamic-labs/sdk-react-core: useUserWallets().find((wallet) =>
// wallet.connector.isEmbeddedWallet && wallet.chain === "EVM").
declare const embeddedWallet: {
  address: string
  signMessage(message: string): Promise<string | undefined>
}

const dynamicSigner: WalletSigner = {
  chain: "eip155",
  name: "Dynamic",
  address: async () => embeddedWallet.address,
  async sign(message) {
    const signature = await embeddedWallet.signMessage(message)

    if (!signature) {
      throw new Error("The wallet returned no signature.")
    }

    return { signature }
  },
}

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

  return () => link(dynamicSigner)
}
```

The message states that it authorizes no transaction or transfer. A Dynamic smart wallet (`wallet_provider` `smartContractWallet`) proves itself the same way: Domino checks a [smart-contract wallet's signature](/docs/build/wallets#limits) on its chain. Give its signer a `chainId()` that returns the chain the wallet is on, such as `8453` for Base; without one, Domino checks the signature on Ethereum. See [Bring your own wallet provider](/docs/build/wallets#bring-your-own-wallet-provider) for the other signers.

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

* Log in with Dynamic and check that the app shows the member's Domino quests.
* In Console, open the member's page: **Wallets** lists the Dynamic 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 Dynamic and check that the member's data disappears.

> [!NOTE] Checked against Dynamic's documentation
> The route is tested against Dynamic's documented JWT (RS256, issuer `app.dynamicauth.com/<environment ID>`, the `user:basic` scope, `verified_credentials`) with a throwaway key served as the environment's JWKS. It has not yet been run against a live Dynamic environment. Which `chain` values Solana wallets carry in the JWT, and whether the minified JWT keeps any wallets, are not yet confirmed against one.

## 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.
