Integrate Delegate Cash in your app to validate ownership at sign up with Next.Auth

If you follow me you know that I created NW3 a Next.js boilerplate to quickly build your token gated saas. In this boilerplate we use Next.Auth and Sign in with Ethereum to authenticate our users and validate the ownership of a particular token. But what if we want to let the user sign up with a different wallet than the one which held the token ? This is what we are going to see in this article and we will do it directly in NW3.

The current functioning

To do the verification we use a Credential Provider from Next.Auth. The credentials are a message and the signature of this message. When we receive the credentials we verify the signature and the message with the help of the Sign in with Ethereum library. If it's valid we use an Ownership service to check if the user owns the token required. And periodically we check if the user still owns the token with the same service. Here how it looks like:

The full Next.Auth config :

export const siweTokenGatedAuthOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: 'Ethereum',
      credentials: {
        message: {
          label: 'Message',
          type: 'text',
          placeholder: '0x0',
        } as CredentialInput,
        signature: {
          label: 'Signature',
          type: 'text',
          placeholder: '0x0',
        },
      },
      async authorize(credentials, req) {
        const siwe = new SiweMessage(JSON.parse(credentials?.message || '{}'))
        const nextAuthUrl = new URL(config.NEXTAUTH_URL)
        const crsf = await getCsrfToken({ req } as any)
        const result = await siwe.verify({
          signature: credentials?.signature || '',
          domain: nextAuthUrl.host,
          nonce: crsf,
        })

        if (result.success) {
          const ownerShipResult = await OwnerShipService.verifyOwnership(
            siwe.address
          )
          if (
            config.TOKEN_GATED_REQUIRED_TO_LOGIN &&
            !ownerShipResult.isOwner
          ) {
            throw new Error(
              `${locales.tokenRequired} : ${config.TOKEN_GATED_ADDRESS}`
            )
          }

          return {
            id: siwe.address,
            image: new Identicon(siwe.address).toString(),
            address: siwe.address,
            name: formatAddress(siwe.address),
            isOwner: ownerShipResult.isOwner,
            lastValidation: ownerShipResult.lastValidation,
          } as User
        }
        return null
      },
    }),
  ],
  pages: {
    signIn: routes.signIn,
  },
  session: {
    strategy: 'jwt',
  },
  secret: config.NEXTAUTH_SECRET,
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.address = user.address
        token.isOwner = (user as any).isOwner
        token.lastValidation = Date.now()
      } else {
        const ownerShipResult = await OwnerShipService.verifyIfNeeded(
          token.address,
          token.lastValidation,
          token.isOwner
        )
        token.isOwner = ownerShipResult.isOwner
        token.lastValidation = ownerShipResult.lastValidation
      }
      return token
    },
    async session({ session, token }) {
      session.user.address = token.address
      return session
    },
  },
}

And the ownership service :

import { Alchemy, Network } from 'alchemy-sdk'

import { config } from '@/config'

export class OwnerShipService {
  static async verifyOwnership(address: string) {
    const contract = config.TOKEN_GATED_ADDRESS

    const alchemy = new Alchemy({
      apiKey: config.ALCHEMY_API_KEY,
      network: config.TOKEN_GATED_NETWORK,
      maxRetries: 0,
    })

    const isOwner = await alchemy.nft.verifyNftOwnership(address, contract)
    return {
      isOwner,
      lastValidation: Date.now(),
    }
  }

  static async verifyIfNeeded(
    address: string,
    lastValidation: number | null | undefined,
    isOwner: boolean | null | undefined
  ) {
    if (
      lastValidation &&
      lastValidation <
        Date.now() - config.TOKEN_GATED_ELAPSED_TIME_BETWEEN_CONTROL
    ) {
      return await OwnerShipService.verifyOwnership(address)
    }

    return {
      isOwner: isOwner ?? false,
      lastValidation,
    }
  }
}

Intregation of Delegate Cash

To integrate Delegate Cash we will use the Delegate Cash SDK

First we need to install the SDK :

yarn add delegatecash

To use the SDK with a custom ether provider we normally need to install ehters package but we already have it in our project so we can use it directly.

Here how to setup the sdk :

import { ethers } from 'ethers'
import { DelegateCash } from 'delegatecash'
const provider = new ethers.providers.JsonRpcProvider(
  `https://eth-mainnet.alchemyapi.io/v2/${config.ALCHEMY_API_KEY}}`,
  'any'
)
const dc = new DelegateCash(provider)

Now to be able to validate the ownership from a particular vault wallet we need to have it when user try to sign up. To do that we need to update our credential provider :

export const siweTokenGatedAuthOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "Ethereum",
      credentials: {
        message: {
          label: "Message",
          type: "text",
          placeholder: "0x0",
        } as CredentialInput,
        signature: {
          label: "Signature",
          type: "text",
          placeholder: "0x0",
        },
        vault: {
          label: "Vault Address",
          type: "text",
          placeholder: "0x0",
        },
      },
      async authorize(credentials, req) {
        const siwe = new SiweMessage(JSON.parse(credentials?.message || '{}'))
        const nextAuthUrl = new URL(config.NEXTAUTH_URL)
        const crsf = await getCsrfToken({ req } as any)
        const result = await siwe.verify({
          signature: credentials?.signature || '',
          domain: nextAuthUrl.host,
          nonce: crsf,
        })

        if (result.success) {
          const ownerShipResult = await OwnerShipService.verifyOwnership(
            siwe.address,
            credentials?.vault
          )
          //... rest of the code

And then we need to update our ownership service :

import { Alchemy, Network } from 'alchemy-sdk'
import { config } from '@/config'
import { ethers } from 'ethers'
import { DelegateCash } from 'delegatecash'
const provider = new ethers.providers.JsonRpcProvider(
  `https://eth-mainnet.alchemyapi.io/v2/${config.ALCHEMY_API_KEY}}`,
  'any'
)

export class OwnerShipService {
  static async verifyOwnership(address: string, vault: string | undefined) {
    const contract = config.TOKEN_GATED_ADDRESS
    const addressToCheck = address

    if (vault) {
      const dc = new DelegateCash(provider)
      const isDelegateForAll = await dc.checkDelegateForAll(address, vault)
      if (!isDelegateForAll) {
        throw new Error('You are not a delegate for this vault')
      }
      addressToCheck = vault
    }

    const alchemy = new Alchemy({
      apiKey: config.ALCHEMY_API_KEY,
      network: config.TOKEN_GATED_NETWORK,
      maxRetries: 0,
    })

    const isOwner = await alchemy.nft.verifyNftOwnership(
      addressToCheck,
      contract
    )
    return {
      isOwner,
      lastValidation: Date.now(),
    }
  }
  // rest of the service
}

So now our service work like that : It accept an optional vault parameter and if it's present it will check if the address is a delegate for the vault and if it's the case it will check if the vault own the required token instead of the user address. And on the front-end side we need to update the signin function of Next.Auth to pass the vault address:

const res = await signIn('credentials', {
  message: JSON.stringify(message),
  signature,
  vault: '0x88A149D1B8AfCA593bbEd2a708D2AEa5a15FdE2c'
  redirect: false,
  callbackUrl: callbackUrl ?? window.location.origin,
})

And that's it ! Now you can use validate the ownership of a token without asking users to use their vault that hold the token.