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.
If your app signs users in with Para, they can use Domino's quests, points and leaderboards without a second login. Your server checks a Para JWT and tells Domino who the user is and which wallets Para holds for them. Their Para wallets join their Domino wallets, so wallet quests count them straight away.
This is app login with Para'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 Para API key's ID. Para sets each JWT's audience (
aud) to the ID of the API key that issued it, so the route only accepts your app's tokens. Save it asPARA_API_KEY_ID. - Para's environment. The route reads Para's production keys from
https://api.getpara.com. With a beta API key, changeapiUrltohttps://api.beta.getpara.com.
Connect Para
Add the server route
Mount this module on /relay/* in your server, with your app's address as origin, and set cosmos to your app's Cosmos chain. It fetches Para's public keys and accepts the JWT as an Authorization: Bearer header.
import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy"
import { createRemoteJWKSet, errors, jwtVerify } from "jose"
// Your app's Cosmos chain: Para's token names no chain ID, and Domino needs one.
const cosmos = { network: "cosmos:cosmoshub-4", prefix: "cosmos" } as const
type ParaWallet = { type?: string; address?: string }
/** A wallet Domino can link, or null for a chain it doesn't take. */
function wallet({ type, address = "" }: ParaWallet) {
if (type === "EVM") {
return { kind: "wallet", chain: "eip155", address } as const
}
if (type === "SOLANA") {
return { kind: "wallet", chain: "solana", address } as const
}
// Only your chain's addresses: another prefix is another Cosmos chain.
if (
type === "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 a Para JWT: its embedded `wallets` first, then the external wallet the user
* signed in with, at most 20.
*/
export function paraWallets(data: {
wallets?: ParaWallet[]
externalWallet?: ParaWallet
}) {
return [...(data.wallets ?? []), data.externalWallet]
.map((entry) => (entry ? wallet(entry) : null))
.filter((account) => account !== null)
.slice(0, 20)
}
/** Verifies the JWT from Para's `issueJwt()` against Para's public keys and your API key's ID. */
export function paraAuthenticate(config: {
/** The `aud` of your app's tokens: your Para API key's ID. */
apiKeyId: string
/** `https://api.getpara.com`, or `https://api.beta.getpara.com` for Para's beta environment. */
apiUrl: string
}) {
const jwks = createRemoteJWKSet(
new URL("/.well-known/jwks.json", config.apiUrl)
)
return async (request: Request) => {
const token = request.headers
.get("Authorization")
?.match(/^Bearer (\S+)$/)?.[1]
if (!token) {
return null
}
try {
const { payload } = await jwtVerify<{
data?: { wallets?: ParaWallet[]; externalWallet?: ParaWallet }
}>(token, jwks, { audience: config.apiKeyId })
return payload.sub
? { subject: payload.sub, accounts: paraWallets(payload.data ?? {}) }
: 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
PARA_API_KEY_ID: string
}) {
backend ??= createParticipantBackend({
baseUrl: "https://relay.domino.run",
origin: "https://app.example.com",
integrationKey: env.APP_IDENTITY_KEY,
authenticate: paraAuthenticate({
apiKeyId: env.PARA_API_KEY_ID,
apiUrl: "https://api.getpara.com",
}),
})
return backend
}The route signs nobody in for a missing, expired or forged token, or one issued for another Para API key. Domino gets the Para user ID as the member's app identity.
Send the JWT from the browser
Give Domino's browser client a getToken that returns a Para JWT, and pass the client to RelayProvider. Each issueJwt() call asks Para's API for a new token and extends the Para session, so keep the token until shortly before it expires; it lasts as long as your Para session length, 30 minutes by default.
import { createBrowserRelayClient } from "@domino-sdk/relay/browser"
import { RelayProvider } from "@domino-sdk/relay/react"
import type { ReactNode } from "react"
// From @getpara/react-sdk or @getpara/web-sdk: the Para client your app created.
declare const para: {
isFullyLoggedIn(): Promise<boolean>
issueJwt(): Promise<{ token: string }>
}
let issued: { token: string; expiresAt: number } | undefined
async function paraToken() {
if (!(await para.isFullyLoggedIn())) return null
if (!issued || issued.expiresAt < Date.now() + 60_000) {
const { token } = await para.issueJwt()
const { exp } = JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")))
issued = { token, expiresAt: exp * 1000 }
}
return issued.token
}
const relay = createBrowserRelayClient({ baseUrl: "/relay", getToken: paraToken })
export function Community({ children }: { children: ReactNode }) {
return <RelayProvider client={relay}>{children}</RelayProvider>
}When the user logs in or out with Para, clear issued on logout and 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 EVM 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.
Para doesn't hand out its embedded wallet as an EIP-1193 provider, so evmSigner doesn't fit. It signs EVM messages through its viem account instead, so wrap that in a signer:
import type { WalletSigner } from "@domino-sdk/relay"
import { useWallets } from "@domino-sdk/relay/react"
// From @getpara/viem-v2-integration: createParaAccount(para), a viem account.
declare const account: {
address: `0x${string}`
signMessage(input: { message: string }): Promise<`0x${string}`>
}
const paraSigner: WalletSigner = {
chain: "eip155",
name: "Para",
address: async () => account.address,
sign: async (message) => ({ signature: await account.signMessage({ message }) }),
}
export function useProveEmbeddedWallet() {
const { link } = useWallets()
return () => link(paraSigner)
}The message states that it authorizes no transaction or transfer. See Bring your own wallet provider for the other signers.
Check it
- Log in with Para and check that the app shows the member's Domino quests.
- In Console, open the member's page: Wallets lists the Para 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 Para and check that the member's data disappears.
Where next
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.
Use Domino with Reown AppKit
Let members sign in to Domino with the wallet they connect through Reown AppKit, WalletConnect included, and link more wallets the same way.