Skip to content

Commit aae1545

Browse files
Adapt SDK to token-based deploy API, add client-side verification
API changes: - Prepare endpoint: GET with URL params → POST with JSON body - Submit payload: token field replaces prepare_response - Response schema: token replaces server_signature Security verification before signing: - Layer 1: factory set (is deployment), sender matches agent_address, key in owners - Layer 2: compute EIP-4337 v0.7 UserOp hash locally, compare with server hash Prevents signing malicious operations even if server is compromised.
1 parent 4010fa0 commit aae1545

4 files changed

Lines changed: 439 additions & 44 deletions

File tree

python/ampersend-sdk/src/ampersend_sdk/ampersend/management.py

Lines changed: 160 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44
from typing import Any, Dict, List, Optional, Self
55

66
import httpx
7+
from eth_abi.abi import encode
78
from eth_account import Account
89
from eth_account.messages import encode_defunct
10+
from eth_utils.crypto import keccak
911
from pydantic import BaseModel, ConfigDict, Field
1012

1113
from .types import ApiError
1214

1315
DEFAULT_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

1622
class 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

Comments
 (0)