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 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)