Skip to content

fix(swap): decode non-EVM receiver addresses from Bitcoin #256

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 13, 2025

Conversation

fadeev
Copy link
Member

@fadeev fadeev commented Apr 15, 2025

✅ Sui

npx hardhat bitcoin-deposit-and-call --amount 0.001 --recipient tb1qy9pqmk2pd9sv63g27jt8r657wy0d9ueeh0nqur  --memo cd09e46Dfa011a17183a6254C99cC72031dd16ED3e128c169564DD527C8e9bd85124BF6A890E5a5f71973ec13be525f912b1bcda5d631f10388cb13cd2202b7b8650d4d6fe4b233901

https://mempool.space/testnet4/tx/af6366356176878d5a0b8a3337e38472885d725227033d864f0dae38c5e56156

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/af6366356176878d5a0b8a3337e38472885d725227033d864f0dae38c5e56156

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0xda4963954fba112504f65479872ccfc848ce7c9a6c97213479a43805bf784d0c

❌ Solana

npx hardhat bitcoin-deposit-and-call --amount 0.0001 --recipient tb1qy9pqmk2pd9sv63g27jt8r657wy0d9ueeh0nqur  --memo cd09e46Dfa011a17183a6254C99cC72031dd16EDADF73ebA3Ebaa7254E859549A44c74eF7cff75018c243ed0142e4c5f24e266073271c20c21c1c385c4f4abc972fc3d7bd864d86c01

https://mempool.space/testnet4/tx/9b5977918764a643690794292ed2a9412bd4bfd4f7337884e8799cd38f2ed64b

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/9b5977918764a643690794292ed2a9412bd4bfd4f7337884e8799cd38f2ed64b

Solana doesn't work, because we're sending base58 bytes as the recipient, but the protocol expects receiver as bytes, which doesn't fit in OP_RETURN.

✅ Base

Just checking that nothing is broken with regular EVM transfers.

npx hardhat bitcoin-deposit-and-call --amount 0.0001 --recipient tb1qy9pqmk2pd9sv63g27jt8r657wy0d9ueeh0nqur  --memo cd09e46Dfa011a17183a6254C99cC72031dd16ED236b0DE675cC8F46AE186897fCCeFe3370C9eDeD4955a3F38ff86ae92A914445099caa8eA2B9bA3201                        

https://mempool.space/testnet4/tx/2a663048ccca46a8c3b5bd31ae9e02f29e829e94bbe43c00e1203aacca037ca5

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/2a663048ccca46a8c3b5bd31ae9e02f29e829e94bbe43c00e1203aacca037ca5

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0x53b6d81b7e45cd46588a46386563d0a524854bf11ae66762999b5397ae698962

Summary by CodeRabbit

  • New Features

    • Added validation to ensure messages for Bitcoin and Bitcoin Testnet chains have a minimum required length, improving error handling with a new error message for invalid message lengths.
  • Bug Fixes

    • Improved parsing of recipient addresses and withdrawal flags in messages, ensuring more accurate and flexible message processing.

Copy link

coderabbitai bot commented Apr 15, 2025

📝 Walkthrough

Walkthrough

A new custom error, InvalidMessageLength(), is introduced in the Swap contract. The onCall function now validates the message length for Bitcoin chains, reverting if it is less than 41 bytes. The logic for parsing the recipient address and the withdraw flag from the message is updated to handle variable-length addresses and to read the withdraw flag from the last byte.

Changes

File(s) Change Summary
examples/swap/contracts/Swap.sol Added InvalidMessageLength error; updated onCall to validate message length for BTC chains; changed parsing of params.to to dynamic slicing; adjusted withdraw flag extraction to use the last byte.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant SwapContract

    Caller->>SwapContract: onCall(chainId, message)
    alt Bitcoin or Bitcoin Testnet
        SwapContract->>SwapContract: Check message length >= 41
        alt Invalid length
            SwapContract-->>Caller: revert InvalidMessageLength()
        else Valid length
            SwapContract->>SwapContract: Parse params.to from message[20:]
            SwapContract->>SwapContract: Parse withdraw flag from last byte
        end
    else Other chains
        SwapContract->>SwapContract: Standard parsing logic
    end
Loading

Tip

⚡️ Faster reviews with caching
  • CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 16th. To opt out, configure Review - Disable Cache at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the Data Retention setting under your Organization Settings.

Enjoy the performance boost—your workflow just got faster.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@fadeev fadeev linked an issue Apr 15, 2025 that may be closed by this pull request
@fadeev fadeev marked this pull request as ready for review April 21, 2025 09:23
@fadeev fadeev requested a review from a team as a code owner April 21, 2025 09:23
@fadeev
Copy link
Member Author

fadeev commented Apr 21, 2025

@zeta-chain/smart-contracts please, review.

@0xM3R
Copy link
Contributor

0xM3R commented Apr 21, 2025

I have a few security improvement proposals that might come in handy.

if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
    // 1. Add proper length validation
    if (message.length < 41) revert InvalidMessageLength();
    if (message.length > 100) revert MessageTooLong(); // Add reasonable upper bound
    
    // 2. Extract and validate target address
    params.target = BytesHelperLib.bytesToAddress(message, 0);
    if (params.target == address(0)) revert InvalidTargetAddress();
    
    // 3. Extract recipient address (fixed 20 bytes)
    address recipient = BytesHelperLib.bytesToAddress(message, 20);
    if (recipient == address(0)) revert InvalidRecipientAddress();
    params.to = abi.encodePacked(recipient);
    
    // 4. Extract withdraw flag from fixed position
    params.withdraw = BytesHelperLib.bytesToBool(message, 40);
}

@fadeev
Copy link
Member Author

fadeev commented Apr 21, 2025

I have a few security improvement proposals that might come in handy.

if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
    // 1. Add proper length validation
    if (message.length < 41) revert InvalidMessageLength();
    if (message.length > 100) revert MessageTooLong(); // Add reasonable upper bound
    
    // 2. Extract and validate target address
    params.target = BytesHelperLib.bytesToAddress(message, 0);
    if (params.target == address(0)) revert InvalidTargetAddress();
    
    // 3. Extract recipient address (fixed 20 bytes)
    address recipient = BytesHelperLib.bytesToAddress(message, 20);
    if (recipient == address(0)) revert InvalidRecipientAddress();
    params.to = abi.encodePacked(recipient);
    
    // 4. Extract withdraw flag from fixed position
    params.withdraw = BytesHelperLib.bytesToBool(message, 40);
}

But this PR is intended to handle variable length receiver address. The reason why have a loop is to handle any kind of address, both Sui and Solana.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
examples/swap/contracts/Swap.sol (3)

97-100: Replace manual byte copying with more gas-efficient methods.

The current implementation manually copies bytes one at a time which is gas-inefficient. Consider using memory copy operations for better performance.

-            params.to = new bytes(message.length - 21);
-            for (uint256 i = 0; i < message.length - 21; i++) {
-                params.to[i] = message[20 + i];
-            }
+            // Extract recipient address from message starting at byte 20
+            uint256 recipientLength = message.length - 21;
+            params.to = new bytes(recipientLength);
+            assembly {
+                let toPtr := add(params.to, 0x20)
+                let msgPtr := add(add(message, 0x20), 20) // 0x20 (array header) + 20 (starting position)
+                mstore(toPtr, mload(msgPtr))
+                
+                // If recipient is longer than 32 bytes, copy the rest
+                if gt(recipientLength, 32) {
+                    mstore(add(toPtr, 32), mload(add(msgPtr, 32)))
+                }
+            }

Alternatively, for clarity and maintainability at a slight gas cost:

-            params.to = new bytes(message.length - 21);
-            for (uint256 i = 0; i < message.length - 21; i++) {
-                params.to[i] = message[20 + i];
-            }
+            uint256 recipientLength = message.length - 21;
+            params.to = new bytes(recipientLength);
+            for (uint256 i = 0; i < recipientLength; i += 32) {
+                // Copy up to 32 bytes at a time
+                uint256 chunkSize = recipientLength - i < 32 ? recipientLength - i : 32;
+                bytes32 chunk;
+                assembly {
+                    chunk := mload(add(add(add(message, 0x20), 20), i))
+                }
+                for (uint256 j = 0; j < chunkSize; j++) {
+                    params.to[i + j] = bytes1(uint8(chunk[j]));
+                }
+            }

101-104: Reading withdraw flag from last byte aligns with variable-length design.

This change correctly reads the withdraw flag from the last byte of the message, supporting variable-length addresses as required. However, consider adding documentation to clarify the expected message format.

Add a comment explaining the expected message format:

+            // Message format for Bitcoin chains:
+            // [0:20]                 - Target token address
+            // [20:(message.length-1)] - Recipient address of variable length
+            // [message.length-1]      - Withdraw flag
             params.withdraw = BytesHelperLib.bytesToBool(
                 message,
                 message.length - 1
             );

94-105: Document the Bitcoin message format in a comment block.

The changes properly implement variable-length address support for non-EVM chains, but lack documentation to explain the expected format.

Add a detailed comment block explaining the Bitcoin message format:

         if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
+            // Bitcoin message format:
+            // - Minimum length: 41 bytes
+            // - Structure:
+            //   * Bytes 0-19: Target token address (20 bytes)
+            //   * Bytes 20-(N-2): Recipient address (variable length)
+            //   * Byte N-1: Withdraw flag (1 byte)
+            // This format supports variable-length recipient addresses from different chains
+            // such as Sui and Solana that don't use fixed 20-byte addresses like EVM chains.
             if (message.length < 41) revert InvalidMessageLength();
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a7fef3d and c787d7b.

📒 Files selected for processing (1)
  • examples/swap/contracts/Swap.sol (2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (8)
  • GitHub Check: test (examples/token)
  • GitHub Check: test (examples/swap)
  • GitHub Check: test (examples/nft)
  • GitHub Check: test (examples/hello)
  • GitHub Check: test (examples/call)
  • GitHub Check: slither (examples/swap, swap.sarif)
  • GitHub Check: slither (examples/call, call.sarif)
  • GitHub Check: slither (examples/hello, hello.sarif)
🔇 Additional comments (1)
examples/swap/contracts/Swap.sol (1)

37-37: Custom error added for better error reporting.

Adding a custom error for invalid message length enhances error reporting and follows best practices for error handling in Solidity. This change is appropriate given the new validation check.

@@ -91,13 +92,16 @@ contract Swap is
});

if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
if (message.length < 41) revert InvalidMessageLength();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Consider adding maximum length validation.

While you've added a minimum length validation which is good, consider also adding a maximum length check to prevent excessively large messages that could lead to out-of-gas errors when processing.

-            if (message.length < 41) revert InvalidMessageLength();
+            if (message.length < 41 || message.length > 100) revert InvalidMessageLength();

Let's check if the codebase handles any potential upper bounds for messages elsewhere:


🏁 Script executed:

#!/bin/bash
# Search for any message length validations in the repo
rg -A 2 -B 2 "message.length" --glob "*.sol"

Length of output: 1025


Enforce an upper bound on message.length

To prevent out-of-gas attacks from excessively large payloads, add a maximum length check alongside the existing minimum check.

• File: examples/swap/contracts/Swap.sol
Line: 95

Suggested diff:

@@ examples/swap/contracts/Swap.sol:95
-            if (message.length < 41) revert InvalidMessageLength();
+            // Validate both minimum and maximum size to guard against oversized messages
+            if (message.length < 41 || message.length > 100) revert InvalidMessageLength();

Consider defining a named constant for clarity:

uint256 private constant MIN_MESSAGE_LENGTH = 41;
uint256 private constant MAX_MESSAGE_LENGTH = 100;
...
if (message.length < MIN_MESSAGE_LENGTH || message.length > MAX_MESSAGE_LENGTH) {
    revert InvalidMessageLength();
}

Comment on lines 94 to 105
if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
if (message.length < 41) revert InvalidMessageLength();
params.target = BytesHelperLib.bytesToAddress(message, 0);
params.to = abi.encodePacked(
BytesHelperLib.bytesToAddress(message, 20)
);
if (message.length >= 41) {
params.withdraw = BytesHelperLib.bytesToBool(message, 40);
params.to = new bytes(message.length - 21);
for (uint256 i = 0; i < message.length - 21; i++) {
params.to[i] = message[20 + i];
}
params.withdraw = BytesHelperLib.bytesToBool(
message,
message.length - 1
);
} else {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate target address to prevent security issues.

There is no validation that the target address (extracted at byte 0) is not the zero address, which could lead to security vulnerabilities.

             if (message.length < 41) revert InvalidMessageLength();
             params.target = BytesHelperLib.bytesToAddress(message, 0);
+            if (params.target == address(0)) revert InvalidAddress();
             params.to = new bytes(message.length - 21);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
if (message.length < 41) revert InvalidMessageLength();
params.target = BytesHelperLib.bytesToAddress(message, 0);
params.to = abi.encodePacked(
BytesHelperLib.bytesToAddress(message, 20)
);
if (message.length >= 41) {
params.withdraw = BytesHelperLib.bytesToBool(message, 40);
params.to = new bytes(message.length - 21);
for (uint256 i = 0; i < message.length - 21; i++) {
params.to[i] = message[20 + i];
}
params.withdraw = BytesHelperLib.bytesToBool(
message,
message.length - 1
);
} else {
if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
if (message.length < 41) revert InvalidMessageLength();
params.target = BytesHelperLib.bytesToAddress(message, 0);
if (params.target == address(0)) revert InvalidAddress();
params.to = new bytes(message.length - 21);
for (uint256 i = 0; i < message.length - 21; i++) {
params.to[i] = message[20 + i];
}
params.withdraw = BytesHelperLib.bytesToBool(
message,
message.length - 1
);
} else {

);
if (message.length >= 41) {
params.withdraw = BytesHelperLib.bytesToBool(message, 40);
params.to = new bytes(message.length - 21);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think some comments might help here, and some constants, but up to you

btw do we need to open issue to not forget about solana related discussion regarding this topic? or is already there?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I'm going to refactor this pretty soon to make sure it supports ABI encoding through inscriptions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the Solana issue is just that it doesn't fit in OP_RETURN, nothing has changed on that side. What we should do is switch to inscriptions by default to make Bitcoin naturally compatible with other chains, like swapping BTC into SOL.

@fadeev fadeev merged commit 43d370d into main May 13, 2025
14 checks passed
@fadeev fadeev deleted the swap-non-evm-target branch May 13, 2025 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Update Swap Contract to Support BTC > Solana / SUI / TON
3 participants