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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.