wagmi for EVM-compatible blockchains or
aptos for the Aptos blockchain. However, you can use
Livepeer to mint NFTs with any blockchain by following the chain-specific NFT
standards.
Adding Dependencies
We first add the required dependencies usingnpm (or your preferred package
manager).
- Ethereum
- Aptos
npm i wagmi ethers
npm i @rainbow-me/rainbowkit
npm i aptos
Setting Up Ethereum and Aptos Providers
- Ethereum
- Aptos
We create both a new React client (using a CORS-protected API key)
and a
wagmi client which is configured to interact with our demo NFT contract on the
Polygon Mumbai chain.
This could be replaced with any EIP-721 or EIP-1155
contract on an EVM-compatible chain.App.tsx
import {
LivepeerConfig,
createReactClient,
studioProvider,
} from '@livepeer/react';
import { WagmiConfig, chain, createClient } from 'wagmi';
const wagmiClient = ...; // set up the wagmi client with RainbowKit or ConnectKit
const livepeerClient = createReactClient({
provider: studioProvider({
apiKey: process.env.NEXT_PUBLIC_STUDIO_API_KEY,
}),
});
function App() {
return (
<WagmiConfig client={wagmiClient}>
<RainbowKitProvider {...}>
<LivepeerConfig client={livepeerClient}>
<CreateAndViewAsset />
</LivepeerConfig>
</RainbowKitProvider>
</WagmiConfig>
);
}
We create both a new React client (using a CORS-protected API key)
and a React
Context which contains an AptosClient to interact with our demo NFT collection on
Aptos Devnet.App.tsx
import {
LivepeerConfig,
createReactClient,
studioProvider,
} from '@livepeer/react';
import { AptosClient } from 'aptos';
import { createContext, useMemo } from 'react';
export const AptosContext = createContext<AptosClient | null>(null);
const livepeerClient = createReactClient({
provider: studioProvider({
apiKey: process.env.NEXT_PUBLIC_STUDIO_API_KEY,
}),
});
function App() {
// create an aptos client using the devnet endpoint on app mount
const aptosClient = useMemo(
() => new AptosClient('https://fullnode.devnet.aptoslabs.com/v1'),
[],
);
return (
<AptosContext.Provider value={aptosClient}>
<LivepeerConfig client={livepeerClient}>
<CreateAndViewAsset />
</LivepeerConfig>
</AptosContext.Provider>
);
}
Add Connect Wallet Button
- Ethereum
- Aptos
Now that our providers are set up, we add a connect button which “logs in” a user using their wallet. We use
RainbowKit for the wallet connection flow. It integrates easily with
wagmi hooks, as well as WalletConnect and Metamask
to support a number of popular wallets.WagmiNft.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount } from 'wagmi';
export const WagmiNft = () => {
const { address } = useAccount();
return (
<div>
<ConnectButton />
</div>
);
};
Now that our providers are set up, we add a connect button which “logs in” a user using their wallet. We recommend using
Petra Wallet - it has a similar interface to Metamask and helps facilitate interactions with
the devnet faucet.
AptosNft.tsx
import { useCallback, useMemo, useState } from 'react';
// we type the global aptos as `any` - it's recommended to narrow these types
declare global {
interface Window {
aptos: any;
}
}
export const AptosNft = () => {
const isAptosDefined = useMemo(
() => (typeof window !== 'undefined' ? Boolean(window?.aptos) : false),
[],
);
const [address, setAddress] = useState<string | null>(null);
// callback when a user clicks the "connect" button
const connectWallet = useCallback(async () => {
try {
if (isAptosDefined) {
// we use the global `aptos` object to interact with the wallet browser extension
await window.aptos.connect();
const account: { address: string } = await window.aptos.account();
setAddress(account.address);
}
} catch (e) {
console.error(e);
}
}, [isAptosDefined]);
return (
<div>
<button
disabled={!isAptosDefined || Boolean(address)}
onClick={connectWallet}
>
{!address ? 'Connect Wallet' : address}
</button>
</div>
);
};
Upload asset to IPFS
We then add a feature to let a user upload the asset to IPFS. Under the hood, the livepeer provider will upload the asset file to IPFS, then generate ERC-721 compatible metadata in IPFS which points to that asset’s CID.- Ethereum
- Aptos
In this example, the asset ID is hardcoded in the component for simplicity, but could be
dynamic.
WagmiNft.tsx
import { useAsset, useUpdateAsset } from '@livepeer/react';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useMemo } from 'react';
import { useAccount } from 'wagmi';
const assetId = '64d3ddee-c44b-4c9c-8739-c3c530d6dfea';
export const WagmiNft = () => {
const { address } = useAccount();
const { mutate: updateAsset, status: updateStatus } = useUpdateAsset({
assetId,
storage: {
ipfs: true,
// metadata overrides can be added here
// see the source code behind this example
},
});
return (
<div>
<ConnectButton />
{address && assetId && (
<>
<p>{assetId}</p>
<button
onClick={() => {
updateAsset?.();
}}
>
Upload to IPFS
</button>
</>
)}
</div>
);
};
In this example, the asset ID is hardcoded in the component for simplicity, but could be
dynamic.
AptosNft.tsx
import { useUpdateAsset } from '@livepeer/react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { AptosContext } from '../core';
declare global {
interface Window {
aptos: any;
}
}
const assetId = '64d3ddee-c44b-4c9c-8739-c3c530d6dfea';
export const AptosNft = () => {
const isAptosDefined = useMemo(
() => (typeof window !== 'undefined' ? Boolean(window?.aptos) : false),
[],
);
const [address, setAddress] = useState<string | null>(null);
const connectWallet = useCallback(async () => {
try {
if (isAptosDefined) {
await window.aptos.connect();
const account: { address: string } = await window.aptos.account();
setAddress(account.address);
}
} catch (e) {
console.error(e);
}
}, [isAptosDefined]);
const { mutate: updateAsset, status: updateStatus } = useUpdateAsset({
assetId,
storage: {
ipfs: true,
// metadata overrides can be added here
// see the source code behind this example
},
});
return (
<div>
<button
disabled={!isAptosDefined || Boolean(address)}
onClick={connectWallet}
>
{!address ? 'Connect Wallet' : address}
</button>
{address && (
<button
onClick={() => {
updateAsset?.();
}}
>
Upload to IPFS
</button>
)}
</div>
);
};
Example IPFS JSON
{
"name": "Spinning Earth",
"description": "The Earth is spinning in this amazing video, and the camera is still.",
"animation_url": "ipfs://bafybeiar26nqkdtiyrzbaxwcdm7zkr2o36xljqskdvg6z6ugwlmpkdhamy/?loop=1&v=efea4eqe0ottx346",
"external_url": "https://lvpr.tv/?muted=0&v=efea4eqe0ottx346",
"image": "ipfs://bafkreidmlgpjoxgvefhid2xjyqjnpmjjmq47yyrcm6ifvoovclty7sm4wm",
"properties": {
"com.livepeer.playbackId": "efea4eqe0ottx346",
"video": "ipfs://bafybeiew466bk3caift2gsnzeb23qmzmpqnim32utahanj5f5ks2ycvk7y"
}
}
Mint a Video NFT
We can now use the NFT metadata CID to mint a video NFT! After the transaction is successful, we show a link to a blockchain explorer so the user can see the blockchain confirmation.- Ethereum
- Aptos
In this example, we rely on
usePrepareContractWrite to write to our demo
Polygon Mumbai NFT contract. This could be
replaced by ethers or another library, but wagmi hooks make it easy
to read/write with React.WagmiNft.tsx
import { useAsset, useUpdateAsset } from '@livepeer/react';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { useAccount, useContractWrite, usePrepareContractWrite } from 'wagmi';
// The demo NFT contract ABI (exported as `const`)
// See: https://wagmi.sh/docs/typescript
import { videoNftAbi } from './videoNftAbi';
export const WagmiNft = () => {
const { address } = useAccount();
const router = useRouter();
const assetId = useMemo(
() => (router?.query?.id ? String(router?.query?.id) : undefined),
[router?.query],
);
const { data: asset } = useAsset({
assetId,
enabled: assetId?.length === 36,
refetchInterval: (asset) =>
asset?.storage?.status?.phase !== 'ready' ? 5000 : false,
});
const { mutate: updateAsset } = useUpdateAsset(
asset
? {
assetId: asset.id,
storage: {
ipfs: true,
metadata: {
name,
description,
},
},
}
: null,
);
const { config } = usePrepareContractWrite({
// The demo NFT contract address on Polygon Mumbai
address: '0xA4E1d8FE768d471B048F9d73ff90ED8fcCC03643',
abi: videoNftAbi,
// Function on the contract
functionName: 'mint',
// Arguments for the mint function
args:
address && asset?.storage?.ipfs?.nftMetadata?.url
? [address, asset?.storage?.ipfs?.nftMetadata?.url]
: undefined,
enabled: Boolean(address && asset?.storage?.ipfs?.nftMetadata?.url),
});
const {
data: contractWriteData,
isSuccess,
write,
error: contractWriteError,
} = useContractWrite(config);
return (
<div>
<ConnectButton />
{address && assetId && (
<>
<p>{assetId}</p>
{asset?.status?.phase === 'ready' &&
asset?.storage?.status?.phase !== 'ready' ? (
<button
onClick={() => {
updateAsset?.();
}}
>
Upload to IPFS
</button>
) : contractWriteData?.hash && isSuccess ? (
<a
target="_blank"
href={`https://mumbai.polygonscan.com/tx/${contractWriteData.hash}`}
>
<button>View Mint Transaction</button>
</a>
) : contractWriteError ? (
<p>{contractWriteError.message}</p>
) : asset?.storage?.status?.phase === 'ready' && write ? (
<button
onClick={() => {
write();
}}
>
Mint NFT
</button>
) : (
<></>
)}
</>
)}
</div>
);
};
We add a custom Next.js API route for the Aptos NFT minting, to
demonstrate the “offer token” flow which can be used
to mint NFTs. This is different from the EVM demo, since we have an NFT collection which we mint a new NFT and
“offer” it to the recipient. The recipient can then accept this offer and transfer the token to their wallet.
/api/create-aptos-nft.ts
import { AptosAccount, AptosClient, HexString, TokenClient } from 'aptos';
import { NextApiRequest, NextApiResponse } from 'next';
export type ApiError = {
message: string;
};
export type CreateAptosTokenBody = {
receiver: string;
metadataUri: string;
};
export type CreateAptosTokenResponse = {
creator: string;
collectionName: string;
tokenName: string;
tokenPropertyVersion: number;
};
const NODE_URL = 'https://fullnode.devnet.aptoslabs.com';
const COLLECTION_NAME = 'Livepeer Video NFT';
const COLLECTION_DESCRIPTION =
"Video NFTs using Livepeer's decentralized video transcoding protocol.";
const COLLECTION_URI = 'https://livepeer.org';
const TOKEN_VERSION = 0;
const TOKEN_DESCRIPTION =
"A video NFT which uses Livepeer's decentralized video transcoding protocol.";
const handler = async (
req: NextApiRequest,
res: NextApiResponse<CreateAptosTokenResponse | ApiError>,
) => {
try {
const method = req.method;
if (method === 'POST') {
const { receiver, metadataUri }: CreateAptosTokenBody = req.body;
if (!receiver || !metadataUri) {
return res.status(400).json({ message: 'Missing data in body.' });
}
const client = new AptosClient(NODE_URL);
const tokenClient = new TokenClient(client);
if (!process.env.APTOS_PRIVATE_KEY) {
return res.status(500).json({ message: 'Aptos config missing.' });
}
const issuer = new AptosAccount(
new HexString(process.env.APTOS_PRIVATE_KEY).toUint8Array(),
);
let collectionData: any;
try {
collectionData = await tokenClient.getCollectionData(
issuer.address(),
COLLECTION_NAME,
);
} catch (e) {
// if the collection does not exist, we create it
const createCollectionHash = await tokenClient.createCollection(
issuer,
COLLECTION_NAME,
COLLECTION_DESCRIPTION,
COLLECTION_URI,
);
await client.waitForTransaction(createCollectionHash, {
checkSuccess: true,
});
collectionData = await tokenClient.getCollectionData(
issuer.address(),
COLLECTION_NAME,
);
}
// each token increments by 1, e.g. "Video NFT 1"
const tokenName = `Video NFT ${Number(collectionData?.supply ?? 0) + 1}`;
const createTokenHash = await tokenClient.createToken(
issuer,
COLLECTION_NAME,
tokenName,
TOKEN_DESCRIPTION,
1,
metadataUri,
);
await client.waitForTransaction(createTokenHash, { checkSuccess: true });
// offer the token to the address in the request body
// this must be confirmed by the recipient, so this is safe
// (a random address could be passed in to the POST body)
//
// a better way would be to have the recipient sign a payload
// and confirm that signature in the backend
const offerTokenHash = await tokenClient.offerToken(
issuer,
receiver,
issuer.address(),
COLLECTION_NAME,
tokenName,
1,
TOKEN_VERSION,
);
await client.waitForTransaction(offerTokenHash, { checkSuccess: true });
return res.status(200).json({
creator: issuer.address().hex(),
collectionName: COLLECTION_NAME,
tokenName,
tokenPropertyVersion: TOKEN_VERSION,
});
}
res.setHeader('Allow', ['POST']);
return res.status(405).end(`Method ${method} Not Allowed`);
} catch (err) {
console.error(err);
return res
.status(500)
.json({ message: (err as Error)?.message ?? 'Error' });
}
};
export default handler;
POST to /api/create-aptos-token) to request creation of a token,
and use the response to “accept” the offer (which uses the function
0x3::token_transfers::claim_script).AptosNft.tsx
import { useAsset, useUpdateAsset } from '@livepeer/react';
import { Types } from 'aptos';
import { useRouter } from 'next/router';
import { useCallback, useContext, useMemo, useState } from 'react';
import {
CreateAptosTokenBody,
CreateAptosTokenResponse,
} from '../../pages/api/create-aptos-token';
import { AptosContext } from '../core';
declare global {
interface Window {
aptos: any;
}
}
export const AptosNft = () => {
const aptosClient = useContext(AptosContext);
const isAptosDefined = useMemo(
() => (typeof window !== 'undefined' ? Boolean(window?.aptos) : false),
[],
);
const [address, setAddress] = useState<string | null>(null);
const connectWallet = useCallback(async () => {
try {
if (isAptosDefined) {
await window.aptos.connect();
const account: { address: string } = await window.aptos.account();
setAddress(account.address);
}
} catch (e) {
console.error(e);
}
}, [isAptosDefined]);
const router = useRouter();
const assetId = useMemo(
() => (router?.query?.id ? String(router?.query?.id) : undefined),
[router?.query],
);
const {
data: asset,
status: assetStatus,
} = useAsset({
assetId,
enabled: assetId?.length === 36,
refetchInterval: (asset) =>
asset?.storage?.status?.phase !== 'ready' ? 5000 : false,
});
const { mutate: updateAsset, status: updateStatus } = useUpdateAsset(asset
? {
assetId: asset.id,
storage: {
ipfs: true,
},
}
: null,
);
const [isCreatingNft, setIsCreatingNft] = useState(false);
const [creationHash, setCreationHash] = useState('');
const mintNft = useCallback(async () => {
setIsCreatingNft(true);
try {
if (address && aptosClient && asset?.storage?.ipfs?.nftMetadata?.url) {
const body: CreateAptosTokenBody = {
receiver: address,
metadataUri: asset.storage.ipfs.nftMetadata.url,
};
const response = await fetch('/api/create-aptos-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const json = (await response.json()) as CreateAptosTokenResponse
if ((json as CreateAptosTokenResponse).tokenName) {
const createResponse = json as CreateAptosTokenResponse;
const transaction = {
type: 'entry_function_payload',
function: '0x3::token_transfers::claim_script',
arguments: [
createResponse.creator,
createResponse.creator,
createResponse.collectionName,
createResponse.tokenName,
createResponse.tokenPropertyVersion,
],
type_arguments: [],
};
const aptosResponse: Types.PendingTransaction =
await window.aptos.signAndSubmitTransaction(transaction);
const result = await aptosClient.waitForTransactionWithResult(
aptosResponse.hash,
{ checkSuccess: true },
);
setCreationHash(result.hash);
}
}
} catch (e) {
console.error(e);
} finally {
setIsCreatingNft(false);
}
}, [
address,
aptosClient,
asset?.storage?.ipfs?.nftMetadata?.url,
setIsCreatingNft,
]);
return (
<div>
<button
disabled={!isAptosDefined || Boolean(address)}
onClick={connectWallet}
>
{!address ? (
'Connect Wallet'
) : (
address
)}
</button>
{address && (
<>
{asset?.status?.phase === 'ready' &&
asset?.storage?.status?.phase !== 'ready' ? (
<button
onClick={() => {
updateAsset?.();
}}
disabled={
!updateAsset || !assetId || Boolean(asset?.storage?.ipfs?.cid)
}
>
Upload to IPFS
</button>
) : creationHash ? (
<a href={`https://explorer.aptoslabs.com/txn/${creationHash}?network=Devnet`}>
View Mint Transaction
</a>
) : asset?.storage?.status?.phase === 'ready' ? (
<button
onClick={mintNft}
>
Mint NFT
</button>
) : (
<></>
)}
</>
)}
</div>
);
};