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/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]}...")
|