t402 1.9.0__py3-none-any.whl → 1.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. t402/__init__.py +2 -1
  2. t402/a2a/__init__.py +73 -0
  3. t402/a2a/helpers.py +158 -0
  4. t402/a2a/types.py +145 -0
  5. t402/bridge/client.py +13 -5
  6. t402/bridge/constants.py +4 -2
  7. t402/bridge/router.py +1 -1
  8. t402/bridge/scan.py +3 -1
  9. t402/chains.py +268 -1
  10. t402/cli.py +31 -9
  11. t402/common.py +2 -0
  12. t402/cosmos_paywall_template.py +2 -0
  13. t402/django/__init__.py +42 -0
  14. t402/django/middleware.py +596 -0
  15. t402/encoding.py +9 -3
  16. t402/erc4337/accounts.py +56 -51
  17. t402/erc4337/bundlers.py +105 -99
  18. t402/erc4337/paymasters.py +100 -109
  19. t402/erc4337/types.py +39 -26
  20. t402/errors.py +213 -0
  21. t402/evm_paywall_template.py +1 -1
  22. t402/facilitator.py +125 -0
  23. t402/fastapi/middleware.py +1 -3
  24. t402/mcp/constants.py +3 -6
  25. t402/mcp/server.py +501 -84
  26. t402/mcp/web3_utils.py +493 -0
  27. t402/multisig/__init__.py +120 -0
  28. t402/multisig/constants.py +54 -0
  29. t402/multisig/safe.py +441 -0
  30. t402/multisig/signature.py +228 -0
  31. t402/multisig/transaction.py +238 -0
  32. t402/multisig/types.py +108 -0
  33. t402/multisig/utils.py +77 -0
  34. t402/near_paywall_template.py +2 -0
  35. t402/networks.py +34 -1
  36. t402/paywall.py +1 -3
  37. t402/schemes/__init__.py +143 -0
  38. t402/schemes/aptos/__init__.py +70 -0
  39. t402/schemes/aptos/constants.py +349 -0
  40. t402/schemes/aptos/exact_direct/__init__.py +44 -0
  41. t402/schemes/aptos/exact_direct/client.py +202 -0
  42. t402/schemes/aptos/exact_direct/facilitator.py +426 -0
  43. t402/schemes/aptos/exact_direct/server.py +272 -0
  44. t402/schemes/aptos/types.py +237 -0
  45. t402/schemes/cosmos/__init__.py +114 -0
  46. t402/schemes/cosmos/constants.py +211 -0
  47. t402/schemes/cosmos/exact_direct/__init__.py +21 -0
  48. t402/schemes/cosmos/exact_direct/client.py +198 -0
  49. t402/schemes/cosmos/exact_direct/facilitator.py +493 -0
  50. t402/schemes/cosmos/exact_direct/server.py +315 -0
  51. t402/schemes/cosmos/types.py +501 -0
  52. t402/schemes/evm/__init__.py +46 -1
  53. t402/schemes/evm/exact/__init__.py +11 -0
  54. t402/schemes/evm/exact/client.py +3 -1
  55. t402/schemes/evm/exact/facilitator.py +894 -0
  56. t402/schemes/evm/exact/server.py +1 -1
  57. t402/schemes/evm/exact_legacy/__init__.py +38 -0
  58. t402/schemes/evm/exact_legacy/client.py +291 -0
  59. t402/schemes/evm/exact_legacy/facilitator.py +777 -0
  60. t402/schemes/evm/exact_legacy/server.py +231 -0
  61. t402/schemes/evm/upto/__init__.py +12 -0
  62. t402/schemes/evm/upto/client.py +6 -2
  63. t402/schemes/evm/upto/facilitator.py +625 -0
  64. t402/schemes/evm/upto/server.py +243 -0
  65. t402/schemes/evm/upto/types.py +3 -1
  66. t402/schemes/interfaces.py +6 -2
  67. t402/schemes/near/__init__.py +137 -0
  68. t402/schemes/near/constants.py +189 -0
  69. t402/schemes/near/exact_direct/__init__.py +21 -0
  70. t402/schemes/near/exact_direct/client.py +204 -0
  71. t402/schemes/near/exact_direct/facilitator.py +455 -0
  72. t402/schemes/near/exact_direct/server.py +303 -0
  73. t402/schemes/near/types.py +419 -0
  74. t402/schemes/near/upto/__init__.py +54 -0
  75. t402/schemes/near/upto/types.py +272 -0
  76. t402/schemes/polkadot/__init__.py +72 -0
  77. t402/schemes/polkadot/constants.py +155 -0
  78. t402/schemes/polkadot/exact_direct/__init__.py +43 -0
  79. t402/schemes/polkadot/exact_direct/client.py +235 -0
  80. t402/schemes/polkadot/exact_direct/facilitator.py +428 -0
  81. t402/schemes/polkadot/exact_direct/server.py +292 -0
  82. t402/schemes/polkadot/types.py +385 -0
  83. t402/schemes/registry.py +6 -2
  84. t402/schemes/stacks/__init__.py +68 -0
  85. t402/schemes/stacks/constants.py +122 -0
  86. t402/schemes/stacks/exact_direct/__init__.py +43 -0
  87. t402/schemes/stacks/exact_direct/client.py +222 -0
  88. t402/schemes/stacks/exact_direct/facilitator.py +424 -0
  89. t402/schemes/stacks/exact_direct/server.py +292 -0
  90. t402/schemes/stacks/types.py +380 -0
  91. t402/schemes/svm/__init__.py +44 -0
  92. t402/schemes/svm/exact/__init__.py +35 -0
  93. t402/schemes/svm/exact/client.py +23 -0
  94. t402/schemes/svm/exact/facilitator.py +24 -0
  95. t402/schemes/svm/exact/server.py +20 -0
  96. t402/schemes/svm/upto/__init__.py +23 -0
  97. t402/schemes/svm/upto/types.py +193 -0
  98. t402/schemes/tezos/__init__.py +84 -0
  99. t402/schemes/tezos/constants.py +372 -0
  100. t402/schemes/tezos/exact_direct/__init__.py +22 -0
  101. t402/schemes/tezos/exact_direct/client.py +226 -0
  102. t402/schemes/tezos/exact_direct/facilitator.py +491 -0
  103. t402/schemes/tezos/exact_direct/server.py +277 -0
  104. t402/schemes/tezos/types.py +220 -0
  105. t402/schemes/ton/__init__.py +24 -2
  106. t402/schemes/ton/exact/__init__.py +7 -0
  107. t402/schemes/ton/exact/facilitator.py +730 -0
  108. t402/schemes/ton/exact/server.py +1 -1
  109. t402/schemes/ton/upto/__init__.py +31 -0
  110. t402/schemes/ton/upto/types.py +215 -0
  111. t402/schemes/tron/__init__.py +28 -2
  112. t402/schemes/tron/exact/__init__.py +9 -0
  113. t402/schemes/tron/exact/facilitator.py +673 -0
  114. t402/schemes/tron/exact/server.py +1 -1
  115. t402/schemes/tron/upto/__init__.py +30 -0
  116. t402/schemes/tron/upto/types.py +213 -0
  117. t402/stacks_paywall_template.py +2 -0
  118. t402/starlette/__init__.py +38 -0
  119. t402/starlette/middleware.py +522 -0
  120. t402/svm.py +45 -11
  121. t402/svm_paywall_template.py +1 -1
  122. t402/ton.py +6 -2
  123. t402/ton_paywall_template.py +1 -192
  124. t402/tron.py +2 -0
  125. t402/tron_paywall_template.py +2 -0
  126. t402/types.py +103 -3
  127. t402/wdk/chains.py +1 -1
  128. t402/wdk/errors.py +15 -5
  129. t402/wdk/signer.py +11 -2
  130. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/METADATA +42 -1
  131. t402-1.10.0.dist-info/RECORD +156 -0
  132. t402-1.9.0.dist-info/RECORD +0 -72
  133. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/WHEEL +0 -0
  134. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/entry_points.txt +0 -0
t402/multisig/safe.py ADDED
@@ -0,0 +1,441 @@
1
+ """
2
+ Safe client implementation for T402 Multi-sig support.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from eth_account import Account
8
+ from web3 import AsyncWeb3, Web3
9
+ from web3.types import TxParams
10
+
11
+ from .constants import (
12
+ GET_OWNERS_SELECTOR,
13
+ GET_THRESHOLD_SELECTOR,
14
+ NONCE_SELECTOR,
15
+ GET_TRANSACTION_HASH_SELECTOR,
16
+ EXEC_TRANSACTION_SELECTOR,
17
+ )
18
+ from .types import (
19
+ SafeConfig,
20
+ SafeInfo,
21
+ SafeSignature,
22
+ SafeTransaction,
23
+ SignatureType,
24
+ TransactionRequest,
25
+ ExecutionResult,
26
+ )
27
+ from .utils import generate_request_id, current_timestamp, sort_addresses
28
+
29
+
30
+ class SafeClient:
31
+ """Client for interacting with Safe multi-sig contracts."""
32
+
33
+ def __init__(self, config: SafeConfig):
34
+ """
35
+ Initialize SafeClient.
36
+
37
+ Args:
38
+ config: Safe configuration with address and RPC URL.
39
+ """
40
+ self.address = Web3.to_checksum_address(config.address)
41
+ self.rpc_url = config.rpc_url
42
+ self._chain_id = config.chain_id
43
+ self._w3: Optional[AsyncWeb3] = None
44
+ self._cached_info: Optional[SafeInfo] = None
45
+
46
+ async def _get_w3(self) -> AsyncWeb3:
47
+ """Get or create async Web3 instance."""
48
+ if self._w3 is None:
49
+ self._w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(self.rpc_url))
50
+ return self._w3
51
+
52
+ async def get_chain_id(self) -> int:
53
+ """Get the chain ID."""
54
+ if self._chain_id is None:
55
+ w3 = await self._get_w3()
56
+ self._chain_id = await w3.eth.chain_id
57
+ return self._chain_id
58
+
59
+ async def get_info(self) -> SafeInfo:
60
+ """
61
+ Get Safe information (owners, threshold, nonce).
62
+
63
+ Returns:
64
+ SafeInfo with current Safe state.
65
+ """
66
+ owners = await self.get_owners()
67
+ threshold = await self.get_threshold()
68
+ nonce = await self.get_nonce()
69
+ chain_id = await self.get_chain_id()
70
+
71
+ info = SafeInfo(
72
+ address=self.address,
73
+ owners=owners,
74
+ threshold=threshold,
75
+ nonce=nonce,
76
+ chain_id=chain_id,
77
+ )
78
+ self._cached_info = info
79
+ return info
80
+
81
+ async def get_owners(self) -> List[str]:
82
+ """Get list of Safe owners."""
83
+ w3 = await self._get_w3()
84
+ result = await w3.eth.call({"to": self.address, "data": GET_OWNERS_SELECTOR})
85
+
86
+ # Decode address[] from ABI
87
+ # Skip offset (32 bytes) and length (32 bytes)
88
+ if len(result) < 64:
89
+ return []
90
+
91
+ length = int.from_bytes(result[32:64], "big")
92
+ owners = []
93
+ for i in range(length):
94
+ start = 64 + i * 32
95
+ addr_bytes = result[start + 12 : start + 32]
96
+ owners.append(Web3.to_checksum_address(addr_bytes.hex()))
97
+
98
+ return owners
99
+
100
+ async def get_threshold(self) -> int:
101
+ """Get the required number of signatures."""
102
+ w3 = await self._get_w3()
103
+ result = await w3.eth.call({"to": self.address, "data": GET_THRESHOLD_SELECTOR})
104
+ return int.from_bytes(result, "big")
105
+
106
+ async def get_nonce(self) -> int:
107
+ """Get the current Safe nonce."""
108
+ w3 = await self._get_w3()
109
+ result = await w3.eth.call({"to": self.address, "data": NONCE_SELECTOR})
110
+ return int.from_bytes(result, "big")
111
+
112
+ async def is_owner(self, address: str) -> bool:
113
+ """Check if an address is a Safe owner."""
114
+ owners = await self.get_owners()
115
+ address_lower = address.lower()
116
+ return any(o.lower() == address_lower for o in owners)
117
+
118
+ async def propose_transaction(
119
+ self, tx: SafeTransaction
120
+ ) -> TransactionRequest:
121
+ """
122
+ Create a new transaction proposal.
123
+
124
+ Args:
125
+ tx: The Safe transaction to propose.
126
+
127
+ Returns:
128
+ TransactionRequest for collecting signatures.
129
+ """
130
+ # Get nonce if not set
131
+ if tx.nonce is None:
132
+ tx.nonce = await self.get_nonce()
133
+
134
+ # Calculate transaction hash
135
+ tx_hash = await self.get_transaction_hash(tx)
136
+
137
+ # Get threshold
138
+ threshold = await self.get_threshold()
139
+
140
+ # Create request
141
+ from .constants import DEFAULT_REQUEST_EXPIRATION_SECONDS
142
+
143
+ now = current_timestamp()
144
+ request = TransactionRequest(
145
+ id=generate_request_id(),
146
+ safe_address=self.address,
147
+ transaction=tx,
148
+ transaction_hash=tx_hash,
149
+ signatures={},
150
+ threshold=threshold,
151
+ created_at=now,
152
+ expires_at=now + DEFAULT_REQUEST_EXPIRATION_SECONDS,
153
+ )
154
+
155
+ return request
156
+
157
+ async def get_transaction_hash(self, tx: SafeTransaction) -> str:
158
+ """
159
+ Calculate the Safe transaction hash.
160
+
161
+ Args:
162
+ tx: The Safe transaction.
163
+
164
+ Returns:
165
+ Transaction hash as hex string.
166
+ """
167
+ w3 = await self._get_w3()
168
+
169
+ # Build calldata for getTransactionHash
170
+ calldata = self._build_get_transaction_hash_calldata(tx)
171
+
172
+ result = await w3.eth.call({"to": self.address, "data": calldata})
173
+ return "0x" + result.hex()
174
+
175
+ def sign_transaction(
176
+ self, tx: SafeTransaction, private_key: str
177
+ ) -> SafeSignature:
178
+ """
179
+ Sign a transaction with a private key.
180
+
181
+ Args:
182
+ tx: The Safe transaction.
183
+ private_key: Private key hex string.
184
+
185
+ Returns:
186
+ SafeSignature containing the signature.
187
+ """
188
+ # Get account from private key
189
+ account = Account.from_key(private_key)
190
+
191
+ # We need the transaction hash - this should be calculated beforehand
192
+ # For now, we'll compute it synchronously using Web3
193
+ w3_sync = Web3(Web3.HTTPProvider(self.rpc_url))
194
+ calldata = self._build_get_transaction_hash_calldata(tx)
195
+ result = w3_sync.eth.call({"to": self.address, "data": calldata})
196
+ tx_hash = result
197
+
198
+ # Sign the hash
199
+ signed = account.signHash(tx_hash)
200
+
201
+ # Adjust v value for Safe (add 4 for EOA signature)
202
+ v = signed.v
203
+ if v < 27:
204
+ v += 27
205
+ v += 4
206
+
207
+ # Combine r, s, v
208
+ signature = signed.r.to_bytes(32, "big") + signed.s.to_bytes(32, "big") + bytes([v])
209
+
210
+ return SafeSignature(
211
+ signer=account.address,
212
+ signature=signature,
213
+ signature_type=SignatureType.EOA,
214
+ )
215
+
216
+ async def sign_transaction_async(
217
+ self, tx: SafeTransaction, private_key: str
218
+ ) -> SafeSignature:
219
+ """
220
+ Sign a transaction asynchronously.
221
+
222
+ Args:
223
+ tx: The Safe transaction.
224
+ private_key: Private key hex string.
225
+
226
+ Returns:
227
+ SafeSignature containing the signature.
228
+ """
229
+ # Get account from private key
230
+ account = Account.from_key(private_key)
231
+
232
+ # Get transaction hash
233
+ tx_hash = await self.get_transaction_hash(tx)
234
+ tx_hash_bytes = bytes.fromhex(tx_hash[2:])
235
+
236
+ # Sign the hash
237
+ signed = account.signHash(tx_hash_bytes)
238
+
239
+ # Adjust v value for Safe (add 4 for EOA signature)
240
+ v = signed.v
241
+ if v < 27:
242
+ v += 27
243
+ v += 4
244
+
245
+ # Combine r, s, v
246
+ signature = signed.r.to_bytes(32, "big") + signed.s.to_bytes(32, "big") + bytes([v])
247
+
248
+ return SafeSignature(
249
+ signer=account.address,
250
+ signature=signature,
251
+ signature_type=SignatureType.EOA,
252
+ )
253
+
254
+ def add_signature(
255
+ self, request: TransactionRequest, sig: SafeSignature
256
+ ) -> None:
257
+ """
258
+ Add a signature to a transaction request.
259
+
260
+ Args:
261
+ request: The transaction request.
262
+ sig: The signature to add.
263
+
264
+ Raises:
265
+ ValueError: If signer is not an owner.
266
+ """
267
+ request.signatures[sig.signer.lower()] = sig
268
+
269
+ async def execute_transaction(
270
+ self,
271
+ request: TransactionRequest,
272
+ private_key: str,
273
+ ) -> ExecutionResult:
274
+ """
275
+ Execute a Safe transaction with collected signatures.
276
+
277
+ Args:
278
+ request: Transaction request with signatures.
279
+ private_key: Private key of the executor.
280
+
281
+ Returns:
282
+ ExecutionResult with transaction details.
283
+
284
+ Raises:
285
+ ValueError: If not enough signatures.
286
+ """
287
+ if not request.is_ready():
288
+ raise ValueError(
289
+ f"Not enough signatures: have {request.collected_count()}, "
290
+ f"need {request.threshold}"
291
+ )
292
+
293
+ w3 = await self._get_w3()
294
+ account = Account.from_key(private_key)
295
+
296
+ # Pack signatures sorted by signer address
297
+ packed_sigs = self._pack_signatures(request.signatures)
298
+
299
+ # Build execTransaction calldata
300
+ calldata = self._build_exec_transaction_calldata(
301
+ request.transaction, packed_sigs
302
+ )
303
+
304
+ # Build transaction
305
+ nonce = await w3.eth.get_transaction_count(account.address)
306
+ gas_price = await w3.eth.gas_price
307
+
308
+ # Estimate gas
309
+ gas_estimate = await w3.eth.estimate_gas(
310
+ {
311
+ "from": account.address,
312
+ "to": self.address,
313
+ "data": calldata,
314
+ }
315
+ )
316
+
317
+ tx_params: TxParams = {
318
+ "from": account.address,
319
+ "to": self.address,
320
+ "data": calldata,
321
+ "gas": gas_estimate,
322
+ "gasPrice": gas_price,
323
+ "nonce": nonce,
324
+ "chainId": await self.get_chain_id(),
325
+ }
326
+
327
+ # Sign and send
328
+ signed_tx = account.sign_transaction(tx_params)
329
+ tx_hash = await w3.eth.send_raw_transaction(signed_tx.rawTransaction)
330
+
331
+ return ExecutionResult(
332
+ tx_hash="0x" + tx_hash.hex(),
333
+ success=True,
334
+ )
335
+
336
+ async def wait_for_execution(self, tx_hash: str) -> ExecutionResult:
337
+ """
338
+ Wait for a transaction to be mined.
339
+
340
+ Args:
341
+ tx_hash: Transaction hash.
342
+
343
+ Returns:
344
+ ExecutionResult with final status.
345
+ """
346
+ w3 = await self._get_w3()
347
+
348
+ # Wait for receipt
349
+ receipt = await w3.eth.wait_for_transaction_receipt(tx_hash)
350
+
351
+ return ExecutionResult(
352
+ tx_hash=tx_hash,
353
+ success=receipt["status"] == 1,
354
+ gas_used=receipt["gasUsed"],
355
+ block_number=receipt["blockNumber"],
356
+ )
357
+
358
+ def _pack_signatures(self, signatures: dict) -> bytes:
359
+ """Pack signatures sorted by signer address."""
360
+ # Sort signers by address
361
+ sorted_signers = sort_addresses(list(signatures.keys()))
362
+
363
+ # Pack signatures
364
+ packed = b""
365
+ for signer in sorted_signers:
366
+ sig = signatures[signer.lower()]
367
+ packed += sig.signature
368
+
369
+ return packed
370
+
371
+ def _build_exec_transaction_calldata(
372
+ self, tx: SafeTransaction, signatures: bytes
373
+ ) -> bytes:
374
+ """Build execTransaction calldata."""
375
+ from eth_abi import encode
376
+
377
+ # Encode parameters
378
+ encoded = encode(
379
+ [
380
+ "address",
381
+ "uint256",
382
+ "bytes",
383
+ "uint8",
384
+ "uint256",
385
+ "uint256",
386
+ "uint256",
387
+ "address",
388
+ "address",
389
+ "bytes",
390
+ ],
391
+ [
392
+ Web3.to_checksum_address(tx.to),
393
+ tx.value,
394
+ tx.data,
395
+ tx.operation,
396
+ tx.safe_tx_gas,
397
+ tx.base_gas,
398
+ tx.gas_price,
399
+ Web3.to_checksum_address(tx.gas_token),
400
+ Web3.to_checksum_address(tx.refund_receiver),
401
+ signatures,
402
+ ],
403
+ )
404
+
405
+ return EXEC_TRANSACTION_SELECTOR + encoded
406
+
407
+ def _build_get_transaction_hash_calldata(self, tx: SafeTransaction) -> bytes:
408
+ """Build getTransactionHash calldata."""
409
+ from eth_abi import encode
410
+
411
+ nonce = tx.nonce if tx.nonce is not None else 0
412
+
413
+ # Encode parameters
414
+ encoded = encode(
415
+ [
416
+ "address",
417
+ "uint256",
418
+ "bytes",
419
+ "uint8",
420
+ "uint256",
421
+ "uint256",
422
+ "uint256",
423
+ "address",
424
+ "address",
425
+ "uint256",
426
+ ],
427
+ [
428
+ Web3.to_checksum_address(tx.to),
429
+ tx.value,
430
+ tx.data,
431
+ tx.operation,
432
+ tx.safe_tx_gas,
433
+ tx.base_gas,
434
+ tx.gas_price,
435
+ Web3.to_checksum_address(tx.gas_token),
436
+ Web3.to_checksum_address(tx.refund_receiver),
437
+ nonce,
438
+ ],
439
+ )
440
+
441
+ return GET_TRANSACTION_HASH_SELECTOR + encoded
@@ -0,0 +1,228 @@
1
+ """
2
+ Signature collector for T402 Multi-sig support.
3
+ """
4
+
5
+ import threading
6
+ from typing import Dict, List, Optional
7
+
8
+ from .constants import DEFAULT_REQUEST_EXPIRATION_SECONDS
9
+ from .types import (
10
+ SafeSignature,
11
+ SafeTransaction,
12
+ TransactionRequest,
13
+ )
14
+ from .utils import generate_request_id, current_timestamp, combine_signatures
15
+
16
+
17
+ class SignatureCollector:
18
+ """Manages pending multi-sig transactions and signature collection."""
19
+
20
+ def __init__(self, expiration_seconds: Optional[int] = None):
21
+ """
22
+ Initialize SignatureCollector.
23
+
24
+ Args:
25
+ expiration_seconds: Request expiration time (default: 1 hour).
26
+ """
27
+ self._pending_requests: Dict[str, TransactionRequest] = {}
28
+ self._expiration_seconds = (
29
+ expiration_seconds or DEFAULT_REQUEST_EXPIRATION_SECONDS
30
+ )
31
+ self._lock = threading.RLock()
32
+
33
+ def create_request(
34
+ self,
35
+ safe_address: str,
36
+ tx: SafeTransaction,
37
+ tx_hash: str,
38
+ owners: List[str],
39
+ threshold: int,
40
+ ) -> TransactionRequest:
41
+ """
42
+ Create a new signature collection request.
43
+
44
+ Args:
45
+ safe_address: Address of the Safe.
46
+ tx: The Safe transaction.
47
+ tx_hash: Transaction hash for signing.
48
+ owners: List of owner addresses.
49
+ threshold: Number of signatures required.
50
+
51
+ Returns:
52
+ New TransactionRequest.
53
+ """
54
+ with self._lock:
55
+ now = current_timestamp()
56
+ request = TransactionRequest(
57
+ id=generate_request_id(),
58
+ safe_address=safe_address,
59
+ transaction=tx,
60
+ transaction_hash=tx_hash,
61
+ signatures={},
62
+ threshold=threshold,
63
+ created_at=now,
64
+ expires_at=now + self._expiration_seconds,
65
+ )
66
+ self._pending_requests[request.id] = request
67
+ return request
68
+
69
+ def add_signature(self, request_id: str, sig: SafeSignature) -> None:
70
+ """
71
+ Add a signature to a request.
72
+
73
+ Args:
74
+ request_id: Request ID.
75
+ sig: Signature to add.
76
+
77
+ Raises:
78
+ ValueError: If request not found, expired, or already signed.
79
+ """
80
+ with self._lock:
81
+ request = self._pending_requests.get(request_id)
82
+ if request is None:
83
+ raise ValueError("Request not found")
84
+
85
+ # Check expiration
86
+ if current_timestamp() > request.expires_at:
87
+ del self._pending_requests[request_id]
88
+ raise ValueError("Request expired")
89
+
90
+ # Check if already signed by this signer
91
+ if sig.signer.lower() in request.signatures:
92
+ raise ValueError("Already signed by this signer")
93
+
94
+ request.signatures[sig.signer.lower()] = sig
95
+
96
+ def get_request(self, request_id: str) -> Optional[TransactionRequest]:
97
+ """
98
+ Get a pending request.
99
+
100
+ Args:
101
+ request_id: Request ID.
102
+
103
+ Returns:
104
+ TransactionRequest or None if not found/expired.
105
+ """
106
+ with self._lock:
107
+ request = self._pending_requests.get(request_id)
108
+ if request is None:
109
+ return None
110
+
111
+ # Check expiration
112
+ if current_timestamp() > request.expires_at:
113
+ del self._pending_requests[request_id]
114
+ return None
115
+
116
+ return request
117
+
118
+ def remove_request(self, request_id: str) -> bool:
119
+ """
120
+ Remove a request.
121
+
122
+ Args:
123
+ request_id: Request ID.
124
+
125
+ Returns:
126
+ True if removed, False if not found.
127
+ """
128
+ with self._lock:
129
+ if request_id in self._pending_requests:
130
+ del self._pending_requests[request_id]
131
+ return True
132
+ return False
133
+
134
+ def get_pending_requests(self) -> List[TransactionRequest]:
135
+ """
136
+ Get all non-expired pending requests.
137
+
138
+ Returns:
139
+ List of pending requests.
140
+ """
141
+ with self._lock:
142
+ self._cleanup_expired()
143
+ return list(self._pending_requests.values())
144
+
145
+ def get_pending_owners(
146
+ self, request_id: str, owners: List[str]
147
+ ) -> Optional[List[str]]:
148
+ """
149
+ Get owners who haven't signed a request yet.
150
+
151
+ Args:
152
+ request_id: Request ID.
153
+ owners: List of all owner addresses.
154
+
155
+ Returns:
156
+ List of pending owner addresses, or None if request not found.
157
+ """
158
+ with self._lock:
159
+ request = self._pending_requests.get(request_id)
160
+ if request is None:
161
+ return None
162
+
163
+ pending = []
164
+ for owner in owners:
165
+ if owner.lower() not in request.signatures:
166
+ pending.append(owner)
167
+ return pending
168
+
169
+ def get_signed_owners(self, request_id: str) -> Optional[List[str]]:
170
+ """
171
+ Get owners who have signed a request.
172
+
173
+ Args:
174
+ request_id: Request ID.
175
+
176
+ Returns:
177
+ List of signed owner addresses, or None if request not found.
178
+ """
179
+ with self._lock:
180
+ request = self._pending_requests.get(request_id)
181
+ if request is None:
182
+ return None
183
+
184
+ return list(request.signatures.keys())
185
+
186
+ def get_combined_signature(self, request_id: str) -> bytes:
187
+ """
188
+ Get the packed signatures for execution.
189
+
190
+ Args:
191
+ request_id: Request ID.
192
+
193
+ Returns:
194
+ Combined signature bytes.
195
+
196
+ Raises:
197
+ ValueError: If request not found or not ready.
198
+ """
199
+ with self._lock:
200
+ request = self._pending_requests.get(request_id)
201
+ if request is None:
202
+ raise ValueError("Request not found")
203
+
204
+ if not request.is_ready():
205
+ raise ValueError("Not enough signatures")
206
+
207
+ return combine_signatures(request.signatures)
208
+
209
+ def cleanup(self) -> None:
210
+ """Remove expired requests."""
211
+ with self._lock:
212
+ self._cleanup_expired()
213
+
214
+ def _cleanup_expired(self) -> None:
215
+ """Remove expired requests (must hold lock)."""
216
+ now = current_timestamp()
217
+ expired = [
218
+ rid
219
+ for rid, req in self._pending_requests.items()
220
+ if now > req.expires_at
221
+ ]
222
+ for rid in expired:
223
+ del self._pending_requests[rid]
224
+
225
+ def clear(self) -> None:
226
+ """Remove all pending requests."""
227
+ with self._lock:
228
+ self._pending_requests.clear()