limitguard 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.
- limitguard/__init__.py +37 -0
- limitguard/client.py +219 -0
- limitguard/exceptions.py +50 -0
- limitguard/models.py +61 -0
- limitguard/x402.py +231 -0
- limitguard-0.1.0.dist-info/METADATA +112 -0
- limitguard-0.1.0.dist-info/RECORD +9 -0
- limitguard-0.1.0.dist-info/WHEEL +4 -0
- limitguard-0.1.0.dist-info/licenses/LICENSE +21 -0
limitguard/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""LimitGuard.ai Python SDK - Trust Intelligence API client."""
|
|
2
|
+
|
|
3
|
+
from limitguard.client import LimitGuardClient
|
|
4
|
+
from limitguard.exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
LimitGuardError,
|
|
7
|
+
PaymentRequiredError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
ServerError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
from limitguard.models import (
|
|
13
|
+
Cluster,
|
|
14
|
+
Recommendation,
|
|
15
|
+
RiskResponse,
|
|
16
|
+
TopFactor,
|
|
17
|
+
TrustLevel,
|
|
18
|
+
TrustResponse,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"LimitGuardClient",
|
|
23
|
+
"LimitGuardError",
|
|
24
|
+
"AuthenticationError",
|
|
25
|
+
"PaymentRequiredError",
|
|
26
|
+
"RateLimitError",
|
|
27
|
+
"ServerError",
|
|
28
|
+
"ValidationError",
|
|
29
|
+
"TrustResponse",
|
|
30
|
+
"RiskResponse",
|
|
31
|
+
"TrustLevel",
|
|
32
|
+
"Recommendation",
|
|
33
|
+
"Cluster",
|
|
34
|
+
"TopFactor",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
limitguard/client.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async HTTP client for LimitGuard.ai Trust Intelligence API.
|
|
3
|
+
|
|
4
|
+
Usage with API key:
|
|
5
|
+
async with LimitGuardClient(api_key="lg_live_...") as client:
|
|
6
|
+
result = await client.check_entity("Acme B.V.", "NL", kvk_number="12345678")
|
|
7
|
+
|
|
8
|
+
Usage with Solana wallet (x402 pay-per-call):
|
|
9
|
+
from limitguard.x402 import SolanaWallet
|
|
10
|
+
from solders.keypair import Keypair
|
|
11
|
+
wallet = SolanaWallet(keypair=Keypair.from_base58_string(PRIVATE_KEY))
|
|
12
|
+
async with LimitGuardClient(wallet=wallet) as client:
|
|
13
|
+
result = await client.check_entity("Acme B.V.", "NL")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from limitguard.exceptions import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
LimitGuardError,
|
|
23
|
+
PaymentRequiredError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ServerError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
from limitguard.models import RiskResponse, TrustResponse
|
|
29
|
+
|
|
30
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
31
|
+
_MAX_RETRIES = 3
|
|
32
|
+
_BACKOFF_BASE = 1.0 # seconds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LimitGuardClient:
|
|
36
|
+
"""Async client for the LimitGuard.ai API.
|
|
37
|
+
|
|
38
|
+
Supports two auth modes:
|
|
39
|
+
- API key: subscription-based access (X-API-Key header)
|
|
40
|
+
- Wallet: x402 pay-per-call with crypto (PAYMENT-SIGNATURE header)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
wallet=None,
|
|
47
|
+
base_url: str = "https://api.limitguard.ai",
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
):
|
|
50
|
+
if not api_key and not wallet:
|
|
51
|
+
raise ValueError("Provide api_key, wallet, or both")
|
|
52
|
+
self._api_key = api_key or ""
|
|
53
|
+
self._wallet = wallet
|
|
54
|
+
self._base_url = base_url.rstrip("/")
|
|
55
|
+
headers = {"Content-Type": "application/json"}
|
|
56
|
+
if api_key:
|
|
57
|
+
headers["X-API-Key"] = api_key
|
|
58
|
+
self._client = httpx.AsyncClient(
|
|
59
|
+
base_url=self._base_url,
|
|
60
|
+
headers=headers,
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def _request(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
json: dict | None = None,
|
|
69
|
+
params: dict | None = None,
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""Make an HTTP request with retry on 429/5xx and auto-pay on 402."""
|
|
72
|
+
last_exc: Exception | None = None
|
|
73
|
+
|
|
74
|
+
for attempt in range(_MAX_RETRIES):
|
|
75
|
+
try:
|
|
76
|
+
response = await self._client.request(
|
|
77
|
+
method, path, json=json, params=params,
|
|
78
|
+
)
|
|
79
|
+
except httpx.HTTPError as exc:
|
|
80
|
+
last_exc = LimitGuardError(f"HTTP error: {exc}")
|
|
81
|
+
await asyncio.sleep(_BACKOFF_BASE * (2 ** attempt))
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if response.status_code == 200:
|
|
85
|
+
return response.json()
|
|
86
|
+
|
|
87
|
+
# Auto-pay on 402 if wallet is configured
|
|
88
|
+
if response.status_code == 402 and self._wallet:
|
|
89
|
+
return await self._pay_and_retry(response, method, path, json, params)
|
|
90
|
+
|
|
91
|
+
if response.status_code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES - 1:
|
|
92
|
+
retry_after = response.headers.get("retry-after")
|
|
93
|
+
wait = float(retry_after) if retry_after else _BACKOFF_BASE * (2 ** attempt)
|
|
94
|
+
await asyncio.sleep(wait)
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
self._raise_for_status(response)
|
|
98
|
+
|
|
99
|
+
if last_exc:
|
|
100
|
+
raise last_exc
|
|
101
|
+
raise LimitGuardError("Max retries exceeded")
|
|
102
|
+
|
|
103
|
+
async def _pay_and_retry(
|
|
104
|
+
self,
|
|
105
|
+
response_402: httpx.Response,
|
|
106
|
+
method: str,
|
|
107
|
+
path: str,
|
|
108
|
+
json: dict | None,
|
|
109
|
+
params: dict | None,
|
|
110
|
+
) -> dict:
|
|
111
|
+
"""Handle 402 by making x402 payment and retrying."""
|
|
112
|
+
from limitguard.x402 import parse_402_response
|
|
113
|
+
|
|
114
|
+
body = response_402.json()
|
|
115
|
+
payment_info = parse_402_response(body)
|
|
116
|
+
|
|
117
|
+
# Prefer Solana for SolanaWallet
|
|
118
|
+
from limitguard.x402 import SolanaWallet
|
|
119
|
+
if isinstance(self._wallet, SolanaWallet) and "solana" in payment_info:
|
|
120
|
+
info = payment_info["solana"]
|
|
121
|
+
elif "base" in payment_info:
|
|
122
|
+
info = payment_info["base"]
|
|
123
|
+
elif "solana" in payment_info:
|
|
124
|
+
info = payment_info["solana"]
|
|
125
|
+
else:
|
|
126
|
+
raise PaymentRequiredError("No supported payment chain in 402 response")
|
|
127
|
+
|
|
128
|
+
payment_header = await self._wallet.build_payment(
|
|
129
|
+
amount_micro_usdc=info["amount"],
|
|
130
|
+
recipient=info["recipient"],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Retry with payment header
|
|
134
|
+
paid_response = await self._client.request(
|
|
135
|
+
method, path, json=json, params=params,
|
|
136
|
+
headers={"PAYMENT-SIGNATURE": payment_header},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if paid_response.status_code == 200:
|
|
140
|
+
return paid_response.json()
|
|
141
|
+
|
|
142
|
+
self._raise_for_status(paid_response)
|
|
143
|
+
raise LimitGuardError("Payment accepted but request failed")
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
147
|
+
"""Map HTTP status codes to typed exceptions."""
|
|
148
|
+
body = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
|
|
149
|
+
detail = body.get("detail", body.get("error", response.text))
|
|
150
|
+
|
|
151
|
+
if response.status_code == 401:
|
|
152
|
+
raise AuthenticationError(str(detail))
|
|
153
|
+
if response.status_code == 402:
|
|
154
|
+
raise PaymentRequiredError(str(detail))
|
|
155
|
+
if response.status_code == 422:
|
|
156
|
+
raise ValidationError(str(detail), detail=str(body))
|
|
157
|
+
if response.status_code == 429:
|
|
158
|
+
retry_after = response.headers.get("retry-after")
|
|
159
|
+
raise RateLimitError(
|
|
160
|
+
str(detail),
|
|
161
|
+
retry_after=float(retry_after) if retry_after else None,
|
|
162
|
+
)
|
|
163
|
+
if response.status_code >= 500:
|
|
164
|
+
raise ServerError(str(detail), status_code=response.status_code)
|
|
165
|
+
|
|
166
|
+
raise LimitGuardError(f"Unexpected status {response.status_code}: {detail}", status_code=response.status_code)
|
|
167
|
+
|
|
168
|
+
async def check_entity(
|
|
169
|
+
self,
|
|
170
|
+
entity_name: str,
|
|
171
|
+
country: str,
|
|
172
|
+
*,
|
|
173
|
+
kvk_number: str | None = None,
|
|
174
|
+
cbe_number: str | None = None,
|
|
175
|
+
domain: str | None = None,
|
|
176
|
+
iban: str | None = None,
|
|
177
|
+
vat_number: str | None = None,
|
|
178
|
+
) -> TrustResponse:
|
|
179
|
+
"""POST /v1/entity/check - Full trust intelligence."""
|
|
180
|
+
payload: dict = {"entity_name": entity_name, "country": country}
|
|
181
|
+
if kvk_number:
|
|
182
|
+
payload["kvk_number"] = kvk_number
|
|
183
|
+
if cbe_number:
|
|
184
|
+
payload["cbe_number"] = cbe_number
|
|
185
|
+
if domain:
|
|
186
|
+
payload["domain"] = domain
|
|
187
|
+
if iban:
|
|
188
|
+
payload["iban"] = iban
|
|
189
|
+
if vat_number:
|
|
190
|
+
payload["vat_number"] = vat_number
|
|
191
|
+
|
|
192
|
+
data = await self._request("POST", "/v1/entity/check", json=payload)
|
|
193
|
+
return TrustResponse(**data)
|
|
194
|
+
|
|
195
|
+
async def risk_score(self, entity_name: str, country: str) -> RiskResponse:
|
|
196
|
+
"""POST /v1/risk/score - Quick risk assessment."""
|
|
197
|
+
data = await self._request("POST", "/v1/risk/score", json={
|
|
198
|
+
"entity_name": entity_name,
|
|
199
|
+
"country": country,
|
|
200
|
+
})
|
|
201
|
+
return RiskResponse(**data)
|
|
202
|
+
|
|
203
|
+
async def get_usage(self, period: str = "day") -> dict:
|
|
204
|
+
"""GET /v1/usage/summary - Usage stats for current API key."""
|
|
205
|
+
return await self._request("GET", "/v1/usage/summary", params={"period": period})
|
|
206
|
+
|
|
207
|
+
async def verify_audit(self) -> dict:
|
|
208
|
+
"""GET /v1/audit/verify - Verify audit trail integrity."""
|
|
209
|
+
return await self._request("GET", "/v1/audit/verify")
|
|
210
|
+
|
|
211
|
+
async def close(self) -> None:
|
|
212
|
+
"""Close the underlying httpx client."""
|
|
213
|
+
await self._client.aclose()
|
|
214
|
+
|
|
215
|
+
async def __aenter__(self) -> "LimitGuardClient":
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
async def __aexit__(self, *args: object) -> None:
|
|
219
|
+
await self.close()
|
limitguard/exceptions.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed exceptions for LimitGuard SDK.
|
|
3
|
+
|
|
4
|
+
Maps HTTP status codes to specific exception types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LimitGuardError(Exception):
|
|
9
|
+
"""Base exception for all LimitGuard SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(LimitGuardError):
|
|
17
|
+
"""Raised on 401 - invalid or missing API key."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str = "Invalid or missing API key"):
|
|
20
|
+
super().__init__(message, status_code=401)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PaymentRequiredError(LimitGuardError):
|
|
24
|
+
"""Raised on 402 - payment required (x402 protocol)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str = "Payment required"):
|
|
27
|
+
super().__init__(message, status_code=402)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RateLimitError(LimitGuardError):
|
|
31
|
+
"""Raised on 429 - rate limit or monthly quota exceeded."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str = "Rate limit exceeded", retry_after: float | None = None):
|
|
34
|
+
self.retry_after = retry_after
|
|
35
|
+
super().__init__(message, status_code=429)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ValidationError(LimitGuardError):
|
|
39
|
+
"""Raised on 422 - request validation failed."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str = "Validation error", detail: str | None = None):
|
|
42
|
+
self.detail = detail
|
|
43
|
+
super().__init__(message, status_code=422)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServerError(LimitGuardError):
|
|
47
|
+
"""Raised on 5xx - server-side error."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str = "Server error", status_code: int = 500):
|
|
50
|
+
super().__init__(message, status_code=status_code)
|
limitguard/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response models for LimitGuard SDK.
|
|
3
|
+
|
|
4
|
+
Mirrors the API response schemas so callers get typed objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TrustLevel(str, Enum):
|
|
12
|
+
HIGH = "high"
|
|
13
|
+
MEDIUM = "medium"
|
|
14
|
+
LOW = "low"
|
|
15
|
+
CRITICAL = "critical"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Recommendation(str, Enum):
|
|
19
|
+
PROCEED = "proceed"
|
|
20
|
+
REVIEW = "review"
|
|
21
|
+
EDD = "enhanced_due_diligence"
|
|
22
|
+
BLOCK = "block"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Cluster(str, Enum):
|
|
26
|
+
ESTABLISHED_EU_ENTERPRISE = "established_eu_enterprise"
|
|
27
|
+
ESTABLISHED_EU_SME = "established_eu_sme"
|
|
28
|
+
VERIFIED_STARTUP = "verified_startup"
|
|
29
|
+
UNVERIFIED_NEW = "unverified_new"
|
|
30
|
+
HIGH_RISK_JURISDICTION = "high_risk_jurisdiction"
|
|
31
|
+
SANCTIONS_FLAGGED = "sanctions_flagged"
|
|
32
|
+
INSUFFICIENT_DATA = "insufficient_data"
|
|
33
|
+
MIXED_SIGNALS = "mixed_signals"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TopFactor(BaseModel):
|
|
37
|
+
source: str
|
|
38
|
+
signal: str
|
|
39
|
+
impact: str
|
|
40
|
+
weight: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TrustResponse(BaseModel):
|
|
44
|
+
trust_score: int = Field(ge=0, le=100)
|
|
45
|
+
trust_level: TrustLevel
|
|
46
|
+
cluster: Cluster
|
|
47
|
+
recommendation: Recommendation
|
|
48
|
+
confidence: float = Field(ge=0, le=1)
|
|
49
|
+
top_factors: list[TopFactor]
|
|
50
|
+
correlations: dict[str, float] = Field(default_factory=dict)
|
|
51
|
+
sources_checked: int = Field(ge=0)
|
|
52
|
+
processing_time_ms: int = Field(ge=0)
|
|
53
|
+
version: str = "1.0"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RiskResponse(BaseModel):
|
|
57
|
+
risk_score: int = Field(ge=0, le=100)
|
|
58
|
+
risk_level: TrustLevel
|
|
59
|
+
recommendation: Recommendation
|
|
60
|
+
top_factors: list[TopFactor]
|
|
61
|
+
processing_time_ms: int = Field(ge=0)
|
limitguard/x402.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
x402 payment support for LimitGuard SDK.
|
|
3
|
+
|
|
4
|
+
Enables AI agents and programmatic clients to pay for API calls
|
|
5
|
+
using Solana USDC or Base USDC via the x402 HTTP payment protocol.
|
|
6
|
+
|
|
7
|
+
Solana flow:
|
|
8
|
+
1. Build SPL TransferChecked instruction (USDC)
|
|
9
|
+
2. Sign and broadcast transaction
|
|
10
|
+
3. Wait for confirmation
|
|
11
|
+
4. Send tx signature in PAYMENT-SIGNATURE header
|
|
12
|
+
|
|
13
|
+
Base (EVM) flow:
|
|
14
|
+
1. Sign EIP-712 typed data (EIP-3009 TransferWithAuthorization)
|
|
15
|
+
2. Send signature in PAYMENT-SIGNATURE header
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
# Solana constants
|
|
26
|
+
USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
|
27
|
+
TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
|
28
|
+
ATA_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
|
|
29
|
+
COMPUTE_BUDGET = "ComputeBudget111111111111111111111111111111"
|
|
30
|
+
SOLANA_CHAIN_ID = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SolanaWallet:
|
|
34
|
+
"""Solana wallet for programmatic x402 payments.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
from solders.keypair import Keypair
|
|
38
|
+
wallet = SolanaWallet(keypair=Keypair.from_base58_string(PRIVATE_KEY))
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
keypair,
|
|
44
|
+
rpc_url: str = "https://api.mainnet-beta.solana.com",
|
|
45
|
+
):
|
|
46
|
+
self._keypair = keypair
|
|
47
|
+
self._rpc_url = rpc_url
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def pubkey(self) -> str:
|
|
51
|
+
return str(self._keypair.pubkey())
|
|
52
|
+
|
|
53
|
+
async def build_payment(
|
|
54
|
+
self,
|
|
55
|
+
amount_micro_usdc: int,
|
|
56
|
+
recipient: str,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Build, sign, broadcast a USDC transfer and return PAYMENT-SIGNATURE header.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
amount_micro_usdc: Amount in micro-USDC (e.g. 100000 = $0.10)
|
|
62
|
+
recipient: Recipient Solana address
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Base64-encoded payment header value for PAYMENT-SIGNATURE header.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
|
|
69
|
+
from solders.hash import Hash
|
|
70
|
+
from solders.keypair import Keypair
|
|
71
|
+
from solders.message import MessageV0
|
|
72
|
+
from solders.pubkey import Pubkey
|
|
73
|
+
from solders.transaction import VersionedTransaction
|
|
74
|
+
import spl.token.instructions as spl_token
|
|
75
|
+
except ImportError:
|
|
76
|
+
raise ImportError(
|
|
77
|
+
"Solana payment requires: pip install 'limitguard[solana]' "
|
|
78
|
+
"(installs solders, solana, spl-token)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
sender_key = self._keypair.pubkey()
|
|
82
|
+
recipient_key = Pubkey.from_string(recipient)
|
|
83
|
+
usdc_mint = Pubkey.from_string(USDC_MINT)
|
|
84
|
+
token_prog = Pubkey.from_string(TOKEN_PROGRAM)
|
|
85
|
+
ata_prog = Pubkey.from_string(ATA_PROGRAM)
|
|
86
|
+
|
|
87
|
+
# Derive ATAs
|
|
88
|
+
sender_ata = Pubkey.find_program_address(
|
|
89
|
+
[bytes(sender_key), bytes(token_prog), bytes(usdc_mint)],
|
|
90
|
+
ata_prog,
|
|
91
|
+
)[0]
|
|
92
|
+
recipient_ata = Pubkey.find_program_address(
|
|
93
|
+
[bytes(recipient_key), bytes(token_prog), bytes(usdc_mint)],
|
|
94
|
+
ata_prog,
|
|
95
|
+
)[0]
|
|
96
|
+
|
|
97
|
+
# Get recent blockhash
|
|
98
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
99
|
+
resp = await client.post(self._rpc_url, json={
|
|
100
|
+
"jsonrpc": "2.0", "id": 1,
|
|
101
|
+
"method": "getLatestBlockhash",
|
|
102
|
+
"params": [{"commitment": "confirmed"}],
|
|
103
|
+
})
|
|
104
|
+
result = resp.json()
|
|
105
|
+
if "error" in result:
|
|
106
|
+
raise RuntimeError(f"Blockhash fetch failed: {result['error']}")
|
|
107
|
+
bh_data = result["result"]["value"]
|
|
108
|
+
blockhash = Hash.from_string(bh_data["blockhash"])
|
|
109
|
+
|
|
110
|
+
# Build instructions
|
|
111
|
+
ix0 = set_compute_unit_limit(200_000)
|
|
112
|
+
ix1 = set_compute_unit_price(50_000)
|
|
113
|
+
|
|
114
|
+
# SPL TransferChecked
|
|
115
|
+
ix2 = spl_token.transfer_checked(
|
|
116
|
+
spl_token.TransferCheckedParams(
|
|
117
|
+
program_id=token_prog,
|
|
118
|
+
source=sender_ata,
|
|
119
|
+
mint=usdc_mint,
|
|
120
|
+
dest=recipient_ata,
|
|
121
|
+
owner=sender_key,
|
|
122
|
+
amount=amount_micro_usdc,
|
|
123
|
+
decimals=6,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Build and sign transaction
|
|
128
|
+
msg = MessageV0.try_compile(
|
|
129
|
+
payer=sender_key,
|
|
130
|
+
instructions=[ix0, ix1, ix2],
|
|
131
|
+
address_lookup_table_accounts=[],
|
|
132
|
+
recent_blockhash=blockhash,
|
|
133
|
+
)
|
|
134
|
+
tx = VersionedTransaction(msg, [self._keypair])
|
|
135
|
+
|
|
136
|
+
# Broadcast
|
|
137
|
+
tx_bytes = bytes(tx)
|
|
138
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
139
|
+
resp = await client.post(self._rpc_url, json={
|
|
140
|
+
"jsonrpc": "2.0", "id": 1,
|
|
141
|
+
"method": "sendTransaction",
|
|
142
|
+
"params": [
|
|
143
|
+
base64.b64encode(tx_bytes).decode(),
|
|
144
|
+
{"encoding": "base64", "skipPreflight": True},
|
|
145
|
+
],
|
|
146
|
+
})
|
|
147
|
+
result = resp.json()
|
|
148
|
+
if "error" in result:
|
|
149
|
+
raise RuntimeError(f"Transaction failed: {result['error']}")
|
|
150
|
+
tx_sig = result["result"]
|
|
151
|
+
|
|
152
|
+
# Poll for confirmation
|
|
153
|
+
confirmed = False
|
|
154
|
+
async with httpx.AsyncClient(timeout=60) as client:
|
|
155
|
+
for _ in range(30):
|
|
156
|
+
await asyncio.sleep(2)
|
|
157
|
+
resp = await client.post(self._rpc_url, json={
|
|
158
|
+
"jsonrpc": "2.0", "id": 1,
|
|
159
|
+
"method": "getSignatureStatuses",
|
|
160
|
+
"params": [[tx_sig]],
|
|
161
|
+
})
|
|
162
|
+
body = resp.json()
|
|
163
|
+
if "error" in body:
|
|
164
|
+
# #343: an RPC error (auth, rate limit, malformed reply) is not
|
|
165
|
+
# "not landed yet". Say so, with the signature, instead of
|
|
166
|
+
# burning the whole budget and blaming the wallet's balance.
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
f"Confirmation poll failed for {tx_sig}: {body['error']}. "
|
|
169
|
+
"The transaction was broadcast and may still land; check the "
|
|
170
|
+
"signature before paying again."
|
|
171
|
+
)
|
|
172
|
+
statuses = body.get("result", {}).get("value", [None])
|
|
173
|
+
st = statuses[0] if statuses else None
|
|
174
|
+
# #344: a transaction can be confirmed or finalized AND have failed
|
|
175
|
+
# (non-null err: missing ATA, insufficient balance, program error).
|
|
176
|
+
# err has to win, or a reverted transfer is reported as a payment.
|
|
177
|
+
if st and st.get("err"):
|
|
178
|
+
raise RuntimeError(f"Transaction {tx_sig} failed on chain: {st['err']}")
|
|
179
|
+
if st and st.get("confirmationStatus") in ("confirmed", "finalized"):
|
|
180
|
+
confirmed = True
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
if not confirmed:
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
f"Transaction {tx_sig} not confirmed after 60s. "
|
|
186
|
+
"Wallet may have insufficient SOL for gas or USDC for payment."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Build payment header
|
|
190
|
+
now = int(time.time())
|
|
191
|
+
payload = {
|
|
192
|
+
"chainId": SOLANA_CHAIN_ID,
|
|
193
|
+
"sender": str(sender_key),
|
|
194
|
+
"recipient": recipient,
|
|
195
|
+
"amount": str(amount_micro_usdc),
|
|
196
|
+
"nonce": tx_sig,
|
|
197
|
+
"validAfter": now - 10,
|
|
198
|
+
"validBefore": now + 300,
|
|
199
|
+
"signature": tx_sig,
|
|
200
|
+
"payload": {"txSignature": tx_sig},
|
|
201
|
+
}
|
|
202
|
+
return base64.b64encode(json.dumps(payload).encode()).decode()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_402_response(response_json: dict) -> dict:
|
|
206
|
+
"""Parse a 402 response to extract payment requirements.
|
|
207
|
+
|
|
208
|
+
Returns dict with keys: amount, recipient, chain_id, fee_payer (Solana only).
|
|
209
|
+
"""
|
|
210
|
+
accepts = response_json.get("accepts", [])
|
|
211
|
+
result = {}
|
|
212
|
+
for accept in accepts:
|
|
213
|
+
network = accept.get("network", "")
|
|
214
|
+
entry = {
|
|
215
|
+
"amount": int(accept.get("amount", 0)),
|
|
216
|
+
"recipient": accept.get("payTo", ""),
|
|
217
|
+
"chain_id": network,
|
|
218
|
+
"asset": accept.get("asset", ""),
|
|
219
|
+
}
|
|
220
|
+
# `or {}` not a default: an accepts entry may carry an explicit
|
|
221
|
+
# `"extra": null`, and `.get("extra", {})` returns None for that,
|
|
222
|
+
# so the caller would see AttributeError instead of a payment error.
|
|
223
|
+
fee_payer = (accept.get("extra") or {}).get("feePayer")
|
|
224
|
+
if fee_payer:
|
|
225
|
+
entry["fee_payer"] = fee_payer
|
|
226
|
+
|
|
227
|
+
if "solana" in network:
|
|
228
|
+
result["solana"] = entry
|
|
229
|
+
elif "eip155" in network:
|
|
230
|
+
result["base"] = entry
|
|
231
|
+
return result
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: limitguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the LimitGuard Trust Intelligence API
|
|
5
|
+
Project-URL: Homepage, https://limitguard.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.limitguard.ai
|
|
7
|
+
Project-URL: Issues, https://github.com/jwconsultancyteam/limitguard-mcp/issues
|
|
8
|
+
Author: LimitGuard
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: compliance,kyb,limitguard,risk,trust,usdc,x402
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx; extra == 'dev'
|
|
27
|
+
Provides-Extra: solana
|
|
28
|
+
Requires-Dist: solana>=0.34; extra == 'solana'
|
|
29
|
+
Requires-Dist: solders>=0.21; extra == 'solana'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# limitguard
|
|
33
|
+
|
|
34
|
+
Official Python SDK for the [LimitGuard](https://limitguard.ai) Trust Intelligence API: entity verification and risk scoring for EU businesses, paid per call in USDC over x402 or covered by a subscription.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install limitguard
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
With Solana wallet support, for agents that pay per call:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install 'limitguard[solana]'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Python 3.10 or newer.
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
from limitguard import LimitGuardClient
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def main():
|
|
58
|
+
async with LimitGuardClient(api_key="lg_live_...") as client:
|
|
59
|
+
result = await client.check_entity("Acme BV", country="NL")
|
|
60
|
+
print(result.trust_score, result.trust_level, result.recommendation)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`check_entity` returns a `TrustResponse` (`trust_score` 0-100, `trust_level`, `cluster`, `recommendation`, `confidence`, `top_factors`). `risk_score` is the cheaper two-source variant and returns a `RiskResponse`.
|
|
67
|
+
|
|
68
|
+
## Paying for calls
|
|
69
|
+
|
|
70
|
+
Two ways to satisfy the API's per-call price, and the client accepts either or both:
|
|
71
|
+
|
|
72
|
+
| You pass | What happens |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `api_key=` on a **paid** tier (`indie` and up) | The subscription covers usage. No per-call payment. |
|
|
75
|
+
| `wallet=` | The client pays each call in USDC over x402 automatically when the API answers 402. |
|
|
76
|
+
| both | Calls are paid by the wallet and attributed to the key. |
|
|
77
|
+
|
|
78
|
+
A `free`-tier key identifies you and tracks usage; it does not pay for calls. On its own it will raise `PaymentRequiredError` on any paid endpoint. Add a wallet, or upgrade the key.
|
|
79
|
+
|
|
80
|
+
Wallet example (Solana, needs the `[solana]` extra and a wallet holding SOL for gas and USDC for payments):
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from solders.keypair import Keypair
|
|
84
|
+
from limitguard import LimitGuardClient
|
|
85
|
+
from limitguard.x402 import SolanaWallet
|
|
86
|
+
|
|
87
|
+
wallet = SolanaWallet(keypair=Keypair.from_base58_string(private_key))
|
|
88
|
+
async with LimitGuardClient(wallet=wallet) as client:
|
|
89
|
+
result = await client.check_entity("Acme BV", country="NL")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A complete agent is in `examples/agent_with_wallet.py`, included in the source distribution (`pip download --no-binary :all: limitguard`).
|
|
93
|
+
|
|
94
|
+
## Errors
|
|
95
|
+
|
|
96
|
+
All exceptions subclass `LimitGuardError`: `AuthenticationError`, `PaymentRequiredError`, `RateLimitError`, `ValidationError`, `ServerError`. The client retries 429 and 5xx responses with backoff before raising.
|
|
97
|
+
|
|
98
|
+
## Links
|
|
99
|
+
|
|
100
|
+
- Documentation: https://docs.limitguard.ai
|
|
101
|
+
- Quickstart and a free sandbox key (mock data, no wallet): https://api.limitguard.ai/v1/quickstart
|
|
102
|
+
- x402 protocol: https://docs.limitguard.ai/x402-protocol
|
|
103
|
+
- Issues and support: https://github.com/jwconsultancyteam/limitguard-mcp/issues
|
|
104
|
+
|
|
105
|
+
## Version
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import limitguard
|
|
109
|
+
print(limitguard.__version__)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Releases are tagged `sdk-v<version>` and published to PyPI from that tag.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
limitguard/__init__.py,sha256=Ya6sWSRAcCodtSZNXFmDZZj-XLZ6h_56UFgwT-vRWNM,732
|
|
2
|
+
limitguard/client.py,sha256=R80aXQ8Fki5-_DFN26t7XiRsOxBuqQjBSpQvqm8boPk,7671
|
|
3
|
+
limitguard/exceptions.py,sha256=eHokz2TsGgOp6Yg3RNAB-6nDTQ1Skgih_0GJHyXbWCI,1549
|
|
4
|
+
limitguard/models.py,sha256=92wKryKmNKD6OJuqHgecrl2dE-K1rVqdmo2TLmO22JU,1525
|
|
5
|
+
limitguard/x402.py,sha256=UmkhPqo43v183KAvAUDbatIAAOtUAJ6IQ5JVDbsyhQ8,8504
|
|
6
|
+
limitguard-0.1.0.dist-info/METADATA,sha256=5JvxubakFp8mflnnJRday_BDY516j5I9kBjkqsaLAb8,3991
|
|
7
|
+
limitguard-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
limitguard-0.1.0.dist-info/licenses/LICENSE,sha256=YxjQ2QGysa2SgAXBO7C8G_ZNukmyLgNkYnogIL2betI,1082
|
|
9
|
+
limitguard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ambulatio Consulting B.V.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|