Skip to content

Build omnichain Frames with Decent

Farcaster Frames suffer from three main pain points when it comes to including transactions to be executed by the user:

  1. Farcaster only supports a limited number of chains. This prevents applications on major networks like Polygon from participating in Frames.
  2. The user needs to have funds on that chain in the currency you selected.
  3. Most protocols are only deployed to a select chain(s) that might not overlap with where Farcaster users hold tokens.

Rather than forcing developers to deploy to every chain or users to hold a specific token or visit external bridge sites to execute transactions, Decent reads users' linked wallet balances to build a transaction that meets the user where they have liquidity.

Decent APIs allow you to build a transaction on your destination chain that is funded by the user on their preferred source chain.

Want to build an omnichain Frame? Let's get started!

Tutorial: minting Courtyard NFTs in a Frame using any token

Video Tutorial

farcaster thumbnail

Learn how to build this cross-chain minting frame with Samuel Huber, an experienced Farcaster community developer!

Getting Started

You can complete this guide without prior knowledge of Frames development. Anyone can build Frames with Decent.

If you are familiar with a Frame development framework, please select it in each code group. Supported options include Frog and Coinbase's OnchainKit. We recommend Frog if you are not yet familiar with either framework.

frogfm
You will see code for Frog.fm

Step-by-step implementation

Setup your Frame project

First up we will start with the samples provided by each Farcaster Framework. Run the following command to get the sample project. These frameworks are built with Typescript and Next JS.

frogfm
npm init frog

Add Decent helper utilities

To make it easy for you to build cross-chain, you can simply add the following decentUtils.ts to your project source code.

You will also need to install the following dependencies:

npm install viem @decent.xyz/box-common

In your .env file add the following variables:

DECENT_API_KEY=''
NEYNAR_API_KEY=''

The Neynar API Key you can obtain at neynar.com. You can create a Decent API key by following this tutorial: Developer Console.

Use the Decent Console to build your transaction

Head to console.decent.xyz, login and then go to the Playground.

Here we can build our transaction by adding the details about the smart contract with which we want to transact. Once you added your contract details and click on the API Request button at the bottom right, copy the txConfig object.

Sample configuration (contract address redacted):

This is what we need to copy and use in our own Frame. Make sure to use the generated txConfig in the following sections.

Create Frame buttons to execute our transaction

Because we want to enable users to execute transactions with any token, we will need to build two buttons:

  • Approval button for non native currencies
  • Execute button for doing the intended transaction

All frameworks will have a set Entrypoint Frame. The Entrypoint will be different for each framework, so we will update our buttons accordingly. To create this NFT minting Frame, we will also require an image URL to display the NFT.

There are two screens involved in any one transaction: a transaction submission screen and a success screen. Users will initiate approvals from the former, we'll show the latter upon success, and then do the same to execute transactions.

frogfm
// file: "app/api/[[routes]]/route.tsx"
// ...
app.frame('/', async (c) => {
  return c.res({
    // adapt the image url to your liking. add an image in the /public folder
    image: `${process.env.FRAME_URL || 'http://localhost:3000/'}your-image.png`,
    imageAspectRatio: '1:1',
    intents: [
      // action is the post_url override apparently according to Frames.Transaction documentation https://frog.fm/intents/button-transaction#action-optional
      <Button.Transaction target="/tx" action="/tx-success">Mint Now</Button.Transaction>,
      <Button.Transaction target="/approve" action="/">Approve</Button.Transaction>,
    ],
  })
})
// ...

Notice how the routes for execute and approval buttons are different! We serve different transaction data and redirect accordingly.

Add the transaction data endpoints

Your framework will have a different way to create routes, where frog registers new routes in file, framesjs and onchainkit use the file-based router from Next JS, so you will crete the folder structure and then the route.ts file to serve your data.

What data are we serving?

Here comes the fun part: now that we have a Frame with a transaction Button, we need to tell the user which transaction to execute.

Because we know the wallet address the user has connected to the Farcaster client (think webpage), we can read the wallet's balances across chains and select their highest balance to use to execute our Frame transaction.

For this example, we will set the Base Layer 2 chain as the source, but you could expand this to any set of supported chains.

The transaction data will be served as a JSON response to the Farcaster client's API request. We can do that by using the txConfig we got from the Decent Console and building our JSON response accordingly.

Generate the approval transaction

To return transaction data for the approval transaction, we need to run the logic that returns the currency to use and then issues an approval transaction back to the user.

Because we know we are detailing with ERC20 tokens, we only need the currency's smart contract address, the amount to approve, and Decent's UTB address on the source chain. Please refer to Decent's addresses here.

We can use Viem to check whether there is an existing approval to avoid redundant transactions. For maximum security, we will only issue an approval for the amount required to complete the transaction.

You can see how our approve logic sends a notification (error) to the user if they do not need to approve.

frogfm
// file: "app/api/[[routes]]/route.tsx"
// ...
app.transaction('/approve', async (c) => {
  const account = c.address; // uses wallet connected to displayed Frame
 
  // Get the sourceToken. The token the user has the maximum balance in (or the native gas token if that has enough balance).
  const tokens = await getUserBalance(chain.id, account);
  const sourceToken = await getTokenWithMaxBalance(chain.id, tokens);
 
  // Build decent.xyz transaction here and use the address it is sent to as the to address for the approve call
 
  const txConfig: BoxActionRequest = {
    sender: account!,
    srcChainId: chain?.id as ChainId,
    dstChainId: ChainId.POLYGON,
    srcToken: sourceToken, // really want to dynamically set based on user's balances
    dstToken: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
    slippage: 1,
    actionType: ActionType.EvmFunction,
    actionConfig: {
      contractAddress: "your_contract_address",
      chainId: ChainId.POLYGON,
      signature: "function mintTokens(uint256 requestedQuantity, address recipient)",
      args: [1n, account!],
      cost: {
        isNative: false,
        amount: 25000000n,
        tokenAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
      },
    }
  }
 
  const { tx, tokenPayment } = await getTransactionData(txConfig);
 
  // Check for allowance if non native.
 
  if (sourceToken == zeroAddress) {
    return c.error({ message: 'You can mint right away. Press Execute!' });
  }
 
  const allowance = await baseClient.readContract({
    address: sourceToken as EvmAddress,
    abi: erc20Abi,
    functionName: 'allowance',
    args: [
      account as EvmAddress,
      tx.to as EvmAddress,
    ]
  });
 
  if (allowance >= tokenPayment.amount) {
    return c.error({ message: 'You can execute right away. Press Execute!' });
  }
 
  // requires approval
  return c.contract({
    abi: erc20Abi,
    chainId: `eip155:${chain.id}`,
    functionName: 'approve',
    to: sourceToken as EvmAddress,
    args: [
      tx.to,
      tokenPayment.amount
    ]
  })
});
// ...

Generate the mint transaction

Similarly to the approve transaction we need to return transaction data JSON for the actual transaction. Here we will format the Decent transaction response to be used in Farcaster Frames.

Decent makes it easy to plug transaction data into the Farcaster Frames API. No need to infer contract methods and worry about ABI and other complexities as we had to with approve.

frogfm
// file: "app/api/[[routes]]/route.tsx"
// ...
app.transaction('/tx', async (c) => {
  const account = c.address; // uses wallet connected to displayed Frame
 
  const tokens = await getUserBalance(chain.id, account);
  const sourceToken = await getTokenWithMaxBalance(chain.id, tokens, true, 25);
 
  // Build decent.xyz transaction here and return it
 
  const txConfig: BoxActionRequest = {
    sender: account!,
    srcChainId: chain?.id as ChainId,
    dstChainId: ChainId.POLYGON,
    srcToken: sourceToken, // really want to dynamically set based on user's balances
    dstToken: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
    slippage: 1,
    actionType: ActionType.EvmFunction,
    actionConfig: {
      contractAddress: "your_contract_address",
      chainId: ChainId.POLYGON,
      signature: "function mintTokens(uint256 requestedQuantity, address recipient)",
      args: [1n, account!],
      cost: {
        isNative: false,
        amount: 25000000n, // 25 USD in USDC which has 6 decimals
        tokenAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
      },
    }
  }
 
  const { tx, tokenPayment } = await getTransactionData(txConfig);
 
  // check for allowance if non native.
  if (sourceToken !== zeroAddress) {
    const allowance = await baseClient.readContract({
      address: sourceToken as EvmAddress,
      abi: erc20Abi,
      functionName: 'allowance',
      args: [
        account as EvmAddress,
        tx.to as EvmAddress,
      ]
    });
 
    if (allowance < tokenPayment.amount) {
      // requires approval
      return c.error({ message: 'Requires approval' });
    }
  }
 
  return c.res({
    chainId: `eip155:${base.id}`,
    method: "eth_sendTransaction",
    params: {
      to: tx.to,
      data: tx.data,
      value: tx.value.toString(),
    },
  },)
})
// ...

Handle the executed transaction from the user

You have now created all of the logic required to build this frame. In this step, we will prepare the actual interface users will see in their feeds.

This is a two part flow where the first frame will be the status frame and the second frame will be our success frame. On the first, we also need to have the success frame ready in case the transaction goes through blazingly fast.

Let's get building the initial status frame:

We need to keep track of the transaction hash we are going to get from the Farcaster client and also of the source Chain since Decent needs both to lookup the transaction. So how can we do that?

To track both the transaction hash we receive from the Farcaster client and Decent, we will tap into Frames' concept of state. We can store a little bit of data that persists between calls.

That way we can keep track of the data needed to query for transaction status without the need for external data storage solutions!

Refer to the sample code to see how we set up status tracking.

Here is the short form of state initialization that we used:

frogfm
// file: "app/api/[[routes]]/route.tsx"
// ...
type State = {
  txHash: string | undefined,
  srcChain: number,
}
 
const app = new Frog<{ State: State }>({
  assetsPath: '/',
  basePath: '/api',
  // Supply a Hub to enable frame verification.
  hub: neynar({ apiKey: process.env.NEYNAR_API_KEY!! }),
  initialState: {
    txHash: undefined,
    srcChain: -1,
  },
})
// ...

To check the actual status of the transaction we will use the Decent helper utils that call Decent's Transaction Status API.

This happens on the first frame where we redirect the user to after they execute the transaction as well as on the final screen where we send the user once they clicked to recheck status once.

frogfm
// file: "app/api/[[routes]]/route.tsx"
// ...
app.frame('/tx-success', async (c) => {
  let { transactionId, deriveState } = c;
 
  let state: State;
  console.log('current transactionId', transactionId);
  state = deriveState(previousState => {
    previousState.txHash = transactionId;
    previousState.srcChain = chain.id;
  })
 
  console.log('Source Chain TX Hash:', transactionId, 'State: ', state)
 
  const { status, transactionHash } = await getTransactionStatus(state.srcChain, state.txHash!!);
 
  if (status === 'Executed') {
    console.log('Transaction has been executed successfully.');
 
    try {
        // do your custom logic on successful transaction here
 
        return c.res({
        image: "https://dtech.vision/frame.png",
        imageAspectRatio: '1:1',
        intents: [
          <Button.Link href={`https://decent.xyz`}> Success, Go to Decent.xyz</Button.Link>,
        ],
      })
 
    } catch (err) {
      console.error('Error in our custom logic:', err);
    }
  } else if (status === 'Failed') {
    console.log('Transaction has failed.');
 
    // return a new frame where image shows failed
    return c.res({
      image: <div style={{ fontSize: 12 }}>Transaction failed, try again!</div>,
      imageAspectRatio: '1:1',
      intents: [
        // action is the post_url override apparently according to Frames.Transaction documentation https://frog.fm/intents/button-transaction#action-optional
        <Button.Transaction target="/tx" action="/tx-success">Mint Now</Button.Transaction>,
      ],
    })
  }
 
  return c.res({
    image: "https://dtech.vision/frame.png", // replace with your nice waiting screen image
    imageAspectRatio: '1:1',
    intents: [
      <Button action='/end'>Processing... Check Status</Button>,
    ],
  })
})
// ...

This is the end frame where the user will loop until the transaction is properly indexed, and we can show them our success screen. If the transaction fails, we prompt the user to try again from the start.

frogfm
// file: "app/api/[[routes]]/route.tsx"
// ...
app.frame('/end', async (c) => {
  let { previousState } = c;
 
  console.log('State: ', previousState)
 
  const { status, transactionHash } = await getTransactionStatus(previousState.srcChain, previousState.txHash!!);
 
  if (status === 'Executed') {
    console.log('Transaction has been executed successfully.');
 
    try {
        // do your custom logic on successful transaction here
 
        return c.res({
        image: "https://dtech.vision/frame.png",
        imageAspectRatio: '1:1',
        intents: [
          <Button.Link href={`https://decent.xyz`}> Success, Go to Decent.xyz</Button.Link>,
        ],
      })
 
    } catch (err) {
      console.error('Error in our custom logic:', err);
    }
  } else if (status === 'Failed') {
    console.log('Transaction has failed.');
 
    // return a new frame where image shows failed
    return c.res({
      image: <div style={{ fontSize: 12 }}>Transaction failed, try again!</div>,
      imageAspectRatio: '1:1',
      intents: [
        // action is the post_url override apparently according to Frames.Transaction documentation https://frog.fm/intents/button-transaction#action-optional
        <Button.Transaction target="/tx" action="/tx-success">Mint Now</Button.Transaction>,
      ],
    })
  }
 
  return c.res({
    image: "https://dtech.vision/frame.png", // replace with your nice waiting screen image
    imageAspectRatio: '1:1',
    intents: [
      <Button action='/end'>Processing... Check Status</Button>,
    ],
  })
})
// ...

Final remarks

Refer to the sample code to see the full code.

You can view Decent's supported chains here.