This tutorial is based on Livepeer React version 3.9 or earlier, which is now deprecated. Please ensure that you use Livepeer React version 4 or later, with the new Livepeer JavaScript SDK. The integration process may appear different, but the underlying concepts remain same.

Token gating is a method of restricting access to videos based on ownership of a specific type of token or cryptocurrency. It can be used to restrict access to certain videos to those who hold a specific type of token, such as an NFT (non-fungible token). One of the primary benefits of token gating is that it allows creators to monetise their content more effectively. It can also provide creators with more control over their content and its distribution. By restricting access to certain videos to those who hold a specific token, creators can ensure that their content is only being viewed by those who have a genuine interest in it.

Lit Protocol is a decentralised key management network powered by threshold cryptography. A blockchain-agnostic middleware layer, Lit can be used to read and write data between blockchains and off-chain platforms, powering conditional decryption and programmatic signing.

When combined with Livepeer, developers can build token-gated video applications and allow creators to token-gate their video with specific tokens, NFTs, addresses, etc. In this tutorial, you will learn how to build a token-gated video application with Lit and Livepeer.

Here is a high-level workflow diagram of how Livepeer’s access control feature works and how we will use it in our app:

Prerequisites

Before you start with this tutorial, make sure you have the following tool installed on your machine:

  • Latest Node.js version
  • An Ethereum wallet such as Metamask or Rainbow

In addition to the above tools, this tutorial assumes that you have a basic understanding of Next.js

Setting up Next.js App

First, create a directory for the project and then initialize a Next.js app using the following command in your terminal:

npx create-next-app .

This will create a new Next.js app in the current directory and install all the necessary dependencies.

While creating a Next.js application, you will be prompted if you would want to use Tailwind. In this tutorial, we will be using Tailwind, so make sure to set up a Next.js app with Tailwind.

Once the project is created successfully, run the following command to install a few other dependencies.

npm install @livepeer/react @lit-protocol/sdk-nodejs @rainbow-me/rainbowkit ethers lit-js-sdklit-share-modal-v3 nanoid wagmi next-transpile-modules

Setting up Clients

In this tutorial, we will be using different packages and SDKs which means we have to set up the context and clients for each of them. Let’s start with RainbowKit and WAGMI

RainbowKit and WAGMI

For this tutorial, we will be using Rainbow Kit and WAGMI to handle authentication such as connecting the wallet and signing message.

First, inside of _app.js import the following from Rainbow and WAGMI.

import "@rainbow-me/rainbowkit/styles.css";

import { getDefaultWallets, RainbowKitProvider } from "@rainbow-me/rainbowkit";
import { configureChains, createClient, WagmiConfig } from "wagmi";
import { mainnet } from "wagmi/chains";

import { publicProvider } from "wagmi/providers/public";

Then, configure your desired chains (in this tutorial, we will be using Ethereum mainnet) and generate the required connectors. You will also need to set up a WAGMI client.

// ... import { alchemyProvider } from 'wagmi/providers/alchemy';
// ... import { publicProvider } from 'wagmi/providers/public';

const { chains, provider } = configureChains([mainnet], [publicProvider()]);
const { connectors } = getDefaultWallets({
  appName: "My Awesome App",
  chains,
});
const wagmiClient = createClient({
  autoConnect: true,
  connectors,
  provider,
});

Finally, wrap your application with RainbowKitProvider and WagmiConfig

const App = () => {
  return (
    <WagmiConfig client={wagmiClient}>
      <RainbowKitProvider chains={chains}>
        <Component {...pageProps} />
      </RainbowKitProvider>
    </WagmiConfig>
  );
};

Livepeer

Livepeer is a decentralized video infrastructure protocol that allows users to upload, transcode, and serve video content. The Livepeer React SDK provides a set of ready-to-use hooks that make it easy to integrate Livepeer into a project.

To get started, navigate to https://livepeer.studio/register and create a new account on Livepeer Studio. This will give you access to your Livepeer dashboard, where you can manage your account and access your API keys.

Once you have created an account, in the dashboard, click on the Developers on the sidebar.

Then, click on Create API Key, give a name to your key, and then copy it as we will need it later.

To use Livepeer React in your project, create a new directory named client in the root directory, and add the following code to index.js

import { createReactClient, studioProvider } from "@livepeer/react";

const LivepeerClient = createReactClient({
  provider: studioProvider({ apiKey: "YOUR_API_KEY" }),
});

export default LivepeerClient;

Make sure to replace the YOUR_API_KEY with the key which you just copied from the Livepeer dashboard. And also replace the code inside of _app.js in the page directory with the below code.

//... import { publicProvider } from 'wagmi/providers/public';

import { LivepeerConfig } from "@livepeer/react";
import LivepeerClient from "../client";

//... const wagmiClient = createClient({

function MyApp({ Component, pageProps }) {
  return (
    <LivepeerConfig client={LivepeerClient}>
      <WagmiConfig client={wagmiClient}>
        <RainbowKitProvider chains={chains}>
          <Component {...pageProps} />
        </RainbowKitProvider>
      </WagmiConfig>
    </LivepeerConfig>
  );
}

export default MyApp;

And that is it, you can now use Livepeer to upload and transcode assets.

Lit Protocol

As mentioned above, we will be using Lit Protocol for token gating/access control in our application. First, create a new directory named hooks and inside of it, create a new file named useLit. This would be the file to handle the connection with Lit’s node. Add the following code to it:

import {
  createContext,
  FunctionComponent,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";
import LitJsSdk from "lit-js-sdk";

const LitClientContext = createContext({
  litNodeClient: null,
  litConnected: false,
});

export const LitProvider = ({ children }) => {
  const client = useMemo(
    () => new LitJsSdk.LitNodeClient({ debug: false }),
    []
  );
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    client
      .connect()
      .then(() => {
        {
          setConnected(true);
        }
      })
      .catch(() =>
        alert(
          "Failed connecting to Lit network! Refresh the page to try again."
        )
      );
  }, [client]);

  return (
    <LitClientContext.Provider
      value={{ litNodeClient: client, litConnected: connected }}
    >
      {children}
    </LitClientContext.Provider>
  );
};

export default function useLit() {
  return useContext(LitClientContext);
}

Here, we have created a provider and a custom hook to manage the connection to a Lit node client throughout the application. This is useful if you want to share the same instance of the Lit node client across multiple components in the app.

Next, add the following lit imports to _app.js and also wrap the application with LitProvider

// import LivepeerClient from '../client';

import "lit-share-modal-v3/dist/ShareModal.css";
import { LitProvider } from "../hooks/useLit";

//... const wagmiClient = createClient({

function MyApp({ Component, pageProps }) {
  return (
    <LitProvider>
      <LivepeerConfig client={LivepeerClient}>
        <WagmiConfig client={wagmiClient}>
          <RainbowKitProvider chains={chains}>
            <Component {...pageProps} />
          </RainbowKitProvider>
        </WagmiConfig>
      </LivepeerConfig>
    </LitProvider>
  );
}

export default MyApp;

Backend

The first step is to set up a background route that will handle the webhook and verify the token. Since we are using Next.js, we can simply use the api routes.

Create a Webhook Handler

The first step is to create an access control webhook handler. We need to set up an endpoint (e.g., https://yourdomain.com/api/check-access) to handle the logic for granting or denying access to your assets. This endpoint should accept a POST request with a JSON payload containing the access key and webhook context.

This is an example of a payload this endpoint would receive:

{
  "accessKey": "your-access-key",
  "context": {
    "assetId": "abcd1234",
    "userId": "user5678"
  },
  "timestamp": 1680530722502
}

In the Next.js api directory, create a new file named verify-lit-jwt.js and add the following code to it

import type { NextApiRequest, NextApiResponse } from "next";
import * as LitJsSdk from "@lit-protocol/lit-node-client-nodejs";

interface Payload {
  baseUrl: string;
  path: string;
  orgId: string;
  iat: number;
  exp: number;
}

const handler = async (req, res) => {
  const { accessKey, webhookContext } = req.body;

  const { verified, header, payload } = LitJsSdk.verifyJwt({
    jwt,
  });

  if (!verified) {
    res.status(403).json({ message: "Access token is not correct" });
  } else if (
    webhookContext?.resourceId &&
    (webhookContext.resourceId.baseUrl !== payload.baseUrl ||
      webhookContext.resourceId.path !== payload.path ||
      webhookContext.resourceId.orgId !== payload.orgId)
  ) {
    res.status(403).json({ message: "ResourceId does not match" });
  } else {
    res.status(200).json({ message: "Success" });
  }
};

export default handler;

The above code verifies a JWT access token and checks if its payload matches the webhook context’s resource ID. The Lit SDK is used to verify the JWT access token, which must contain a payload object with baseUrl, path, orgId, iat, and exp fields.

If the access token is not verified or if the resource ID does not match, an HTTP response with a status code of 403 is returned. If everything is fine, the handler returns an HTTP response with a status code of 200.

Next, deploy your Next.js app to Vercel or any other hosting provider as we will need a link to the verify JWT API.

Register an Access Control Webhook

Once you deployed, you can use the Livepeer Studio dashboard to create a new webhook with the type playback.accessControl and specify the URL of your access control endpoint.

Once created, copy the id of Webhook as we will need it later in the next step.

Frontend

Now that we have set up the clients and backend, we can move to the front end and add the upload and access control features to our app.

Wallet Connection

First, we need to allow users to connect their wallets. Create a new directory named components and inside of it, create a new file named Connect and add the following code to it.

import React from "react";
import { ConnectButton } from "@rainbow-me/rainbowkit";

export default function Navbar() {
  return (
    <div className="absolute top-0 left-0  ml-10 mt-10 flex items-center ">
      <ConnectButton />
    </div>
  );
}

It is a simple component that imports ConnectButton from Rainbow which would handle the authentication.

Upload page

In the pages directory, we can use index.jsx as the upload page, or you can also create a new file named upload.jsx for the upload page. In this tutorial, we will be using index.jsx as the upload page and it will be the index page of our app.

First import the following in the index.jsx file.

import React, { useEffect, useMemo, useRef, useState } from "react";
import useLit from "../../hooks/useLit";
import { nanoid } from "nanoid";
import { useAsset, useCreateAsset } from "@livepeer/react";
import Link from "next/link";
import { useAccount } from "wagmi";
import LitJsSdk from "lit-js-sdk";
import LitShareModal from "lit-share-modal-v3";

After that, we have to create a resourceId that will be used by Lit for checking the access control.

//... import LitShareModal from "lit-share-modal-v3";

const resourceId = {
  baseUrl: "my-awesome-app.vercel.app",
  path: `/asset/${nanoid()}`,
  orgId: "some-app",
  role: "",
  extraData: `createdAt=${Date.now()}`,
};

Next, add the following hooks inside the component. The code contains a few useStates for managing the state, the lit hook which we created earlier, and finally the useAccount hook that is imported from WAGMI.

// Inputs
const [file, setFile] = useState(undefined);
const fileInputRef = useRef(null);

// Lit
const [showShareModal, setShowShareModal] = useState(false);
const [savedSigningConditionsId, setSavedSigningConditionsId] = useState();
const [authSig, setAuthSig] = useState({});
const { litNodeClient, litConnected } = useLit();

const [litGateParams, setLitGateParams] = useState({
  unifiedAccessControlConditions: null,
  permanent: false,
  chains: [],
  authSigTypes: [],
});

// Misc
const { address: publicKey } = useAccount();

Next, we need to pre-sign the user’s wallet after it is connected using Lit. To do that, you can add the following code after the useAccount hook.

//...const { address: publicKey } = useAccount();

// Step 1: pre-sign the auth message
  useEffect(() => {
    if (publicKey) {
      Promise.resolve().then(async () => {
        try {
          setAuthSig({
            ethereum: await LitJsSdk.checkAndSignAuthMessage({
              chain: "ethereum",
              switchChain: false,
            }),
          });
        } catch (err: any) {
          alert(`Error signing auth message: ${err?.message || err}`);
        }
      });
    }
  }, [publicKey]);

In the above code, we are checking if the user is connected, if they are, the app asks to sign the message and then save it to AuthSign state.

Next, after signing the message, we can use useCreateAsset to upload the video to Livepeer. In the below code, we also specify the webhook id, which we created earlier. And useAsset hook to check if the asset upload/processing is completed

// Step 2: Creating an asset
const {
  mutate: createAsset,
  data: createdAsset,
  status: createStatus,
  progress,
} = useCreateAsset(
  file
    ? {
        sources: [
          {
            file: file,
            name: file.name,
            playbackPolicy: {
              type: "webhook",
              webhookId: "WEBHOOK_ID",
              webhookContext: {
                accessControl: litGateParams.unifiedAccessControlConditions,
                resourceId: resourceId,
              },
            },
          },
        ] as const,
      }
    : null
);

// Step 3: Getting asset and refreshing for the status
const {
  data: asset,
  error,
  status: assetStatus,
} = useAsset({
  assetId: createdAsset?.[0].id,
  refetchInterval: (asset) =>
    asset?.storage?.status?.phase !== "ready" ? 5000 : false,
});

You can also add the below code to check the status of the upload.

const progressFormatted = useMemo(
  () =>
    progress?.[0].phase === "failed" || createStatus === "error"
      ? "Failed to upload video."
      : progress?.[0].phase === "waiting"
      ? "Waiting"
      : progress?.[0].phase === "uploading"
      ? `Uploading: ${Math.round(progress?.[0]?.progress * 100)}%`
      : progress?.[0].phase === "processing"
      ? `Processing: ${Math.round(progress?.[0].progress * 100)}%`
      : null,
  [progress, createStatus]
);

const isLoading = useMemo(
  () =>
    createStatus === "loading" ||
    assetStatus === "loading" ||
    (asset && asset?.status?.phase !== "ready") ||
    (asset?.storage && asset?.storage?.status?.phase !== "ready"),
  [asset, assetStatus, createStatus]
);

Add the following code that would get called once everything is complete to save the signing conditions on Lit.

// Step 4: After an asset is created, save the signing condition
  useEffect(() => {
    if (
      createStatus === "success" &&
      asset?.id &&
      asset?.id !== savedSigningConditionsId
    ) {
      setSavedSigningConditionsId(asset?.id);
      // @ts-ignore
      const ACConditions = asset?.playbackPolicy.webhookContext.accessControl;
      console.log(ACConditions, resourceId);
      Promise.resolve().then(async () => {
        try {
          await litNodeClient.saveSigningCondition({
            unifiedAccessControlConditions: ACConditions,
            authSig,
            resourceId: resourceId,
          });
        } catch (err: any) {
          alert(`Error saving signing condition: ${err?.message || err}`);
        }
      });
    }
  }, [litNodeClient, createStatus, savedSigningConditionsId, authSig, asset]);

Finally, we can write a function that would call the createAsset to upload the video.

const handleClick = async () => {
  if (!publicKey) {
    console.log("Please connect your wallet to continue");
    return;
  }

  if (!file) {
    console.log("Please choose a file");
    return;
  }
  if (!litGateParams.unifiedAccessControlConditions) {
    console.log("Please choose the access control conditions");
    return;
  }
  createAsset?.();
};

Next, in the return function add the following JSX code. It is pretty long, but I am going to explain it.

return (
  <section className="p-10 h-screen flex flex-col lg:flex-row-reverse">
    <div className="w-full h-1/2 lg:h-full lg:w-1/2 ">
      <div className="relative">
        <img
          src="<https://solana-nft.withlivepeer.com/_next/image?url=%2Fhero.png&w=2048&q=75>"
          alt="BannerImage"
          className=" h-[90vh] w-full lg:object-cover lg:block hidden rounded-xl"
        />
      </div>
    </div>
    <div className="lg:w-1/2  w-full h-full lg:mr-20">
      <p className="text-base font-light text-primary lg:mt-20 mt-5">
        Livepeer x Ethereum x Lit
      </p>
      <h1 className="text-5xl font-bold font-MontHeavy text-gray-100 mt-6 leading-tight">
        Token gate your videos on Ethereum with Livepeer.
      </h1>
      <p className="text-base font-light text-zinc-500 mt-2">
        Token gating is a powerful tool for content creators who want to
        monetize their video content. With Livepeer, you can easily create a
        gated video that requires users to hold a certain amount of tokens/NFT
        in order to access the content. <br /> <br /> Livepeer&apos;s token
        gating feature is easy to use and highly customizable
      </p>
      <div className="flex flex-col mt-6">
        <div className="h-4" />
        <div
          onClick={() => fileInputRef.current?.click()}
          className="w-full border-dashed border-zinc-800 border rounded-md text-zinc-700  p-4 flex items-center justify-center hover:border-zinc-700 "
        >
          <p className="">
            {file ? (
              file.name +
              " - " +
              Number(file.size / 1024 / 1024).toFixed() +
              " MB"
            ) : (
              <>Choose a video file to upload</>
            )}
          </p>
        </div>
        <div className="h-5" />
        <div onClick={() => setShowShareModal(true)}>
          <input
            className={
              " bg-transparent p-4  border-zinc-800 border rounded-md text-zinc-400 text-sm font-light w-full  placeholder:text-zinc-700 focus:outline-none"
            }
            placeholder={"Choose the access control conditions"}
            disabled
            value={
              !litGateParams.unifiedAccessControlConditions
                ? ""
                : JSON.stringify(
                    litGateParams.unifiedAccessControlConditions,
                    null,
                    2
                  )
            }
          />
        </div>

        <input
          onChange={(e) => {
            if (e.target.files) {
              setFile(e.target.files[0]);
            }
          }}
          type="file"
          accept="video/*"
          ref={fileInputRef}
          hidden
        />
      </div>
      <div className="flex flex-row items-center mb-20 lg:mb-0">
        <button
          onClick={handleClick}
          className={
            "rounded-xl  text-sm font-medium p-3 mt-6  w-36 hover:cursor-pointer bg-primary"
          }
        >
          {isLoading ? progressFormatted || "Uploading..." : "Upload"}
        </button>

        {asset?.status?.phase === "ready" && (
          <div>
            <div className="flex flex-col justify-center items-center ml-5 font-matter">
              <p className="mt-6 text-white">
                Your token-gated video is uploaded, and you can view it{" "}
                <Link
                  className="text-primary"
                  target={"_blank"}
                  rel={"noreferrer"}
                  href={`/watch/${asset?.playbackId}`}
                >
                  here
                </Link>
              </p>
            </div>
          </div>
        )}
      </div>

      {showShareModal && (
        <div className="fixed top-0 left-0 w-full h-full z-50 bg-black bg-opacity-50 flex items-center justify-center">
          <div className="w-1/3 h-[95%] mt-10">
            <LitShareModal
              onClose={() => {
                setShowShareModal(false);
              }}
              chainsAllowed={["ethereum"]}
              injectInitialState={true}
              defaultChain={"ethereum"}
              initialUnifiedAccessControlConditions={
                litGateParams?.unifiedAccessControlConditions
              }
              onUnifiedAccessControlConditionsSelected={(
                val: LitGateParams
              ) => {
                setLitGateParams(val);
                setShowShareModal(false);
              }}
              darkMode={true}
              injectCSS={false}
            />
          </div>
        </div>
      )}
    </div>
  </section>
);

The first section contains a banner image and some text that describes the purpose of token gating on the Livepeer platform. Then we have two input fields. The first input field allows the user to select a video file to upload. The second input field allows the user to set access control conditions. Once a user clicks on the second input, it displays a modal window that allows the user to customize the access control conditions using the LitShareModal component.

And that is it for the upload page. Save the file and you should see a similar page:

Watch Page

For this watch page, you can create a new directory named watch inside the page and then create a new file named [playbackId].tsx. This will tell Next.js to match any link of /watch/anything to this page. Next, import the following into the page.

import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useAccount } from "wagmi";
import { Player, usePlaybackInfo } from "@livepeer/react";
import useLit from "../../hooks/useLit";
import LitJsSdk from "lit-js-sdk";

Then, add the following hooks inside of the page:

// ... import LitJsSdk from "lit-js-sdk";

export default function Watch() {
  const router = useRouter();
  const playbackId = router.query.playbackId?.toString();
  const { address } = useAccount();
  const { litNodeClient, litConnected } = useLit();

  const [gatingError, setGatingError] = useState();
  const [gateState, setGateState] = useState();
  const [jwt, setJwt] = useState("");

Next, we would need to fetch the playback info using the playback id. We can do this very easily using the usePlaybackInfo hook from @livepeer/react

// Step 1: Fetch playback URL
const { data: playbackInfo, status: playbackInfoStatus } = usePlaybackInfo({
  playbackId,
});

Next, we need a function to check whether the users have access. This function can easily be handled with Lit SDK

async function checkLitGate(litNodeClient, playbackPolicy) {
  const ethSign = await LitJsSdk.checkAndSignAuthMessage({
    chain: "ethereum",
    switchChain: false,
  });
  const jwt = await litNodeClient.getSignedToken({
    unifiedAccessControlConditions: playbackPolicy.webhookContext.accessControl,
    authSig: { ethereum: ethSign },
    resourceId: playbackPolicy.webhookContext.resourceId,
  });

  console.log(jwt);
  setGateState("open");
  setJwt(jwt);
  return jwt;
}

And finally, we can add a useEffect function to get playback info and check Lit access control when the component mounts.

// Step 2: Check Lit signing condition and obtain playback cookie
useEffect(() => {
  if (playbackInfoStatus !== "success" || !playbackId) return;
  const { playbackPolicy } = playbackInfo?.meta ?? {};
  setGateState("checking");

  setTimeout(() => {
    checkLitGate(litNodeClient, playbackPolicy)
      .then(() => {
        console.log("open");
      })
      .catch((err: any) => {
        setGateState("closed");
      });
  }, 1000);
}, [address, playbackInfoStatus, playbackInfo, playbackId, litNodeClient]);

And that is it. We can add the following code to the return function to render the player if the JWT is valid, otherwise show a message that the user doesn’t have access.

return (
  <>
    <div className="flex flex-col text-lg items-center justify-center mt-40">
      <h1 className="text-4xl font-bold font-MontHeavy text-gray-100 mt-6">
        Asset Token Gating with Lit Signing Conditions
      </h1>
      <p className="text-base font-light text-gray-500 mt-2 w-1/2 text-center">
        Prove your identity to access the gated content.
      </p>
    </div>
    <div className="flex justify-center text-center font-matter">
      <div className="overflow-auto border border-solid border-[#00FFB250] rounded-md p-6 w-3/5 mt-20">
        {jwt && (
          <Player.Root src={src} jwt={jwt}>
            <Player.Container>
              <Player.Video />
            </Player.Container>
          </Player.Root>
        )}
        {gateState == "checking" && (
          <p className="text-white">Checking, please wait...</p>
        )}
        {gateState == "closed" && (
          <p className="text-white">
            Sorry, you do not have access to this content
          </p>
        )}
      </div>
    </div>
  </>
);

And that is it. Save the file. Now try uploading a video and then once it is uploading come to the watch screen and check if you have access to it. Here is a short video of the whole flow:

Token gate multiple assets with the same ACL condition

If you want to token gate multiple assets with the same access control conditions, then you can simply get unified access control conditions from the Lit SDK and the same resources Id and then send the same info to as many assets as would like to token gate.

The access controls conditions and resource id should be inside of the Webhook Context.

const {
    mutate: createAsset,
    data: createdAsset,
    status: createStatus,
    progress,
  } = useCreateAsset(
    file
      ? {
          sources: [
            {
              file: file,
              name: file.name,
              playbackPolicy: {
                type: "webhook",
                webhookId: "WEBHOOK_ID",
                webhookContext: {
                  accessControl: litGateParams.unifiedAccessControlConditions,
                  resourceId: resourceId,
                },
              },
            },
          ] as const,
        }
      : null
  );

Remove the ACL conditions

If you want to remove the access control conditions from an asset. You need to update both the Livepeer Studio API as well as the Lit state. However, this is only possible, if the access control condition is not chosen permanently.