governance: node-side Proof-of-AI governance MCP (the operator-LLM sensory surface)

The missing "LLM of node operator" half of on-chain AI governance: a read-only
MCP server a node exposes so an operator-LLM can query its own chain mid-thought
before signing an AIGovernor verdict — chain_state, param_history, receipt_lookup,
quorum_status, etc. Read-only by construction (AST gate, no state-mutating verb),
bounded calls + per-call timeout, every read at a pinned block.

EthCaller is the read-only EVM seam; quorum tally mirrors AIGovernor.settle so the
LLM sees the same canonical view the chain will. cmd/aivm-gov-mcp serves it over
stdio (drop-in for hanzo-dev / Claude Desktop mcp-servers, like gym.distributed.mcp).

Verified: go test ./governance/ = ok (SDKROOT set; CGO secp256k1/gopsutil need the
macOS SDK path). Pairs with luxfi/standard ai/governance (AIGovernor/AIApproval/
AIExecute). Future: decomplect transport+evmread to module root as a Surface once a
2nd surface (mining/treasury) lands — held per YAGNI.
This commit is contained in:
zeekay
2026-06-24 19:08:04 -07:00
commit e0b32e58ab
20 changed files with 4971 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Go
bin/
*.out
*.test
*.prof
# OS / editor
.DS_Store
.idea/
.vscode/
# env
.env
.env.local
.env.*.local
+94
View File
@@ -0,0 +1,94 @@
module github.com/luxfi/mcp
go 1.26.4
require (
github.com/holiman/uint256 v1.3.2
github.com/luxfi/crypto v1.19.25
github.com/luxfi/geth v1.17.12
)
require (
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/consensys/gnark-crypto v0.20.1 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/emicklei/dot v1.11.0 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.7 // indirect
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect
github.com/ferranbt/fastssz v1.0.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/holiman/bloomfilter/v2 v2.0.3 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/atomic v1.0.0 // indirect
github.com/luxfi/cache v1.2.1 // indirect
github.com/luxfi/compress v0.0.5 // indirect
github.com/luxfi/concurrent v0.0.3 // indirect
github.com/luxfi/consensus v1.25.15 // indirect
github.com/luxfi/constants v1.5.8 // indirect
github.com/luxfi/container v0.0.4 // indirect
github.com/luxfi/corona v0.7.6 // indirect
github.com/luxfi/crypto/ipa v1.2.4 // indirect
github.com/luxfi/database v1.19.0 // indirect
github.com/luxfi/ids v1.2.15 // indirect
github.com/luxfi/lattice/v7 v7.1.4 // indirect
github.com/luxfi/log v1.4.3 // indirect
github.com/luxfi/math v1.4.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/mdns v0.1.1 // indirect
github.com/luxfi/metric v1.5.8 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/luxfi/p2p v1.21.1 // indirect
github.com/luxfi/pq v1.0.3 // indirect
github.com/luxfi/precompile v0.5.44 // indirect
github.com/luxfi/runtime v1.1.1 // indirect
github.com/luxfi/sampler v1.1.0 // indirect
github.com/luxfi/utils v1.2.0 // indirect
github.com/luxfi/validators v1.2.0 // indirect
github.com/luxfi/version v1.0.1 // indirect
github.com/luxfi/vm v1.2.3 // indirect
github.com/luxfi/warp v1.19.3 // indirect
github.com/luxfi/zap v0.7.2 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.9.0 // indirect
github.com/mr-tron/base58 v1.3.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+343
View File
@@ -0,0 +1,343 @@
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo=
github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g=
github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 h1:pU88SPhIFid6/k0egdR5V6eALQYq2qbSmukrkgIh/0A=
github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k=
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo=
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
github.com/cockroachdb/redact v1.1.8 h1:8eVLLj6juKxiKrAEw2b8cJvNqWq++U8WOfQFuL7KTaA=
github.com/cockroachdb/redact v1.1.8/go.mod h1:GceHHpJ0rMDpYARL5In88Alq/xMBUtVlz7Qxix6ZVkw=
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB8PbJVMX1ouCcSIxvsqKPYM7gs72o0zC76n9g=
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/dot v1.11.0 h1:zsrhCuFHAJge/aZIC4N4LdHy5tqYu4tWEaUzIwdYj4Y=
github.com/emicklei/dot v1.11.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.7 h1:aat3CuITdDbPC6pmEGRT0zJ5eOxzrZj8TJT5z7Xk//M=
github.com/ethereum/c-kzg-4844/v2 v2.1.7/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
github.com/ferranbt/fastssz v1.0.0 h1:9EXXYsracSqQRBQiHeaVsG/KQeYblPf40hsQPb9Dzk8=
github.com/ferranbt/fastssz v1.0.0/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI=
github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/hashicorp/go-bexpr v0.1.16 h1:D+fKoGyUzXVS0FdjOX1ws3vIck8DVtBqQ0tsusmYDR8=
github.com/hashicorp/go-bexpr v0.1.16/go.mod h1:HGKbAByHn2aJWUV47gL7+IjLK79iU3EZIbOwCXJZLoE=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg=
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
github.com/luxfi/compress v0.0.5 h1:4tEUHw5MK1bu5UOjfYCt4OKMiH7yykIgmGPRA/BfJTM=
github.com/luxfi/compress v0.0.5/go.mod h1:Cc1yxD2pfzrvpO32W2GDwLKff+CylHEvzZh2Ko8RSIU=
github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7cc=
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
github.com/luxfi/consensus v1.25.15 h1:7Bm/dm8pWhVM6j6gVQTIfVyVda/9oNOAiSohT0hok7s=
github.com/luxfi/consensus v1.25.15/go.mod h1:cBj4bELXj1hfB+cHHlAdAQ7oDtS1RzNPgftniakrF9Q=
github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g=
github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48=
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
github.com/luxfi/corona v0.7.6 h1:CJP6smygD55dL0HHkKkWryL9H24a+wXvs+L+WchK7Nc=
github.com/luxfi/corona v0.7.6/go.mod h1:4aD7+ZqnlZ2aVuU/DBQ5aspIagv5ux45LW2sJ4+siY8=
github.com/luxfi/crypto v1.19.25 h1:aYBgLhokFmxX32ARVxM3wGs6ANSfML3h3nMrxOrIDJ4=
github.com/luxfi/crypto v1.19.25/go.mod h1:0tfz+EbAjsW1QBWB0cte9kdjB5XhhYFmCr8BkZRux48=
github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xfU=
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
github.com/luxfi/database v1.19.0 h1:sySH5eS8ihJtyc+yb0LgTignGwJ9H4W9bv+nboCuIm0=
github.com/luxfi/database v1.19.0/go.mod h1:o1ccjgVKMYbVBfi+6+sDvx4swGFgGnCGFQ4gL/oJ+kY=
github.com/luxfi/geth v1.17.12 h1:UP/fhpcfbGPTrkOCwX3d88Oc3jVm5gTOgfjgq+lek6s=
github.com/luxfi/geth v1.17.12/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/luxfi/magnetar v1.2.0 h1:bsxHmBnJiswc/A6ElQ0pWz5g6ogqewIEKKqR26VgizA=
github.com/luxfi/magnetar v1.2.0/go.mod h1:7J9YP9jByWbwCjssMFJNUkTU8tcPlSUoVSSiYShtvFs=
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
github.com/luxfi/metric v1.5.8 h1:axPwfq+erOlIue7IJp5g+hMcMtVhYHja9gJAaT3+KNA=
github.com/luxfi/metric v1.5.8/go.mod h1:fO2giazkg4NDtr72JM/QXJBYebplAMeWC1JoZyNDvKw=
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
github.com/luxfi/p2p v1.21.1 h1:gmz1JMDhzHIL3dQlhwIDvR4OlFuhNVfnWUl/ipYhAIo=
github.com/luxfi/p2p v1.21.1/go.mod h1:SsNPR5fPGWWNem9plGWhSmRqyDoysJ3kPAN0zG0g3iw=
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
github.com/luxfi/precompile v0.5.44 h1:r5g98nYTwHX0v7mOpico8Lksc/MInSHEaK/ST+QwKfc=
github.com/luxfi/precompile v0.5.44/go.mod h1:FYfq9CT8XRmlpjFAtblHAMcpnjByuoM+YpKq/FF9QVk=
github.com/luxfi/runtime v1.1.1 h1:vOMe82PL3bpSbslS7p69dKRpbr2qW2vOOAwej3UkkmU=
github.com/luxfi/runtime v1.1.1/go.mod h1:fmG6+Zxj4wSNlXwiUfDthQDY+SfxVu6S0fX2lL6VbrE=
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
github.com/luxfi/utils v1.2.0 h1:gtEiI7/NM6PQ/OasEpH0PvB+e5hIS/tpum9r64pYjMc=
github.com/luxfi/utils v1.2.0/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
github.com/luxfi/validators v1.2.0/go.mod h1:GYLulrNXAan23ZlX7sgWVbVnLpUexeB/m2qr2ymsXok=
github.com/luxfi/version v1.0.1 h1:T/1KYWEMmsrNQk7pN7PFPAwh/7XbeX7cFAKLBqI37Sk=
github.com/luxfi/version v1.0.1/go.mod h1:Y5fPkQ2DB0XRBCxgSPXp4ISzL1/jptKnmFknShRJCyg=
github.com/luxfi/vm v1.2.3 h1:IdfB02OtDmdy/YNxaIR8DquPp5Aq0fi5QXBqGHoRkug=
github.com/luxfi/vm v1.2.3/go.mod h1:ZXmw1sKA6GL3Ma3BfYUv5Fzvx3aWu2QMtkxF3lL/3R4=
github.com/luxfi/warp v1.19.3 h1:tU6aAniYrPiRGrht7oFSwsUmk5KBb9RDFSyZq3lJ4HY=
github.com/luxfi/warp v1.19.3/go.mod h1:kqyk7Fa5mgw1zzMLqjrZWVLuTef35CCW0gOEv10UlZk=
github.com/luxfi/zap v0.7.2 h1:YecWTWNE5PPJXL56sLIkzS8b23bprUwZ5lPAQuLUtTE=
github.com/luxfi/zap v0.7.2/go.mod h1:1k+nwT+JW802YzuPAuf7CxMSGr/qxvbGgGwi5k6X9Ok=
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
github.com/luxfi/zapdb v1.10.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw=
github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4=
github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a h1:1ur3QoCqvE5fl+nylMaIr9PVV1w343YRDtsy+Rwu7XI=
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+313
View File
@@ -0,0 +1,313 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"context"
"fmt"
"math/big"
"strings"
"github.com/luxfi/geth/accounts/abi"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
gethereum "github.com/luxfi/geth"
)
// This file holds the minimal, hand-written ABI for the four governance contracts
// and a single read-only call helper. Only the VIEW functions and the structs the
// eight read tools touch are declared — the package never packs a state-mutating
// method, so it has no calldata path that could change chain state. The struct
// tuple component order/types below mirror the Solidity sources EXACTLY
// (AIParams.sol, AIGovernor.sol, IAIGovernor.sol, AIThoughtRegistry.sol,
// AIReputation.sol); the parity tests assert that.
// EthCaller is the minimal, READ-ONLY chain surface this server needs. Both the
// production *ethclient.Client (dialed in New) and the in-process simulated test
// backend's Client satisfy it. It deliberately exposes NO transaction-sending
// method: there is no Send/SendTransaction here, so no caller of this package can
// submit a tx through it. (Decomplecting, Hickey: the dependency is "a thing that
// can read the chain", not "a URL" — which is also what makes the tests injectable.)
type EthCaller interface {
CallContract(ctx context.Context, call gethereum.CallMsg, blockNumber *big.Int) ([]byte, error)
ChainID(ctx context.Context) (*big.Int, error)
BlockNumber(ctx context.Context) (uint64, error)
HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error)
}
// ----------------------------------------------------------------------------
// Solidity-mirror structs. Field ORDER and Go types match the .sol tuple layout
// exactly so abi.UnpackIntoInterface decodes a returned struct field-for-field.
// ----------------------------------------------------------------------------
// Round mirrors AIParams.Round (AIParams.sol struct order).
type Round struct {
ModelSpecHash [32]byte
PromptHash [32]byte
KnobKey string
Lo *big.Int
Hi *big.Int
N uint8
Threshold uint8
OpenedAt uint64
Deadline uint64
Opener common.Address
Status uint8
SubmissionCount uint8
CanonicalValue *big.Int
}
// Proposal mirrors AIParams.Proposal.
type Proposal struct {
Operator common.Address
Value *big.Int
ConfidenceBucket uint16
EvidenceHash [32]byte
SubmittedAt uint64
}
// Thought mirrors IAIGovernor.Thought (field order is load-bearing — settle fills
// the tail fields, and the commit-reveal trio closes the struct).
type Thought struct {
ModelSpecHash [32]byte
PromptHash [32]byte
EvidenceHash [32]byte
N uint8
Threshold uint8
OpenedAt uint64
Deadline uint64
Opener common.Address
Status uint8
SubmissionCount uint8
KnobKey string
CanonicalVote uint8
CanonicalBucket uint16
AgreeCount uint8
EvidenceRoot [32]byte
CommitReveal bool
CommitDeadline uint64
RevealDeadline uint64
}
// Verdict mirrors IAIGovernor.Verdict.
type Verdict struct {
Operator common.Address
Vote uint8
ConfidenceBucket uint16
EvidenceHash [32]byte
SubmittedAt uint64
}
// ThoughtReceipt mirrors IAIThoughtRegistry.ThoughtReceipt.
type ThoughtReceipt struct {
ModelId [32]byte
PromptHash [32]byte
OutputHash [32]byte
PaymentHash [32]byte
QuorumProof [32]byte
Payer common.Address
Operator common.Address
Cost *big.Int // uint96
RegisteredAt uint64
BlockNumber uint64
}
// Rep mirrors AIReputation.Rep.
type Rep struct {
WeightBps uint32
Participated uint32
Agreed uint32
LastTaskId1 uint64
LastUpdated uint64
}
// ----------------------------------------------------------------------------
// ABI JSON — view functions only. Tuple components carry names so the unpacker
// can map them, but we decode into the mirror structs above by position.
// ----------------------------------------------------------------------------
const roundTuple = `{"name":"r","type":"tuple","components":[` +
`{"name":"modelSpecHash","type":"bytes32"},` +
`{"name":"promptHash","type":"bytes32"},` +
`{"name":"knobKey","type":"string"},` +
`{"name":"lo","type":"uint256"},` +
`{"name":"hi","type":"uint256"},` +
`{"name":"n","type":"uint8"},` +
`{"name":"threshold","type":"uint8"},` +
`{"name":"openedAt","type":"uint64"},` +
`{"name":"deadline","type":"uint64"},` +
`{"name":"opener","type":"address"},` +
`{"name":"status","type":"uint8"},` +
`{"name":"submissionCount","type":"uint8"},` +
`{"name":"canonicalValue","type":"uint256"}]}`
const proposalTuple = `{"name":"p","type":"tuple","components":[` +
`{"name":"operator","type":"address"},` +
`{"name":"value","type":"uint256"},` +
`{"name":"confidenceBucket","type":"uint16"},` +
`{"name":"evidenceHash","type":"bytes32"},` +
`{"name":"submittedAt","type":"uint64"}]}`
const thoughtTuple = `{"name":"t","type":"tuple","components":[` +
`{"name":"modelSpecHash","type":"bytes32"},` +
`{"name":"promptHash","type":"bytes32"},` +
`{"name":"evidenceHash","type":"bytes32"},` +
`{"name":"n","type":"uint8"},` +
`{"name":"threshold","type":"uint8"},` +
`{"name":"openedAt","type":"uint64"},` +
`{"name":"deadline","type":"uint64"},` +
`{"name":"opener","type":"address"},` +
`{"name":"status","type":"uint8"},` +
`{"name":"submissionCount","type":"uint8"},` +
`{"name":"knobKey","type":"string"},` +
`{"name":"canonicalVote","type":"uint8"},` +
`{"name":"canonicalBucket","type":"uint16"},` +
`{"name":"agreeCount","type":"uint8"},` +
`{"name":"evidenceRoot","type":"bytes32"},` +
`{"name":"commitReveal","type":"bool"},` +
`{"name":"commitDeadline","type":"uint64"},` +
`{"name":"revealDeadline","type":"uint64"}]}`
const verdictTuple = `{"name":"v","type":"tuple","components":[` +
`{"name":"operator","type":"address"},` +
`{"name":"vote","type":"uint8"},` +
`{"name":"confidenceBucket","type":"uint16"},` +
`{"name":"evidenceHash","type":"bytes32"},` +
`{"name":"submittedAt","type":"uint64"}]}`
const receiptTuple = `{"name":"rc","type":"tuple","components":[` +
`{"name":"modelId","type":"bytes32"},` +
`{"name":"promptHash","type":"bytes32"},` +
`{"name":"outputHash","type":"bytes32"},` +
`{"name":"paymentHash","type":"bytes32"},` +
`{"name":"quorumProof","type":"bytes32"},` +
`{"name":"payer","type":"address"},` +
`{"name":"operator","type":"address"},` +
`{"name":"cost","type":"uint96"},` +
`{"name":"registeredAt","type":"uint64"},` +
`{"name":"blockNumber","type":"uint64"}]}`
const repTuple = `{"name":"rep","type":"tuple","components":[` +
`{"name":"weightBps","type":"uint32"},` +
`{"name":"participated","type":"uint32"},` +
`{"name":"agreed","type":"uint32"},` +
`{"name":"lastTaskId1","type":"uint64"},` +
`{"name":"lastUpdated","type":"uint64"}]}`
// aiParamsABI: AIParams view functions used by param_value / param_history.
const aiParamsABI = `[` +
`{"type":"function","stateMutability":"view","name":"valueOf","inputs":[{"name":"modelSpecHash","type":"bytes32"},{"name":"knobKey","type":"string"}],"outputs":[{"name":"value","type":"uint256"},{"name":"decided","type":"bool"}]},` +
`{"type":"function","stateMutability":"view","name":"roundCount","inputs":[],"outputs":[{"name":"","type":"uint256"}]},` +
`{"type":"function","stateMutability":"view","name":"getRound","inputs":[{"name":"roundId","type":"uint256"}],"outputs":[` + roundTuple + `]},` +
`{"type":"function","stateMutability":"view","name":"getProposals","inputs":[{"name":"roundId","type":"uint256"}],"outputs":[{"name":"","type":"tuple[]","components":[` +
`{"name":"operator","type":"address"},{"name":"value","type":"uint256"},{"name":"confidenceBucket","type":"uint16"},{"name":"evidenceHash","type":"bytes32"},{"name":"submittedAt","type":"uint64"}]}]}` +
`]`
// aiGovernorABI: AIGovernor view functions used by thought_status / quorum_status /
// operator_reputation / pending_operations.
const aiGovernorABI = `[` +
`{"type":"function","stateMutability":"view","name":"getThought","inputs":[{"name":"taskId","type":"uint256"}],"outputs":[` + thoughtTuple + `]},` +
`{"type":"function","stateMutability":"view","name":"getVerdicts","inputs":[{"name":"taskId","type":"uint256"}],"outputs":[{"name":"","type":"tuple[]","components":[` +
`{"name":"operator","type":"address"},{"name":"vote","type":"uint8"},{"name":"confidenceBucket","type":"uint16"},{"name":"evidenceHash","type":"bytes32"},{"name":"submittedAt","type":"uint64"}]}]},` +
`{"type":"function","stateMutability":"view","name":"taskCount","inputs":[],"outputs":[{"name":"","type":"uint256"}]},` +
`{"type":"function","stateMutability":"view","name":"isOperator","inputs":[{"name":"who","type":"address"}],"outputs":[{"name":"","type":"bool"}]},` +
`{"type":"function","stateMutability":"view","name":"bondOf","inputs":[{"name":"who","type":"address"}],"outputs":[{"name":"","type":"uint256"}]},` +
`{"type":"function","stateMutability":"view","name":"minBond","inputs":[],"outputs":[{"name":"","type":"uint256"}]}` +
`]`
// aiThoughtRegistryABI: registry view functions used by receipt_lookup.
const aiThoughtRegistryABI = `[` +
`{"type":"function","stateMutability":"view","name":"exists","inputs":[{"name":"receiptId","type":"bytes32"}],"outputs":[{"name":"","type":"bool"}]},` +
`{"type":"function","stateMutability":"view","name":"getReceipt","inputs":[{"name":"receiptId","type":"bytes32"}],"outputs":[` + receiptTuple + `]},` +
`{"type":"function","stateMutability":"view","name":"receiptCount","inputs":[],"outputs":[{"name":"","type":"uint256"}]}` +
`]`
// aiReputationABI: reputation view functions used by operator_reputation.
const aiReputationABI = `[` +
`{"type":"function","stateMutability":"view","name":"weightOf","inputs":[{"name":"operator","type":"address"}],"outputs":[{"name":"","type":"uint32"}]},` +
`{"type":"function","stateMutability":"view","name":"repOf","inputs":[{"name":"operator","type":"address"}],"outputs":[` + repTuple + `]},` +
`{"type":"function","stateMutability":"view","name":"agreementRateBps","inputs":[{"name":"operator","type":"address"}],"outputs":[{"name":"","type":"uint32"}]}` +
`]`
// boundABI pairs a parsed ABI with the address it is deployed at. A read tool
// packs a method's calldata, sends it via EthCaller.CallContract (eth_call), and
// unpacks the return. There is exactly one chain-access verb here (CallContract),
// which is the read-only eth_call — no path packs or sends a transaction.
type boundABI struct {
abi abi.ABI
addr common.Address
}
func newBoundABI(jsonABI string, addr common.Address) (*boundABI, error) {
parsed, err := abi.JSON(strings.NewReader(jsonABI))
if err != nil {
return nil, fmt.Errorf("mcp: parse ABI: %w", err)
}
return &boundABI{abi: parsed, addr: addr}, nil
}
// call packs `method`(args...), executes it as a read-only eth_call against the
// bound address at the latest block, and returns the decoded output values.
func (b *boundABI) call(ctx context.Context, ec EthCaller, method string, args ...interface{}) ([]interface{}, error) {
return b.callAt(ctx, ec, nil, method, args...)
}
// callAt is `call` pinned to a specific block (nil = latest). Pinning lets a tool
// that issues SEVERAL reads which must agree (e.g. quorum_status reading verdicts and
// then each operator's bond) take them all at ONE block, so a state change between the
// reads — a bond withdraw racing the tally — cannot produce an inconsistent snapshot.
// The in-process test backend ignores the block arg (it has only latest state); the
// production *ethclient.Client honors it.
func (b *boundABI) callAt(ctx context.Context, ec EthCaller, block *big.Int, method string, args ...interface{}) ([]interface{}, error) {
m, ok := b.abi.Methods[method]
if !ok {
return nil, fmt.Errorf("mcp: unknown method %q", method)
}
in, err := b.abi.Pack(method, args...)
if err != nil {
return nil, fmt.Errorf("mcp: pack %s: %w", method, err)
}
out, err := ec.CallContract(ctx, gethereum.CallMsg{To: &b.addr, Data: in}, block)
if err != nil {
return nil, fmt.Errorf("mcp: eth_call %s @ %s: %w", method, b.addr.Hex(), err)
}
vals, err := m.Outputs.Unpack(out)
if err != nil {
return nil, fmt.Errorf("mcp: unpack %s: %w", method, err)
}
return vals, nil
}
// callStruct packs `method`(args...), runs the read-only eth_call, and unpacks a
// SINGLE struct (or slice-of-struct) return into a value of type T, mapping the
// Solidity tuple field-for-field by position.
//
// geth's abi.UnpackIntoInterface special-cases a single output: it treats the
// destination as a wrapper struct and writes the unpacked value into its FIRST field
// (Arguments.copyAtomic). So we hand it a one-field wrapper whose field is T; geth
// then field-copies the tuple into it by index — which is exactly why T's field order
// MUST mirror the Solidity struct (it does; the parity tests assert it).
func callStruct[T any](ctx context.Context, b *boundABI, ec EthCaller, method string, args ...interface{}) (T, error) {
return callStructAt[T](ctx, b, ec, nil, method, args...)
}
// callStructAt is `callStruct` pinned to a specific block (nil = latest); see callAt
// for why pinning matters. quorum_status uses it to read getThought / getVerdicts at
// the same block it reads the bonds, for a consistent settle-equivalent snapshot.
func callStructAt[T any](ctx context.Context, b *boundABI, ec EthCaller, block *big.Int, method string, args ...interface{}) (T, error) {
var wrap struct{ V T }
in, err := b.abi.Pack(method, args...)
if err != nil {
return wrap.V, fmt.Errorf("mcp: pack %s: %w", method, err)
}
out, err := ec.CallContract(ctx, gethereum.CallMsg{To: &b.addr, Data: in}, block)
if err != nil {
return wrap.V, fmt.Errorf("mcp: eth_call %s @ %s: %w", method, b.addr.Hex(), err)
}
if err := b.abi.UnpackIntoInterface(&wrap, method, out); err != nil {
return wrap.V, fmt.Errorf("mcp: unpack %s: %w", method, err)
}
return wrap.V, nil
}
+199
View File
@@ -0,0 +1,199 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"encoding/json"
"fmt"
"math/big"
"strings"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/common/hexutil"
)
// Boundary validation (Hickey: validate at the edge, trust internal functions).
// MCP tool arguments arrive as decoded JSON (numbers are float64, everything else a
// string), so every public-input field is parsed and checked here before any read.
// maxArgStringLen bounds any string-shaped argument (knob keys, 0x-hex ids, decimal/hex
// integers) BEFORE it is parsed, so a multi-megabyte hex/decimal blob is rejected at the
// boundary rather than driving a giant big.Int.SetString or hex decode. Real knob keys,
// 32-byte hashes, addresses and task ids are all well under this.
const maxArgStringLen = 4096
// argString returns a required string argument, rejecting absurdly long inputs.
func argString(args map[string]interface{}, name string) (string, error) {
v, ok := args[name]
if !ok {
return "", fmt.Errorf("%s: missing required argument %q", "args", name)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("args: %q must be a string", name)
}
if len(s) > maxArgStringLen {
return "", fmt.Errorf("args: %q is too long (%d > %d bytes)", name, len(s), maxArgStringLen)
}
return s, nil
}
// argBytes32 parses a required 0x-hex bytes32 argument.
func argBytes32(args map[string]interface{}, name string) ([32]byte, error) {
var out [32]byte
s, err := argString(args, name)
if err != nil {
return out, err
}
b, err := hexutil.Decode(ensure0x(s))
if err != nil {
return out, fmt.Errorf("args: %q is not valid hex: %w", name, err)
}
if len(b) != 32 {
return out, fmt.Errorf("args: %q must be 32 bytes, got %d", name, len(b))
}
copy(out[:], b)
return out, nil
}
// argAddress parses a required 0x-hex address argument.
func argAddress(args map[string]interface{}, name string) (common.Address, error) {
s, err := argString(args, name)
if err != nil {
return common.Address{}, err
}
s = ensure0x(s)
if !common.IsHexAddress(s) {
return common.Address{}, fmt.Errorf("args: %q is not a valid address", name)
}
return common.HexToAddress(s), nil
}
// argUint256 parses a required unsigned integer (used for task/round ids). Accepts a
// JSON number, a decimal string, or a 0x-hex string. Negative values are rejected.
func argUint256(args map[string]interface{}, name string) (*big.Int, error) {
v, ok := args[name]
if !ok {
return nil, fmt.Errorf("args: missing required argument %q", name)
}
n, err := toBigInt(v)
if err != nil {
return nil, fmt.Errorf("args: %q: %w", name, err)
}
if n.Sign() < 0 {
return nil, fmt.Errorf("args: %q must be non-negative", name)
}
return n, nil
}
// maxLimit is the hard cap on any "limit" argument. It bounds the per-tool read fan-out
// (param_history issues ~2 eth_calls per round, so maxLimit rounds stays well under the
// per-request eth_call ceiling in server.go). MCP results are small; a caller never
// legitimately needs more in one call.
const maxLimit = 256
// argLimit reads an optional positive "limit" argument, falling back to def. A
// non-positive or absent limit yields def; anything over maxLimit is clamped to maxLimit
// so a caller cannot force an unbounded scan.
func argLimit(args map[string]interface{}, def int) int {
v, ok := args["limit"]
if !ok {
return def
}
n, err := toBigInt(v)
if err != nil || n.Sign() <= 0 {
return def
}
if !n.IsInt64() || n.Int64() > maxLimit {
return maxLimit
}
return int(n.Int64())
}
// argFromRound reads the optional "fromRound" argument; nil (use default) when
// absent or unparseable. Clamped to <= count-1 by the caller.
func argFromRound(args map[string]interface{}, count *big.Int) *big.Int {
v, ok := args["fromRound"]
if !ok {
return nil
}
n, err := toBigInt(v)
if err != nil || n.Sign() < 0 {
return nil
}
return n
}
// toBigInt converts a decoded-JSON value (float64, json.Number, or string) to a
// *big.Int. Floats must be integral. Strings may be decimal or 0x-hex.
func toBigInt(v interface{}) (*big.Int, error) {
switch t := v.(type) {
case float64:
if t != float64(int64(t)) {
return nil, fmt.Errorf("expected an integer, got %v", t)
}
return big.NewInt(int64(t)), nil
case json.Number:
n, ok := new(big.Int).SetString(t.String(), 10)
if !ok {
return nil, fmt.Errorf("invalid integer %q", t.String())
}
return n, nil
case string:
// Bound the raw string BEFORE parsing so a multi-MB numeric literal cannot drive
// an unbounded big.Int allocation.
if len(t) > maxArgStringLen {
return nil, fmt.Errorf("integer string too long (%d > %d bytes)", len(t), maxArgStringLen)
}
s := strings.TrimSpace(t)
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
n, ok := new(big.Int).SetString(s[2:], 16)
if !ok {
return nil, fmt.Errorf("invalid hex integer %q", s)
}
return n, nil
}
n, ok := new(big.Int).SetString(s, 10)
if !ok {
return nil, fmt.Errorf("invalid integer %q", s)
}
return n, nil
default:
return nil, fmt.Errorf("expected a number, got %T", v)
}
}
func ensure0x(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
return s
}
return "0x" + s
}
// ----------------------------------------------------------------------------
// JSON-schema helpers for the tool descriptors (tools/list inputSchema).
// ----------------------------------------------------------------------------
func objSchema(props map[string]interface{}, required []string) map[string]interface{} {
if props == nil {
props = map[string]interface{}{}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
func strSchema(desc string) map[string]interface{} {
return map[string]interface{}{"type": "string", "description": desc}
}
func intSchema(desc string) map[string]interface{} {
return map[string]interface{}{"type": "integer", "description": desc}
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Command aivm-gov-mcp is the drop-in, READ-ONLY governance MCP server binary for
// the Lux AIVM (A-Chain) governance stack. It reads its EVM RPC URL and the four
// deployed contract addresses from the environment and serves the eight read tools
// over stdio (JSON-RPC 2.0). It holds no key and submits no transaction.
//
// Environment:
//
// LUX_GOV_EVM_RPC EVM RPC URL of the governance chain (required)
// LUX_AIPARAMS_ADDR AIParams contract address (required)
// LUX_AIGOVERNOR_ADDR AIGovernor contract address (required)
// LUX_AITHOUGHTREGISTRY_ADDR AIThoughtRegistry contract address (required)
// LUX_AIREPUTATION_ADDR AIReputation contract address (required)
//
// Drop into an MCP client (hanzo-dev / claude desktop) as a stdio server:
//
// { "command": "aivm-gov-mcp", "env": { "LUX_GOV_EVM_RPC": "https://…/ext/bc/C/rpc", … } }
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/luxfi/geth/common"
governance "github.com/luxfi/mcp/governance"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "aivm-gov-mcp:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := configFromEnv()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv, err := governance.New(ctx, cfg)
if err != nil {
return err
}
return srv.Serve(ctx, os.Stdin, os.Stdout)
}
func configFromEnv() (governance.Config, error) {
var cfg governance.Config
cfg.EVMRPC = os.Getenv("LUX_GOV_EVM_RPC")
if cfg.EVMRPC == "" {
return cfg, fmt.Errorf("LUX_GOV_EVM_RPC is required")
}
var err error
if cfg.AIParams, err = envAddr("LUX_AIPARAMS_ADDR"); err != nil {
return cfg, err
}
if cfg.AIGovernor, err = envAddr("LUX_AIGOVERNOR_ADDR"); err != nil {
return cfg, err
}
if cfg.AIThoughtRegistry, err = envAddr("LUX_AITHOUGHTREGISTRY_ADDR"); err != nil {
return cfg, err
}
if cfg.AIReputation, err = envAddr("LUX_AIREPUTATION_ADDR"); err != nil {
return cfg, err
}
return cfg, nil
}
func envAddr(name string) (common.Address, error) {
v := os.Getenv(name)
if v == "" {
return common.Address{}, fmt.Errorf("%s is required", name)
}
if !common.IsHexAddress(v) {
return common.Address{}, fmt.Errorf("%s=%q is not a valid address", name, v)
}
return common.HexToAddress(v), nil
}
+367
View File
@@ -0,0 +1,367 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"bufio"
"context"
"math/big"
"strings"
"sync/atomic"
"testing"
"time"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
)
// ----------------------------------------------------------------------------
// Test EthCaller stubs (read-only; they implement only the four read methods, so
// they can never grow a write path — same surface the production client exposes).
// ----------------------------------------------------------------------------
// blockingCaller's CallContract blocks until the call's context is cancelled, modeling a
// hung upstream RPC. It returns the context error so the per-call timeout surfaces.
type blockingCaller struct{}
func (blockingCaller) CallContract(ctx context.Context, _ gethereum.CallMsg, _ *big.Int) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (blockingCaller) ChainID(context.Context) (*big.Int, error) { return big.NewInt(1), nil }
func (blockingCaller) BlockNumber(context.Context) (uint64, error) { return 1, nil }
func (blockingCaller) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) {
return &types.Header{Number: big.NewInt(1), Time: 1_700_000_000}, nil
}
// countingThoughtCaller returns a minimal still-Open thought for every getThought call
// and counts CallContract invocations. taskCount is large so pending_operations wants to
// scan a big window — letting us prove the per-request eth_call ceiling bites first.
type countingThoughtCaller struct {
calls atomic.Int64
taskCount uint64
}
func (c *countingThoughtCaller) CallContract(_ context.Context, call gethereum.CallMsg, _ *big.Int) ([]byte, error) {
n := c.calls.Add(1)
// We don't decode the selector; we answer based on output shape the caller expects.
// pending_operations issues taskCount() (one uint256) then getThought() (a Thought
// tuple) repeatedly. Distinguish by returning a uint256 for the FIRST call and a
// Thought tuple thereafter — the unpacker only needs well-formed bytes.
if n == 1 {
return encodeUint256(new(big.Int).SetUint64(c.taskCount)), nil
}
return encodeOpenThought(), nil
}
func (c *countingThoughtCaller) ChainID(context.Context) (*big.Int, error) { return big.NewInt(1), nil }
func (c *countingThoughtCaller) BlockNumber(context.Context) (uint64, error) { return c.taskCount, nil }
func (c *countingThoughtCaller) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) {
return &types.Header{Number: new(big.Int).SetUint64(c.taskCount), Time: 1_700_000_000}, nil
}
// stubServer builds a Server over a custom EthCaller with non-zero contract addresses
// (so newServer accepts the config) and small test budgets.
func stubServer(t *testing.T, ec EthCaller) *Server {
t.Helper()
addr := common.HexToAddress("0x000000000000000000000000000000000000dEaD")
srv, err := NewWithCaller(ec, Config{
AIParams: addr,
AIGovernor: addr,
AIThoughtRegistry: addr,
AIReputation: addr,
})
if err != nil {
t.Fatalf("stub server: %v", err)
}
return srv
}
// TestPerCallTimeoutDoesNotWedgeServer is the HIGH-3 test: a hung upstream RPC must not
// wedge the stdio loop. With a tiny call budget, a tools/call that blocks on a never-
// returning CallContract returns a timeout (isError) AND the server still answers the
// NEXT request on the same stream.
func TestPerCallTimeoutDoesNotWedgeServer(t *testing.T) {
srv := stubServer(t, blockingCaller{})
srv.callTimeout = 100 * time.Millisecond // shrink so the test is fast
// First request hits the blocking caller (param_value issues an eth_call -> blocks
// until the per-call deadline). Second request is chain_state, which the stub answers
// without an eth_call, proving the loop survived.
in := strings.Join([]string{
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_value","arguments":{"modelSpecHash":"0x` + strings.Repeat("00", 32) + `","knobKey":"x"}}}`,
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chain_state","arguments":{}}}`,
}, "\n") + "\n"
start := time.Now()
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(in), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
elapsed := time.Since(start)
resps := decodeLines(t, out.String())
if len(resps) != 2 {
t.Fatalf("want 2 responses, got %d:\n%s", len(resps), out.String())
}
// 1) The blocked call returns a tool error (isError=true) mentioning a deadline.
r1 := resps[0]["result"].(map[string]interface{})
if r1["isError"] != true {
t.Fatalf("blocked call should be isError, got: %v", resps[0])
}
txt := r1["content"].([]interface{})[0].(map[string]interface{})["text"].(string)
if !strings.Contains(strings.ToLower(txt), "deadline") && !strings.Contains(strings.ToLower(txt), "context") {
t.Fatalf("blocked call error %q does not look like a timeout", txt)
}
// 2) The SECOND request was answered — the server is still responsive.
r2 := resps[1]["result"].(map[string]interface{})
if _, ok := r2["content"]; !ok {
t.Fatalf("second request not answered (server wedged?): %v", resps[1])
}
// Sanity: total time is bounded by the budget, not infinite.
if elapsed > 5*time.Second {
t.Fatalf("Serve took %v — the timeout did not bound the hung call", elapsed)
}
t.Logf("hung call bounded by per-call timeout; server stayed responsive (elapsed %v)", elapsed)
}
// TestPerRequestCallCeiling is the MEDIUM-4 test: one tools/call cannot issue an
// unbounded number of eth_calls. With a small ceiling and a chain that would otherwise
// invite a deep scan, pending_operations bails with a ceiling error rather than hammering
// the upstream thousands of times.
func TestPerRequestCallCeiling(t *testing.T) {
caller := &countingThoughtCaller{taskCount: 100000}
srv := stubServer(t, caller)
srv.maxCallsPerRequest = 8 // tiny ceiling for the test
// pending_operations with a large limit would normally scan many tasks. The ceiling
// must stop it well before taskCount.
_, err := srv.CallTool(context.Background(), toolPendingOperations, map[string]interface{}{"limit": 200})
if err == nil {
t.Fatal("expected a per-request ceiling error, got nil")
}
if !strings.Contains(err.Error(), "ceiling") {
t.Fatalf("error %q is not the ceiling error", err.Error())
}
// The wrapper allows exactly `max` calls then fails the (max+1)th, so total calls is
// bounded at max+1 — proving we did NOT run away to taskCount.
if got := caller.calls.Load(); got > int64(srv.maxCallsPerRequest+1) {
t.Fatalf("issued %d eth_calls, ceiling was %d — runaway not stopped", got, srv.maxCallsPerRequest)
}
t.Logf("call ceiling stopped the scan at %d eth_calls (max %d)", caller.calls.Load(), srv.maxCallsPerRequest)
}
// TestBoundedCallerForwardsUnderLimit proves the bounded caller is transparent below the
// ceiling (it must not break normal operation).
func TestBoundedCallerForwardsUnderLimit(t *testing.T) {
caller := &countingThoughtCaller{taskCount: 3}
bc := newBoundedCaller(caller, 100)
for i := 0; i < 5; i++ {
if _, err := bc.CallContract(context.Background(), gethereum.CallMsg{To: &common.Address{}}, nil); err != nil {
t.Fatalf("call %d under the ceiling errored: %v", i, err)
}
}
if got := bc.calls.Load(); got != 5 {
t.Fatalf("bounded caller counted %d, want 5", got)
}
}
// TestOversizedLineIsRejectedAndLoopSurvives is the HIGH-2 test: a request line larger
// than maxLineBytes must be rejected as a parse error WITHOUT buffering it whole, and the
// stdio loop must survive to answer the next (well-formed) request.
func TestOversizedLineIsRejectedAndLoopSurvives(t *testing.T) {
srv := stubServer(t, blockingCaller{}) // chain_state needs no eth_call here
// One oversized line (a JSON string padded past the cap) then a valid chain_state.
huge := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"chain_state","arguments":{"pad":"` +
strings.Repeat("A", maxLineBytes+1024) + `"}}}`
valid := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chain_state","arguments":{}}}`
in := huge + "\n" + valid + "\n"
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(in), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
resps := decodeLines(t, out.String())
if len(resps) != 2 {
t.Fatalf("want 2 responses (oversized parse error + valid result), got %d", len(resps))
}
// 1) Oversized line -> a JSON-RPC parse error (id null since we never parsed it).
if resps[0]["error"] == nil {
t.Fatalf("oversized line should yield a parse error, got: %v", resps[0])
}
em := resps[0]["error"].(map[string]interface{})
if int(em["code"].(float64)) != codeParseError {
t.Fatalf("oversized line error code=%v, want %d", em["code"], codeParseError)
}
// 2) The following valid request was still served.
if _, ok := resps[1]["result"]; !ok {
t.Fatalf("loop did not survive the oversized line: %v", resps[1])
}
t.Logf("oversized line rejected as parse error; loop survived and served the next request")
}
// TestReadLimitedLineDrainsAndResyncs unit-tests the bounded reader directly: an
// oversized line is reported tooLong with no buffered bytes, and the NEXT ReadByte starts
// on the following line (the reader drained to the newline).
func TestReadLimitedLineDrainsAndResyncs(t *testing.T) {
const max = 16
data := strings.Repeat("X", max*4) + "\n" + "ok\n"
r := bufio.NewReader(strings.NewReader(data))
line, tooLong, err := readLimitedLine(r, max)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !tooLong {
t.Fatal("expected tooLong=true for the long line")
}
if len(line) != 0 {
t.Fatalf("oversized line should return no buffered bytes, got %d", len(line))
}
// Next read returns the SECOND line intact.
line2, tooLong2, err := readLimitedLine(r, max)
if err != nil {
t.Fatalf("unexpected err on line 2: %v", err)
}
if tooLong2 {
t.Fatal("second line is short; tooLong must be false")
}
if strings.TrimSpace(string(line2)) != "ok" {
t.Fatalf("after draining, next line=%q, want %q", strings.TrimSpace(string(line2)), "ok")
}
}
// TestArgInputLengthCapped is the LOW test: an absurdly long string/integer argument is
// rejected at the boundary BEFORE parsing (no giant big.Int or hex decode).
func TestArgInputLengthCapped(t *testing.T) {
bigStr := strings.Repeat("9", maxArgStringLen+1)
// argString path (knobKey).
if _, err := argString(map[string]interface{}{"knobKey": bigStr}, "knobKey"); err == nil {
t.Fatal("expected argString to reject an over-long string")
}
// toBigInt string path (task/round ids).
if _, err := argUint256(map[string]interface{}{"taskId": bigStr}, "taskId"); err == nil {
t.Fatal("expected argUint256 to reject an over-long integer string")
}
// A normal value still works.
if _, err := argString(map[string]interface{}{"knobKey": "temperature"}, "knobKey"); err != nil {
t.Fatalf("normal knobKey wrongly rejected: %v", err)
}
}
// TestArgLimitClampedToMax is the MEDIUM-4 boundary test: limit is clamped to maxLimit.
func TestArgLimitClampedToMax(t *testing.T) {
if got := argLimit(map[string]interface{}{"limit": float64(1_000_000)}, 16); got != maxLimit {
t.Fatalf("argLimit(1e6)=%d, want clamp to %d", got, maxLimit)
}
if got := argLimit(map[string]interface{}{}, 16); got != 16 {
t.Fatalf("argLimit(absent)=%d, want default 16", got)
}
if got := argLimit(map[string]interface{}{"limit": float64(10)}, 16); got != 10 {
t.Fatalf("argLimit(10)=%d, want 10", got)
}
}
// TestDispatchRecoversPanic is the LOW test: a handler panic becomes one isError tool
// result, not a whole-server crash. We register a panicking handler on a stub server.
func TestDispatchRecoversPanic(t *testing.T) {
srv := stubServer(t, blockingCaller{})
srv.tools["boom"] = func(_ *Server, _ context.Context, _ map[string]interface{}) (interface{}, error) {
panic("kaboom")
}
_, err := srv.CallTool(context.Background(), "boom", nil)
if err == nil {
t.Fatal("expected a recovered-panic error, got nil")
}
if !strings.Contains(err.Error(), "panicked") {
t.Fatalf("error %q is not the recovered-panic error", err.Error())
}
// And the server is still usable afterwards.
if _, err := srv.CallTool(context.Background(), toolChainState, nil); err != nil {
t.Fatalf("server unusable after recovered panic: %v", err)
}
}
// ----------------------------------------------------------------------------
// helpers to synthesize ABI-encoded return values for the counting stub
// ----------------------------------------------------------------------------
// encodeUint256 returns the 32-byte big-endian encoding of v (an ABI uint256 return).
func encodeUint256(v *big.Int) []byte {
return common.LeftPadBytes(v.Bytes(), 32)
}
// encodeOpenThought returns a minimally-valid ABI encoding of the AIGovernor Thought
// tuple with Status=Open. The tuple has two dynamic fields (knobKey string at the end is
// the only string; everything else is static within the head), so we hand-assemble the
// head + the string tail. We only need the unpacker to succeed and Status to read Open.
func encodeOpenThought() []byte {
// The Thought tuple is a single dynamic struct return, so the outer return is one
// offset word pointing at the tuple, then the tuple's own head, then its dynamic tail
// (knobKey). Build it explicitly.
zero32 := make([]byte, 32)
word := func(n uint64) []byte { return common.LeftPadBytes(new(big.Int).SetUint64(n).Bytes(), 32) }
// Tuple field layout (mirrors abi.go Thought / thoughtTuple), all padded to 32 bytes
// in the tuple head; knobKey is dynamic so its head slot is an offset into the tuple.
// Fields in order:
// 0 modelSpecHash bytes32
// 1 promptHash bytes32
// 2 evidenceHash bytes32
// 3 n uint8
// 4 threshold uint8
// 5 openedAt uint64
// 6 deadline uint64
// 7 opener address
// 8 status uint8 <- must be Open(1)
// 9 submissionCount uint8
// 10 knobKey string (dynamic -> offset)
// 11 canonicalVote uint8
// 12 canonicalBucket uint16
// 13 agreeCount uint8
// 14 evidenceRoot bytes32
// 15 commitReveal bool
// 16 commitDeadline uint64
// 17 revealDeadline uint64
const nFields = 18
head := make([]byte, 0, nFields*32)
appendWord := func(b []byte) { head = append(head, b...) }
appendWord(zero32) // 0 modelSpecHash
appendWord(zero32) // 1 promptHash
appendWord(zero32) // 2 evidenceHash
appendWord(word(0)) // 3 n
appendWord(word(0)) // 4 threshold
appendWord(word(0)) // 5 openedAt
appendWord(word(0)) // 6 deadline
appendWord(zero32) // 7 opener
appendWord(word(uint64(statusOpen))) // 8 status = Open
appendWord(word(0)) // 9 submissionCount
// 10 knobKey offset: filled after we know the head size. Placeholder for now.
knobOffsetIdx := len(head)
appendWord(zero32) // 10 knobKey offset (placeholder)
appendWord(word(0)) // 11 canonicalVote
appendWord(word(0)) // 12 canonicalBucket
appendWord(word(0)) // 13 agreeCount
appendWord(zero32) // 14 evidenceRoot
appendWord(word(0)) // 15 commitReveal (false)
appendWord(word(0)) // 16 commitDeadline
appendWord(word(0)) // 17 revealDeadline
// knobKey tail: offset is relative to the START of the tuple head. Empty string =
// length 0 (one zero word).
tupleHeadLen := uint64(len(head))
copy(head[knobOffsetIdx:knobOffsetIdx+32], word(tupleHeadLen))
tail := word(0) // string length 0
tuple := append(head, tail...)
// Outer return: one offset word (0x20) to the tuple.
out := append(word(32), tuple...)
return out
}
+553
View File
@@ -0,0 +1,553 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Test harness — OPTION B, done with the REAL EVM (not canned values).
//
// The in-process `simulated` backend in THIS geth checkout is broken: its own
// TestNewBackend panics in node.Start -> eth.setupDiscovery (p2pServer.LocalNode()
// is nil in the NoDiscovery path — a fork regression). Rather than patch upstream
// geth, the harness drives the contracts on a real EVM via core/vm/runtime: a
// persistent state.StateDB seeded with the REAL compiled contract bytecode
// (testdata/*.json copied from standard/out). runtime.Create runs each real
// constructor; runtime.Call runs the real Solidity for both state-mutating ops and
// read-only eth_calls. So the parity tests compare MCP output against what the REAL
// getRound/getThought/valueOf actually return — full Solidity execution, not a fake
// table.
//
// The harness exposes an evmChain that satisfies the package's read-only EthCaller
// (CallContract/ChainID/BlockNumber/HeaderByNumber), so the MCP tools read the exact
// chain the harness drove. The HARNESS legitimately deploys and sends mutating calls
// to set up state — that is test scaffolding, NOT the MCP library (whose read-only
// invariant is asserted over its own non-test source by TestMCPReadOnlyToolsCannotSubmitTx).
package governance
import (
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"math/big"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/accounts/abi"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/common/hexutil"
"github.com/luxfi/geth/core/state"
"github.com/luxfi/geth/core/tracing"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/core/vm/runtime"
"github.com/luxfi/geth/params"
gethereum "github.com/luxfi/geth"
)
// artifact is the subset of a foundry build artifact we load.
type artifact struct {
ABI json.RawMessage `json:"abi"`
Bytecode struct {
Object string `json:"object"`
} `json:"bytecode"`
}
func loadArtifact(t *testing.T, name string) (abi.ABI, []byte) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read artifact %s: %v", name, err)
}
var a artifact
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("parse artifact %s: %v", name, err)
}
parsed, err := abi.JSON(strings.NewReader(string(a.ABI)))
if err != nil {
t.Fatalf("parse abi %s: %v", name, err)
}
code, err := hexutil.Decode(a.Bytecode.Object)
if err != nil {
t.Fatalf("decode bytecode %s: %v", name, err)
}
return parsed, code
}
// boundContract pairs a parsed ABI with the address the harness deployed it at, for
// harness-side reads/writes (INDEPENDENT of the MCP package's hand-written ABI).
type boundContract struct {
abi abi.ABI
addr common.Address
}
// evmChain is a real-EVM test chain: a persistent StateDB, the real deployed
// contracts, and a block counter. It implements the package's EthCaller.
type evmChain struct {
t *testing.T
cfg *params.ChainConfig
st *state.StateDB
blockNum uint64
blockTime uint64
chainID *big.Int
deployer common.Address
gasLimit uint64
}
var _ EthCaller = (*evmChain)(nil)
// newEVMChain builds the chain, funds the deployer + operator keys, and deploys the
// four governance contracts (Governor first; Params/Reputation reference it).
func newEVMChain(t *testing.T, operatorKeys []*ecdsa.PrivateKey) (*evmChain, *chainEnv) {
t.Helper()
st, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
if err != nil {
t.Fatalf("new statedb: %v", err)
}
c := &evmChain{
t: t,
cfg: params.AllDevChainProtocolChanges,
st: st,
blockNum: 1,
blockTime: 1_700_000_000,
chainID: new(big.Int).Set(params.AllDevChainProtocolChanges.ChainID),
gasLimit: 120_000_000,
}
deployerKey, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("gen deployer: %v", err)
}
c.deployer = addrOf(deployerKey)
c.fund(c.deployer)
for _, k := range operatorKeys {
c.fund(addrOf(k))
}
env := &chainEnv{t: t, c: c, deployerKey: deployerKey}
env.deployAll()
return c, env
}
func (c *evmChain) fund(a common.Address) {
c.st.SetBalance(a, uint256.MustFromBig(ethers(1_000_000)), tracing.BalanceChangeUnspecified)
}
// getHashFn supplies blockhash(n): a deterministic non-zero hash for PAST blocks
// (n < current), zero for the current/future block. AIParams seeds its committee
// with blockhash(openBlock), available only once a later block exists — this models
// that exactly.
func (c *evmChain) getHashFn(n uint64) common.Hash {
if n >= c.blockNum {
return common.Hash{}
}
return common.BytesToHash(crypto.Keccak256([]byte(fmt.Sprintf("block-%d", n))))
}
// runtimeCfg builds the runtime.Config for the CURRENT block from `origin`.
func (c *evmChain) runtimeCfg(origin common.Address) *runtime.Config {
return &runtime.Config{
ChainConfig: c.cfg,
Origin: origin,
State: c.st,
BlockNumber: new(big.Int).SetUint64(c.blockNum),
Time: c.blockTime,
GasLimit: c.gasLimit,
GasPrice: big.NewInt(0),
Value: big.NewInt(0),
BaseFee: big.NewInt(0),
Difficulty: big.NewInt(0),
GetHashFn: c.getHashFn,
}
}
// deploy runs a real constructor and returns the deployed address. The deployer's
// nonce is bumped first so successive CREATE addresses don't collide.
func (c *evmChain) deploy(code []byte) common.Address {
cfg := c.runtimeCfg(c.deployer)
_, addr, _, err := runtime.Create(code, cfg)
if err != nil {
c.t.Fatalf("deploy: %v", err)
}
if len(c.st.GetCode(addr)) == 0 {
c.t.Fatalf("deploy: no runtime code at %s", addr.Hex())
}
// Bump nonce so the next CREATE uses a fresh address.
c.st.SetNonce(c.deployer, c.st.GetNonce(c.deployer)+1, tracing.NonceChangeUnspecified)
return addr
}
// sendTx runs a state-mutating call from `from` to `to`, persisting state changes.
// A revert fails the test (mirrors a mined tx that must succeed). value is wei.
func (c *evmChain) sendTx(from common.Address, to common.Address, data []byte, value *big.Int) {
cfg := c.runtimeCfg(from)
if value != nil {
cfg.Value = value
}
_, _, err := runtime.Call(to, data, cfg)
if err != nil {
c.t.Fatalf("sendTx -> %s reverted: %v", to.Hex(), err)
}
// Each tx advances the chain by one block (and the clock), as a real chain would.
c.mine()
}
// mine advances the block number and clock. Empty mining (no tx) is used to make a
// future block exist so blockhash(openBlock) becomes available.
func (c *evmChain) mine() {
c.st.Finalise(true)
c.blockNum++
c.blockTime += 12
}
// advanceBlocks mines `n` empty blocks (advances number + clock without a tx).
func (c *evmChain) advanceBlocks(n int) {
for i := 0; i < n; i++ {
c.mine()
}
}
// advanceSeconds jumps the clock forward (to pass a voting deadline) and mines.
func (c *evmChain) advanceSeconds(secs uint64) {
c.blockTime += secs
c.mine()
}
// ---- EthCaller (the read-only surface the MCP server consumes) ----
// CallContract executes a read-only eth_call: it snapshots the StateDB, runs the
// call, and reverts so a view cannot mutate state. blockNumber is ignored (the
// in-memory chain has only the latest state) — matching how the tools call at HEAD.
func (c *evmChain) CallContract(_ context.Context, call gethereum.CallMsg, _ *big.Int) ([]byte, error) {
if call.To == nil {
return nil, fmt.Errorf("evmChain: nil To in eth_call")
}
snap := c.st.Snapshot()
cfg := c.runtimeCfg(call.From)
ret, _, err := runtime.Call(*call.To, call.Data, cfg)
c.st.RevertToSnapshot(snap)
if err != nil {
return nil, err
}
return ret, nil
}
func (c *evmChain) ChainID(_ context.Context) (*big.Int, error) {
return new(big.Int).Set(c.chainID), nil
}
func (c *evmChain) BlockNumber(_ context.Context) (uint64, error) {
return c.blockNum, nil
}
// HeaderByNumber returns a synthetic header for the requested height (nil = head).
// Its Hash() must equal getHashFn for the SAME height so ChainObservation.Verify's
// reorg check is consistent: we set Number/Time and derive a deterministic root such
// that header.Hash() == the canonical hash the harness would report. To keep the two
// in lockstep, the header hash is taken from getHashFn for past blocks and a stable
// head hash for the current block.
func (c *evmChain) HeaderByNumber(_ context.Context, number *big.Int) (*types.Header, error) {
n := c.blockNum
if number != nil {
n = number.Uint64()
}
return c.syntheticHeader(n), nil
}
// syntheticHeader builds a header whose Hash() is deterministic per height. We encode
// the height into the Extra field so types.Header.Hash() (keccak of the RLP) is a
// pure, stable function of the height — the observation's blockHash is then stable
// and the reorg check (re-read the same height, compare hash) is meaningful.
func (c *evmChain) syntheticHeader(n uint64) *types.Header {
h := &types.Header{
Number: new(big.Int).SetUint64(n),
Time: c.headerTime(n),
Extra: []byte(fmt.Sprintf("aivm-mcp-test-block-%d", n)),
}
return h
}
// headerTime maps a height to the timestamp it had. The current block uses the live
// clock; past blocks reconstruct (blockTime - 12*(blockNum-n)).
func (c *evmChain) headerTime(n uint64) uint64 {
if n >= c.blockNum {
return c.blockTime
}
return c.blockTime - 12*(c.blockNum-n)
}
// ---- harness-side reads (independent decode path) ----
// readStruct is the harness-side INDEPENDENT struct decode (full artifact ABI),
// using the same single-output wrapper geth requires (see callStruct in abi.go).
func readStruct[T any](t *testing.T, c *evmChain, b boundContract, method string, args ...interface{}) T {
t.Helper()
var wrap struct{ V T }
in, err := b.abi.Pack(method, args...)
if err != nil {
t.Fatalf("pack %s: %v", method, err)
}
ret, err := c.CallContract(context.Background(), gethereum.CallMsg{To: &b.addr, Data: in}, nil)
if err != nil {
t.Fatalf("call %s: %v", method, err)
}
if err := b.abi.UnpackIntoInterface(&wrap, method, ret); err != nil {
t.Fatalf("unpack %s: %v", method, err)
}
return wrap.V
}
// callViewValues unpacks into a []interface{} (for non-struct returns).
func (c *evmChain) callViewValues(b boundContract, method string, args ...interface{}) []interface{} {
in, err := b.abi.Pack(method, args...)
if err != nil {
c.t.Fatalf("pack %s: %v", method, err)
}
ret, err := c.CallContract(context.Background(), gethereum.CallMsg{To: &b.addr, Data: in}, nil)
if err != nil {
c.t.Fatalf("call %s: %v", method, err)
}
m := b.abi.Methods[method]
vals, err := m.Outputs.Unpack(ret)
if err != nil {
c.t.Fatalf("unpack %s: %v", method, err)
}
return vals
}
// ----------------------------------------------------------------------------
// chainEnv — the high-level driver the tests use (open/settle rounds & thoughts).
// ----------------------------------------------------------------------------
type chainEnv struct {
t *testing.T
c *evmChain
deployerKey *ecdsa.PrivateKey
params boundContract
governor boundContract
registry boundContract
rep boundContract
}
func (e *chainEnv) cli() EthCaller { return e.c }
func (e *chainEnv) deployAll() {
t := e.t
govABI, govCode := loadArtifact(t, "AIGovernor.json")
paramABI, paramCode := loadArtifact(t, "AIParams.json")
regABI, regCode := loadArtifact(t, "AIThoughtRegistry.json")
repABI, repCode := loadArtifact(t, "AIReputation.json")
// AIGovernor(minBond=1, deregisterCooldown=0, rewardPerThought=0, openFee=0,
// treasury=0, keyValuePairs=0). openFee==0 lets treasury be zero.
govAddr := e.deployWithCtor(govABI, govCode,
big.NewInt(1), uint64(0), big.NewInt(0), big.NewInt(0), common.Address{}, common.Address{})
e.governor = boundContract{abi: govABI, addr: govAddr}
// AIParams(governor, treasury=0, openFee=0, proposalFee=0).
paramAddr := e.deployWithCtor(paramABI, paramCode, govAddr, common.Address{}, big.NewInt(0), big.NewInt(0))
e.params = boundContract{abi: paramABI, addr: paramAddr}
// AIThoughtRegistry(admin=deployer).
regAddr := e.deployWithCtor(regABI, regCode, e.c.deployer)
e.registry = boundContract{abi: regABI, addr: regAddr}
// AIReputation(governor, alphaBps=2000).
repAddr := e.deployWithCtor(repABI, repCode, govAddr, uint32(2000))
e.rep = boundContract{abi: repABI, addr: repAddr}
}
// deployWithCtor packs constructor args, appends to creation bytecode, and deploys.
func (e *chainEnv) deployWithCtor(a abi.ABI, code []byte, args ...interface{}) common.Address {
packed, err := a.Pack("", args...)
if err != nil {
e.t.Fatalf("pack ctor: %v", err)
}
return e.c.deploy(append(append([]byte{}, code...), packed...))
}
// mcpServer builds an MCP Server reading THIS chain through the EthCaller.
func (e *chainEnv) mcpServer() *Server {
srv, err := NewWithCaller(e.c, Config{
AIParams: e.params.addr,
AIGovernor: e.governor.addr,
AIThoughtRegistry: e.registry.addr,
AIReputation: e.rep.addr,
})
if err != nil {
e.t.Fatalf("new mcp server: %v", err)
}
return srv
}
// ---- AIParams round driving ----
func (e *chainEnv) registerOperator(key *ecdsa.PrivateKey) {
in, err := e.governor.abi.Pack("registerOperator")
if err != nil {
e.t.Fatalf("pack registerOperator: %v", err)
}
e.c.sendTx(addrOf(key), e.governor.addr, in, big.NewInt(1)) // minBond = 1 wei
}
const oneHour = uint64(3600)
// openRound opens an AIParams value round and returns its roundId. n == operator
// population so the sortition committee is the WHOLE set (size>=population ⇒ every
// registered operator is selected), making the round deterministic to drive.
func (e *chainEnv) openRound(spec [32]byte, knobKey string, lo, hi *big.Int, n uint8) *big.Int {
roundID := e.callParams("roundCount")[0].(*big.Int)
threshold := n/2 + 1
var zero [32]byte
in, err := e.params.abi.Pack("open", spec, zero, knobKey, lo, hi, n, threshold, oneHour)
if err != nil {
e.t.Fatalf("pack open: %v", err)
}
e.c.sendTx(e.c.deployer, e.params.addr, in, nil)
return roundID
}
// proposalDigest mirrors AIParams.proposalDigest (packed encoding).
func (e *chainEnv) proposalDigest(roundID *big.Int, op common.Address, spec [32]byte, value *big.Int, bucket uint16, evidence [32]byte) []byte {
domain := crypto.Keccak256([]byte("hanzo/thinking-parameters/proposal/v1"))
packed := concat(domain, leftPad32(e.c.chainID.Bytes()), e.params.addr.Bytes(),
leftPad32(roundID.Bytes()), spec[:], leftPad32(value.Bytes()), u16be(bucket), evidence[:], op.Bytes())
return crypto.Keccak256(packed)
}
func (e *chainEnv) submitProposal(key *ecdsa.PrivateKey, roundID *big.Int, spec [32]byte, value *big.Int, bucket uint16) {
op := addrOf(key)
var evidence [32]byte
sig := e.sign(key, e.proposalDigest(roundID, op, spec, value, bucket, evidence))
in, err := e.params.abi.Pack("submitProposal", roundID, value, bucket, evidence, sig)
if err != nil {
e.t.Fatalf("pack submitProposal: %v", err)
}
e.c.sendTx(op, e.params.addr, in, nil)
}
func (e *chainEnv) settleRound(roundID *big.Int) {
in, err := e.params.abi.Pack("settle", roundID)
if err != nil {
e.t.Fatalf("pack settle: %v", err)
}
e.c.sendTx(e.c.deployer, e.params.addr, in, nil)
}
func (e *chainEnv) callParams(method string, args ...interface{}) []interface{} {
return e.c.callViewValues(e.params, method, args...)
}
// ---- AIGovernor thought driving ----
func (e *chainEnv) openThought(spec [32]byte, knobKey string, n uint8) *big.Int {
taskID := e.c.callViewValues(e.governor, "taskCount")[0].(*big.Int)
threshold := n/2 + 1
var zero [32]byte
in, err := e.governor.abi.Pack("openThought", spec, zero, zero, n, threshold, oneHour, knobKey)
if err != nil {
e.t.Fatalf("pack openThought: %v", err)
}
e.c.sendTx(e.c.deployer, e.governor.addr, in, nil)
return taskID
}
// verdictDigest mirrors AIGovernor._verdictDigest (packed encoding).
func (e *chainEnv) verdictDigest(taskID *big.Int, op common.Address, spec [32]byte, vote uint8, bucket uint16, evidence [32]byte) []byte {
domain := crypto.Keccak256([]byte("hanzo/thinking-governor/verdict/v1"))
packed := concat(domain, leftPad32(e.c.chainID.Bytes()), e.governor.addr.Bytes(),
leftPad32(taskID.Bytes()), spec[:], []byte{vote}, u16be(bucket), evidence[:], op.Bytes())
return crypto.Keccak256(packed)
}
func (e *chainEnv) submitVerdict(key *ecdsa.PrivateKey, taskID *big.Int, spec [32]byte, vote uint8, bucket uint16) {
op := addrOf(key)
var evidence [32]byte
sig := e.sign(key, e.verdictDigest(taskID, op, spec, vote, bucket, evidence))
in, err := e.governor.abi.Pack("submitVerdict", taskID, vote, bucket, evidence, sig)
if err != nil {
e.t.Fatalf("pack submitVerdict: %v", err)
}
e.c.sendTx(op, e.governor.addr, in, nil)
}
func (e *chainEnv) settleThought(taskID *big.Int) {
in, err := e.governor.abi.Pack("settle", taskID)
if err != nil {
e.t.Fatalf("pack settle: %v", err)
}
e.c.sendTx(e.c.deployer, e.governor.addr, in, nil)
}
// sign produces a secp256k1 signature over `digest` with v in {27,28} (the form the
// contracts' OZ ECDSA expects; Go crypto.Sign yields {0,1}, so add 27).
func (e *chainEnv) sign(key *ecdsa.PrivateKey, digest []byte) []byte {
sig, err := crypto.Sign(digest, key)
if err != nil {
e.t.Fatalf("sign: %v", err)
}
sig[64] += 27
return sig
}
// ----------------------------------------------------------------------------
// byte helpers (packed-encoding parity with abi.encodePacked) + misc
// ----------------------------------------------------------------------------
func concat(parts ...[]byte) []byte {
var out []byte
for _, p := range parts {
out = append(out, p...)
}
return out
}
func leftPad32(b []byte) []byte {
if len(b) >= 32 {
return b[len(b)-32:]
}
out := make([]byte, 32)
copy(out[32-len(b):], b)
return out
}
func u16be(v uint16) []byte { return []byte{byte(v >> 8), byte(v)} }
func ethers(n int64) *big.Int { return new(big.Int).Mul(big.NewInt(n), big.NewInt(1e18)) }
// addrOf derives the geth common.Address for a key. luxfi/crypto.PubkeyToAddress
// returns its OWN common.Address ([20]byte under a different package); we bridge
// through the raw bytes — both are 20-byte big-endian, byte-identical.
func addrOf(key *ecdsa.PrivateKey) common.Address {
a := crypto.PubkeyToAddress(key.PublicKey)
return common.BytesToAddress(a[:])
}
// specHash is a deterministic test model-spec hash from a label.
func specHash(label string) [32]byte {
var s [32]byte
copy(s[:], crypto.Keccak256([]byte(label)))
return s
}
// genKeys returns n freshly generated keys, sorted by address ascending for a stable
// operator ordering across runs.
func genKeys(t *testing.T, n int) []*ecdsa.PrivateKey {
t.Helper()
keys := make([]*ecdsa.PrivateKey, 0, n)
for i := 0; i < n; i++ {
k, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("gen key: %v", err)
}
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return addrOf(keys[i]).Hex() < addrOf(keys[j]).Hex()
})
return keys
}
+423
View File
@@ -0,0 +1,423 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"context"
"crypto/ecdsa"
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// callTool runs a read tool and returns its result as a map (all tool results are
// JSON objects). Fails the test on tool error or wrong shape.
func callTool(t *testing.T, srv *Server, name string, args map[string]interface{}) map[string]interface{} {
t.Helper()
res, err := srv.CallTool(context.Background(), name, args)
if err != nil {
t.Fatalf("tool %s: %v", name, err)
}
m, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("tool %s: result not a map: %T", name, res)
}
return m
}
// driveSettledRound registers `n` operators, opens a round, fills all n slots with
// the SAME value (so the median is deterministic), and settles it. Returns the
// roundId, the spec, the knobKey, and the decided value. n is the committee size and
// equals the operator population, so every operator is sampled (size>=population).
func driveSettledRound(t *testing.T, env *chainEnv, ops []*ecdsa.PrivateKey, value *big.Int) (*big.Int, [32]byte, string) {
t.Helper()
spec := specHash("zen-nano-temperature")
knobKey := "temperature"
n := uint8(len(ops))
for _, k := range ops {
env.registerOperator(k)
}
roundID := env.openRound(spec, knobKey, big.NewInt(0), big.NewInt(1000), n)
// Seed = blockhash(openBlock) is only available from openBlock+1, so mine one
// empty block before the first proposal.
env.c.advanceBlocks(1)
for _, k := range ops {
env.submitProposal(k, roundID, spec, value, 5000)
}
// All n slots filled ⇒ settle is allowed before the deadline.
env.settleRound(roundID)
return roundID, spec, knobKey
}
// TestLiveParamRoundSetsAIParamsValue drives a full AIParams round on the deployed
// contract (open → operators propose → settle) and asserts param_value (MCP) reads
// back the newly-decided value equal to the on-chain valueOf.
func TestLiveParamRoundSetsAIParamsValue(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
value := big.NewInt(420)
// Before the round settles, the knob is not decided.
specPre := specHash("zen-nano-temperature")
pre := callTool(t, srv, toolParamValue, map[string]interface{}{
"modelSpecHash": common.BytesToHash(specPre[:]).Hex(),
"knobKey": "temperature",
})
if pre["decided"] != false {
t.Fatalf("expected decided=false before round, got %v", pre["decided"])
}
_, spec, knobKey := driveSettledRound(t, env, ops, value)
// MCP param_value must now reflect the decided value.
got := callTool(t, srv, toolParamValue, map[string]interface{}{
"modelSpecHash": common.BytesToHash(spec[:]).Hex(),
"knobKey": knobKey,
})
if got["decided"] != true {
t.Fatalf("param_value decided=%v, want true", got["decided"])
}
if got["value"] != value.String() {
t.Fatalf("param_value value=%v, want %s", got["value"], value.String())
}
// Cross-check directly against the on-chain valueOf (independent of MCP).
out := env.callParams("valueOf", spec, knobKey)
onchainValue := out[0].(*big.Int)
onchainDecided := out[1].(bool)
if !onchainDecided || onchainValue.Cmp(value) != 0 {
t.Fatalf("on-chain valueOf=(%s,%v), want (%s,true)", onchainValue, onchainDecided, value)
}
if got["value"] != onchainValue.String() {
t.Fatalf("MCP value %v != on-chain value %s", got["value"], onchainValue)
}
}
// TestMCPParamHistoryMatchesAIParams asserts param_history output equals what
// AIParams.roundCount/getRound/getProposals return for the same rounds (field parity).
func TestMCPParamHistoryMatchesAIParams(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
_, spec, knobKey := driveSettledRound(t, env, ops, big.NewInt(777))
hist := callTool(t, srv, toolParamHistory, map[string]interface{}{})
// roundCount parity.
rcOut := env.callParams("roundCount")
wantCount := rcOut[0].(*big.Int)
if hist["roundCount"] != wantCount.String() {
t.Fatalf("param_history roundCount=%v, want %s", hist["roundCount"], wantCount)
}
rounds, ok := hist["rounds"].([]interface{})
if !ok || len(rounds) == 0 {
t.Fatalf("param_history returned no rounds: %v", hist["rounds"])
}
// The newest round (index 0, descending) is roundId wantCount-1. Field parity
// against the on-chain getRound.
first := rounds[0].(map[string]interface{})
roundID := new(big.Int).Sub(wantCount, big.NewInt(1))
if first["roundId"] != roundID.String() {
t.Fatalf("first round id=%v, want %s", first["roundId"], roundID)
}
onchain := readStruct[Round](t, env.c, env.params, "getRound", roundID)
rj := first["round"].(map[string]interface{})
if rj["knobKey"] != onchain.KnobKey || onchain.KnobKey != knobKey {
t.Fatalf("knobKey mismatch: mcp=%v onchain=%v want=%s", rj["knobKey"], onchain.KnobKey, knobKey)
}
if rj["modelSpecHash"] != common.BytesToHash(spec[:]).Hex() {
t.Fatalf("modelSpecHash mismatch: %v", rj["modelSpecHash"])
}
if rj["canonicalValue"] != onchain.CanonicalValue.String() {
t.Fatalf("canonicalValue mismatch: mcp=%v onchain=%s", rj["canonicalValue"], onchain.CanonicalValue)
}
if got := toUint8(t, rj["submissionCount"]); got != onchain.SubmissionCount {
t.Fatalf("submissionCount mismatch: mcp=%d onchain=%d", got, onchain.SubmissionCount)
}
if got := toUint8(t, rj["status"]); got != onchain.Status {
t.Fatalf("status mismatch: mcp=%d onchain=%d", got, onchain.Status)
}
// Proposals parity: count and each value/operator.
onProps := readStruct[[]Proposal](t, env.c, env.params, "getProposals", roundID)
mcpProps := first["proposals"].([]interface{})
if len(mcpProps) != len(onProps) {
t.Fatalf("proposals count mismatch: mcp=%d onchain=%d", len(mcpProps), len(onProps))
}
for i := range onProps {
p := mcpProps[i].(map[string]interface{})
if p["value"] != onProps[i].Value.String() {
t.Errorf("proposal[%d] value: mcp=%v onchain=%s", i, p["value"], onProps[i].Value)
}
if p["operator"] != onProps[i].Operator.Hex() {
t.Errorf("proposal[%d] operator: mcp=%v onchain=%s", i, p["operator"], onProps[i].Operator.Hex())
}
}
}
// TestMCPThoughtStatusMatchesAIGovernor asserts thought_status fields equal
// AIGovernor.getThought for the same taskId, and the derived status matches the
// contract's open/settled/quorum semantics.
func TestMCPThoughtStatusMatchesAIGovernor(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
spec := specHash("upgrade-proposal-7")
knobKey := "approve-upgrade-7"
n := uint8(len(ops))
for _, k := range ops {
env.registerOperator(k)
}
taskID := env.openThought(spec, knobKey, n)
// While Open: derived status must be "Open".
openRes := callTool(t, srv, toolThoughtStatus, map[string]interface{}{"taskId": taskID.String()})
if openRes["status"] != "Open" {
t.Fatalf("open thought status=%v, want Open", openRes["status"])
}
// All operators vote YES @ bucket 10000 (unanimous ⇒ quorum on settle).
for _, k := range ops {
env.submitVerdict(k, taskID, spec, voteYes, 10000)
}
// Committee full (submissionCount==n) but settle needs the deadline; advance time.
env.c.advanceSeconds(2 * 3600) // past the 1h voting window
env.settleThought(taskID)
res := callTool(t, srv, toolThoughtStatus, map[string]interface{}{"taskId": taskID.String()})
onchain := readStruct[Thought](t, env.c, env.governor, "getThought", taskID)
// Field parity.
if res["modelSpecHash"] != common.BytesToHash(onchain.ModelSpecHash[:]).Hex() {
t.Fatalf("modelSpecHash mismatch: %v", res["modelSpecHash"])
}
if res["knobKey"] != onchain.KnobKey {
t.Fatalf("knobKey mismatch: mcp=%v onchain=%v", res["knobKey"], onchain.KnobKey)
}
if got := toUint8(t, res["threshold"]); got != onchain.Threshold {
t.Fatalf("threshold mismatch: mcp=%d onchain=%d", got, onchain.Threshold)
}
if got := toUint8(t, res["n"]); got != onchain.N {
t.Fatalf("n mismatch: mcp=%d onchain=%d", got, onchain.N)
}
if got := toUint8(t, res["agreeCount"]); got != onchain.AgreeCount {
t.Fatalf("agreeCount mismatch: mcp=%d onchain=%d", got, onchain.AgreeCount)
}
if got := toUint8(t, res["canonicalVote"]); got != onchain.CanonicalVote {
t.Fatalf("canonicalVote mismatch: mcp=%d onchain=%d", got, onchain.CanonicalVote)
}
// Derived status semantics: a YES-quorum settle ⇒ Settled, status==2.
if onchain.Status != statusSettled {
t.Fatalf("expected on-chain Settled (2), got %d", onchain.Status)
}
if res["status"] != "Settled" {
t.Fatalf("derived status=%v, want Settled", res["status"])
}
// taskCount parity.
tcOut := env.c.callViewValues(env.governor, "taskCount")
if res["taskCount"] != tcOut[0].(*big.Int).String() {
t.Fatalf("taskCount mismatch: mcp=%v onchain=%v", res["taskCount"], tcOut[0])
}
// quorum_status cross-check: unanimous YES means votesFor==n, quorumReached.
q := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if got := toInt(t, q["votesFor"]); got != int(n) {
t.Fatalf("quorum votesFor=%d, want %d", got, n)
}
if q["quorumReached"] != true {
t.Fatalf("quorumReached=%v, want true", q["quorumReached"])
}
}
// TestOperatorVerdictBindsMCPObservedState constructs a ChainObservation from tool
// reads, hashes it, embeds the hash in a mock verdict, and proves the verdict's bound
// observation-hash re-derives from re-reading the chain — i.e. a verdict can be
// checked against the state it claims to have observed.
func TestOperatorVerdictBindsMCPObservedState(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
_, spec, knobKey := driveSettledRound(t, env, ops, big.NewInt(333))
// 1. The operator reads the decided knob via MCP and pins it into an observation.
value, decided, err := srv.readParamValue(context.Background(), spec, knobKey)
if err != nil {
t.Fatalf("readParamValue: %v", err)
}
obs1, err := newObservation(context.Background(), env.c, toolParamValue, []ObservedFact{
{Key: "knobKey", Value: knobKey},
{Key: "value", Value: value.String()},
{Key: "decided", Value: boolStr(decided)},
})
if err != nil {
t.Fatalf("newObservation: %v", err)
}
boundHash := obs1.Hash()
// 2. A mock verdict embeds that observation hash as its evidence binding.
verdict := mockVerdict{
taskID: big.NewInt(0),
vote: voteYes,
obsBound: boundHash,
}
// 3. A checker RE-READS the same chain facts (no new block has been added) and
// re-derives the observation hash; it must equal what the verdict bound.
value2, decided2, err := srv.readParamValue(context.Background(), spec, knobKey)
if err != nil {
t.Fatalf("re-read: %v", err)
}
obs2, err := newObservation(context.Background(), env.c, toolParamValue, []ObservedFact{
// Deliberately a different append order to prove canonicalization sorts it.
{Key: "value", Value: value2.String()},
{Key: "decided", Value: boolStr(decided2)},
{Key: "knobKey", Value: knobKey},
})
if err != nil {
t.Fatalf("newObservation re-read: %v", err)
}
rederived := obs2.Hash()
if rederived != verdict.obsBound {
t.Fatalf("verdict observation binding broken:\n bound: %s\n rederived: %s", verdict.obsBound.Hex(), rederived.Hex())
}
// Canonical bytes must be byte-identical regardless of fact append order.
if string(obs1.Canonical()) != string(obs2.Canonical()) {
t.Fatalf("canonical serialization not order-independent:\n %s\n %s", obs1.Canonical(), obs2.Canonical())
}
// And a verdict claiming a DIFFERENT value must NOT match (binding is meaningful).
tampered, _ := newObservation(context.Background(), env.c, toolParamValue, []ObservedFact{
{Key: "knobKey", Value: knobKey},
{Key: "value", Value: "999999"}, // lie about the value
{Key: "decided", Value: boolStr(decided)},
})
if tampered.Hash() == verdict.obsBound {
t.Fatal("tampered observation hashed equal to the honest binding — hash is not binding")
}
}
// TestStaleMCPObservationRejectedOrFlagged proves an observation taken at block N is
// detectably stale at block N+k: Verify flags/rejects when the chain advances past
// maxAgeBlocks, and rejects when the observed block's hash no longer matches (reorg).
func TestStaleMCPObservationRejectedOrFlagged(t *testing.T) {
ops := genKeys(t, 1)
_, env := newEVMChain(t, ops)
// Observation at the current head.
obs, err := newObservation(context.Background(), env.c, toolChainState, nil)
if err != nil {
t.Fatalf("newObservation: %v", err)
}
// Immediately, with zero tolerance, it is fresh.
fresh, err := obs.Verify(context.Background(), env.c, 0)
if err != nil || !fresh {
t.Fatalf("fresh observation rejected at age 0: fresh=%v err=%v", fresh, err)
}
// Advance the chain by 3 blocks.
env.c.advanceBlocks(3)
// With zero tolerance it is now STALE.
fresh, err = obs.Verify(context.Background(), env.c, 0)
if fresh {
t.Fatal("stale observation (3 blocks old) passed Verify with maxAge=0")
}
if err == nil {
t.Fatal("expected a staleness error, got nil")
}
t.Logf("staleness correctly flagged: %v", err)
// With a generous tolerance (>=3) the SAME observation is still acceptable,
// because its block is still canonical (no reorg) — proving the check is age,
// not just inequality.
fresh, err = obs.Verify(context.Background(), env.c, 8)
if err != nil || !fresh {
t.Fatalf("observation within maxAge=8 wrongly rejected: fresh=%v err=%v", fresh, err)
}
// Reorg detection: forge an observation whose recorded blockHash is wrong for its
// height. Verify must reject it even within the age window.
bad := &ChainObservation{
ChainID: obs.ChainID,
BlockNumber: obs.BlockNumber,
BlockHash: common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
Timestamp: obs.Timestamp,
Tool: toolChainState,
}
fresh, err = bad.Verify(context.Background(), env.c, 1000)
if fresh {
t.Fatal("observation with a non-canonical blockHash passed Verify (reorg not detected)")
}
if err == nil {
t.Fatal("expected a reorg error, got nil")
}
t.Logf("reorg correctly flagged: %v", err)
}
// ----------------------------------------------------------------------------
// test helpers
// ----------------------------------------------------------------------------
// mockVerdict stands in for the operator's signed verdict; the only field the MCP
// binding cares about is obsBound — the ChainObservation hash the operator commits.
type mockVerdict struct {
taskID *big.Int
vote uint8
obsBound common.Hash
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
// toUint8 coerces a JSON-decoded numeric (uint8 from the tool, or float64 if it
// round-tripped through JSON) to uint8.
func toUint8(t *testing.T, v interface{}) uint8 {
t.Helper()
switch n := v.(type) {
case uint8:
return n
case float64:
return uint8(n)
case int:
return uint8(n)
default:
t.Fatalf("not a uint8-ish value: %T (%v)", v, v)
return 0
}
}
func toInt(t *testing.T, v interface{}) int {
t.Helper()
switch n := v.(type) {
case int:
return n
case float64:
return int(n)
case uint8:
return int(n)
default:
t.Fatalf("not an int-ish value: %T (%v)", v, v)
return 0
}
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"context"
"encoding/json"
"fmt"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// ChainObservation is the verifiable record of WHAT the chain said and WHEN.
//
// An operator-LLM reads chain facts through the MCP read tools while deliberating,
// then signs a verdict it submits through the normal AIGovernor tx path (NOT through
// this server). To make that verdict checkable against the state it claims to have
// seen, the operator binds an observation hash into the verdict's evidence. The
// observation pins the block context (number + hash + chainId + timestamp) and the
// exact reads a tool returned, so a third party can (a) re-derive the hash from the
// same reads and (b) detect when the observation is stale or sits on a reorged block.
//
// It is DETERMINISTICALLY SERIALIZABLE — Canonical() emits stable JSON with sorted
// keys (Go's encoding/json sorts map keys, and we sort the Reads slice by key) — and
// HASHABLE via keccak256 over those canonical bytes (Hash). Two parties that observed
// the identical facts at the identical block produce byte-identical Canonical() and
// thus the identical Hash.
type ChainObservation struct {
// Block context: the point in the chain the reads were taken at.
ChainID *big.Int `json:"chainId"`
BlockNumber uint64 `json:"blockNumber"`
BlockHash common.Hash `json:"blockHash"`
Timestamp uint64 `json:"timestamp"`
// Tool is the read tool that produced this observation (e.g. "param_value").
Tool string `json:"tool"`
// Reads is the set of named facts the tool returned, each an already-canonical
// value. Kept as sorted key/value pairs (not a Go map) so the serialization is
// deterministic regardless of insertion order.
Reads []ObservedFact `json:"reads"`
}
// ObservedFact is one named, canonically-encoded chain fact inside an observation.
// Value is a JSON-canonical encoding of the fact (decimal string for integers, hex
// for bytes, etc.) so the hash is stable and language-independent.
type ObservedFact struct {
Key string `json:"key"`
Value string `json:"value"`
}
// newObservation builds an observation by reading the current chain head once and
// stamping it with the supplied reads (which the caller has already canonicalized).
// The reads are sorted by key so the observation is order-independent.
func newObservation(ctx context.Context, ec EthCaller, tool string, reads []ObservedFact) (*ChainObservation, error) {
chainID, err := ec.ChainID(ctx)
if err != nil {
return nil, fmt.Errorf("mcp: observation chainID: %w", err)
}
head, err := ec.HeaderByNumber(ctx, nil)
if err != nil {
return nil, fmt.Errorf("mcp: observation head: %w", err)
}
o := &ChainObservation{
ChainID: chainID,
BlockNumber: head.Number.Uint64(),
BlockHash: head.Hash(),
Timestamp: head.Time,
Tool: tool,
Reads: append([]ObservedFact(nil), reads...),
}
o.sortReads()
return o, nil
}
// sortReads orders Reads by Key (stable insertion-sort; read sets are tiny) and then
// collapses duplicate keys to LAST-writer-wins, so Canonical() is independent of the
// order the tool appended facts AND a set that names the same key twice cannot yield two
// different hashes (which would let two parties "observe" different values under one
// key). The sort is stable, so among equal keys the last-APPENDED value is kept.
func (o *ChainObservation) sortReads() {
for i := 1; i < len(o.Reads); i++ {
k := o.Reads[i]
j := i
for j > 0 && o.Reads[j-1].Key > k.Key {
o.Reads[j] = o.Reads[j-1]
j--
}
o.Reads[j] = k
}
o.Reads = dedupLastWins(o.Reads)
}
// dedupLastWins removes earlier entries that share a Key with a later entry, on an
// already key-sorted (and stable) slice: for a run of equal keys it keeps the last one.
func dedupLastWins(reads []ObservedFact) []ObservedFact {
if len(reads) < 2 {
return reads
}
out := reads[:0]
for i := 0; i < len(reads); i++ {
// Skip this entry if the NEXT entry has the same key (a later writer wins).
if i+1 < len(reads) && reads[i+1].Key == reads[i].Key {
continue
}
out = append(out, reads[i])
}
return out
}
// Canonical returns the deterministic byte serialization of the observation: the
// ChainID is rendered as a decimal string and the block hash as 0x-hex (both via the
// MarshalJSON below), the Reads are sorted, and Go's encoder writes struct fields in
// declaration order. The result is stable across processes and languages.
func (o *ChainObservation) Canonical() []byte {
// Defensive copy + sort so Canonical() never depends on external mutation order.
c := &ChainObservation{
ChainID: o.ChainID,
BlockNumber: o.BlockNumber,
BlockHash: o.BlockHash,
Timestamp: o.Timestamp,
Tool: o.Tool,
Reads: append([]ObservedFact(nil), o.Reads...),
}
c.sortReads()
b, err := json.Marshal(canonicalView{
ChainID: bigString(c.ChainID),
BlockNumber: c.BlockNumber,
BlockHash: c.BlockHash.Hex(),
Timestamp: c.Timestamp,
Tool: c.Tool,
Reads: c.Reads,
})
if err != nil {
// canonicalView is composed only of marshalable scalars/strings/slices, so
// json.Marshal cannot fail here; return an empty slice rather than panic in
// library code if it somehow does.
return []byte{}
}
return b
}
// canonicalView is the fixed-shape, all-scalar projection that Canonical() hashes.
// Using explicit string forms (decimal chainId, 0x-hex hash) removes any ambiguity
// from big.Int / common.Hash JSON encodings across implementations.
type canonicalView struct {
ChainID string `json:"chainId"`
BlockNumber uint64 `json:"blockNumber"`
BlockHash string `json:"blockHash"`
Timestamp uint64 `json:"timestamp"`
Tool string `json:"tool"`
Reads []ObservedFact `json:"reads"`
}
// Hash is keccak256 over the canonical bytes — the value an operator binds into its
// verdict so the verdict can later be checked against the observed chain state.
func (o *ChainObservation) Hash() common.Hash {
return common.BytesToHash(crypto.Keccak256(o.Canonical()))
}
// Verify checks the observation is still current: the chain must not have advanced
// more than maxAgeBlocks past the observed block, and the canonical block at the
// observed height must still hash to the observed hash (a reorg changes it). It
// returns (fresh, error): fresh is false (with a descriptive error) when the
// observation is stale or sits on a block no longer canonical, true when it is still
// safe to act on. A read error is surfaced as a non-nil error with fresh=false.
//
// maxAgeBlocks == 0 means "must be the exact head block" (zero tolerance).
func (o *ChainObservation) Verify(ctx context.Context, ec EthCaller, maxAgeBlocks uint64) (bool, error) {
// Chain identity: the observation must be checked against the SAME chain it was taken
// on. A mismatch means we are pointed at a different network entirely (e.g. testnet vs
// mainnet), so block numbers/hashes are not comparable and the observation is invalid
// here. This is a cheap call and guards against silently verifying on the wrong chain.
chainID, err := ec.ChainID(ctx)
if err != nil {
return false, fmt.Errorf("mcp: verify chainID: %w", err)
}
if o.ChainID == nil || chainID.Cmp(o.ChainID) != 0 {
return false, fmt.Errorf("mcp: chain mismatch: observation chainId=%s, live chainId=%s", bigString(o.ChainID), chainID.String())
}
head, err := ec.BlockNumber(ctx)
if err != nil {
return false, fmt.Errorf("mcp: verify head number: %w", err)
}
if head < o.BlockNumber {
// The chain we are checking against is BEHIND the observation — either a
// reorg shortened it or we are pointed at a different (lagging) node. Either
// way the observation cannot be trusted as current.
return false, fmt.Errorf("mcp: observation is ahead of chain head (obs=%d head=%d): reorg or wrong node", o.BlockNumber, head)
}
if age := head - o.BlockNumber; age > maxAgeBlocks {
return false, fmt.Errorf("mcp: stale observation: chain advanced %d blocks past observed %d (max %d)", age, o.BlockNumber, maxAgeBlocks)
}
// Reorg / fork check: the block at the observed height must still hash to what we
// recorded. If the observed block was reorged out, this hash differs.
hdr, err := ec.HeaderByNumber(ctx, new(big.Int).SetUint64(o.BlockNumber))
if err != nil {
return false, fmt.Errorf("mcp: verify header @ %d: %w", o.BlockNumber, err)
}
if hdr.Hash() != o.BlockHash {
return false, fmt.Errorf("mcp: reorg detected at block %d: observed %s, canonical %s", o.BlockNumber, o.BlockHash.Hex(), hdr.Hash().Hex())
}
return true, nil
}
// bigString renders a *big.Int as a decimal string, treating nil as "0".
func bigString(v *big.Int) string {
if v == nil {
return "0"
}
return v.String()
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"context"
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// TestPendingOperationsAnnotatesDeadlineAndTruncation is the MEDIUM-5 test. It proves:
// - a still-Open task whose voting window has CLOSED is returned with deadlinePassed=true
// (so the LLM knows the window is over even though settle was never called), while a
// fresh Open task reads deadlinePassed=false;
// - when more tasks exist than the scan window inspects, truncated=true and scannedFrom
// marks the lowest id seen (the list is honestly flagged as incomplete).
func TestPendingOperationsAnnotatesDeadlineAndTruncation(t *testing.T) {
ops := genKeys(t, 1)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
for _, k := range ops {
env.registerOperator(k)
}
spec := specHash("pending-ops-spec")
// Task 0: open it, then advance PAST its 1h window without settling -> still Open,
// but deadlinePassed must be true.
t0 := env.openThought(spec, "knob-0", 1)
env.c.advanceSeconds(2 * 3600)
// Task 1: open it fresh (deadline in the future) -> Open, deadlinePassed false.
t1 := env.openThought(spec, "knob-1", 1)
res := callTool(t, srv, toolPendingOperations, map[string]interface{}{})
pending := res["pending"].([]interface{})
// Both tasks are Open, so both appear. Index them by taskId.
byID := map[string]map[string]interface{}{}
for _, p := range pending {
m := p.(map[string]interface{})
byID[m["taskId"].(string)] = m
}
p0, ok := byID[t0.String()]
if !ok {
t.Fatalf("task 0 missing from pending: %v", pending)
}
p1, ok := byID[t1.String()]
if !ok {
t.Fatalf("task 1 missing from pending: %v", pending)
}
if p0["deadlinePassed"] != true {
t.Fatalf("task 0 deadlinePassed=%v, want true (window closed, unsettled)", p0["deadlinePassed"])
}
if p1["deadlinePassed"] != false {
t.Fatalf("task 1 deadlinePassed=%v, want false (fresh window)", p1["deadlinePassed"])
}
// With a tiny limit and only the tail scanned, truncation must be reported honestly.
// Open several more tasks so taskCount exceeds a limit=1 window.
for i := 0; i < 3; i++ {
env.openThought(spec, "knob-extra", 1)
}
// Pass limit as float64 to mirror JSON-decoded args (the real transport path).
small := callTool(t, srv, toolPendingOperations, map[string]interface{}{"limit": float64(1)})
// We asked for at most 1 result; with many open tasks at the tail it returns 1.
if got := len(small["pending"].([]interface{})); got != 1 {
t.Fatalf("limit=1 returned %d pending, want 1", got)
}
if small["truncated"] != true {
t.Fatalf("truncated=%v, want true (older tasks below the window were not inspected)", small["truncated"])
}
// scannedFrom must be > 0 (we did not reach task 0).
sf, ok := new(big.Int).SetString(small["scannedFrom"].(string), 10)
if !ok || sf.Sign() <= 0 {
t.Fatalf("scannedFrom=%v, want a positive lowest-scanned id", small["scannedFrom"])
}
}
// TestPendingOperationsNotTruncatedWhenAllScanned proves truncated=false when the scan
// reaches task 0 (the window covers everything), so the flag is meaningful (not always on).
func TestPendingOperationsNotTruncatedWhenAllScanned(t *testing.T) {
ops := genKeys(t, 1)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
for _, k := range ops {
env.registerOperator(k)
}
spec := specHash("pending-small-spec")
// Just two tasks; the default scan floor (64) covers them to task 0.
env.openThought(spec, "a", 1)
env.openThought(spec, "b", 1)
res := callTool(t, srv, toolPendingOperations, map[string]interface{}{})
if res["truncated"] != false {
t.Fatalf("truncated=%v, want false (scan reached task 0)", res["truncated"])
}
if res["scannedFrom"] != "0" {
t.Fatalf("scannedFrom=%v, want 0 (inspected down to genesis task)", res["scannedFrom"])
}
}
// TestObservationVerifyRejectsChainMismatch is the LOW test: Verify must reject an
// observation taken on a DIFFERENT chain id, even when block number/hash would match.
func TestObservationVerifyRejectsChainMismatch(t *testing.T) {
ops := genKeys(t, 1)
_, env := newEVMChain(t, ops)
obs, err := newObservation(context.Background(), env.c, toolChainState, nil)
if err != nil {
t.Fatalf("newObservation: %v", err)
}
// Sanity: it verifies on its own chain.
if fresh, err := obs.Verify(context.Background(), env.c, 0); err != nil || !fresh {
t.Fatalf("fresh observation rejected on its own chain: fresh=%v err=%v", fresh, err)
}
// Forge a wrong chain id on the observation; Verify must reject as a chain mismatch.
wrong := &ChainObservation{
ChainID: new(big.Int).Add(obs.ChainID, big.NewInt(1)),
BlockNumber: obs.BlockNumber,
BlockHash: obs.BlockHash,
Timestamp: obs.Timestamp,
Tool: toolChainState,
}
fresh, err := wrong.Verify(context.Background(), env.c, 1000)
if fresh {
t.Fatal("observation with mismatched chainId passed Verify")
}
if err == nil {
t.Fatal("expected a chain-mismatch error, got nil")
}
t.Logf("chain mismatch correctly rejected: %v", err)
// A nil chainId is also a mismatch (cannot be trusted).
nilCID := &ChainObservation{BlockNumber: obs.BlockNumber, BlockHash: obs.BlockHash, Tool: toolChainState}
if fresh, _ := nilCID.Verify(context.Background(), env.c, 1000); fresh {
t.Fatal("observation with nil chainId passed Verify")
}
}
// TestObservationDedupsDuplicateKeys is the NIT test: an observation whose Reads name the
// SAME key twice must canonicalize deterministically (last-writer-wins), so two parties
// cannot produce divergent hashes from a dup-key set, and the dup collapses to one entry.
func TestObservationDedupsDuplicateKeys(t *testing.T) {
mk := func(reads []ObservedFact) *ChainObservation {
return &ChainObservation{
ChainID: big.NewInt(7),
BlockNumber: 42,
BlockHash: common.HexToHash("0x01"),
Timestamp: 1000,
Tool: toolParamValue,
Reads: reads,
}
}
// Two sets that differ only in the ORDER of a duplicated "value" key. Last writer
// ("final") must win in BOTH, so the canonical bytes and hash are identical.
a := mk([]ObservedFact{
{Key: "knobKey", Value: "temp"},
{Key: "value", Value: "first"},
{Key: "value", Value: "final"},
})
b := mk([]ObservedFact{
{Key: "value", Value: "first"},
{Key: "knobKey", Value: "temp"},
{Key: "value", Value: "final"},
})
if string(a.Canonical()) != string(b.Canonical()) {
t.Fatalf("dup-key observations not canonical-equal:\n a=%s\n b=%s", a.Canonical(), b.Canonical())
}
if a.Hash() != b.Hash() {
t.Fatalf("dup-key observations hashed differently: %s vs %s", a.Hash().Hex(), b.Hash().Hex())
}
// The duplicate must collapse to a single "value" entry equal to the last writer.
c := mk([]ObservedFact{
{Key: "value", Value: "first"},
{Key: "value", Value: "final"},
})
c.sortReads()
count := 0
var kept string
for _, r := range c.Reads {
if r.Key == "value" {
count++
kept = r.Value
}
}
if count != 1 {
t.Fatalf("duplicate key not collapsed: %d 'value' entries remain", count)
}
if kept != "final" {
t.Fatalf("last-writer-wins broken: kept %q, want final", kept)
}
}
+311
View File
@@ -0,0 +1,311 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"crypto/ecdsa"
"math/big"
"testing"
)
// deregister starts an operator's withdrawal cooldown (does NOT change the bond — the
// operator stays _bonded, so an already-cast verdict still counts at settle).
func (e *chainEnv) deregister(key *ecdsa.PrivateKey) {
in, err := e.governor.abi.Pack("deregister")
if err != nil {
e.t.Fatalf("pack deregister: %v", err)
}
e.c.sendTx(addrOf(key), e.governor.addr, in, nil)
}
// withdrawBond zeroes an operator's bond (after the cooldown — the harness deploys with
// deregisterCooldown==0, so this succeeds immediately after deregister). A zero bond ⇒
// !_bonded ⇒ settle DROPS this operator's verdict.
func (e *chainEnv) withdrawBond(key *ecdsa.PrivateKey) {
in, err := e.governor.abi.Pack("withdrawBond")
if err != nil {
e.t.Fatalf("pack withdrawBond: %v", err)
}
e.c.sendTx(addrOf(key), e.governor.addr, in, nil)
}
// onchainThoughtStatus reads the raw on-chain Status of a task directly (independent of
// MCP), so the test can compare MCP's prediction against the ACTUAL settle outcome.
func (e *chainEnv) onchainThoughtStatus(taskID *big.Int) uint8 {
th := readStruct[Thought](e.t, e.c, e.governor, "getThought", taskID)
return th.Status
}
// TestQuorumStatusMatchesSettleAfterWithdrawRace is the HIGH-1 PoC, made green.
//
// Red's exploit: N operators vote to form a quorum, then enough of them withdraw their
// bond that the operators STILL bonded at settle time fall below threshold. The old
// tallyQuorum counted EVERY verdict, so quorum_status reported quorumReached=true — but
// AIGovernor.settle() counts ONLY bonded operators (see _bonded) and therefore settles
// the task FAILED (NoQuorum). An operator-LLM trusting quorum_status would sign a
// concurring verdict for a quorum that does not exist on-chain.
//
// The fix re-derives the tally under the SAME bonded predicate settle uses (bond != 0 &&
// bond >= minBond, ignoring the deregister flag) at the observed block. This test proves
// quorum_status.quorumReached now MATCHES the real settle outcome in three scenarios:
// - all operators bonded -> quorum stands, settle Settled
// - enough withdraw to drop quorum -> quorum gone, settle Failed (NoQuorum)
// - deregistered but STILL bonded -> verdict still counts (matches _bonded exactly)
func TestQuorumStatusMatchesSettleAfterWithdrawRace(t *testing.T) {
// --- Scenario A: withdrawals drop the bonded set below threshold -> NoQuorum ---
t.Run("withdraw_drops_below_threshold", func(t *testing.T) {
ops := genKeys(t, 3) // threshold = 3/2+1 = 2
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
spec := specHash("upgrade-proposal-withdraw")
knobKey := "approve-upgrade-withdraw"
n := uint8(len(ops))
for _, k := range ops {
env.registerOperator(k)
}
taskID := env.openThought(spec, knobKey, n)
// All three vote YES @ the SAME bucket -> one group of 3 (well over threshold 2).
for _, k := range ops {
env.submitVerdict(k, taskID, spec, voteYes, 10000)
}
// BEFORE any withdrawal, MCP must see a real quorum (sanity: the fix didn't break
// the honest case).
qBefore := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if qBefore["quorumReached"] != true {
t.Fatalf("pre-withdraw quorumReached=%v, want true", qBefore["quorumReached"])
}
if got := toInt(t, qBefore["verdictsCounted"]); got != 3 {
t.Fatalf("pre-withdraw verdictsCounted=%d, want 3", got)
}
// THE RACE: two of the three operators deregister + withdraw their bond. Their
// verdicts are still in getVerdicts(), but they are no longer _bonded, so settle
// drops them. Bonded YES group is now 1 < threshold 2.
for _, k := range ops[:2] {
env.deregister(k)
env.withdrawBond(k)
}
// MCP quorum_status must now report quorumReached=FALSE (matching settle), and
// account for the two dropped verdicts.
q := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if q["quorumReached"] != false {
t.Fatalf("post-withdraw quorumReached=%v, want false (settle will drop unbonded verdicts)", q["quorumReached"])
}
if got := toInt(t, q["verdictsTotal"]); got != 3 {
t.Fatalf("verdictsTotal=%d, want 3 (all verdicts still on-chain)", got)
}
if got := toInt(t, q["verdictsCounted"]); got != 1 {
t.Fatalf("verdictsCounted=%d, want 1 (only the still-bonded operator)", got)
}
if got := toInt(t, q["droppedUnbonded"]); got != 2 {
t.Fatalf("droppedUnbonded=%d, want 2", got)
}
if got := toInt(t, q["bestGroup"]); got != 1 {
t.Fatalf("bestGroup=%d, want 1 (bonded YES group)", got)
}
// Now ACTUALLY settle on-chain (past the deadline) and prove the chain agrees:
// the task settles FAILED (NoQuorum), exactly what MCP predicted.
env.c.advanceSeconds(2 * 3600)
env.settleThought(taskID)
if got := env.onchainThoughtStatus(taskID); got != statusFailed {
t.Fatalf("on-chain settle status=%d, want Failed(%d) — MCP predicted NoQuorum", got, statusFailed)
}
// And thought_status now reads NoQuorum, consistent with the prediction.
ts := callTool(t, srv, toolThoughtStatus, map[string]interface{}{"taskId": taskID.String()})
if ts["status"] != "NoQuorum" {
t.Fatalf("thought_status=%v, want NoQuorum", ts["status"])
}
})
// --- Scenario B: nobody withdraws -> quorum stands -> Settled ---
t.Run("all_bonded_quorum_stands", func(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
spec := specHash("upgrade-proposal-stands")
knobKey := "approve-upgrade-stands"
n := uint8(len(ops))
for _, k := range ops {
env.registerOperator(k)
}
taskID := env.openThought(spec, knobKey, n)
for _, k := range ops {
env.submitVerdict(k, taskID, spec, voteYes, 10000)
}
q := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if q["quorumReached"] != true {
t.Fatalf("quorumReached=%v, want true", q["quorumReached"])
}
if got := toInt(t, q["droppedUnbonded"]); got != 0 {
t.Fatalf("droppedUnbonded=%d, want 0", got)
}
env.c.advanceSeconds(2 * 3600)
env.settleThought(taskID)
if got := env.onchainThoughtStatus(taskID); got != statusSettled {
t.Fatalf("on-chain settle status=%d, want Settled(%d)", got, statusSettled)
}
})
// --- Scenario C: deregistered but STILL bonded -> verdict counts (matches _bonded) ---
// This is the nuance red flagged: settle uses _bonded (bond floor only), NOT _eligible
// (which also checks deregisterAt). A deregistered-but-bonded operator's verdict MUST
// still count. If we wrongly excluded deregistered operators, this quorum would vanish.
t.Run("deregistered_but_bonded_still_counts", func(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
spec := specHash("upgrade-proposal-dereg")
knobKey := "approve-upgrade-dereg"
n := uint8(len(ops))
for _, k := range ops {
env.registerOperator(k)
}
taskID := env.openThought(spec, knobKey, n)
for _, k := range ops {
env.submitVerdict(k, taskID, spec, voteYes, 10000)
}
// Two operators DEREGISTER but do NOT withdraw — bond stays at risk, so _bonded is
// still true and their verdicts count. (deregisterCooldown==0 here, but we simply
// never call withdrawBond.)
for _, k := range ops[:2] {
env.deregister(k)
}
q := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if got := toInt(t, q["verdictsCounted"]); got != 3 {
t.Fatalf("verdictsCounted=%d, want 3 (deregistered-but-bonded verdicts still count)", got)
}
if got := toInt(t, q["droppedUnbonded"]); got != 0 {
t.Fatalf("droppedUnbonded=%d, want 0 (no bond was withdrawn)", got)
}
if q["quorumReached"] != true {
t.Fatalf("quorumReached=%v, want true (3 bonded YES >= threshold 2)", q["quorumReached"])
}
// Chain confirms: a deregistered-but-bonded committee still settles to a quorum.
env.c.advanceSeconds(2 * 3600)
env.settleThought(taskID)
if got := env.onchainThoughtStatus(taskID); got != statusSettled {
t.Fatalf("on-chain settle status=%d, want Settled(%d) — deregistered-but-bonded must count", got, statusSettled)
}
})
}
// TestQuorumStatusWinningVoteForNonApproval is the MEDIUM-6 winning-vote test: a quorum
// can form around a NON-Yes vote (e.g. No). quorum_status must surface the winning vote
// so an LLM does not read "quorumReached=true" as "approved". Here the committee reaches
// a NO quorum: quorumReached is true, but winningVote is No and winningIsApprove false.
func TestQuorumStatusWinningVoteForNonApproval(t *testing.T) {
ops := genKeys(t, 3) // threshold 2
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
spec := specHash("reject-upgrade-9")
knobKey := "approve-upgrade-9"
n := uint8(len(ops))
for _, k := range ops {
env.registerOperator(k)
}
taskID := env.openThought(spec, knobKey, n)
// Two vote NO @ bucket 10000 (a NO quorum of 2 >= threshold 2); the third votes YES.
env.submitVerdict(ops[0], taskID, spec, voteNo, 10000)
env.submitVerdict(ops[1], taskID, spec, voteNo, 10000)
env.submitVerdict(ops[2], taskID, spec, voteYes, 10000)
q := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if q["quorumReached"] != true {
t.Fatalf("quorumReached=%v, want true (NO group of 2 >= threshold 2)", q["quorumReached"])
}
if got := toUint8(t, q["winningVote"]); got != voteNo {
t.Fatalf("winningVote=%d, want No(%d)", got, voteNo)
}
if q["winningVoteLabel"] != "No" {
t.Fatalf("winningVoteLabel=%v, want No", q["winningVoteLabel"])
}
if q["winningIsApprove"] != false {
t.Fatalf("winningIsApprove=%v, want false (a No quorum is NOT an approval)", q["winningIsApprove"])
}
if got := toInt(t, q["votesAgainst"]); got != 2 {
t.Fatalf("votesAgainst=%d, want 2", got)
}
if got := toInt(t, q["votesFor"]); got != 1 {
t.Fatalf("votesFor=%d, want 1", got)
}
// Settle on-chain and confirm the winning vote MCP reported matches the canonical
// vote the chain recorded (settle decodes the same best key).
env.c.advanceSeconds(2 * 3600)
env.settleThought(taskID)
onchain := readStruct[Thought](t, env.c, env.governor, "getThought", taskID)
if onchain.Status != statusSettled {
t.Fatalf("on-chain status=%d, want Settled (a NO quorum settles)", onchain.Status)
}
if onchain.CanonicalVote != voteNo {
t.Fatalf("on-chain canonicalVote=%d, want No(%d)", onchain.CanonicalVote, voteNo)
}
if got := toUint8(t, q["winningVote"]); got != onchain.CanonicalVote {
t.Fatalf("MCP winningVote=%d != on-chain canonicalVote=%d", got, onchain.CanonicalVote)
}
}
// TestQuorumStatusDeadlineGate is the MEDIUM-6 deadline test: mid-window, a reached
// quorum is NOT yet settleable (settle reverts before the deadline). quorum_status must
// label this: quorumReached can be true while settleable is false and deadlinePassed is
// false. After the deadline passes, settleable flips true.
func TestQuorumStatusDeadlineGate(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
spec := specHash("deadline-gate-task")
knobKey := "approve-deadline-gate"
n := uint8(len(ops))
for _, k := range ops {
env.registerOperator(k)
}
taskID := env.openThought(spec, knobKey, n)
for _, k := range ops {
env.submitVerdict(k, taskID, spec, voteYes, 10000)
}
// Mid-window: quorum reached, but the deadline has NOT passed -> not settleable.
q := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if q["quorumReached"] != true {
t.Fatalf("quorumReached=%v, want true", q["quorumReached"])
}
if q["deadlinePassed"] != false {
t.Fatalf("deadlinePassed=%v, want false (still mid-window)", q["deadlinePassed"])
}
if q["settleable"] != false {
t.Fatalf("settleable=%v, want false mid-window (settle reverts before deadline)", q["settleable"])
}
// Advance past the 1h voting window; now the quorum is settleable.
env.c.advanceSeconds(2 * 3600)
q2 := callTool(t, srv, toolQuorumStatus, map[string]interface{}{"taskId": taskID.String()})
if q2["deadlinePassed"] != true {
t.Fatalf("deadlinePassed=%v, want true after the window", q2["deadlinePassed"])
}
if q2["settleable"] != true {
t.Fatalf("settleable=%v, want true after deadline with quorum", q2["settleable"])
}
// And the chain agrees it can settle now.
env.settleThought(taskID)
if got := env.onchainThoughtStatus(taskID); got != statusSettled {
t.Fatalf("on-chain status=%d, want Settled", got)
}
}
+349
View File
@@ -0,0 +1,349 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// TestMCPReadOnlyToolsCannotSubmitTx is the read-only acceptance gate. It proves two
// things:
//
// 1. The package's OWN code (non-test .go files) REFERENCES no transaction-signing
// or transaction-submitting symbol. The check parses each file with go/parser and
// inspects the AST — selector expressions (pkg.Symbol), identifiers, and imports —
// so prose in comments/strings that merely NAMES these tokens (as this package's
// docs do, to explain the invariant) is correctly ignored. Only real code use
// fails the test.
// 2. tools/list exposes EXACTLY the eight read tools — no more.
//
// The scan is scoped to non-test files because the test harness legitimately signs
// and sends transactions to set up chain state; the invariant is about the LIBRARY
// the operator runs, not the test scaffolding.
func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
// ALWAYS-forbidden symbols — these names have no legitimate read-only use, so any
// reference (qualified or bare) is a failure.
alwaysForbidden := map[string]bool{
"SendTransaction": true, // ethclient / bind send a tx
"sendRawTransaction": true, // raw JSON-RPC tx submit
"SendRawTransaction": true,
"NewKeyedTransactor": true, // keyed transactor (signing)
"NewKeyedTransactorWithChainID": true,
"DeployContract": true, // sends a creation tx
"PrivateKey": true, // ecdsa.PrivateKey / *.PrivateKey field
}
// CONTEXT-forbidden: a method/func named like a signing op (e.g. "Sign",
// "GenerateKey") is forbidden ONLY when qualified by a crypto/tx package. This
// avoids false positives on legitimate, unrelated methods such as
// (*big.Int).Sign(), which reports a number's sign and is read-only.
signingNames := map[string]bool{
"Sign": true,
"SignTx": true,
"SignText": true,
"SignHash": true,
"GenerateKey": true,
}
signingPkgs := map[string]bool{
"crypto": true, // luxfi/crypto or geth/crypto signing
"ecdsa": true, // crypto/ecdsa
"bind": true, // accounts/abi/bind
"types": true, // types.SignTx
}
// Forbidden imports — packages that exist to sign or send txs.
forbiddenImports := map[string]bool{
"github.com/luxfi/geth/accounts/abi/bind": true, // transactors / DeployContract
"github.com/luxfi/geth/crypto": true, // signing primitives
"github.com/luxfi/geth/rpc": true, // rpc.Client.CallContext can send raw tx
"reflect": true, // reflect.MethodByName could call a write method past the static denylist
}
// Forbidden RPC method strings — even passed as a literal to a generic RPC caller,
// these are write methods; a read-only server must never name them.
forbiddenRPCStrings := map[string]bool{
"eth_sendTransaction": true,
"eth_sendRawTransaction": true,
}
// Substring markers: any literal CONTAINING one of these is a write-path smell,
// which defeats string-concatenation evasion ("eth_send"+"RawTransaction") and the
// bare method verbs ("SendTransaction") that reflect.MethodByName would consume.
forbiddenRPCStringFragments := map[string]bool{
"eth_send": true,
"SendTransaction": true,
"SendRawTransaction": true,
}
fset := token.NewFileSet()
// Walk the WHOLE package tree (this dir AND subpackages such as cmd/aivm-gov-mcp),
// so a write path added in the command binary cannot slip past a top-dir-only scan.
var files []string
if err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Skip testdata (compiled-contract JSON, not Go) — nothing to parse there.
if d.Name() == "testdata" {
return fs.SkipDir
}
return nil
}
name := d.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
return nil
}
files = append(files, path)
return nil
}); err != nil {
t.Fatalf("walk package tree: %v", err)
}
scanned := 0
sawCmd := false
for _, path := range files {
if sp := filepath.ToSlash(path); strings.Contains(sp, "cmd/") {
sawCmd = true
}
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
// Parse WITHOUT comments so prose can never trip the check; we walk the AST,
// which contains only real code nodes (plus string literals, checked explicitly).
f, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
scanned++
// Imports.
for _, imp := range f.Imports {
ip := strings.Trim(imp.Path.Value, `"`)
// observation.go imports github.com/luxfi/crypto ONLY for Keccak256 (a pure
// hash, not signing). That import path is NOT in forbiddenImports, so it is
// allowed; we additionally assert below that the only crypto symbol it uses
// is the hash.
if forbiddenImports[ip] {
t.Errorf("FORBIDDEN import %q in %s (a signing/tx-send package)", ip, path)
}
}
// Selector, identifier, and string-literal references in real code.
ast.Inspect(f, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.SelectorExpr:
if alwaysForbidden[x.Sel.Name] {
t.Errorf("FORBIDDEN write-path symbol %q referenced in %s — the MCP package must be read-only",
selString(x), path)
}
// A signing-named method is forbidden only under a crypto/tx package.
if signingNames[x.Sel.Name] {
if pkg, ok := x.X.(*ast.Ident); ok && signingPkgs[pkg.Name] {
t.Errorf("FORBIDDEN signing call %q in %s — the MCP package must not sign",
selString(x), path)
}
}
case *ast.Ident:
// Bare identifiers (e.g. a local named PrivateKey). Only the
// always-forbidden names are checked bare; signing-named methods are
// only meaningful when package-qualified (handled above).
if alwaysForbidden[x.Name] {
t.Errorf("FORBIDDEN write-path identifier %q in %s", x.Name, path)
}
case *ast.BasicLit:
// Belt-and-suspenders: catch a write JSON-RPC method name passed as a
// string literal to any generic caller, even if the caller package were
// allowed. The package legitimately uses no raw RPC method strings.
// Substring (not exact) match so a FRAGMENTED literal — e.g.
// "eth_send" + "RawTransaction" — is still caught on each fragment.
if x.Kind == token.STRING {
if lit, lerr := unquote(x.Value); lerr == nil {
if forbiddenRPCStrings[lit] {
t.Errorf("FORBIDDEN write RPC method string %q in %s — read-only server must not name it", lit, path)
}
for marker := range forbiddenRPCStringFragments {
if strings.Contains(lit, marker) {
t.Errorf("FORBIDDEN write-path string fragment %q (in %q) in %s — read-only server must not name it", marker, lit, path)
}
}
}
}
}
return true
})
// The one allowed crypto use (observation.go) must be Keccak256 ONLY.
if filepath.Base(path) == "observation.go" {
assertCryptoHashOnly(t, f, path)
}
}
if scanned == 0 {
t.Fatal("scanned 0 source files — scan is broken")
}
if !sawCmd {
t.Fatal("scan never visited the cmd/ subpackage — the recursive walk is broken (a write path could hide there)")
}
t.Logf("AST-scanned %d non-test source files (incl. cmd/): no write-path symbol referenced", scanned)
// POSITIVE assertion: the ONLY chain dependency is EthCaller, and its method set is
// EXACTLY the four read methods — no Send*/sign verb. This is stronger than the
// denylist: a new write method on the chain interface fails here even if its NAME is
// not on any forbidden list.
assertEthCallerIsReadOnly(t, fset)
// tools/list surface: exactly the eight read tools, by name.
keys := genKeys(t, 1)
_, env := newEVMChain(t, keys)
srv := env.mcpServer()
got := make([]string, 0, len(srv.Tools()))
for _, tl := range srv.Tools() {
got = append(got, tl.Name)
}
sort.Strings(got)
want := []string{
"chain_state",
"operator_reputation",
"param_history",
"param_value",
"pending_operations",
"quorum_status",
"receipt_lookup",
"thought_status",
}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("tools/list returned %d tools, want exactly 8: %v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("tool[%d] = %q, want %q (full set: %v)", i, got[i], want[i], got)
}
}
}
// assertCryptoHashOnly verifies that the only github.com/luxfi/crypto symbol used in
// `f` is Keccak256 (a pure hash). Any other crypto.* selector (e.g. crypto.Sign)
// fails — proving observation.go's crypto dependency is hashing, never signing.
func assertCryptoHashOnly(t *testing.T, f *ast.File, name string) {
t.Helper()
// Find the local name bound to the crypto import (usually "crypto").
alias := ""
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == "github.com/luxfi/crypto" {
if imp.Name != nil {
alias = imp.Name.Name
} else {
alias = "crypto"
}
}
}
if alias == "" {
return // crypto not imported here
}
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
pkgIdent, ok := sel.X.(*ast.Ident)
if !ok || pkgIdent.Name != alias {
return true
}
if sel.Sel.Name != "Keccak256" && sel.Sel.Name != "Keccak256Hash" {
t.Errorf("%s uses crypto.%s — only the keccak hash is permitted (no signing)", name, sel.Sel.Name)
}
return true
})
}
// selString renders a selector expression like "pkg.Symbol" for error messages.
func selString(s *ast.SelectorExpr) string {
if id, ok := s.X.(*ast.Ident); ok {
return id.Name + "." + s.Sel.Name
}
return s.Sel.Name
}
// unquote strips Go string-literal quoting (handles both "..." and `...`).
func unquote(lit string) (string, error) {
return strconv.Unquote(lit)
}
// assertEthCallerIsReadOnly parses abi.go, finds the EthCaller interface, and asserts
// its method set is EXACTLY the four read methods. This is the positive complement to
// the denylist: it FAILS if any method is added or renamed (e.g. a SendTransaction
// slipped onto the chain seam), so the read-only surface cannot silently grow. The four
// methods — CallContract (read-only eth_call), ChainID, BlockNumber, HeaderByNumber —
// are all read verbs with no transaction-submitting capability.
func assertEthCallerIsReadOnly(t *testing.T, fset *token.FileSet) {
t.Helper()
src, err := os.ReadFile("abi.go")
if err != nil {
t.Fatalf("read abi.go: %v", err)
}
f, err := parser.ParseFile(fset, "abi.go", src, 0)
if err != nil {
t.Fatalf("parse abi.go: %v", err)
}
want := map[string]bool{
"CallContract": true,
"ChainID": true,
"BlockNumber": true,
"HeaderByNumber": true,
}
var methods []string
found := false
ast.Inspect(f, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok || ts.Name.Name != "EthCaller" {
return true
}
iface, ok := ts.Type.(*ast.InterfaceType)
if !ok {
t.Fatalf("EthCaller is not an interface type")
}
found = true
for _, m := range iface.Methods.List {
// Embedded interfaces (no Names) would widen the surface invisibly — reject.
if len(m.Names) == 0 {
t.Errorf("EthCaller embeds another interface — read-only surface must be explicit, not embedded")
continue
}
for _, nm := range m.Names {
methods = append(methods, nm.Name)
}
}
return false
})
if !found {
t.Fatal("EthCaller interface not found in abi.go — the read-only chain seam is the invariant's anchor")
}
sort.Strings(methods)
got := map[string]bool{}
for _, m := range methods {
got[m] = true
if !want[m] {
t.Errorf("EthCaller exposes unexpected method %q — only read methods are allowed on the chain seam", m)
}
}
for m := range want {
if !got[m] {
t.Errorf("EthCaller is missing expected read method %q", m)
}
}
t.Logf("EthCaller method set verified read-only: %v", methods)
}
+500
View File
@@ -0,0 +1,500 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mcp is a node-side, READ-ONLY governance MCP (Model Context Protocol)
// server for the Lux AIVM (A-Chain) governance stack — the "sensory layer" an
// operator-LLM uses to query chain facts during deliberation.
//
// READ-ONLY BY CONSTRUCTION. v1 exposes only the eight read tools below. The
// package holds no private key and links no signing or transaction-submission
// primitive: every chain access goes through EthCaller.CallContract (the read-only
// eth_call) or the header/chainId/blockNumber readers. There is no SendTransaction,
// no keyed transactor, no eth_sendRawTransaction anywhere in this package. Verdicts
// are produced and submitted by the operator through the normal AIGovernor tx path,
// NOT through this server. (TestMCPReadOnlyToolsCannotSubmitTx asserts this by
// scanning the package source for forbidden write tokens and by checking that
// tools/list returns exactly the eight read tools.)
//
// Orthogonal split (Rich Hickey): MCP READS the chain. AIGovernor settles votes.
// AIParams stores knobs. AIThoughtRegistry records receipts. AIReputation scores
// operators. This package depends on NONE of the write paths — it links only the
// read surface (EthCaller) and the hand-written view-function ABIs in abi.go. The
// EVM-call discipline mirrors chains/dexvm/registry/rpcverify: dial an EVM RPC,
// call view functions, keep JSON-RPC/consensus write deps out of the path.
//
// Transport is JSON-RPC 2.0 over stdio (newline-delimited): Serve reads requests
// from an io.Reader and writes responses to an io.Writer. Methods: initialize,
// tools/list, tools/call. Standard library only — no external MCP SDK.
package governance
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"strings"
"sync/atomic"
"time"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/ethclient"
)
// ServerName / ServerVersion are returned in the initialize handshake.
const (
ServerName = "aivm-gov-mcp"
ServerVersion = "1.0.0"
// ProtocolVersion is the MCP protocol revision this server speaks.
ProtocolVersion = "2024-11-05"
)
// Availability/safety bounds. These cap what a single stdio line can cost so one
// client (or one wedged upstream RPC) cannot exhaust the server.
const (
// maxLineBytes bounds one JSON-RPC request line. MCP requests are tiny (a tool name
// + a few scalar args), so 1 MiB is generous; a longer line is rejected as a parse
// error WITHOUT buffering it whole (the reader stops at the cap and drains the rest).
maxLineBytes = 1 << 20 // 1 MiB
// defaultCallTimeout bounds one tools/call dispatch end-to-end (reaching every
// eth_call). It mirrors the per-request budget in chains/dexvm/registry/rpcverify:
// long enough for a healthy RPC, short enough that one hung call cannot wedge the
// stdio loop (the next request is still served once the budget elapses).
defaultCallTimeout = 20 * time.Second
// defaultMaxCallsPerRequest caps the eth_calls one tools/call may issue, so a single
// line cannot amplify into thousands of upstream calls. param_history at the limit
// cap (256 rounds x 2 calls + count ~= 513) and pending_operations both stay well
// under this; it is the hard backstop, not the normal path.
defaultMaxCallsPerRequest = 1024
)
// Config holds the EVM RPC endpoint and the four deployed governance contract
// addresses. AIParams, AIGovernor are required (most tools need them);
// AIThoughtRegistry and AIReputation are required for receipt_lookup and the
// reputation reads respectively. All four are validated in New.
type Config struct {
// EVMRPC is the governance EVM chain's RPC URL — an L1/L2/L3 EVM endpoint or a
// …/ext/bc/C/rpc. Dialed read-only via ethclient.DialContext.
EVMRPC string
AIParams common.Address
AIGovernor common.Address
AIThoughtRegistry common.Address
AIReputation common.Address
}
// Server is the read-only governance MCP server. It owns a read-only EthCaller and
// the four bound view-ABIs. It exposes NO method that can sign or submit a tx.
type Server struct {
ec EthCaller
params *boundABI
governor *boundABI
registry *boundABI
rep *boundABI
tools map[string]toolHandler
descs []Tool
// callTimeout bounds one tools/call dispatch; maxCallsPerRequest caps its eth_calls.
// Defaulted in newServer; overridable in-package (tests shrink them).
callTimeout time.Duration
maxCallsPerRequest int
}
// toolHandler runs one read tool: bound as a method expression on *Server, it
// receives the server, the context, and the decoded args, and returns a
// JSON-serializable result (or an error). It may issue read-only eth_calls via the
// server's EthCaller; it can never send a transaction.
type toolHandler func(s *Server, ctx context.Context, args map[string]interface{}) (interface{}, error)
// New dials the EVM RPC (read-only) and binds the four contract view-ABIs. The
// dialed *ethclient.Client satisfies EthCaller; the production server reads the
// live chain through it. Returns an error if any address is zero or the dial fails.
func New(ctx context.Context, cfg Config) (*Server, error) {
if cfg.EVMRPC == "" {
return nil, errors.New("mcp: empty EVMRPC")
}
if (cfg.AIParams == common.Address{}) {
return nil, errors.New("mcp: AIParams address is zero")
}
if (cfg.AIGovernor == common.Address{}) {
return nil, errors.New("mcp: AIGovernor address is zero")
}
if (cfg.AIThoughtRegistry == common.Address{}) {
return nil, errors.New("mcp: AIThoughtRegistry address is zero")
}
if (cfg.AIReputation == common.Address{}) {
return nil, errors.New("mcp: AIReputation address is zero")
}
ec, err := ethclient.DialContext(ctx, cfg.EVMRPC)
if err != nil {
return nil, fmt.Errorf("mcp: dial EVM RPC %s: %w", cfg.EVMRPC, err)
}
return newServer(ec, cfg)
}
// NewWithCaller builds a Server over an already-constructed read-only EthCaller and
// the contract addresses (no dial). This is the seam the in-process tests use to
// inject the simulated backend's Client; it carries no signing capability because
// EthCaller has none. Production callers use New.
func NewWithCaller(ec EthCaller, cfg Config) (*Server, error) {
if ec == nil {
return nil, errors.New("mcp: nil EthCaller")
}
return newServer(ec, cfg)
}
func newServer(ec EthCaller, cfg Config) (*Server, error) {
params, err := newBoundABI(aiParamsABI, cfg.AIParams)
if err != nil {
return nil, err
}
governor, err := newBoundABI(aiGovernorABI, cfg.AIGovernor)
if err != nil {
return nil, err
}
registry, err := newBoundABI(aiThoughtRegistryABI, cfg.AIThoughtRegistry)
if err != nil {
return nil, err
}
rep, err := newBoundABI(aiReputationABI, cfg.AIReputation)
if err != nil {
return nil, err
}
s := &Server{
ec: ec,
params: params,
governor: governor,
registry: registry,
rep: rep,
callTimeout: defaultCallTimeout,
maxCallsPerRequest: defaultMaxCallsPerRequest,
}
s.tools, s.descs = registerTools()
return s, nil
}
// boundedCaller wraps a read-only EthCaller and fails after `max` CallContract calls,
// bounding the eth_call fan-out of a SINGLE tools/call so one request cannot amplify
// into an unbounded burst of upstream calls. It forwards the non-amplifying reads
// (ChainID/BlockNumber/HeaderByNumber) unchanged. max <= 0 disables the ceiling.
type boundedCaller struct {
ec EthCaller
max int
calls atomic.Int64
}
func newBoundedCaller(ec EthCaller, max int) *boundedCaller {
return &boundedCaller{ec: ec, max: max}
}
func (b *boundedCaller) CallContract(ctx context.Context, call gethereum.CallMsg, block *big.Int) ([]byte, error) {
if b.max > 0 {
if n := b.calls.Add(1); n > int64(b.max) {
return nil, fmt.Errorf("mcp: per-request eth_call ceiling exceeded (%d) — narrow the query (smaller limit/range)", b.max)
}
}
return b.ec.CallContract(ctx, call, block)
}
func (b *boundedCaller) ChainID(ctx context.Context) (*big.Int, error) { return b.ec.ChainID(ctx) }
func (b *boundedCaller) BlockNumber(ctx context.Context) (uint64, error) {
return b.ec.BlockNumber(ctx)
}
func (b *boundedCaller) HeaderByNumber(ctx context.Context, n *big.Int) (*types.Header, error) {
return b.ec.HeaderByNumber(ctx, n)
}
// withBoundedCaller returns a shallow copy of s whose EthCaller is wrapped with a
// fresh per-request call ceiling. The copy shares the bound ABIs and tool map (all
// immutable after construction); only the read seam is swapped. This keeps each
// request's eth_call budget independent without mutating the shared server.
func (s *Server) withBoundedCaller() *Server {
cp := *s
cp.ec = newBoundedCaller(s.ec, s.maxCallsPerRequest)
return &cp
}
// ----------------------------------------------------------------------------
// JSON-RPC 2.0 envelope (stdio). Mirrors the shapes in zap/mcp/bridge.go.
// ----------------------------------------------------------------------------
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result interface{} `json:"result,omitempty"`
Error *rpcErr `json:"error,omitempty"`
}
type rpcErr struct {
Code int `json:"code"`
Message string `json:"message"`
}
// JSON-RPC error codes (subset of the spec used here).
const (
codeParseError = -32700
codeInvalidReq = -32600
codeMethodNotFnd = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
// Tool is the MCP tool descriptor (same shape as zap/mcp.Tool: ID, Name,
// Description, InputSchema with the `inputSchema` JSON key).
type Tool struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
// content is one MCP tool-result content block. Tool results are returned as a
// single text block carrying the JSON-encoded result value.
type content struct {
Type string `json:"type"`
Text string `json:"text"`
}
// Serve runs the stdio JSON-RPC loop: it reads newline-delimited JSON-RPC requests
// from `in` and writes responses to `out` until EOF or ctx cancellation. Each request
// is dispatched to initialize / tools/list / tools/call. Notifications (no id, e.g.
// notifications/initialized) get no response, per the JSON-RPC spec.
func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error {
r := bufio.NewReader(in)
w := bufio.NewWriter(out)
defer w.Flush()
for {
if ctx.Err() != nil {
return ctx.Err()
}
line, tooLong, err := readLimitedLine(r, maxLineBytes)
if tooLong {
// The request line exceeded the cap; we did NOT buffer it whole (the rest of
// the line was drained). Reply with a parse error and keep serving — one
// oversized line cannot OOM the server or kill the loop.
if werr := writeJSONLine(w, errResp(nil, codeParseError, "request line exceeds maximum length")); werr != nil {
return werr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
} else if len(line) > 0 {
trimmed := strings.TrimSpace(string(line))
if trimmed != "" {
if resp := s.handle(ctx, []byte(trimmed)); resp != nil {
if werr := writeJSONLine(w, resp); werr != nil {
return werr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
// readLimitedLine reads one newline-terminated line but never buffers more than `max`
// bytes. If the line is longer than `max`, it returns tooLong=true with the partial
// bytes discarded (it drains the rest of the line so the NEXT read starts on a fresh
// line), so an adversarial multi-megabyte line cannot grow memory without bound. The
// returned error mirrors bufio.Reader.ReadBytes (io.EOF at stream end, possibly with a
// final unterminated line). A trailing '\n' is included in the returned slice when the
// line fit, matching the prior ReadBytes('\n') contract for the parse path.
func readLimitedLine(r *bufio.Reader, max int) (line []byte, tooLong bool, err error) {
buf := make([]byte, 0, 256)
for {
var c byte
c, err = r.ReadByte()
if err != nil {
return buf, false, err
}
if c == '\n' {
buf = append(buf, c)
return buf, false, nil
}
if len(buf) >= max {
// Over the cap: stop buffering and drain to end-of-line (or EOF) so the loop
// resynchronizes on the next line instead of mid-line.
for {
d, derr := r.ReadByte()
if derr != nil {
return nil, true, derr
}
if d == '\n' {
return nil, true, nil
}
}
}
buf = append(buf, c)
}
}
// handle parses one request line and routes it. Returns the response to write, or
// nil for notifications (requests without an id).
func (s *Server) handle(ctx context.Context, line []byte) *rpcResponse {
var req rpcRequest
if err := json.Unmarshal(line, &req); err != nil {
return errResp(nil, codeParseError, "parse error: "+err.Error())
}
// A request with no id is a notification — process side effects (none here) and
// return nothing.
isNotification := len(req.ID) == 0 || string(req.ID) == "null"
switch req.Method {
case "initialize":
if isNotification {
return nil
}
return okResp(req.ID, s.initializeResult())
case "notifications/initialized", "initialized":
return nil
case "tools/list":
if isNotification {
return nil
}
return okResp(req.ID, map[string]interface{}{"tools": s.descs})
case "tools/call":
if isNotification {
return nil
}
return s.handleToolsCall(ctx, req.ID, req.Params)
default:
if isNotification {
return nil
}
return errResp(req.ID, codeMethodNotFnd, "method not found: "+req.Method)
}
}
func (s *Server) initializeResult() map[string]interface{} {
return map[string]interface{}{
"protocolVersion": ProtocolVersion,
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": ServerName,
"version": ServerVersion,
},
}
}
// toolCallParams is the MCP tools/call params shape.
type toolCallParams struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
func (s *Server) handleToolsCall(ctx context.Context, id json.RawMessage, raw json.RawMessage) *rpcResponse {
var p toolCallParams
if err := json.Unmarshal(raw, &p); err != nil {
return errResp(id, codeInvalidParams, "invalid params: "+err.Error())
}
if _, ok := s.tools[p.Name]; !ok {
return errResp(id, codeMethodNotFnd, "unknown tool: "+p.Name)
}
result, err := s.dispatch(ctx, p.Name, p.Arguments)
if err != nil {
// Tool errors (incl. timeout, call-ceiling, and recovered panics) are returned
// as a successful MCP response with isError=true so the client model sees the
// failure text (per MCP tool-result convention), not as a JSON-RPC protocol
// error, and the stdio loop keeps serving the next request.
return okResp(id, map[string]interface{}{
"content": []content{{Type: "text", Text: err.Error()}},
"isError": true,
})
}
encoded, merr := json.Marshal(result)
if merr != nil {
return errResp(id, codeInternalError, "encode result: "+merr.Error())
}
return okResp(id, map[string]interface{}{
"content": []content{{Type: "text", Text: string(encoded)}},
})
}
// dispatch runs ONE tool with the request-scoped guards: a per-call timeout (so a hung
// upstream RPC cannot wedge the server), a per-request eth_call ceiling (so one line
// cannot amplify into thousands of calls), and a panic recover (so a single bad call
// becomes one error, never a whole-server crash). The handler reads only — these guards
// add no write capability. Used by both the stdio path and CallTool, so the invariants
// hold no matter how a tool is invoked.
func (s *Server) dispatch(parent context.Context, name string, args map[string]interface{}) (result interface{}, err error) {
h, ok := s.tools[name]
if !ok {
return nil, fmt.Errorf("mcp: unknown tool %q", name)
}
if args == nil {
args = map[string]interface{}{}
}
ctx := parent
var cancel context.CancelFunc
if s.callTimeout > 0 {
ctx, cancel = context.WithTimeout(parent, s.callTimeout)
defer cancel()
}
// Fresh per-request call ceiling on a shallow server copy (shared immutable ABIs).
scoped := s.withBoundedCaller()
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("mcp: tool %q panicked: %v", name, r)
result = nil
}
}()
return h(scoped, ctx, args)
}
// CallTool runs a read tool directly (bypassing the stdio envelope) and returns the
// decoded result. Exposed for in-process callers and tests; it is read-only like
// every tool. Unknown tool names return an error.
func (s *Server) CallTool(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
if _, ok := s.tools[name]; !ok {
return nil, fmt.Errorf("mcp: unknown tool %q", name)
}
return s.dispatch(ctx, name, args)
}
// Tools returns the descriptors for the registered read tools (the tools/list
// surface). The slice is the canonical v1 surface — exactly eight read tools.
func (s *Server) Tools() []Tool { return s.descs }
func okResp(id json.RawMessage, result interface{}) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: result}
}
func errResp(id json.RawMessage, code int, msg string) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcErr{Code: code, Message: msg}}
}
func writeJSONLine(w io.Writer, v interface{}) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.Write(b)
return err
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"bufio"
"context"
"encoding/json"
"io"
"strings"
"testing"
)
// TestStdioServeRoundTrip exercises the ACTUAL JSON-RPC 2.0 stdio loop (Serve),
// not just the in-process CallTool: it feeds initialize, tools/list, and a
// tools/call(chain_state) over a pipe and asserts the framed responses. This proves
// the transport envelope (initialize handshake, tools/list shape, tools/call content
// block) works end-to-end against the real EVM-backed reads.
func TestStdioServeRoundTrip(t *testing.T) {
keys := genKeys(t, 1)
_, env := newEVMChain(t, keys)
srv := env.mcpServer()
// Three newline-delimited requests + a notification (no id, no response).
requests := strings.Join([]string{
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`,
`{"jsonrpc":"2.0","method":"notifications/initialized"}`,
`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`,
`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"chain_state","arguments":{}}}`,
}, "\n") + "\n"
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(requests), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
resps := decodeLines(t, out.String())
// initialize + tools/list + tools/call = 3 responses; the notification yields none.
if len(resps) != 3 {
t.Fatalf("expected 3 responses (notification yields none), got %d:\n%s", len(resps), out.String())
}
// 1) initialize: serverInfo.name == ServerName, protocolVersion set.
init := resps[0]
if idNum(init["id"]) != 1 {
t.Fatalf("initialize response id=%v, want 1", init["id"])
}
result := init["result"].(map[string]interface{})
si := result["serverInfo"].(map[string]interface{})
if si["name"] != ServerName {
t.Fatalf("serverInfo.name=%v, want %s", si["name"], ServerName)
}
if result["protocolVersion"] != ProtocolVersion {
t.Fatalf("protocolVersion=%v, want %s", result["protocolVersion"], ProtocolVersion)
}
// 2) tools/list: exactly 8 tools.
list := resps[1]["result"].(map[string]interface{})
tools := list["tools"].([]interface{})
if len(tools) != 8 {
t.Fatalf("tools/list returned %d tools, want 8", len(tools))
}
// Each tool descriptor must carry name + inputSchema (the MCP shape).
for _, tl := range tools {
td := tl.(map[string]interface{})
if _, ok := td["name"].(string); !ok {
t.Fatalf("tool descriptor missing name: %v", td)
}
if _, ok := td["inputSchema"].(map[string]interface{}); !ok {
t.Fatalf("tool descriptor missing inputSchema: %v", td)
}
}
// 3) tools/call(chain_state): content[0].text is a JSON object with chainId etc.
call := resps[2]["result"].(map[string]interface{})
content := call["content"].([]interface{})
if len(content) != 1 {
t.Fatalf("tools/call content length=%d, want 1", len(content))
}
block := content[0].(map[string]interface{})
if block["type"] != "text" {
t.Fatalf("content type=%v, want text", block["type"])
}
var state map[string]interface{}
if err := json.Unmarshal([]byte(block["text"].(string)), &state); err != nil {
t.Fatalf("chain_state text not JSON: %v", err)
}
wantChainID, _ := env.c.ChainID(context.Background())
if state["chainId"] != wantChainID.String() {
t.Fatalf("chain_state chainId=%v, want %s", state["chainId"], wantChainID)
}
if _, ok := state["blockHash"].(string); !ok {
t.Fatalf("chain_state missing blockHash: %v", state)
}
if _, ok := state["blockNumber"]; !ok {
t.Fatalf("chain_state missing blockNumber: %v", state)
}
}
// TestStdioUnknownToolIsError verifies an unknown tool returns an MCP tool error
// (isError=true content), not a transport crash.
func TestStdioUnknownToolIsError(t *testing.T) {
keys := genKeys(t, 1)
_, env := newEVMChain(t, keys)
srv := env.mcpServer()
req := `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"no_such_tool","arguments":{}}}` + "\n"
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(req), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
resps := decodeLines(t, out.String())
if len(resps) != 1 {
t.Fatalf("want 1 response, got %d", len(resps))
}
// An unknown tool is a JSON-RPC method-not-found error (we reject before dispatch).
if resps[0]["error"] == nil {
t.Fatalf("expected an error for unknown tool, got: %v", resps[0])
}
}
// decodeLines parses newline-delimited JSON-RPC responses.
func decodeLines(t *testing.T, s string) []map[string]interface{} {
t.Helper()
var out []map[string]interface{}
r := bufio.NewReader(strings.NewReader(s))
for {
line, err := r.ReadString('\n')
if t := strings.TrimSpace(line); t != "" {
var m map[string]interface{}
if jerr := json.Unmarshal([]byte(t), &m); jerr != nil {
panic("bad response line: " + t)
}
out = append(out, m)
}
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read line: %v", err)
}
}
return out
}
// idNum extracts a numeric JSON-RPC id (decoded as float64).
func idNum(v interface{}) int {
if f, ok := v.(float64); ok {
return int(f)
}
return -1
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+847
View File
@@ -0,0 +1,847 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
import (
"context"
"fmt"
"math/big"
"github.com/luxfi/geth/common"
)
// The eight read tools — the entire v1 surface. Each reads chain facts via the
// server's read-only EthCaller (eth_call / header reads) and returns a
// JSON-serializable value. NONE writes; there is no tx-submitting path here.
const (
toolChainState = "chain_state"
toolParamValue = "param_value"
toolParamHistory = "param_history"
toolThoughtStatus = "thought_status"
toolReceiptLookup = "receipt_lookup"
toolQuorumStatus = "quorum_status"
toolOperatorReputation = "operator_reputation"
toolPendingOperations = "pending_operations"
)
// Thought lifecycle status values (IAIGovernor.Status / AIParams.Status).
const (
statusNone uint8 = 0
statusOpen uint8 = 1
statusSettled uint8 = 2
statusFailed uint8 = 3
)
// Vote values (IAIGovernor.Vote): invalid=0, yes=1, no=2, abstain=3, delay=4,
// unsafe=5. A settling group whose winning vote is anything other than Yes is NOT an
// approval — quorum_status surfaces the winning vote so an operator-LLM never reads a
// No/Unsafe/Delay quorum as a go-ahead.
const (
voteInvalid uint8 = 0
voteYes uint8 = 1
voteNo uint8 = 2
voteAbstain uint8 = 3
voteDelay uint8 = 4
voteUnsafe uint8 = 5
)
// voteLabel maps a Vote value to its IAIGovernor.Vote name (so a winning bucket reads
// as "Yes"/"No"/… and a reader cannot mistake a non-Yes quorum for an approval).
func voteLabel(v uint8) string {
switch v {
case voteYes:
return "Yes"
case voteNo:
return "No"
case voteAbstain:
return "Abstain"
case voteDelay:
return "Delay"
case voteUnsafe:
return "Unsafe"
default:
return "Invalid"
}
}
const defaultLimit = 16
// pendingScanFloor is the minimum number of tail tasks pending_operations inspects even
// when the caller's limit is tiny, so a just-opened task isn't missed behind a few
// freshly-settled ones. The per-request eth_call ceiling (see server.go) is the hard
// backstop against amplification; this only sizes the look-back window.
const pendingScanFloor = int64(64)
// registerTools builds the handler map and the descriptor list. The two are kept
// in lockstep so tools/list always matches what tools/call can dispatch.
func registerTools() (map[string]toolHandler, []Tool) {
descs := []Tool{
{
Name: toolChainState,
Description: "Current chain head: block number, chain id, latest block hash and timestamp.",
InputSchema: objSchema(nil, nil),
},
{
Name: toolParamValue,
Description: "Read a decided governance knob value from AIParams.valueOf(modelSpecHash, knobKey). Returns {value, decided}.",
InputSchema: objSchema(map[string]interface{}{
"modelSpecHash": strSchema("bytes32 model spec hash, 0x-hex"),
"knobKey": strSchema("knob key string"),
}, []string{"modelSpecHash", "knobKey"}),
},
{
Name: toolParamHistory,
Description: "AIParams round history (newest first): each round with its proposals. Optional limit (default 16) and fromRound.",
InputSchema: objSchema(map[string]interface{}{
"limit": intSchema("max rounds to return (default 16)"),
"fromRound": intSchema("highest round id to start from (default roundCount-1)"),
}, nil),
},
{
Name: toolThoughtStatus,
Description: "AIGovernor.getThought(taskId) fields plus a derived status (Open/Settled/NoQuorum) and taskCount.",
InputSchema: objSchema(map[string]interface{}{
"taskId": intSchema("task id"),
}, []string{"taskId"}),
},
{
Name: toolReceiptLookup,
Description: "AIThoughtRegistry receipt lookup by receiptId. Returns {exists, receipt, receiptCount}.",
InputSchema: objSchema(map[string]interface{}{
"receiptId": strSchema("bytes32 receipt id, 0x-hex"),
}, []string{"receiptId"}),
},
{
Name: toolQuorumStatus,
Description: "Quorum tally for a task, computed to match AIGovernor.settle() exactly. " +
"Counts ONLY verdicts from operators still bonded at the observed block (a " +
"withdrawn operator's verdict is dropped, as settle drops it), groups by " +
"(vote,bucket), and reports quorumReached, the winningVote/winningBucket, " +
"winningIsApprove (winning vote == Yes), deadlinePassed and settleable " +
"(quorum reached AND deadline passed — settle reverts before the deadline). " +
"Also returns verdictsTotal/verdictsCounted/droppedUnbonded and observedBlock.",
InputSchema: objSchema(map[string]interface{}{
"taskId": intSchema("task id"),
}, []string{"taskId"}),
},
{
Name: toolOperatorReputation,
Description: "Operator standing: {isOperator, bond, weight, agreementRateBps, rep} from AIGovernor and AIReputation.",
InputSchema: objSchema(map[string]interface{}{
"operator": strSchema("operator address, 0x-hex"),
}, []string{"operator"}),
},
{
Name: toolPendingOperations,
Description: "Currently-OPEN (unsettled) thoughts. Optional limit (default 16). Scans only the " +
"TAIL of taskCount (newest tasks), so a still-Open task buried below the window is " +
"NOT returned; when that happens truncated=true and scannedFrom marks the lowest id " +
"inspected (range scannedFrom..taskCount-1). Each entry carries deadlinePassed " +
"(now >= deadline) so a still-Open task whose voting window has closed is visible as " +
"settle-ready. Also returns observedBlock.",
InputSchema: objSchema(map[string]interface{}{
"limit": intSchema("max open thoughts to return (default 16)"),
}, nil),
},
}
handlers := map[string]toolHandler{
toolChainState: (*Server).toolChainState,
toolParamValue: (*Server).toolParamValue,
toolParamHistory: (*Server).toolParamHistory,
toolThoughtStatus: (*Server).toolThoughtStatus,
toolReceiptLookup: (*Server).toolReceiptLookup,
toolQuorumStatus: (*Server).toolQuorumStatus,
toolOperatorReputation: (*Server).toolOperatorReputation,
toolPendingOperations: (*Server).toolPendingOperations,
}
return handlers, descs
}
// ----------------------------------------------------------------------------
// 1. chain_state
// ----------------------------------------------------------------------------
func (s *Server) toolChainState(ctx context.Context, _ map[string]interface{}) (interface{}, error) {
chainID, err := s.ec.ChainID(ctx)
if err != nil {
return nil, fmt.Errorf("chain_state: chainID: %w", err)
}
bn, err := s.ec.BlockNumber(ctx)
if err != nil {
return nil, fmt.Errorf("chain_state: blockNumber: %w", err)
}
hdr, err := s.ec.HeaderByNumber(ctx, nil)
if err != nil {
return nil, fmt.Errorf("chain_state: header: %w", err)
}
return map[string]interface{}{
"chainId": chainID.String(),
"blockNumber": bn,
"blockHash": hdr.Hash().Hex(),
"timestamp": hdr.Time,
}, nil
}
// ----------------------------------------------------------------------------
// 2. param_value
// ----------------------------------------------------------------------------
func (s *Server) toolParamValue(ctx context.Context, args map[string]interface{}) (interface{}, error) {
spec, err := argBytes32(args, "modelSpecHash")
if err != nil {
return nil, err
}
key, err := argString(args, "knobKey")
if err != nil {
return nil, err
}
value, decided, err := s.readParamValue(ctx, spec, key)
if err != nil {
return nil, err
}
return map[string]interface{}{
"value": value.String(),
"decided": decided,
}, nil
}
// readParamValue is the shared AIParams.valueOf read (used by the tool and tests).
func (s *Server) readParamValue(ctx context.Context, spec [32]byte, key string) (*big.Int, bool, error) {
out, err := s.params.call(ctx, s.ec, "valueOf", spec, key)
if err != nil {
return nil, false, err
}
if len(out) != 2 {
return nil, false, fmt.Errorf("param_value: valueOf returned %d values", len(out))
}
value, ok := out[0].(*big.Int)
if !ok {
return nil, false, fmt.Errorf("param_value: value not *big.Int")
}
decided, ok := out[1].(bool)
if !ok {
return nil, false, fmt.Errorf("param_value: decided not bool")
}
return value, decided, nil
}
// ----------------------------------------------------------------------------
// 3. param_history
// ----------------------------------------------------------------------------
func (s *Server) toolParamHistory(ctx context.Context, args map[string]interface{}) (interface{}, error) {
limit := argLimit(args, defaultLimit)
count, err := s.readRoundCount(ctx)
if err != nil {
return nil, err
}
rounds, err := s.readParamHistory(ctx, count, argFromRound(args, count), limit)
if err != nil {
return nil, err
}
return map[string]interface{}{
"roundCount": count.String(),
"rounds": rounds,
}, nil
}
func (s *Server) readRoundCount(ctx context.Context) (*big.Int, error) {
out, err := s.params.call(ctx, s.ec, "roundCount")
if err != nil {
return nil, err
}
c, ok := out[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("param_history: roundCount not *big.Int")
}
return c, nil
}
// readParamHistory walks rounds DESCENDING from `from` (inclusive), capped at limit,
// returning each round's fields and its proposals. `from` defaults to count-1 when
// not supplied by the caller; rounds beyond count-1 are clamped.
func (s *Server) readParamHistory(ctx context.Context, count, from *big.Int, limit int) ([]interface{}, error) {
out := []interface{}{}
if count.Sign() == 0 {
return out, nil
}
last := new(big.Int).Sub(count, big.NewInt(1))
start := from
if start == nil || start.Cmp(last) > 0 {
start = last
}
for i := new(big.Int).Set(start); i.Sign() >= 0 && len(out) < limit; i.Sub(i, big.NewInt(1)) {
round, err := s.readRound(ctx, i)
if err != nil {
return nil, err
}
proposals, err := s.readProposals(ctx, i)
if err != nil {
return nil, err
}
out = append(out, map[string]interface{}{
"roundId": new(big.Int).Set(i).String(),
"round": roundJSON(round),
"proposals": proposalsJSON(proposals),
})
}
return out, nil
}
func (s *Server) readRound(ctx context.Context, roundID *big.Int) (*Round, error) {
r, err := callStruct[Round](ctx, s.params, s.ec, "getRound", roundID)
if err != nil {
return nil, err
}
return &r, nil
}
func (s *Server) readProposals(ctx context.Context, roundID *big.Int) ([]Proposal, error) {
return callStruct[[]Proposal](ctx, s.params, s.ec, "getProposals", roundID)
}
// ----------------------------------------------------------------------------
// 4. thought_status
// ----------------------------------------------------------------------------
func (s *Server) toolThoughtStatus(ctx context.Context, args map[string]interface{}) (interface{}, error) {
taskID, err := argUint256(args, "taskId")
if err != nil {
return nil, err
}
t, err := s.readThought(ctx, taskID)
if err != nil {
return nil, err
}
count, err := s.readTaskCount(ctx)
if err != nil {
return nil, err
}
res := thoughtJSON(t)
res["status"] = derivedStatus(t.Status)
res["taskCount"] = count.String()
return res, nil
}
func (s *Server) readThought(ctx context.Context, taskID *big.Int) (*Thought, error) {
return s.readThoughtAt(ctx, nil, taskID)
}
func (s *Server) readTaskCount(ctx context.Context) (*big.Int, error) {
return s.readTaskCountAt(ctx, nil)
}
// derivedStatus maps the on-chain Status to the operator-facing label. The chain's
// settle() moves a task Open->Settled on quorum or Open->Failed on no-quorum, so the
// derived label is: Open while accepting verdicts, Settled on quorum, NoQuorum on a
// Failed settle (the task ran but no group reached threshold). None = nonexistent.
func derivedStatus(status uint8) string {
switch status {
case statusOpen:
return "Open"
case statusSettled:
return "Settled"
case statusFailed:
return "NoQuorum"
default:
return "None"
}
}
// ----------------------------------------------------------------------------
// 5. receipt_lookup
// ----------------------------------------------------------------------------
func (s *Server) toolReceiptLookup(ctx context.Context, args map[string]interface{}) (interface{}, error) {
id, err := argBytes32(args, "receiptId")
if err != nil {
return nil, err
}
existsOut, err := s.registry.call(ctx, s.ec, "exists", id)
if err != nil {
return nil, err
}
exists, ok := existsOut[0].(bool)
if !ok {
return nil, fmt.Errorf("receipt_lookup: exists not bool")
}
countOut, err := s.registry.call(ctx, s.ec, "receiptCount")
if err != nil {
return nil, err
}
count, ok := countOut[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("receipt_lookup: receiptCount not *big.Int")
}
res := map[string]interface{}{
"exists": exists,
"receiptCount": count.String(),
}
if exists {
rc, err := callStruct[ThoughtReceipt](ctx, s.registry, s.ec, "getReceipt", id)
if err != nil {
return nil, err
}
res["receipt"] = receiptJSON(&rc)
}
return res, nil
}
// ----------------------------------------------------------------------------
// 6. quorum_status
// ----------------------------------------------------------------------------
func (s *Server) toolQuorumStatus(ctx context.Context, args map[string]interface{}) (interface{}, error) {
taskID, err := argUint256(args, "taskId")
if err != nil {
return nil, err
}
// Pin every read of this tally to ONE block. settle() decides quorum from the
// operators bonded AT settle time; if we read the verdicts at block N but the bonds
// at a later head, an operator that withdrew in between could be counted (or not)
// inconsistently. Reading thought, verdicts and bonds all at `block` gives a single
// settle-equivalent snapshot and closes that withdraw race.
block, err := s.observedBlock(ctx)
if err != nil {
return nil, err
}
now, err := s.blockTimestamp(ctx, block)
if err != nil {
return nil, err
}
t, err := s.readThoughtAt(ctx, block, taskID)
if err != nil {
return nil, err
}
verdicts, err := s.readVerdictsAt(ctx, block, taskID)
if err != nil {
return nil, err
}
minBond, err := s.readMinBond(ctx, block)
if err != nil {
return nil, err
}
bonded, err := s.readBondedSet(ctx, block, verdicts, minBond)
if err != nil {
return nil, err
}
tally := tallyQuorum(verdicts, t.Threshold, bonded)
// settle()'s liveness gate is `block.timestamp < deadline -> revert` (AIGovernor.sol
// settle, the only gate — there is no full-committee early exit in the code path).
// So a quorum is only ACTUALLY settleable once the deadline has passed.
deadlinePassed := now >= t.Deadline
return map[string]interface{}{
"verdicts": verdictsJSON(verdicts),
"threshold": t.Threshold,
"deadline": t.Deadline,
"votesFor": tally.votesFor,
"votesAgainst": tally.votesAgainst,
"quorumReached": tally.quorumReached,
"bestGroup": tally.bestCount,
"winningVote": tally.winningVote,
"winningVoteLabel": voteLabel(tally.winningVote),
"winningBucket": tally.winningBucket,
"winningIsApprove": tally.quorumReached && tally.winningVote == voteYes,
"deadlinePassed": deadlinePassed,
// settleable mirrors what settle() will accept right now: a reached quorum whose
// deadline has passed. Mid-window a reached quorum is NOT yet settleable.
"settleable": tally.quorumReached && deadlinePassed,
"verdictsTotal": len(verdicts),
"verdictsCounted": tally.counted,
"droppedUnbonded": len(verdicts) - tally.counted,
"observedBlock": block.String(),
}, nil
}
func (s *Server) readVerdictsAt(ctx context.Context, block, taskID *big.Int) ([]Verdict, error) {
return callStructAt[[]Verdict](ctx, s.governor, s.ec, block, "getVerdicts", taskID)
}
func (s *Server) readThoughtAt(ctx context.Context, block, taskID *big.Int) (*Thought, error) {
t, err := callStructAt[Thought](ctx, s.governor, s.ec, block, "getThought", taskID)
if err != nil {
return nil, err
}
return &t, nil
}
// readMinBond reads AIGovernor.minBond() at `block` — the bonded-eligibility floor
// settle() applies (see _bonded).
func (s *Server) readMinBond(ctx context.Context, block *big.Int) (*big.Int, error) {
out, err := s.governor.callAt(ctx, s.ec, block, "minBond")
if err != nil {
return nil, err
}
mb, ok := out[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("quorum_status: minBond not *big.Int")
}
return mb, nil
}
// readBondedSet returns the set of verdict operators that are BONDED at `block` under
// the contract's exact settle predicate: AIGovernor._bonded(who) == (bondOf(who) != 0
// && bondOf(who) >= minBond). It deliberately does NOT consider the deregister flag — a
// deregistered-but-still-bonded operator's verdict still counts at settle, and only a
// fully-withdrawn (bond == 0) operator is dropped. Each distinct operator is read once.
func (s *Server) readBondedSet(ctx context.Context, block *big.Int, verdicts []Verdict, minBond *big.Int) (map[common.Address]bool, error) {
bonded := make(map[common.Address]bool, len(verdicts))
for i := range verdicts {
op := verdicts[i].Operator
if _, seen := bonded[op]; seen {
continue
}
out, err := s.governor.callAt(ctx, s.ec, block, "bondOf", op)
if err != nil {
return nil, err
}
bond, ok := out[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("quorum_status: bondOf not *big.Int")
}
// _bonded: bond != 0 && bond >= minBond.
bonded[op] = bond.Sign() != 0 && bond.Cmp(minBond) >= 0
}
return bonded, nil
}
// observedBlock pins the block this tool's reads are taken at: the current head number.
// Reading it once and threading it through every call gives a consistent snapshot.
func (s *Server) observedBlock(ctx context.Context) (*big.Int, error) {
bn, err := s.ec.BlockNumber(ctx)
if err != nil {
return nil, fmt.Errorf("mcp: observed block number: %w", err)
}
return new(big.Int).SetUint64(bn), nil
}
// blockTimestamp returns the timestamp of `block` (the same point the reads reflect),
// used to evaluate settle()'s deadline gate against the observed state, not a later head.
func (s *Server) blockTimestamp(ctx context.Context, block *big.Int) (uint64, error) {
hdr, err := s.ec.HeaderByNumber(ctx, block)
if err != nil {
return 0, fmt.Errorf("mcp: observed block header: %w", err)
}
return hdr.Time, nil
}
type quorumTally struct {
votesFor int // Yes verdicts among BONDED operators (what settle would tally)
votesAgainst int // No verdicts among BONDED operators
bestCount int
counted int // total BONDED verdicts considered
winningVote uint8
winningBucket uint16
quorumReached bool
}
// tallyQuorum reproduces AIGovernor.settle()'s tally EXACTLY: it considers ONLY
// verdicts whose operator is bonded at the observed block (bonded[op] true), groups
// them by the consensus key (vote, confidenceBucket) — the same _consensusKey settle
// uses — tracks the largest group, and reports quorumReached = (largest group >=
// threshold). A verdict from a withdrawn (unbonded) operator is DROPPED, just as settle
// drops it, so MCP cannot report quorumReached=true for a quorum the chain will settle
// Failed. winningVote/winningBucket name the best group so a non-Yes quorum is legible.
// votesFor / votesAgainst are the Yes / No counts among the BONDED set. Pure projection
// of on-chain reads — it submits nothing.
//
// Structural invariant (load-bearing): AIGovernor sets threshold = n/2 + 1, so AT MOST
// ONE (vote,bucket) group can ever reach quorum — two groups clearing threshold would
// need 2*threshold <= n, i.e. n+2 <= n, impossible. A withdrawal can therefore only
// DESTROY a quorum, never transfer it to a different vote-group. The first-seen strict-`>`
// tie-break below thus only governs the cosmetic winningVote/winningBucket on the
// no-quorum path (settle records Invalid there). If the threshold formula in
// AIGovernor.sol ever drops below n/2+1, this single-winner guarantee breaks and the
// tie-break parity with settle() becomes quorum-affecting — re-audit HIGH-1 then.
func tallyQuorum(verdicts []Verdict, threshold uint8, bonded map[common.Address]bool) quorumTally {
type key struct {
vote uint8
bucket uint16
}
groups := map[key]int{}
var t quorumTally
// Iterate in verdict order so ties resolve to the FIRST-seen group, matching
// settle()'s submitter-order scan (it keeps the earliest key at a given bestCount).
var bestKey key
haveBest := false
for i := range verdicts {
v := verdicts[i]
if !bonded[v.Operator] {
continue
}
t.counted++
k := key{v.Vote, v.ConfidenceBucket}
groups[k]++
switch v.Vote {
case voteYes:
t.votesFor++
case voteNo:
t.votesAgainst++
}
if c := groups[k]; c > t.bestCount {
t.bestCount = c
bestKey = k
haveBest = true
}
}
if haveBest {
t.winningVote = bestKey.vote
t.winningBucket = bestKey.bucket
}
t.quorumReached = t.bestCount >= int(threshold) && threshold > 0
return t
}
// ----------------------------------------------------------------------------
// 7. operator_reputation
// ----------------------------------------------------------------------------
func (s *Server) toolOperatorReputation(ctx context.Context, args map[string]interface{}) (interface{}, error) {
op, err := argAddress(args, "operator")
if err != nil {
return nil, err
}
isOpOut, err := s.governor.call(ctx, s.ec, "isOperator", op)
if err != nil {
return nil, err
}
isOp, ok := isOpOut[0].(bool)
if !ok {
return nil, fmt.Errorf("operator_reputation: isOperator not bool")
}
bondOut, err := s.governor.call(ctx, s.ec, "bondOf", op)
if err != nil {
return nil, err
}
bond, ok := bondOut[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("operator_reputation: bondOf not *big.Int")
}
weightOut, err := s.rep.call(ctx, s.ec, "weightOf", op)
if err != nil {
return nil, err
}
weight, ok := weightOut[0].(uint32)
if !ok {
return nil, fmt.Errorf("operator_reputation: weightOf not uint32")
}
rateOut, err := s.rep.call(ctx, s.ec, "agreementRateBps", op)
if err != nil {
return nil, err
}
rate, ok := rateOut[0].(uint32)
if !ok {
return nil, fmt.Errorf("operator_reputation: agreementRateBps not uint32")
}
rep, err := callStruct[Rep](ctx, s.rep, s.ec, "repOf", op)
if err != nil {
return nil, err
}
return map[string]interface{}{
"isOperator": isOp,
"bond": bond.String(),
"weight": weight,
"agreementRateBps": rate,
"rep": repJSON(&rep),
}, nil
}
// ----------------------------------------------------------------------------
// 8. pending_operations
// ----------------------------------------------------------------------------
func (s *Server) toolPendingOperations(ctx context.Context, args map[string]interface{}) (interface{}, error) {
limit := argLimit(args, defaultLimit)
// Pin reads to one block so taskCount, each thought, and the "now" used for the
// deadlinePassed flag all reflect the same chain point.
block, err := s.observedBlock(ctx)
if err != nil {
return nil, err
}
now, err := s.blockTimestamp(ctx, block)
if err != nil {
return nil, err
}
count, err := s.readTaskCountAt(ctx, block)
if err != nil {
return nil, err
}
open, truncated, scannedFrom, err := s.readPendingOperations(ctx, block, count, limit, now)
if err != nil {
return nil, err
}
return map[string]interface{}{
"taskCount": count.String(),
"pending": open,
// truncated is true when OLDER tasks below the scanned window were NOT inspected,
// so the caller knows this list may be incomplete (a deep still-Open task can
// hide below the tail window). scannedFrom..(taskCount-1) is the range looked at.
"truncated": truncated,
"scannedFrom": scannedFrom.String(),
"observedBlock": block.String(),
}, nil
}
// readPendingOperations scans the tail of taskCount descending and returns thoughts
// still in Open status, capped at limit. (Open means the task is accepting verdicts or
// awaiting settle; the chain only leaves Open via settle.) Each entry is annotated with
// deadlinePassed = now >= deadline, so the caller sees that a still-"Open" task whose
// voting window has CLOSED is settle-ready even though settle was never called. Returns
// truncated=true when the scan stopped above task 0 (older tasks were not inspected),
// and the lowest task id scanned. The bounded window plus the per-request eth_call
// ceiling keep a huge taskCount from issuing an unbounded scan.
func (s *Server) readPendingOperations(ctx context.Context, block, count *big.Int, limit int, now uint64) ([]interface{}, bool, *big.Int, error) {
out := []interface{}{}
last := new(big.Int).Sub(count, big.NewInt(1))
if count.Sign() == 0 {
return out, false, big.NewInt(0), nil
}
scanWindow := int64(limit)
if scanWindow < pendingScanFloor {
scanWindow = pendingScanFloor
}
floor := new(big.Int).Sub(last, big.NewInt(scanWindow-1))
if floor.Sign() < 0 {
floor = big.NewInt(0)
}
// lowestInspected tracks the smallest task id we actually read; truncated is then
// simply "did we stop before reaching task 0". This is honest whether the loop ended
// on the limit or on the window floor.
lowestInspected := new(big.Int).Set(last)
for i := new(big.Int).Set(last); i.Cmp(floor) >= 0 && len(out) < limit; i.Sub(i, big.NewInt(1)) {
lowestInspected.Set(i)
t, err := s.readThoughtAt(ctx, block, i)
if err != nil {
return nil, false, nil, err
}
if t.Status != statusOpen {
continue
}
res := thoughtJSON(t)
res["taskId"] = new(big.Int).Set(i).String()
res["status"] = derivedStatus(t.Status)
// A still-Open task past its deadline is settle-ready but un-settled — flag it so
// the LLM does not read "Open" as "still accepting / outcome not yet fixed".
res["deadlinePassed"] = now >= t.Deadline
out = append(out, res)
}
truncated := lowestInspected.Sign() > 0
return out, truncated, lowestInspected, nil
}
func (s *Server) readTaskCountAt(ctx context.Context, block *big.Int) (*big.Int, error) {
out, err := s.governor.callAt(ctx, s.ec, block, "taskCount")
if err != nil {
return nil, err
}
c, ok := out[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("pending_operations: taskCount not *big.Int")
}
return c, nil
}
// ----------------------------------------------------------------------------
// JSON projections — stable, all-scalar renderings of the mirror structs.
// ----------------------------------------------------------------------------
func roundJSON(r *Round) map[string]interface{} {
return map[string]interface{}{
"modelSpecHash": hexBytes32(r.ModelSpecHash),
"promptHash": hexBytes32(r.PromptHash),
"knobKey": r.KnobKey,
"lo": bigString(r.Lo),
"hi": bigString(r.Hi),
"n": r.N,
"threshold": r.Threshold,
"openedAt": r.OpenedAt,
"deadline": r.Deadline,
"opener": r.Opener.Hex(),
"status": r.Status,
"statusLabel": derivedStatus(r.Status),
"submissionCount": r.SubmissionCount,
"canonicalValue": bigString(r.CanonicalValue),
}
}
func proposalsJSON(ps []Proposal) []interface{} {
out := make([]interface{}, 0, len(ps))
for i := range ps {
out = append(out, map[string]interface{}{
"operator": ps[i].Operator.Hex(),
"value": bigString(ps[i].Value),
"confidenceBucket": ps[i].ConfidenceBucket,
"evidenceHash": hexBytes32(ps[i].EvidenceHash),
"submittedAt": ps[i].SubmittedAt,
})
}
return out
}
func thoughtJSON(t *Thought) map[string]interface{} {
return map[string]interface{}{
"modelSpecHash": hexBytes32(t.ModelSpecHash),
"promptHash": hexBytes32(t.PromptHash),
"evidenceHash": hexBytes32(t.EvidenceHash),
"n": t.N,
"threshold": t.Threshold,
"openedAt": t.OpenedAt,
"deadline": t.Deadline,
"opener": t.Opener.Hex(),
"rawStatus": t.Status,
"submissionCount": t.SubmissionCount,
"knobKey": t.KnobKey,
"canonicalVote": t.CanonicalVote,
"canonicalBucket": t.CanonicalBucket,
"agreeCount": t.AgreeCount,
"evidenceRoot": hexBytes32(t.EvidenceRoot),
"commitReveal": t.CommitReveal,
"commitDeadline": t.CommitDeadline,
"revealDeadline": t.RevealDeadline,
}
}
func verdictsJSON(vs []Verdict) []interface{} {
out := make([]interface{}, 0, len(vs))
for i := range vs {
out = append(out, map[string]interface{}{
"operator": vs[i].Operator.Hex(),
"vote": vs[i].Vote,
"confidenceBucket": vs[i].ConfidenceBucket,
"evidenceHash": hexBytes32(vs[i].EvidenceHash),
"submittedAt": vs[i].SubmittedAt,
})
}
return out
}
func receiptJSON(rc *ThoughtReceipt) map[string]interface{} {
return map[string]interface{}{
"modelId": hexBytes32(rc.ModelId),
"promptHash": hexBytes32(rc.PromptHash),
"outputHash": hexBytes32(rc.OutputHash),
"paymentHash": hexBytes32(rc.PaymentHash),
"quorumProof": hexBytes32(rc.QuorumProof),
"payer": rc.Payer.Hex(),
"operator": rc.Operator.Hex(),
"cost": bigString(rc.Cost),
"registeredAt": rc.RegisteredAt,
"blockNumber": rc.BlockNumber,
}
}
func repJSON(r *Rep) map[string]interface{} {
return map[string]interface{}{
"weightBps": r.WeightBps,
"participated": r.Participated,
"agreed": r.Agreed,
"lastTaskId1": r.LastTaskId1,
"lastUpdated": r.LastUpdated,
}
}
func hexBytes32(b [32]byte) string {
return common.BytesToHash(b[:]).Hex()
}