mirror of
https://github.com/luxfi/mcp.git
synced 2026-07-26 23:59:51 +00:00
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:
+15
@@ -0,0 +1,15 @@
|
||||
# Go
|
||||
bin/
|
||||
*.out
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user