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/models/loan.py ADDED
@@ -0,0 +1,81 @@
1
+ """Loan state model for XLS-66 loans."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class Loan:
10
+ """
11
+ Represents an XLS-66 Loan ledger object.
12
+
13
+ Tracks loan state including outstanding amounts, payment schedule,
14
+ and default status.
15
+ """
16
+ loan_id: str
17
+ loan_broker_id: str
18
+ borrower: str
19
+ principal_outstanding: int
20
+ total_value_outstanding: int
21
+ interest_outstanding: int
22
+ management_fee_outstanding: int
23
+ next_payment_due_date: Optional[datetime]
24
+ grace_period: int # seconds
25
+ flags: int
26
+ ledger_index: int
27
+
28
+ # Flags from XLS-66 spec
29
+ LSF_LOAN_DEFAULT = 0x00010000
30
+ LSF_LOAN_IMPAIRED = 0x00020000
31
+ LSF_LOAN_OVERPAYMENT = 0x00040000
32
+
33
+ @property
34
+ def is_defaulted(self) -> bool:
35
+ """Check if loan has defaulted."""
36
+ return bool(self.flags & self.LSF_LOAN_DEFAULT)
37
+
38
+ @property
39
+ def is_impaired(self) -> bool:
40
+ """Check if loan is impaired (early warning signal)."""
41
+ return bool(self.flags & self.LSF_LOAN_IMPAIRED)
42
+
43
+ @property
44
+ def allows_overpayment(self) -> bool:
45
+ """Check if loan allows overpayment."""
46
+ return bool(self.flags & self.LSF_LOAN_OVERPAYMENT)
47
+
48
+ @classmethod
49
+ def from_ledger_entry(cls, ledger_entry: dict) -> 'Loan':
50
+ """
51
+ Create Loan instance from XRPL ledger_entry response.
52
+
53
+ Args:
54
+ ledger_entry: Response from ledger_entry RPC call
55
+
56
+ Returns:
57
+ Loan instance
58
+ """
59
+ node = ledger_entry.get('node', ledger_entry)
60
+
61
+ # Parse payment due date if present
62
+ next_payment_due = None
63
+ if 'NextPaymentDueDate' in node:
64
+ # Convert Ripple epoch to datetime
65
+ ripple_epoch = node['NextPaymentDueDate']
66
+ unix_epoch = ripple_epoch + 946684800 # Ripple epoch offset
67
+ next_payment_due = datetime.utcfromtimestamp(unix_epoch)
68
+
69
+ return cls(
70
+ loan_id=node.get('index', ''),
71
+ loan_broker_id=node.get('LoanBrokerID', ''),
72
+ borrower=node.get('Borrower', ''),
73
+ principal_outstanding=int(node.get('PrincipalOutstanding', 0)),
74
+ total_value_outstanding=int(node.get('TotalValueOutstanding', 0)),
75
+ interest_outstanding=int(node.get('InterestOutstanding', 0)),
76
+ management_fee_outstanding=int(node.get('ManagementFeeOutstanding', 0)),
77
+ next_payment_due_date=next_payment_due,
78
+ grace_period=int(node.get('GracePeriod', 0)),
79
+ flags=int(node.get('Flags', 0)),
80
+ ledger_index=int(ledger_entry.get('ledger_index', 0))
81
+ )
@@ -0,0 +1,82 @@
1
+ """LoanBroker state model for XLS-66 lending."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class LoanBroker:
9
+ """
10
+ Represents an XLS-66 LoanBroker ledger object.
11
+
12
+ Tracks lending activity, first-loss capital, and coverage ratios.
13
+ """
14
+ loan_broker_id: str
15
+ owner: str
16
+ vault_id: str
17
+ debt_total: int
18
+ debt_maximum: int
19
+ cover_available: int
20
+ cover_rate_minimum: float # Basis points / 10000
21
+ cover_rate_liquidation: float # Basis points / 10000
22
+ management_fee_rate: int
23
+ owner_count: int
24
+ ledger_index: int
25
+
26
+ @property
27
+ def minimum_cover_required(self) -> int:
28
+ """Calculate minimum first-loss capital required."""
29
+ return int(self.debt_total * self.cover_rate_minimum)
30
+
31
+ @property
32
+ def max_liquidation_coverage(self) -> int:
33
+ """Calculate maximum coverage on liquidation."""
34
+ return int(self.minimum_cover_required * self.cover_rate_liquidation)
35
+
36
+ @property
37
+ def is_adequately_covered(self) -> bool:
38
+ """Check if first-loss capital meets minimum requirements."""
39
+ return self.cover_available >= self.minimum_cover_required
40
+
41
+ @property
42
+ def coverage_ratio(self) -> float:
43
+ """
44
+ Calculate coverage ratio.
45
+
46
+ Returns:
47
+ Ratio of available cover to minimum required (e.g., 1.5 = 150%)
48
+ """
49
+ if self.minimum_cover_required == 0:
50
+ return float('inf')
51
+ return self.cover_available / self.minimum_cover_required
52
+
53
+ @classmethod
54
+ def from_ledger_entry(cls, ledger_entry: dict) -> 'LoanBroker':
55
+ """
56
+ Create LoanBroker instance from XRPL ledger_entry response.
57
+
58
+ Args:
59
+ ledger_entry: Response from ledger_entry RPC call
60
+
61
+ Returns:
62
+ LoanBroker instance
63
+ """
64
+ node = ledger_entry.get('node', ledger_entry)
65
+
66
+ # Convert basis points to decimal
67
+ cover_rate_min = int(node.get('CoverRateMinimum', 0)) / 10000
68
+ cover_rate_liq = int(node.get('CoverRateLiquidation', 0)) / 10000
69
+
70
+ return cls(
71
+ loan_broker_id=node.get('index', ''),
72
+ owner=node.get('Owner', ''),
73
+ vault_id=node.get('VaultID', ''),
74
+ debt_total=int(node.get('DebtTotal', 0)),
75
+ debt_maximum=int(node.get('DebtMaximum', 0)),
76
+ cover_available=int(node.get('CoverAvailable', 0)),
77
+ cover_rate_minimum=cover_rate_min,
78
+ cover_rate_liquidation=cover_rate_liq,
79
+ management_fee_rate=int(node.get('ManagementFeeRate', 0)),
80
+ owner_count=int(node.get('OwnerCount', 0)),
81
+ ledger_index=int(ledger_entry.get('ledger_index', 0))
82
+ )
ward/models/vault.py ADDED
@@ -0,0 +1,104 @@
1
+ """Vault state model for XLS-65 vaults."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class Vault:
9
+ """
10
+ Represents an XLS-65 Vault ledger object.
11
+
12
+ Tracks pooled depositor funds and loss exposure.
13
+ """
14
+ vault_id: str
15
+ owner: str
16
+ account: str # Pseudo-account address
17
+ asset: dict # Asset type (XRP, IOU, or MPT)
18
+ assets_total: int
19
+ assets_available: int
20
+ loss_unrealized: int
21
+ shares_total: int
22
+ share_mpt_id: str
23
+ ledger_index: int
24
+
25
+ @property
26
+ def real_vault_value(self) -> int:
27
+ """
28
+ Calculate real vault value accounting for unrealized losses.
29
+
30
+ Returns:
31
+ AssetsTotal - LossUnrealized (in drops)
32
+ """
33
+ return self.assets_total - self.loss_unrealized
34
+
35
+ @property
36
+ def share_value(self) -> float:
37
+ """
38
+ Calculate current share value.
39
+
40
+ Uses XLS-65 formula:
41
+ ShareValue = (AssetsTotal - LossUnrealized) / SharesTotal
42
+
43
+ Returns:
44
+ Share value in XRP (or asset units)
45
+ """
46
+ if self.shares_total == 0:
47
+ return 0.0
48
+
49
+ DROPS_PER_XRP = 1_000_000
50
+ value_drops = self.real_vault_value / self.shares_total
51
+ return value_drops / DROPS_PER_XRP
52
+
53
+ @property
54
+ def utilization_rate(self) -> float:
55
+ """
56
+ Calculate vault utilization rate.
57
+
58
+ Returns:
59
+ (AssetsTotal - AssetsAvailable) / AssetsTotal
60
+ """
61
+ if self.assets_total == 0:
62
+ return 0.0
63
+
64
+ deployed = self.assets_total - self.assets_available
65
+ return deployed / self.assets_total
66
+
67
+ @property
68
+ def impairment_ratio(self) -> float:
69
+ """
70
+ Calculate impairment ratio.
71
+
72
+ Returns:
73
+ LossUnrealized / AssetsTotal
74
+ """
75
+ if self.assets_total == 0:
76
+ return 0.0
77
+
78
+ return self.loss_unrealized / self.assets_total
79
+
80
+ @classmethod
81
+ def from_ledger_entry(cls, ledger_entry: dict) -> 'Vault':
82
+ """
83
+ Create Vault instance from XRPL ledger_entry response.
84
+
85
+ Args:
86
+ ledger_entry: Response from ledger_entry RPC call
87
+
88
+ Returns:
89
+ Vault instance
90
+ """
91
+ node = ledger_entry.get('node', ledger_entry)
92
+
93
+ return cls(
94
+ vault_id=node.get('index', ''),
95
+ owner=node.get('Owner', ''),
96
+ account=node.get('Account', ''),
97
+ asset=node.get('Asset', {}),
98
+ assets_total=int(node.get('AssetsTotal', 0)),
99
+ assets_available=int(node.get('AssetsAvailable', 0)),
100
+ loss_unrealized=int(node.get('LossUnrealized', 0)),
101
+ shares_total=int(node.get('SharesTotal', 0)),
102
+ share_mpt_id=node.get('ShareMPTID', ''),
103
+ ledger_index=int(ledger_entry.get('ledger_index', 0))
104
+ )
ward/monitor.py ADDED
@@ -0,0 +1,342 @@
1
+ """
2
+ XLS-66 Default Event Monitor.
3
+
4
+ Real-time monitoring of XLS-66 loans, detecting defaults and calculating
5
+ vault depositor losses.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Callable, Optional, Dict, List
11
+ from datetime import datetime
12
+
13
+ from xrpl.asyncio.clients import AsyncWebsocketClient
14
+ from xrpl.models import Subscribe, LedgerEntry, Tx
15
+
16
+ from .models import Loan, LoanBroker, Vault
17
+ from .utils.calculations import calculate_vault_loss, calculate_share_value_impact
18
+ from .database import WardDatabase
19
+
20
+
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class DefaultEvent:
26
+ """Represents a detected XLS-66 loan default event."""
27
+
28
+ def __init__(
29
+ self,
30
+ loan: Loan,
31
+ loan_broker: LoanBroker,
32
+ vault: Vault,
33
+ default_amount: int,
34
+ default_covered: int,
35
+ vault_loss: int,
36
+ tx_hash: str,
37
+ ledger_index: int
38
+ ):
39
+ self.loan = loan
40
+ self.loan_broker = loan_broker
41
+ self.vault = vault
42
+ self.default_amount = default_amount
43
+ self.default_covered = default_covered
44
+ self.vault_loss = vault_loss
45
+ self.tx_hash = tx_hash
46
+ self.ledger_index = ledger_index
47
+ self.detected_at = datetime.utcnow()
48
+
49
+ def __repr__(self):
50
+ return (
51
+ f"DefaultEvent(loan={self.loan.loan_id[:8]}..., "
52
+ f"vault_loss={self.vault_loss / 1_000_000:.2f} XRP, "
53
+ f"tx={self.tx_hash[:8]}...)"
54
+ )
55
+
56
+
57
+ class XLS66Monitor:
58
+ """
59
+ Monitor XLS-66 lending protocol for default events.
60
+
61
+ Subscribes to XRPL ledger stream and detects LoanManage transactions
62
+ with tfLoanDefault flag. Calculates vault losses and triggers callbacks.
63
+
64
+ Example:
65
+ monitor = XLS66Monitor(
66
+ "wss://xrplcluster.com",
67
+ database_url="postgresql://user:pass@localhost/ward"
68
+ )
69
+
70
+ @monitor.on_default
71
+ async def handle_default(event: DefaultEvent):
72
+ print(f"Default detected: {event.vault_loss} drops lost")
73
+
74
+ await monitor.start()
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ websocket_url: str = "wss://xrplcluster.com",
80
+ monitored_loan_brokers: Optional[List[str]] = None,
81
+ database_url: Optional[str] = None
82
+ ):
83
+ """
84
+ Initialize XLS-66 monitor.
85
+
86
+ Args:
87
+ websocket_url: XRPL WebSocket endpoint
88
+ monitored_loan_brokers: List of LoanBroker IDs to monitor
89
+ (None = monitor all)
90
+ database_url: PostgreSQL connection string (optional)
91
+ """
92
+ self.websocket_url = websocket_url
93
+ self.monitored_loan_brokers = set(monitored_loan_brokers or [])
94
+ self.client: Optional[AsyncWebsocketClient] = None
95
+ self.running = False
96
+ self.default_callbacks: List[Callable] = []
97
+
98
+ # Database connection (optional)
99
+ self.db = WardDatabase(database_url) if database_url else None
100
+
101
+ # Cache for ledger state
102
+ self._loan_broker_cache: Dict[str, LoanBroker] = {}
103
+ self._vault_cache: Dict[str, Vault] = {}
104
+
105
+ def on_default(self, callback: Callable):
106
+ """
107
+ Register callback for default events.
108
+
109
+ Decorator usage:
110
+ @monitor.on_default
111
+ async def handle(event: DefaultEvent):
112
+ ...
113
+
114
+ Args:
115
+ callback: Async function taking DefaultEvent parameter
116
+ """
117
+ self.default_callbacks.append(callback)
118
+ return callback
119
+
120
+ async def start(self):
121
+ """Start monitoring XLS-66 events."""
122
+ logger.info(f"Starting XLS-66 monitor on {self.websocket_url}")
123
+
124
+ # Connect to database if configured
125
+ if self.db:
126
+ await self.db.connect()
127
+ logger.info("Connected to PostgreSQL database")
128
+
129
+ self.client = AsyncWebsocketClient(self.websocket_url)
130
+ await self.client.open()
131
+
132
+ # Subscribe to ledger stream
133
+ subscribe_request = Subscribe(streams=["ledger", "transactions"])
134
+ await self.client.send(subscribe_request)
135
+
136
+ logger.info("Subscribed to ledger stream")
137
+ self.running = True
138
+
139
+ # Process messages
140
+ async for message in self.client:
141
+ try:
142
+ await self._handle_message(message)
143
+ except Exception as e:
144
+ logger.error(f"Error processing message: {e}", exc_info=True)
145
+
146
+ async def stop(self):
147
+ """Stop monitoring."""
148
+ logger.info("Stopping XLS-66 monitor")
149
+ self.running = False
150
+ if self.client and self.client.is_open():
151
+ await self.client.close()
152
+ if self.db:
153
+ await self.db.disconnect()
154
+ logger.info("Disconnected from database")
155
+
156
+ async def _handle_message(self, message: dict):
157
+ """Process incoming WebSocket message."""
158
+ if not isinstance(message, dict):
159
+ return
160
+
161
+ msg_type = message.get('type')
162
+
163
+ if msg_type == 'transaction':
164
+ await self._handle_transaction(message)
165
+
166
+ async def _handle_transaction(self, message: dict):
167
+ """Process transaction message looking for LoanManage defaults."""
168
+ tx = message.get('transaction', {})
169
+ meta = message.get('meta', {})
170
+
171
+ # Check if this is a LoanManage transaction
172
+ if tx.get('TransactionType') != 'LoanManage':
173
+ return
174
+
175
+ # Check for default flag (0x00010000 = tfLoanDefault)
176
+ TF_LOAN_DEFAULT = 0x00010000
177
+ flags = tx.get('Flags', 0)
178
+
179
+ if not (flags & TF_LOAN_DEFAULT):
180
+ return
181
+
182
+ # This is a default event!
183
+ loan_id = tx.get('LoanID')
184
+ tx_hash = tx.get('hash')
185
+ ledger_index = message.get('ledger_index', 0)
186
+
187
+ logger.info(f"Default detected: Loan {loan_id[:8]}... in tx {tx_hash[:8]}...")
188
+
189
+ try:
190
+ # Fetch loan, broker, and vault state
191
+ loan = await self._get_loan(loan_id)
192
+ loan_broker = await self._get_loan_broker(loan.loan_broker_id)
193
+ vault = await self._get_vault(loan_broker.vault_id)
194
+
195
+ # Check if we're monitoring this broker
196
+ if self.monitored_loan_brokers and loan_broker.loan_broker_id not in self.monitored_loan_brokers:
197
+ logger.debug(f"Skipping unmonitored LoanBroker {loan_broker.loan_broker_id[:8]}...")
198
+ return
199
+
200
+ # Calculate loss amounts
201
+ loss_calc = calculate_vault_loss(
202
+ principal_outstanding=loan.principal_outstanding,
203
+ interest_outstanding=loan.interest_outstanding,
204
+ debt_total=loan_broker.debt_total,
205
+ cover_available=loan_broker.cover_available,
206
+ cover_rate_minimum=loan_broker.cover_rate_minimum,
207
+ cover_rate_liquidation=loan_broker.cover_rate_liquidation
208
+ )
209
+
210
+ # Create default event
211
+ event = DefaultEvent(
212
+ loan=loan,
213
+ loan_broker=loan_broker,
214
+ vault=vault,
215
+ default_amount=loss_calc['default_amount'],
216
+ default_covered=loss_calc['default_covered'],
217
+ vault_loss=loss_calc['vault_loss'],
218
+ tx_hash=tx_hash,
219
+ ledger_index=ledger_index
220
+ )
221
+
222
+ # Log event details
223
+ logger.info(
224
+ f"Default details: "
225
+ f"Amount={event.default_amount / 1_000_000:.2f} XRP, "
226
+ f"Covered={event.default_covered / 1_000_000:.2f} XRP, "
227
+ f"VaultLoss={event.vault_loss / 1_000_000:.2f} XRP"
228
+ )
229
+
230
+ # Calculate share value impact
231
+ share_impact = calculate_share_value_impact(
232
+ assets_total_before=vault.assets_total,
233
+ loss_unrealized=vault.loss_unrealized,
234
+ shares_total=vault.shares_total,
235
+ vault_loss=event.vault_loss
236
+ )
237
+
238
+ logger.info(
239
+ f"Share impact: "
240
+ f"{share_impact['share_value_before']:.6f} XRP → "
241
+ f"{share_impact['share_value_after']:.6f} XRP "
242
+ f"({share_impact['loss_percentage']:.2f}% loss)"
243
+ )
244
+
245
+ # Store event in database
246
+ if self.db:
247
+ try:
248
+ event_id = await self.db.log_default_event(
249
+ loan_id=loan.loan_id,
250
+ loan_broker_id=loan_broker.loan_broker_id,
251
+ vault_id=vault.vault_id,
252
+ borrower_address=loan.borrower,
253
+ default_amount=event.default_amount,
254
+ default_covered=event.default_covered,
255
+ vault_loss=event.vault_loss,
256
+ tx_hash=tx_hash,
257
+ ledger_index=ledger_index
258
+ )
259
+ logger.info(f"Logged default event to database: {event_id}")
260
+
261
+ # Update vault state
262
+ await self.db.upsert_vault_state(
263
+ vault_id=vault.vault_id,
264
+ loan_broker_id=loan_broker.loan_broker_id,
265
+ asset_type="XRP", # TODO: Parse from vault.asset
266
+ assets_total=vault.assets_total - event.vault_loss,
267
+ assets_available=vault.assets_available,
268
+ loss_unrealized=vault.loss_unrealized,
269
+ shares_total=vault.shares_total,
270
+ share_value=share_impact['share_value_after'],
271
+ ledger_index=ledger_index
272
+ )
273
+ logger.info("Updated vault state in database")
274
+ except Exception as db_error:
275
+ logger.error(f"Database error: {db_error}")
276
+
277
+ # Trigger callbacks
278
+ await self._trigger_callbacks(event)
279
+
280
+ except Exception as e:
281
+ logger.error(f"Error processing default event: {e}", exc_info=True)
282
+
283
+ async def _trigger_callbacks(self, event: DefaultEvent):
284
+ """Trigger all registered default callbacks."""
285
+ for callback in self.default_callbacks:
286
+ try:
287
+ if asyncio.iscoroutinefunction(callback):
288
+ await callback(event)
289
+ else:
290
+ callback(event)
291
+ except Exception as e:
292
+ logger.error(f"Error in callback {callback.__name__}: {e}", exc_info=True)
293
+
294
+ async def _get_loan(self, loan_id: str) -> Loan:
295
+ """Fetch Loan ledger entry."""
296
+ request = LedgerEntry(loan=loan_id)
297
+ response = await self.client.request(request)
298
+
299
+ if not response.is_successful():
300
+ raise ValueError(f"Failed to fetch Loan {loan_id}: {response.result}")
301
+
302
+ return Loan.from_ledger_entry(response.result)
303
+
304
+ async def _get_loan_broker(self, loan_broker_id: str) -> LoanBroker:
305
+ """Fetch LoanBroker ledger entry (with caching)."""
306
+ if loan_broker_id in self._loan_broker_cache:
307
+ return self._loan_broker_cache[loan_broker_id]
308
+
309
+ request = LedgerEntry(loan_broker=loan_broker_id)
310
+ response = await self.client.request(request)
311
+
312
+ if not response.is_successful():
313
+ raise ValueError(f"Failed to fetch LoanBroker {loan_broker_id}: {response.result}")
314
+
315
+ broker = LoanBroker.from_ledger_entry(response.result)
316
+ self._loan_broker_cache[loan_broker_id] = broker
317
+ return broker
318
+
319
+ async def _get_vault(self, vault_id: str) -> Vault:
320
+ """Fetch Vault ledger entry (with caching)."""
321
+ if vault_id in self._vault_cache:
322
+ return self._vault_cache[vault_id]
323
+
324
+ request = LedgerEntry(vault=vault_id)
325
+ response = await self.client.request(request)
326
+
327
+ if not response.is_successful():
328
+ raise ValueError(f"Failed to fetch Vault {vault_id}: {response.result}")
329
+
330
+ vault = Vault.from_ledger_entry(response.result)
331
+ self._vault_cache[vault_id] = vault
332
+ return vault
333
+
334
+ def add_loan_broker(self, loan_broker_id: str):
335
+ """Add a LoanBroker to monitor list."""
336
+ self.monitored_loan_brokers.add(loan_broker_id)
337
+ logger.info(f"Now monitoring LoanBroker {loan_broker_id[:8]}...")
338
+
339
+ def remove_loan_broker(self, loan_broker_id: str):
340
+ """Remove a LoanBroker from monitor list."""
341
+ self.monitored_loan_brokers.discard(loan_broker_id)
342
+ logger.info(f"Stopped monitoring LoanBroker {loan_broker_id[:8]}...")