ward-protocol 0.2.4__tar.gz → 0.2.5__tar.gz
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_protocol-0.2.4 → ward_protocol-0.2.5}/PKG-INFO +5 -5
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/README.md +2 -2
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/pyproject.toml +3 -3
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/__init__.py +49 -32
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/chain_reader.py +2 -6
- ward_protocol-0.2.5/ward/client.py +427 -0
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/constants.py +53 -46
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/coverage.py +2 -1
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/keys.py +10 -6
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/monitor.py +8 -11
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/pool.py +173 -27
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/primitives.py +15 -15
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/registry.py +8 -5
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/settlement.py +53 -36
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/tx_builder.py +4 -1
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/validator.py +73 -38
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/vault_monitor.py +61 -44
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward/webhooks.py +26 -18
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward_protocol.egg-info/PKG-INFO +5 -5
- ward_protocol-0.2.4/ward/client.py +0 -210
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/LICENSE +0 -0
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/setup.cfg +0 -0
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward_client.py +0 -0
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward_protocol.egg-info/SOURCES.txt +0 -0
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward_protocol.egg-info/dependency_links.txt +0 -0
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward_protocol.egg-info/requires.txt +0 -0
- {ward_protocol-0.2.4 → ward_protocol-0.2.5}/ward_protocol.egg-info/top_level.txt +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ward-protocol
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Deterministic default resolution for XLS-66 lending vaults on the XRP Ledger
|
|
5
5
|
Author-email: Ward Protocol <team@wardprotocol.org>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://wardprotocol.org
|
|
8
|
-
Project-URL: Repository, https://
|
|
8
|
+
Project-URL: Repository, https://wardprotocol.org
|
|
9
9
|
Project-URL: Documentation, https://api.wardprotocol.org/docs
|
|
10
10
|
Project-URL: Bug Tracker, https://github.com/wflores9/ward-protocol/issues
|
|
11
11
|
Keywords: xrpl,default-protection,defi,blockchain,escrow,nft,xls-66
|
|
@@ -180,7 +180,7 @@ Output: `VALID / INVALID` + deterministic unsigned EscrowCreate
|
|
|
180
180
|
|
|
181
181
|
## Implementation Status
|
|
182
182
|
|
|
183
|
-
- ✅ SDK built and tested —
|
|
183
|
+
- ✅ SDK built and tested — 296/296 Python tests passing
|
|
184
184
|
- ✅ 40/40 Rust tests passing
|
|
185
185
|
- ✅ 45/45 TypeScript tests passing (sdk/typescript/)
|
|
186
186
|
- ✅ v0.2.4 modular architecture — 8 Python modules + 2 Rust modules
|
|
@@ -314,7 +314,7 @@ ruff check ward/
|
|
|
314
314
|
|
|
315
315
|
See [CHANGELOG.md](CHANGELOG.md)
|
|
316
316
|
|
|
317
|
-
Current: v0.2.4 —
|
|
317
|
+
Current: v0.2.4 — 296/296 Python tests · 40/40 Rust tests · 45/45 TypeScript tests · ruff clean
|
|
318
318
|
|
|
319
319
|
### TypeScript (sdk/typescript/)
|
|
320
320
|
|
|
@@ -148,7 +148,7 @@ Output: `VALID / INVALID` + deterministic unsigned EscrowCreate
|
|
|
148
148
|
|
|
149
149
|
## Implementation Status
|
|
150
150
|
|
|
151
|
-
- ✅ SDK built and tested —
|
|
151
|
+
- ✅ SDK built and tested — 296/296 Python tests passing
|
|
152
152
|
- ✅ 40/40 Rust tests passing
|
|
153
153
|
- ✅ 45/45 TypeScript tests passing (sdk/typescript/)
|
|
154
154
|
- ✅ v0.2.4 modular architecture — 8 Python modules + 2 Rust modules
|
|
@@ -282,7 +282,7 @@ ruff check ward/
|
|
|
282
282
|
|
|
283
283
|
See [CHANGELOG.md](CHANGELOG.md)
|
|
284
284
|
|
|
285
|
-
Current: v0.2.4 —
|
|
285
|
+
Current: v0.2.4 — 296/296 Python tests · 40/40 Rust tests · 45/45 TypeScript tests · ruff clean
|
|
286
286
|
|
|
287
287
|
### TypeScript (sdk/typescript/)
|
|
288
288
|
|
|
@@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "ward-protocol"
|
|
8
|
-
version = "0.2.
|
|
9
|
-
description = "
|
|
8
|
+
version = "0.2.5"
|
|
9
|
+
description = "Deterministic default resolution for XLS-66 lending vaults on the XRP Ledger"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
license = {text = "MIT"}
|
|
@@ -42,7 +42,7 @@ dev = [
|
|
|
42
42
|
|
|
43
43
|
[project.urls]
|
|
44
44
|
Homepage = "https://wardprotocol.org"
|
|
45
|
-
Repository = "https://
|
|
45
|
+
Repository = "https://wardprotocol.org"
|
|
46
46
|
Documentation = "https://api.wardprotocol.org/docs"
|
|
47
47
|
"Bug Tracker" = "https://github.com/wflores9/ward-protocol/issues"
|
|
48
48
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Ward Protocol SDK — Public Package API v0.2.
|
|
2
|
+
Ward Protocol SDK — Public Package API v0.2.4
|
|
3
3
|
|
|
4
4
|
from ward import WardClient, VaultMonitor, ClaimValidator
|
|
5
5
|
from ward import EscrowSettlement, PoolHealthMonitor
|
|
@@ -7,20 +7,19 @@ Ward Protocol SDK — Public Package API v0.2.2
|
|
|
7
7
|
from ward import PoolHealth, VerifiedDefault, ValidationResult
|
|
8
8
|
|
|
9
9
|
Changelog:
|
|
10
|
-
v0.2.
|
|
11
|
-
|
|
10
|
+
v0.2.4 README/stats corrected; CI workflows overhauled; ruff.toml added.
|
|
11
|
+
v0.2.3 On-chain coverage registry; webhook notifications; TypeScript SDK.
|
|
12
|
+
v0.2.2 Add ripple_time_now, get_ledger_close_time exports; full __all__.
|
|
12
13
|
v0.2.1 Add ClaimValidator, ValidationResult, EscrowSettlement, EscrowRecord.
|
|
13
14
|
v0.2.0 Initial modular split from ward_client monolith.
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
__version__ = "0.2.3"
|
|
17
|
+
__version__ = "0.2.4"
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
from ward.client import WardClient
|
|
21
21
|
from ward.constants import (
|
|
22
22
|
ALLOWED_WS_URLS,
|
|
23
|
-
VaultRegistration,
|
|
24
23
|
CLAIM_RATE_LIMIT_MAX,
|
|
25
24
|
CLAIM_RATE_LIMIT_WINDOW_S,
|
|
26
25
|
CREDENTIAL_NFT_TAXON,
|
|
@@ -43,6 +42,7 @@ from ward.constants import (
|
|
|
43
42
|
XRPL_BASE_RESERVE_DROPS,
|
|
44
43
|
XRPL_OWNER_RESERVE_DROPS,
|
|
45
44
|
LicenseTier,
|
|
45
|
+
VaultRegistration,
|
|
46
46
|
)
|
|
47
47
|
from ward.pool import PoolHealth, PoolHealthMonitor
|
|
48
48
|
from ward.primitives import (
|
|
@@ -74,48 +74,65 @@ from ward.vault_monitor import DefaultSignal, VaultMonitor, VerifiedDefault
|
|
|
74
74
|
|
|
75
75
|
__all__ = [
|
|
76
76
|
# Registry
|
|
77
|
-
"register_vault",
|
|
77
|
+
"register_vault",
|
|
78
|
+
"get_vaults",
|
|
79
|
+
"get_vault",
|
|
80
|
+
"deregister_vault",
|
|
78
81
|
"VaultRegistration",
|
|
79
|
-
|
|
80
82
|
# Core SDK classes
|
|
81
83
|
"WardClient",
|
|
82
|
-
"VaultMonitor",
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
|
|
84
|
+
"VaultMonitor",
|
|
85
|
+
"VerifiedDefault",
|
|
86
|
+
"DefaultSignal",
|
|
87
|
+
"ClaimValidator",
|
|
88
|
+
"ValidationResult",
|
|
89
|
+
"EscrowSettlement",
|
|
90
|
+
"EscrowRecord",
|
|
91
|
+
"PoolHealthMonitor",
|
|
92
|
+
"PoolHealth",
|
|
87
93
|
# Errors
|
|
88
|
-
"WardError",
|
|
89
|
-
|
|
94
|
+
"WardError",
|
|
95
|
+
"ValidationError",
|
|
96
|
+
"SecurityError",
|
|
97
|
+
"LedgerError",
|
|
90
98
|
# Validators / crypto utilities
|
|
91
|
-
"validate_xrpl_address",
|
|
92
|
-
"
|
|
99
|
+
"validate_xrpl_address",
|
|
100
|
+
"validate_drops_amount",
|
|
101
|
+
"validate_drops",
|
|
102
|
+
"validate_nft_id",
|
|
103
|
+
"validate_wallet",
|
|
93
104
|
"check_rate_limit",
|
|
94
|
-
"make_preimage_condition",
|
|
95
|
-
"
|
|
105
|
+
"make_preimage_condition",
|
|
106
|
+
"generate_claim_preimage",
|
|
107
|
+
"get_ledger_close_time",
|
|
108
|
+
"ripple_time_now",
|
|
96
109
|
"submit_with_retry",
|
|
97
|
-
|
|
98
110
|
# Licensing tier
|
|
99
111
|
"LicenseTier",
|
|
100
|
-
|
|
101
112
|
# Network endpoints
|
|
102
|
-
"DEFAULT_TESTNET_URL",
|
|
103
|
-
"
|
|
113
|
+
"DEFAULT_TESTNET_URL",
|
|
114
|
+
"DEFAULT_TESTNET_WS",
|
|
115
|
+
"DEFAULT_MAINNET_URL",
|
|
116
|
+
"DEFAULT_MAINNET_WS",
|
|
104
117
|
"ALLOWED_WS_URLS",
|
|
105
|
-
|
|
106
118
|
# NFT / taxon constants
|
|
107
|
-
"WARD_POLICY_TAXON",
|
|
108
|
-
"
|
|
119
|
+
"WARD_POLICY_TAXON",
|
|
120
|
+
"WARD_CREDENTIAL_TAXON",
|
|
121
|
+
"CREDENTIAL_NFT_TAXON",
|
|
122
|
+
"TF_BURNABLE",
|
|
123
|
+
"TF_TRANSFERABLE",
|
|
109
124
|
"VALID_KYC_TYPES",
|
|
110
|
-
|
|
111
125
|
# Risk / rate constants
|
|
112
126
|
"MIN_COVERAGE_RATIO",
|
|
113
|
-
"CLAIM_RATE_LIMIT_MAX",
|
|
114
|
-
"
|
|
127
|
+
"CLAIM_RATE_LIMIT_MAX",
|
|
128
|
+
"CLAIM_RATE_LIMIT_WINDOW_S",
|
|
129
|
+
"ESCROW_DISPUTE_HOURS",
|
|
130
|
+
"ESCROW_CANCEL_HOURS",
|
|
115
131
|
"MONITOR_HEARTBEAT_TIMEOUT_S",
|
|
116
|
-
|
|
117
132
|
# XRPL chain constants
|
|
118
133
|
"LSF_LOAN_DEFAULT",
|
|
119
|
-
"XRPL_BASE_RESERVE_DROPS",
|
|
120
|
-
"
|
|
134
|
+
"XRPL_BASE_RESERVE_DROPS",
|
|
135
|
+
"XRPL_OWNER_RESERVE_DROPS",
|
|
136
|
+
"XRP_MAX_DROPS",
|
|
137
|
+
"RIPPLE_EPOCH_OFFSET",
|
|
121
138
|
]
|
|
@@ -115,9 +115,7 @@ class ChainReader:
|
|
|
115
115
|
request = AccountObjects(**kwargs)
|
|
116
116
|
response = await self.client.request(request)
|
|
117
117
|
if not response.is_successful():
|
|
118
|
-
raise LedgerError(
|
|
119
|
-
f"AccountObjects failed for {address}: {response.result}"
|
|
120
|
-
)
|
|
118
|
+
raise LedgerError(f"AccountObjects failed for {address}: {response.result}")
|
|
121
119
|
return response.result.get("account_objects", [])
|
|
122
120
|
|
|
123
121
|
async def get_escrows(self, address: str) -> list[EscrowInfo]:
|
|
@@ -168,7 +166,5 @@ class ChainReader:
|
|
|
168
166
|
request = AccountTx(account=address, limit=limit)
|
|
169
167
|
response = await self.client.request(request)
|
|
170
168
|
if not response.is_successful():
|
|
171
|
-
raise LedgerError(
|
|
172
|
-
f"AccountTx failed for {address}: {response.result}"
|
|
173
|
-
)
|
|
169
|
+
raise LedgerError(f"AccountTx failed for {address}: {response.result}")
|
|
174
170
|
return response.result.get("transactions", [])
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ward Protocol - Module 1: WardClient
|
|
3
|
+
|
|
4
|
+
Entry point for purchasing default-protection policies on XRPL.
|
|
5
|
+
|
|
6
|
+
Workflow per policy purchase:
|
|
7
|
+
1. Validate all inputs (addresses, amounts, period).
|
|
8
|
+
2. Calculate premium in drops (no float XRP arithmetic).
|
|
9
|
+
3. Submit Payment (premium to pool) via submit_with_retry.
|
|
10
|
+
4. Assemble NFT URI metadata (compact JSON, <=512 hex chars enforced).
|
|
11
|
+
5. Submit NFTokenMint (non-transferable, burnable policy certificate).
|
|
12
|
+
6. Return structured result with on-chain proof.
|
|
13
|
+
|
|
14
|
+
Fixes applied:
|
|
15
|
+
#1 Extracted from ward_client.py monolith into own module.
|
|
16
|
+
#2 wallet typed as xrpl.wallet.Wallet - validated at boundary.
|
|
17
|
+
#3 AsyncJsonRpcClient used as async context manager (no leaked connections).
|
|
18
|
+
#6 submit_with_retry for both Payment and NFTokenMint.
|
|
19
|
+
#7 URI hex length assertion enforced before any network call.
|
|
20
|
+
#7 License tier embedded in NFT metadata (on-chain self-description).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
from typing import Any, Dict, List
|
|
28
|
+
|
|
29
|
+
from xrpl.asyncio.clients import AsyncJsonRpcClient
|
|
30
|
+
from xrpl.asyncio.transaction import autofill
|
|
31
|
+
from xrpl.models import Memo, NFTokenMint, Payment
|
|
32
|
+
from xrpl.utils import str_to_hex
|
|
33
|
+
from xrpl.wallet import Wallet
|
|
34
|
+
|
|
35
|
+
from ward.constants import (
|
|
36
|
+
DEFAULT_TESTNET_URL,
|
|
37
|
+
TF_BURNABLE,
|
|
38
|
+
WARD_POLICY_TAXON,
|
|
39
|
+
LicenseTier,
|
|
40
|
+
)
|
|
41
|
+
from ward.coverage import build_premium_memo
|
|
42
|
+
from ward.primitives import (
|
|
43
|
+
ValidationError,
|
|
44
|
+
WardError,
|
|
45
|
+
get_ledger_close_time,
|
|
46
|
+
submit_with_retry,
|
|
47
|
+
validate_drops_amount,
|
|
48
|
+
validate_wallet,
|
|
49
|
+
validate_xrpl_address,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger("ward.client")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WardClient:
|
|
56
|
+
"""
|
|
57
|
+
High-level Ward Protocol client for purchasing default-protection coverage.
|
|
58
|
+
|
|
59
|
+
Usage::
|
|
60
|
+
|
|
61
|
+
client = WardClient()
|
|
62
|
+
result = await client.purchase_coverage(
|
|
63
|
+
wallet=depositor_wallet,
|
|
64
|
+
vault_address="rVault...",
|
|
65
|
+
coverage_drops=10_000_000,
|
|
66
|
+
period_days=90,
|
|
67
|
+
pool_address="rPool...",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
Ward never stores wallet keys. The wallet object is used in memory only
|
|
71
|
+
during the transaction signing flow, then discarded.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, url: str = DEFAULT_TESTNET_URL) -> None:
|
|
75
|
+
self._url = url
|
|
76
|
+
|
|
77
|
+
async def purchase_coverage(
|
|
78
|
+
self,
|
|
79
|
+
wallet: Wallet,
|
|
80
|
+
vault_address: str,
|
|
81
|
+
coverage_drops: int,
|
|
82
|
+
period_days: int,
|
|
83
|
+
pool_address: str,
|
|
84
|
+
premium_rate: float = 0.01,
|
|
85
|
+
license_tier: str = "starter",
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Purchase a default-protection policy: pay premium + mint NFT certificate.
|
|
89
|
+
|
|
90
|
+
All monetary amounts are in drops (1 XRP = 1_000_000 drops).
|
|
91
|
+
No float XRP arithmetic is performed.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
wallet: Depositor's Wallet (used in memory only, never stored).
|
|
95
|
+
vault_address: XRPL address of the vault being protected.
|
|
96
|
+
coverage_drops: Coverage amount in drops (must be > 0).
|
|
97
|
+
period_days: Coverage period in days (must be > 0).
|
|
98
|
+
pool_address: Coverage pool XRPL address.
|
|
99
|
+
premium_rate: Annual premium rate as a fraction (0.0 < rate <= 1.0).
|
|
100
|
+
license_tier: One of "starter", "standard", "enterprise".
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Dict with keys:
|
|
104
|
+
nft_token_id - the minted NFTokenID (hex)
|
|
105
|
+
premium_tx - hash of the premium Payment transaction
|
|
106
|
+
mint_tx - hash of the NFTokenMint transaction
|
|
107
|
+
coverage_drops - confirmed coverage amount in drops
|
|
108
|
+
expiry_ledger - ledger close time at which policy expires
|
|
109
|
+
"""
|
|
110
|
+
# -- Input validation (addresses/amounts first; wallet last) --------
|
|
111
|
+
validate_xrpl_address(vault_address, "vault_address")
|
|
112
|
+
validate_xrpl_address(pool_address, "pool_address")
|
|
113
|
+
validate_drops_amount(coverage_drops, "coverage_drops")
|
|
114
|
+
|
|
115
|
+
if period_days <= 0:
|
|
116
|
+
raise ValidationError(f"period_days must be > 0, got {period_days}")
|
|
117
|
+
if not (0 < premium_rate <= 1.0):
|
|
118
|
+
raise ValidationError(f"premium_rate must be in (0, 1], got {premium_rate}")
|
|
119
|
+
|
|
120
|
+
wallet = validate_wallet(wallet)
|
|
121
|
+
|
|
122
|
+
# -- Tier gate check ------------------------------------------------
|
|
123
|
+
# (Risk tier enforcement happens at mint time in pool.py; here we
|
|
124
|
+
# block unsupported tiers from reaching the network at all.)
|
|
125
|
+
allowed = LicenseTier.TIER_MINT_GATES.get(license_tier)
|
|
126
|
+
if allowed is None:
|
|
127
|
+
raise ValidationError(
|
|
128
|
+
f"Unknown license tier: {license_tier!r}. "
|
|
129
|
+
"Must be one of: starter, standard, enterprise."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# -- Premium calculation (integer drops only) -----------------------
|
|
133
|
+
premium_drops = int(coverage_drops * premium_rate * period_days / 365)
|
|
134
|
+
if premium_drops < 1:
|
|
135
|
+
premium_drops = 1
|
|
136
|
+
|
|
137
|
+
client = AsyncJsonRpcClient(self._url)
|
|
138
|
+
# Step 1: Determine expiry from ledger close time
|
|
139
|
+
current_time = await get_ledger_close_time(client)
|
|
140
|
+
expiry = current_time + (period_days * 86_400)
|
|
141
|
+
|
|
142
|
+
# Step 2: Assemble compact URI metadata
|
|
143
|
+
metadata = {
|
|
144
|
+
"w": "ward-v1",
|
|
145
|
+
"v": vault_address,
|
|
146
|
+
"c": str(coverage_drops),
|
|
147
|
+
"e": expiry,
|
|
148
|
+
"t": license_tier,
|
|
149
|
+
"pa": pool_address,
|
|
150
|
+
}
|
|
151
|
+
uri_json = json.dumps(metadata, separators=(",", ":"))
|
|
152
|
+
uri_hex = str_to_hex(uri_json).upper()
|
|
153
|
+
if len(uri_hex) > 512:
|
|
154
|
+
raise ValidationError(f"URI hex exceeds 512 chars: {len(uri_hex)}")
|
|
155
|
+
|
|
156
|
+
# Step 3: Mint NFT policy certificate (before payment — NFT ID needed for memo)
|
|
157
|
+
nft_memo = Memo(
|
|
158
|
+
memo_data=str_to_hex(
|
|
159
|
+
f"ward-policy|{license_tier}|cov={coverage_drops}"
|
|
160
|
+
).upper()
|
|
161
|
+
)
|
|
162
|
+
mint_tx = NFTokenMint(
|
|
163
|
+
account=wallet.classic_address,
|
|
164
|
+
nftoken_taxon=WARD_POLICY_TAXON,
|
|
165
|
+
flags=TF_BURNABLE,
|
|
166
|
+
uri=uri_hex,
|
|
167
|
+
memos=[nft_memo],
|
|
168
|
+
)
|
|
169
|
+
mint_tx = await autofill(mint_tx, client)
|
|
170
|
+
mint_result = await submit_with_retry(mint_tx, client, wallet)
|
|
171
|
+
nft_token_id = mint_result.result.get("meta", {}).get("nftoken_id", "")
|
|
172
|
+
if not nft_token_id:
|
|
173
|
+
raise WardError(
|
|
174
|
+
"NFT mint succeeded but nftoken_id is empty in response metadata"
|
|
175
|
+
)
|
|
176
|
+
mint_tx_hash = mint_result.result.get("hash", "") or mint_result.result.get(
|
|
177
|
+
"tx_json", {}
|
|
178
|
+
).get("hash", "")
|
|
179
|
+
|
|
180
|
+
# Step 4: Premium Payment with ward/policy-premium memo (on-chain registry)
|
|
181
|
+
_pm = build_premium_memo(nft_token_id, coverage_drops)
|
|
182
|
+
premium_memo = Memo(
|
|
183
|
+
memo_type=_pm["Memo"].get("MemoType"),
|
|
184
|
+
memo_data=_pm["Memo"].get("MemoData"),
|
|
185
|
+
)
|
|
186
|
+
payment = Payment(
|
|
187
|
+
account=wallet.classic_address,
|
|
188
|
+
destination=pool_address,
|
|
189
|
+
amount=str(premium_drops),
|
|
190
|
+
memos=[premium_memo],
|
|
191
|
+
)
|
|
192
|
+
payment = await autofill(payment, client)
|
|
193
|
+
premium_result = await submit_with_retry(payment, client, wallet)
|
|
194
|
+
premium_tx = premium_result.result.get("hash", "") or premium_result.result.get(
|
|
195
|
+
"tx_json", {}
|
|
196
|
+
).get("hash", "")
|
|
197
|
+
if not premium_tx:
|
|
198
|
+
raise WardError(
|
|
199
|
+
"Premium payment succeeded but transaction hash not found in result. "
|
|
200
|
+
"Check submit_and_wait response structure."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
logger.info(
|
|
204
|
+
"Policy purchased: vault=%s cov=%d tier=%s nft=%s",
|
|
205
|
+
vault_address,
|
|
206
|
+
coverage_drops,
|
|
207
|
+
license_tier,
|
|
208
|
+
nft_token_id,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
"nft_token_id": nft_token_id,
|
|
213
|
+
"premium_tx": premium_tx,
|
|
214
|
+
"mint_tx": mint_tx_hash,
|
|
215
|
+
"coverage_drops": coverage_drops,
|
|
216
|
+
"expiry_ledger": expiry,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async def purchase_multi_vault_coverage(
|
|
220
|
+
self,
|
|
221
|
+
wallet: Wallet,
|
|
222
|
+
vault_addresses: List[str],
|
|
223
|
+
coverage_drops: int,
|
|
224
|
+
period_days: int,
|
|
225
|
+
pool_address: str,
|
|
226
|
+
premium_rate: float = 0.01,
|
|
227
|
+
license_tier: str = "starter",
|
|
228
|
+
) -> List[Dict[str, Any]]:
|
|
229
|
+
"""
|
|
230
|
+
Purchase default-protection policies for multiple vaults in one operation.
|
|
231
|
+
|
|
232
|
+
Mints one non-transferable NFT per vault (taxon 281, TF_BURNABLE only).
|
|
233
|
+
Collects a single premium payment covering all vaults combined.
|
|
234
|
+
ward_signed = False throughout — no Ward key touches any transaction.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
wallet: Depositor's Wallet (used in memory only, never stored).
|
|
238
|
+
vault_addresses: XRPL addresses of vaults to protect (1–10, no duplicates).
|
|
239
|
+
coverage_drops: Per-vault coverage in drops (must be > 0).
|
|
240
|
+
period_days: Coverage period in days (must be > 0).
|
|
241
|
+
pool_address: Coverage pool XRPL address.
|
|
242
|
+
premium_rate: Annual premium rate as a fraction (0.0 < rate <= 1.0).
|
|
243
|
+
license_tier: One of "starter", "standard", "enterprise".
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
List of dicts (one per vault), each with:
|
|
247
|
+
vault_address - the vault this NFT covers
|
|
248
|
+
nft_token_id - the minted NFTokenID (hex)
|
|
249
|
+
premium_tx - hash of the single premium Payment transaction
|
|
250
|
+
mint_tx - hash of this vault's NFTokenMint transaction
|
|
251
|
+
coverage_drops - per-vault coverage amount in drops
|
|
252
|
+
expiry_ledger - ledger close time at which policy expires
|
|
253
|
+
"""
|
|
254
|
+
# -- Structural validation (fast-fail before any network I/O) ----------
|
|
255
|
+
if not vault_addresses:
|
|
256
|
+
raise ValidationError("vault_addresses must not be empty")
|
|
257
|
+
if len(vault_addresses) > 10:
|
|
258
|
+
raise ValidationError(
|
|
259
|
+
f"At most 10 vaults per multi-vault policy, got {len(vault_addresses)}"
|
|
260
|
+
)
|
|
261
|
+
for i, addr in enumerate(vault_addresses):
|
|
262
|
+
validate_xrpl_address(addr, f"vault_addresses[{i}]")
|
|
263
|
+
if len(set(vault_addresses)) != len(vault_addresses):
|
|
264
|
+
raise ValidationError("vault_addresses contains duplicate entries")
|
|
265
|
+
|
|
266
|
+
validate_xrpl_address(pool_address, "pool_address")
|
|
267
|
+
validate_drops_amount(coverage_drops, "coverage_drops")
|
|
268
|
+
|
|
269
|
+
if period_days <= 0:
|
|
270
|
+
raise ValidationError(f"period_days must be > 0, got {period_days}")
|
|
271
|
+
if not (0 < premium_rate <= 1.0):
|
|
272
|
+
raise ValidationError(f"premium_rate must be in (0, 1], got {premium_rate}")
|
|
273
|
+
|
|
274
|
+
wallet = validate_wallet(wallet)
|
|
275
|
+
|
|
276
|
+
allowed = LicenseTier.TIER_MINT_GATES.get(license_tier)
|
|
277
|
+
if allowed is None:
|
|
278
|
+
raise ValidationError(
|
|
279
|
+
f"Unknown license tier: {license_tier!r}. "
|
|
280
|
+
"Must be one of: starter, standard, enterprise."
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# -- Premium covers combined coverage of all vaults --------------------
|
|
284
|
+
total_coverage_drops = coverage_drops * len(vault_addresses)
|
|
285
|
+
premium_drops = int(total_coverage_drops * premium_rate * period_days / 365)
|
|
286
|
+
if premium_drops < 1:
|
|
287
|
+
premium_drops = 1
|
|
288
|
+
|
|
289
|
+
client = AsyncJsonRpcClient(self._url)
|
|
290
|
+
current_time = await get_ledger_close_time(client)
|
|
291
|
+
expiry = current_time + (period_days * 86_400)
|
|
292
|
+
|
|
293
|
+
results: List[Dict[str, Any]] = []
|
|
294
|
+
nft_token_ids: List[str] = []
|
|
295
|
+
|
|
296
|
+
# -- Mint one NFT per vault --------------------------------------------
|
|
297
|
+
for vault_address in vault_addresses:
|
|
298
|
+
metadata = {
|
|
299
|
+
"w": "ward-v1",
|
|
300
|
+
"v": vault_address,
|
|
301
|
+
"c": str(coverage_drops),
|
|
302
|
+
"e": expiry,
|
|
303
|
+
"t": license_tier,
|
|
304
|
+
"pa": pool_address,
|
|
305
|
+
}
|
|
306
|
+
uri_json = json.dumps(metadata, separators=(",", ":"))
|
|
307
|
+
uri_hex = str_to_hex(uri_json).upper()
|
|
308
|
+
if len(uri_hex) > 512:
|
|
309
|
+
raise ValidationError(f"URI hex exceeds 512 chars: {len(uri_hex)}")
|
|
310
|
+
|
|
311
|
+
nft_memo = Memo(
|
|
312
|
+
memo_data=str_to_hex(
|
|
313
|
+
f"ward-policy|{license_tier}|cov={coverage_drops}"
|
|
314
|
+
).upper()
|
|
315
|
+
)
|
|
316
|
+
mint_tx = NFTokenMint(
|
|
317
|
+
account=wallet.classic_address,
|
|
318
|
+
nftoken_taxon=WARD_POLICY_TAXON,
|
|
319
|
+
flags=TF_BURNABLE,
|
|
320
|
+
uri=uri_hex,
|
|
321
|
+
memos=[nft_memo],
|
|
322
|
+
)
|
|
323
|
+
mint_tx = await autofill(mint_tx, client)
|
|
324
|
+
mint_result = await submit_with_retry(mint_tx, client, wallet)
|
|
325
|
+
nft_token_id = mint_result.result.get("meta", {}).get("nftoken_id", "")
|
|
326
|
+
if not nft_token_id:
|
|
327
|
+
raise WardError(
|
|
328
|
+
f"NFT mint succeeded for vault {vault_address} but "
|
|
329
|
+
"nftoken_id is empty in response metadata"
|
|
330
|
+
)
|
|
331
|
+
mint_tx_hash = mint_result.result.get("hash", "") or mint_result.result.get(
|
|
332
|
+
"tx_json", {}
|
|
333
|
+
).get("hash", "")
|
|
334
|
+
|
|
335
|
+
nft_token_ids.append(nft_token_id)
|
|
336
|
+
results.append(
|
|
337
|
+
{
|
|
338
|
+
"vault_address": vault_address,
|
|
339
|
+
"nft_token_id": nft_token_id,
|
|
340
|
+
"mint_tx": mint_tx_hash,
|
|
341
|
+
"coverage_drops": coverage_drops,
|
|
342
|
+
"expiry_ledger": expiry,
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# -- Single premium payment covering all vaults -----------------------
|
|
347
|
+
_pm = build_premium_memo(nft_token_ids[0], total_coverage_drops)
|
|
348
|
+
premium_memo = Memo(
|
|
349
|
+
memo_type=_pm["Memo"].get("MemoType"),
|
|
350
|
+
memo_data=_pm["Memo"].get("MemoData"),
|
|
351
|
+
)
|
|
352
|
+
payment = Payment(
|
|
353
|
+
account=wallet.classic_address,
|
|
354
|
+
destination=pool_address,
|
|
355
|
+
amount=str(premium_drops),
|
|
356
|
+
memos=[premium_memo],
|
|
357
|
+
)
|
|
358
|
+
payment = await autofill(payment, client)
|
|
359
|
+
premium_result = await submit_with_retry(payment, client, wallet)
|
|
360
|
+
premium_tx = premium_result.result.get("hash", "") or premium_result.result.get(
|
|
361
|
+
"tx_json", {}
|
|
362
|
+
).get("hash", "")
|
|
363
|
+
if not premium_tx:
|
|
364
|
+
raise WardError(
|
|
365
|
+
"Multi-vault premium payment succeeded but transaction hash "
|
|
366
|
+
"not found in result."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
for r in results:
|
|
370
|
+
r["premium_tx"] = premium_tx
|
|
371
|
+
|
|
372
|
+
logger.info(
|
|
373
|
+
"Multi-vault policy purchased: vaults=%d cov_each=%d tier=%s premium_tx=%s",
|
|
374
|
+
len(vault_addresses),
|
|
375
|
+
coverage_drops,
|
|
376
|
+
license_tier,
|
|
377
|
+
premium_tx,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return results
|
|
381
|
+
|
|
382
|
+
def register_pool_member(
|
|
383
|
+
self,
|
|
384
|
+
pool_address: str,
|
|
385
|
+
member_address: str,
|
|
386
|
+
contribution_drops: int,
|
|
387
|
+
) -> Dict[str, Any]:
|
|
388
|
+
"""
|
|
389
|
+
Build an unsigned AccountSet transaction for pool member registration.
|
|
390
|
+
|
|
391
|
+
The institution signs and submits the returned transaction themselves.
|
|
392
|
+
ward_signed = False — Ward never holds or touches signing keys.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
pool_address: XRPL address of the target coverage pool.
|
|
396
|
+
member_address: Institution address joining the pool.
|
|
397
|
+
contribution_drops: Capital contribution in drops (must be > 0).
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Unsigned transaction dict ready for institution signing and submission.
|
|
401
|
+
"""
|
|
402
|
+
validate_xrpl_address(pool_address, "pool_address")
|
|
403
|
+
validate_xrpl_address(member_address, "member_address")
|
|
404
|
+
validate_drops_amount(contribution_drops, "contribution_drops")
|
|
405
|
+
|
|
406
|
+
memo_data = json.dumps(
|
|
407
|
+
{
|
|
408
|
+
"pool": pool_address,
|
|
409
|
+
"contribution_drops": contribution_drops,
|
|
410
|
+
"ward_signed": False,
|
|
411
|
+
},
|
|
412
|
+
separators=(",", ":"),
|
|
413
|
+
)
|
|
414
|
+
return {
|
|
415
|
+
"TransactionType": "AccountSet",
|
|
416
|
+
"Account": member_address,
|
|
417
|
+
"Domain": str_to_hex(f"ward-pool:{pool_address}").upper(),
|
|
418
|
+
"Memos": [
|
|
419
|
+
{
|
|
420
|
+
"Memo": {
|
|
421
|
+
"MemoType": str_to_hex("ward/pool-join").upper(),
|
|
422
|
+
"MemoData": str_to_hex(memo_data).upper(),
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
],
|
|
426
|
+
"ward_signed": False,
|
|
427
|
+
}
|