t402 1.9.1__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.
- t402/__init__.py +1 -1
- t402/a2a/__init__.py +73 -0
- t402/a2a/helpers.py +158 -0
- t402/a2a/types.py +145 -0
- t402/bridge/constants.py +1 -1
- t402/django/__init__.py +42 -0
- t402/django/middleware.py +596 -0
- t402/errors.py +213 -0
- t402/facilitator.py +125 -0
- t402/mcp/constants.py +3 -6
- t402/mcp/server.py +428 -44
- t402/mcp/web3_utils.py +493 -0
- t402/multisig/__init__.py +120 -0
- t402/multisig/constants.py +54 -0
- t402/multisig/safe.py +441 -0
- t402/multisig/signature.py +228 -0
- t402/multisig/transaction.py +238 -0
- t402/multisig/types.py +108 -0
- t402/multisig/utils.py +77 -0
- t402/schemes/__init__.py +19 -0
- t402/schemes/cosmos/__init__.py +114 -0
- t402/schemes/cosmos/constants.py +211 -0
- t402/schemes/cosmos/exact_direct/__init__.py +21 -0
- t402/schemes/cosmos/exact_direct/client.py +198 -0
- t402/schemes/cosmos/exact_direct/facilitator.py +493 -0
- t402/schemes/cosmos/exact_direct/server.py +315 -0
- t402/schemes/cosmos/types.py +501 -0
- t402/schemes/evm/__init__.py +1 -1
- t402/schemes/evm/exact_legacy/server.py +1 -1
- t402/schemes/near/__init__.py +25 -0
- t402/schemes/near/upto/__init__.py +54 -0
- t402/schemes/near/upto/types.py +272 -0
- t402/schemes/svm/__init__.py +15 -0
- t402/schemes/svm/upto/__init__.py +23 -0
- t402/schemes/svm/upto/types.py +193 -0
- t402/schemes/ton/__init__.py +15 -0
- t402/schemes/ton/upto/__init__.py +31 -0
- t402/schemes/ton/upto/types.py +215 -0
- t402/schemes/tron/__init__.py +21 -4
- t402/schemes/tron/upto/__init__.py +30 -0
- t402/schemes/tron/upto/types.py +213 -0
- t402/starlette/__init__.py +38 -0
- t402/starlette/middleware.py +522 -0
- t402/ton.py +1 -1
- t402/ton_paywall_template.py +1 -1
- t402/types.py +100 -2
- t402/wdk/chains.py +1 -1
- {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/METADATA +3 -3
- {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/RECORD +51 -20
- {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/WHEEL +0 -0
- {t402-1.9.1.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()
|