agentsync-sdk 1.0.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.
- agentsync/__init__.py +88 -0
- agentsync/attestation.py +89 -0
- agentsync/client.py +565 -0
- agentsync/errors.py +50 -0
- agentsync/rap.py +165 -0
- agentsync/types.py +328 -0
- agentsync_sdk-1.0.0.dist-info/METADATA +68 -0
- agentsync_sdk-1.0.0.dist-info/RECORD +10 -0
- agentsync_sdk-1.0.0.dist-info/WHEEL +5 -0
- agentsync_sdk-1.0.0.dist-info/top_level.txt +1 -0
agentsync/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentSync Python SDK.
|
|
3
|
+
|
|
4
|
+
Idiomatic Python client for the AgentSync x402 micropayment gateway
|
|
5
|
+
on the Radix ledger. Supports ROLA authentication, Mode A (credit
|
|
6
|
+
buffer), Mode B (single-pay), and Mode C (RAP/1 bundled non-custodial
|
|
7
|
+
on-chain settlement), plus catalog discovery and withdrawal attestation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .attestation import SignatureEnvelope, verify_platform_attestation
|
|
11
|
+
from .client import AgentSyncClient, CatalogModule, set_default_registry_component
|
|
12
|
+
from .errors import AuthenticationError, GatewayError, InsufficientCreditError
|
|
13
|
+
from .rap import generate_composite_manifest, generate_rap_manifest
|
|
14
|
+
from .types import (
|
|
15
|
+
AgentSyncClientOptions,
|
|
16
|
+
AgentSyncFetchOptions,
|
|
17
|
+
AuthChallengeResponse,
|
|
18
|
+
AuthVerifyResponse,
|
|
19
|
+
CatalogEntry,
|
|
20
|
+
CatalogResponse,
|
|
21
|
+
CompositeManifestConfig,
|
|
22
|
+
CompositePurchase,
|
|
23
|
+
InsufficientCreditPayload,
|
|
24
|
+
PremiumDataResponse,
|
|
25
|
+
RadixAccountAddress,
|
|
26
|
+
RapManifestConfig,
|
|
27
|
+
RapPublicKey,
|
|
28
|
+
RapSignatureValue,
|
|
29
|
+
RapHash,
|
|
30
|
+
RapRootManifest,
|
|
31
|
+
RapNotary,
|
|
32
|
+
RapDraftInputs,
|
|
33
|
+
RapEconomicParams,
|
|
34
|
+
RapSignatureFile,
|
|
35
|
+
RapPayload,
|
|
36
|
+
RolaSigner,
|
|
37
|
+
SessionDebitEntry,
|
|
38
|
+
SessionSummary,
|
|
39
|
+
SettleResponse,
|
|
40
|
+
WithdrawalPayload,
|
|
41
|
+
WithdrawResponse,
|
|
42
|
+
UsdcDecimal,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Client
|
|
47
|
+
"AgentSyncClient",
|
|
48
|
+
"CatalogModule",
|
|
49
|
+
"set_default_registry_component",
|
|
50
|
+
# Standalone functions
|
|
51
|
+
"generate_composite_manifest",
|
|
52
|
+
"generate_rap_manifest",
|
|
53
|
+
"verify_platform_attestation",
|
|
54
|
+
# Errors
|
|
55
|
+
"AuthenticationError",
|
|
56
|
+
"GatewayError",
|
|
57
|
+
"InsufficientCreditError",
|
|
58
|
+
# Types
|
|
59
|
+
"AgentSyncClientOptions",
|
|
60
|
+
"AgentSyncFetchOptions",
|
|
61
|
+
"AuthChallengeResponse",
|
|
62
|
+
"AuthVerifyResponse",
|
|
63
|
+
"CatalogEntry",
|
|
64
|
+
"CatalogResponse",
|
|
65
|
+
"CompositeManifestConfig",
|
|
66
|
+
"CompositePurchase",
|
|
67
|
+
"InsufficientCreditPayload",
|
|
68
|
+
"PremiumDataResponse",
|
|
69
|
+
"RadixAccountAddress",
|
|
70
|
+
"RapManifestConfig",
|
|
71
|
+
"RapPublicKey",
|
|
72
|
+
"RapSignatureValue",
|
|
73
|
+
"RapHash",
|
|
74
|
+
"RapRootManifest",
|
|
75
|
+
"RapNotary",
|
|
76
|
+
"RapDraftInputs",
|
|
77
|
+
"RapEconomicParams",
|
|
78
|
+
"RapSignatureFile",
|
|
79
|
+
"RapPayload",
|
|
80
|
+
"RolaSigner",
|
|
81
|
+
"SessionDebitEntry",
|
|
82
|
+
"SessionSummary",
|
|
83
|
+
"SettleResponse",
|
|
84
|
+
"SignatureEnvelope",
|
|
85
|
+
"WithdrawalPayload",
|
|
86
|
+
"WithdrawResponse",
|
|
87
|
+
"UsdcDecimal",
|
|
88
|
+
]
|
agentsync/attestation.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Platform attestation verification for AgentSync.
|
|
3
|
+
|
|
4
|
+
Verifies the Ed25519 signature over the SHA-256 pre-hash of an attestation
|
|
5
|
+
payload, mirroring the platform signing path in ``worker/src/attestation.ts``.
|
|
6
|
+
|
|
7
|
+
Signature scheme: SHA-256 pre-hash → Ed25519 verify (matches Scrypto's
|
|
8
|
+
``verify_ed25519`` convention where messages are hashed before verifying).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from nacl.exceptions import BadSignatureError
|
|
18
|
+
from nacl.signing import VerifyKey
|
|
19
|
+
|
|
20
|
+
SUPPORTED_ALGORITHM = "ed25519+sha256"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SignatureEnvelope:
|
|
25
|
+
"""Attestation envelope produced by the platform."""
|
|
26
|
+
|
|
27
|
+
payload: str
|
|
28
|
+
signature: str
|
|
29
|
+
public_key: str
|
|
30
|
+
algorithm: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def verify_platform_attestation(
|
|
34
|
+
envelope: SignatureEnvelope,
|
|
35
|
+
trusted_public_key: str,
|
|
36
|
+
) -> bool:
|
|
37
|
+
"""Verify a platform attestation envelope against the trusted platform key.
|
|
38
|
+
|
|
39
|
+
The function is pure and synchronous: the trusted public key is injected by
|
|
40
|
+
the caller, never fetched from the network. To prevent tautological spoofing
|
|
41
|
+
(an attacker signing a tampered payload with their own key), the envelope's
|
|
42
|
+
``public_key`` must match ``trusted_public_key`` before any signature check.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
envelope: Attestation envelope (payload, signature, public key,
|
|
46
|
+
algorithm) produced by the platform.
|
|
47
|
+
trusted_public_key: Hex-encoded 32-byte Ed25519 public key the caller
|
|
48
|
+
has independently verified as the platform's key.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
``True`` if the algorithm is supported, the embedded key matches the
|
|
52
|
+
trusted key, and the signature verifies over ``SHA-256(payload)``.
|
|
53
|
+
"""
|
|
54
|
+
if envelope.algorithm != SUPPORTED_ALGORITHM:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
if not _constant_time_hex_equals(envelope.public_key, trusted_public_key):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
public_key = _hex_to_bytes(trusted_public_key)
|
|
61
|
+
signature = _hex_to_bytes(envelope.signature)
|
|
62
|
+
if public_key is None or signature is None:
|
|
63
|
+
return False
|
|
64
|
+
if len(public_key) != 32 or len(signature) != 64:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# SHA-256 pre-hash — matches the platform's Ed25519(SHA-256(payload)) scheme.
|
|
68
|
+
message_hash = hashlib.sha256(envelope.payload.encode("utf-8")).digest()
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
VerifyKey(public_key).verify(message_hash, signature)
|
|
72
|
+
return True
|
|
73
|
+
except (BadSignatureError, ValueError):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hex_to_bytes(value: str) -> bytes | None:
|
|
78
|
+
"""Decode a hex string to bytes, returning ``None`` on malformed input."""
|
|
79
|
+
try:
|
|
80
|
+
return bytes.fromhex(value)
|
|
81
|
+
except ValueError:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _constant_time_hex_equals(a: str, b: str) -> bool:
|
|
86
|
+
"""Compare two hex strings in constant time."""
|
|
87
|
+
if len(a) != len(b):
|
|
88
|
+
return False
|
|
89
|
+
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
|
agentsync/client.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentSyncClient — primary entry point for the ``agentsync`` Python SDK.
|
|
3
|
+
|
|
4
|
+
Configuration:
|
|
5
|
+
- base_url: Gateway API URL (e.g. https://agentsync-api.rennv-agentsync.workers.dev)
|
|
6
|
+
- agent_address: The agent's Radix component/account address
|
|
7
|
+
- signer: Optional ROLA signing callback
|
|
8
|
+
|
|
9
|
+
Public surface:
|
|
10
|
+
- client.catalog.list() → list[CatalogEntry]
|
|
11
|
+
- client.catalog.get_llms_txt() → str
|
|
12
|
+
- client.catalog.by_service_id() → list[CatalogEntry]
|
|
13
|
+
- client.catalog.by_provider_address() → list[CatalogEntry]
|
|
14
|
+
- client.fetch(path, opts?) → httpx.Response (Mode A/B x402)
|
|
15
|
+
- client.generate_rap_manifest(agent, amount, config) → RTM string (Mode C)
|
|
16
|
+
- client.settle_session() → dict with manifest + attestation
|
|
17
|
+
- client.request_withdrawal(amt) → dict with manifest + attestation
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json as _json
|
|
23
|
+
import os
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from .errors import AuthenticationError, GatewayError, InsufficientCreditError
|
|
30
|
+
from .rap import generate_composite_manifest as _generate_composite_manifest_fn
|
|
31
|
+
from .rap import generate_rap_manifest as _generate_rap_manifest_fn
|
|
32
|
+
from .types import (
|
|
33
|
+
AgentSyncClientOptions,
|
|
34
|
+
AgentSyncFetchOptions,
|
|
35
|
+
AuthChallengeResponse,
|
|
36
|
+
AuthVerifyResponse,
|
|
37
|
+
CatalogEntry,
|
|
38
|
+
CatalogResponse,
|
|
39
|
+
CompositeManifestConfig,
|
|
40
|
+
CompositePurchase,
|
|
41
|
+
InsufficientCreditPayload,
|
|
42
|
+
RapManifestConfig,
|
|
43
|
+
SettleResponse,
|
|
44
|
+
WithdrawResponse,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ── Internal helpers ───────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Live Stokenet AgentRegistry component — override via the
|
|
51
|
+
# REGISTRY_COMPONENT env var or ``set_default_registry_component``.
|
|
52
|
+
_LIVE_STOKENET_REGISTRY_COMPONENT = (
|
|
53
|
+
"component_tdx_2_1cz75s5p3xjnk38puy34hdapqgyddg9v5tmg7juzzpwvw8rgww9ac0c"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_default_registry_component = os.environ.get(
|
|
57
|
+
"REGISTRY_COMPONENT", _LIVE_STOKENET_REGISTRY_COMPONENT
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_default_registry_component(address: str) -> None:
|
|
62
|
+
"""Override the default registry component address used in generated
|
|
63
|
+
Radix transaction manifests."""
|
|
64
|
+
global _default_registry_component
|
|
65
|
+
_default_registry_component = address
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Manifest formatters ────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_settle_manifest(
|
|
72
|
+
agent_address: str,
|
|
73
|
+
payload: str,
|
|
74
|
+
signature: str,
|
|
75
|
+
) -> str:
|
|
76
|
+
parts = payload.split(":")
|
|
77
|
+
amount = parts[1] if len(parts) >= 2 else "0.000000"
|
|
78
|
+
nonce = parts[2] if len(parts) >= 3 else "0"
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
"CALL_METHOD\n"
|
|
82
|
+
f' Address("{_default_registry_component}")\n'
|
|
83
|
+
' "settle_agent_session"\n'
|
|
84
|
+
f' Decimal("{amount}")\n'
|
|
85
|
+
f' Address("{agent_address}")\n'
|
|
86
|
+
f' {nonce}u64\n'
|
|
87
|
+
f' Bytes("{signature}");'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _format_withdraw_manifest(
|
|
92
|
+
agent_address: str,
|
|
93
|
+
payload: str,
|
|
94
|
+
signature: str,
|
|
95
|
+
) -> str:
|
|
96
|
+
parts = payload.split(":")
|
|
97
|
+
amount = parts[1] if len(parts) >= 2 else "0.000000"
|
|
98
|
+
nonce = parts[2] if len(parts) >= 3 else "0"
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
"CALL_METHOD\n"
|
|
102
|
+
f' Address("{_default_registry_component}")\n'
|
|
103
|
+
' "request_withdrawal"\n'
|
|
104
|
+
f' Decimal("{amount}")\n'
|
|
105
|
+
f' Address("{agent_address}")\n'
|
|
106
|
+
f' {nonce}u64\n'
|
|
107
|
+
f' Bytes("{signature}");'
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Catalog module ─────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CatalogModule:
|
|
115
|
+
"""Discovery module — browse available data products and fetch
|
|
116
|
+
AI-agent documentation from the gateway."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, client: "AgentSyncClient") -> None:
|
|
119
|
+
self._client = client
|
|
120
|
+
|
|
121
|
+
async def list(self) -> list[CatalogEntry]:
|
|
122
|
+
"""Fetch the full x402 product catalog (filtered ≥ 0.75 XRD floor).
|
|
123
|
+
|
|
124
|
+
No authentication required — public endpoint.
|
|
125
|
+
"""
|
|
126
|
+
resp = await self._client._raw_fetch(
|
|
127
|
+
f"{self._client._base_url}/v1/catalog",
|
|
128
|
+
headers={"accept": "application/json"},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not resp.is_success:
|
|
132
|
+
try:
|
|
133
|
+
body = resp.json()
|
|
134
|
+
except Exception:
|
|
135
|
+
body = {}
|
|
136
|
+
raise GatewayError(
|
|
137
|
+
f"Catalog fetch failed: {resp.status_code}",
|
|
138
|
+
resp.status_code,
|
|
139
|
+
body,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
body = resp.json()
|
|
143
|
+
services = body["data"]["services"]
|
|
144
|
+
return [
|
|
145
|
+
CatalogEntry(
|
|
146
|
+
service_id=s["service_id"],
|
|
147
|
+
provider_address=s["provider_address"],
|
|
148
|
+
provider_name=s["provider_name"],
|
|
149
|
+
name=s["name"],
|
|
150
|
+
description=s["description"],
|
|
151
|
+
base_tariff=s["base_tariff"],
|
|
152
|
+
currency=s.get("currency", "USDC"),
|
|
153
|
+
endpoint_paths=s.get("endpoint_paths", []),
|
|
154
|
+
swagger_url=s.get("swagger_url", ""),
|
|
155
|
+
)
|
|
156
|
+
for s in services
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
async def get_llms_txt(self) -> str:
|
|
160
|
+
"""Fetch the ``llms.txt`` markdown documentation.
|
|
161
|
+
|
|
162
|
+
No authentication required — public endpoint.
|
|
163
|
+
"""
|
|
164
|
+
resp = await self._client._raw_fetch(
|
|
165
|
+
f"{self._client._base_url}/.well-known/llms.txt",
|
|
166
|
+
headers={"accept": "text/markdown"},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not resp.is_success:
|
|
170
|
+
raise GatewayError(
|
|
171
|
+
f"llms.txt fetch failed: {resp.status_code}",
|
|
172
|
+
resp.status_code,
|
|
173
|
+
None,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return resp.text
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def by_service_id(
|
|
180
|
+
entries: list[CatalogEntry], service_id: str
|
|
181
|
+
) -> list[CatalogEntry]:
|
|
182
|
+
"""Filter catalog entries by ``service_id``."""
|
|
183
|
+
return [e for e in entries if e.service_id == service_id]
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def by_provider_address(
|
|
187
|
+
entries: list[CatalogEntry], provider_address: str
|
|
188
|
+
) -> list[CatalogEntry]:
|
|
189
|
+
"""Filter catalog entries by ``provider_address``."""
|
|
190
|
+
return [e for e in entries if e.provider_address == provider_address]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── Client ─────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AgentSyncClient:
|
|
197
|
+
"""Primary entry point for the AgentSync Python SDK.
|
|
198
|
+
|
|
199
|
+
Handles ROLA authentication, Mode A/B x402 fetch, Mode C RAP/1
|
|
200
|
+
bundle settlement, session settlement, and withdrawal attestation.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, options: AgentSyncClientOptions) -> None:
|
|
204
|
+
self._base_url = options.base_url.rstrip("/")
|
|
205
|
+
self._agent_address = options.agent_address
|
|
206
|
+
self._signer = options.signer
|
|
207
|
+
self._options = options
|
|
208
|
+
self._bearer_token: Optional[str] = None
|
|
209
|
+
self._token_expires_at: Optional[datetime] = None
|
|
210
|
+
self._http = httpx.AsyncClient()
|
|
211
|
+
self.catalog = CatalogModule(self)
|
|
212
|
+
|
|
213
|
+
# ── Package-private helpers for CatalogModule ──────────────────────
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def _raw_fetch(self):
|
|
217
|
+
"""Raw HTTP fetch for unauthenticated catalog endpoints."""
|
|
218
|
+
return self._http.get
|
|
219
|
+
|
|
220
|
+
# ── ROLA Authentication ───────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
async def authenticate(self) -> None:
|
|
223
|
+
"""Perform the full ROLA challenge-response flow.
|
|
224
|
+
|
|
225
|
+
Must be called before any authenticated endpoint unless auto-auth
|
|
226
|
+
was triggered by a prior call.
|
|
227
|
+
"""
|
|
228
|
+
if self._signer is None:
|
|
229
|
+
raise AuthenticationError(
|
|
230
|
+
"No signer provided — cannot authenticate. "
|
|
231
|
+
"Pass a `signer` callback in AgentSyncClientOptions.",
|
|
232
|
+
401,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# 1. Request challenge
|
|
236
|
+
chal_resp = await self._http.post(
|
|
237
|
+
f"{self._base_url}/v1/auth/challenge",
|
|
238
|
+
headers={"content-type": "application/json"},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if not chal_resp.is_success:
|
|
242
|
+
raise AuthenticationError(
|
|
243
|
+
f"Challenge request failed: {chal_resp.status_code}",
|
|
244
|
+
chal_resp.status_code,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
challenge = chal_resp.json()
|
|
248
|
+
|
|
249
|
+
# 2. Sign challenge via user-provided callback
|
|
250
|
+
signed = await self._signer(challenge["data"]["challenge"])
|
|
251
|
+
|
|
252
|
+
# 3. Verify and get token
|
|
253
|
+
verify_resp = await self._http.post(
|
|
254
|
+
f"{self._base_url}/v1/auth/verify",
|
|
255
|
+
headers={"content-type": "application/json"},
|
|
256
|
+
json={
|
|
257
|
+
"challenge": challenge["data"]["challenge"],
|
|
258
|
+
"signature": signed["signature"],
|
|
259
|
+
"identity_address": self._agent_address,
|
|
260
|
+
"public_key": signed["public_key"],
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not verify_resp.is_success:
|
|
265
|
+
raise AuthenticationError(
|
|
266
|
+
f"Verification failed: {verify_resp.status_code}",
|
|
267
|
+
verify_resp.status_code,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
verified = verify_resp.json()
|
|
271
|
+
self._bearer_token = verified["data"]["auth_token"]
|
|
272
|
+
self._token_expires_at = datetime.fromisoformat(
|
|
273
|
+
verified["data"]["expires_at"]
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
async def _ensure_auth(self) -> str:
|
|
277
|
+
"""Return the current bearer token, authenticating if necessary."""
|
|
278
|
+
if (
|
|
279
|
+
self._bearer_token is not None
|
|
280
|
+
and self._token_expires_at is not None
|
|
281
|
+
and self._token_expires_at > datetime.now(timezone.utc)
|
|
282
|
+
):
|
|
283
|
+
return self._bearer_token
|
|
284
|
+
await self.authenticate()
|
|
285
|
+
return self._bearer_token # type: ignore[return-value]
|
|
286
|
+
|
|
287
|
+
# ── Generic authenticated request ─────────────────────────────────
|
|
288
|
+
|
|
289
|
+
async def _request(self, path: str, init: dict[str, Any] | None = None) -> Any:
|
|
290
|
+
"""Low-level authenticated request with standard error handling."""
|
|
291
|
+
if init is None:
|
|
292
|
+
init = {}
|
|
293
|
+
token = await self._ensure_auth()
|
|
294
|
+
|
|
295
|
+
headers = {**init.get("headers", {})}
|
|
296
|
+
headers.setdefault("content-type", "application/json")
|
|
297
|
+
headers["authorization"] = f"Bearer {token}"
|
|
298
|
+
|
|
299
|
+
method = init.get("method", "GET")
|
|
300
|
+
body = init.get("body") or init.get("json")
|
|
301
|
+
|
|
302
|
+
if isinstance(body, dict):
|
|
303
|
+
resp = await self._http.request(
|
|
304
|
+
method,
|
|
305
|
+
f"{self._base_url}{path}",
|
|
306
|
+
headers=headers,
|
|
307
|
+
json=body,
|
|
308
|
+
)
|
|
309
|
+
elif isinstance(body, (str, bytes)):
|
|
310
|
+
resp = await self._http.request(
|
|
311
|
+
method,
|
|
312
|
+
f"{self._base_url}{path}",
|
|
313
|
+
headers=headers,
|
|
314
|
+
content=body,
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
resp = await self._http.request(
|
|
318
|
+
method,
|
|
319
|
+
f"{self._base_url}{path}",
|
|
320
|
+
headers=headers,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if not resp.is_success:
|
|
324
|
+
try:
|
|
325
|
+
resp_body = resp.json()
|
|
326
|
+
except Exception:
|
|
327
|
+
resp_body = {}
|
|
328
|
+
|
|
329
|
+
if resp.status_code == 401:
|
|
330
|
+
self._bearer_token = None
|
|
331
|
+
self._token_expires_at = None
|
|
332
|
+
msg = (
|
|
333
|
+
resp_body.get("error", {}).get("message")
|
|
334
|
+
if isinstance(resp_body, dict)
|
|
335
|
+
else "Authentication failed"
|
|
336
|
+
)
|
|
337
|
+
raise AuthenticationError(msg, resp.status_code)
|
|
338
|
+
|
|
339
|
+
if resp.status_code == 402:
|
|
340
|
+
details = (
|
|
341
|
+
resp_body.get("error", {}).get("details")
|
|
342
|
+
if isinstance(resp_body, dict)
|
|
343
|
+
else None
|
|
344
|
+
)
|
|
345
|
+
raise InsufficientCreditError(
|
|
346
|
+
details.get("required", "0") if details else "0",
|
|
347
|
+
details.get("available", "0") if details else "0",
|
|
348
|
+
resp_body,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
raise GatewayError(
|
|
352
|
+
(
|
|
353
|
+
resp_body.get("error", {}).get("message")
|
|
354
|
+
if isinstance(resp_body, dict)
|
|
355
|
+
else f"HTTP {resp.status_code}"
|
|
356
|
+
),
|
|
357
|
+
resp.status_code,
|
|
358
|
+
resp_body,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return resp.json()
|
|
362
|
+
|
|
363
|
+
# ── Augmented fetch (x402) ─────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
async def fetch(
|
|
366
|
+
self,
|
|
367
|
+
path: str,
|
|
368
|
+
opts: AgentSyncFetchOptions | None = None,
|
|
369
|
+
) -> httpx.Response:
|
|
370
|
+
"""Augmented fetch for premium data endpoints.
|
|
371
|
+
|
|
372
|
+
**Mode A (Credit Buffer)**: Default. Automatically injects ROLA
|
|
373
|
+
auth headers. Throws ``InsufficientCreditError`` on 402.
|
|
374
|
+
|
|
375
|
+
**Mode B (Single-Pay)**: Pass ``rap_payload`` (and optional
|
|
376
|
+
``requirements``) in opts to send the body
|
|
377
|
+
``{ payment: { rap_payload, requirements } }`` — the structure
|
|
378
|
+
expected by ``handleModeBSponsored``.
|
|
379
|
+
"""
|
|
380
|
+
if opts is None:
|
|
381
|
+
opts = AgentSyncFetchOptions()
|
|
382
|
+
|
|
383
|
+
token = await self._ensure_auth()
|
|
384
|
+
|
|
385
|
+
headers: dict[str, str] = {**(opts.headers or {})}
|
|
386
|
+
headers["authorization"] = f"Bearer {token}"
|
|
387
|
+
|
|
388
|
+
method = opts.method or "GET"
|
|
389
|
+
body = opts.body
|
|
390
|
+
|
|
391
|
+
if opts.rap_payload is not None:
|
|
392
|
+
method = "POST"
|
|
393
|
+
headers.setdefault("content-type", "application/json")
|
|
394
|
+
body = _json.dumps(
|
|
395
|
+
{
|
|
396
|
+
"payment": {
|
|
397
|
+
"rap_payload": opts.rap_payload.to_dict(),
|
|
398
|
+
"requirements": opts.requirements or {},
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if body is not None:
|
|
404
|
+
resp = await self._http.request(
|
|
405
|
+
method,
|
|
406
|
+
f"{self._base_url}{path}",
|
|
407
|
+
headers=headers,
|
|
408
|
+
content=body if isinstance(body, bytes) else body,
|
|
409
|
+
)
|
|
410
|
+
else:
|
|
411
|
+
resp = await self._http.request(
|
|
412
|
+
method,
|
|
413
|
+
f"{self._base_url}{path}",
|
|
414
|
+
headers=headers,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if not resp.is_success:
|
|
418
|
+
if resp.status_code == 401:
|
|
419
|
+
self._bearer_token = None
|
|
420
|
+
self._token_expires_at = None
|
|
421
|
+
try:
|
|
422
|
+
body_json = resp.json()
|
|
423
|
+
except Exception:
|
|
424
|
+
body_json = {}
|
|
425
|
+
msg = (
|
|
426
|
+
body_json.get("error", {}).get("message")
|
|
427
|
+
if isinstance(body_json, dict)
|
|
428
|
+
else "Authentication failed"
|
|
429
|
+
)
|
|
430
|
+
raise AuthenticationError(msg, resp.status_code)
|
|
431
|
+
|
|
432
|
+
if resp.status_code == 402:
|
|
433
|
+
try:
|
|
434
|
+
body_json = resp.json()
|
|
435
|
+
except Exception:
|
|
436
|
+
body_json = {}
|
|
437
|
+
details = (
|
|
438
|
+
body_json.get("error", {}).get("details")
|
|
439
|
+
if isinstance(body_json, dict)
|
|
440
|
+
else None
|
|
441
|
+
)
|
|
442
|
+
raise InsufficientCreditError(
|
|
443
|
+
details.get("required", "0") if details else "0",
|
|
444
|
+
details.get("available", "0") if details else "0",
|
|
445
|
+
body_json,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return resp
|
|
449
|
+
|
|
450
|
+
# ── Mode C: Path A (Non-Custodial) Manifest ────────────────────────
|
|
451
|
+
|
|
452
|
+
def generate_rap_manifest(
|
|
453
|
+
self,
|
|
454
|
+
agent: str,
|
|
455
|
+
amount: str,
|
|
456
|
+
config: RapManifestConfig,
|
|
457
|
+
) -> str:
|
|
458
|
+
"""Generate the Radix transaction manifest (RTM) for Mode C
|
|
459
|
+
bundled settlement — **Path A (non-custodial)**.
|
|
460
|
+
|
|
461
|
+
Produces a manifest that withdraws USDC from the agent's on-chain
|
|
462
|
+
Radix account and routes it through ``process_api_payment`` on
|
|
463
|
+
the registry. Platform gas is sponsored via ``lock_fee``.
|
|
464
|
+
|
|
465
|
+
**Synchronous** — no Ed25519 attestation needed. The agent's
|
|
466
|
+
Radix account signature on the transaction proves authorization.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
agent: The agent's Radix account address (the withdrawing account).
|
|
470
|
+
amount: USDC amount to settle as a 6-decimal string.
|
|
471
|
+
config: Economic parameters (platform account, USDC resource,
|
|
472
|
+
registry component, XRD fee loan amount).
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
Raw Radix Transaction Manifest (RTM) string.
|
|
476
|
+
"""
|
|
477
|
+
return _generate_rap_manifest_fn(agent, amount, config)
|
|
478
|
+
|
|
479
|
+
# ── Composite Manifest (Mode B Multi-Endpoint) ─────────────────────
|
|
480
|
+
|
|
481
|
+
def generate_composite_manifest(
|
|
482
|
+
self,
|
|
483
|
+
config: CompositeManifestConfig,
|
|
484
|
+
purchases: list[CompositePurchase],
|
|
485
|
+
) -> str:
|
|
486
|
+
"""Generate a composite Radix transaction manifest (RTM) that
|
|
487
|
+
atomically settles multiple API endpoint purchases in a single
|
|
488
|
+
on-chain transaction.
|
|
489
|
+
|
|
490
|
+
Extends Mode B (Single-Pay) to bundle N endpoint purchases into
|
|
491
|
+
one atomic manifest: a single aggregate ``withdraw`` followed by
|
|
492
|
+
per-endpoint ``TAKE_FROM_WORKTOP`` + ``CALL_METHOD
|
|
493
|
+
process_api_payment`` pairs.
|
|
494
|
+
|
|
495
|
+
**Synchronous** — no network call. The agent submits the resulting
|
|
496
|
+
RTM to the Radix network via their wallet.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
config: Economic parameters (agent, platform account, USDC
|
|
500
|
+
resource, registry component, XRD fee loan amount).
|
|
501
|
+
purchases: List of endpoint purchases (endpoint_id + amount).
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Raw Radix Transaction Manifest (RTM) string.
|
|
505
|
+
"""
|
|
506
|
+
return _generate_composite_manifest_fn(config, purchases)
|
|
507
|
+
|
|
508
|
+
# ── Settlement ────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
async def settle_session(self) -> dict[str, Any]:
|
|
511
|
+
"""Initiate session settlement.
|
|
512
|
+
|
|
513
|
+
Calls ``POST /v1/settle`` and returns payload, signature,
|
|
514
|
+
session summary, and a ready-to-submit RTM string.
|
|
515
|
+
"""
|
|
516
|
+
data = await self._request("/v1/settle", {"method": "POST"})
|
|
517
|
+
|
|
518
|
+
manifest = _format_settle_manifest(
|
|
519
|
+
self._agent_address,
|
|
520
|
+
data["data"]["payload"],
|
|
521
|
+
data["data"]["signature"],
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
"payload": data["data"]["payload"],
|
|
526
|
+
"signature": data["data"]["signature"],
|
|
527
|
+
"session_summary": data["data"]["session_summary"],
|
|
528
|
+
"manifest": manifest,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
# ── Withdrawal ────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
async def request_withdrawal(
|
|
534
|
+
self, amount_usdc: str
|
|
535
|
+
) -> dict[str, Any]:
|
|
536
|
+
"""Request an attested USDC withdrawal from the registry.
|
|
537
|
+
|
|
538
|
+
Calls ``POST /v1/withdraw`` and returns payload, signature,
|
|
539
|
+
withdrawal details, and a ready-to-submit RTM string.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
amount_usdc: USDC amount as a 6-decimal string
|
|
543
|
+
(e.g. ``"10.000000"``).
|
|
544
|
+
"""
|
|
545
|
+
data = await self._request(
|
|
546
|
+
"/v1/withdraw",
|
|
547
|
+
{"method": "POST", "json": {"amount_usdc": amount_usdc}},
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
manifest = _format_withdraw_manifest(
|
|
551
|
+
self._agent_address,
|
|
552
|
+
data["data"]["payload"],
|
|
553
|
+
data["data"]["signature"],
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
"payload": data["data"]["payload"],
|
|
558
|
+
"signature": data["data"]["signature"],
|
|
559
|
+
"withdrawal": data["data"]["withdrawal"],
|
|
560
|
+
"manifest": manifest,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async def close(self) -> None:
|
|
564
|
+
"""Release the underlying HTTP client resources."""
|
|
565
|
+
await self._http.aclose()
|
agentsync/errors.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error types for the AgentSync SDK.
|
|
3
|
+
|
|
4
|
+
Mirrors the TypeScript error hierarchy in `sdk-typescript/src/errors.ts`.
|
|
5
|
+
All exception classes carry structured fields so calling code can
|
|
6
|
+
programmatically inspect failures without string parsing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InsufficientCreditError(Exception):
|
|
15
|
+
"""Thrown when the gateway returns ``402 Payment Required`` — the
|
|
16
|
+
agent's credit buffer is too low for the requested API call."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
required: str,
|
|
21
|
+
available: str,
|
|
22
|
+
response_body: Any = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.required = required
|
|
25
|
+
self.available = available
|
|
26
|
+
self.response_body = response_body
|
|
27
|
+
super().__init__(
|
|
28
|
+
f"Insufficient credit: required {required} USDC, "
|
|
29
|
+
f"available {available} USDC. "
|
|
30
|
+
"Top up your credit buffer via the on-chain registry's "
|
|
31
|
+
"top_up_usdc_vault method."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationError(Exception):
|
|
36
|
+
"""Thrown when authentication fails (bad credentials, expired token,
|
|
37
|
+
etc.)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, status: int) -> None:
|
|
40
|
+
self.status = status
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GatewayError(Exception):
|
|
45
|
+
"""Thrown when the gateway returns an unexpected error response."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, status: int, body: Any = None) -> None:
|
|
48
|
+
self.status = status
|
|
49
|
+
self.body = body
|
|
50
|
+
super().__init__(message)
|
agentsync/rap.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RAP/1 Module — Mode C Path A Settlement (Python SDK).
|
|
3
|
+
|
|
4
|
+
Builds the Path A non-custodial Radix transaction manifest (RTM) for
|
|
5
|
+
on-chain bundled settlement of API consumption sessions.
|
|
6
|
+
|
|
7
|
+
Path A (non-custodial): USDC is withdrawn directly from the agent's
|
|
8
|
+
on-chain Radix account at settlement time via ``process_api_payment``.
|
|
9
|
+
Platform gas is sponsored via ``lock_fee``. No Ed25519 attestation
|
|
10
|
+
signing is needed in the SDK — the agent's Radix account signature
|
|
11
|
+
on the transaction provides proof of authorization.
|
|
12
|
+
|
|
13
|
+
Lifecycle:
|
|
14
|
+
1. ``generate_rap_manifest`` → build Path A RTM string (synchronous)
|
|
15
|
+
2. Developer submits RTM to Radix network
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Sequence
|
|
19
|
+
|
|
20
|
+
from .types import (
|
|
21
|
+
CompositeManifestConfig,
|
|
22
|
+
CompositePurchase,
|
|
23
|
+
RapManifestConfig,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_rap_manifest(
|
|
28
|
+
agent: str,
|
|
29
|
+
amount: str,
|
|
30
|
+
config: RapManifestConfig,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Generate the Radix transaction manifest (RTM) for Mode C bundled
|
|
33
|
+
settlement — **Path A (non-custodial)**.
|
|
34
|
+
|
|
35
|
+
The manifest follows the non-custodial Path A flow:
|
|
36
|
+
|
|
37
|
+
1. ``lock_fee`` — platform sponsors XRD network fee
|
|
38
|
+
2. ``withdraw`` — pull USDC from agent's on-chain Radix account
|
|
39
|
+
3. ``TAKE_ALL_FROM_WORKTOP`` — take the withdrawn USDC into a bucket
|
|
40
|
+
4. ``CALL_METHOD process_api_payment`` — split payment via registry
|
|
41
|
+
(99.5% provider, 0.25% gas vault, 0.25% developer treasury)
|
|
42
|
+
|
|
43
|
+
**Synchronous** — no Ed25519 attestation required. The agent's Radix
|
|
44
|
+
account signature on the transaction proves authorization to withdraw.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
agent: The agent's Radix account address (the withdrawing account).
|
|
48
|
+
amount: USDC amount to settle as a 6-decimal string.
|
|
49
|
+
config: Economic parameters (platform account, USDC resource,
|
|
50
|
+
registry component, XRD fee loan amount).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Raw Radix Transaction Manifest (RTM) string.
|
|
54
|
+
"""
|
|
55
|
+
return "\n".join(
|
|
56
|
+
[
|
|
57
|
+
"CALL_METHOD",
|
|
58
|
+
f' Address("{config.platform_account}")',
|
|
59
|
+
' "lock_fee"',
|
|
60
|
+
f' Decimal("{config.fee_loan_amount}");',
|
|
61
|
+
"CALL_METHOD",
|
|
62
|
+
f' Address("{agent}")',
|
|
63
|
+
' "withdraw"',
|
|
64
|
+
f' Address("{config.usdc_resource}")',
|
|
65
|
+
f' Decimal("{amount}");',
|
|
66
|
+
"TAKE_ALL_FROM_WORKTOP",
|
|
67
|
+
f' Address("{config.usdc_resource}")',
|
|
68
|
+
' Bucket("payment");',
|
|
69
|
+
"CALL_METHOD",
|
|
70
|
+
f' Address("{config.registry_component}")',
|
|
71
|
+
' "process_api_payment"',
|
|
72
|
+
' Bucket("payment");',
|
|
73
|
+
"CALL_METHOD",
|
|
74
|
+
f' Address("{config.merchant_account}")',
|
|
75
|
+
' "deposit_batch"',
|
|
76
|
+
' Expression("ENTIRE_WORKTOP");',
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Composite Manifest Builder ──────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _sum_usdc_amounts(amounts: Sequence[str]) -> str:
|
|
85
|
+
"""Sum USDC decimal strings via integer micro-unit arithmetic."""
|
|
86
|
+
total = 0
|
|
87
|
+
for amt in amounts:
|
|
88
|
+
total += round(float(amt) * 1_000_000)
|
|
89
|
+
return f"{total / 1_000_000:.6f}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def generate_composite_manifest(
|
|
93
|
+
config: CompositeManifestConfig,
|
|
94
|
+
purchases: Sequence[CompositePurchase],
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Generate a composite Radix transaction manifest (RTM) that atomically
|
|
97
|
+
settles multiple API endpoint purchases in a single on-chain transaction.
|
|
98
|
+
|
|
99
|
+
The manifest follows the non-custodial Path A flow extended for N endpoints:
|
|
100
|
+
|
|
101
|
+
1. ``lock_fee`` — platform sponsors XRD network fee
|
|
102
|
+
2. ``withdraw`` — single aggregate USDC withdrawal from agent's account
|
|
103
|
+
3. For each purchase:
|
|
104
|
+
a. ``TAKE_FROM_WORKTOP`` — partition exact USDC into ``payment_{i}`` bucket
|
|
105
|
+
b. ``CALL_METHOD process_api_payment`` — settle that endpoint via registry
|
|
106
|
+
|
|
107
|
+
All ``process_api_payment`` calls execute within the same atomic
|
|
108
|
+
transaction, so the entire batch either fully succeeds or fully reverts.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
config: Economic parameters (agent, platform account, USDC resource,
|
|
112
|
+
registry component, XRD fee loan amount).
|
|
113
|
+
purchases: Ordered sequence of endpoint purchases (endpoint_id + amount).
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Raw Radix Transaction Manifest (RTM) string.
|
|
117
|
+
"""
|
|
118
|
+
if not purchases:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"generate_composite_manifest: purchases must not be empty"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
total = _sum_usdc_amounts([p.amount for p in purchases])
|
|
124
|
+
|
|
125
|
+
lines: list[str] = [
|
|
126
|
+
# 1. Platform sponsors network fee
|
|
127
|
+
"CALL_METHOD",
|
|
128
|
+
f' Address("{config.platform_account}")',
|
|
129
|
+
' "lock_fee"',
|
|
130
|
+
f' Decimal("{config.fee_loan_amount}");',
|
|
131
|
+
# 2. Single aggregate withdraw from agent account
|
|
132
|
+
"CALL_METHOD",
|
|
133
|
+
f' Address("{config.agent}")',
|
|
134
|
+
' "withdraw"',
|
|
135
|
+
f' Address("{config.usdc_resource}")',
|
|
136
|
+
f' Decimal("{total}");',
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
# 3. Per-endpoint: partition worktop → process_api_payment
|
|
140
|
+
for i, p in enumerate(purchases):
|
|
141
|
+
bucket = f"payment_{i}"
|
|
142
|
+
lines.extend(
|
|
143
|
+
[
|
|
144
|
+
"TAKE_FROM_WORKTOP",
|
|
145
|
+
f' Address("{config.usdc_resource}")',
|
|
146
|
+
f' Decimal("{p.amount}")',
|
|
147
|
+
f' Bucket("{bucket}");',
|
|
148
|
+
"CALL_METHOD",
|
|
149
|
+
f' Address("{config.registry_component}")',
|
|
150
|
+
' "process_api_payment"',
|
|
151
|
+
f' Bucket("{bucket}");',
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# 4. Deposit any remaining worktop balance to the merchant account
|
|
156
|
+
lines.extend(
|
|
157
|
+
[
|
|
158
|
+
"CALL_METHOD",
|
|
159
|
+
f' Address("{config.merchant_account}")',
|
|
160
|
+
' "deposit_batch"',
|
|
161
|
+
' Expression("ENTIRE_WORKTOP");',
|
|
162
|
+
]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return "\n".join(lines)
|
agentsync/types.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentSync Python SDK — type definitions.
|
|
3
|
+
|
|
4
|
+
Mirrors the gateway API response shapes from the TypeScript SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Coroutine, Optional, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── ROLA Auth ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AuthChallengeResponse:
|
|
15
|
+
ok: bool
|
|
16
|
+
data: dict[str, str] # challenge, nonce, expires_at
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AuthVerifyResponse:
|
|
21
|
+
ok: bool
|
|
22
|
+
data: dict[str, str] # auth_token, expires_at
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Catalog ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
# Radix account component address (e.g. `account_tdx_2_...`)
|
|
28
|
+
RadixAccountAddress = str
|
|
29
|
+
|
|
30
|
+
# USDC decimal string (e.g. `"0.75"`, `"2.500"`)
|
|
31
|
+
UsdcDecimal = str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class CatalogEntry:
|
|
36
|
+
service_id: str
|
|
37
|
+
provider_address: RadixAccountAddress
|
|
38
|
+
provider_name: str
|
|
39
|
+
name: str
|
|
40
|
+
description: str
|
|
41
|
+
base_tariff: UsdcDecimal
|
|
42
|
+
currency: str = "USDC"
|
|
43
|
+
endpoint_paths: list[str] = field(default_factory=list)
|
|
44
|
+
swagger_url: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CatalogResponse:
|
|
49
|
+
ok: bool
|
|
50
|
+
data: dict[str, Any] # services, total, platform_floor_usdc, currency
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Premium Data Proxy ─────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PremiumDataResponse:
|
|
57
|
+
ok: bool
|
|
58
|
+
data: dict[str, Any] # proxied, mode, remaining_balance?, txid?
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class InsufficientCreditPayload:
|
|
63
|
+
ok: bool
|
|
64
|
+
error: dict[str, Any] # code, message, details (required, available)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── RAP/1: Mode C (Path A / Non-Custodial) ─────────────────────────────
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class RapManifestConfig:
|
|
71
|
+
"""Economic configuration for Path A (non-custodial) manifest generation."""
|
|
72
|
+
platform_account: str
|
|
73
|
+
usdc_resource: str
|
|
74
|
+
registry_component: str
|
|
75
|
+
fee_loan_amount: str
|
|
76
|
+
merchant_account: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Composite Manifests (Mode B Multi-Endpoint) ──────────────────────────
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class CompositePurchase:
|
|
83
|
+
"""A single purchase within a composite (multi-endpoint) manifest."""
|
|
84
|
+
endpoint_id: str
|
|
85
|
+
amount: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class CompositeManifestConfig:
|
|
90
|
+
"""Configuration for generating a composite (multi-endpoint) manifest."""
|
|
91
|
+
agent: str
|
|
92
|
+
platform_account: str
|
|
93
|
+
usdc_resource: str
|
|
94
|
+
registry_component: str
|
|
95
|
+
fee_loan_amount: str
|
|
96
|
+
merchant_account: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Settlement ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class SessionDebitEntry:
|
|
103
|
+
timestamp: str
|
|
104
|
+
cost_usdc: str
|
|
105
|
+
provider_address: str
|
|
106
|
+
service_id: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class SessionSummary:
|
|
111
|
+
total_debits: int
|
|
112
|
+
total_spent: str
|
|
113
|
+
by_provider: dict[str, str]
|
|
114
|
+
by_service: dict[str, str]
|
|
115
|
+
entries: list[SessionDebitEntry]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class SettleResponse:
|
|
120
|
+
ok: bool
|
|
121
|
+
data: dict[str, Any] # payload, signature, session_summary
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Withdrawal ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class WithdrawalPayload:
|
|
128
|
+
agent: str
|
|
129
|
+
amount: str
|
|
130
|
+
nonce: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class WithdrawResponse:
|
|
135
|
+
ok: bool
|
|
136
|
+
data: dict[str, Any] # payload, signature, withdrawal
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Auth Signer ────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
@runtime_checkable
|
|
142
|
+
class RolaSigner(Protocol):
|
|
143
|
+
"""Callback protocol for ROLA challenge signing."""
|
|
144
|
+
def __call__(self, challenge: str) -> Coroutine[Any, Any, dict[str, str]]: ...
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Client Options ─────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class AgentSyncClientOptions:
|
|
151
|
+
"""Options passed to AgentSyncClient.
|
|
152
|
+
|
|
153
|
+
base_url: Gateway API base URL (e.g. ``https://agentsync-api.rennv-agentsync.workers.dev``).
|
|
154
|
+
"""
|
|
155
|
+
base_url: str
|
|
156
|
+
agent_address: RadixAccountAddress
|
|
157
|
+
signer: Optional[RolaSigner] = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Mode B (x402 Sponsored) rap_payload wire shapes ─────────────────────
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class RapPublicKey:
|
|
164
|
+
"""Wire shape of a public key (serde camelCase)."""
|
|
165
|
+
curve: str
|
|
166
|
+
hex: str
|
|
167
|
+
|
|
168
|
+
def to_dict(self) -> dict[str, Any]:
|
|
169
|
+
return {"curve": self.curve, "hex": self.hex}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class RapSignatureValue:
|
|
174
|
+
"""Wire shape of a signature value (serde camelCase)."""
|
|
175
|
+
curve: str
|
|
176
|
+
hex: str
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict[str, Any]:
|
|
179
|
+
return {"curve": self.curve, "hex": self.hex}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class RapHash:
|
|
184
|
+
"""Wire shape of a hash (serde camelCase)."""
|
|
185
|
+
id: str
|
|
186
|
+
hex: str
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict[str, Any]:
|
|
189
|
+
return {"id": self.id, "hex": self.hex}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class RapRootManifest:
|
|
194
|
+
"""Wire shape of the draft inputs root manifest (serde camelCase)."""
|
|
195
|
+
kind: str
|
|
196
|
+
rtm: str
|
|
197
|
+
source_name: Optional[str] = None
|
|
198
|
+
|
|
199
|
+
def to_dict(self) -> dict[str, Any]:
|
|
200
|
+
result: dict[str, Any] = {"kind": self.kind, "rtm": self.rtm}
|
|
201
|
+
if self.source_name is not None:
|
|
202
|
+
result["sourceName"] = self.source_name
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class RapNotary:
|
|
208
|
+
"""Wire shape of the draft inputs notary (serde camelCase)."""
|
|
209
|
+
type: str
|
|
210
|
+
version: int
|
|
211
|
+
public_key: RapPublicKey
|
|
212
|
+
notary_is_signatory: Optional[bool] = None
|
|
213
|
+
|
|
214
|
+
def to_dict(self) -> dict[str, Any]:
|
|
215
|
+
result: dict[str, Any] = {
|
|
216
|
+
"type": self.type,
|
|
217
|
+
"version": self.version,
|
|
218
|
+
"publicKey": self.public_key.to_dict(),
|
|
219
|
+
}
|
|
220
|
+
if self.notary_is_signatory is not None:
|
|
221
|
+
result["notaryIsSignatory"] = self.notary_is_signatory
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class RapDraftInputs:
|
|
227
|
+
"""Wire shape of ``DraftInputsState`` (serde camelCase)."""
|
|
228
|
+
state: str
|
|
229
|
+
network: str
|
|
230
|
+
root_manifest: RapRootManifest
|
|
231
|
+
notary: RapNotary
|
|
232
|
+
agent_public_key: str
|
|
233
|
+
requested_tariff: str
|
|
234
|
+
subintents: Optional[Any] = None
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> dict[str, Any]:
|
|
237
|
+
result: dict[str, Any] = {
|
|
238
|
+
"state": self.state,
|
|
239
|
+
"network": self.network,
|
|
240
|
+
"rootManifest": self.root_manifest.to_dict(),
|
|
241
|
+
"notary": self.notary.to_dict(),
|
|
242
|
+
"agentPublicKey": self.agent_public_key,
|
|
243
|
+
"requestedTariff": self.requested_tariff,
|
|
244
|
+
}
|
|
245
|
+
if self.subintents is not None:
|
|
246
|
+
result["subintents"] = self.subintents
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class RapEconomicParams:
|
|
252
|
+
"""Wire shape of ``LedgerEconomicParams`` (serde snake_case)."""
|
|
253
|
+
fee_loan_amount: str
|
|
254
|
+
registry_component: str
|
|
255
|
+
lock_fee_payer_account: str
|
|
256
|
+
usdc_resource: str
|
|
257
|
+
xrd_gas_vault: str
|
|
258
|
+
merchant_account: str
|
|
259
|
+
start_epoch_inclusive: Optional[int] = None
|
|
260
|
+
end_epoch_exclusive: Optional[int] = None
|
|
261
|
+
intent_discriminator: Optional[int] = None
|
|
262
|
+
|
|
263
|
+
def to_dict(self) -> dict[str, Any]:
|
|
264
|
+
result: dict[str, Any] = {
|
|
265
|
+
"fee_loan_amount": self.fee_loan_amount,
|
|
266
|
+
"registry_component": self.registry_component,
|
|
267
|
+
"lock_fee_payer_account": self.lock_fee_payer_account,
|
|
268
|
+
"usdc_resource": self.usdc_resource,
|
|
269
|
+
"xrd_gas_vault": self.xrd_gas_vault,
|
|
270
|
+
"merchant_account": self.merchant_account,
|
|
271
|
+
}
|
|
272
|
+
if self.start_epoch_inclusive is not None:
|
|
273
|
+
result["start_epoch_inclusive"] = self.start_epoch_inclusive
|
|
274
|
+
if self.end_epoch_exclusive is not None:
|
|
275
|
+
result["end_epoch_exclusive"] = self.end_epoch_exclusive
|
|
276
|
+
if self.intent_discriminator is not None:
|
|
277
|
+
result["intent_discriminator"] = self.intent_discriminator
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True)
|
|
282
|
+
class RapSignatureFile:
|
|
283
|
+
"""Wire shape of ``LegacySignatureFile`` (serde camelCase)."""
|
|
284
|
+
type: str
|
|
285
|
+
version: int
|
|
286
|
+
scope: dict[str, Any]
|
|
287
|
+
signature: RapSignatureValue
|
|
288
|
+
hash: RapHash
|
|
289
|
+
public_key: RapPublicKey
|
|
290
|
+
|
|
291
|
+
def to_dict(self) -> dict[str, Any]:
|
|
292
|
+
return {
|
|
293
|
+
"type": self.type,
|
|
294
|
+
"version": self.version,
|
|
295
|
+
"scope": self.scope,
|
|
296
|
+
"signature": self.signature.to_dict(),
|
|
297
|
+
"hash": self.hash.to_dict(),
|
|
298
|
+
"publicKey": self.public_key.to_dict(),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass(frozen=True)
|
|
303
|
+
class RapPayload:
|
|
304
|
+
"""The ``rap_payload`` object expected by ``handleModeBSponsored``."""
|
|
305
|
+
draft_inputs: RapDraftInputs
|
|
306
|
+
economic_params: RapEconomicParams
|
|
307
|
+
signatures: RapSignatureFile
|
|
308
|
+
|
|
309
|
+
def to_dict(self) -> dict[str, Any]:
|
|
310
|
+
return {
|
|
311
|
+
"draft_inputs": self.draft_inputs.to_dict(),
|
|
312
|
+
"economic_params": self.economic_params.to_dict(),
|
|
313
|
+
"signatures": self.signatures.to_dict(),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── Fetch Extensions ───────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True)
|
|
320
|
+
class AgentSyncFetchOptions:
|
|
321
|
+
"""Extended request options for AgentSyncClient.fetch."""
|
|
322
|
+
method: str = "GET"
|
|
323
|
+
headers: Optional[dict[str, str]] = None
|
|
324
|
+
body: Optional[Any] = None
|
|
325
|
+
# Mode B (Single-Pay): the RAP payload object. The SDK serializes this as
|
|
326
|
+
# `{ payment: { rap_payload, requirements } }` in the request body.
|
|
327
|
+
rap_payload: Optional[RapPayload] = None
|
|
328
|
+
requirements: Optional[dict[str, Any]] = None
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentsync-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for the AgentSync x402 micropayment gateway on Radix
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: httpx>=0.27.0
|
|
17
|
+
Requires-Dist: PyNaCl>=1.5.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-httpx>=0.30.0; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# AgentSync Python SDK
|
|
24
|
+
|
|
25
|
+
Python SDK for the AgentSync x402 micropayment gateway on Radix.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install agentsync-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> Note: the distribution name is `agentsync-sdk`; the import package remains
|
|
34
|
+
> `agentsync` (`from agentsync import ...`). For local development use
|
|
35
|
+
> `pip install -e .` in the repo.
|
|
36
|
+
|
|
37
|
+
## Development
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e ".[dev]"
|
|
41
|
+
pytest
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Platform Attestation Verification
|
|
45
|
+
|
|
46
|
+
Verify a platform-signed attestation envelope against the trusted platform
|
|
47
|
+
Ed25519 public key:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from agentsync import SignatureEnvelope, verify_platform_attestation
|
|
51
|
+
|
|
52
|
+
trusted_public_key = "9afcc1cf66b256d7d9567a906fba4317305d92c3e7fd7f27386dffe182332576"
|
|
53
|
+
envelope = SignatureEnvelope(
|
|
54
|
+
payload="account_tdx_2_...:5.500000:0",
|
|
55
|
+
signature="<128-char hex>",
|
|
56
|
+
public_key=trusted_public_key,
|
|
57
|
+
algorithm="ed25519+sha256",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
valid = verify_platform_attestation(envelope, trusted_public_key) # bool
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`verify_platform_attestation()` is pure and synchronous: the trusted key is
|
|
64
|
+
injected by the caller and never fetched from the network. It asserts that the
|
|
65
|
+
envelope's `public_key` matches the trusted key before verifying the
|
|
66
|
+
`Ed25519(SHA-256(payload))` signature.
|
|
67
|
+
|
|
68
|
+
The TypeScript counterpart lives at [`../sdk-typescript`](../sdk-typescript).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
agentsync/__init__.py,sha256=0zQIqBWkq3Lm06SwVL5BtqlITPU4VkQgxuZFWSMmW0E,2368
|
|
2
|
+
agentsync/attestation.py,sha256=CQu4gM_8eVH5bptBfLBE5zPRuoNXz7GxK9HfJtcU8Zk,2899
|
|
3
|
+
agentsync/client.py,sha256=2tlEFvEOMJnaqGvW8nrMX4VhFcGp7YrTORsWyoKcAOg,20724
|
|
4
|
+
agentsync/errors.py,sha256=UkTaAywvscWMDsEruX_bpCQQ3omRC5Q17TrAQNVJiRY,1550
|
|
5
|
+
agentsync/rap.py,sha256=A15PsV7RiUYKgtpAaVoCRlUMtimvKTDiT4vVK74nTYI,6035
|
|
6
|
+
agentsync/types.py,sha256=f3JfvkYrm69WlFmlAJkQpP7Bz1UhNTjb1z1xOGSBrGI,10582
|
|
7
|
+
agentsync_sdk-1.0.0.dist-info/METADATA,sha256=xfIS2e3_zBD_QvEXPj-F66u88IqZR1bvTrgWXEkQC-Y,2117
|
|
8
|
+
agentsync_sdk-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
agentsync_sdk-1.0.0.dist-info/top_level.txt,sha256=8R5D9yLyyVwJIvwInvOWXj2hiEny0OnFg03eJFLvO14,10
|
|
10
|
+
agentsync_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentsync
|