Use Domino with Dynamic
Sign members in to Domino with the Dynamic login your app already has, link their Dynamic wallets automatically, and make the embedded wallet count for payouts.
If your app signs users in with Dynamic, 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 with Dynamic's JWT 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. - 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
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.
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.
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.
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 explains.
Link the embedded wallet for payouts
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.
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:
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 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 for the other signers.
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.
Where next
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.
Use Domino with Para
Sign members in to Domino with the Para login your app already has, link their Para wallets on EVM, Solana and Cosmos automatically, and make the embedded wallet count for payouts.