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!

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 checks are gas efficient but impractical in a multi-chain context (where gas is also negligible!). 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))) 
      } 
  }