diff --git a/.env.example b/.env.example index 780a39b..3f0bc59 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ PK= CHAIN_ID= RELAYER_URL= +RPC_URL= BUILDER_API_KEY= BUILDER_SECRET= BUILDER_PASS_PHRASE= diff --git a/README.md b/README.md index fa9f6be..a2dc689 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Create a `.env` file based on .env.example with credentials: ```env RELAYER_URL=https://relayer-v2-staging.polymarket.dev/ +RPC_URL=https://polygon-rpc.com CHAIN_ID=80002 PK=your_private_key_here BUILDER_API_KEY=your_api_key diff --git a/examples/execute_proxy.py b/examples/execute_proxy.py new file mode 100644 index 0000000..51fe965 --- /dev/null +++ b/examples/execute_proxy.py @@ -0,0 +1,77 @@ +import os + +from dotenv import load_dotenv +from eth_abi import encode +from eth_utils import keccak, to_checksum_address +from py_builder_signing_sdk.config import BuilderApiKeyCreds, BuilderConfig + +from py_builder_relayer_client.client import RelayClient +from py_builder_relayer_client.models import ( + OperationType, + RelayerTxType, + SafeTransaction, +) + +load_dotenv() + + +def _function_selector(signature: str) -> bytes: + """First 4 bytes of Keccak-256 of the function signature.""" + return keccak(text=signature)[:4] + + +def encode_approve(spender: str, amount: int) -> str: + selector = _function_selector("approve(address,uint256)") + + encoded_args = encode(["address", "uint256"], [spender, amount]) + return "0x" + (selector + encoded_args).hex() + + +def create_usdc_approve_txn(token: str, spender: str): + token = to_checksum_address(token) + spender = to_checksum_address(spender) + + data = encode_approve( + spender, + 115792089237316195423570985008687907853269984665640564039457584007913129639935, + ) + return SafeTransaction( + to=token, + operation=OperationType.Call, + data=data, + value="0", + ) + + +def main(): + print("starting proxy wallet example...") + relayer_url = os.getenv("RELAYER_URL", "https://relayer-v2-staging.polymarket.dev/") + chain_id = int(os.getenv("CHAIN_ID", 80002)) + pk = os.getenv("PK") + + builder_config = BuilderConfig( + local_builder_creds=BuilderApiKeyCreds( + key=os.getenv("BUILDER_API_KEY"), + secret=os.getenv("BUILDER_SECRET"), + passphrase=os.getenv("BUILDER_PASS_PHRASE"), + ) + ) + + # Initialize client with PROXY transaction type + client = RelayClient( + relayer_url, chain_id, pk, builder_config, relayer_tx_type=RelayerTxType.PROXY + ) + + usdc = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + ctf = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045" + txn = create_usdc_approve_txn(usdc, ctf) + + resp = client.execute([txn, txn], "approve USDC on CTF via proxy") + print(resp) + + awaited_txn = resp.wait() + print(awaited_txn) + + +if __name__ == "__main__": + main() diff --git a/examples/redeem.py b/examples/redeem.py new file mode 100644 index 0000000..f21b5de --- /dev/null +++ b/examples/redeem.py @@ -0,0 +1,137 @@ +import os + +from dotenv import load_dotenv +from eth_abi import encode +from eth_utils import keccak, to_checksum_address, to_hex +from py_builder_signing_sdk.config import BuilderApiKeyCreds, BuilderConfig + +from py_builder_relayer_client.client import RelayClient +from py_builder_relayer_client.models import OperationType, SafeTransaction + +load_dotenv() + + +def _function_selector(signature: str) -> bytes: + """First 4 bytes of Keccak-256 of the function signature.""" + return keccak(text=signature)[:4] + + +def create_ctf_redeem_txn( + contract: str, + condition_id: str, + collateral: str, +) -> SafeTransaction: + """ + Create a CTF redeem transaction + """ + contract = to_checksum_address(contract) + collateral = to_checksum_address(collateral) + + # Function signature: redeemPositions(address,bytes32,bytes32,uint256[]) + selector = _function_selector("redeemPositions(address,bytes32,bytes32,uint256[])") + + # zeroHash for parentCollectionId + zero_hash = bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000000" + ) + + # Convert condition_id from hex string to bytes32 + condition_id_bytes = bytes.fromhex( + condition_id[2:] if condition_id.startswith("0x") else condition_id + ) + + # Encode arguments: [collateral, zeroHash, conditionId, [1, 2]] + encoded_args = encode( + ["address", "bytes32", "bytes32", "uint256[]"], + [collateral, zero_hash, condition_id_bytes, [1, 2]], + ) + + calldata = to_hex(selector + encoded_args) + + return SafeTransaction( + to=contract, + operation=OperationType.Call, + data=calldata, + value="0", + ) + + +def create_nr_adapter_redeem_txn( + contract: str, + condition_id: str, + redeem_amounts: list[int], +) -> SafeTransaction: + """ + Create a Negative Risk Adapter redeem transaction + """ + contract = to_checksum_address(contract) + + # Function signature: redeemPositions(bytes32,uint256[]) + selector = _function_selector("redeemPositions(bytes32,uint256[])") + + # Convert condition_id from hex string to bytes32 + condition_id_bytes = bytes.fromhex( + condition_id[2:] if condition_id.startswith("0x") else condition_id + ) + + # Encode arguments: [conditionId, redeemAmounts] + encoded_args = encode( + ["bytes32", "uint256[]"], [condition_id_bytes, redeem_amounts] + ) + + calldata = to_hex(selector + encoded_args) + + return SafeTransaction( + to=contract, + operation=OperationType.Call, + data=calldata, + value="0", + ) + + +def main(): + print("Starting...") + + relayer_url = os.getenv("RELAYER_URL", "https://relayer-v2-staging.polymarket.dev/") + chain_id = int(os.getenv("CHAIN_ID", 80002)) + pk = os.getenv("PK") + + builder_config = BuilderConfig( + local_builder_creds=BuilderApiKeyCreds( + key=os.getenv("BUILDER_API_KEY"), + secret=os.getenv("BUILDER_SECRET"), + passphrase=os.getenv("BUILDER_PASS_PHRASE"), + ) + ) + + client = RelayClient(relayer_url, chain_id, pk, builder_config) + + # Set your values here + neg_risk = False + condition_id = "0x...." # conditionId to redeem + + # Amounts to redeem per outcome, only necessary for neg risk + # Must be an array of length 2 with: + # the first element being the amount of yes tokens to redeem and + # the second element being the amount of no tokens to redeem + redeem_amounts = [111000000, 0] + + usdc = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + ctf = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045" + neg_risk_adapter = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" + + txn = ( + create_nr_adapter_redeem_txn(neg_risk_adapter, condition_id, redeem_amounts) + if neg_risk + else create_ctf_redeem_txn(ctf, condition_id, usdc) + ) + + resp = client.execute([txn], "redeem") + print(resp) + + awaited_txn = resp.wait() + print(awaited_txn) + + +if __name__ == "__main__": + main() diff --git a/py_builder_relayer_client/builder/proxy.py b/py_builder_relayer_client/builder/proxy.py new file mode 100644 index 0000000..da67e16 --- /dev/null +++ b/py_builder_relayer_client/builder/proxy.py @@ -0,0 +1,185 @@ +from typing import List, Optional + +from eth_abi import encode +from eth_abi.packed import encode_packed +from eth_utils import keccak, to_bytes, to_checksum_address, to_hex + +from ..config import ContractConfig +from ..constants.constants import PROXY_INIT_CODE_HASH +from ..models import ( + ProxyTransactionArgs, + SignatureParams, + TransactionRequest, + TransactionType, +) +from ..signer import Signer +from .derive import get_create2_address + +DEFAULT_GAS_LIMIT = 10_000_000 + + +def derive_proxy(address: str, proxy_factory: str) -> str: + """ + Derive proxy wallet address from signer address and proxy factory + """ + address = to_checksum_address(address) + proxy_factory = to_checksum_address(proxy_factory) + + # Salt is keccak256(encodePacked(["address"], [address])) + salt = keccak(encode_packed(["address"], [address])) + + proxy_address = get_create2_address( + bytecode_hash=PROXY_INIT_CODE_HASH, from_address=proxy_factory, salt=salt + ) + return to_checksum_address(proxy_address) + + +def create_struct_hash( + from_addr: str, + to_addr: str, + data: str, + tx_fee: str, + gas_price: str, + gas_limit: str, + nonce: str, + relay_hub_address: str, + relay_address: str, +) -> bytes: + """ + Create struct hash for proxy transaction + """ + relay_hub_prefix = b"rlx:" + encoded_from = ( + to_bytes(hexstr=from_addr) + if from_addr.startswith("0x") + else to_bytes(hexstr="0x" + from_addr) + ) + encoded_to = ( + to_bytes(hexstr=to_addr) + if to_addr.startswith("0x") + else to_bytes(hexstr="0x" + to_addr) + ) + encoded_data = ( + to_bytes(hexstr=data) if data.startswith("0x") else to_bytes(hexstr="0x" + data) + ) + + # Encode as 32-byte big-endian integers + encoded_tx_fee = int(tx_fee).to_bytes(32, "big") + encoded_gas_price = int(gas_price).to_bytes(32, "big") + encoded_gas_limit = int(gas_limit).to_bytes(32, "big") + encoded_nonce = int(nonce).to_bytes(32, "big") + + encoded_relay_hub = ( + to_bytes(hexstr=relay_hub_address) + if relay_hub_address.startswith("0x") + else to_bytes(hexstr="0x" + relay_hub_address) + ) + encoded_relay = ( + to_bytes(hexstr=relay_address) + if relay_address.startswith("0x") + else to_bytes(hexstr="0x" + relay_address) + ) + + data_to_hash = ( + relay_hub_prefix + + encoded_from + + encoded_to + + encoded_data + + encoded_tx_fee + + encoded_gas_price + + encoded_gas_limit + + encoded_nonce + + encoded_relay_hub + + encoded_relay + ) + return keccak(data_to_hash) + + +def create_proxy_signature(signer: Signer, struct_hash: bytes) -> str: + """ + Create proxy signature by signing the struct hash + """ + return signer.sign_message(struct_hash) + + +def get_gas_limit(signer: Signer, to: str, args: ProxyTransactionArgs) -> str: + """ + Get gas limit for proxy transaction + Uses provided gasLimit if available, otherwise estimates or uses default + """ + if args.gas_limit and args.gas_limit != "0": + return args.gas_limit + + try: + # Try to estimate gas if RPC URL is available + gas_limit_bigint = signer.estimate_gas( + from_address=args.from_address, + to=to, + data=args.data, + ) + return str(gas_limit_bigint) + except (ValueError, AttributeError) as e: + # If estimation fails (no RPC URL or RPC error), use default + print( + f"Error estimating gas for proxy transaction, using default gas limit: {e}" + ) + return str(DEFAULT_GAS_LIMIT) + + +def build_proxy_transaction_request( + signer: Signer, + args: ProxyTransactionArgs, + config: ContractConfig, + metadata: Optional[str] = None, +) -> TransactionRequest: + """ + Generate a Proxy Transaction Request for the Relayer API + """ + if config.proxy_factory is None or config.relay_hub is None: + raise ValueError( + "proxy_factory and relay_hub are required for PROXY transaction type" + ) + + proxy_wallet_factory = config.proxy_factory + to = proxy_wallet_factory + proxy = derive_proxy(args.from_address, proxy_wallet_factory) + relayer_fee = "0" + relay_hub = config.relay_hub + gas_limit_str = get_gas_limit(signer, to, args) + + sig_params = SignatureParams( + gas_price=args.gas_price, + gas_limit=gas_limit_str, + relayer_fee=relayer_fee, + relay_hub=relay_hub, + relay=args.relay, + ) + + tx_hash = create_struct_hash( + args.from_address, + to, + args.data, + relayer_fee, + args.gas_price, + gas_limit_str, + args.nonce, + relay_hub, + args.relay, + ) + + sig = create_proxy_signature(signer, tx_hash) + + if metadata is None: + metadata = "" + + return TransactionRequest( + type=TransactionType.PROXY.value, + from_address=args.from_address, + to=to, + proxy=proxy, + data=args.data, + nonce=args.nonce, + signature=sig, + signature_params=sig_params, + metadata=metadata, + ) diff --git a/py_builder_relayer_client/client.py b/py_builder_relayer_client/client.py index 29f80ce..b31adea 100644 --- a/py_builder_relayer_client/client.py +++ b/py_builder_relayer_client/client.py @@ -1,4 +1,5 @@ import logging +import os import time from py_builder_signing_sdk.config import BuilderConfig @@ -11,15 +12,23 @@ from .builder.derive import derive from .builder.safe import build_safe_transaction_request from .builder.create import build_safe_create_transaction_request +from .builder.proxy import build_proxy_transaction_request +from .encode.proxy import encode_proxy_transaction_data from .models import ( SafeTransaction, SafeTransactionArgs, SafeCreateTransactionArgs, TransactionType, + RelayerTxType, + RelayPayload, + ProxyTransactionArgs, + ProxyTransaction, + CallType, ) from .exceptions import RelayerClientException from .endpoints import ( GET_NONCE, + GET_RELAY_PAYLOAD, GET_DEPLOYED, GET_TRANSACTION, GET_TRANSACTIONS, @@ -40,16 +49,22 @@ def __init__( chain_id: int, private_key: str = None, builder_config: BuilderConfig = None, + relayer_tx_type: RelayerTxType = RelayerTxType.SAFE, + rpc_url: Optional[str] = None, ): self.relayer_url = ( relayer_url[0:-1] if relayer_url.endswith("/") else relayer_url ) self.chain_id = chain_id self.contract_config = get_contract_config(chain_id) + self.relayer_tx_type = relayer_tx_type + + # Use provided rpc_url, or fall back to environment variable + rpc_url = rpc_url or os.getenv("RPC_URL") self.signer = None if private_key is not None: - self.signer = Signer(private_key, chain_id) + self.signer = Signer(private_key, chain_id, rpc_url=rpc_url) self.builder_config = None if builder_config is not None: @@ -64,6 +79,20 @@ def get_nonce(self, signer_address: str, signer_type: str): f"{self.relayer_url}{GET_NONCE}?address={signer_address}&type={signer_type}" ) + def get_relay_payload(self, signer_address: str, signer_type: str) -> RelayPayload: + """ + Gets the relay payload (relay address and nonce) for the signer + """ + payload = get( + f"{self.relayer_url}{GET_RELAY_PAYLOAD}?address={signer_address}&type={signer_type}" + ) + if payload is None or payload.get("address") is None or payload.get("nonce") is None: + raise RelayerClientException("invalid relay payload received") + return RelayPayload( + address=payload.get("address"), + nonce=payload.get("nonce"), + ) + def get_transaction(self, transaction_id: str): """ Gets the transaction given the transaction_id @@ -88,8 +117,81 @@ def get_deployed(self, safe_address) -> bool: return False def execute(self, transactions: list[SafeTransaction], metadata: str = None): + """ + Executes a batch of transactions + Automatically routes to executeProxyTransactions or executeSafeTransactions based on relayer_tx_type + """ + if self.relayer_tx_type == RelayerTxType.PROXY: + return self.executeProxyTransactions(transactions, metadata) + else: + return self.executeSafeTransactions(transactions, metadata) + + def executeProxyTransactions(self, transactions: list[ProxyTransaction], metadata: str = None): + """ + Executes a batch of proxy transactions + """ + self.assert_signer_needed() + self.assert_builder_creds_needed() + + if self.contract_config.proxy_factory is None or self.contract_config.relay_hub is None: + raise RelayerClientException("Proxy contracts are not configured for this chain") + + self.logger.debug("Executing proxy transactions...") + start = time.time() + from_address = self.signer.address() + + # Get relay payload (relay address and nonce) + relay_payload = self.get_relay_payload(from_address, TransactionType.PROXY.value) + + # Convert SafeTransaction to ProxyTransaction + proxy_transactions = [ + ProxyTransaction( + to=txn.to, + type_code=CallType.Call, + data=txn.data, + value=txn.value, + ) + for txn in transactions + ] + + # Encode proxy transaction data + encoded_data = encode_proxy_transaction_data(proxy_transactions) + + # Build ProxyTransactionArgs + args = ProxyTransactionArgs( + from_address=from_address, + gas_price="0", + data=encoded_data, + relay=relay_payload.address, + nonce=relay_payload.nonce, + ) + + # Build proxy transaction request + txn_request = build_proxy_transaction_request( + signer=self.signer, + args=args, + config=self.contract_config, + metadata=metadata, + ).to_dict() + + self.logger.debug(f"Client side proxy request creation took: {(time.time() - start):.3f} seconds") + self.logger.debug(f"Created transaction request: {txn_request}") + + resp = self._post_request(POST, SUBMIT_TRANSACTION, txn_request) + return ClientRelayerTransactionResponse( + resp.get("transactionID"), + resp.get("transactionHash"), + self, + ) + + def executeSafeTransactions(self, transactions: list[SafeTransaction], metadata: str = None): + """ + Executes a batch of safe transactions + """ + import time self.assert_signer_needed() self.assert_builder_creds_needed() + safe_address = self.get_expected_safe() deployed = self.get_deployed(safe_address) @@ -98,6 +200,7 @@ def execute(self, transactions: list[SafeTransaction], metadata: str = None): f"expected safe {safe_address} is not deployed" ) + start = time.time() from_address = self.signer.address() nonce_payload = self.get_nonce(from_address, TransactionType.SAFE.value) @@ -120,7 +223,9 @@ def execute(self, transactions: list[SafeTransaction], metadata: str = None): metadata=metadata, ).to_dict() + self.logger.debug(f"Client side safe request creation took: {(time.time() - start):.3f} seconds") self.logger.debug(f"Created transaction request: {txn_request}") + resp = self._post_request(POST, SUBMIT_TRANSACTION, txn_request) return ClientRelayerTransactionResponse( resp.get("transactionID"), diff --git a/py_builder_relayer_client/config.py b/py_builder_relayer_client/config.py index 8fbcc8d..06f586b 100644 --- a/py_builder_relayer_client/config.py +++ b/py_builder_relayer_client/config.py @@ -1,4 +1,5 @@ from dataclasses import dataclass + from eth_utils import to_checksum_address from .exceptions import RelayerClientException @@ -14,6 +15,10 @@ class ContractConfig: safe_multisend: str + proxy_factory: str = None + + relay_hub: str = None + CONFIG = { 137: ContractConfig( @@ -21,12 +26,17 @@ class ContractConfig: safe_multisend=to_checksum_address( "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761" ), + proxy_factory=to_checksum_address("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"), + relay_hub=to_checksum_address("0xD216153c06E857cD7f72665E0aF1d7D82172F494"), ), 80002: ContractConfig( safe_factory=to_checksum_address("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"), safe_multisend=to_checksum_address( "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761" ), + # Proxy factory unsupported on Amoy testnet + proxy_factory=None, + relay_hub=None, ), } diff --git a/py_builder_relayer_client/constants/constants.py b/py_builder_relayer_client/constants/constants.py index 381f782..cc06aff 100644 --- a/py_builder_relayer_client/constants/constants.py +++ b/py_builder_relayer_client/constants/constants.py @@ -2,6 +2,10 @@ "0x2bce2127ff07fb632d16c8347c4ebf501f4841168bed00d9e6ef715ddb6fcecf" ) +PROXY_INIT_CODE_HASH = ( + "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b" +) + ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" SAFE_FACTORY_NAME = "Polymarket Contract Proxy Factory" diff --git a/py_builder_relayer_client/encode/proxy.py b/py_builder_relayer_client/encode/proxy.py new file mode 100644 index 0000000..5b7c0fb --- /dev/null +++ b/py_builder_relayer_client/encode/proxy.py @@ -0,0 +1,44 @@ +from typing import List + +from eth_abi import encode +from eth_utils import keccak, to_bytes, to_checksum_address, to_hex + +from ..models import CallType, ProxyTransaction + + +def encode_proxy_transaction_data(txns: List[ProxyTransaction]) -> str: + """ + Encode proxy transactions data using proxy function signature + """ + # Function selector: keccak256("proxy((uint8,address,uint256,bytes)[])")[:4] + # The function signature is: proxy((uint8,address,uint256,bytes)[]) + function_selector = keccak(text="proxy((uint8,address,uint256,bytes)[])")[:4] + + # Prepare the calls array + # Each call is a tuple: (uint8 typeCode, address to, uint256 value, bytes data) + calls = [] + for txn in txns: + to_address = to_checksum_address(txn.to) + data_bytes = ( + to_bytes(hexstr=txn.data) + if txn.data.startswith("0x") + else to_bytes(hexstr="0x" + txn.data) + ) + + calls.append( + ( + int(txn.type_code.value), + to_address, + int(txn.value), + data_bytes, + ) + ) + + # Encode the array of tuples + # The ABI type is: tuple(uint8,address,uint256,bytes)[] + encoded_data = encode(["(uint8,address,uint256,bytes)[]"], [calls]) + + # Combine function selector with encoded data + full_data = function_selector + encoded_data + + return to_hex(full_data) diff --git a/py_builder_relayer_client/endpoints.py b/py_builder_relayer_client/endpoints.py index 657fadb..63b52ec 100644 --- a/py_builder_relayer_client/endpoints.py +++ b/py_builder_relayer_client/endpoints.py @@ -1,4 +1,5 @@ GET_NONCE = "/nonce" +GET_RELAY_PAYLOAD = "/relay-payload" GET_TRANSACTION = "/transaction" GET_TRANSACTIONS = "/transactions" SUBMIT_TRANSACTION = "/submit" diff --git a/py_builder_relayer_client/models.py b/py_builder_relayer_client/models.py index 374d094..371354a 100644 --- a/py_builder_relayer_client/models.py +++ b/py_builder_relayer_client/models.py @@ -8,6 +8,12 @@ class OperationType(Enum): DelegateCall = 1 +class CallType(Enum): + Invalid = "0" + Call = "1" + DelegateCall = "2" + + @dataclass class SafeTransaction: to: str @@ -16,13 +22,33 @@ class SafeTransaction: value: str +@dataclass +class ProxyTransaction: + to: str + type_code: CallType + data: str + value: str + + class TransactionType(Enum): SAFE = "SAFE" SAFE_CREATE = "SAFE-CREATE" + PROXY = "PROXY" + + +class RelayerTxType(Enum): + SAFE = "SAFE" + PROXY = "PROXY" @dataclass class SignatureParams: + # Proxy RelayHub sig params + relayer_fee: str = None + gas_limit: str = None + relay_hub: str = None + relay: str = None + # SAFE signature params gas_price: str = None operation: str = None @@ -38,6 +64,16 @@ class SignatureParams: def to_dict(self): d = {} + # Proxy params + if self.relayer_fee is not None: + d["relayerFee"] = self.relayer_fee + if self.gas_limit is not None: + d["gasLimit"] = self.gas_limit + if self.relay_hub is not None: + d["relayHub"] = self.relay_hub + if self.relay is not None: + d["relay"] = self.relay + # SAFE params if self.gas_price is not None: d["gasPrice"] = self.gas_price if self.operation is not None: @@ -50,6 +86,7 @@ def to_dict(self): d["gasToken"] = self.gas_token if self.refund_receiver is not None: d["refundReceiver"] = self.refund_receiver + # SAFE-CREATE params if self.payment_token is not None: d["paymentToken"] = self.payment_token if self.payment is not None: @@ -109,6 +146,22 @@ class SafeCreateTransactionArgs: payment_receiver: str +@dataclass +class RelayPayload: + address: str + nonce: str + + +@dataclass +class ProxyTransactionArgs: + from_address: str + nonce: str + gas_price: str + data: str + relay: str + gas_limit: str = None + + class RelayerTransactionState(Enum): STATE_NEW = "STATE_NEW" STATE_EXECUTED = "STATE_EXECUTED" diff --git a/py_builder_relayer_client/signer.py b/py_builder_relayer_client/signer.py index 0667493..474068d 100644 --- a/py_builder_relayer_client/signer.py +++ b/py_builder_relayer_client/signer.py @@ -1,17 +1,24 @@ +from typing import Optional +import os +import requests + from eth_account import Account from eth_account.messages import encode_defunct from hexbytes import HexBytes + from .utils.utils import prepend_zx class Signer: - def __init__(self, private_key: str, chain_id: int): + def __init__(self, private_key: str, chain_id: int, rpc_url: Optional[str] = None): if private_key is None or chain_id is None: raise ValueError("invalid private key or chain_id") self.private_key = private_key self.account = Account.from_key(private_key) self.chain_id = chain_id + # Use provided rpc_url, or fall back to environment variable + self.rpc_url = rpc_url or os.getenv("RPC_URL") def address(self): return self.account.address @@ -34,3 +41,61 @@ def sign_eip712_struct_hash(self, message_hash): msg = encode_defunct(HexBytes(message_hash)) sig = Account.sign_message(msg, self.private_key).signature.hex() return prepend_zx(sig) + + def sign_message(self, message_hash): + """ + Signs a message hash (for proxy transactions) + """ + if isinstance(message_hash, bytes): + msg = encode_defunct(message_hash) + else: + msg = encode_defunct(HexBytes(message_hash)) + sig = Account.sign_message(msg, self.private_key).signature.hex() + return prepend_zx(sig) + + def estimate_gas(self, from_address: str, to: str, data: str) -> int: + """ + Estimate gas for a transaction by calling eth_estimateGas RPC method + """ + if self.rpc_url is None: + raise ValueError("RPC URL is required for gas estimation") + + # Prepare the transaction object for eth_estimateGas + tx_params = { + "from": from_address, + "to": to, + "data": data, + } + + # JSON-RPC request payload + payload = { + "jsonrpc": "2.0", + "method": "eth_estimateGas", + "params": [tx_params], + "id": 1, + } + + try: + response = requests.post(self.rpc_url, json=payload, timeout=10) + response.raise_for_status() + result = response.json() + + if "error" in result: + raise ValueError(f"RPC error: {result['error']}") + + if "result" not in result: + raise ValueError("No result in RPC response") + + # Convert hex string to int + gas_hex = result["result"] + if isinstance(gas_hex, str): + # Remove '0x' prefix and convert to int + gas_limit = int(gas_hex, 16) + else: + gas_limit = int(gas_hex) + + return gas_limit + except requests.RequestException as e: + raise ValueError(f"Failed to estimate gas: {e}") + except (ValueError, KeyError) as e: + raise ValueError(f"Invalid RPC response: {e}") diff --git a/tests/builder/test_proxy.py b/tests/builder/test_proxy.py new file mode 100644 index 0000000..97606f9 --- /dev/null +++ b/tests/builder/test_proxy.py @@ -0,0 +1,60 @@ +from unittest import TestCase + +from eth_utils import to_checksum_address + +from py_builder_relayer_client.builder.proxy import ( + build_proxy_transaction_request, + derive_proxy, +) +from py_builder_relayer_client.config import ContractConfig +from py_builder_relayer_client.models import ProxyTransactionArgs, RelayerTxType +from py_builder_relayer_client.signer import Signer + + +class TestProxy(TestCase): + + def test_derive_proxy(self): + """Test proxy wallet address derivation""" + address = "0x6e0c80c90ea6c15917308F820Eac91Ce2724B5b5" + proxy_factory = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + proxy_address = derive_proxy(address, proxy_factory) + # Verify it returns a valid checksummed address + self.assertTrue(proxy_address.startswith("0x")) + self.assertEqual(len(proxy_address), 42) + self.assertEqual(proxy_address, to_checksum_address(proxy_address)) + + def test_build_proxy_transaction_request(self): + """Test building a proxy transaction request""" + signer = Signer( + private_key="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + chain_id=137, + ) + config = ContractConfig( + safe_factory="0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b", + safe_multisend="0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761", + proxy_factory="0xaB45c5A4B0c941a2F231C04C3f49182e1A254052", + relay_hub="0xD216153c06E857cD7f72665E0aF1d7D82172F494", + ) + args = ProxyTransactionArgs( + from_address=signer.address(), + gas_price="0", + data="0x095ea7b3", + relay="0x1234567890123456789012345678901234567890", + nonce="0", + ) + request = build_proxy_transaction_request( + signer=signer, + args=args, + config=config, + metadata="test", + ) + self.assertEqual(request.type, "PROXY") + self.assertEqual(request.from_address, signer.address()) + self.assertEqual(request.to, config.proxy_factory) + self.assertIsNotNone(request.proxy) + self.assertIsNotNone(request.signature) + self.assertEqual(request.metadata, "test") + self.assertEqual(request.nonce, "0") + self.assertIsNotNone(request.signature_params) + self.assertEqual(request.signature_params.relay, args.relay) + self.assertEqual(request.signature_params.relay_hub, config.relay_hub) diff --git a/tests/encode/test_proxy.py b/tests/encode/test_proxy.py new file mode 100644 index 0000000..1452e26 --- /dev/null +++ b/tests/encode/test_proxy.py @@ -0,0 +1,44 @@ +from unittest import TestCase + +from py_builder_relayer_client.encode.proxy import encode_proxy_transaction_data +from py_builder_relayer_client.models import CallType, ProxyTransaction + + +class TestProxyEncode(TestCase): + + def test_encode_proxy_transaction_data_single(self): + """Test encoding a single proxy transaction""" + tx = ProxyTransaction( + to="0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + type_code=CallType.Call, + data="0x095ea7b3", + value="0", + ) + result = encode_proxy_transaction_data([tx]) + # Check basic format + self.assertTrue(result.startswith("0x")) + # Function selector is 4 bytes = 8 hex chars, plus "0x" = 10 chars minimum + self.assertGreaterEqual(len(result), 10) + # Should have reasonable length (at least function selector + some encoded data) + self.assertGreater(len(result), 100) + + def test_encode_proxy_transaction_data_multiple(self): + """Test encoding multiple proxy transactions""" + tx1 = ProxyTransaction( + to="0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + type_code=CallType.Call, + data="0x095ea7b3", + value="0", + ) + tx2 = ProxyTransaction( + to="0x4d97dcd97ec945f40cf65f87097ace5ea0476045", + type_code=CallType.Call, + data="0x095ea7b3", + value="0", + ) + result = encode_proxy_transaction_data([tx1, tx2]) + # Check basic format + self.assertTrue(result.startswith("0x")) + # Multiple transactions should be longer than single transaction + single_result = encode_proxy_transaction_data([tx1]) + self.assertGreater(len(result), len(single_result))