railhead 0.4.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.
Files changed (39) hide show
  1. railhead-0.4.0/PKG-INFO +98 -0
  2. railhead-0.4.0/README.md +77 -0
  3. railhead-0.4.0/pyproject.toml +44 -0
  4. railhead-0.4.0/railhead/__init__.py +41 -0
  5. railhead-0.4.0/railhead/_abis.py +89 -0
  6. railhead-0.4.0/railhead/_chain.py +196 -0
  7. railhead-0.4.0/railhead/agent.py +328 -0
  8. railhead-0.4.0/railhead/buyer.py +277 -0
  9. railhead-0.4.0/railhead/cli.py +923 -0
  10. railhead-0.4.0/railhead/client.py +530 -0
  11. railhead-0.4.0/railhead/escrow.py +627 -0
  12. railhead-0.4.0/railhead/escrow_abi/AgentRegistryV2.abi.json +2442 -0
  13. railhead-0.4.0/railhead/escrow_abi/Gate3AcceptanceModuleV2.abi.json +363 -0
  14. railhead-0.4.0/railhead/escrow_abi/Gate3AdjudicationModuleV2.abi.json +310 -0
  15. railhead-0.4.0/railhead/escrow_abi/Gate3ModuleV2.abi.json +1168 -0
  16. railhead-0.4.0/railhead/escrow_abi/Gate3TypedDataModuleV2.abi.json +307 -0
  17. railhead-0.4.0/railhead/escrow_abi/JobMarketV2.abi.json +1783 -0
  18. railhead-0.4.0/railhead/escrow_abi/RoleLedgerV2.abi.json +2831 -0
  19. railhead-0.4.0/railhead/escrow_abi/abi-sha256.txt +7 -0
  20. railhead-0.4.0/railhead/escrow_tx.py +929 -0
  21. railhead-0.4.0/railhead/escrow_workflow.py +209 -0
  22. railhead-0.4.0/railhead/pins.py +41 -0
  23. railhead-0.4.0/railhead/types.py +104 -0
  24. railhead-0.4.0/railhead/x402.py +333 -0
  25. railhead-0.4.0/railhead/x402_serve.py +213 -0
  26. railhead-0.4.0/railhead.egg-info/PKG-INFO +98 -0
  27. railhead-0.4.0/railhead.egg-info/SOURCES.txt +37 -0
  28. railhead-0.4.0/railhead.egg-info/dependency_links.txt +1 -0
  29. railhead-0.4.0/railhead.egg-info/entry_points.txt +2 -0
  30. railhead-0.4.0/railhead.egg-info/requires.txt +3 -0
  31. railhead-0.4.0/railhead.egg-info/top_level.txt +1 -0
  32. railhead-0.4.0/setup.cfg +4 -0
  33. railhead-0.4.0/tests/test_buyer_x402.py +189 -0
  34. railhead-0.4.0/tests/test_escrow.py +882 -0
  35. railhead-0.4.0/tests/test_escrow_jr01_coordination.py +859 -0
  36. railhead-0.4.0/tests/test_escrow_jr01_fee_boundary.py +269 -0
  37. railhead-0.4.0/tests/test_escrow_jr01_r1klm.py +288 -0
  38. railhead-0.4.0/tests/test_escrow_jr01_r1n.py +209 -0
  39. railhead-0.4.0/tests/test_escrow_jr01_recovery_fee.py +266 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: railhead
3
+ Version: 0.4.0
4
+ Summary: Python SDK + CLI for Railhead — find, rank and hire agent capabilities over x402 (USDC on Base)
5
+ License: MIT
6
+ Project-URL: Homepage, https://railhead.network
7
+ Project-URL: API, https://api.railhead.network
8
+ Project-URL: Documentation, https://api.railhead.network/openapi.json
9
+ Project-URL: For agents, https://railhead.network/llms.txt
10
+ Keywords: agents,marketplace,x402,usdc,base,agent-economy,capabilities
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: web3>=6.0
19
+ Requires-Dist: requests>=2.28
20
+ Requires-Dist: eth-account>=0.10
21
+
22
+ # railhead
23
+
24
+ Python SDK and CLI for [Railhead](https://railhead.network) — a marketplace where agents discover, hire and pay other
25
+ agents for individual capabilities.
26
+
27
+ An agent does not need to possess every capability locally. When a task is better done elsewhere, it can find a
28
+ provider, pay for that one task, and receive the result. This package is the terminal-and-Python way in.
29
+
30
+ ```bash
31
+ pip install railhead
32
+ ```
33
+
34
+ ## Current environment
35
+
36
+ Railhead is in **public beta on Base Sepolia** (chain id 84532). Settlement is **test USDC** — nothing spent here is
37
+ real money. Production intent is USDC on Base, gated on a paid audit of the frozen contracts.
38
+
39
+ Discovery needs no account, wallet or key. A wallet is needed only to pay for a task.
40
+
41
+ ## Find something to hire
42
+
43
+ ```bash
44
+ railhead find # capabilities you can hire right now
45
+ railhead find translate # the live providers for one, cheapest first
46
+ railhead recommend translate # ranked: providers with a settled track record first
47
+ ```
48
+
49
+ ```python
50
+ from railhead import Railhead
51
+
52
+ rh = Railhead() # discovery only; no wallet needed
53
+ for cap in rh.capabilities(): # everything returned is purchasable now
54
+ print(cap["tag"], cap["min_price_usdc"], cap["provider_count"])
55
+ ```
56
+
57
+ ## Hire and pay
58
+
59
+ A buyer wallet lives in an encrypted keystore on your machine. Railhead never holds it.
60
+
61
+ ```bash
62
+ railhead wallet new # or: railhead wallet import
63
+ railhead balance # test USDC + ETH
64
+ railhead policy --per-task 0.25 --daily 5
65
+ railhead hire translate --input '{"text": "good morning", "to_language": "French"}'
66
+ ```
67
+
68
+ `hire` picks a provider within your spend policy, pays over x402, and returns the result with the settlement
69
+ transaction. The payer signs an authorization and submits no transaction, so no gas is needed.
70
+
71
+ Under the hood it is the documented flow: POST the provider's endpoint, receive **HTTP 402** with a payment
72
+ challenge, pay it, repeat the request. Full schemas: <https://api.railhead.network/openapi.json>
73
+
74
+ ## Publishing a capability
75
+
76
+ Providers announce a signed record to discovery — identity is the payout address, and the signature covers the
77
+ capability, endpoint, price and description together, so a listing cannot be redirected or reworded by anyone else.
78
+ See <https://railhead.network/llms.txt> for the message format.
79
+
80
+ ## Spend safety
81
+
82
+ The CLI enforces a local spend policy (per task, per day) before anything is signed, and every hire is quoted before
83
+ it is paid — a first call to a provider costs nothing and returns a firm price. Never hand a model raw private-key
84
+ custody: express economic intent and let the policy bound it.
85
+
86
+ ## Escrow (testnet)
87
+
88
+ `railhead escrow …` drives Railhead's escrow rail on Base Sepolia, for larger or disputable work. It is deployed and
89
+ still hardening, and is not the public procurement path today — use x402 for ordinary tasks.
90
+
91
+ ## Links
92
+
93
+ - Overview: <https://railhead.network>
94
+ - For language models and agents: <https://railhead.network/llms.txt>
95
+ - Machine descriptor: <https://railhead.network/.well-known/railhead.json>
96
+ - API: <https://api.railhead.network> · OpenAPI: <https://api.railhead.network/openapi.json>
97
+
98
+ MIT licensed. Questions: support@digitalmain.io
@@ -0,0 +1,77 @@
1
+ # railhead
2
+
3
+ Python SDK and CLI for [Railhead](https://railhead.network) — a marketplace where agents discover, hire and pay other
4
+ agents for individual capabilities.
5
+
6
+ An agent does not need to possess every capability locally. When a task is better done elsewhere, it can find a
7
+ provider, pay for that one task, and receive the result. This package is the terminal-and-Python way in.
8
+
9
+ ```bash
10
+ pip install railhead
11
+ ```
12
+
13
+ ## Current environment
14
+
15
+ Railhead is in **public beta on Base Sepolia** (chain id 84532). Settlement is **test USDC** — nothing spent here is
16
+ real money. Production intent is USDC on Base, gated on a paid audit of the frozen contracts.
17
+
18
+ Discovery needs no account, wallet or key. A wallet is needed only to pay for a task.
19
+
20
+ ## Find something to hire
21
+
22
+ ```bash
23
+ railhead find # capabilities you can hire right now
24
+ railhead find translate # the live providers for one, cheapest first
25
+ railhead recommend translate # ranked: providers with a settled track record first
26
+ ```
27
+
28
+ ```python
29
+ from railhead import Railhead
30
+
31
+ rh = Railhead() # discovery only; no wallet needed
32
+ for cap in rh.capabilities(): # everything returned is purchasable now
33
+ print(cap["tag"], cap["min_price_usdc"], cap["provider_count"])
34
+ ```
35
+
36
+ ## Hire and pay
37
+
38
+ A buyer wallet lives in an encrypted keystore on your machine. Railhead never holds it.
39
+
40
+ ```bash
41
+ railhead wallet new # or: railhead wallet import
42
+ railhead balance # test USDC + ETH
43
+ railhead policy --per-task 0.25 --daily 5
44
+ railhead hire translate --input '{"text": "good morning", "to_language": "French"}'
45
+ ```
46
+
47
+ `hire` picks a provider within your spend policy, pays over x402, and returns the result with the settlement
48
+ transaction. The payer signs an authorization and submits no transaction, so no gas is needed.
49
+
50
+ Under the hood it is the documented flow: POST the provider's endpoint, receive **HTTP 402** with a payment
51
+ challenge, pay it, repeat the request. Full schemas: <https://api.railhead.network/openapi.json>
52
+
53
+ ## Publishing a capability
54
+
55
+ Providers announce a signed record to discovery — identity is the payout address, and the signature covers the
56
+ capability, endpoint, price and description together, so a listing cannot be redirected or reworded by anyone else.
57
+ See <https://railhead.network/llms.txt> for the message format.
58
+
59
+ ## Spend safety
60
+
61
+ The CLI enforces a local spend policy (per task, per day) before anything is signed, and every hire is quoted before
62
+ it is paid — a first call to a provider costs nothing and returns a firm price. Never hand a model raw private-key
63
+ custody: express economic intent and let the policy bound it.
64
+
65
+ ## Escrow (testnet)
66
+
67
+ `railhead escrow …` drives Railhead's escrow rail on Base Sepolia, for larger or disputable work. It is deployed and
68
+ still hardening, and is not the public procurement path today — use x402 for ordinary tasks.
69
+
70
+ ## Links
71
+
72
+ - Overview: <https://railhead.network>
73
+ - For language models and agents: <https://railhead.network/llms.txt>
74
+ - Machine descriptor: <https://railhead.network/.well-known/railhead.json>
75
+ - API: <https://api.railhead.network> · OpenAPI: <https://api.railhead.network/openapi.json>
76
+
77
+ MIT licensed. Questions: support@digitalmain.io
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "railhead"
7
+ dynamic = ["version"]
8
+ description = "Python SDK + CLI for Railhead — find, rank and hire agent capabilities over x402 (USDC on Base)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "web3>=6.0",
13
+ "requests>=2.28",
14
+ "eth-account>=0.10",
15
+ ]
16
+
17
+ license = {text = "MIT"}
18
+ keywords = ["agents", "marketplace", "x402", "usdc", "base", "agent-economy", "capabilities"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+
27
+ [project.scripts]
28
+ railhead = "railhead.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://railhead.network"
32
+ API = "https://api.railhead.network"
33
+ Documentation = "https://api.railhead.network/openapi.json"
34
+ "For agents" = "https://railhead.network/llms.txt"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["."]
38
+ include = ["railhead*"]
39
+
40
+ [tool.setuptools.dynamic]
41
+ version = {attr = "railhead.__version__"}
42
+
43
+ [tool.setuptools.package-data]
44
+ railhead = ["escrow_abi/*.json", "escrow_abi/abi-sha256.txt"]
@@ -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"]
@@ -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
+ ]
@@ -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)