Skip to content

Multi-Chain Development Best Practices

Managing multiple tokens and networks is hard. This page contains helpful resources for specific implementation patterns that you might consider as you develop your application. If you haven't come across these questions yet, chances are you will!

Creating the Transaction Object

Once of Decent's core features is building calldata to execute cross-chain transactions. If you currently prepare transactions with wagmi or ethers, a simple way to think about this is Decent wraps the calldata you need to execute your smart contract function with the calldata required to navigate user tokens across chains and token swaps. We offer two patterns for developers to build transaction objects:

Explicit Action Config

If you are integrating transactions into your app for the first time, we recommend this solution. The Playground tab in Decent's Developer Console also assists teams with this configuration. In this pattern, developers specify the target function signature and arguments by which it can be successfully called. The Playground provides a no-code interface to help developers identify the correct function signature and populate arguments. A transaction object to mint an NFT might look like:

const txConfig: BoxActionRequest = { 
  actionType: ActionType.EvmFunction, 
  sender: 'Connected Wallet', 
  srcToken: '0x0000000000000000000000000000000000000000', 
  dstToken: '0x0000000000000000000000000000000000000000', 
  srcChainId: "Connected Chain", 
  dstChainId: ChainId.OPTIMISM, 
  slippage: 1, // 1%, Note: cannot be 0
  actionConfig: { 
    chainId: ChainId.OPTIMISM, 
    contractAddress: '0xa9de16b1484C11b481B23dbdEC534a29F5668a22', 
    cost: { 
      amount: 100000000000000n, 
      isNative: true, 
    }, 
    signature: 'function mint(address to, uint256 numberOfTokens)', 
    args: ['0x5D7370fCD6e446bbC14A64c1EFfe5FBB1c893232', 1n], 
  }, 
}; 

Please refer to either the Hooks or API sections of our documentation to see how you can build and send this transaction object on chain.

Wrap Existing Calldata

If you are already encoding function data in your application, you might prefer to wrap that information with Decent's. For example, if you are using Viem's encodeFunctionData, chances are this approach is for you. The same transaction object above can look like:

const data = encodeFunctionData({ 
abi: YOUR_NFT_ABI, 
functionName: 'mint', 
args: ['0xD34e4cA230B08c2Fca0ae2DA11D00d44D9B66850', 1n], 
}); 
 
const txConfig: BoxActionRequest = { 
  actionType: ActionType.EvmCalldataTx, 
  sender: 'Connected Wallet', 
  srcToken: '0x0000000000000000000000000000000000000000', 
  dstToken: '0x0000000000000000000000000000000000000000', 
  srcChainId: "Connected Chain", 
  dstChainId: ChainId.OPTIMISM, 
  slippage: 1, // 1%, Note: cannot be 0
  actionConfig: { 
    chainId: ChainId.OPTIMISM, 
    contractAddress: '0xa9de16b1484C11b481B23dbdEC534a29F5668a22', 
    cost: { 
      amount: 100000000000000n, 
      isNative: true, 
    }, 
    data: data 
  }, 
}; 

The same methods to prepare and send transactions apply. Please notice the different actionType used in these examples.

Meet Users Where They Are

Decent provides helpful hooks, like useUsersBalances, to return users' token balances across chains. We recommend that you show users' their available balances and enable them to choose a source token. As users have moved across chains, many forget what tokens they have where. Users typically appreciate a friendly interface that answers this question for them.

Balance Selector

Check for Approvals

If you select an ERC20 token as the source token to complete a transaction, you will need to make sure the user has granted Decent's UTB contract on the source chain an approval to spend the required amount for the transaction. Decent's React components abstract this step (only ever approving the amount required for a transaction); however, you will need to be mindful of it if you are building with our Hooks or APIs.

You can reference this sample code from the Mint button in our Launch NFTs site to see how to initiate an approval transaction, and view the usage of these functions in the MintButton.tsx component here.

Source Chain Confirmations

Same chain transactions - either direct or swap & execute - will confirm in one block, which is typically 1-3 seconds. Cross-chain transactions are subject to both the source and destination chains confirming the transaction. This typically takes between 20-40 seconds.

Decent's transactions are atomic, meaning any transaction that confirms on the source chain is guaranteed to execute on the destination chain. This is obviously valuable for application experiences, but it is particularly critical for any function looking to automate transactions (think trading bots or LLM transaction interfaces).

We recommend updating UI states based on the source chain confirmation for the best user experience. Because ultimate execution is guaranteed, there is no strong reason to force your users to wait until it has been finalized on the destination chain. You may still want to track the full transactions' status; the example below demonstrates how you might poll Decent's /getStatus endpoint.

Permissioned Functions

Some smart contracts include permissioned functions that resolve based on the wallet address sending the transaction. Often, you will see a check based on the msg.sender.

Cross-chain transactions are executed from relayer accounts versus the user's wallet directly. As a result, a msg.sender check will fail even if it actually is the authorized wallet address sending the source chain transaction.

msg.sender are typically impractical in a multi-chain context. Instead, we recommend a signature-based approach to authentication.

For example,the Songcamp team wanted users to mint NFTs without metadata and then send a permissioned function to write metadata to the NFT based on certain traits of the user's wallet address.

To authenticate wallets while enabling cross-chain execution of this arbitrary function, Decent included a hash of the user's wallet address in the transaction calldata and recovered the singer's address as follows:

View full contract

  function multiWriteToDiscSignature( 
      uint256[] memory tokenIds, 
      uint256[] memory songSelections, 
      bytes memory signature
  ) public {
      require(
          tokenIds.length == songSelections.length,
          "tokenIds and songSelections arrays must have the same length"
      );
 
      //Constructing the signed hash for signer address recovery
      bytes32 messageHash = keccak256(abi.encodePacked(tokenIds, songSelections));  
 
      bytes32 ethSignedHash = keccak256(  
          abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)  
      );  
 
      address signer = recoverSigner(ethSignedHash, signature);  
 
      for (uint256 i = 0; i < tokenIds.length; i++) {
          uint256 tokenId = tokenIds[i];
          uint256 songChoiceId = songSelections[i];
 
          // Check if CdMemory is not written before allowing an update
          CdMemory storage cd = readCdMemory[tokenId];
          require(
              ownerOf(tokenId) == signer,
              "Only the owner can set CdMemory"
          );
          require(
              songChoiceId >= 1 && songChoiceId <= 5,
              "Invalid song choice ID"
          );
          require(!cd.written, "One or more tokens are already written.");
 
          // Update CdMemory and mark it as written
          cd.writerAddress = signer;
          cd.songChoiceId = songChoiceId;
          cd.written = true;
 
          writeCount += 1;
 
          emit CdMemorySet(tokenId, signer, songChoiceId);
      }
  }
 
  //Helper function to determine signer address based on the signed hash and the signature
 
  function recoverSigner( 
      bytes32 ethSignedHash, 
      bytes memory signature
  ) public pure returns (address) { 
      // Extract the r, s, and v values from the signature
      (bytes32 r, bytes32 s, uint8 v) = splitSignature(signature); 
 
      // Recover and return the signer address
      return ecrecover(ethSignedHash, v, r, s); 
  }
 
  //Helper Function to split signature into RSV values
  function splitSignature( 
      bytes memory signature
  ) public pure returns (bytes32 r, bytes32 s, uint8 v) { 
      require(signature.length == 65, "Invalid signature length"); 
 
      assembly { 
          // Slice the r, s, and v components from the signature
          r := mload(add(signature, 32)) 
          s := mload(add(signature, 64)) 
          v := byte(0, mload(add(signature, 96))) 
      } 
  }