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.
- hunch_agent-0.1.0/.gitignore +98 -0
- hunch_agent-0.1.0/PKG-INFO +78 -0
- hunch_agent-0.1.0/README.md +63 -0
- hunch_agent-0.1.0/hunch_agent/__init__.py +42 -0
- hunch_agent-0.1.0/hunch_agent/client.py +205 -0
- hunch_agent-0.1.0/hunch_agent/errors.py +42 -0
- hunch_agent-0.1.0/hunch_agent/webhooks.py +63 -0
- hunch_agent-0.1.0/hunch_agent/x402.py +127 -0
- hunch_agent-0.1.0/pyproject.toml +26 -0
- hunch_agent-0.1.0/tests/test_webhooks.py +52 -0
- hunch_agent-0.1.0/tests/test_x402.py +84 -0
|
@@ -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")
|