oceanalt-agentkit-hook 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.
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""oceanalt_agentkit_hook 0.1.0. Sync before-payment hook for Coinbase AgentKit (Python).
|
|
2
|
+
|
|
3
|
+
Works with:
|
|
4
|
+
* coinbase/agentkit#1577: X402Config(before_payment=...) -> x402ClientSync.on_before_payment_creation.
|
|
5
|
+
Context is x402's PaymentCreationContext; we return AbortResult to block, None to allow.
|
|
6
|
+
* a dict context shaped like #1454 / #1613 (url, payTo, network, asset, amount, ...);
|
|
7
|
+
we return {"abort": True, "reason": ..., "code": ...} to block, None to allow.
|
|
8
|
+
Never return a dict or a coroutine to the x402 SDK: it only blocks on isinstance(result, AbortResult).
|
|
9
|
+
Not affiliated with Coinbase.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
import httpx # inside the agentkit repo itself, use `requests` instead (it is already a dependency)
|
|
20
|
+
|
|
21
|
+
try: # x402 SDK is optional; only needed for the #1577 context
|
|
22
|
+
from x402.schemas.hooks import AbortResult # type: ignore
|
|
23
|
+
except Exception: # pragma: no cover
|
|
24
|
+
AbortResult = None # type: ignore
|
|
25
|
+
|
|
26
|
+
Policy = Literal["block", "allow"]
|
|
27
|
+
_EVM = re.compile(r"^0x[0-9a-fA-F]{40}$")
|
|
28
|
+
_V1 = {
|
|
29
|
+
"base": "eip155:8453", "base-sepolia": "eip155:84532", "ethereum": "eip155:1",
|
|
30
|
+
"polygon": "eip155:137", "arbitrum": "eip155:42161", "optimism": "eip155:10",
|
|
31
|
+
"avalanche": "eip155:43114", "solana": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize(ctx: Any) -> tuple[dict[str, Any], bool]:
|
|
36
|
+
"""Return (normalized fields, is_sdk_context)."""
|
|
37
|
+
sel = getattr(ctx, "selected_requirements", None)
|
|
38
|
+
if sel is not None:
|
|
39
|
+
pr = getattr(ctx, "payment_required", None)
|
|
40
|
+
resource = getattr(pr, "resource", None)
|
|
41
|
+
url = getattr(resource, "url", None) or getattr(sel, "resource", None) # v1 requirements carry resource
|
|
42
|
+
return {
|
|
43
|
+
"url": url,
|
|
44
|
+
"payTo": getattr(sel, "pay_to", None),
|
|
45
|
+
"network": getattr(sel, "network", None),
|
|
46
|
+
"asset": getattr(sel, "asset", None),
|
|
47
|
+
"amount": getattr(sel, "amount", None) or getattr(sel, "max_amount_required", None),
|
|
48
|
+
}, True
|
|
49
|
+
c = ctx if isinstance(ctx, dict) else {}
|
|
50
|
+
return {k: c.get(k) for k in ("url", "payTo", "network", "asset", "amount")}, False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _caip2(n: str | None) -> str | None:
|
|
54
|
+
if not n:
|
|
55
|
+
return None
|
|
56
|
+
return n if ":" in n else _V1.get(n.lower(), n)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _strip(u: str | None) -> str | None:
|
|
60
|
+
if not u:
|
|
61
|
+
return None
|
|
62
|
+
p = urlsplit(u)
|
|
63
|
+
if p.scheme not in ("http", "https") or not p.netloc:
|
|
64
|
+
return None
|
|
65
|
+
return f"{p.scheme}://{p.netloc}{p.path}"[:2048]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decide_body(n: dict[str, Any], send_ref: bool) -> dict[str, Any]:
|
|
69
|
+
net, pay_to, asset, amount = _caip2(n["network"]), n["payTo"], n["asset"], n["amount"]
|
|
70
|
+
ref = _strip(n["url"]) if send_ref else None
|
|
71
|
+
if (net and net.startswith("eip155:") and pay_to and _EVM.match(pay_to)
|
|
72
|
+
and asset and _EVM.match(asset) and isinstance(amount, str) and amount.isdigit()):
|
|
73
|
+
intent = {"v": "1", "payee": pay_to, "amount_atomic": amount,
|
|
74
|
+
"asset_id": f"{net}/erc20:{asset}", "network": net}
|
|
75
|
+
if ref:
|
|
76
|
+
intent["resource_ref"] = ref
|
|
77
|
+
return {"payment_intent": intent}
|
|
78
|
+
body: dict[str, Any] = {"to": pay_to}
|
|
79
|
+
if net:
|
|
80
|
+
body["network"] = net
|
|
81
|
+
if isinstance(amount, str) and re.fullmatch(r"\$\d+(\.\d+)?", amount):
|
|
82
|
+
body["amountUsdc"] = float(amount[1:])
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def oceanalt_before_payment(
|
|
87
|
+
*,
|
|
88
|
+
endpoint: Literal["decide", "x402-decision", "x402-payee"] = "decide",
|
|
89
|
+
base_url: str = "https://oceanalt.com",
|
|
90
|
+
api_key: str | None = None,
|
|
91
|
+
paid_client: httpx.Client | None = None, # an httpx client wrapped with x402 payment, for x402-* endpoints
|
|
92
|
+
on_review: Policy = "block",
|
|
93
|
+
on_error: Policy = "block",
|
|
94
|
+
on_missing_pay_to: Policy = "block",
|
|
95
|
+
timeout_s: float = 3.0,
|
|
96
|
+
code_prefix: str = "oceanalt:",
|
|
97
|
+
send_resource_ref: bool = True,
|
|
98
|
+
on_decision: Callable[[dict[str, Any]], None] | None = None,
|
|
99
|
+
) -> Callable[[Any], Any]:
|
|
100
|
+
paid = endpoint != "decide"
|
|
101
|
+
if paid and paid_client is None:
|
|
102
|
+
raise ValueError(f"oceanalt_before_payment: endpoint {endpoint} needs paid_client")
|
|
103
|
+
client = paid_client or httpx.Client(timeout=timeout_s)
|
|
104
|
+
path = {"decide": "/api/decide", "x402-decision": "/api/x402/decision", "x402-payee": "/api/x402/payee"}[endpoint]
|
|
105
|
+
headers = {"content-type": "application/json", "user-agent": "oceanalt-agentkit-hook-py/0.1.0"}
|
|
106
|
+
if api_key:
|
|
107
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
108
|
+
|
|
109
|
+
def hook(ctx: Any) -> Any:
|
|
110
|
+
n, sdk = _normalize(ctx)
|
|
111
|
+
|
|
112
|
+
def emit(outcome: str, decision: str, code: str, response: Any = None) -> None:
|
|
113
|
+
if on_decision:
|
|
114
|
+
try:
|
|
115
|
+
on_decision({"outcome": outcome, "decision": decision, "code": code,
|
|
116
|
+
"payTo": n["payTo"], "network": n["network"],
|
|
117
|
+
"url": _strip(n["url"]), "response": response})
|
|
118
|
+
except Exception:
|
|
119
|
+
pass # logging must never change the decision
|
|
120
|
+
|
|
121
|
+
def stop(decision: str, code: str, reason: str, response: Any = None) -> Any:
|
|
122
|
+
full, text = code_prefix + code, f"[OceanAlt] {reason}"[:300]
|
|
123
|
+
emit("aborted", decision, full, response)
|
|
124
|
+
if sdk:
|
|
125
|
+
if AbortResult is None:
|
|
126
|
+
raise RuntimeError(text) # SDK context without AbortResult import: raising still blocks
|
|
127
|
+
return AbortResult(reason=full, message=text)
|
|
128
|
+
return {"abort": True, "reason": text, "code": full}
|
|
129
|
+
|
|
130
|
+
def allow(decision: str, code: str, response: Any = None) -> None:
|
|
131
|
+
emit("allowed", decision, code_prefix + code, response)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def unavailable(code: str, detail: str) -> Any:
|
|
135
|
+
if on_error == "allow":
|
|
136
|
+
return allow("unavailable", code)
|
|
137
|
+
return stop("unavailable", code, f"decision unavailable ({detail}); payment not signed")
|
|
138
|
+
|
|
139
|
+
if not n["payTo"]:
|
|
140
|
+
if on_missing_pay_to == "allow":
|
|
141
|
+
return allow("missing_pay_to", "payee.missing")
|
|
142
|
+
return stop("missing_pay_to", "payee.missing", "the selected payment option declares no payTo")
|
|
143
|
+
|
|
144
|
+
if endpoint == "x402-payee":
|
|
145
|
+
body: dict[str, Any] = {"pay_to": n["payTo"]}
|
|
146
|
+
if _caip2(n["network"]):
|
|
147
|
+
body["network"] = _caip2(n["network"])
|
|
148
|
+
if send_resource_ref and _strip(n["url"]):
|
|
149
|
+
body["resource_url"] = _strip(n["url"])
|
|
150
|
+
else:
|
|
151
|
+
body = _decide_body(n, send_resource_ref)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
r = client.post(base_url.rstrip("/") + path, json=body, headers=headers, timeout=timeout_s)
|
|
155
|
+
except httpx.TimeoutException:
|
|
156
|
+
return unavailable("unavailable.timeout", f"no answer within {timeout_s}s")
|
|
157
|
+
except httpx.HTTPError:
|
|
158
|
+
return unavailable("unavailable.network", "network error")
|
|
159
|
+
if r.status_code >= 400:
|
|
160
|
+
if r.status_code == 402 and not paid:
|
|
161
|
+
return unavailable("unavailable.quota", "free allowance used up; set api_key")
|
|
162
|
+
return unavailable(f"unavailable.http_{r.status_code}", f"HTTP {r.status_code}")
|
|
163
|
+
try:
|
|
164
|
+
j = r.json()
|
|
165
|
+
except ValueError:
|
|
166
|
+
return unavailable("unavailable.bad_json", "unreadable response")
|
|
167
|
+
|
|
168
|
+
raw = j.get("decision") if isinstance(j, dict) else None
|
|
169
|
+
d = raw if raw in ("allow", "decline") else "review"
|
|
170
|
+
if endpoint == "x402-payee":
|
|
171
|
+
reasons = j.get("reasons") or []
|
|
172
|
+
rc = reasons[0].get("code") if reasons and isinstance(reasons[0], dict) else None
|
|
173
|
+
else:
|
|
174
|
+
rc = j.get("reason_code")
|
|
175
|
+
rc = rc or (d if raw == d else "unrecognized_decision")
|
|
176
|
+
summary = j.get("summary") if isinstance(j.get("summary"), str) else f"payee {n['payTo']} {d}"
|
|
177
|
+
|
|
178
|
+
if d == "allow":
|
|
179
|
+
return allow("allow", rc, j)
|
|
180
|
+
if d == "decline":
|
|
181
|
+
return stop("decline", rc, summary, j)
|
|
182
|
+
return allow("review", rc, j) if on_review == "allow" else stop("review", rc, summary, j)
|
|
183
|
+
|
|
184
|
+
return hook
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oceanalt-agentkit-hook
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync before-payment check for Coinbase AgentKit x402 payments (AgentKit #1577 / x402 SDK on_before_payment_creation, and #1454-shaped dicts). Fail-closed by default. Not affiliated with Coinbase.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://oceanalt.com/en/api-docs
|
|
7
|
+
Keywords: agentkit,x402,before_payment,agent-payments,aml
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: httpx>=0.25
|
|
11
|
+
|
|
12
|
+
# oceanalt-agentkit-hook (Python)
|
|
13
|
+
|
|
14
|
+
A synchronous before-payment check for Coinbase AgentKit x402 payments. Before anything is signed, it asks OceanAlt for a decision on the payee: `allow`, `review` or `decline`.
|
|
15
|
+
|
|
16
|
+
- With the x402 SDK hook (`on_before_payment_creation`, used by AgentKit #1577) it returns `AbortResult` to block and `None` to allow. The SDK only blocks on `AbortResult`; a returned dict or a coroutine would let the payment through, so this package never returns those to the SDK.
|
|
17
|
+
- With a dict context shaped like AgentKit #1454 it returns `{"abort": True, "reason": ..., "code": ...}` to block and `None` to allow.
|
|
18
|
+
|
|
19
|
+
Defaults are fail-closed: `review`, an unreachable service, a timeout (3 s) and a missing `payTo` all block. Change with `on_review`, `on_error`, `on_missing_pay_to`, `timeout_s`.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from oceanalt_agentkit_hook import oceanalt_before_payment
|
|
23
|
+
|
|
24
|
+
client.on_before_payment_creation(oceanalt_before_payment())
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It calls the free `POST https://oceanalt.com/api/decide`, sending the payee, network, asset, amount and the paid URL without its query string, so the service can also check the payee against that origin's own quote. Without a key the free endpoint allows 2,000 calls a month per source, then answers 402, which blocks payments under the default `on_error="block"`. For production agents, get a free key at https://oceanalt.com/en/api-keys (email only, 5,000 calls a month) and pass it as `api_key`. OceanAlt holds no keys and moves no money. Not affiliated with Coinbase. MIT.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
oceanalt_agentkit_hook/__init__.py,sha256=2Qr8yzMoRE6sPT8KDjcmbGnkz3EZdbWsW_AuyiEkVvE,8196
|
|
2
|
+
oceanalt_agentkit_hook-0.1.0.dist-info/METADATA,sha256=gImu_YOGFHT7Y-G-cF0KhWPQd07G4SRqGW8QjG613Cg,2017
|
|
3
|
+
oceanalt_agentkit_hook-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
oceanalt_agentkit_hook-0.1.0.dist-info/top_level.txt,sha256=TiWy8LvT6Rl3PlS1uB6ukmnUcx_51ZeJVu7_e_jUCLM,23
|
|
5
|
+
oceanalt_agentkit_hook-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
oceanalt_agentkit_hook
|