diff --git a/intentkit/skills/xmtp/transfer.py b/intentkit/skills/xmtp/transfer.py index 9e7a8ad4f..d6c84a897 100644 --- a/intentkit/skills/xmtp/transfer.py +++ b/intentkit/skills/xmtp/transfer.py @@ -1,8 +1,10 @@ +from decimal import ROUND_DOWN, Decimal, InvalidOperation from typing import cast, override from langchain_core.tools import ArgsSchema from langchain_core.tools.base import ToolException from pydantic import BaseModel, Field +from web3 import Web3 from web3.exceptions import ContractLogicError from intentkit.models.chat import ChatMessageAttachment, ChatMessageAttachmentType @@ -23,6 +25,25 @@ class TransferInput(BaseModel): ) +def _parse_token_amount(amount: str, decimals: int) -> int: + """Convert a human-readable token amount to base units exactly.""" + try: + decimal_amount = Decimal(amount) + except InvalidOperation: + raise ToolException("Amount must be a valid decimal number") + + if not decimal_amount.is_finite() or decimal_amount <= 0: + raise ToolException("Amount must be a positive finite number") + + base_units = decimal_amount * (Decimal(10) ** decimals) + if base_units != base_units.to_integral_value(rounding=ROUND_DOWN): + raise ToolException( + f"Amount has more decimal places than supported by token decimals ({decimals})" + ) + + return int(base_units) + + class XmtpTransfer(XmtpBaseTool): """Skill for creating XMTP transfer transactions.""" @@ -126,7 +147,7 @@ async def _arun( ) # Calculate amount in smallest unit (wei for ETH, token units for ERC20) - amount_int = int(float(amount) * (10**decimals)) + amount_int = _parse_token_amount(amount, decimals) if token_contract_address: # ERC20 Token Transfer @@ -139,7 +160,7 @@ async def _arun( method_id = "0xa9059cbb" # transfer(address,uint256) method ID # Encode to_address (32 bytes, left-padded) - to_address_clean = to_address.replace("0x", "") + to_address_clean = Web3.to_checksum_address(to_address).replace("0x", "") to_address_padded = to_address_clean.zfill(64) # Encode amount (32 bytes, left-padded) diff --git a/tests/skills/test_xmtp_transfer.py b/tests/skills/test_xmtp_transfer.py new file mode 100644 index 000000000..dbd227788 --- /dev/null +++ b/tests/skills/test_xmtp_transfer.py @@ -0,0 +1,22 @@ +import pytest +from langchain_core.tools.base import ToolException + +from intentkit.skills.xmtp.transfer import _parse_token_amount + + +def test_parse_token_amount_preserves_large_integer_precision(): + assert ( + _parse_token_amount("123456789123456789.123456789123456789", 18) + == 123456789123456789123456789123456789 + ) + + +@pytest.mark.parametrize("amount", ["1e309", "Infinity", "NaN", "0", "-1"]) +def test_parse_token_amount_rejects_non_positive_or_non_finite_values(amount): + with pytest.raises(ToolException): + _parse_token_amount(amount, 18) + + +def test_parse_token_amount_rejects_more_precision_than_token_supports(): + with pytest.raises(ToolException): + _parse_token_amount("1.0000001", 6)