Wallet sign-in in your own Next.js app

Sign members in with Sign-In with Ethereum or Solana in an app you host, and develop it on localhost against real Domino.

Your Next.js app signs users in with their wallet, the way Scaffold-ETH 2, create-solana-dapp and most wagmi apps already do. Domino then uses that login: your server tells Domino which wallet signed in, and members collect quests and points under it without a second sign-in.

This is app login with a wallet as your app's user. Your server checks the wallet's signature itself with verifyWalletSignIn, so it works on any host, localhost included.

Before you start

  • An app login key. An organization admin creates one in Console under Settings → App login keys in Test, with the app's address as its origin. For local development that is http://localhost:3000; see develop on localhost. Save it in the server's environment as APP_IDENTITY_KEY.
  • Allow vouching on the key. Under Connected apps, choose Allow vouching on the key, give a reason, and confirm. This needs an organization admin with Publish permission. Your server can then attach the wallet to the member; see vouch for your members' accounts.
  • A session library. The examples keep the nonce and the signed-in wallet in an encrypted cookie session, such as iron-session's. appSession() stands for yours.

Sign in with Ethereum

Issue a nonce

The server issues a fresh nonce for each sign-in and keeps it in the session, and the verify route clears it before checking the message, so the browser can't send the same message twice. A cookie-only session, such as iron-session's, keeps no record of a cleared nonce: someone holding the earlier cookie and the signed message can replay both until the message expires, at most 10 minutes later. Store sessions on the server when each message must work once.

// app/api/wallet/nonce/route.ts
import { randomBytes } from "node:crypto"
import { appSession } from "./your-app-session"

export async function GET() {
  const session = await appSession()
  session.nonce = randomBytes(16).toString("hex")
  await session.save()

  return Response.json({ nonce: session.nonce })
}

Sign the message in the browser

Build an EIP-4361 message with viem for your page's host and the chain the wallet is on, and ask the wallet to sign it.

"use client"
import "viem/window"
import { createWalletClient, custom } from "viem"
import { createSiweMessage } from "viem/siwe"

export async function signInWithEthereum() {
  const wallet = createWalletClient({ transport: custom(window.ethereum!) })
  const [address] = await wallet.requestAddresses()
  const { nonce } = await (await fetch("/api/wallet/nonce")).json()
  const message = createSiweMessage({
    domain: window.location.host,
    address,
    statement: "Sign in to Booth.",
    uri: window.location.origin,
    version: "1",
    chainId: await wallet.getChainId(),
    nonce,
    issuedAt: new Date(),
    expirationTime: new Date(Date.now() + 5 * 60 * 1000),
  })
  const signature = await wallet.signMessage({ account: address, message })
  const answer = await fetch("/api/wallet/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message, signature }),
  })

  if (!answer.ok) {
    throw new Error("Wallet sign-in failed")
  }
}

With wagmi, take address and chainId from useAccount() and sign with useSignMessage().signMessageAsync({ message }). With RainbowKit, call the same two routes from your authentication adapter's getNonce and verify.

Verify it on the server

verifyWalletSignIn from @domino-sdk/relay/server checks the message without calling any network. It refuses it unless its domain is your origin's host, its nonce is the session's, it is no older than 10 minutes and not past its expiry, its chain is one of chains, and the signature is the address's.

// app/api/wallet/verify/route.ts
import { isWalletSignInError, verifyWalletSignIn } from "@domino-sdk/relay/server"
import { appSession } from "./your-app-session"

export async function POST(request: Request) {
  const session = await appSession()
  const { message, signature } = await request.json()
  const nonce = session.nonce
  // Clear the nonce before checking, so a refused message can't be retried with it.
  session.nonce = undefined
  await session.save()

  if (!nonce) {
    return Response.json({ error: "Ask for a nonce first" }, { status: 400 })
  }

  try {
    const signedIn = await verifyWalletSignIn({
      message,
      signature,
      origin: process.env.APP_ORIGIN!,
      nonce,
      chains: ["eip155:1", "eip155:8453"],
    })
    session.wallet = signedIn.account
    await session.save()

    return Response.json({ address: signedIn.account.address })
  } catch (error) {
    if (isWalletSignInError(error)) {
      return Response.json({ error: "Sign-in failed", code: error.code }, { status: 401 })
    }

    throw error
  }
}

APP_ORIGIN is the exact origin of the app, such as http://localhost:3000 or https://booth.example.com, the one the app login key names.

Sign in with Solana

Solana wallets that support Sign-In with Solana build and sign the message themselves: call the Wallet Standard solana:signIn feature with domain, nonce, uri and issuedAt, then send the signed message as text and the signature to the same verify route. Pass the signature's bytes or base58 as signature, and the Solana clusters you accept as chains, such as ["solana:mainnet"]. A message without a Chain ID line means mainnet. account is then { kind: "wallet", chain: "solana", address }.

Connect Domino

Mount createParticipantBackend in a catch-all route handler under /relay. Its authenticate reads the wallet from your session, signs the member in under it, and vouches for the wallet so quests that read a member's wallet see it.

// app/relay/[...path]/route.ts
import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy"
import { appSession } from "./your-app-session"

const participant = createParticipantBackend({
  baseUrl: "https://relay.domino.run",
  origin: process.env.APP_ORIGIN!,
  integrationKey: process.env.APP_IDENTITY_KEY!,
  async authenticate() {
    const { wallet } = await appSession()

    return wallet
      ? { subject: `${wallet.chain}:${wallet.address}`, accounts: [wallet] }
      : null
  },
})

export { participant as GET, participant as POST }

In the browser, use createBrowserRelayClient({ baseUrl: "/relay" }), or RelayProvider with it in React, as app login describes. After your verify route answers, and after your app signs out, call useSession().refresh().

The subject is your app's user ID for this person, here the wallet itself, so the same wallet always signs in as the same member.

Pay prizes to the wallet

Domino attaches the wallet your server vouches for as a vouched wallet: quests and trackers count it right away, but a leaderboard's payout list leaves it out, because Domino didn't see the wallet sign anything itself.

When a prize goes to the wallet, have the member link it once more through Domino. Call relay.wallets.link(signer) from the browser client, or link from useWallets(), with the same wallet. The wallet signs Domino's own message for your page's domain, and the wallet becomes one the member proved, which payout lists include.

Develop on localhost

A Test app login key accepts a loopback origin as well as HTTPS: http://localhost:<port>, http://127.0.0.1:<port> or http://[::1]:<port>. Create one for http://localhost:3000, set APP_ORIGIN=http://localhost:3000, and next dev signs members in to your project's Test environment against real Domino.

  • Live keys need HTTPS. Console refuses a loopback origin on a Live key: "Live app login keys need an HTTPS origin. Loopback origins such as http://localhost:3000 work only on Test keys."
  • Each port is its own origin. A key for http://localhost:3000 refuses requests from http://localhost:3001. Create one key per address the app runs at.
  • Other addresses on plain HTTP are refused. Console answers "Use an HTTPS origin, or a loopback origin such as http://localhost:3000 for a Test key." Reach the app through a tunnel with an HTTPS address, and create a key for that address, to test from a phone.

When you deploy, create a Live key for the deployed HTTPS address and set APP_ORIGIN to it.

On this page