hunch-agent 0.1.0__tar.gz

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,98 @@
1
+ # dependencies
2
+ /node_modules
3
+ .pnpm-store
4
+
5
+ # next
6
+ /.next
7
+ /out
8
+ next-env.d.ts
9
+
10
+ # production
11
+ /build
12
+
13
+ # test / coverage
14
+ /coverage
15
+
16
+ # misc
17
+ .DS_Store
18
+ *.tsbuildinfo
19
+ *.log
20
+
21
+ # env
22
+ .env*
23
+ !.env.example
24
+ *.pem
25
+ *.key
26
+ *.p8
27
+ *.p12
28
+ *.cer
29
+ *.crt
30
+ *.secret
31
+ *.secrets.*
32
+ /secrets/
33
+
34
+ # editor
35
+ .vscode
36
+ .idea
37
+ .vercel
38
+ .claude/
39
+ .superpowers/
40
+ .burn-rate/
41
+ supabase/.temp/
42
+
43
+ # local agent/operator notes
44
+ AGENTS.md
45
+ CLAUDE.md
46
+ **/AGENTS.md
47
+ **/CLAUDE.md
48
+ /hunch-scope.md
49
+ /SUBMISSION.md
50
+ /notes/
51
+ /scratch/
52
+ /tmp/
53
+
54
+ # internal docs; keep the markdown source used by the public roadmap route
55
+ /docs/*
56
+ !/docs/ARBITRUM.md
57
+ !/docs/hunch-catalysts-10m-manifesto.md
58
+ !/docs/playhunch-future-roadmap.md
59
+ # Context Router per-feature docs (committed — the relocated CLAUDE.md context)
60
+ !/docs/context/
61
+ !/docs/context/*.md
62
+ # Hunch-on-Sui docs (committed — runbook + submission notes for the Sui rail)
63
+ !/docs/sui/
64
+ !/docs/sui/*.md
65
+
66
+ # generated media and local capture artifacts
67
+ /videos/
68
+ /recordings/
69
+ /screenshots/
70
+ /captures/
71
+ /generated_images/
72
+ captures/
73
+ *.mp4
74
+ *.mov
75
+ *.webm
76
+ *.m4v
77
+ *.wav
78
+ *.mp3
79
+ .gstack/
80
+
81
+ # Foundry (Arbitrum vault)
82
+ contracts/out/
83
+ contracts/cache/
84
+ contracts/broadcast/
85
+
86
+ # Python agent SDK (sdk/python) + examples
87
+ __pycache__/
88
+ *.pyc
89
+ .pytest_cache/
90
+ *.egg-info/
91
+ sdk/python/dist/
92
+ sdk/python/build/
93
+
94
+ # Agent SDK build output
95
+ packages/*/dist/
96
+
97
+ # Move build artifacts (regenerated by `sui move build`)
98
+ move/**/build/
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: hunch-agent
3
+ Version: 0.1.0
4
+ Summary: Python client for the Hunch agent platform — keyless, no-cap x402 prediction-market betting on Base.
5
+ Project-URL: Homepage, https://www.playhunch.xyz/agents
6
+ Project-URL: Documentation, https://www.playhunch.xyz/llms-full.txt
7
+ License: MIT
8
+ Keywords: agents,base,hunch,prediction-markets,usdc,x402
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: eth-account>=0.10
11
+ Requires-Dist: httpx>=0.24
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=7; extra == 'test'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # hunch-agent (Python)
17
+
18
+ Python client for the [Hunch](https://www.playhunch.xyz) agent platform. Keyless,
19
+ no-cap, auto-payout.
20
+
21
+ ```bash
22
+ # PyPI release pending — install from source:
23
+ pip install "git+https://github.com/rajkaria/hunch.git#subdirectory=sdk/python"
24
+ # or from a local clone: pip install -e sdk/python
25
+ ```
26
+
27
+ ## $0 simulation (no wallet)
28
+
29
+ ```python
30
+ from hunch_agent import HunchAgent
31
+
32
+ hunch = HunchAgent() # defaults to https://www.playhunch.xyz
33
+ markets = hunch.markets(status="open", limit=5)
34
+ research = hunch.research(markets[0]["id"])
35
+ print(research["resolutionRules"]["description"], research["odds"])
36
+
37
+ sim = hunch.bet(
38
+ markets[0]["id"], "yes", 1,
39
+ wallet_address="0xYourWallet...", simulate=True,
40
+ )
41
+ print(sim["simulated"], sim["position"]) # True, {...}
42
+ ```
43
+
44
+ ## Real bet (x402 USDC on Base)
45
+
46
+ The client runs the whole x402 loop for you — POST, get the 402, sign the exact
47
+ USDC `transferWithAuthorization` with `eth_account`, retry with `X-PAYMENT`. The
48
+ wallet only needs USDC on Base; gas is sponsored. Winners are paid automatically —
49
+ no claim step.
50
+
51
+ ```python
52
+ from eth_account import Account
53
+ from hunch_agent import HunchAgent
54
+
55
+ account = Account.from_key("0x...") # a funded Base wallet
56
+ hunch = HunchAgent(account=account)
57
+
58
+ receipt = hunch.bet("market-id", "yes", 5) # <= $10: simple tier
59
+ print(receipt["txHash"], receipt["proofUrl"])
60
+
61
+ # > $10: lock a quote first.
62
+ q = hunch.quote("market-id", "yes", 250)
63
+ hunch.bet("market-id", "yes", 250, quote_id=q["quoteId"], min_shares_out=q["suggestedMinSharesOut"])
64
+ ```
65
+
66
+ ## Verifying webhooks
67
+
68
+ ```python
69
+ from hunch_agent import verify_webhook
70
+
71
+ result = verify_webhook(request.headers, raw_body, secret)
72
+ if result["valid"]:
73
+ handle(result["event"])
74
+ ```
75
+
76
+ The TypeScript SDK (`@hunchxyz/agent-sdk`) carries the full live-route contract
77
+ tests; this client is the Python convenience surface, tested against recorded
78
+ fixtures. Full protocol docs: <https://www.playhunch.xyz/llms-full.txt>.
@@ -0,0 +1,63 @@
1
+ # hunch-agent (Python)
2
+
3
+ Python client for the [Hunch](https://www.playhunch.xyz) agent platform. Keyless,
4
+ no-cap, auto-payout.
5
+
6
+ ```bash
7
+ # PyPI release pending — install from source:
8
+ pip install "git+https://github.com/rajkaria/hunch.git#subdirectory=sdk/python"
9
+ # or from a local clone: pip install -e sdk/python
10
+ ```
11
+
12
+ ## $0 simulation (no wallet)
13
+
14
+ ```python
15
+ from hunch_agent import HunchAgent
16
+
17
+ hunch = HunchAgent() # defaults to https://www.playhunch.xyz
18
+ markets = hunch.markets(status="open", limit=5)
19
+ research = hunch.research(markets[0]["id"])
20
+ print(research["resolutionRules"]["description"], research["odds"])
21
+
22
+ sim = hunch.bet(
23
+ markets[0]["id"], "yes", 1,
24
+ wallet_address="0xYourWallet...", simulate=True,
25
+ )
26
+ print(sim["simulated"], sim["position"]) # True, {...}
27
+ ```
28
+
29
+ ## Real bet (x402 USDC on Base)
30
+
31
+ The client runs the whole x402 loop for you — POST, get the 402, sign the exact
32
+ USDC `transferWithAuthorization` with `eth_account`, retry with `X-PAYMENT`. The
33
+ wallet only needs USDC on Base; gas is sponsored. Winners are paid automatically —
34
+ no claim step.
35
+
36
+ ```python
37
+ from eth_account import Account
38
+ from hunch_agent import HunchAgent
39
+
40
+ account = Account.from_key("0x...") # a funded Base wallet
41
+ hunch = HunchAgent(account=account)
42
+
43
+ receipt = hunch.bet("market-id", "yes", 5) # <= $10: simple tier
44
+ print(receipt["txHash"], receipt["proofUrl"])
45
+
46
+ # > $10: lock a quote first.
47
+ q = hunch.quote("market-id", "yes", 250)
48
+ hunch.bet("market-id", "yes", 250, quote_id=q["quoteId"], min_shares_out=q["suggestedMinSharesOut"])
49
+ ```
50
+
51
+ ## Verifying webhooks
52
+
53
+ ```python
54
+ from hunch_agent import verify_webhook
55
+
56
+ result = verify_webhook(request.headers, raw_body, secret)
57
+ if result["valid"]:
58
+ handle(result["event"])
59
+ ```
60
+
61
+ The TypeScript SDK (`@hunchxyz/agent-sdk`) carries the full live-route contract
62
+ tests; this client is the Python convenience surface, tested against recorded
63
+ fixtures. Full protocol docs: <https://www.playhunch.xyz/llms-full.txt>.
@@ -0,0 +1,42 @@
1
+ """hunch_agent — Python client for the Hunch agent platform.
2
+
3
+ Keyless, no-cap, auto-payout prediction-market betting. The paying wallet IS the
4
+ account — no API keys. Winners are paid out automatically on resolution.
5
+
6
+ from hunch_agent import HunchAgent
7
+ hunch = HunchAgent()
8
+ markets = hunch.markets(status="open", limit=5)
9
+ sim = hunch.bet(markets[0]["id"], "yes", 1, wallet_address="0x...", simulate=True)
10
+
11
+ Docs: https://www.playhunch.xyz/llms.txt
12
+ """
13
+
14
+ from .client import DEFAULT_BASE_URL, HunchAgent
15
+ from .errors import HunchApiError, HunchPaymentRequiredError
16
+ from .webhooks import sign_webhook_payload, verify_webhook
17
+ from .x402 import (
18
+ BASE_CHAIN_ID,
19
+ HunchX402Error,
20
+ build_transfer_authorization,
21
+ encode_x_payment_header,
22
+ parse_x402_challenge,
23
+ random_nonce,
24
+ )
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "HunchAgent",
30
+ "DEFAULT_BASE_URL",
31
+ "HunchApiError",
32
+ "HunchPaymentRequiredError",
33
+ "verify_webhook",
34
+ "sign_webhook_payload",
35
+ "parse_x402_challenge",
36
+ "build_transfer_authorization",
37
+ "encode_x_payment_header",
38
+ "random_nonce",
39
+ "HunchX402Error",
40
+ "BASE_CHAIN_ID",
41
+ "__version__",
42
+ ]
@@ -0,0 +1,205 @@
1
+ """HunchAgent — the Python client for the Hunch agent platform.
2
+
3
+ Mirrors the TypeScript SDK: thin typed GETs that return the wire dicts, and a
4
+ ``bet()`` that runs the full x402 loop (POST → 402 → sign EIP-3009 → retry with
5
+ ``X-PAYMENT``). ``simulate=True`` needs no wallet. The TS SDK owns the
6
+ live-route contract tests; this client is the convenience surface for Python
7
+ agents and is exercised against recorded fixtures.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ from typing import Any, Dict, List, Optional, Sequence
14
+
15
+ import httpx
16
+
17
+ from .errors import HunchApiError, HunchPaymentRequiredError
18
+ from .x402 import (
19
+ build_transfer_authorization,
20
+ encode_x_payment_header,
21
+ parse_x402_challenge,
22
+ )
23
+
24
+ DEFAULT_BASE_URL = "https://www.playhunch.xyz"
25
+
26
+
27
+ def _clean(params: Dict[str, Any]) -> Dict[str, Any]:
28
+ return {k: v for k, v in params.items() if v is not None}
29
+
30
+
31
+ def _random_idem() -> str:
32
+ return "sdk-" + secrets.token_hex(16)
33
+
34
+
35
+ def _sign_typed_data(account: Any, typed_data: Dict[str, Any]) -> str:
36
+ """Sign EIP-712 typed data with an ``eth_account`` local account → ``0x``+130 hex."""
37
+ from eth_account.messages import encode_typed_data
38
+
39
+ signable = encode_typed_data(full_message=typed_data)
40
+ signed = account.sign_message(signable)
41
+ signature = signed.signature.hex()
42
+ return signature if signature.startswith("0x") else "0x" + signature
43
+
44
+
45
+ class HunchAgent:
46
+ """A keyless client. Pass ``account`` (an ``eth_account`` LocalAccount) only
47
+ for real bets; reads and simulations need nothing."""
48
+
49
+ def __init__(
50
+ self,
51
+ base_url: str = DEFAULT_BASE_URL,
52
+ account: Any = None,
53
+ client: Optional[httpx.Client] = None,
54
+ timeout: float = 30.0,
55
+ ) -> None:
56
+ self.base_url = base_url.rstrip("/")
57
+ self.account = account
58
+ self._client = client or httpx.Client(timeout=timeout)
59
+
60
+ # -- internals ---------------------------------------------------------
61
+
62
+ def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
63
+ res = self._client.get(self.base_url + path, params=params)
64
+ return self._unwrap(res)
65
+
66
+ def _unwrap(self, res: httpx.Response) -> Any:
67
+ body: Any = res.json() if res.content else {}
68
+ if res.status_code >= 400:
69
+ raise HunchApiError(res.status_code, body if isinstance(body, dict) else {})
70
+ return body
71
+
72
+ # -- read surface ------------------------------------------------------
73
+
74
+ def markets(
75
+ self,
76
+ status: Optional[str] = None,
77
+ type: Optional[str] = None,
78
+ token: Optional[str] = None,
79
+ ids: Optional[Sequence[str]] = None,
80
+ limit: Optional[int] = None,
81
+ ) -> List[Dict[str, Any]]:
82
+ params = _clean(
83
+ {
84
+ "status": status,
85
+ "type": type,
86
+ "token": token,
87
+ "ids": ",".join(ids) if ids else None,
88
+ "limit": limit,
89
+ }
90
+ )
91
+ return self._get("/api/agent/v1/markets", params).get("markets", [])
92
+
93
+ def discover(
94
+ self, q: Optional[str] = None, post: Optional[str] = None, limit: Optional[int] = None
95
+ ) -> List[Dict[str, Any]]:
96
+ params = _clean({"q": q, "post": post, "limit": limit})
97
+ return self._get("/api/agent/v1/discover", params).get("matches", [])
98
+
99
+ def market(self, market_id: str) -> Dict[str, Any]:
100
+ return self._get(f"/api/agent/v1/markets/{market_id}").get("market")
101
+
102
+ def research(self, market_id: str) -> Dict[str, Any]:
103
+ return self._get(f"/api/agent/v1/markets/{market_id}/research").get("research")
104
+
105
+ def quote(
106
+ self, market_id: str, side: str, size_usd: float, wallet: Optional[str] = None
107
+ ) -> Dict[str, Any]:
108
+ params = _clean(
109
+ {"marketId": market_id, "side": side, "sizeUsd": size_usd, "wallet": wallet}
110
+ )
111
+ return self._get("/api/agent/v1/quote", params).get("quote")
112
+
113
+ def positions(self, wallet: str) -> Dict[str, Any]:
114
+ return self._get("/api/agent/v1/positions", {"wallet": wallet})
115
+
116
+ def result(self, market_id: str) -> Dict[str, Any]:
117
+ return self._get("/api/agent/v1/result", {"marketId": market_id}).get("result")
118
+
119
+ def proof(self, trade_id: str) -> Dict[str, Any]:
120
+ body = self._get(f"/api/agent/v1/proof/{trade_id}")
121
+ body.pop("meta", None)
122
+ return body
123
+
124
+ def readiness(self, address: str) -> Dict[str, Any]:
125
+ return self._get(f"/api/agent/v1/wallet/{address}/readiness").get("readiness")
126
+
127
+ def stats(self) -> Dict[str, Any]:
128
+ return self._get("/api/agent/v1/stats").get("stats")
129
+
130
+ def health(self) -> Dict[str, Any]:
131
+ return self._get("/api/agent/v1/health").get("health")
132
+
133
+ def poll(self, wallet: str, since: int = 0, limit: Optional[int] = None) -> Dict[str, Any]:
134
+ body = self._get(
135
+ "/api/agent/v1/resolved", _clean({"wallet": wallet, "since": since, "limit": limit})
136
+ )
137
+ return {
138
+ "events": body.get("events", []),
139
+ "nextSince": body.get("nextSince", since),
140
+ "count": body.get("count", 0),
141
+ }
142
+
143
+ # -- money path --------------------------------------------------------
144
+
145
+ def bet(
146
+ self,
147
+ market_id: str,
148
+ side: str,
149
+ size_usd: float,
150
+ *,
151
+ idem_key: Optional[str] = None,
152
+ wallet_address: Optional[str] = None,
153
+ quote_id: Optional[str] = None,
154
+ min_shares_out: Optional[float] = None,
155
+ max_price_cents: Optional[int] = None,
156
+ simulate: bool = False,
157
+ builder_code: Optional[str] = None,
158
+ ref: Optional[str] = None,
159
+ ) -> Dict[str, Any]:
160
+ """Place a bet. ``simulate=True`` is a $0 dry run (no account needed); a real
161
+ bet runs the x402 loop automatically. Raises ``HunchPaymentRequiredError`` if a
162
+ real bet needs payment but no account is set, or ``HunchApiError`` on a 4xx/5xx."""
163
+ wallet = wallet_address or (self.account.address if self.account else None)
164
+ if not wallet:
165
+ raise ValueError("bet() needs wallet_address or a configured account.")
166
+
167
+ body = _clean(
168
+ {
169
+ "marketId": market_id,
170
+ "side": side,
171
+ "sizeUsd": size_usd,
172
+ "idemKey": idem_key or _random_idem(),
173
+ "walletAddress": wallet,
174
+ "quoteId": quote_id,
175
+ "minSharesOut": min_shares_out,
176
+ "maxPriceCents": max_price_cents,
177
+ "simulate": True if simulate else None,
178
+ "builderCode": builder_code,
179
+ "ref": ref,
180
+ }
181
+ )
182
+ url = self.base_url + "/api/agent/v1/trade"
183
+ res = self._client.post(url, json=body)
184
+
185
+ if res.status_code == 402:
186
+ challenge = res.json()
187
+ if not self.account:
188
+ raise HunchPaymentRequiredError(challenge)
189
+ requirements = parse_x402_challenge(challenge)
190
+ typed_data, authorization = build_transfer_authorization(wallet, requirements)
191
+ signature = _sign_typed_data(self.account, typed_data)
192
+ header = encode_x_payment_header({**authorization, "signature": signature})
193
+ res = self._client.post(url, json=body, headers={"X-PAYMENT": header})
194
+
195
+ out = self._unwrap(res)
196
+ return out.get("receipt")
197
+
198
+ def close(self) -> None:
199
+ self._client.close()
200
+
201
+ def __enter__(self) -> "HunchAgent":
202
+ return self
203
+
204
+ def __exit__(self, *_exc: Any) -> None:
205
+ self.close()
@@ -0,0 +1,42 @@
1
+ """Typed errors for the Hunch agent client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+
8
+ class HunchApiError(Exception):
9
+ """Any non-2xx (and non-402-handled) Hunch API response.
10
+
11
+ Carries the errors-as-documentation body: ``error`` (a stable machine code),
12
+ ``message``, ``hint``, ``docsUrl``, ``retriable``, plus machine-actionable
13
+ fields like ``requiredUsd``.
14
+ """
15
+
16
+ def __init__(self, status: int, body: Dict[str, Any]) -> None:
17
+ self.status = status
18
+ self.body = body or {}
19
+ super().__init__(
20
+ self.body.get("message")
21
+ or self.body.get("error")
22
+ or f"Hunch API error {status}"
23
+ )
24
+
25
+ @property
26
+ def code(self) -> str:
27
+ return self.body.get("error", "unknown_error")
28
+
29
+ @property
30
+ def retriable(self) -> bool:
31
+ return self.body.get("retriable") is True
32
+
33
+
34
+ class HunchPaymentRequiredError(Exception):
35
+ """A real bet hit a 402 but no signing ``account`` was configured to pay."""
36
+
37
+ def __init__(self, challenge: Dict[str, Any]) -> None:
38
+ self.challenge = challenge
39
+ super().__init__(
40
+ "This bet requires an x402 USDC payment, but no signing `account` was "
41
+ "configured. Pass `account=` to HunchAgent, or use simulate=True."
42
+ )
@@ -0,0 +1,63 @@
1
+ """Webhook signature verification — the receiving half of Hunch's signed events.
2
+
3
+ The recipe is byte-identical to the server's ``signEvent`` (src/agent/events.ts)
4
+ and the TS SDK: ``hex(hmacSHA256(secret, eventId + "." + timestamp + "." + body))``,
5
+ sent as ``Hunch-Signature: v1=<hex>`` alongside ``Hunch-Event-Id`` /
6
+ ``Hunch-Timestamp``. Verify over the RAW request body.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import re
15
+ from typing import Any, Dict, Optional
16
+
17
+
18
+ def _read_header(headers: Any, name: str) -> Optional[str]:
19
+ # Works for dict-likes and frameworks' case-insensitive header maps.
20
+ if hasattr(headers, "get"):
21
+ value = headers.get(name)
22
+ if value is None:
23
+ value = headers.get(name.lower())
24
+ if value is not None:
25
+ return value if isinstance(value, str) else value[0]
26
+ try:
27
+ items = headers.items()
28
+ except AttributeError:
29
+ items = dict(headers).items()
30
+ lower = name.lower()
31
+ for key, value in items:
32
+ if str(key).lower() == lower:
33
+ return value if isinstance(value, str) else value[0]
34
+ return None
35
+
36
+
37
+ def sign_webhook_payload(secret: str, event_id: str, timestamp: str, body: str) -> str:
38
+ """The bare ``hex(hmacSHA256(secret, eventId + "." + timestamp + "." + body))``."""
39
+ message = f"{event_id}.{timestamp}.{body}".encode("utf-8")
40
+ return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
41
+
42
+
43
+ def verify_webhook(headers: Any, body: str, secret: str) -> Dict[str, Any]:
44
+ """Verify a Hunch webhook delivery. Returns ``{valid, event, reason}``; never raises."""
45
+ event_id = _read_header(headers, "Hunch-Event-Id")
46
+ timestamp = _read_header(headers, "Hunch-Timestamp")
47
+ signature = _read_header(headers, "Hunch-Signature")
48
+ if not event_id or not timestamp or not signature:
49
+ return {"valid": False, "event": None, "reason": "missing_headers"}
50
+
51
+ match = re.match(r"^v1=([0-9a-f]+)$", signature.strip())
52
+ if not match:
53
+ return {"valid": False, "event": None, "reason": "bad_signature_format"}
54
+
55
+ expected = sign_webhook_payload(secret, event_id, timestamp, body)
56
+ if not hmac.compare_digest(match.group(1), expected):
57
+ return {"valid": False, "event": None, "reason": "signature_mismatch"}
58
+
59
+ try:
60
+ event = json.loads(body)
61
+ except (ValueError, TypeError):
62
+ event = None
63
+ return {"valid": True, "event": event, "reason": "ok"}
@@ -0,0 +1,127 @@
1
+ """Client-side x402 codec — the payment half of the Hunch agent rail.
2
+
3
+ Mirrors the TS SDK (`packages/hunch-agent-sdk/src/x402.ts`) and the server codec
4
+ (`src/lib/x402.ts`): parse a 402 challenge, build the EIP-3009
5
+ ``transferWithAuthorization`` typed data, and encode the base64 ``X-PAYMENT``
6
+ header the trade route decodes. Pure — signing is done by the caller's
7
+ ``eth_account`` account.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ import secrets
15
+ import time
16
+ from typing import Any, Dict, Optional, Tuple
17
+
18
+ #: Base mainnet chain id — the only settlement chain on the agent rail.
19
+ BASE_CHAIN_ID = 8453
20
+
21
+ _DEFAULT_VALID_SECONDS = 10 * 60
22
+ _CLOCK_SKEW_SECONDS = 60
23
+
24
+ #: EIP-712 type for USDC's EIP-3009 ``transferWithAuthorization``.
25
+ TRANSFER_WITH_AUTHORIZATION_TYPES = {
26
+ "TransferWithAuthorization": [
27
+ {"name": "from", "type": "address"},
28
+ {"name": "to", "type": "address"},
29
+ {"name": "value", "type": "uint256"},
30
+ {"name": "validAfter", "type": "uint256"},
31
+ {"name": "validBefore", "type": "uint256"},
32
+ {"name": "nonce", "type": "bytes32"},
33
+ ],
34
+ }
35
+
36
+
37
+ class HunchX402Error(Exception):
38
+ """Raised when a 402 challenge is missing or not the Base USDC exact scheme."""
39
+
40
+
41
+ def parse_x402_challenge(body: Dict[str, Any]) -> Dict[str, Any]:
42
+ """Return the single usable payment requirement from a 402 challenge body."""
43
+ accepts = (body or {}).get("accepts")
44
+ if not isinstance(accepts, list) or not accepts:
45
+ raise HunchX402Error("402 challenge did not advertise a payment requirement.")
46
+ first = accepts[0]
47
+ if first.get("scheme") != "exact":
48
+ raise HunchX402Error(f"Unsupported x402 scheme: {first.get('scheme')}")
49
+ if first.get("network") != "base":
50
+ raise HunchX402Error(f"Unsupported x402 network: {first.get('network')}")
51
+ if not first.get("payTo") or not first.get("asset") or not first.get("extra"):
52
+ raise HunchX402Error("402 challenge is missing payTo / asset / domain extra.")
53
+ return first
54
+
55
+
56
+ def random_nonce() -> str:
57
+ """A fresh, single-use 32-byte EIP-3009 nonce (``0x`` + 64 hex)."""
58
+ return "0x" + secrets.token_hex(32)
59
+
60
+
61
+ def build_transfer_authorization(
62
+ from_addr: str,
63
+ requirements: Dict[str, Any],
64
+ now: Optional[int] = None,
65
+ nonce: Optional[str] = None,
66
+ valid_seconds: int = _DEFAULT_VALID_SECONDS,
67
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
68
+ """Build ``(typed_data, authorization)`` from a parsed challenge requirement."""
69
+ now = int(now if now is not None else time.time())
70
+ valid_after = max(0, now - _CLOCK_SKEW_SECONDS)
71
+ valid_before = now + valid_seconds
72
+ nonce = nonce or random_nonce()
73
+ to = requirements["payTo"]
74
+ value = requirements["maxAmountRequired"]
75
+
76
+ authorization = {
77
+ "from": from_addr,
78
+ "to": to,
79
+ "value": value,
80
+ "validAfter": valid_after,
81
+ "validBefore": valid_before,
82
+ "nonce": nonce,
83
+ }
84
+ typed_data = {
85
+ "types": {
86
+ "EIP712Domain": [
87
+ {"name": "name", "type": "string"},
88
+ {"name": "version", "type": "string"},
89
+ {"name": "chainId", "type": "uint256"},
90
+ {"name": "verifyingContract", "type": "address"},
91
+ ],
92
+ **TRANSFER_WITH_AUTHORIZATION_TYPES,
93
+ },
94
+ "domain": {
95
+ "name": requirements["extra"]["name"],
96
+ "version": requirements["extra"]["version"],
97
+ "chainId": BASE_CHAIN_ID,
98
+ "verifyingContract": requirements["asset"],
99
+ },
100
+ "primaryType": "TransferWithAuthorization",
101
+ "message": {
102
+ "from": from_addr,
103
+ "to": to,
104
+ "value": int(value),
105
+ "validAfter": valid_after,
106
+ "validBefore": valid_before,
107
+ "nonce": nonce,
108
+ },
109
+ }
110
+ return typed_data, authorization
111
+
112
+
113
+ def encode_x_payment_header(signed_authorization: Dict[str, Any]) -> str:
114
+ """Encode a signed authorization into the base64 ``X-PAYMENT`` header value."""
115
+ payload = {
116
+ "x402Version": 1,
117
+ "scheme": "exact",
118
+ "network": "base",
119
+ "payload": {
120
+ "signature": signed_authorization["signature"],
121
+ "authorization": {
122
+ k: signed_authorization[k]
123
+ for k in ("from", "to", "value", "validAfter", "validBefore", "nonce")
124
+ },
125
+ },
126
+ }
127
+ return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hunch-agent"
7
+ version = "0.1.0"
8
+ description = "Python client for the Hunch agent platform — keyless, no-cap x402 prediction-market betting on Base."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["hunch", "prediction-markets", "x402", "agents", "base", "usdc"]
13
+ dependencies = [
14
+ "httpx>=0.24",
15
+ "eth-account>=0.10",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://www.playhunch.xyz/agents"
20
+ Documentation = "https://www.playhunch.xyz/llms-full.txt"
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest>=7"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["hunch_agent"]
@@ -0,0 +1,52 @@
1
+ """Webhook verifier — recorded-fixture tests (no network).
2
+
3
+ The signing recipe matches the server (`signEvent` in src/agent/events.ts) and
4
+ the TS SDK exactly, so a self-consistent sign→verify round trip proves parity.
5
+ """
6
+
7
+ import json
8
+
9
+ from hunch_agent.webhooks import sign_webhook_payload, verify_webhook
10
+
11
+ SECRET = "whsec_test_secret_0123456789"
12
+ EVENT_ID = "ev_deadbeef"
13
+ TS = "2026-06-13T00:00:00.000Z"
14
+
15
+
16
+ def _delivery(body=None, sig=None):
17
+ body = body if body is not None else json.dumps(
18
+ {"eventId": EVENT_ID, "seq": 42, "type": "market.resolved", "payload": {"resolvedSide": "no"}}
19
+ )
20
+ sig = sig if sig is not None else "v1=" + sign_webhook_payload(SECRET, EVENT_ID, TS, body)
21
+ headers = {
22
+ "Hunch-Event-Id": EVENT_ID,
23
+ "Hunch-Timestamp": TS,
24
+ "Hunch-Signature": sig,
25
+ }
26
+ return headers, body
27
+
28
+
29
+ def test_accepts_a_correctly_signed_delivery():
30
+ headers, body = _delivery()
31
+ result = verify_webhook(headers, body, SECRET)
32
+ assert result["valid"] is True
33
+ assert result["event"]["type"] == "market.resolved"
34
+
35
+
36
+ def test_rejects_tampered_body():
37
+ headers, body = _delivery()
38
+ tampered = body.replace('"no"', '"yes"')
39
+ result = verify_webhook(headers, tampered, SECRET)
40
+ assert result["valid"] is False
41
+ assert result["reason"] == "signature_mismatch"
42
+
43
+
44
+ def test_rejects_wrong_secret_and_missing_headers():
45
+ headers, body = _delivery()
46
+ assert verify_webhook(headers, body, "whsec_wrong")["valid"] is False
47
+ assert verify_webhook({}, body, SECRET)["reason"] == "missing_headers"
48
+
49
+
50
+ def test_rejects_malformed_signature_header():
51
+ headers, body = _delivery(sig="garbage")
52
+ assert verify_webhook(headers, body, SECRET)["reason"] == "bad_signature_format"
@@ -0,0 +1,84 @@
1
+ """x402 codec — recorded-fixture tests (no network)."""
2
+
3
+ import base64
4
+ import json
5
+
6
+ from hunch_agent.x402 import (
7
+ BASE_CHAIN_ID,
8
+ HunchX402Error,
9
+ build_transfer_authorization,
10
+ encode_x_payment_header,
11
+ parse_x402_challenge,
12
+ random_nonce,
13
+ )
14
+
15
+ SINK = "0x1111111111111111111111111111111111111111"
16
+ WALLET = "0x2222222222222222222222222222222222222222"
17
+ USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
18
+
19
+
20
+ def _challenge(amount_atomic):
21
+ return {
22
+ "x402Version": 1,
23
+ "error": None,
24
+ "accepts": [
25
+ {
26
+ "scheme": "exact",
27
+ "network": "base",
28
+ "maxAmountRequired": amount_atomic,
29
+ "resource": "https://www.playhunch.xyz/api/agent/v1/trade#0xabc",
30
+ "description": "t",
31
+ "mimeType": "application/json",
32
+ "payTo": SINK,
33
+ "maxTimeoutSeconds": 120,
34
+ "asset": USDC,
35
+ "extra": {"name": "USD Coin", "version": "2"},
36
+ }
37
+ ],
38
+ }
39
+
40
+
41
+ def test_parse_extracts_uncapped_requirement():
42
+ req = parse_x402_challenge(_challenge("5000000000"))
43
+ assert req["maxAmountRequired"] == "5000000000"
44
+ assert req["payTo"] == SINK
45
+
46
+
47
+ def test_parse_rejects_bad_scheme_and_empty():
48
+ for bad in ({"accepts": []}, {"accepts": [{"scheme": "x", "network": "base"}]}):
49
+ try:
50
+ parse_x402_challenge(bad)
51
+ assert False, "expected HunchX402Error"
52
+ except HunchX402Error:
53
+ pass
54
+
55
+
56
+ def test_build_typed_data_domain_and_message():
57
+ req = parse_x402_challenge(_challenge("3000000"))
58
+ typed, auth = build_transfer_authorization(
59
+ WALLET, req, now=1000, nonce="0x" + "a" * 64
60
+ )
61
+ assert typed["domain"]["chainId"] == BASE_CHAIN_ID
62
+ assert typed["domain"]["name"] == "USD Coin"
63
+ assert typed["primaryType"] == "TransferWithAuthorization"
64
+ assert typed["message"]["value"] == 3000000
65
+ assert auth["validAfter"] == 940 # now - 60
66
+ assert auth["validBefore"] == 1600 # now + 600
67
+
68
+
69
+ def test_encode_header_round_trips():
70
+ req = parse_x402_challenge(_challenge("3000000"))
71
+ _typed, auth = build_transfer_authorization(WALLET, req, now=1000, nonce="0x" + "a" * 64)
72
+ header = encode_x_payment_header({**auth, "signature": "0x" + "b" * 130})
73
+ decoded = json.loads(base64.b64decode(header))
74
+ assert decoded["scheme"] == "exact"
75
+ assert decoded["network"] == "base"
76
+ assert decoded["payload"]["authorization"]["value"] == "3000000"
77
+ assert decoded["payload"]["authorization"]["to"] == SINK
78
+ assert decoded["payload"]["signature"] == "0x" + "b" * 130
79
+
80
+
81
+ def test_random_nonce_is_fresh_32_bytes():
82
+ a, b = random_nonce(), random_nonce()
83
+ assert a != b
84
+ assert len(a) == 66 and a.startswith("0x")