ward-protocol 0.1.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.
- ward/__init__.py +0 -0
- ward/database.py +266 -0
- ward/escrow.py +370 -0
- ward/models/__init__.py +7 -0
- ward/models/loan.py +81 -0
- ward/models/loan_broker.py +82 -0
- ward/models/vault.py +104 -0
- ward/monitor.py +342 -0
- ward/payment.py +259 -0
- ward/policy.py +375 -0
- ward/pool.py +454 -0
- ward/premium.py +275 -0
- ward/utils/__init__.py +0 -0
- ward/utils/calculations.py +125 -0
- ward/validator.py +277 -0
- ward_protocol-0.1.0.dist-info/METADATA +80 -0
- ward_protocol-0.1.0.dist-info/RECORD +19 -0
- ward_protocol-0.1.0.dist-info/WHEEL +5 -0
- ward_protocol-0.1.0.dist-info/top_level.txt +1 -0
ward/__init__.py
ADDED
|
File without changes
|
ward/database.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Database connection and operations for Ward Protocol."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional, List, Dict, Any
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import asyncpg
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WardDatabase:
|
|
11
|
+
"""PostgreSQL database connection for Ward Protocol."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, connection_string: Optional[str] = None):
|
|
14
|
+
"""
|
|
15
|
+
Initialize database connection.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
connection_string: PostgreSQL connection string
|
|
19
|
+
(defaults to DATABASE_URL env var)
|
|
20
|
+
"""
|
|
21
|
+
self.connection_string = connection_string or os.getenv(
|
|
22
|
+
'DATABASE_URL',
|
|
23
|
+
'postgresql://ward:ward@localhost/ward_protocol'
|
|
24
|
+
)
|
|
25
|
+
self.pool: Optional[asyncpg.Pool] = None
|
|
26
|
+
|
|
27
|
+
async def connect(self):
|
|
28
|
+
"""Create connection pool."""
|
|
29
|
+
self.pool = await asyncpg.create_pool(
|
|
30
|
+
self.connection_string,
|
|
31
|
+
min_size=2,
|
|
32
|
+
max_size=10
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def disconnect(self):
|
|
36
|
+
"""Close connection pool."""
|
|
37
|
+
if self.pool:
|
|
38
|
+
await self.pool.close()
|
|
39
|
+
|
|
40
|
+
@asynccontextmanager
|
|
41
|
+
async def transaction(self):
|
|
42
|
+
"""Context manager for database transactions."""
|
|
43
|
+
async with self.pool.acquire() as conn:
|
|
44
|
+
async with conn.transaction():
|
|
45
|
+
yield conn
|
|
46
|
+
|
|
47
|
+
# ===== DEFAULT EVENTS =====
|
|
48
|
+
|
|
49
|
+
async def log_default_event(
|
|
50
|
+
self,
|
|
51
|
+
loan_id: str,
|
|
52
|
+
loan_broker_id: str,
|
|
53
|
+
vault_id: str,
|
|
54
|
+
borrower_address: str,
|
|
55
|
+
default_amount: int,
|
|
56
|
+
default_covered: int,
|
|
57
|
+
vault_loss: int,
|
|
58
|
+
tx_hash: str,
|
|
59
|
+
ledger_index: int
|
|
60
|
+
) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Log a default event.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
event_id (UUID)
|
|
66
|
+
"""
|
|
67
|
+
async with self.pool.acquire() as conn:
|
|
68
|
+
row = await conn.fetchrow(
|
|
69
|
+
"""
|
|
70
|
+
INSERT INTO default_events (
|
|
71
|
+
loan_id, loan_broker_id, vault_id, borrower_address,
|
|
72
|
+
default_amount, default_covered, vault_loss,
|
|
73
|
+
tx_hash, ledger_index
|
|
74
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
75
|
+
RETURNING event_id
|
|
76
|
+
""",
|
|
77
|
+
loan_id, loan_broker_id, vault_id, borrower_address,
|
|
78
|
+
default_amount, default_covered, vault_loss,
|
|
79
|
+
tx_hash, ledger_index
|
|
80
|
+
)
|
|
81
|
+
return str(row['event_id'])
|
|
82
|
+
|
|
83
|
+
async def get_default_events(
|
|
84
|
+
self,
|
|
85
|
+
vault_id: Optional[str] = None,
|
|
86
|
+
limit: int = 100
|
|
87
|
+
) -> List[Dict[str, Any]]:
|
|
88
|
+
"""Get default events, optionally filtered by vault."""
|
|
89
|
+
async with self.pool.acquire() as conn:
|
|
90
|
+
if vault_id:
|
|
91
|
+
rows = await conn.fetch(
|
|
92
|
+
"""
|
|
93
|
+
SELECT * FROM default_events
|
|
94
|
+
WHERE vault_id = $1
|
|
95
|
+
ORDER BY detected_at DESC
|
|
96
|
+
LIMIT $2
|
|
97
|
+
""",
|
|
98
|
+
vault_id, limit
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
rows = await conn.fetch(
|
|
102
|
+
"""
|
|
103
|
+
SELECT * FROM default_events
|
|
104
|
+
ORDER BY detected_at DESC
|
|
105
|
+
LIMIT $1
|
|
106
|
+
""",
|
|
107
|
+
limit
|
|
108
|
+
)
|
|
109
|
+
return [dict(row) for row in rows]
|
|
110
|
+
|
|
111
|
+
# ===== CLAIMS =====
|
|
112
|
+
|
|
113
|
+
async def create_claim(
|
|
114
|
+
self,
|
|
115
|
+
policy_id: str,
|
|
116
|
+
loan_id: str,
|
|
117
|
+
loan_manage_tx_hash: str,
|
|
118
|
+
loan_broker_id: str,
|
|
119
|
+
vault_id: str,
|
|
120
|
+
default_amount: int,
|
|
121
|
+
default_covered: int,
|
|
122
|
+
vault_loss: int,
|
|
123
|
+
claim_payout: int
|
|
124
|
+
) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Create a new claim.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
claim_id (UUID)
|
|
130
|
+
"""
|
|
131
|
+
async with self.pool.acquire() as conn:
|
|
132
|
+
row = await conn.fetchrow(
|
|
133
|
+
"""
|
|
134
|
+
INSERT INTO claims (
|
|
135
|
+
policy_id, loan_id, loan_manage_tx_hash,
|
|
136
|
+
loan_broker_id, vault_id,
|
|
137
|
+
default_amount, default_covered, vault_loss,
|
|
138
|
+
claim_payout, status
|
|
139
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')
|
|
140
|
+
RETURNING claim_id
|
|
141
|
+
""",
|
|
142
|
+
policy_id, loan_id, loan_manage_tx_hash,
|
|
143
|
+
loan_broker_id, vault_id,
|
|
144
|
+
default_amount, default_covered, vault_loss,
|
|
145
|
+
claim_payout
|
|
146
|
+
)
|
|
147
|
+
return str(row['claim_id'])
|
|
148
|
+
|
|
149
|
+
async def update_claim_status(
|
|
150
|
+
self,
|
|
151
|
+
claim_id: str,
|
|
152
|
+
status: str,
|
|
153
|
+
escrow_tx_hash: Optional[str] = None,
|
|
154
|
+
settlement_tx_hash: Optional[str] = None,
|
|
155
|
+
rejection_reason: Optional[str] = None
|
|
156
|
+
):
|
|
157
|
+
"""Update claim status."""
|
|
158
|
+
async with self.pool.acquire() as conn:
|
|
159
|
+
if status == 'validated':
|
|
160
|
+
await conn.execute(
|
|
161
|
+
"""
|
|
162
|
+
UPDATE claims
|
|
163
|
+
SET status = $1, validated_at = NOW()
|
|
164
|
+
WHERE claim_id = $2
|
|
165
|
+
""",
|
|
166
|
+
status, claim_id
|
|
167
|
+
)
|
|
168
|
+
elif status == 'escrowed' and escrow_tx_hash:
|
|
169
|
+
await conn.execute(
|
|
170
|
+
"""
|
|
171
|
+
UPDATE claims
|
|
172
|
+
SET status = $1, escrow_tx_hash = $2
|
|
173
|
+
WHERE claim_id = $3
|
|
174
|
+
""",
|
|
175
|
+
status, escrow_tx_hash, claim_id
|
|
176
|
+
)
|
|
177
|
+
elif status == 'settled' and settlement_tx_hash:
|
|
178
|
+
await conn.execute(
|
|
179
|
+
"""
|
|
180
|
+
UPDATE claims
|
|
181
|
+
SET status = $1, settlement_tx_hash = $2, settled_at = NOW()
|
|
182
|
+
WHERE claim_id = $3
|
|
183
|
+
""",
|
|
184
|
+
status, settlement_tx_hash, claim_id
|
|
185
|
+
)
|
|
186
|
+
elif status == 'rejected' and rejection_reason:
|
|
187
|
+
await conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
UPDATE claims
|
|
190
|
+
SET status = $1, rejection_reason = $2
|
|
191
|
+
WHERE claim_id = $3
|
|
192
|
+
""",
|
|
193
|
+
status, rejection_reason, claim_id
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def get_claims(
|
|
197
|
+
self,
|
|
198
|
+
policy_id: Optional[str] = None,
|
|
199
|
+
status: Optional[str] = None,
|
|
200
|
+
limit: int = 100
|
|
201
|
+
) -> List[Dict[str, Any]]:
|
|
202
|
+
"""Get claims with optional filters."""
|
|
203
|
+
async with self.pool.acquire() as conn:
|
|
204
|
+
query = "SELECT * FROM claims WHERE 1=1"
|
|
205
|
+
params = []
|
|
206
|
+
param_num = 1
|
|
207
|
+
|
|
208
|
+
if policy_id:
|
|
209
|
+
query += f" AND policy_id = ${param_num}"
|
|
210
|
+
params.append(policy_id)
|
|
211
|
+
param_num += 1
|
|
212
|
+
|
|
213
|
+
if status:
|
|
214
|
+
query += f" AND status = ${param_num}"
|
|
215
|
+
params.append(status)
|
|
216
|
+
param_num += 1
|
|
217
|
+
|
|
218
|
+
query += f" ORDER BY created_at DESC LIMIT ${param_num}"
|
|
219
|
+
params.append(limit)
|
|
220
|
+
|
|
221
|
+
rows = await conn.fetch(query, *params)
|
|
222
|
+
return [dict(row) for row in rows]
|
|
223
|
+
|
|
224
|
+
# ===== MONITORED VAULTS =====
|
|
225
|
+
|
|
226
|
+
async def upsert_vault_state(
|
|
227
|
+
self,
|
|
228
|
+
vault_id: str,
|
|
229
|
+
loan_broker_id: str,
|
|
230
|
+
asset_type: str,
|
|
231
|
+
assets_total: int,
|
|
232
|
+
assets_available: int,
|
|
233
|
+
loss_unrealized: int,
|
|
234
|
+
shares_total: int,
|
|
235
|
+
share_value: float,
|
|
236
|
+
ledger_index: int
|
|
237
|
+
):
|
|
238
|
+
"""Insert or update vault state."""
|
|
239
|
+
async with self.pool.acquire() as conn:
|
|
240
|
+
await conn.execute(
|
|
241
|
+
"""
|
|
242
|
+
INSERT INTO monitored_vaults (
|
|
243
|
+
vault_id, loan_broker_id, asset_type,
|
|
244
|
+
assets_total, assets_available, loss_unrealized,
|
|
245
|
+
shares_total, share_value, last_updated_ledger,
|
|
246
|
+
last_checked
|
|
247
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
|
248
|
+
ON CONFLICT (vault_id) DO UPDATE SET
|
|
249
|
+
assets_total = EXCLUDED.assets_total,
|
|
250
|
+
assets_available = EXCLUDED.assets_available,
|
|
251
|
+
loss_unrealized = EXCLUDED.loss_unrealized,
|
|
252
|
+
shares_total = EXCLUDED.shares_total,
|
|
253
|
+
share_value = EXCLUDED.share_value,
|
|
254
|
+
last_updated_ledger = EXCLUDED.last_updated_ledger,
|
|
255
|
+
last_checked = NOW()
|
|
256
|
+
""",
|
|
257
|
+
vault_id, loan_broker_id, asset_type,
|
|
258
|
+
assets_total, assets_available, loss_unrealized,
|
|
259
|
+
shares_total, share_value, ledger_index
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def get_monitored_vaults(self) -> List[Dict[str, Any]]:
|
|
263
|
+
"""Get all monitored vaults."""
|
|
264
|
+
async with self.pool.acquire() as conn:
|
|
265
|
+
rows = await conn.fetch("SELECT * FROM monitored_vaults")
|
|
266
|
+
return [dict(row) for row in rows]
|
ward/escrow.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Escrow settlement for insurance claims.
|
|
3
|
+
|
|
4
|
+
Implements 48-hour dispute window before claim payouts using XRPL Escrow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional, Dict, Any, List
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from xrpl.models import (
|
|
13
|
+
EscrowCreate, EscrowFinish, EscrowCancel,
|
|
14
|
+
AccountObjects, Memo
|
|
15
|
+
)
|
|
16
|
+
from xrpl.wallet import Wallet
|
|
17
|
+
from xrpl.asyncio.clients import AsyncWebsocketClient
|
|
18
|
+
from xrpl.asyncio.transaction import submit_and_wait
|
|
19
|
+
from xrpl.utils import str_to_hex, ripple_time_to_datetime, datetime_to_ripple_time
|
|
20
|
+
|
|
21
|
+
from .database import WardDatabase
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logging.basicConfig(level=logging.INFO)
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class EscrowStatus:
|
|
30
|
+
"""Status of an escrowed claim."""
|
|
31
|
+
claim_id: str
|
|
32
|
+
escrow_sequence: int
|
|
33
|
+
amount: int # drops
|
|
34
|
+
destination: str
|
|
35
|
+
finish_after: datetime
|
|
36
|
+
cancel_after: datetime
|
|
37
|
+
status: str # pending, finishable, finished, cancelled
|
|
38
|
+
can_finish: bool
|
|
39
|
+
can_cancel: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClaimEscrow:
|
|
43
|
+
"""
|
|
44
|
+
Manages escrowed insurance claim payouts.
|
|
45
|
+
|
|
46
|
+
Creates time-locked escrows with 48-hour dispute windows,
|
|
47
|
+
allowing community review before claim settlement.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
DISPUTE_WINDOW_HOURS = 48
|
|
51
|
+
CANCEL_BUFFER_HOURS = 72 # Extra time before auto-cancel
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
client: AsyncWebsocketClient,
|
|
56
|
+
wallet: Wallet,
|
|
57
|
+
database: WardDatabase
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Initialize escrow manager.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
client: XRPL client
|
|
64
|
+
wallet: Pool wallet (escrow source)
|
|
65
|
+
database: Database connection
|
|
66
|
+
"""
|
|
67
|
+
self.client = client
|
|
68
|
+
self.wallet = wallet
|
|
69
|
+
self.db = database
|
|
70
|
+
|
|
71
|
+
async def create_claim_escrow(
|
|
72
|
+
self,
|
|
73
|
+
claim_id: str,
|
|
74
|
+
payout_amount: int,
|
|
75
|
+
destination: str
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
"""
|
|
78
|
+
Create escrowed claim payout with 48-hour dispute window.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
claim_id: Claim UUID
|
|
82
|
+
payout_amount: Payout in drops
|
|
83
|
+
destination: Claim recipient address
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dictionary with escrow details
|
|
87
|
+
"""
|
|
88
|
+
logger.info(
|
|
89
|
+
f"Creating claim escrow: {payout_amount / 1_000_000:.2f} XRP "
|
|
90
|
+
f"to {destination} (48hr window)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Calculate times
|
|
94
|
+
now = datetime.utcnow()
|
|
95
|
+
finish_after = now + timedelta(hours=self.DISPUTE_WINDOW_HOURS)
|
|
96
|
+
cancel_after = finish_after + timedelta(hours=self.CANCEL_BUFFER_HOURS)
|
|
97
|
+
|
|
98
|
+
# Convert to Ripple time
|
|
99
|
+
finish_after_ripple = datetime_to_ripple_time(finish_after)
|
|
100
|
+
cancel_after_ripple = datetime_to_ripple_time(cancel_after)
|
|
101
|
+
|
|
102
|
+
# Create EscrowCreate transaction
|
|
103
|
+
escrow_tx = EscrowCreate(
|
|
104
|
+
account=self.wallet.address,
|
|
105
|
+
destination=destination,
|
|
106
|
+
amount=str(payout_amount),
|
|
107
|
+
finish_after=finish_after_ripple,
|
|
108
|
+
cancel_after=cancel_after_ripple,
|
|
109
|
+
memos=[
|
|
110
|
+
Memo(
|
|
111
|
+
memo_type=str_to_hex("ward_claim_escrow"),
|
|
112
|
+
memo_data=str_to_hex(f"Claim ID: {claim_id}")
|
|
113
|
+
)
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
response = await submit_and_wait(escrow_tx, self.client, self.wallet)
|
|
118
|
+
|
|
119
|
+
if not response.is_successful():
|
|
120
|
+
raise Exception(f"Escrow creation failed: {response.result}")
|
|
121
|
+
|
|
122
|
+
tx_hash = response.result['hash']
|
|
123
|
+
escrow_sequence = response.result['Sequence']
|
|
124
|
+
|
|
125
|
+
# Update claim in database
|
|
126
|
+
await self.db.update_claim_status(
|
|
127
|
+
claim_id=claim_id,
|
|
128
|
+
status='escrowed',
|
|
129
|
+
escrow_tx_hash=tx_hash
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Store escrow sequence
|
|
133
|
+
await self._store_escrow_sequence(claim_id, escrow_sequence)
|
|
134
|
+
|
|
135
|
+
logger.info(
|
|
136
|
+
f"Escrow created: {tx_hash} (seq: {escrow_sequence}). "
|
|
137
|
+
f"Finishable after: {finish_after.isoformat()}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
'claim_id': claim_id,
|
|
142
|
+
'escrow_tx_hash': tx_hash,
|
|
143
|
+
'escrow_sequence': escrow_sequence,
|
|
144
|
+
'amount': payout_amount,
|
|
145
|
+
'destination': destination,
|
|
146
|
+
'finish_after': finish_after,
|
|
147
|
+
'cancel_after': cancel_after,
|
|
148
|
+
'status': 'escrowed'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async def finish_escrow(
|
|
152
|
+
self,
|
|
153
|
+
claim_id: str,
|
|
154
|
+
escrow_sequence: int
|
|
155
|
+
) -> str:
|
|
156
|
+
"""
|
|
157
|
+
Finish escrow and release claim payout.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
claim_id: Claim UUID
|
|
161
|
+
escrow_sequence: Escrow sequence number
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Transaction hash
|
|
165
|
+
"""
|
|
166
|
+
logger.info(f"Finishing escrow for claim {claim_id} (seq: {escrow_sequence})")
|
|
167
|
+
|
|
168
|
+
# Verify escrow is finishable
|
|
169
|
+
status = await self.get_escrow_status(escrow_sequence)
|
|
170
|
+
|
|
171
|
+
if not status.can_finish:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"Escrow not finishable yet. Available after: {status.finish_after}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Create EscrowFinish transaction
|
|
177
|
+
finish_tx = EscrowFinish(
|
|
178
|
+
account=self.wallet.address,
|
|
179
|
+
owner=self.wallet.address,
|
|
180
|
+
offer_sequence=escrow_sequence
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
response = await submit_and_wait(finish_tx, self.client, self.wallet)
|
|
184
|
+
|
|
185
|
+
if not response.is_successful():
|
|
186
|
+
raise Exception(f"Escrow finish failed: {response.result}")
|
|
187
|
+
|
|
188
|
+
tx_hash = response.result['hash']
|
|
189
|
+
|
|
190
|
+
# Update claim status
|
|
191
|
+
await self.db.update_claim_status(
|
|
192
|
+
claim_id=claim_id,
|
|
193
|
+
status='settled',
|
|
194
|
+
settlement_tx_hash=tx_hash
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
logger.info(f"Escrow finished: {tx_hash}. Claim settled.")
|
|
198
|
+
|
|
199
|
+
return tx_hash
|
|
200
|
+
|
|
201
|
+
async def cancel_escrow(
|
|
202
|
+
self,
|
|
203
|
+
claim_id: str,
|
|
204
|
+
escrow_sequence: int,
|
|
205
|
+
reason: str
|
|
206
|
+
) -> str:
|
|
207
|
+
"""
|
|
208
|
+
Cancel escrow (for disputed/fraudulent claims).
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
claim_id: Claim UUID
|
|
212
|
+
escrow_sequence: Escrow sequence number
|
|
213
|
+
reason: Cancellation reason
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Transaction hash
|
|
217
|
+
"""
|
|
218
|
+
logger.info(
|
|
219
|
+
f"Cancelling escrow for claim {claim_id} (seq: {escrow_sequence}). "
|
|
220
|
+
f"Reason: {reason}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Verify escrow is cancellable
|
|
224
|
+
status = await self.get_escrow_status(escrow_sequence)
|
|
225
|
+
|
|
226
|
+
if not status.can_cancel:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"Escrow not cancellable yet. Available after: {status.cancel_after}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Create EscrowCancel transaction
|
|
232
|
+
cancel_tx = EscrowCancel(
|
|
233
|
+
account=self.wallet.address,
|
|
234
|
+
owner=self.wallet.address,
|
|
235
|
+
offer_sequence=escrow_sequence
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
response = await submit_and_wait(cancel_tx, self.client, self.wallet)
|
|
239
|
+
|
|
240
|
+
if not response.is_successful():
|
|
241
|
+
raise Exception(f"Escrow cancel failed: {response.result}")
|
|
242
|
+
|
|
243
|
+
tx_hash = response.result['hash']
|
|
244
|
+
|
|
245
|
+
# Update claim status
|
|
246
|
+
await self.db.update_claim_status(
|
|
247
|
+
claim_id=claim_id,
|
|
248
|
+
status='rejected',
|
|
249
|
+
rejection_reason=f"Escrow cancelled: {reason}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
logger.info(f"Escrow cancelled: {tx_hash}")
|
|
253
|
+
|
|
254
|
+
return tx_hash
|
|
255
|
+
|
|
256
|
+
async def get_escrow_status(
|
|
257
|
+
self,
|
|
258
|
+
escrow_sequence: int
|
|
259
|
+
) -> EscrowStatus:
|
|
260
|
+
"""
|
|
261
|
+
Get current status of an escrow.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
escrow_sequence: Escrow sequence number
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
EscrowStatus with current state
|
|
268
|
+
"""
|
|
269
|
+
# Query escrow from ledger
|
|
270
|
+
account_objects = AccountObjects(
|
|
271
|
+
account=self.wallet.address,
|
|
272
|
+
type="escrow"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
response = await self.client.request(account_objects)
|
|
276
|
+
|
|
277
|
+
if not response.is_successful():
|
|
278
|
+
raise Exception(f"Failed to query escrows: {response.result}")
|
|
279
|
+
|
|
280
|
+
# Find our escrow
|
|
281
|
+
escrow_obj = None
|
|
282
|
+
for obj in response.result.get('account_objects', []):
|
|
283
|
+
if obj.get('Sequence') == escrow_sequence:
|
|
284
|
+
escrow_obj = obj
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
if not escrow_obj:
|
|
288
|
+
# Escrow not found - either finished or cancelled
|
|
289
|
+
return EscrowStatus(
|
|
290
|
+
claim_id="unknown",
|
|
291
|
+
escrow_sequence=escrow_sequence,
|
|
292
|
+
amount=0,
|
|
293
|
+
destination="unknown",
|
|
294
|
+
finish_after=datetime.utcnow(),
|
|
295
|
+
cancel_after=datetime.utcnow(),
|
|
296
|
+
status="finished_or_cancelled",
|
|
297
|
+
can_finish=False,
|
|
298
|
+
can_cancel=False
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Parse escrow details
|
|
302
|
+
amount = int(escrow_obj['Amount'])
|
|
303
|
+
destination = escrow_obj['Destination']
|
|
304
|
+
finish_after = ripple_time_to_datetime(escrow_obj['FinishAfter'])
|
|
305
|
+
cancel_after = ripple_time_to_datetime(escrow_obj['CancelAfter'])
|
|
306
|
+
|
|
307
|
+
now = datetime.utcnow()
|
|
308
|
+
can_finish = now >= finish_after
|
|
309
|
+
can_cancel = now >= cancel_after
|
|
310
|
+
|
|
311
|
+
if can_finish and not can_cancel:
|
|
312
|
+
status = "finishable"
|
|
313
|
+
elif can_cancel:
|
|
314
|
+
status = "cancellable"
|
|
315
|
+
else:
|
|
316
|
+
status = "pending"
|
|
317
|
+
|
|
318
|
+
return EscrowStatus(
|
|
319
|
+
claim_id="unknown", # Would need to look up from database
|
|
320
|
+
escrow_sequence=escrow_sequence,
|
|
321
|
+
amount=amount,
|
|
322
|
+
destination=destination,
|
|
323
|
+
finish_after=finish_after,
|
|
324
|
+
cancel_after=cancel_after,
|
|
325
|
+
status=status,
|
|
326
|
+
can_finish=can_finish,
|
|
327
|
+
can_cancel=can_cancel
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
async def get_pending_escrows(self) -> List[EscrowStatus]:
|
|
331
|
+
"""
|
|
332
|
+
Get all pending escrows for this wallet.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List of EscrowStatus objects
|
|
336
|
+
"""
|
|
337
|
+
account_objects = AccountObjects(
|
|
338
|
+
account=self.wallet.address,
|
|
339
|
+
type="escrow"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
response = await self.client.request(account_objects)
|
|
343
|
+
|
|
344
|
+
if not response.is_successful():
|
|
345
|
+
raise Exception(f"Failed to query escrows: {response.result}")
|
|
346
|
+
|
|
347
|
+
escrows = []
|
|
348
|
+
for obj in response.result.get('account_objects', []):
|
|
349
|
+
sequence = obj.get('Sequence')
|
|
350
|
+
if sequence:
|
|
351
|
+
status = await self.get_escrow_status(sequence)
|
|
352
|
+
escrows.append(status)
|
|
353
|
+
|
|
354
|
+
return escrows
|
|
355
|
+
|
|
356
|
+
async def _store_escrow_sequence(
|
|
357
|
+
self,
|
|
358
|
+
claim_id: str,
|
|
359
|
+
escrow_sequence: int
|
|
360
|
+
):
|
|
361
|
+
"""Store escrow sequence number in claims table."""
|
|
362
|
+
async with self.db.pool.acquire() as conn:
|
|
363
|
+
await conn.execute(
|
|
364
|
+
"""
|
|
365
|
+
UPDATE claims
|
|
366
|
+
SET escrow_sequence = $1
|
|
367
|
+
WHERE claim_id = $2
|
|
368
|
+
""",
|
|
369
|
+
escrow_sequence, claim_id
|
|
370
|
+
)
|