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.
- railhead-0.4.0/PKG-INFO +98 -0
- railhead-0.4.0/README.md +77 -0
- railhead-0.4.0/pyproject.toml +44 -0
- railhead-0.4.0/railhead/__init__.py +41 -0
- railhead-0.4.0/railhead/_abis.py +89 -0
- railhead-0.4.0/railhead/_chain.py +196 -0
- railhead-0.4.0/railhead/agent.py +328 -0
- railhead-0.4.0/railhead/buyer.py +277 -0
- railhead-0.4.0/railhead/cli.py +923 -0
- railhead-0.4.0/railhead/client.py +530 -0
- railhead-0.4.0/railhead/escrow.py +627 -0
- railhead-0.4.0/railhead/escrow_abi/AgentRegistryV2.abi.json +2442 -0
- railhead-0.4.0/railhead/escrow_abi/Gate3AcceptanceModuleV2.abi.json +363 -0
- railhead-0.4.0/railhead/escrow_abi/Gate3AdjudicationModuleV2.abi.json +310 -0
- railhead-0.4.0/railhead/escrow_abi/Gate3ModuleV2.abi.json +1168 -0
- railhead-0.4.0/railhead/escrow_abi/Gate3TypedDataModuleV2.abi.json +307 -0
- railhead-0.4.0/railhead/escrow_abi/JobMarketV2.abi.json +1783 -0
- railhead-0.4.0/railhead/escrow_abi/RoleLedgerV2.abi.json +2831 -0
- railhead-0.4.0/railhead/escrow_abi/abi-sha256.txt +7 -0
- railhead-0.4.0/railhead/escrow_tx.py +929 -0
- railhead-0.4.0/railhead/escrow_workflow.py +209 -0
- railhead-0.4.0/railhead/pins.py +41 -0
- railhead-0.4.0/railhead/types.py +104 -0
- railhead-0.4.0/railhead/x402.py +333 -0
- railhead-0.4.0/railhead/x402_serve.py +213 -0
- railhead-0.4.0/railhead.egg-info/PKG-INFO +98 -0
- railhead-0.4.0/railhead.egg-info/SOURCES.txt +37 -0
- railhead-0.4.0/railhead.egg-info/dependency_links.txt +1 -0
- railhead-0.4.0/railhead.egg-info/entry_points.txt +2 -0
- railhead-0.4.0/railhead.egg-info/requires.txt +3 -0
- railhead-0.4.0/railhead.egg-info/top_level.txt +1 -0
- railhead-0.4.0/setup.cfg +4 -0
- railhead-0.4.0/tests/test_buyer_x402.py +189 -0
- railhead-0.4.0/tests/test_escrow.py +882 -0
- railhead-0.4.0/tests/test_escrow_jr01_coordination.py +859 -0
- railhead-0.4.0/tests/test_escrow_jr01_fee_boundary.py +269 -0
- railhead-0.4.0/tests/test_escrow_jr01_r1klm.py +288 -0
- railhead-0.4.0/tests/test_escrow_jr01_r1n.py +209 -0
- railhead-0.4.0/tests/test_escrow_jr01_recovery_fee.py +266 -0
railhead-0.4.0/PKG-INFO
ADDED
|
@@ -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
|
railhead-0.4.0/README.md
ADDED
|
@@ -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)
|