oryne 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.
- oryne/__init__.py +55 -0
- oryne/agent.py +299 -0
- oryne/guard.py +114 -0
- oryne/payment.py +222 -0
- oryne/units.py +34 -0
- oryne-0.1.0.dist-info/METADATA +146 -0
- oryne-0.1.0.dist-info/RECORD +9 -0
- oryne-0.1.0.dist-info/WHEEL +4 -0
- oryne-0.1.0.dist-info/licenses/LICENSE +21 -0
oryne/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Oryne: give an AI agent a wallet it can spend from, with limits it cannot break.
|
|
2
|
+
|
|
3
|
+
>>> from oryne import OryneAgent, PaymentRefusedError
|
|
4
|
+
>>> agent = OryneAgent(secret=os.environ["ORYNE_AGENT_SECRET"])
|
|
5
|
+
>>> response = agent.fetch("https://api.example.com/search?q=stellar")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .agent import (
|
|
9
|
+
DEFAULT_API_URL,
|
|
10
|
+
Approval,
|
|
11
|
+
OryneAgent,
|
|
12
|
+
OryneAPIError,
|
|
13
|
+
PaymentEvent,
|
|
14
|
+
PaymentRefusedError,
|
|
15
|
+
RefusalSource,
|
|
16
|
+
)
|
|
17
|
+
from .guard import BeforePay, PayeeRule, PaymentCheck, parse_payee_rule, payee_refusal
|
|
18
|
+
from .payment import (
|
|
19
|
+
PAYMENT_HEADER,
|
|
20
|
+
PAYMENT_RESPONSE_HEADER,
|
|
21
|
+
PaymentRequirements,
|
|
22
|
+
SettleResponse,
|
|
23
|
+
StellarError,
|
|
24
|
+
agent_signature_scval,
|
|
25
|
+
contract_error_name,
|
|
26
|
+
sign_payment,
|
|
27
|
+
)
|
|
28
|
+
from .units import from_base_units, to_base_units
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DEFAULT_API_URL",
|
|
34
|
+
"PAYMENT_HEADER",
|
|
35
|
+
"PAYMENT_RESPONSE_HEADER",
|
|
36
|
+
"Approval",
|
|
37
|
+
"BeforePay",
|
|
38
|
+
"OryneAPIError",
|
|
39
|
+
"OryneAgent",
|
|
40
|
+
"PayeeRule",
|
|
41
|
+
"PaymentCheck",
|
|
42
|
+
"PaymentEvent",
|
|
43
|
+
"PaymentRefusedError",
|
|
44
|
+
"PaymentRequirements",
|
|
45
|
+
"RefusalSource",
|
|
46
|
+
"SettleResponse",
|
|
47
|
+
"StellarError",
|
|
48
|
+
"agent_signature_scval",
|
|
49
|
+
"contract_error_name",
|
|
50
|
+
"from_base_units",
|
|
51
|
+
"parse_payee_rule",
|
|
52
|
+
"payee_refusal",
|
|
53
|
+
"sign_payment",
|
|
54
|
+
"to_base_units",
|
|
55
|
+
]
|
oryne/agent.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""An agent's connection to Oryne.
|
|
2
|
+
|
|
3
|
+
Give it the agent's secret key (S…, shown once when the owner connects the
|
|
4
|
+
agent) and use ``agent.fetch()`` wherever you'd make an HTTP request: when a
|
|
5
|
+
service answers 402 Payment Required, it pays from the agent's wallet, inside
|
|
6
|
+
its mandate, and retries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable, Literal, TypedDict
|
|
16
|
+
from urllib.parse import urljoin
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from stellar_sdk import Keypair, SorobanServer
|
|
20
|
+
|
|
21
|
+
from .guard import BeforePay, PayeeRule, PaymentCheck, hook_refusal, payee_refusal
|
|
22
|
+
from .payment import (
|
|
23
|
+
PAYMENT_HEADER,
|
|
24
|
+
PAYMENT_RESPONSE_HEADER,
|
|
25
|
+
PaymentRequirements,
|
|
26
|
+
SettleResponse,
|
|
27
|
+
decode_header,
|
|
28
|
+
encode_header,
|
|
29
|
+
sign_payment,
|
|
30
|
+
)
|
|
31
|
+
from .units import from_base_units, to_base_units
|
|
32
|
+
|
|
33
|
+
#: The hosted Oryne API, on Stellar testnet.
|
|
34
|
+
DEFAULT_API_URL = "https://oryne.onrender.com"
|
|
35
|
+
|
|
36
|
+
#: How long directory lookups are reused, in seconds.
|
|
37
|
+
DIRECTORY_TTL = 5 * 60
|
|
38
|
+
|
|
39
|
+
#: Who refused: ``"wallet"`` is the contract (or the service failing to settle),
|
|
40
|
+
#: after the agent signed; ``"client"`` is this SDK, before anything was signed.
|
|
41
|
+
RefusalSource = Literal["wallet", "client"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PaymentRefusedError(Exception):
|
|
45
|
+
"""A payment wasn't made. ``reason`` says why, ``source`` says who refused."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, reason: str, requirements: PaymentRequirements, source: RefusalSource = "wallet") -> None:
|
|
48
|
+
self.reason = reason
|
|
49
|
+
self.requirements = requirements
|
|
50
|
+
self.source = source
|
|
51
|
+
prefix = "Payment refused by the wallet" if source == "wallet" else "Payment refused before signing"
|
|
52
|
+
super().__init__(f"{prefix}: {reason}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OryneAPIError(Exception):
|
|
56
|
+
"""The Oryne API answered with an error."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str, status: int) -> None:
|
|
59
|
+
self.status = status
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PaymentEvent:
|
|
65
|
+
"""Passed to ``on_payment``: ``paying``, then ``paid`` or ``refused``."""
|
|
66
|
+
|
|
67
|
+
type: Literal["paying", "paid", "refused"]
|
|
68
|
+
#: In token units, e.g. "0.05".
|
|
69
|
+
amount: str
|
|
70
|
+
requirements: PaymentRequirements
|
|
71
|
+
settlement: SettleResponse | None = None
|
|
72
|
+
reason: str | None = None
|
|
73
|
+
source: RefusalSource | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Approval(TypedDict):
|
|
77
|
+
id: str
|
|
78
|
+
status: Literal["pending", "approved", "declined", "expired", "used"]
|
|
79
|
+
amount: str
|
|
80
|
+
payee: str
|
|
81
|
+
description: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OryneAgent:
|
|
85
|
+
"""An Oryne agent: an HTTP client that pays 402 Payment Required from the agent's wallet.
|
|
86
|
+
|
|
87
|
+
>>> agent = OryneAgent(secret=os.environ["ORYNE_AGENT_SECRET"])
|
|
88
|
+
>>> response = agent.fetch("https://api.example.com/search?q=stellar", max_price="0.10")
|
|
89
|
+
|
|
90
|
+
:param secret: The agent's secret key (``S…``).
|
|
91
|
+
:param api_url: Oryne API. Defaults to ``ORYNE_API_URL``, then the hosted testnet API.
|
|
92
|
+
:param max_per_request: Most this client pays for one request, whatever a service asks, e.g. ``"1"``.
|
|
93
|
+
:param payees: Who it may pay: ``"any"`` (default), ``"directory"`` (only services in the
|
|
94
|
+
Oryne directory, at the address listed for that host) or a list of Stellar addresses.
|
|
95
|
+
Checked before signing; the wallet's on-chain mandate still applies on top.
|
|
96
|
+
:param before_pay: Last say before signing. Return ``True`` to pay, or ``False`` or a reason to refuse.
|
|
97
|
+
:param on_payment: Called with a :class:`PaymentEvent` as a payment progresses.
|
|
98
|
+
:param http: An ``httpx.Client`` to use, e.g. with your own timeouts or proxies.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
secret: str,
|
|
104
|
+
*,
|
|
105
|
+
api_url: str | None = None,
|
|
106
|
+
max_per_request: str | None = None,
|
|
107
|
+
payees: PayeeRule = "any",
|
|
108
|
+
before_pay: BeforePay | None = None,
|
|
109
|
+
on_payment: Callable[[PaymentEvent], None] | None = None,
|
|
110
|
+
http: httpx.Client | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.keypair = Keypair.from_secret(secret)
|
|
113
|
+
self.api_url = (api_url or os.environ.get("ORYNE_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
114
|
+
self.max_per_request = to_base_units(max_per_request) if max_per_request is not None else None
|
|
115
|
+
self.payees = payees
|
|
116
|
+
self.before_pay = before_pay
|
|
117
|
+
self.on_payment = on_payment
|
|
118
|
+
self._http = http or httpx.Client(follow_redirects=True, timeout=60)
|
|
119
|
+
self._info: dict[str, Any] | None = None
|
|
120
|
+
self._listings: dict[str, tuple[float, list[dict]]] = {}
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def public_key(self) -> str:
|
|
124
|
+
return self.keypair.public_key
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
self._http.close()
|
|
128
|
+
|
|
129
|
+
def __enter__(self) -> OryneAgent:
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def __exit__(self, *exc: object) -> None:
|
|
133
|
+
self.close()
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------ Oryne API
|
|
136
|
+
|
|
137
|
+
def api(self, path: str, *, method: str = "GET", body: Any = None) -> Any:
|
|
138
|
+
"""Calls the Oryne API as this agent; each request is signed with the agent key."""
|
|
139
|
+
url = f"{self.api_url}/v1/agent{path}"
|
|
140
|
+
pathname = httpx.URL(url).path
|
|
141
|
+
timestamp = int(time.time())
|
|
142
|
+
message = f"oryne-agent\n{method.upper()}\n{pathname}\n{timestamp}".encode()
|
|
143
|
+
signature = base64.b64encode(self.keypair.sign(message)).decode()
|
|
144
|
+
response = self._http.request(
|
|
145
|
+
method,
|
|
146
|
+
url,
|
|
147
|
+
headers={"x-oryne-agent": f"{self.public_key}.{timestamp}.{signature}"},
|
|
148
|
+
json=body,
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
data = response.json()
|
|
152
|
+
except ValueError:
|
|
153
|
+
data = {}
|
|
154
|
+
if response.is_error:
|
|
155
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
156
|
+
raise OryneAPIError(error or f"Oryne API {response.status_code}", response.status_code)
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
def me(self, refresh: bool = False) -> dict[str, Any]:
|
|
160
|
+
"""The agent, its wallet, its limits and what it has left. Cached after the first call."""
|
|
161
|
+
if self._info is None or refresh:
|
|
162
|
+
self._info = self.api("")
|
|
163
|
+
return self._info
|
|
164
|
+
|
|
165
|
+
def directory(self, origin: str) -> list[dict]:
|
|
166
|
+
"""The Oryne directory's listings for an origin. Cached for a few minutes; failures aren't cached."""
|
|
167
|
+
cached = self._listings.get(origin)
|
|
168
|
+
if cached and time.monotonic() - cached[0] < DIRECTORY_TTL:
|
|
169
|
+
return cached[1]
|
|
170
|
+
host = origin.split("://", 1)[1]
|
|
171
|
+
response = self._http.get(f"{self.api_url}/v1/services", params={"host": host})
|
|
172
|
+
if response.is_error:
|
|
173
|
+
raise OryneAPIError(f"directory answered {response.status_code}", response.status_code)
|
|
174
|
+
listings = response.json().get("services") or []
|
|
175
|
+
self._listings[origin] = (time.monotonic(), listings)
|
|
176
|
+
return listings
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------ paying
|
|
179
|
+
|
|
180
|
+
def fetch(self, url: str, method: str = "GET", *, max_price: str | None = None, **kwargs: Any) -> httpx.Response:
|
|
181
|
+
"""Makes an HTTP request, paying a 402 Payment Required from the agent's wallet.
|
|
182
|
+
|
|
183
|
+
Takes the same keyword arguments as ``httpx.Client.request`` (``params``,
|
|
184
|
+
``json``, ``headers``, ``content``…), plus ``max_price``: refuse to pay more
|
|
185
|
+
than this for this request, in token units, e.g. ``"0.10"``.
|
|
186
|
+
|
|
187
|
+
Raises :class:`PaymentRefusedError` when the payment isn't made.
|
|
188
|
+
"""
|
|
189
|
+
first = self._http.request(method, url, **kwargs)
|
|
190
|
+
if first.status_code != 402:
|
|
191
|
+
return first
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
challenge = first.json()
|
|
195
|
+
except ValueError:
|
|
196
|
+
return first
|
|
197
|
+
info = self.me()
|
|
198
|
+
requirements: PaymentRequirements | None = next(
|
|
199
|
+
(
|
|
200
|
+
option
|
|
201
|
+
for option in (challenge or {}).get("accepts") or []
|
|
202
|
+
if option.get("scheme") == "exact"
|
|
203
|
+
and option.get("network") == info["network"]
|
|
204
|
+
and option.get("asset") == info["token"]
|
|
205
|
+
),
|
|
206
|
+
None,
|
|
207
|
+
)
|
|
208
|
+
if requirements is None:
|
|
209
|
+
return first
|
|
210
|
+
|
|
211
|
+
amount = int(requirements["maxAmountRequired"])
|
|
212
|
+
display = from_base_units(amount)
|
|
213
|
+
refusal = self._refusal(str(first.url), requirements, amount, display, max_price)
|
|
214
|
+
if refusal:
|
|
215
|
+
self._emit(PaymentEvent("refused", display, requirements, reason=refusal, source="client"))
|
|
216
|
+
raise PaymentRefusedError(refusal, requirements, "client")
|
|
217
|
+
|
|
218
|
+
self._emit(PaymentEvent("paying", display, requirements))
|
|
219
|
+
payload = sign_payment(
|
|
220
|
+
rpc_server=SorobanServer(info["rpcUrl"]),
|
|
221
|
+
network_passphrase=info["networkPassphrase"],
|
|
222
|
+
agent=self.keypair,
|
|
223
|
+
wallet=info["agent"]["wallet"],
|
|
224
|
+
requirements=requirements,
|
|
225
|
+
simulation_source=info["simulationSource"],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
headers = dict(kwargs.pop("headers", None) or {})
|
|
229
|
+
headers[PAYMENT_HEADER] = encode_header(payload)
|
|
230
|
+
paid = self._http.request(method, url, headers=headers, **kwargs)
|
|
231
|
+
|
|
232
|
+
receipt = paid.headers.get(PAYMENT_RESPONSE_HEADER)
|
|
233
|
+
settlement: SettleResponse | None = decode_header(receipt) if receipt else None
|
|
234
|
+
if paid.status_code == 402:
|
|
235
|
+
try:
|
|
236
|
+
body = paid.json()
|
|
237
|
+
except ValueError:
|
|
238
|
+
body = {}
|
|
239
|
+
reason = (settlement or {}).get("errorReason") or (body or {}).get("error") or "Payment was not accepted"
|
|
240
|
+
self._emit(PaymentEvent("refused", display, requirements, settlement, reason, "wallet"))
|
|
241
|
+
raise PaymentRefusedError(reason, requirements, "wallet")
|
|
242
|
+
if settlement:
|
|
243
|
+
self._emit(PaymentEvent("paid", display, requirements, settlement))
|
|
244
|
+
return paid
|
|
245
|
+
|
|
246
|
+
def _refusal(
|
|
247
|
+
self,
|
|
248
|
+
url: str,
|
|
249
|
+
requirements: PaymentRequirements,
|
|
250
|
+
amount: int,
|
|
251
|
+
display: str,
|
|
252
|
+
max_price: str | None,
|
|
253
|
+
) -> str | None:
|
|
254
|
+
"""Why this agent shouldn't sign the payment, or ``None`` if it may."""
|
|
255
|
+
if amount <= 0:
|
|
256
|
+
return f"Asks a payment of {display}"
|
|
257
|
+
if self.max_per_request is not None and amount > self.max_per_request:
|
|
258
|
+
return f"Asks {display}, above this client's max_per_request of {from_base_units(self.max_per_request)}"
|
|
259
|
+
if max_price is not None and amount > to_base_units(max_price):
|
|
260
|
+
return f"Asks {display}, above the {max_price} expected for this request"
|
|
261
|
+
payee = payee_refusal(self.payees, url, requirements["payTo"], self.directory)
|
|
262
|
+
if payee:
|
|
263
|
+
return payee
|
|
264
|
+
return hook_refusal(self.before_pay, PaymentCheck(url, requirements, display))
|
|
265
|
+
|
|
266
|
+
def _emit(self, event: PaymentEvent) -> None:
|
|
267
|
+
if self.on_payment:
|
|
268
|
+
self.on_payment(event)
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------ approvals
|
|
271
|
+
|
|
272
|
+
def request_approval(
|
|
273
|
+
self,
|
|
274
|
+
*,
|
|
275
|
+
pay_to: str,
|
|
276
|
+
amount: str,
|
|
277
|
+
description: str,
|
|
278
|
+
resource: str | None = None,
|
|
279
|
+
reason: str | None = None,
|
|
280
|
+
) -> Approval:
|
|
281
|
+
"""Asks the owner to approve a payment the mandate refused. Returns the pending approval."""
|
|
282
|
+
body = {"payTo": pay_to, "amount": amount, "description": description}
|
|
283
|
+
if resource:
|
|
284
|
+
body["resource"] = resource
|
|
285
|
+
if reason:
|
|
286
|
+
body["reason"] = reason
|
|
287
|
+
return self.api("/approvals", method="POST", body=body)
|
|
288
|
+
|
|
289
|
+
def approval(self, approval_id: str) -> Approval:
|
|
290
|
+
return self.api(f"/approvals/{approval_id}")
|
|
291
|
+
|
|
292
|
+
def wait_for_approval(self, approval_id: str, *, timeout: float = 600, interval: float = 3) -> Approval:
|
|
293
|
+
"""Polls until the owner decides, or ``timeout`` seconds pass."""
|
|
294
|
+
deadline = time.monotonic() + timeout
|
|
295
|
+
while True:
|
|
296
|
+
approval = self.approval(approval_id)
|
|
297
|
+
if approval["status"] != "pending" or time.monotonic() > deadline:
|
|
298
|
+
return approval
|
|
299
|
+
time.sleep(interval)
|
oryne/guard.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Checks the agent makes before it signs a payment.
|
|
2
|
+
|
|
3
|
+
The wallet contract is the hard limit: it refuses anything outside the
|
|
4
|
+
mandate, whatever the agent signs. These checks sit in front of it and decide
|
|
5
|
+
whether the agent should agree to a 402 at all, for example because the
|
|
6
|
+
service asks to be paid at an address that isn't the one it's listed with.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Callable, Literal, Union
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from .payment import PaymentRequirements
|
|
16
|
+
|
|
17
|
+
#: Who the agent may pay:
|
|
18
|
+
#:
|
|
19
|
+
#: - ``"any"``: whoever the service names (the wallet's on-chain payee list still applies);
|
|
20
|
+
#: - ``"directory"``: only services listed in the Oryne directory, and only at
|
|
21
|
+
#: the address their listing gives for that host;
|
|
22
|
+
#: - a list of Stellar addresses: only those.
|
|
23
|
+
PayeeRule = Union[Literal["any", "directory"], "list[str]"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PaymentCheck:
|
|
28
|
+
"""A payment the agent is about to sign."""
|
|
29
|
+
|
|
30
|
+
#: The URL that answered 402, after any redirects.
|
|
31
|
+
url: str
|
|
32
|
+
requirements: PaymentRequirements
|
|
33
|
+
#: The amount in token units, e.g. "0.05".
|
|
34
|
+
amount: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Return ``True`` to pay, or ``False`` or a reason to refuse.
|
|
38
|
+
BeforePay = Callable[[PaymentCheck], Union[bool, str]]
|
|
39
|
+
|
|
40
|
+
#: Looks up the directory listings (dicts with ``url`` and ``payTo``) for an origin.
|
|
41
|
+
DirectoryLookup = Callable[[str], "list[dict]"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def origin_of(url: str) -> str | None:
|
|
45
|
+
"""``https://api.example.com:8443/x?y`` -> ``https://api.example.com:8443``, like ``URL.origin``."""
|
|
46
|
+
try:
|
|
47
|
+
parts = urlsplit(url)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
if not parts.scheme or not parts.hostname:
|
|
51
|
+
return None
|
|
52
|
+
default_port = {"http": 80, "https": 443}.get(parts.scheme)
|
|
53
|
+
try:
|
|
54
|
+
port = parts.port
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
host = parts.hostname
|
|
58
|
+
if ":" in host:
|
|
59
|
+
host = f"[{host}]"
|
|
60
|
+
suffix = f":{port}" if port is not None and port != default_port else ""
|
|
61
|
+
return f"{parts.scheme}://{host}{suffix}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def host_of(origin: str) -> str:
|
|
65
|
+
return origin.split("://", 1)[1]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _short(address: str) -> str:
|
|
69
|
+
return f"{address[:4]}…{address[-4:]}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def payee_refusal(rule: PayeeRule, url: str, pay_to: str, directory: DirectoryLookup) -> str | None:
|
|
73
|
+
"""Why the agent should not pay ``pay_to`` for ``url`` under ``rule``, or ``None`` if it may."""
|
|
74
|
+
if rule == "any":
|
|
75
|
+
return None
|
|
76
|
+
if not isinstance(rule, str):
|
|
77
|
+
return None if pay_to in rule else f"Payee {_short(pay_to)} is not in this agent's allowed payees"
|
|
78
|
+
if rule != "directory":
|
|
79
|
+
raise ValueError(f"Unknown payee rule: {rule!r}")
|
|
80
|
+
|
|
81
|
+
origin = origin_of(url)
|
|
82
|
+
if not origin:
|
|
83
|
+
return f"Can't tell which service {url} is"
|
|
84
|
+
try:
|
|
85
|
+
listings = [listing for listing in directory(origin) if origin_of(str(listing.get("url", ""))) == origin]
|
|
86
|
+
except Exception as error: # noqa: BLE001 - any lookup failure refuses the payment
|
|
87
|
+
return f"Could not check the Oryne directory: {error}"
|
|
88
|
+
host = host_of(origin)
|
|
89
|
+
if not listings:
|
|
90
|
+
return f"{host} is not listed in the Oryne directory"
|
|
91
|
+
if any(listing.get("payTo") == pay_to for listing in listings):
|
|
92
|
+
return None
|
|
93
|
+
listed = ", ".join(dict.fromkeys(_short(str(listing.get("payTo", ""))) for listing in listings))
|
|
94
|
+
return f"{host} asks to be paid at {_short(pay_to)}, but its directory listing pays {listed}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def hook_refusal(hook: BeforePay | None, payment: PaymentCheck) -> str | None:
|
|
98
|
+
"""Runs a ``before_pay`` hook and turns its answer into a refusal reason, or ``None`` to pay."""
|
|
99
|
+
if hook is None:
|
|
100
|
+
return None
|
|
101
|
+
answer = hook(payment)
|
|
102
|
+
if answer is True:
|
|
103
|
+
return None
|
|
104
|
+
return answer if isinstance(answer, str) and answer else "Refused by before_pay"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_payee_rule(value: str | None) -> PayeeRule | None:
|
|
108
|
+
"""Reads a payee rule from text, as in an environment variable: "any", "directory" or "G…,G…"."""
|
|
109
|
+
text = (value or "").strip()
|
|
110
|
+
if not text:
|
|
111
|
+
return None
|
|
112
|
+
if text in ("any", "directory"):
|
|
113
|
+
return text # type: ignore[return-value]
|
|
114
|
+
return [address.strip() for address in text.split(",") if address.strip()]
|
oryne/payment.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""The payment format Oryne speaks, modelled on x402.
|
|
2
|
+
|
|
3
|
+
1. A service answers ``402 Payment Required`` with payment requirements.
|
|
4
|
+
2. The agent signs a Soroban authorization entry for
|
|
5
|
+
``token.transfer(from = its wallet, to = payTo, amount)`` with its own key,
|
|
6
|
+
and retries with the ``X-PAYMENT`` header.
|
|
7
|
+
3. The service hands the payload to a facilitator, which submits the
|
|
8
|
+
transfer. The wallet contract's ``__check_auth`` enforces the mandate.
|
|
9
|
+
|
|
10
|
+
The agent's key never leaves the agent, and the facilitator can't change the
|
|
11
|
+
payee or amount: they are inside what the agent signed.
|
|
12
|
+
|
|
13
|
+
This mirrors ``@oryne/core`` (TypeScript) byte for byte.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, TypedDict
|
|
22
|
+
|
|
23
|
+
from stellar_sdk import Keypair, SorobanServer, TransactionBuilder, scval
|
|
24
|
+
from stellar_sdk import xdr as stellar_xdr
|
|
25
|
+
from stellar_sdk.address import Address
|
|
26
|
+
from stellar_sdk.auth import authorization_payload_hash, authorize_entry
|
|
27
|
+
|
|
28
|
+
X402_VERSION = 1
|
|
29
|
+
SCHEME = "exact"
|
|
30
|
+
PAYMENT_HEADER = "X-PAYMENT"
|
|
31
|
+
PAYMENT_RESPONSE_HEADER = "X-PAYMENT-RESPONSE"
|
|
32
|
+
|
|
33
|
+
#: How long a signed payment stays valid, in ledgers (about 5 seconds each).
|
|
34
|
+
DEFAULT_VALID_FOR_LEDGERS = 24
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PaymentRequirements(TypedDict, total=False):
|
|
38
|
+
scheme: str
|
|
39
|
+
#: "stellar-testnet" or "stellar-mainnet"
|
|
40
|
+
network: str
|
|
41
|
+
#: Token contract ID.
|
|
42
|
+
asset: str
|
|
43
|
+
#: Base units, as a decimal string.
|
|
44
|
+
maxAmountRequired: str
|
|
45
|
+
payTo: str
|
|
46
|
+
resource: str
|
|
47
|
+
description: str
|
|
48
|
+
#: Where the service settles payments.
|
|
49
|
+
facilitator: str
|
|
50
|
+
maxTimeoutSeconds: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SettleResponse(TypedDict, total=False):
|
|
54
|
+
success: bool
|
|
55
|
+
#: Transaction hash when settled.
|
|
56
|
+
transaction: str
|
|
57
|
+
ledger: int
|
|
58
|
+
payer: str
|
|
59
|
+
#: Contract error name or another reason when not.
|
|
60
|
+
errorReason: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------- contract errors
|
|
64
|
+
|
|
65
|
+
#: The agent-wallet contract's error enum, by number.
|
|
66
|
+
CONTRACT_ERRORS = {
|
|
67
|
+
1: "UnknownAgent",
|
|
68
|
+
2: "AgentPaused",
|
|
69
|
+
3: "AgentExpired",
|
|
70
|
+
4: "NotATransfer",
|
|
71
|
+
5: "WrongToken",
|
|
72
|
+
6: "PayeeNotAllowed",
|
|
73
|
+
7: "OverPerTxLimit",
|
|
74
|
+
8: "OverBudget",
|
|
75
|
+
9: "InvalidAmount",
|
|
76
|
+
10: "AgentExists",
|
|
77
|
+
11: "InvalidPolicy",
|
|
78
|
+
12: "NoPasskey",
|
|
79
|
+
13: "InvalidPasskeySignature",
|
|
80
|
+
14: "InvalidExpiry",
|
|
81
|
+
15: "AgentRemoved",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#: Errors of the Stellar Asset Contract (the token, e.g. USDC) that a payment can hit.
|
|
85
|
+
TOKEN_ERRORS = {
|
|
86
|
+
6: "AccountMissing",
|
|
87
|
+
8: "NegativeAmount",
|
|
88
|
+
10: "InsufficientBalance",
|
|
89
|
+
11: "BalanceDeauthorized",
|
|
90
|
+
12: "Overflow",
|
|
91
|
+
13: "TrustlineMissing",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def contract_error_name(message: str) -> str | None:
|
|
96
|
+
"""Finds ``Error(Contract, #8)`` in a simulation or submission error and names it.
|
|
97
|
+
|
|
98
|
+
The wallet and the token number their errors separately, so when the
|
|
99
|
+
diagnostic log shows the error came from the contract ``transfer`` was
|
|
100
|
+
called on, it's named from the token's errors.
|
|
101
|
+
"""
|
|
102
|
+
match = re.search(r"Error\(Contract, #(\d+)\)", message)
|
|
103
|
+
if not match:
|
|
104
|
+
return None
|
|
105
|
+
code = int(match.group(1))
|
|
106
|
+
raised_by = re.search(r"contract:(C[A-Z2-7]{55}), topics:\[error, Error\(Contract, #\d+\)\]", message)
|
|
107
|
+
if raised_by and f"topics:[fn_call, {raised_by.group(1)}, transfer]" in message:
|
|
108
|
+
return TOKEN_ERRORS.get(code, f"TokenError{code}")
|
|
109
|
+
return CONTRACT_ERRORS.get(code, f"ContractError{code}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class StellarError(Exception):
|
|
113
|
+
"""A failure from Stellar, with the contract error named when there is one."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, message: str, detail: str) -> None:
|
|
116
|
+
self.contract_error = contract_error_name(detail)
|
|
117
|
+
self.detail = detail
|
|
118
|
+
super().__init__(f"{message}: {self.contract_error or detail[:300]}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------- encoding
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def encode_header(value: Any) -> str:
|
|
125
|
+
return base64.b64encode(json.dumps(value, separators=(",", ":")).encode()).decode()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def decode_header(value: str) -> Any:
|
|
129
|
+
return json.loads(base64.b64decode(value))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def agent_signature_scval(public_key: bytes, signature: bytes) -> stellar_xdr.SCVal:
|
|
133
|
+
"""``Signature::Agent``: what an agent attaches to an authorization entry.
|
|
134
|
+
|
|
135
|
+
A ``#[contracttype]`` enum variant with one value is ``vec[symbol, value]``,
|
|
136
|
+
and a struct is a map with symbol keys.
|
|
137
|
+
"""
|
|
138
|
+
return scval.to_vec(
|
|
139
|
+
[
|
|
140
|
+
scval.to_symbol("Agent"),
|
|
141
|
+
scval.to_map(
|
|
142
|
+
{
|
|
143
|
+
scval.to_symbol("public_key"): scval.to_bytes(public_key),
|
|
144
|
+
scval.to_symbol("signature"): scval.to_bytes(signature),
|
|
145
|
+
}
|
|
146
|
+
),
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _address_of(entry: stellar_xdr.SorobanAuthorizationEntry) -> str | None:
|
|
152
|
+
credentials = entry.credentials
|
|
153
|
+
kind = stellar_xdr.SorobanCredentialsType
|
|
154
|
+
if credentials.type == kind.SOROBAN_CREDENTIALS_ADDRESS:
|
|
155
|
+
node = credentials.address
|
|
156
|
+
elif credentials.type == kind.SOROBAN_CREDENTIALS_ADDRESS_V2:
|
|
157
|
+
node = credentials.address_v2
|
|
158
|
+
else:
|
|
159
|
+
return None
|
|
160
|
+
return Address.from_xdr_sc_address(node.address).address if node else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------- signing
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sign_payment(
|
|
167
|
+
*,
|
|
168
|
+
rpc_server: SorobanServer,
|
|
169
|
+
network_passphrase: str,
|
|
170
|
+
agent: Keypair,
|
|
171
|
+
wallet: str,
|
|
172
|
+
requirements: PaymentRequirements,
|
|
173
|
+
simulation_source: str,
|
|
174
|
+
valid_for_ledgers: int = DEFAULT_VALID_FOR_LEDGERS,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""Builds the transfer, simulates it to get the authorization entry the
|
|
177
|
+
wallet must sign, and signs it with the agent key.
|
|
178
|
+
|
|
179
|
+
``simulation_source`` is any funded account; it pays for nothing.
|
|
180
|
+
Returns the payment payload to send in the ``X-PAYMENT`` header.
|
|
181
|
+
"""
|
|
182
|
+
amount = int(requirements["maxAmountRequired"])
|
|
183
|
+
source = rpc_server.load_account(simulation_source)
|
|
184
|
+
tx = (
|
|
185
|
+
TransactionBuilder(source, network_passphrase, base_fee=100)
|
|
186
|
+
.append_invoke_contract_function_op(
|
|
187
|
+
contract_id=requirements["asset"],
|
|
188
|
+
function_name="transfer",
|
|
189
|
+
parameters=[
|
|
190
|
+
scval.to_address(wallet),
|
|
191
|
+
scval.to_address(requirements["payTo"]),
|
|
192
|
+
scval.to_int128(amount),
|
|
193
|
+
],
|
|
194
|
+
)
|
|
195
|
+
.set_timeout(120)
|
|
196
|
+
.build()
|
|
197
|
+
)
|
|
198
|
+
sim = rpc_server.simulate_transaction(tx)
|
|
199
|
+
if sim.error:
|
|
200
|
+
raise StellarError("Simulation failed", sim.error)
|
|
201
|
+
|
|
202
|
+
entries = [
|
|
203
|
+
stellar_xdr.SorobanAuthorizationEntry.from_xdr(raw)
|
|
204
|
+
for result in sim.results or []
|
|
205
|
+
for raw in result.auth or []
|
|
206
|
+
]
|
|
207
|
+
entry = next((candidate for candidate in entries if _address_of(candidate) == wallet), None)
|
|
208
|
+
if entry is None:
|
|
209
|
+
raise RuntimeError("The transfer did not ask the wallet for authorization")
|
|
210
|
+
|
|
211
|
+
def signer(preimage: stellar_xdr.HashIDPreimage) -> stellar_xdr.SCVal:
|
|
212
|
+
payload = authorization_payload_hash(preimage)
|
|
213
|
+
return agent_signature_scval(agent.raw_public_key(), agent.sign(payload))
|
|
214
|
+
|
|
215
|
+
latest = rpc_server.get_latest_ledger().sequence
|
|
216
|
+
signed = authorize_entry(entry, signer, latest + valid_for_ledgers, network_passphrase)
|
|
217
|
+
return {
|
|
218
|
+
"x402Version": X402_VERSION,
|
|
219
|
+
"scheme": SCHEME,
|
|
220
|
+
"network": requirements["network"],
|
|
221
|
+
"payload": {"from": wallet, "authorization": signed.to_xdr()},
|
|
222
|
+
}
|
oryne/units.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Token amounts: Stellar assets have 7 decimals, so 1 USDC is 10_000_000 base units."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
DECIMALS = 7
|
|
8
|
+
|
|
9
|
+
_AMOUNT = re.compile(r"^\d+(\.\d+)?$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_base_units(amount: str | int, decimals: int = DECIMALS) -> int:
|
|
13
|
+
"""``"0.008"`` -> ``80_000``. Rejects anything that isn't a plain positive decimal.
|
|
14
|
+
|
|
15
|
+
Floats are refused on purpose: ``0.1 + 0.2`` is not ``0.3``, and money
|
|
16
|
+
should not depend on that. Pass amounts as strings.
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(amount, bool) or not isinstance(amount, (str, int)):
|
|
19
|
+
raise TypeError(f"Pass amounts as strings, e.g. '0.05', not {type(amount).__name__}")
|
|
20
|
+
text = str(amount).strip()
|
|
21
|
+
if not _AMOUNT.match(text):
|
|
22
|
+
raise ValueError(f"Invalid amount: {amount!r}")
|
|
23
|
+
whole, _, fraction = text.partition(".")
|
|
24
|
+
if len(fraction) > decimals:
|
|
25
|
+
raise ValueError(f"Too many decimals in {amount!r}")
|
|
26
|
+
return int(whole) * 10**decimals + int(fraction.ljust(decimals, "0") or "0")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def from_base_units(units: int, decimals: int = DECIMALS) -> str:
|
|
30
|
+
"""``80_000`` -> ``"0.008"``"""
|
|
31
|
+
sign = "-" if units < 0 else ""
|
|
32
|
+
whole, fraction = divmod(abs(units), 10**decimals)
|
|
33
|
+
fraction_text = str(fraction).rjust(decimals, "0").rstrip("0")
|
|
34
|
+
return f"{sign}{whole}{'.' + fraction_text if fraction_text else ''}"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oryne
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Give an AI agent a wallet it can spend from, with limits it cannot break: x402 payments in USDC on Stellar.
|
|
5
|
+
Project-URL: Homepage, https://oryne.org
|
|
6
|
+
Project-URL: Source, https://github.com/m2moryne/Oryne/tree/master/packages/python
|
|
7
|
+
Project-URL: Issues, https://github.com/m2moryne/Oryne/issues
|
|
8
|
+
Author: Oryne
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-payments,ai-agents,soroban,stellar,usdc,x402
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx<1,>=0.27
|
|
19
|
+
Requires-Dist: stellar-sdk<18,>=17.0.1
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# oryne (Python)
|
|
25
|
+
|
|
26
|
+
Give an AI agent a wallet it can spend from, with limits it cannot break.
|
|
27
|
+
|
|
28
|
+
`agent.fetch()` makes an HTTP request. When a service answers `402 Payment Required` ([x402](https://x402.org)), it pays in USDC from the agent's Oryne wallet on Stellar and retries. The wallet's smart contract checks every payment against the mandate the owner set: per-payment limit, budget, allowed payees, expiry. Nothing the agent does can get past it.
|
|
29
|
+
|
|
30
|
+
The same SDK as [`@oryne/agent`](https://www.npmjs.com/package/@oryne/agent) for TypeScript, for Python agents: LangChain, CrewAI, LlamaIndex, or a plain script.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install oryne
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Needs Python 3.10 or later.
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
Connect an agent in the [Oryne dashboard](https://oryne.org/app). You get its secret key once.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from oryne import OryneAgent
|
|
45
|
+
|
|
46
|
+
agent = OryneAgent(os.environ["ORYNE_AGENT_SECRET"])
|
|
47
|
+
|
|
48
|
+
response = agent.fetch("https://api.example.com/search", params={"q": "stellar"})
|
|
49
|
+
print(response.json())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`fetch` takes the same keyword arguments as `httpx.Client.request` (`params`, `json`, `headers`, `content`, …) and returns an `httpx.Response`.
|
|
53
|
+
|
|
54
|
+
When the wallet refuses a payment, `fetch` raises `PaymentRefusedError` with the reason (`OverPerTxLimit`, `OverBudget`, `PayeeNotAllowed`, …). The agent can then ask the owner:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from oryne import PaymentRefusedError
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
agent.fetch(url)
|
|
61
|
+
except PaymentRefusedError as refused:
|
|
62
|
+
if refused.source != "wallet":
|
|
63
|
+
raise # refused before signing, see below
|
|
64
|
+
approval = agent.request_approval(
|
|
65
|
+
pay_to=refused.requirements["payTo"],
|
|
66
|
+
amount="30",
|
|
67
|
+
description="Research report for the weekly summary",
|
|
68
|
+
resource=refused.requirements["resource"],
|
|
69
|
+
reason=refused.reason,
|
|
70
|
+
)
|
|
71
|
+
agent.wait_for_approval(approval["id"]) # the owner approves in the dashboard
|
|
72
|
+
agent.fetch(url) # now within what was approved
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Check a payment before signing
|
|
76
|
+
|
|
77
|
+
The wallet contract is the hard limit. Before signing, the SDK can also check whether a payment request makes sense at all. That catches a request that is inside the mandate but still wrong: a service that suddenly asks for more, or a hijacked endpoint that asks to be paid at someone else's address.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
agent = OryneAgent(
|
|
81
|
+
os.environ["ORYNE_AGENT_SECRET"],
|
|
82
|
+
payees="directory", # only services in the Oryne directory, at the address they're listed with
|
|
83
|
+
# Your own rule, last before signing: True to pay, or a reason to refuse.
|
|
84
|
+
before_pay=lambda payment: payment.url.startswith("https://api.example.com/") or "only example.com",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# This search should cost at most 0.10; a 402 asking more is refused unpaid.
|
|
88
|
+
agent.fetch("https://api.example.com/search?q=stellar", max_price="0.10")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A refusal here raises `PaymentRefusedError` with `source == "client"`. Nothing was signed and no money moved. Refusals from the wallet contract have `source == "wallet"`.
|
|
92
|
+
|
|
93
|
+
The directory doesn't verify who owns a listed URL yet. `payees="directory"` stops payments to unlisted hosts and to addresses a host isn't listed with, but for a guarantee, set the allowed payees in the agent's on-chain mandate in the dashboard.
|
|
94
|
+
|
|
95
|
+
## Options
|
|
96
|
+
|
|
97
|
+
| Option | |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `secret` | The agent's secret key (`S…`). |
|
|
100
|
+
| `api_url` | Oryne API. Defaults to `ORYNE_API_URL`, then `https://oryne.onrender.com` (Stellar testnet). |
|
|
101
|
+
| `max_per_request` | Most this client pays for one request, whatever a service asks, e.g. `"1"`. |
|
|
102
|
+
| `payees` | Who it may pay: `"any"` (default), `"directory"`, or a list of Stellar addresses. |
|
|
103
|
+
| `before_pay` | `(PaymentCheck) -> True \| False \| reason`. Last say before signing. |
|
|
104
|
+
| `on_payment` | Called with a `PaymentEvent`: `paying`, then `paid` or `refused`. |
|
|
105
|
+
| `http` | Your own `httpx.Client`, e.g. with other timeouts or a proxy. |
|
|
106
|
+
|
|
107
|
+
Per request: `agent.fetch(url, max_price="0.10")`.
|
|
108
|
+
|
|
109
|
+
Amounts are strings (`"0.05"`), never floats, so money doesn't depend on float rounding.
|
|
110
|
+
|
|
111
|
+
Other methods: `agent.me()` (the agent, its mandate and what's left of its budget), `agent.approval(id)`. `OryneAgent` is a context manager that closes its HTTP client.
|
|
112
|
+
|
|
113
|
+
## As a tool for your agent framework
|
|
114
|
+
|
|
115
|
+
`fetch` is a plain function, so wrapping it as a tool is a few lines. With LangChain:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from langchain_core.tools import tool
|
|
119
|
+
|
|
120
|
+
@tool
|
|
121
|
+
def fetch_paid(url: str, max_price: str = "1") -> str:
|
|
122
|
+
"""Fetch a URL. If it costs money, pay up to max_price USDC from the agent's wallet."""
|
|
123
|
+
try:
|
|
124
|
+
return agent.fetch(url, max_price=max_price).text[:20_000]
|
|
125
|
+
except PaymentRefusedError as refused:
|
|
126
|
+
return f"Not paid: {refused.reason}"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Develop
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
133
|
+
.venv/bin/pytest # unit tests, no network
|
|
134
|
+
ORYNE_TESTNET=1 .venv/bin/pytest -m testnet # end to end on Stellar testnet
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The unit tests compare signatures and encodings with fixtures produced by the TypeScript SDK, so the two can't drift apart.
|
|
138
|
+
|
|
139
|
+
## Links
|
|
140
|
+
|
|
141
|
+
- [Oryne](https://oryne.org)
|
|
142
|
+
- [@oryne/agent](https://www.npmjs.com/package/@oryne/agent): the TypeScript SDK
|
|
143
|
+
- [@oryne/mcp](https://www.npmjs.com/package/@oryne/mcp): the same as tools for MCP clients like Claude
|
|
144
|
+
- [Source](https://github.com/m2moryne/Oryne/tree/master/packages/python)
|
|
145
|
+
|
|
146
|
+
MIT licensed.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
oryne/__init__.py,sha256=OiC-b6Q2kHBbfJYxmhe2lgOncrqxG3mzFtBGKyXfsuE,1303
|
|
2
|
+
oryne/agent.py,sha256=L06tlb7XZ4zF86F1V0IhPd_RkFV7gyKfNmqFv5gO90U,11942
|
|
3
|
+
oryne/guard.py,sha256=TpzIMHn6RhA9G7xz3QR91a1XMCk-5Gf1w9IB4hDMoOg,4234
|
|
4
|
+
oryne/payment.py,sha256=4J46TbYj80cWwSmhQWyzhWpGRfrD__LJT14OF8XKW-U,7351
|
|
5
|
+
oryne/units.py,sha256=9CtPES43WMBjNwIhzcKtzu5c570Fa0duXfjofkBXo18,1331
|
|
6
|
+
oryne-0.1.0.dist-info/METADATA,sha256=C7NkX2JGTlcMcwuuAQwEeO0In7Xa6BRsVgdRkqdXSVg,6254
|
|
7
|
+
oryne-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
oryne-0.1.0.dist-info/licenses/LICENSE,sha256=bwHHmBL92uS-uVx0pmc-45GFF06tFHu8Qtzv1zvjX4Y,1062
|
|
9
|
+
oryne-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oryne
|
|
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.
|