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 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
+ )
@@ -0,0 +1,7 @@
1
+ """Ward Protocol data models."""
2
+
3
+ from .loan import Loan
4
+ from .loan_broker import LoanBroker
5
+ from .vault import Vault
6
+
7
+ __all__ = ['Loan', 'LoanBroker', 'Vault']