railhead 0.4.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.
- railhead/__init__.py +41 -0
- railhead/_abis.py +89 -0
- railhead/_chain.py +196 -0
- railhead/agent.py +328 -0
- railhead/buyer.py +277 -0
- railhead/cli.py +923 -0
- railhead/client.py +530 -0
- railhead/escrow.py +627 -0
- railhead/escrow_abi/AgentRegistryV2.abi.json +2442 -0
- railhead/escrow_abi/Gate3AcceptanceModuleV2.abi.json +363 -0
- railhead/escrow_abi/Gate3AdjudicationModuleV2.abi.json +310 -0
- railhead/escrow_abi/Gate3ModuleV2.abi.json +1168 -0
- railhead/escrow_abi/Gate3TypedDataModuleV2.abi.json +307 -0
- railhead/escrow_abi/JobMarketV2.abi.json +1783 -0
- railhead/escrow_abi/RoleLedgerV2.abi.json +2831 -0
- railhead/escrow_abi/abi-sha256.txt +7 -0
- railhead/escrow_tx.py +929 -0
- railhead/escrow_workflow.py +209 -0
- railhead/pins.py +41 -0
- railhead/types.py +104 -0
- railhead/x402.py +333 -0
- railhead/x402_serve.py +213 -0
- railhead-0.4.0.dist-info/METADATA +98 -0
- railhead-0.4.0.dist-info/RECORD +27 -0
- railhead-0.4.0.dist-info/WHEEL +5 -0
- railhead-0.4.0.dist-info/entry_points.txt +2 -0
- railhead-0.4.0.dist-info/top_level.txt +1 -0
railhead/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Railhead Python SDK
|
|
3
|
+
===================
|
|
4
|
+
The agentic marketplace — discover, contract, and pay for AI capabilities on-chain.
|
|
5
|
+
|
|
6
|
+
Quick start (buyer — x402, live on Base Sepolia)::
|
|
7
|
+
|
|
8
|
+
from railhead import Railhead
|
|
9
|
+
rh = Railhead() # api.railhead.network · wallet + policy in ~/.railhead
|
|
10
|
+
rh.recommend("compute") # ranked providers, proven track records first
|
|
11
|
+
receipt = rh.hire("compute", {"a": 40, "b": 2, "op": "add"}) # pays over x402 within your spend policy
|
|
12
|
+
print(receipt["result"], receipt["explorer"])
|
|
13
|
+
|
|
14
|
+
Quick start (legacy escrow-v1 client)::
|
|
15
|
+
|
|
16
|
+
from railhead import RailheadClient
|
|
17
|
+
|
|
18
|
+
client = RailheadClient.from_api("https://api.railhead.network", private_key="0x...")
|
|
19
|
+
result = client.post_job("price_signal", payment_usdc=5).wait()
|
|
20
|
+
print(result.note)
|
|
21
|
+
|
|
22
|
+
Quick start (agent)::
|
|
23
|
+
|
|
24
|
+
from railhead import RailheadAgent
|
|
25
|
+
|
|
26
|
+
agent = RailheadAgent.from_api("https://api.railhead.network", private_key="0x...")
|
|
27
|
+
|
|
28
|
+
@agent.on("my_capability")
|
|
29
|
+
def handle(job):
|
|
30
|
+
return {"answer": 42}
|
|
31
|
+
|
|
32
|
+
agent.run()
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from .client import RailheadClient
|
|
36
|
+
from .agent import RailheadAgent
|
|
37
|
+
from .types import Capability, Agent, Job, Result
|
|
38
|
+
from .buyer import Railhead, PolicyRefused
|
|
39
|
+
|
|
40
|
+
__version__ = "0.4.0"
|
|
41
|
+
__all__ = ["Railhead", "PolicyRefused", "RailheadClient", "RailheadAgent", "Capability", "Agent", "Job", "Result"]
|
railhead/_abis.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Minimal ABIs — only the functions the SDK calls.
|
|
2
|
+
# Keeping these inline means the SDK has no dependency on forge build output.
|
|
3
|
+
#
|
|
4
|
+
# 2026-07-12 (Phase-A): resynced to the frozen optimistic-acceptance contracts.
|
|
5
|
+
# • JobMarket.jobs now has 16 fields — submittedAt/disputedAt/disputedPreResult were
|
|
6
|
+
# added, so `status` is index 13 (was 10). New enum: Open,Accepted,Submitted,Complete,
|
|
7
|
+
# Disputed,Cancelled,Expired.
|
|
8
|
+
# • AgentRegistry.getAgent tuple regenerated (completedJobs/disputedJobs precede status;
|
|
9
|
+
# unstakeRequestedAt/unstakeUnlocksAt/metadataHash added).
|
|
10
|
+
# • Added approveResult / claimPayout / disputeJob (optimistic finality + client dispute).
|
|
11
|
+
# • The token is USDC (6-decimal) — this ERC20 ABI is unchanged, only the amounts' decimals.
|
|
12
|
+
# Regenerated from `forge inspect <Contract> abi`; verify against a fresh build after any edit.
|
|
13
|
+
|
|
14
|
+
USDC_ABI = [
|
|
15
|
+
{"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
|
16
|
+
{"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},
|
|
17
|
+
{"inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},
|
|
18
|
+
]
|
|
19
|
+
# Back-compat alias — the token handle used to be named RAIL; it is USDC now.
|
|
20
|
+
RAIL_ABI = USDC_ABI
|
|
21
|
+
|
|
22
|
+
REGISTRY_ABI = [
|
|
23
|
+
{"inputs":[{"name":"capabilities","type":"string[]"},{"name":"pricePerCall","type":"uint256"},{"name":"stake","type":"uint256"},{"name":"endpoint","type":"string"},{"name":"metadataHash","type":"bytes32"}],"name":"register","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
24
|
+
{"inputs":[{"name":"addr","type":"address"}],"name":"getAgent","outputs":[{"name":"","type":"tuple","components":[
|
|
25
|
+
{"name":"addr","type":"address"},
|
|
26
|
+
{"name":"capabilities","type":"string[]"},
|
|
27
|
+
{"name":"pricePerCall","type":"uint256"},
|
|
28
|
+
{"name":"stake","type":"uint256"},
|
|
29
|
+
{"name":"completedJobs","type":"uint256"},
|
|
30
|
+
{"name":"disputedJobs","type":"uint256"},
|
|
31
|
+
{"name":"status","type":"uint8"},
|
|
32
|
+
{"name":"registeredAt","type":"uint256"},
|
|
33
|
+
{"name":"unstakeRequestedAt","type":"uint256"},
|
|
34
|
+
{"name":"unstakeUnlocksAt","type":"uint256"},
|
|
35
|
+
{"name":"endpoint","type":"string"},
|
|
36
|
+
{"name":"metadataHash","type":"bytes32"}
|
|
37
|
+
]}],"stateMutability":"view","type":"function"},
|
|
38
|
+
{"inputs":[],"name":"getActiveAgents","outputs":[{"name":"","type":"address[]"}],"stateMutability":"view","type":"function"},
|
|
39
|
+
{"inputs":[],"name":"minStake","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
|
40
|
+
{"inputs":[{"name":"addr","type":"address"},{"name":"authorized","type":"bool"}],"name":"setAgentWhitelist","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
JOB_MARKET_ABI = [
|
|
44
|
+
{"inputs":[{"name":"agent","type":"address"},{"name":"capability","type":"string"},{"name":"inputHash","type":"bytes32"},{"name":"payment","type":"uint256"},{"name":"deadline","type":"uint256"}],"name":"createJob","outputs":[{"name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},
|
|
45
|
+
{"inputs":[{"name":"jobId","type":"uint256"}],"name":"acceptJob","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
46
|
+
# Optimistic-finality settlement paths (mediator-free):
|
|
47
|
+
{"inputs":[{"name":"jobId","type":"uint256"}],"name":"approveResult","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
48
|
+
{"inputs":[{"name":"jobId","type":"uint256"}],"name":"claimPayout","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
49
|
+
# Client dispute (must land within CHALLENGE_WINDOW of submission):
|
|
50
|
+
{"inputs":[{"name":"jobId","type":"uint256"},{"name":"note","type":"string"}],"name":"disputeJob","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
51
|
+
{"inputs":[{"name":"","type":"uint256"}],"name":"jobs","outputs":[
|
|
52
|
+
{"name":"id","type":"uint256"},
|
|
53
|
+
{"name":"client","type":"address"},
|
|
54
|
+
{"name":"agent","type":"address"},
|
|
55
|
+
{"name":"capability","type":"string"},
|
|
56
|
+
{"name":"inputHash","type":"bytes32"},
|
|
57
|
+
{"name":"payment","type":"uint256"},
|
|
58
|
+
{"name":"fee","type":"uint256"},
|
|
59
|
+
{"name":"createdAt","type":"uint256"},
|
|
60
|
+
{"name":"acceptedAt","type":"uint256"},
|
|
61
|
+
{"name":"deadline","type":"uint256"},
|
|
62
|
+
{"name":"submittedAt","type":"uint256"},
|
|
63
|
+
{"name":"disputedAt","type":"uint256"},
|
|
64
|
+
{"name":"disputedPreResult","type":"bool"},
|
|
65
|
+
{"name":"status","type":"uint8"},
|
|
66
|
+
{"name":"resultHash","type":"bytes32"},
|
|
67
|
+
{"name":"disputeNote","type":"string"}
|
|
68
|
+
],"stateMutability":"view","type":"function"},
|
|
69
|
+
{"inputs":[],"name":"CHALLENGE_WINDOW","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
|
70
|
+
{"anonymous":False,"inputs":[{"indexed":True,"name":"jobId","type":"uint256"},{"indexed":True,"name":"client","type":"address"},{"indexed":True,"name":"agent","type":"address"},{"indexed":False,"name":"capability","type":"string"},{"indexed":False,"name":"payment","type":"uint256"}],"name":"JobCreated","type":"event"},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
RESULT_STORE_ABI = [
|
|
74
|
+
{"inputs":[{"name":"jobId","type":"uint256"},{"name":"resultHash","type":"bytes32"},{"name":"schemaHash","type":"bytes32"}],"name":"submitResult","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
75
|
+
{"inputs":[{"name":"jobId","type":"uint256"},{"name":"note","type":"string"}],"name":"validate","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
76
|
+
{"inputs":[{"name":"jobId","type":"uint256"},{"name":"evidenceHash","type":"bytes32"},{"name":"note","type":"string"}],"name":"dispute","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
77
|
+
{"inputs":[{"name":"","type":"uint256"}],"name":"results","outputs":[
|
|
78
|
+
{"name":"jobId","type":"uint256"},
|
|
79
|
+
{"name":"agent","type":"address"},
|
|
80
|
+
{"name":"resultHash","type":"bytes32"},
|
|
81
|
+
{"name":"schemaHash","type":"bytes32"},
|
|
82
|
+
{"name":"evidenceHash","type":"bytes32"},
|
|
83
|
+
{"name":"submittedAt","type":"uint256"},
|
|
84
|
+
{"name":"validatedAt","type":"uint256"},
|
|
85
|
+
{"name":"status","type":"uint8"},
|
|
86
|
+
{"name":"mediator","type":"address"},
|
|
87
|
+
{"name":"note","type":"string"}
|
|
88
|
+
],"stateMutability":"view","type":"function"},
|
|
89
|
+
]
|
railhead/_chain.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Internal: web3 connection, contract instances, tx helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json, time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from web3 import Web3
|
|
8
|
+
from eth_account import Account
|
|
9
|
+
from eth_account.messages import encode_defunct
|
|
10
|
+
|
|
11
|
+
from ._abis import RAIL_ABI, REGISTRY_ABI, JOB_MARKET_ABI, RESULT_STORE_ABI
|
|
12
|
+
|
|
13
|
+
# USDC is 6-decimal (NOT 18 like the old RAIL token). Use these for every TOKEN amount.
|
|
14
|
+
# ETH/gas amounts stay on w3.to_wei/from_wei(…, "ether") — those are real 18-decimal ETH.
|
|
15
|
+
USDC_DEC = 6
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_usdc(x: float) -> int:
|
|
19
|
+
"""Human USDC → base units (6dp). e.g. 25 → 25_000_000."""
|
|
20
|
+
return int(round(x * 10 ** USDC_DEC))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def from_usdc(x: int) -> float:
|
|
24
|
+
"""Base units (6dp) → human USDC. e.g. 25_000_000 → 25.0."""
|
|
25
|
+
return x / 10 ** USDC_DEC
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def relay_auth(private_key: str, job_id: int, valid_secs: int = 120) -> str:
|
|
29
|
+
"""Sign a short-lived, job-scoped relay-access token ``'<validBefore>.<sig>'``. The signer must be
|
|
30
|
+
the job's client (reading results / posting inputs) or agent (reading inputs / posting results) for
|
|
31
|
+
the discovery API to serve the payload — so a stranger can't enumerate and vacuum everyone's data."""
|
|
32
|
+
valid_before = int(time.time()) + valid_secs
|
|
33
|
+
sig = Account.sign_message(
|
|
34
|
+
encode_defunct(text=f"railhead-relay|{job_id}|{valid_before}"), private_key).signature
|
|
35
|
+
return f"{valid_before}.{Web3.to_hex(sig)}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _relay_post(url: str, payload: dict, *, retry_on=(404, 409), attempts: int = 6,
|
|
39
|
+
delay: float = 2.0, label: str = "relay", log=None, auth: str = None) -> bool:
|
|
40
|
+
"""POST to a data-plane relay endpoint that validates the payload against on-chain state.
|
|
41
|
+
|
|
42
|
+
Public / load-balanced RPCs lag between a write tx and the API's confirming read, so a
|
|
43
|
+
just-created job (404 "job not found") or a just-submitted result (409 "not submitted
|
|
44
|
+
yet") can be invisible to the API for a few seconds. Retry with backoff until the chain
|
|
45
|
+
state propagates. A non-lag error (e.g. 400 hash-mismatch) is terminal — stop early.
|
|
46
|
+
``auth`` is the job-scoped X-Relay-Auth token from ``relay_auth``. Returns True on a 2xx."""
|
|
47
|
+
headers = {"X-Relay-Auth": auth} if auth else None
|
|
48
|
+
last = None
|
|
49
|
+
for i in range(attempts):
|
|
50
|
+
try:
|
|
51
|
+
resp = requests.post(url, json=payload, timeout=10, headers=headers)
|
|
52
|
+
if resp.ok:
|
|
53
|
+
return True
|
|
54
|
+
last = f"HTTP {resp.status_code}: {resp.text[:160]}"
|
|
55
|
+
if resp.status_code not in retry_on:
|
|
56
|
+
break
|
|
57
|
+
except Exception as e:
|
|
58
|
+
last = str(e)
|
|
59
|
+
if i < attempts - 1:
|
|
60
|
+
time.sleep(delay)
|
|
61
|
+
if log is not None:
|
|
62
|
+
log.warning("%s relay failed after %d attempts: %s", label, attempts, last)
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _relay_get_json(url: str, key: str, *, attempts: int = 6, delay: float = 2.0, auth: str = None) -> dict:
|
|
67
|
+
"""GET a data-plane payload that may not be relayed yet (404 until the counterparty's POST
|
|
68
|
+
lands / its on-chain write propagates). Retry on 404; return the ``key`` field of the JSON,
|
|
69
|
+
or {} if it never became available. ``auth`` is the X-Relay-Auth token from ``relay_auth``."""
|
|
70
|
+
headers = {"X-Relay-Auth": auth} if auth else None
|
|
71
|
+
for i in range(attempts):
|
|
72
|
+
try:
|
|
73
|
+
r = requests.get(url, timeout=8, headers=headers)
|
|
74
|
+
if r.ok:
|
|
75
|
+
return r.json().get(key, {}) or {}
|
|
76
|
+
if r.status_code != 404:
|
|
77
|
+
break
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
if i < attempts - 1:
|
|
81
|
+
time.sleep(delay)
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fetch_abis(api_url: str) -> dict:
|
|
86
|
+
"""Fetch compiled ABIs from the Railhead discovery API. Falls back to minimal ABIs."""
|
|
87
|
+
try:
|
|
88
|
+
r = requests.get(f"{api_url.rstrip('/')}/abis", timeout=8)
|
|
89
|
+
if r.ok:
|
|
90
|
+
return r.json()
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Chain:
|
|
97
|
+
"""Thin wrapper around a web3 connection + all four Railhead contracts."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, rpc: str, private_key: str,
|
|
100
|
+
rail: str, registry: str, job_market: str, result_store: str):
|
|
101
|
+
self.w3 = Web3(Web3.HTTPProvider(rpc))
|
|
102
|
+
if not self.w3.is_connected():
|
|
103
|
+
raise ConnectionError(f"Cannot connect to RPC: {rpc}")
|
|
104
|
+
|
|
105
|
+
self.account = self.w3.eth.account.from_key(private_key)
|
|
106
|
+
self._key = private_key
|
|
107
|
+
cs = self.w3.to_checksum_address
|
|
108
|
+
|
|
109
|
+
self.rail = self.w3.eth.contract(address=cs(rail), abi=RAIL_ABI)
|
|
110
|
+
self.registry = self.w3.eth.contract(address=cs(registry), abi=REGISTRY_ABI)
|
|
111
|
+
self.job_market = self.w3.eth.contract(address=cs(job_market), abi=JOB_MARKET_ABI)
|
|
112
|
+
self.result_store = self.w3.eth.contract(address=cs(result_store), abi=RESULT_STORE_ABI)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_api(cls, api_url: str, private_key: str) -> "Chain":
|
|
116
|
+
"""Auto-discover contract addresses and ABIs from the Railhead discovery API."""
|
|
117
|
+
url = api_url.rstrip("/") + "/health"
|
|
118
|
+
try:
|
|
119
|
+
r = requests.get(url, timeout=8)
|
|
120
|
+
r.raise_for_status()
|
|
121
|
+
except Exception as e:
|
|
122
|
+
raise ConnectionError(f"Cannot reach Railhead API at {url}: {e}")
|
|
123
|
+
data = r.json()
|
|
124
|
+
contracts = data.get("contracts", {})
|
|
125
|
+
rpc = data.get("rpc", "")
|
|
126
|
+
if not rpc:
|
|
127
|
+
from urllib.parse import urlparse
|
|
128
|
+
parsed = urlparse(api_url)
|
|
129
|
+
rpc = f"http://{parsed.hostname}:8545"
|
|
130
|
+
|
|
131
|
+
inst = cls.__new__(cls)
|
|
132
|
+
inst.w3 = Web3(Web3.HTTPProvider(rpc))
|
|
133
|
+
inst.account = inst.w3.eth.account.from_key(private_key)
|
|
134
|
+
inst._key = private_key
|
|
135
|
+
cs = inst.w3.to_checksum_address
|
|
136
|
+
|
|
137
|
+
# Prefer full ABIs from API; fall back to minimal bundled ABIs
|
|
138
|
+
abis = _fetch_abis(api_url)
|
|
139
|
+
inst.rail = inst.w3.eth.contract(address=cs(contracts["rail_token"]), abi=abis.get("Rail", RAIL_ABI))
|
|
140
|
+
inst.registry = inst.w3.eth.contract(address=cs(contracts["agent_registry"]), abi=abis.get("AgentRegistry", REGISTRY_ABI))
|
|
141
|
+
inst.job_market = inst.w3.eth.contract(address=cs(contracts["job_market"]), abi=abis.get("JobMarket", JOB_MARKET_ABI))
|
|
142
|
+
inst.result_store = inst.w3.eth.contract(address=cs(contracts["result_store"]), abi=abis.get("ResultStore", RESULT_STORE_ABI))
|
|
143
|
+
return inst
|
|
144
|
+
|
|
145
|
+
def send(self, fn, gas: int = 1_200_000) -> Any:
|
|
146
|
+
"""Sign and broadcast a contract function call. Returns the receipt.
|
|
147
|
+
|
|
148
|
+
If the on-chain receipt comes back with ``status == 0`` (revert), a
|
|
149
|
+
warning is logged with the tx hash and gas-used so callers can see
|
|
150
|
+
that the call cheerily succeeded at the RPC level but failed at the
|
|
151
|
+
contract level — the EVM-silent-success trap.
|
|
152
|
+
"""
|
|
153
|
+
import logging
|
|
154
|
+
_log = logging.getLogger("railhead.chain")
|
|
155
|
+
# Local nonce tracking — public/load-balanced RPCs lag on get_transaction_count between
|
|
156
|
+
# back-to-back txs ("replacement transaction underpriced"). Track locally; resync on failure.
|
|
157
|
+
if getattr(self, "_nonce", None) is None:
|
|
158
|
+
self._nonce = self.w3.eth.get_transaction_count(self.account.address, "pending")
|
|
159
|
+
tx = fn.build_transaction({
|
|
160
|
+
"from": self.account.address,
|
|
161
|
+
"nonce": self._nonce,
|
|
162
|
+
"gas": gas,
|
|
163
|
+
"gasPrice": self.w3.eth.gas_price,
|
|
164
|
+
})
|
|
165
|
+
signed = self.w3.eth.account.sign_transaction(tx, self._key)
|
|
166
|
+
try:
|
|
167
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
168
|
+
except Exception:
|
|
169
|
+
self._nonce = None # resync from chain on the next send
|
|
170
|
+
raise
|
|
171
|
+
self._nonce += 1
|
|
172
|
+
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash)
|
|
173
|
+
if receipt.status == 0:
|
|
174
|
+
_log.warning(
|
|
175
|
+
"Transaction REVERTED on-chain: tx=%s gasUsed=%d/%d (likely out of gas — bump the gas argument)",
|
|
176
|
+
receipt.transactionHash.hex(), receipt.gasUsed, gas,
|
|
177
|
+
)
|
|
178
|
+
return receipt
|
|
179
|
+
|
|
180
|
+
def usdc_balance(self, address: str | None = None) -> float:
|
|
181
|
+
"""USDC balance (6-decimal) of the wallet. NOTE: `self.rail` is the USDC token handle."""
|
|
182
|
+
addr = self.w3.to_checksum_address(address or self.account.address)
|
|
183
|
+
return from_usdc(self.rail.functions.balanceOf(addr).call())
|
|
184
|
+
|
|
185
|
+
# Back-compat alias — callers used to say rail_balance(); it returns USDC now.
|
|
186
|
+
def rail_balance(self, address: str | None = None) -> float:
|
|
187
|
+
return self.usdc_balance(address)
|
|
188
|
+
|
|
189
|
+
def result_hash(self, output: dict) -> bytes:
|
|
190
|
+
"""Compute keccak256 of the JSON-encoded output dict (canonical form)."""
|
|
191
|
+
payload = json.dumps(output, sort_keys=True, separators=(",", ":")).encode()
|
|
192
|
+
return self.w3.keccak(payload)
|
|
193
|
+
|
|
194
|
+
def input_hash(self, input_data: dict) -> bytes:
|
|
195
|
+
payload = json.dumps(input_data, sort_keys=True, separators=(",", ":")).encode()
|
|
196
|
+
return self.w3.keccak(payload)
|
railhead/agent.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""RailheadAgent — register a capability and handle incoming jobs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json, logging, time
|
|
4
|
+
from typing import Callable, Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from ._chain import Chain, to_usdc, from_usdc, _relay_post, _relay_get_json, relay_auth
|
|
9
|
+
from .types import Job, Result
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("railhead.agent")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RailheadAgent:
|
|
15
|
+
"""
|
|
16
|
+
Register an agent on Railhead and handle incoming jobs.
|
|
17
|
+
|
|
18
|
+
Quick start::
|
|
19
|
+
|
|
20
|
+
agent = RailheadAgent.from_api("https://api.railheads.ai", private_key="0x...")
|
|
21
|
+
|
|
22
|
+
agent.register(
|
|
23
|
+
capabilities=["price_signal"],
|
|
24
|
+
price_usdc=1,
|
|
25
|
+
stake_usdc=25,
|
|
26
|
+
endpoint="https://myagent.example.com",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@agent.on("price_signal")
|
|
30
|
+
def handle(job):
|
|
31
|
+
return {"signal": "buy", "confidence": 0.85}
|
|
32
|
+
|
|
33
|
+
agent.run()
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, rpc: str, private_key: str,
|
|
37
|
+
rail: str, registry: str, job_market: str, result_store: str):
|
|
38
|
+
self._chain = Chain(rpc, private_key, rail, registry, job_market, result_store)
|
|
39
|
+
self._handlers: dict[str, Callable] = {}
|
|
40
|
+
self._last_block: int = 0
|
|
41
|
+
self._api_url: str = ""
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_api(cls, api_url: str, private_key: str) -> "RailheadAgent":
|
|
45
|
+
"""
|
|
46
|
+
Recommended constructor. Auto-discovers contract addresses from the API.
|
|
47
|
+
|
|
48
|
+
:param api_url: Railhead discovery API, e.g. "https://api.railheads.ai"
|
|
49
|
+
:param private_key: 0x-prefixed hex private key
|
|
50
|
+
"""
|
|
51
|
+
chain = Chain.from_api(api_url, private_key)
|
|
52
|
+
inst = cls.__new__(cls)
|
|
53
|
+
inst._chain = chain
|
|
54
|
+
inst._handlers = {}
|
|
55
|
+
inst._last_block = 0
|
|
56
|
+
inst._api_url = api_url.rstrip("/")
|
|
57
|
+
return inst
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_credentials(cls, config_path: str | None = None) -> "RailheadAgent":
|
|
61
|
+
"""
|
|
62
|
+
Construct from credentials saved by `railhead init` at ~/.railhead/config.json.
|
|
63
|
+
|
|
64
|
+
Reads private_key + api_url from disk. The config file should be chmod 600.
|
|
65
|
+
"""
|
|
66
|
+
import json
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
path = Path(config_path) if config_path else Path.home() / ".railhead" / "config.json"
|
|
69
|
+
if not path.exists():
|
|
70
|
+
raise FileNotFoundError(
|
|
71
|
+
f"No Railhead credentials at {path}. "
|
|
72
|
+
"Run `railhead init --invite-code XXX` to set up."
|
|
73
|
+
)
|
|
74
|
+
cfg = json.loads(path.read_text())
|
|
75
|
+
return cls.from_api(cfg["api_url"], private_key=cfg["private_key"])
|
|
76
|
+
|
|
77
|
+
# ── Registration ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def register(
|
|
80
|
+
self,
|
|
81
|
+
capabilities: list[str],
|
|
82
|
+
price_usdc: float = 1.0,
|
|
83
|
+
stake_usdc: float = 25.0, # min is 10 USDC on-chain; 25 is a sane default
|
|
84
|
+
endpoint: str = "",
|
|
85
|
+
) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Register this agent on-chain. Stakes USDC (6-decimal) as reputation collateral.
|
|
88
|
+
|
|
89
|
+
The connected wallet must hold enough USDC to cover the stake (min 10 USDC).
|
|
90
|
+
On Base Sepolia, fund the wallet from Circle's test-USDC faucet.
|
|
91
|
+
|
|
92
|
+
:returns: Transaction hash of the registration.
|
|
93
|
+
"""
|
|
94
|
+
c = self._chain
|
|
95
|
+
w3 = c.w3
|
|
96
|
+
|
|
97
|
+
# Check if already registered
|
|
98
|
+
existing = c.registry.functions.getAgent(c.account.address).call()
|
|
99
|
+
if existing[0] != "0x0000000000000000000000000000000000000000":
|
|
100
|
+
log.info("Agent already registered — capabilities: %s", existing[1])
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
stake_units = to_usdc(stake_usdc) # 6-decimal base units
|
|
104
|
+
price_units = to_usdc(price_usdc)
|
|
105
|
+
|
|
106
|
+
# Check USDC balance
|
|
107
|
+
bal = c.usdc_balance()
|
|
108
|
+
if bal < stake_usdc:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Insufficient USDC: have {bal:.2f}, need {stake_usdc:.2f}. "
|
|
111
|
+
"Fund your wallet with Base Sepolia test-USDC (Circle faucet)."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Approve registry to pull stake
|
|
115
|
+
c.send(c.rail.functions.approve(c.registry.address, stake_units))
|
|
116
|
+
log.info("USDC approved ✓")
|
|
117
|
+
|
|
118
|
+
# Register. Gas grows linearly with the number of capabilities; pass an
|
|
119
|
+
# explicit roomy budget so multi-capability registrations don't silently
|
|
120
|
+
# revert when the default isn't enough (the bug that bit Agent #5).
|
|
121
|
+
gas_budget = 400_000 + 250_000 * max(1, len(capabilities))
|
|
122
|
+
receipt = c.send(c.registry.functions.register(
|
|
123
|
+
capabilities, price_units, stake_units, endpoint, b'\x00' * 32
|
|
124
|
+
), gas=gas_budget)
|
|
125
|
+
|
|
126
|
+
if receipt.status != 1:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
f"Registration reverted on-chain (tx {receipt.transactionHash.hex()}, "
|
|
129
|
+
f"gasUsed {receipt.gasUsed}/{gas_budget}). "
|
|
130
|
+
"Check whitelist status + USDC allowance + stake balance."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
tx = receipt.transactionHash.hex()
|
|
134
|
+
log.info("Registered on Railhead ✓ capabilities=%s tx=%s gasUsed=%d", capabilities, tx[:20], receipt.gasUsed)
|
|
135
|
+
return tx
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def address(self) -> str:
|
|
139
|
+
return self._chain.account.address
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def balance(self) -> float:
|
|
143
|
+
return self._chain.usdc_balance()
|
|
144
|
+
|
|
145
|
+
def claim_payout(self, job_id: int) -> str:
|
|
146
|
+
"""
|
|
147
|
+
Release escrow for one of your delivered jobs after its challenge window closes.
|
|
148
|
+
|
|
149
|
+
Under the optimistic-acceptance model, a Submitted result that the client neither
|
|
150
|
+
approved nor disputed within CHALLENGE_WINDOW can be settled by ANYONE — no mediator
|
|
151
|
+
needed. Call this on your own jobs once the window has passed to get paid.
|
|
152
|
+
|
|
153
|
+
Reverts on-chain (surfaced as a warning) if the window is still open or the job isn't
|
|
154
|
+
in the Submitted state.
|
|
155
|
+
"""
|
|
156
|
+
c = self._chain
|
|
157
|
+
receipt = c.send(c.job_market.functions.claimPayout(job_id))
|
|
158
|
+
if receipt.status != 1:
|
|
159
|
+
log.warning(" claimPayout #%d reverted — window still open, or job not Submitted.", job_id)
|
|
160
|
+
else:
|
|
161
|
+
log.info(" claimPayout #%d ✓ escrow released (mediator-free)", job_id)
|
|
162
|
+
return receipt.transactionHash.hex()
|
|
163
|
+
|
|
164
|
+
# ── Capability handler registry ───────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def on(self, capability: str) -> Callable:
|
|
167
|
+
"""
|
|
168
|
+
Decorator — register a handler for a capability.
|
|
169
|
+
|
|
170
|
+
The handler receives a Job and must return a dict (the result payload).
|
|
171
|
+
The SDK handles hashing, submitting, and validating on-chain automatically.
|
|
172
|
+
|
|
173
|
+
Example::
|
|
174
|
+
|
|
175
|
+
@agent.on("price_signal")
|
|
176
|
+
def handle(job):
|
|
177
|
+
return {"signal": "buy", "asset": "ETH", "confidence": 0.9}
|
|
178
|
+
"""
|
|
179
|
+
def decorator(fn: Callable) -> Callable:
|
|
180
|
+
self._handlers[capability] = fn
|
|
181
|
+
log.info("Handler registered for '%s'", capability)
|
|
182
|
+
return fn
|
|
183
|
+
return decorator
|
|
184
|
+
|
|
185
|
+
# ── Job loop ──────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def run(self, poll_secs: float = 5.0) -> None:
|
|
188
|
+
"""
|
|
189
|
+
Start polling for jobs. Blocks forever (Ctrl+C to stop).
|
|
190
|
+
|
|
191
|
+
For each incoming job assigned to this agent:
|
|
192
|
+
1. Calls the registered handler
|
|
193
|
+
2. Submits the result hash on-chain
|
|
194
|
+
3. Self-validates (Phase 1 — agent is its own mediator)
|
|
195
|
+
4. Escrow releases automatically
|
|
196
|
+
"""
|
|
197
|
+
log.info("Agent %s watching for jobs (poll every %.0fs)", self.address[:10], poll_secs)
|
|
198
|
+
log.info("Handling capabilities: %s", list(self._handlers.keys()))
|
|
199
|
+
self._last_block = self._chain.w3.eth.block_number
|
|
200
|
+
try:
|
|
201
|
+
while True:
|
|
202
|
+
try:
|
|
203
|
+
self._poll()
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
raise
|
|
206
|
+
except Exception as e:
|
|
207
|
+
# A transient RPC/DNS/network failure must NOT kill the daemon — there is no process
|
|
208
|
+
# supervisor (review ops finding). Log and retry on the next cycle instead of dying.
|
|
209
|
+
log.warning("poll error (transient?) — retrying next cycle: %s", e)
|
|
210
|
+
time.sleep(poll_secs)
|
|
211
|
+
except KeyboardInterrupt:
|
|
212
|
+
log.info("Stopped.")
|
|
213
|
+
|
|
214
|
+
def _poll(self) -> None:
|
|
215
|
+
c = self._chain
|
|
216
|
+
current_block = c.w3.eth.block_number
|
|
217
|
+
if current_block <= self._last_block:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
# Scan for JobCreated events assigned to this agent
|
|
221
|
+
try:
|
|
222
|
+
jc_sig = c.w3.keccak(text="JobCreated(uint256,address,address,string,uint256)")
|
|
223
|
+
logs = c.w3.eth.get_logs({
|
|
224
|
+
"fromBlock": self._last_block + 1,
|
|
225
|
+
"toBlock": current_block,
|
|
226
|
+
"address": c.job_market.address,
|
|
227
|
+
"topics": [jc_sig],
|
|
228
|
+
})
|
|
229
|
+
except Exception as e:
|
|
230
|
+
log.warning("Log fetch error: %s", e)
|
|
231
|
+
self._last_block = current_block
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
self._last_block = current_block
|
|
235
|
+
|
|
236
|
+
for entry in logs:
|
|
237
|
+
try:
|
|
238
|
+
job_id = int(entry["topics"][1].hex(), 16)
|
|
239
|
+
raw = c.job_market.functions.jobs(job_id).call()
|
|
240
|
+
agent = raw[2]
|
|
241
|
+
status = raw[13] # status index in the 16-field jobs() tuple
|
|
242
|
+
cap = raw[3]
|
|
243
|
+
|
|
244
|
+
if agent.lower() != c.account.address.lower():
|
|
245
|
+
continue
|
|
246
|
+
if status != 0: # only Open jobs
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
log.info("Job #%d received capability=%s payment=%.2f USDC",
|
|
250
|
+
job_id, cap, from_usdc(raw[5]))
|
|
251
|
+
self._handle(job_id, cap, raw)
|
|
252
|
+
|
|
253
|
+
except Exception as e:
|
|
254
|
+
log.error("Error processing job: %s", e, exc_info=True)
|
|
255
|
+
|
|
256
|
+
def _fetch_input(self, job_id: int) -> dict:
|
|
257
|
+
"""
|
|
258
|
+
Retrieve the off-chain input payload from the discovery API's relay.
|
|
259
|
+
Returns {} if no API is configured, none is stored, or the fetch fails —
|
|
260
|
+
so a missing relay degrades to the old empty-input behavior rather than erroring.
|
|
261
|
+
"""
|
|
262
|
+
if not self._api_url:
|
|
263
|
+
return {}
|
|
264
|
+
# The client's input POST may still be in flight (or its on-chain visibility lagging),
|
|
265
|
+
# so retry on 404 for a few seconds before degrading to empty input.
|
|
266
|
+
return _relay_get_json(f"{self._api_url}/job-inputs/{job_id}", "input",
|
|
267
|
+
auth=relay_auth(self._chain._key, job_id)) # only the assigned agent may read
|
|
268
|
+
|
|
269
|
+
def _handle(self, job_id: int, capability: str, raw: tuple) -> None:
|
|
270
|
+
c = self._chain
|
|
271
|
+
|
|
272
|
+
handler = self._handlers.get(capability)
|
|
273
|
+
if not handler:
|
|
274
|
+
log.warning("No handler for capability '%s' — skipping job #%d", capability, job_id)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Input is hashed on-chain and stored off-chain; retrieve the real payload
|
|
278
|
+
# from the discovery API relay (falls back to {} if unavailable).
|
|
279
|
+
job = Job(
|
|
280
|
+
id=job_id,
|
|
281
|
+
capability=capability,
|
|
282
|
+
client=raw[1],
|
|
283
|
+
agent=raw[2],
|
|
284
|
+
payment_usdc=from_usdc(raw[5]),
|
|
285
|
+
status="Open",
|
|
286
|
+
input=self._fetch_input(job_id),
|
|
287
|
+
_chain=c,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Accept the job
|
|
291
|
+
c.send(c.job_market.functions.acceptJob(job_id))
|
|
292
|
+
log.info(" acceptJob #%d ✓", job_id)
|
|
293
|
+
|
|
294
|
+
# Run the handler
|
|
295
|
+
try:
|
|
296
|
+
output = handler(job)
|
|
297
|
+
if not isinstance(output, dict):
|
|
298
|
+
output = {"result": str(output)}
|
|
299
|
+
except Exception as e:
|
|
300
|
+
log.error(" Handler error on job #%d: %s", job_id, e)
|
|
301
|
+
output = {"error": str(e)}
|
|
302
|
+
|
|
303
|
+
# Submit result hash
|
|
304
|
+
result_hash = c.result_hash(output)
|
|
305
|
+
c.send(c.result_store.functions.submitResult(
|
|
306
|
+
job_id, result_hash, b'\x00' * 32
|
|
307
|
+
))
|
|
308
|
+
log.info(" submitResult #%d ✓ hash=%s", job_id, result_hash.hex()[:16])
|
|
309
|
+
|
|
310
|
+
# Relay the result text off-chain so clients can read it (not just the hash).
|
|
311
|
+
# Best-effort — the on-chain submit already happened, so a relay failure
|
|
312
|
+
# just means clients see the hash but can't fetch the body. The relay
|
|
313
|
+
# endpoint verifies our keccak matches the on-chain resultHash.
|
|
314
|
+
if self._api_url:
|
|
315
|
+
# The API validates our payload against the on-chain resultHash; its RPC may not see
|
|
316
|
+
# our just-landed submitResult yet (409), so retry until the write propagates.
|
|
317
|
+
_relay_post(f"{self._api_url}/job-results/{job_id}", output,
|
|
318
|
+
retry_on=(404, 409), label=f"result #{job_id}", log=log,
|
|
319
|
+
auth=relay_auth(c._key, job_id)) # only the assigned agent may post
|
|
320
|
+
|
|
321
|
+
# Self-validate (Phase 1 — agent wallet must be whitelisted as mediator)
|
|
322
|
+
note = output.get("_summary", f"{capability} completed")
|
|
323
|
+
try:
|
|
324
|
+
c.send(c.result_store.functions.validate(job_id, note))
|
|
325
|
+
log.info(" validate #%d ✓ escrow released note=%r", job_id, note)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
log.warning(" validate #%d failed — wallet not whitelisted as mediator. "
|
|
328
|
+
"Contact the Railhead team to enable auto-settlement. error=%s", job_id, e)
|