ward-protocol 0.2.3__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.
Files changed (27) hide show
  1. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/PKG-INFO +9 -8
  2. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/README.md +6 -5
  3. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/pyproject.toml +3 -3
  4. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/__init__.py +49 -32
  5. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/chain_reader.py +2 -6
  6. ward_protocol-0.2.5/ward/client.py +427 -0
  7. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/constants.py +53 -46
  8. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/coverage.py +2 -1
  9. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/keys.py +10 -6
  10. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/monitor.py +8 -11
  11. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/pool.py +173 -27
  12. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/primitives.py +15 -15
  13. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/registry.py +8 -5
  14. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/settlement.py +53 -36
  15. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/tx_builder.py +4 -1
  16. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/validator.py +73 -38
  17. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/vault_monitor.py +61 -44
  18. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward/webhooks.py +26 -18
  19. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward_protocol.egg-info/PKG-INFO +9 -8
  20. ward_protocol-0.2.3/ward/client.py +0 -210
  21. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/LICENSE +0 -0
  22. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/setup.cfg +0 -0
  23. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward_client.py +0 -0
  24. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward_protocol.egg-info/SOURCES.txt +0 -0
  25. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward_protocol.egg-info/dependency_links.txt +0 -0
  26. {ward_protocol-0.2.3 → ward_protocol-0.2.5}/ward_protocol.egg-info/requires.txt +0 -0
  27. {ward_protocol-0.2.3 → 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.3
4
- Summary: Open specification for XLS-66 vault default protection on the XRP Ledger
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://github.com/wflores9/ward-protocol
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
@@ -33,8 +33,8 @@ Dynamic: license-file
33
33
  # Ward Protocol
34
34
 
35
35
  [![Website](https://img.shields.io/badge/website-wardprotocol.org-blue)](https://wardprotocol.org)
36
- [![SDK](https://img.shields.io/badge/SDK-v0.2.3-green)](#sdk-changelog)
37
- [![Tests](https://img.shields.io/badge/tests-165%2F165-brightgreen)](#running-tests)
36
+ [![SDK](https://img.shields.io/badge/SDK-v0.2.4-green)](#sdk-changelog)
37
+ [![Tests](https://img.shields.io/badge/tests-204%2F204-brightgreen)](#running-tests)
38
38
  [![XRPL](https://img.shields.io/badge/XRPL-XLS--66%20%C2%B7%20XLS--70%20%C2%B7%20XLS--20-orange)](https://github.com/XRPLF/XRPL-Standards)
39
39
  [![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)
40
40
 
@@ -180,9 +180,10 @@ Output: `VALID / INVALID` + deterministic unsigned EscrowCreate
180
180
 
181
181
  ## Implementation Status
182
182
 
183
- - ✅ SDK built and tested — 165/165 tests passing
183
+ - ✅ SDK built and tested — 296/296 Python tests passing
184
184
  - ✅ 40/40 Rust tests passing
185
- - ✅ v0.2.3 modular architecture 8 Python modules + 2 Rust modules
185
+ - ✅ 45/45 TypeScript tests passing (sdk/typescript/)
186
+ - ✅ v0.2.4 modular architecture — 8 Python modules + 2 Rust modules
186
187
  - ✅ Testnet simulation confirmed — 5 on-chain transactions (Altnet)
187
188
  - ✅ XRPLF Discussion #474 — active
188
189
  - ✅ Security review complete — all findings resolved
@@ -313,7 +314,7 @@ ruff check ward/
313
314
 
314
315
  See [CHANGELOG.md](CHANGELOG.md)
315
316
 
316
- Current: v0.2.3165/165 Python tests · 40/40 Rust tests · 45/45 TypeScript tests · ruff clean
317
+ Current: v0.2.4296/296 Python tests · 40/40 Rust tests · 45/45 TypeScript tests · ruff clean
317
318
 
318
319
  ### TypeScript (sdk/typescript/)
319
320
 
@@ -1,8 +1,8 @@
1
1
  # Ward Protocol
2
2
 
3
3
  [![Website](https://img.shields.io/badge/website-wardprotocol.org-blue)](https://wardprotocol.org)
4
- [![SDK](https://img.shields.io/badge/SDK-v0.2.3-green)](#sdk-changelog)
5
- [![Tests](https://img.shields.io/badge/tests-165%2F165-brightgreen)](#running-tests)
4
+ [![SDK](https://img.shields.io/badge/SDK-v0.2.4-green)](#sdk-changelog)
5
+ [![Tests](https://img.shields.io/badge/tests-204%2F204-brightgreen)](#running-tests)
6
6
  [![XRPL](https://img.shields.io/badge/XRPL-XLS--66%20%C2%B7%20XLS--70%20%C2%B7%20XLS--20-orange)](https://github.com/XRPLF/XRPL-Standards)
7
7
  [![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)
8
8
 
@@ -148,9 +148,10 @@ Output: `VALID / INVALID` + deterministic unsigned EscrowCreate
148
148
 
149
149
  ## Implementation Status
150
150
 
151
- - ✅ SDK built and tested — 165/165 tests passing
151
+ - ✅ SDK built and tested — 296/296 Python tests passing
152
152
  - ✅ 40/40 Rust tests passing
153
- - ✅ v0.2.3 modular architecture 8 Python modules + 2 Rust modules
153
+ - ✅ 45/45 TypeScript tests passing (sdk/typescript/)
154
+ - ✅ v0.2.4 modular architecture — 8 Python modules + 2 Rust modules
154
155
  - ✅ Testnet simulation confirmed — 5 on-chain transactions (Altnet)
155
156
  - ✅ XRPLF Discussion #474 — active
156
157
  - ✅ Security review complete — all findings resolved
@@ -281,7 +282,7 @@ ruff check ward/
281
282
 
282
283
  See [CHANGELOG.md](CHANGELOG.md)
283
284
 
284
- Current: v0.2.3165/165 Python tests · 40/40 Rust tests · 45/45 TypeScript tests · ruff clean
285
+ Current: v0.2.4296/296 Python tests · 40/40 Rust tests · 45/45 TypeScript tests · ruff clean
285
286
 
286
287
  ### TypeScript (sdk/typescript/)
287
288
 
@@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "ward-protocol"
8
- version = "0.2.3"
9
- description = "Open specification for XLS-66 vault default protection on the XRP Ledger"
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://github.com/wflores9/ward-protocol"
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
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.2 Add ripple_time_now, get_ledger_close_time exports;
11
- full __all__ with all constants; aligned with ward_client.py shim.
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", "get_vaults", "get_vault", "deregister_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", "VerifiedDefault", "DefaultSignal",
83
- "ClaimValidator", "ValidationResult",
84
- "EscrowSettlement", "EscrowRecord",
85
- "PoolHealthMonitor", "PoolHealth",
86
-
84
+ "VaultMonitor",
85
+ "VerifiedDefault",
86
+ "DefaultSignal",
87
+ "ClaimValidator",
88
+ "ValidationResult",
89
+ "EscrowSettlement",
90
+ "EscrowRecord",
91
+ "PoolHealthMonitor",
92
+ "PoolHealth",
87
93
  # Errors
88
- "WardError", "ValidationError", "SecurityError", "LedgerError",
89
-
94
+ "WardError",
95
+ "ValidationError",
96
+ "SecurityError",
97
+ "LedgerError",
90
98
  # Validators / crypto utilities
91
- "validate_xrpl_address", "validate_drops_amount", "validate_drops",
92
- "validate_nft_id", "validate_wallet",
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", "generate_claim_preimage",
95
- "get_ledger_close_time", "ripple_time_now",
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", "DEFAULT_TESTNET_WS",
103
- "DEFAULT_MAINNET_URL", "DEFAULT_MAINNET_WS",
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", "WARD_CREDENTIAL_TAXON", "CREDENTIAL_NFT_TAXON",
108
- "TF_BURNABLE", "TF_TRANSFERABLE",
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", "CLAIM_RATE_LIMIT_WINDOW_S",
114
- "ESCROW_DISPUTE_HOURS", "ESCROW_CANCEL_HOURS",
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", "XRPL_OWNER_RESERVE_DROPS",
120
- "XRP_MAX_DROPS", "RIPPLE_EPOCH_OFFSET",
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
+ }