44from typing import Any , Dict , List , Optional , Self
55
66import httpx
7+ from eth_abi .abi import encode
78from eth_account import Account
89from eth_account .messages import encode_defunct
10+ from eth_utils .crypto import keccak
911from pydantic import BaseModel , ConfigDict , Field
1012
1113from .types import ApiError
1214
1315DEFAULT_API_URL = "https://api.ampersend.ai"
16+ DEFAULT_CHAIN_ID = 84532 # Base Sepolia
17+
18+ # ERC-4337 EntryPoint v0.7 address (same on all chains)
19+ ENTRYPOINT_V07_ADDRESS = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
1420
1521
1622class SpendConfig (BaseModel ):
@@ -84,10 +90,12 @@ def __init__(
8490 api_key : str ,
8591 api_url : str = DEFAULT_API_URL ,
8692 timeout : float = 30 ,
93+ chain_id : int = DEFAULT_CHAIN_ID ,
8794 ) -> None :
8895 self ._api_key = api_key
8996 self ._base_url = api_url .rstrip ("/" )
9097 self ._timeout = timeout
98+ self ._chain_id = chain_id
9199 self ._http_client : Optional [httpx .AsyncClient ] = None
92100
93101 async def __aenter__ (self ) -> Self :
@@ -122,6 +130,8 @@ async def create_agent(
122130 only locally to derive the address and sign the deployment UserOp; it is
123131 never sent to the server.
124132
133+ Verifies the server response before signing to prevent malicious operations.
134+
125135 Args:
126136 name: Human-readable agent name.
127137 private_key: Agent owner private key (hex, 0x-prefixed).
@@ -134,33 +144,176 @@ async def create_agent(
134144 account = Account .from_key (private_key )
135145 agent_key_address = account .address
136146
137- # 1. Prepare unsigned UserOp
147+ # 1. Prepare unsigned UserOp (POST with JSON body)
138148 prepare_response = await self ._fetch (
139- "GET " ,
149+ "POST " ,
140150 "/api/v1/sdk/agents/prepare" ,
141- params = {"agent_key_address" : agent_key_address },
151+ json_data = {"agent_key_address" : agent_key_address },
142152 )
143153
144- # 2. Sign the UserOp hash
154+ # 2. Verify the response before signing (Layer 1: basic sanity checks)
155+ self ._verify_prepare_response (prepare_response , agent_key_address )
156+
157+ # 3. Sign the UserOp hash
145158 user_op_hash : str = prepare_response ["user_op_hash" ]
146159 hash_bytes = bytes .fromhex (user_op_hash .removeprefix ("0x" ))
147160 signed = account .sign_message (encode_defunct (primitive = hash_bytes ))
148161 signature = "0x" + signed .signature .hex ()
149162
150- # 3 . Build create payload
163+ # 4 . Build create payload (token-based, not full prepare_response)
151164 payload = {
165+ "token" : prepare_response ["token" ],
152166 "signature" : signature ,
153- "prepare_response" : prepare_response ,
154167 "name" : name ,
155168 "keys" : [{"address" : agent_key_address , "permission_id" : None }],
156169 "spend_config" : spend_config .to_api_dict () if spend_config else None ,
157170 "authorized_sellers" : authorized_sellers ,
158171 }
159172
160- # 4 . Submit signed deployment
173+ # 5 . Submit signed deployment
161174 response = await self ._fetch ("POST" , "/api/v1/sdk/agents" , json_data = payload )
162175 return AgentResponse (** response )
163176
177+ def _verify_prepare_response (
178+ self , response : Dict [str , Any ], agent_key_address : str
179+ ) -> None :
180+ """Verify the prepare response before signing.
181+
182+ Layer 1: Basic sanity checks to prevent signing malicious operations.
183+ Layer 2: Hash verification to ensure we sign what we expect.
184+ """
185+ user_op = response .get ("unsigned_user_op" , {})
186+
187+ # Layer 1, Check 1: This is a deployment (factory must be set)
188+ if user_op .get ("factory" ) is None :
189+ raise ApiError (
190+ "Invalid prepare response: not a deployment operation (factory is null)"
191+ )
192+
193+ # Layer 1, Check 2: Deploying the expected address
194+ sender = user_op .get ("sender" )
195+ agent_address = response .get ("agent_address" , "" )
196+ if not sender or sender .lower () != agent_address .lower ():
197+ raise ApiError (
198+ f"Invalid prepare response: sender mismatch "
199+ f"(expected { agent_address } , got { sender } )"
200+ )
201+
202+ # Layer 1, Check 3: Our key is in the owners list
203+ owners = response .get ("owners" , [])
204+ owner_addresses = [o .lower () for o in owners ]
205+ if agent_key_address .lower () not in owner_addresses :
206+ raise ApiError (
207+ f"Invalid prepare response: agent key { agent_key_address } "
208+ f"not in owners list"
209+ )
210+
211+ # Layer 2: Verify hash matches the UserOp we're signing
212+ computed_hash = self ._compute_user_op_hash (user_op )
213+ server_hash = response .get ("user_op_hash" , "" )
214+ if computed_hash .lower () != server_hash .lower ():
215+ raise ApiError (
216+ f"Invalid prepare response: hash mismatch "
217+ f"(computed { computed_hash } , server provided { server_hash } )"
218+ )
219+
220+ def _compute_user_op_hash (self , user_op : Dict [str , Any ]) -> str :
221+ """Compute ERC-4337 v0.7 UserOperation hash.
222+
223+ Reference: https://eips.ethereum.org/EIPS/eip-4337
224+ """
225+
226+ def to_bytes (hex_str : Optional [str ]) -> bytes :
227+ if not hex_str :
228+ return b""
229+ return bytes .fromhex (hex_str .removeprefix ("0x" ))
230+
231+ def to_int (value : Any ) -> int :
232+ if isinstance (value , int ):
233+ return value
234+ if isinstance (value , str ):
235+ return int (value , 16 ) if value .startswith ("0x" ) else int (value )
236+ return 0
237+
238+ def to_address (addr : Optional [str ]) -> bytes :
239+ if not addr :
240+ return b"\x00 " * 20
241+ return bytes .fromhex (addr .removeprefix ("0x" ).zfill (40 ))
242+
243+ # Extract fields
244+ sender = to_address (user_op .get ("sender" ))
245+ nonce = to_int (user_op .get ("nonce" , 0 ))
246+ factory = user_op .get ("factory" )
247+ factory_data = user_op .get ("factoryData" , "0x" )
248+ call_data = to_bytes (user_op .get ("callData" , "0x" ))
249+ call_gas_limit = to_int (user_op .get ("callGasLimit" , 0 ))
250+ verification_gas_limit = to_int (user_op .get ("verificationGasLimit" , 0 ))
251+ pre_verification_gas = to_int (user_op .get ("preVerificationGas" , 0 ))
252+ max_fee_per_gas = to_int (user_op .get ("maxFeePerGas" , 0 ))
253+ max_priority_fee_per_gas = to_int (user_op .get ("maxPriorityFeePerGas" , 0 ))
254+ paymaster = user_op .get ("paymaster" )
255+ paymaster_verification_gas_limit = to_int (
256+ user_op .get ("paymasterVerificationGasLimit" , 0 )
257+ )
258+ paymaster_post_op_gas_limit = to_int (user_op .get ("paymasterPostOpGasLimit" , 0 ))
259+ paymaster_data = to_bytes (user_op .get ("paymasterData" , "0x" ))
260+
261+ # Build initCode: factory + factoryData (or empty)
262+ if factory :
263+ init_code = to_address (factory ) + to_bytes (factory_data )
264+ else :
265+ init_code = b""
266+
267+ # Build paymasterAndData: paymaster + gasLimits (16 bytes each) + data
268+ if paymaster :
269+ paymaster_and_data = (
270+ to_address (paymaster )
271+ + paymaster_verification_gas_limit .to_bytes (16 , "big" )
272+ + paymaster_post_op_gas_limit .to_bytes (16 , "big" )
273+ + paymaster_data
274+ )
275+ else :
276+ paymaster_and_data = b""
277+
278+ # Pack gas limits: (verificationGasLimit << 128) | callGasLimit
279+ account_gas_limits = (verification_gas_limit << 128 ) | call_gas_limit
280+
281+ # Pack gas fees: (maxPriorityFeePerGas << 128) | maxFeePerGas
282+ gas_fees = (max_priority_fee_per_gas << 128 ) | max_fee_per_gas
283+
284+ # Encode inner struct (v0.7 format)
285+ inner_encoded = encode (
286+ [
287+ "address" ,
288+ "uint256" ,
289+ "bytes32" ,
290+ "bytes32" ,
291+ "bytes32" ,
292+ "uint256" ,
293+ "bytes32" ,
294+ "bytes32" ,
295+ ],
296+ [
297+ sender ,
298+ nonce ,
299+ keccak (init_code ),
300+ keccak (call_data ),
301+ account_gas_limits .to_bytes (32 , "big" ),
302+ pre_verification_gas ,
303+ gas_fees .to_bytes (32 , "big" ),
304+ keccak (paymaster_and_data ),
305+ ],
306+ )
307+
308+ # Final hash: keccak(keccak(innerEncoded), entryPoint, chainId)
309+ entrypoint = to_address (ENTRYPOINT_V07_ADDRESS )
310+ final_encoded = encode (
311+ ["bytes32" , "address" , "uint256" ],
312+ [keccak (inner_encoded ), entrypoint , self ._chain_id ],
313+ )
314+
315+ return "0x" + keccak (final_encoded ).hex ()
316+
164317 async def list_agents (self ) -> List [AgentResponse ]:
165318 """List all agents belonging to the authenticated user."""
166319 result = await self ._fetch ("GET" , "/api/v1/sdk/agents" )
0 commit comments