Minting NFTs from Ethereum or OP Mainnet

This guest post from Kiwi News discusses how to tap into Ethereum liquidity by making NFT minting available both on L1 and L2.

Minting NFTs from Ethereum or OP Mainnet

Ethereum Mainnet still has the most ETH liquidity - as of writing, about 100X more than OP. At the same time, we all want users to engage with L2 smart contracts so they don’t pay a $50 gas fee for every simple action.

So, how could we tap into Mainnet liquidity while using L2 contracts to mint the NFT?

We faced exactly this dilemma with our project - Kiwi News. It’s a Hacker News-like link aggregator focused on crypto tech, products & culture content.

Kiwi is both an app and a protocol built on an algorithm similar to Farcaster's (it's called "set reconciliation"). This means that everyone can fork the Kiwi network and run their own app, accessing our content with different algorithms, moderation, and so on.

But to actually use the app for upvoting, submitting links, and commenting, our users must first buy our NFT. Just like you need to pay gas to send an Ethereum transaction or you have to pay when creating a Farcaster account on Kiwi News, you need to mint an OP NFT to engage.

But we actually didn’t start our NFT sale on OP Mainnet. Our NFT was first only available on Ethereum, but as it turned out, during the $PEPE mania, it cost $19 to mint our $15 NFT. So we decided to move to OP Mainnet.

And it did seem like a win-win: Our users paid less, and we earned the same amount and could technically even increase prices.

But we quickly learned that most people - even long-time Ethereum users - didn’t have money bridged to OP Mainnet. Of course, they could go to a bridge, but taking them off our website meant an overall lower sales conversion. And as the old distribution mantra says, you must “fish where the fish are.”

So, we decided to solve this problem by making our NFT minting available both on L1 and L2.

Technical details

Here’s the issue: Our NFT contract is on L2, and only there is Zora’s mintWithRewards(...) function, which is callable to mint the NFT. Yet, a new Kiwi News user may only have funds on the Ethereum Mainnet.

If they have funds on the OP Mainnet, then that's great; they can just directly buy and mint the NFT.

However, if they have funds on the Ethereum Mainnet, we’ll have to get them to bridge these funds to the OP Mainnet first and, ideally, buy the NFT during the bridging process.

Now, under no circumstances would we want the user to sign multiple transactions, as this would lead to higher churn rates and it would make the overall process more cumbersome. Having to confirm multiple transactions in a row means a higher chance of things going wrong, which is why we started looking into ways to combine bridging and buying the NFT atomically.

So, let's dive into actual code now.

First of all, you have to understand that the Optimism system has APIs both on L1 and L2. You can, for example, call a contract on L2 to withdraw your funds into L1. Or you can call a function on L1 to deposit directly to the L2.

These interfaces are particularly useful in our case as the OptimismPortal (Etherscan, Source code) on Ethereum Mainnet has a function called OptimismPortal.depositTransaction(...):

/// @notice Accepts deposits of ETH and data, and emits a TransactionDeposited event for use in
///         deriving deposit transactions. Note that if a deposit is made by a contract, its
///         address will be aliased when retrieved using `tx.origin` or `msg.sender`. Consider
///         using the CrossDomainMessenger contracts for a simpler developer experience.

/// @param _to         Target address on L2.
/// @param _value      ETH value to send to the recipient.
/// @param _gasLimit   Amount of L2 gas to purchase by burning gas on L1.
/// @param _isCreation Whether or not the transaction is a contract creation.
/// @param _data       Data to trigger the recipient with.

function depositTransaction(
    address to,
    uint256 value,
    uint64 gasLimit,
    bool isCreation,
    bytes memory data
)
public
payable;

Notice how depositTransaction(...)'s arguments seem similar to the naming of a regular Ethereum transaction? There’s the address to, the uint256 value, and a bytes memory data encoding the function call details. uint64 gasLimit is, no surprises here either, defining the maximum gas that transaction can use and bool isCreation whether bytes memory data shall create a new contract.

depositTransaction(...) 's arguments are similar because internally, during the bridging process, the OP mainnet node will send a transaction on the OP Mainnet with the respective parameters when the L1 caller’s bridging process succeeds.

This is particularly useful for us when wanting to both mint the NFT using Zora’s mintWithRewards(...) function on OP mainnet, but also allowing users to call this function from Ethereum mainnet without separate bridging and minting transactions.

So, let’s actually dive deeply into the code here. Below is the interface for our NFT collection contract on OP mainnet deployed via Zora:

function mintWithRewards(
    address recipient,
    uint256 quantity,
    string calldata comment,
    address mintReferral
) external payable returns (uint256);

The function requires an address recipient, the receiver of the NFT, a uint256 quantity to define how many NTFs should be minted, a string calldata comment for onchain comments and an address mintReferral to credit a referrer for recommending the mint.

So, assuming we take on the job of a front-end engineer tasked with building an NFT mint button that works both on OP mainnet and ETH mainnet, here's a step-by-step process on what we'd have to compute:

  1. Check the user's ETH balance on OP Mainnet. If the user can afford to mint the NFT, prompt the user to call mintWithRewards(...) directly on Optimism.
  2. If the user doesn't have enough ETH on OP Mainnet, check the user's Ethereum Mainnet balance. If the user cannot afford to buy the NFT on ETH mainnet, inform the user about it; otherwise, continue with step 3.
import { fetchBalance, getAccount } from "@wagmi/core";
import { mainnet, optimism } from "wagmi/chains";
Import { utils } from “ethers”;

const { address } = getAccount();
if (!address) {
  throw new Error("Account not available");
}

const balance = {
  mainnet: (await fetchBalance({ address, chainId: mainnet.id })).value,
  optimism: (await fetchBalance({ address, chainId: optimism.id })).value,
};

const mintPriceETH = utils.parseEther(“0.00256”);

if (balance.optimism >= mintPriceETH) {
  // mint on OP mainnet
} else if (balance.mainnet >= mintPriceETH) {
  // mint on ETH mainnet
} else {
  throw new Error(“Insufficient balance”);
}
  1. Now that we know that the user is going to mint the NFT from the ETH mainnet via the OptimismPortal, we'll have to prepare two ETH calls. One is for calling depositTransaction(...) on L1, and one is for calling mintWithRewards(...) on L2. We’ll pass the second call meant to be executed on the OP mainnet into depositTransaction(...)'s bytes memory data. Here’s how we do that:

3.1. We build the function call data for mintWithRewards(...) by collecting the inputs for the mintWithRewards(...) function on L2. We use ethers's interface.encodeFunctionData(name, [...inputs]) to package the call as a hex string.

import { getAccount } from "@wagmi/core";
import { Contract } from "@ethersproject/contracts";
import { mainnet, optimism } from "wagmi/chains";

import { getProvider } from “./viem-adapter.mjs”;

const nftAddress = 0xabc…;
const nftABI = [{...}];

function prepareL2Call() {
  const { address } = getAccount();
  const opProvider = getProvider({ chainId: optimism.id });
  const contract = new Contract(nftAddress, nftABI, opProvider);
  const recipient = address;
  const quantity = 1;
  const comment = “minting this from mainnet!”
  const referrer = null;
  return contract.interface.encodeFunctionData("mintWithRewards", [
    recipient,
    quantity,
    comment,
    referrer,
  ]);
}

3.2. For depositTransaction(...), we then select the function call's target as address to and we set uint256 value to the value of ETH we want to pass on to address to. As for uint64 gasLimit, we are supposed to simulate the cost of the ETH call on Optimism with the user's balance. However, the user's ETH balance is insufficient, remember? So any estimateGas call with the OP mainnet provider and the user's address will error.

Hence, we suggest discovering a static guestimate by, e.g., calling the mintWithRewards(...) function manually and use this value in the code.

As for the other arguments, bool isCreation is false and bytes memory data finally contains the call data generated in step 3.1.

import { prepareWriteContract } from "@wagmi/core";
import { mainnet } from "wagmi/chains";

const optimismPortalAddress = 0x…;
const optimismPortalABI = [{...}, …];

async function writeToDeposit(nftAddress, price, data) {
  const isCreation = false;
  const gasLimit = 170000;

  return await prepareWriteContract({
    address: optimismPortalAddress,
    abi: optimismPortalABI,
    functionName: "depositTransaction",
    args: [nftAddress, price, gasLimit, isCreation, data],
    value: price,
    chainId: mainnet.id,
  });
}

3.3. Importantly, we'll have to deposit at least the amount of uint256 value or more as msg.value to the L1 call. This ensures that some Ether is being deposited into Optimism and made available for the mintWithRewards(...) call. We do this by setting msg.value to the value of price.

3.4. Finally, having all arguments assembled, we prompt the user to sign this transaction for the Ethereum mainnet.

import { useContractWrite } from "wagmi";
// …

function prepareL2Call(...) {...}
async function writeToDeposit(...) {...}
// …


const BuyButton = (props) => {
  // …
  const { write } = useContractWrite(config);
  // …

  return (
    <button disabled={!write} onClick={() => write?.()}>
    Buy NFT
    </button>
  );
}

And that's it! That’s what you have to do if you want to atomically mint an NFT on Optimism while provisioning the call directly from Ethereum mainnet.

While the code snippets aren’t exactly the production code that we use in Kiwi News, they’re very close to the code that we’ve open-sourced. You can find the full preparation sequence and more on our GitHub. And you can actually try this out directly by going to our NFT minting page.

Now, surely, using the OptimismPortal’s depositTransaction(...) function isn’t the only option for going from L1 to L2. At this point, there are many exchanges offering similar services, too. However, a specialty of the OP Portal is that it preserves the msg.sender of that address which signed and sent the transaction on ETH mainnet. This can be particularly important when your dapp relies on that address having to be legitimate.

Conclusion

When migrating our NFT from Ethereum to OP Mainnet, we needed a way to tap into L1 liquidity while still allowing all our users to mint it from OP Mainnet as well.

The OptimismPortal provides a convenient functionality to both bridge funds and execute an ETH call in a single atomic transaction, enabling a high chance of success when a user sends the call.

We detailed our React.js code, which checks both Ethereum and L2 balances to provide the right call for each user. If a user doesn’t have OP ETH, they get directly onboarded by bridging through the Portal.

In the future, we’re looking forward to more such exciting technical opportunities. For example, we’d love to be able to atomically tap into the liquidity of the Base chain with a similar Portal transaction as to make our code more easily callable for all Ethereum users.

We hope you’ve found this post helpful in your work. At Kiwi News, we’re always working towards serving the ecosystem with as much utility as we can—be that with the latest news, blog posts, or GitHub repositories. We would love to have you on board as a reader! Check us out at https://kiwinews.xyz.