diff --git a/demos/dev-reputation/README.md b/demos/dev-reputation/README.md new file mode 100644 index 0000000000..0a4bf012c5 --- /dev/null +++ b/demos/dev-reputation/README.md @@ -0,0 +1,79 @@ +# IPC Developer Reputation Demo + +This demo implements a verifiable developer reputation pipeline on IPC: an off-chain Node.js agent collects and analyses GitHub PR activity, applies anti-gaming controls, writes a signed evidence package to Basin, and submits a compact score record to an IPC reputation actor. + +## Directory layout + +- `agent/` - off-chain scorer, CLI, and HTTP API +- `actor/` - Rust/WASM reputation actor +- `frontend/` - static dashboard (no build step) +- `scripts/` - deploy, scoring helper, smoke test + +## Agent setup + +```bash +cd demos/dev-reputation/agent +cp .env.example .env +npm install +npm start +``` + +### Agent environment variables + +- `ANTHROPIC_API_KEY` (required) +- `AGENT_PRIVATE_KEY` (required, secp256k1 hex without `0x`) +- `GITHUB_TOKEN` (optional but recommended) +- `BASIN_API_URL` (required) +- `BASIN_BUCKET` (required) +- `IPC_RPC_URL` (required) +- `REPUTATION_ACTOR_ADDRESS` (required for on-chain submit) +- `ADMIN_ADDRESS` (required) +- `PORT` (optional, defaults to `3001`) + +## Actor build + +```bash +cd demos/dev-reputation/actor +cargo build --target wasm32-unknown-unknown --release +``` + +## Deploy actor + +```bash +cd demos/dev-reputation +export ADMIN_ADDRESS=0x... +export AGENT_ADDRESS=0x... +./scripts/deploy.sh +``` + +`deploy.sh` builds the WASM, deploys the actor via `ipc-cli`, initializes admin + initial agent, writes `.env.actor`, and updates `frontend/config.js`. + +## Score a developer + +```bash +cd demos/dev-reputation +./scripts/score.sh torvalds 0x000000000000000000000000000000000000dEaD +``` + +## API + +- `POST /score` with `{ github_handle, wallet_address }` returns `job_id` +- `GET /job/:job_id` returns status and progress +- `GET /score/:github_handle` returns latest score +- `GET /health` returns agent metadata + +## Dashboard + +Open `frontend/index.html` directly in a browser. The dashboard supports leaderboard + profile views and client-side verification checks (content hash, signer recovery, and block timestamp check). + +## Anti-gaming model + +The anti-gaming system combines LLM judgment with deterministic heuristics: relocation-without-change downweights synthetic large moves, formatter-only commits and low-signal message inflation are excluded from weighted commit counts, and generated artifacts are discounted to 0.1x line contribution so score signal stays anchored to substantive engineering work rather than surface churn. + +## Tests + +```bash +cd demos/dev-reputation/agent && npm test +cd ../actor && cargo test +cd .. && ./scripts/smoke_test.sh +``` diff --git a/demos/dev-reputation/actor/Cargo.lock b/demos/dev-reputation/actor/Cargo.lock new file mode 100644 index 0000000000..f0b18e125a --- /dev/null +++ b/demos/dev-reputation/actor/Cargo.lock @@ -0,0 +1,970 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cbor4ii" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd94671561e36e4e7de75f753f577edafb0e7c05d6e4547229fdf7938fbcd2c3" +dependencies = [ + "core2", + "multibase", + "multihash 0.18.1", + "serde", + "serde_bytes", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "cid" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" +dependencies = [ + "core2", + "multibase", + "multihash 0.19.3", + "serde", + "serde_bytes", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "data-encoding-macro" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fvm_ipld_blockstore" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d064b957420f5ecc137a153baaa6c32e2eb19b674135317200b6f2537eabdbfd" +dependencies = [ + "anyhow", + "cid 0.10.1", + "multihash 0.18.1", +] + +[[package]] +name = "fvm_ipld_blockstore" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b8b31e022f71b73440054f7e5171231a1ebc745adf075014d5aa8ea78ea283" +dependencies = [ + "anyhow", + "cid 0.11.1", + "multihash-codetable", +] + +[[package]] +name = "fvm_ipld_encoding" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90608092e31d9a06236268c58f7c36668ab4b2a48afafe3a97e08f094ad7ae50" +dependencies = [ + "anyhow", + "cid 0.10.1", + "fvm_ipld_blockstore 0.2.1", + "multihash 0.18.1", + "serde", + "serde_ipld_dagcbor 0.4.2", + "serde_repr", + "serde_tuple", + "thiserror 1.0.69", +] + +[[package]] +name = "fvm_ipld_encoding" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4fd0c7d16be0076920acd5bf13e705a80dfe6540d4722b19745daa9ea93722a" +dependencies = [ + "anyhow", + "cid 0.11.1", + "fvm_ipld_blockstore 0.3.1", + "multihash-codetable", + "serde", + "serde_ipld_dagcbor 0.6.4", + "serde_repr", + "serde_tuple", + "thiserror 2.0.18", +] + +[[package]] +name = "fvm_sdk" +version = "4.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e532109bf5bc699cfbe1b7e3bf694bed381c28f8a9243a1549254d43866e1588" +dependencies = [ + "cid 0.11.1", + "fvm_ipld_encoding 0.5.3", + "fvm_shared", + "lazy_static", + "log", + "num-traits", + "thiserror 2.0.18", +] + +[[package]] +name = "fvm_shared" +version = "4.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d3a23bf6d2a96ca280745afea1a79c5b701d5ac07814ec50ea34380762d47a" +dependencies = [ + "anyhow", + "bitflags", + "blake2b_simd", + "cid 0.11.1", + "data-encoding", + "data-encoding-macro", + "fvm_ipld_encoding 0.5.3", + "num-bigint", + "num-derive", + "num-integer", + "num-traits", + "serde", + "thiserror 2.0.18", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest", +] + +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest", + "generic-array", + "hmac", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipc-reputation-actor" +version = "0.1.0" +dependencies = [ + "fvm_ipld_blockstore 0.2.1", + "fvm_ipld_encoding 0.4.0", + "fvm_sdk", + "fvm_shared", + "libsecp256k1", + "serde", + "tiny-keccak", +] + +[[package]] +name = "ipld-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda" +dependencies = [ + "cid 0.11.1", + "serde", + "serde_bytes", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64", + "digest", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand", + "serde", + "sha2", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd8a792c1694c6da4f68db0a9d707c72bd260994da179e6030a5dcee00bb815" +dependencies = [ + "blake2b_simd", + "core2", + "multihash-derive 0.8.1", + "serde", + "serde-big-array", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "multihash" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +dependencies = [ + "core2", + "serde", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "multihash-codetable" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67996849749d25f1da9f238e8ace2ece8f9d6bdf3f9750aaf2ae7de3a5cad8ea" +dependencies = [ + "blake2b_simd", + "core2", + "multihash-derive 0.9.1", +] + +[[package]] +name = "multihash-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6d4752e6230d8ef7adf7bd5d8c4b1f6561c1014c5ba9a37445ccefe18aa1db" +dependencies = [ + "proc-macro-crate 1.1.3", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "multihash-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f1b7edab35d920890b88643a765fc9bd295cf0201f4154dda231bef9b8404eb" +dependencies = [ + "core2", + "multihash 0.19.3", + "multihash-derive-impl", +] + +[[package]] +name = "multihash-derive-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3dc7141bd06405929948754f0628d247f5ca1865be745099205e5086da957cb" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure 0.13.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "proc-macro-crate" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17d47ce914bf4de440332250b0edd23ce48c005f59fab39d3335866b114f11a" +dependencies = [ + "thiserror 1.0.69", + "toml", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd31f59f6fe2b0c055371bb2f16d7f0aa7d8881676c04a55b1596d1a17cd10a4" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_ipld_dagcbor" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e880e0b1f9c7a8db874642c1217f7e19b29e325f24ab9f0fcb11818adec7f01" +dependencies = [ + "cbor4ii", + "cid 0.10.1", + "scopeguard", + "serde", +] + +[[package]] +name = "serde_ipld_dagcbor" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" +dependencies = [ + "cbor4ii", + "ipld-core", + "scopeguard", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_tuple" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f025b91216f15a2a32aa39669329a475733590a015835d1783549a56d09427" +dependencies = [ + "serde", + "serde_tuple_macros", +] + +[[package]] +name = "serde_tuple_macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4076151d1a2b688e25aaf236997933c66e18b870d0369f8b248b8ab2be630d7e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer", + "cfg-if", + "cpufeatures", + "digest", + "opaque-debug", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] diff --git a/demos/dev-reputation/actor/Cargo.toml b/demos/dev-reputation/actor/Cargo.toml new file mode 100644 index 0000000000..6787e649d2 --- /dev/null +++ b/demos/dev-reputation/actor/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ipc-reputation-actor" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +fvm_sdk = { version = "4", default-features = false } +fvm_shared = { version = "4", default-features = false } +fvm_ipld_encoding = { version = "0.4" } +fvm_ipld_blockstore = { version = "0.2" } +libsecp256k1 = { version = "0.7", default-features = false, features = ["static-context", "hmac"] } +serde = { version = "1", features = ["derive"] } +tiny-keccak = { version = "2", features = ["keccak"] } + +[profile.release] +opt-level = "z" +lto = true +panic = "abort" +overflow-checks = true + +[workspace] diff --git a/demos/dev-reputation/actor/src/lib.rs b/demos/dev-reputation/actor/src/lib.rs new file mode 100644 index 0000000000..59fa08057c --- /dev/null +++ b/demos/dev-reputation/actor/src/lib.rs @@ -0,0 +1,114 @@ +pub mod registry; +pub mod verify; + +use fvm_ipld_encoding::tuple::*; +use fvm_shared::address::Address; + +use registry::{ReputationRecord, State}; +use verify::recover_address; + +pub const EXIT_ILLEGAL_ARGUMENT: u32 = 16; +pub const EXIT_FORBIDDEN: u32 = 18; + +#[derive(Debug, Clone)] +pub struct ActorError { + pub code: u32, + pub message: String, +} + +impl ActorError { + pub fn illegal_argument(message: impl Into) -> Self { + Self { + code: EXIT_ILLEGAL_ARGUMENT, + message: message.into(), + } + } + + pub fn forbidden(message: impl Into) -> Self { + Self { + code: EXIT_FORBIDDEN, + message: message.into(), + } + } +} + +#[derive(Serialize_tuple, Deserialize_tuple, Clone, Debug)] +pub struct SetScoreParams { + pub developer: [u8; 20], + pub github_handle: String, + pub score: u8, + pub tier: String, + pub evidence_cid: String, + pub period: String, + pub document_hash: [u8; 32], + pub agent_address: [u8; 20], + pub signature: Vec, +} + +#[derive(Clone, Debug)] +pub struct ReputationActor { + pub state: State, +} + +impl ReputationActor { + pub fn constructor(admin: Address, initial_agent: [u8; 20]) -> Self { + Self { + state: State::new(admin, vec![initial_agent]), + } + } + + pub fn set_score( + &mut self, + caller: [u8; 20], + params: SetScoreParams, + block_height: u64, + timestamp: u64, + ) -> Result<(), ActorError> { + let recovered = recover_address(params.document_hash, ¶ms.signature) + .map_err(|e| ActorError::illegal_argument(format!("signature verification failed: {e}")))?; + + if recovered != params.agent_address { + return Err(ActorError::illegal_argument("recovered signer does not match agent_address")); + } + + if !self.state.is_authorised(¶ms.agent_address) || !self.state.is_authorised(&caller) { + return Err(ActorError::forbidden("caller or agent not authorised")); + } + + let record = ReputationRecord { + score: params.score, + tier: params.tier, + evidence_cid: params.evidence_cid, + period: params.period, + agent_address: params.agent_address, + block_height, + timestamp, + }; + self.state.records.insert(params.developer, record); + Ok(()) + } + + pub fn get_score(&self, developer: [u8; 20]) -> Option { + self.state.records.get(&developer).cloned() + } + + pub fn add_agent(&mut self, caller_admin: &Address, agent: [u8; 20]) -> Result<(), ActorError> { + if caller_admin != &self.state.admin { + return Err(ActorError::forbidden("only admin can add agents")); + } + self.state.add_agent(agent); + Ok(()) + } + + pub fn remove_agent(&mut self, caller_admin: &Address, agent: [u8; 20]) -> Result<(), ActorError> { + if caller_admin != &self.state.admin { + return Err(ActorError::forbidden("only admin can remove agents")); + } + self.state.remove_agent(&agent); + Ok(()) + } + + pub fn get_authorised_agents(&self) -> Vec<[u8; 20]> { + self.state.authorised_agents.clone() + } +} diff --git a/demos/dev-reputation/actor/src/registry.rs b/demos/dev-reputation/actor/src/registry.rs new file mode 100644 index 0000000000..44daeb9267 --- /dev/null +++ b/demos/dev-reputation/actor/src/registry.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use fvm_ipld_encoding::tuple::*; +use fvm_shared::address::Address; +#[derive(Serialize_tuple, Deserialize_tuple, Clone, Debug, PartialEq, Eq)] +pub struct ReputationRecord { + pub score: u8, + pub tier: String, + pub evidence_cid: String, + pub period: String, + pub agent_address: [u8; 20], + pub block_height: u64, + pub timestamp: u64, +} + +#[derive(Serialize_tuple, Deserialize_tuple, Clone, Debug)] +pub struct State { + pub records: HashMap<[u8; 20], ReputationRecord>, + pub authorised_agents: Vec<[u8; 20]>, + pub admin: Address, +} + +impl State { + pub fn new(admin: Address, initial_agents: Vec<[u8; 20]>) -> Self { + Self { + records: HashMap::new(), + authorised_agents: initial_agents, + admin, + } + } + + pub fn is_authorised(&self, agent: &[u8; 20]) -> bool { + self.authorised_agents.iter().any(|a| a == agent) + } + + pub fn add_agent(&mut self, agent: [u8; 20]) { + if !self.authorised_agents.iter().any(|a| a == &agent) { + self.authorised_agents.push(agent); + } + } + + pub fn remove_agent(&mut self, agent: &[u8; 20]) { + self.authorised_agents.retain(|a| a != agent); + } +} diff --git a/demos/dev-reputation/actor/src/verify.rs b/demos/dev-reputation/actor/src/verify.rs new file mode 100644 index 0000000000..7ae52ad46d --- /dev/null +++ b/demos/dev-reputation/actor/src/verify.rs @@ -0,0 +1,41 @@ +use libsecp256k1::{recover, Message, RecoveryId, Signature}; +use tiny_keccak::{Hasher, Keccak}; + +fn keccak256(input: &[u8]) -> [u8; 32] { + let mut output = [0u8; 32]; + let mut keccak = Keccak::v256(); + keccak.update(input); + keccak.finalize(&mut output); + output +} + +pub fn prefixed_hash(document_hash: [u8; 32]) -> [u8; 32] { + let mut bytes = b"\x19Ethereum Signed Message:\n32".to_vec(); + bytes.extend_from_slice(&document_hash); + keccak256(&bytes) +} + +pub fn recover_address(document_hash: [u8; 32], signature: &[u8]) -> Result<[u8; 20], String> { + if signature.len() != 65 { + return Err("invalid signature length".to_string()); + } + let mut sig = [0u8; 64]; + sig.copy_from_slice(&signature[0..64]); + let mut v = signature[64]; + if v >= 27 { + v -= 27; + } + if v > 1 { + return Err("invalid recovery id".to_string()); + } + + let recovery_id = RecoveryId::parse(v).map_err(|_| "invalid recovery id".to_string())?; + let standard_sig = Signature::parse_standard_slice(&sig).map_err(|_| "invalid signature".to_string())?; + let msg = Message::parse(&prefixed_hash(document_hash)); + let pubkey = recover(&msg, &standard_sig, &recovery_id).map_err(|_| "recover failed".to_string())?; + let serialized = pubkey.serialize(); + let hashed = keccak256(&serialized[1..]); + let mut addr = [0u8; 20]; + addr.copy_from_slice(&hashed[12..]); + Ok(addr) +} diff --git a/demos/dev-reputation/actor/tests/actor_tests.rs b/demos/dev-reputation/actor/tests/actor_tests.rs new file mode 100644 index 0000000000..19bec1c36f --- /dev/null +++ b/demos/dev-reputation/actor/tests/actor_tests.rs @@ -0,0 +1,120 @@ +use fvm_shared::address::Address; +use ipc_reputation_actor::{ + verify::prefixed_hash, ActorError, ReputationActor, SetScoreParams, EXIT_FORBIDDEN, EXIT_ILLEGAL_ARGUMENT, +}; +use libsecp256k1::{sign, Message, PublicKey, SecretKey}; +use tiny_keccak::{Hasher, Keccak}; + +fn keccak256(input: &[u8]) -> [u8; 32] { + let mut output = [0u8; 32]; + let mut keccak = Keccak::v256(); + keccak.update(input); + keccak.finalize(&mut output); + output +} + +fn secret_key() -> SecretKey { + SecretKey::parse(&[7u8; 32]).expect("secret key") +} + +fn ethereum_address(secret: &SecretKey) -> [u8; 20] { + let public = PublicKey::from_secret_key(secret); + let serialized = public.serialize(); + let hash = keccak256(&serialized[1..]); + let mut out = [0u8; 20]; + out.copy_from_slice(&hash[12..]); + out +} + +fn signed_params(agent_secret: &SecretKey, developer: [u8; 20], score: u8) -> SetScoreParams { + let document_hash = keccak256(b"demo-doc"); + let prefixed = prefixed_hash(document_hash); + let message = Message::parse(&prefixed); + let (sig, rid) = sign(&message, agent_secret); + let mut signature = vec![0u8; 65]; + signature[..64].copy_from_slice(&sig.serialize()); + signature[64] = rid.serialize() + 27; + + SetScoreParams { + developer, + github_handle: "alice".to_string(), + score, + tier: "senior".to_string(), + evidence_cid: "bafy-demo".to_string(), + period: "2026-Q1".to_string(), + document_hash, + agent_address: ethereum_address(agent_secret), + signature, + } +} + +fn must_err(res: Result) -> ActorError { + match res { + Ok(_) => panic!("expected error"), + Err(e) => e, + } +} + +#[test] +fn test_set_score() { + let secret = secret_key(); + let agent_addr = ethereum_address(&secret); + let mut actor = ReputationActor::constructor(Address::new_id(1000), agent_addr); + + let developer = [1u8; 20]; + let params = signed_params(&secret, developer, 88); + actor + .set_score(agent_addr, params.clone(), 1234, 1710850000) + .expect("set score should pass"); + + let record = actor.get_score(developer).expect("record must exist"); + assert_eq!(record.score, 88); + assert_eq!(record.evidence_cid, params.evidence_cid); + assert_eq!(record.agent_address, agent_addr); +} + +#[test] +fn test_invalid_signature() { + let secret = secret_key(); + let agent_addr = ethereum_address(&secret); + let mut actor = ReputationActor::constructor(Address::new_id(1000), agent_addr); + + let mut params = signed_params(&secret, [2u8; 20], 70); + params.signature[10] ^= 0xff; + let err = must_err(actor.set_score(agent_addr, params, 10, 20)); + assert_eq!(err.code, EXIT_ILLEGAL_ARGUMENT); +} + +#[test] +fn test_unauthorised_agent() { + let secret = secret_key(); + let agent_addr = ethereum_address(&secret); + let mut actor = ReputationActor::constructor(Address::new_id(1000), agent_addr); + + let params = signed_params(&secret, [3u8; 20], 66); + let unauthorised_caller = [9u8; 20]; + let err = must_err(actor.set_score(unauthorised_caller, params, 10, 20)); + assert_eq!(err.code, EXIT_FORBIDDEN); +} + +#[test] +fn test_update_score() { + let secret = secret_key(); + let agent_addr = ethereum_address(&secret); + let mut actor = ReputationActor::constructor(Address::new_id(1000), agent_addr); + let developer = [4u8; 20]; + + let params1 = signed_params(&secret, developer, 40); + actor + .set_score(agent_addr, params1, 11, 100) + .expect("first score should set"); + + let params2 = signed_params(&secret, developer, 92); + actor + .set_score(agent_addr, params2, 12, 200) + .expect("second score should overwrite"); + + let record = actor.get_score(developer).expect("record should exist"); + assert_eq!(record.score, 92); + assert_eq!(record.block_height, 12); +} diff --git a/demos/dev-reputation/agent/.env.example b/demos/dev-reputation/agent/.env.example new file mode 100644 index 0000000000..214eebfc9d --- /dev/null +++ b/demos/dev-reputation/agent/.env.example @@ -0,0 +1,9 @@ +ANTHROPIC_API_KEY= +AGENT_PRIVATE_KEY= +GITHUB_TOKEN= +BASIN_API_URL=https://basin.tableland.xyz +BASIN_BUCKET= +IPC_RPC_URL=https://api.calibration.node.glif.io/rpc/v1 +REPUTATION_ACTOR_ADDRESS= +ADMIN_ADDRESS= +PORT=3001 diff --git a/demos/dev-reputation/agent/package.json b/demos/dev-reputation/agent/package.json new file mode 100644 index 0000000000..68ddb84b3b --- /dev/null +++ b/demos/dev-reputation/agent/package.json @@ -0,0 +1,19 @@ +{ + "name": "ipc-dev-reputation-agent", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "score": "AGENT_MODE=cli node src/index.js", + "test": "node --test tests/*.test.js" + }, + "dependencies": { + "axios": "^1.8.4", + "dotenv": "^16.4.7", + "ethers": "^5.7.2", + "express": "^4.21.2", + "js-sha3": "^0.9.3" + } +} diff --git a/demos/dev-reputation/agent/src/analyser.js b/demos/dev-reputation/agent/src/analyser.js new file mode 100644 index 0000000000..6a1569f85d --- /dev/null +++ b/demos/dev-reputation/agent/src/analyser.js @@ -0,0 +1,141 @@ +const axios = require("axios"); + +const ANALYSIS_SYSTEM_PROMPT = `You are a senior engineering evaluator scoring pull requests for technical merit. +You evaluate the ACTUAL WORK done, not surface metrics like line count. + +You will be given a pull request diff along with its title, description, +file list, and review comment thread. Evaluate it on the following six +dimensions and return a JSON object — nothing else, no prose, no markdown. + +Dimensions: + problem_solving_depth (0-100): + How hard was the underlying problem? Does the solution show real + understanding of the domain? Penalise obvious or mechanical solutions. + + code_quality (0-100): + Is the code readable, well-structured, and appropriately tested? + Does it handle edge cases? Is complexity justified? + + review_responsiveness (0-100): + How well did the author engage with review feedback? Did they + explain their reasoning? Did they push back thoughtfully when appropriate? + Score 50 if there were no review comments. + + scope_appropriateness (0-100): + Is the PR an appropriate unit of work — not too large, not trivially + small? Does it solve exactly one thing cleanly? + + net_complexity_reduction (0-100): + Did the PR make the codebase simpler or harder to understand? + Refactors that reduce coupling score high. Features that add + necessary complexity score medium. Pure additions score lower. + + gaming_likelihood (0-100): + How likely is this PR to be padding rather than real work? + 0 = clearly genuine, 100 = almost certainly gaming. + Look for: moved-without-changed code, formatter-only commits, + generated output committed verbatim, trivial renames at scale. + +Also return: + pr_score (0-100): weighted aggregate of the above dimensions + verdict (string): 2-3 sentence plain English summary of what + the PR demonstrates about the developer. Be specific about + the technical content — name the pattern, technique, or + problem domain. Do not be generic. + weight_multiplier (float 0.0-1.0): how much this PR should + contribute to the overall score. 1.0 for substantive work, + 0.5 for medium-value PRs, 0.1 for near-trivial, 0.0 for + pure gaming. This is your judgment call. + +Return only valid JSON. No prose before or after.`; + +function summariseReviewThread(reviews = [], comments = []) { + if (!reviews.length && !comments.length) { + return "No review comments."; + } + const reviewSummary = reviews + .slice(0, 8) + .map((r) => `${r.user?.login || "reviewer"}:${r.state || "COMMENTED"}:${(r.body || "").slice(0, 160)}`) + .join(" | "); + const commentSummary = comments + .slice(0, 10) + .map((c) => `${c.user?.login || "reviewer"}:${(c.body || "").slice(0, 160)}`) + .join(" | "); + return [reviewSummary, commentSummary].filter(Boolean).join(" || "); +} + +function truncatePatch(patch = "", perFileLimit = 6000) { + if (!patch) { + return ""; + } + if (patch.length <= perFileLimit) { + return patch; + } + return `${patch.slice(0, 3000)}\n\n... [TRUNCATED] ...\n\n${patch.slice(-3000)}`; +} + +function buildDiffText(files = [], globalLimit = 32000) { + const joined = files + .map((f) => `FILE: ${f.filename}\nSTATUS: ${f.status}\n${truncatePatch(f.patch || "")}`) + .join("\n\n"); + if (joined.length <= globalLimit) { + return joined; + } + return `${joined.slice(0, globalLimit / 2)}\n\n... [GLOBAL TRUNCATION] ...\n\n${joined.slice(-globalLimit / 2)}`; +} + +function safeJsonParse(text) { + const trimmed = text.trim(); + try { + return JSON.parse(trimmed); + } catch (_) { + const firstBrace = trimmed.indexOf("{"); + const lastBrace = trimmed.lastIndexOf("}"); + if (firstBrace >= 0 && lastBrace > firstBrace) { + return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1)); + } + throw new Error("Claude response was not valid JSON"); + } +} + +async function analysePR(pr, { anthropicApiKey }) { + const reviewSummary = summariseReviewThread(pr.reviews, pr.comments); + const diff = buildDiffText(pr.files); + const userPrompt = `PR title: ${pr.title} +PR description: ${pr.body || "none"} +Files changed: ${pr.changed_files}, Additions: ${pr.additions}, Deletions: ${pr.deletions} +Review rounds: ${(pr.reviews || []).length} +Review comments summary: ${reviewSummary} + +Full diff (truncated to 8000 tokens if necessary): +${diff}`; + + const response = await axios.post( + "https://api.anthropic.com/v1/messages", + { + model: "claude-sonnet-4-20250514", + max_tokens: 1200, + temperature: 0.1, + system: ANALYSIS_SYSTEM_PROMPT, + messages: [{ role: "user", content: userPrompt }], + }, + { + headers: { + "Content-Type": "application/json", + "x-api-key": anthropicApiKey, + "anthropic-version": "2023-06-01", + }, + timeout: 60000, + } + ); + + const text = response.data?.content?.[0]?.text || "{}"; + return safeJsonParse(text); +} + +module.exports = { + ANALYSIS_SYSTEM_PROMPT, + analysePR, + buildDiffText, + summariseReviewThread, +}; diff --git a/demos/dev-reputation/agent/src/anticheat.js b/demos/dev-reputation/agent/src/anticheat.js new file mode 100644 index 0000000000..1cf90db4a7 --- /dev/null +++ b/demos/dev-reputation/agent/src/anticheat.js @@ -0,0 +1,174 @@ +const GENERATED_FILE_PATTERNS = [ + /\.pb\.go$/i, + /\.generated\.ts$/i, + /_gen\./i, + /^\d{14}_.*\.sql$/i, +]; + +const MESSAGE_EXCLUDE_RE = /\b(fix typo|formatting|whitespace|prettier|lint|nit)\b/i; +const WHITESPACE_DIFF_RE = /^[+-]\s*[;,{}()[\]]*\s*$/; + +function normaliseLine(line) { + return line.replace(/\s+/g, ""); +} + +function extractDiffLines(patch = "") { + const lines = patch.split("\n"); + const added = []; + const removed = []; + for (const line of lines) { + if (line.startsWith("+++ ") || line.startsWith("--- ")) { + continue; + } + if (line.startsWith("+")) { + added.push(normaliseLine(line.slice(1))); + } else if (line.startsWith("-")) { + removed.push(normaliseLine(line.slice(1))); + } + } + return { added, removed }; +} + +function jaccardSimilarity(a, b) { + const setA = new Set(a.filter(Boolean)); + const setB = new Set(b.filter(Boolean)); + if (!setA.size && !setB.size) { + return 1; + } + const intersection = new Set([...setA].filter((x) => setB.has(x))).size; + const union = new Set([...setA, ...setB]).size || 1; + return intersection / union; +} + +function isGeneratedFile(file) { + if (GENERATED_FILE_PATTERNS.some((re) => re.test(file.filename || ""))) { + return true; + } + const patch = file.patch || ""; + const fingerprints = ["DO NOT EDIT", "Code generated by", "This file was generated"]; + return fingerprints.some((s) => patch.includes(s)); +} + +function isFormatterCommit(commitDetail) { + const files = commitDetail.files || []; + if (!files.length) { + return false; + } + return files.every((file) => { + const patch = file.patch || ""; + if (!patch) { + return false; + } + const changed = patch + .split("\n") + .filter((line) => line.startsWith("+") || line.startsWith("-")) + .filter((line) => !line.startsWith("+++ ") && !line.startsWith("--- ")); + return changed.length > 0 && changed.every((line) => WHITESPACE_DIFF_RE.test(line)); + }); +} + +function applyAntiCheat({ prs, commits, commitDetailsBySha }) { + const flags = []; + let rawLinesAdded = 0; + let effectiveLinesAdded = 0; + let weightedCommits = 0; + let excludedCommits = 0; + + for (const pr of prs) { + rawLinesAdded += Number(pr.additions || 0); + let relocatedLines = 0; + let changedLines = 0; + const flaggedFiles = []; + + for (const file of pr.files || []) { + const additions = Number(file.additions || 0); + const deletions = Number(file.deletions || 0); + const localChanged = additions + deletions; + changedLines += localChanged; + + const { added, removed } = extractDiffLines(file.patch || ""); + const similarity = jaccardSimilarity(added, removed); + if ((file.status === "added" || file.status === "renamed") && similarity > 0.85) { + relocatedLines += localChanged; + flaggedFiles.push({ file: file.filename, similarity }); + } + + let fileWeight = 1.0; + const generated = isGeneratedFile(file); + const likelyGeneratedLargeAdd = additions > 200 && (!pr.comments || pr.comments.length === 0); + if (generated || likelyGeneratedLargeAdd) { + fileWeight = 0.1; + } + effectiveLinesAdded += additions * fileWeight; + } + + const relocatedRatio = changedLines > 0 ? relocatedLines / changedLines : 0; + if (relocatedRatio > 0.6) { + const original = Number(pr.analysis?.weight_multiplier ?? 1.0); + const overridden = Math.max(0.05, original * 0.1); + if (pr.analysis) { + pr.analysis.weight_multiplier = overridden; + } + flags.push({ + pattern: "code_relocation_without_modification", + affected_prs: [pr.number], + description: `Relocation ratio ${(relocatedRatio * 100).toFixed(1)}% across ${flaggedFiles.length} file(s).`, + weight_applied: overridden, + }); + } + } + + for (const commit of commits) { + const sha = commit.sha; + const message = commit.commit?.message || ""; + const detail = commitDetailsBySha[sha] || {}; + const lower = message.toLowerCase(); + + if (lower.startsWith("merge ") || lower.startsWith("revert ")) { + excludedCommits += 1; + continue; + } + + if (MESSAGE_EXCLUDE_RE.test(message)) { + excludedCommits += 1; + continue; + } + + if (isFormatterCommit(detail)) { + excludedCommits += 1; + continue; + } + + if (message.trim().length < 10) { + weightedCommits += 0.3; + flags.push({ + pattern: "commit_message_inflation", + affected_prs: [], + description: `Low-quality short commit message on ${sha.slice(0, 8)}`, + weight_applied: 0.3, + }); + continue; + } + + weightedCommits += 1; + } + + const rawInflationPct = rawLinesAdded > 0 ? Math.round((1 - effectiveLinesAdded / rawLinesAdded) * 100) : 0; + + return { + adjusted: { + weighted_commits: Number(weightedCommits.toFixed(2)), + effective_lines_added: Math.round(effectiveLinesAdded), + inflation_removed_pct: rawInflationPct, + excluded_commits: excludedCommits, + }, + gaming_flags: flags, + }; +} + +module.exports = { + applyAntiCheat, + extractDiffLines, + isFormatterCommit, + jaccardSimilarity, +}; diff --git a/demos/dev-reputation/agent/src/basin.js b/demos/dev-reputation/agent/src/basin.js new file mode 100644 index 0000000000..5b4a6558de --- /dev/null +++ b/demos/dev-reputation/agent/src/basin.js @@ -0,0 +1,25 @@ +const axios = require("axios"); + +async function writeEvidenceToBasin(document, { basinApiUrl, bucket }) { + const url = `${basinApiUrl.replace(/\/$/, "")}/api/v1/buckets/${encodeURIComponent(bucket)}/objects`; + const response = await axios.post(url, document, { + headers: { "Content-Type": "application/json" }, + timeout: 30000, + }); + const cid = response.data?.cid || response.data?.data?.cid || response.data?.result?.cid; + if (!cid) { + throw new Error("Basin response missing CID"); + } + return cid; +} + +async function fetchEvidenceFromBasin(cid, { basinApiUrl, bucket }) { + const url = `${basinApiUrl.replace(/\/$/, "")}/api/v1/buckets/${encodeURIComponent(bucket)}/objects/${encodeURIComponent(cid)}`; + const response = await axios.get(url, { timeout: 30000 }); + return response.data; +} + +module.exports = { + writeEvidenceToBasin, + fetchEvidenceFromBasin, +}; diff --git a/demos/dev-reputation/agent/src/github.js b/demos/dev-reputation/agent/src/github.js new file mode 100644 index 0000000000..8dd44d5498 --- /dev/null +++ b/demos/dev-reputation/agent/src/github.js @@ -0,0 +1,182 @@ +const axios = require("axios"); + +const GITHUB_API = "https://api.github.com"; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class GitHubClient { + constructor({ token } = {}) { + this.http = axios.create({ + baseURL: GITHUB_API, + timeout: 30000, + headers: { + Accept: "application/vnd.github+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } + + async request(config, maxRetries = 5) { + let attempt = 0; + let backoffMs = 1000; + while (attempt <= maxRetries) { + try { + const response = await this.http.request(config); + return response.data; + } catch (error) { + const status = error.response?.status; + const retryAfter = Number(error.response?.headers?.["retry-after"]); + const isRateLimit = status === 403 || status === 429; + const canRetry = attempt < maxRetries && (isRateLimit || !status || status >= 500); + if (!canRetry) { + throw error; + } + const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoffMs; + await sleep(waitMs); + backoffMs = Math.min(backoffMs * 2, 30000); + attempt += 1; + } + } + throw new Error("GitHub request exhausted retries"); + } + + async getUserRepos(username, limit = 5) { + const repos = await this.request({ + method: "GET", + url: `/users/${encodeURIComponent(username)}/repos`, + params: { type: "all", sort: "pushed", per_page: Math.max(limit, 5) }, + }); + return repos.slice(0, limit); + } + + async getMergedPRs(owner, repo, username, sinceIso) { + const prs = await this.request({ + method: "GET", + url: `/repos/${owner}/${repo}/pulls`, + params: { state: "closed", creator: username, per_page: 100 }, + }); + return prs.filter((pr) => pr.merged_at && new Date(pr.merged_at) >= new Date(sinceIso)); + } + + async getPRFiles(owner, repo, prNumber) { + return this.request({ + method: "GET", + url: `/repos/${owner}/${repo}/pulls/${prNumber}/files`, + params: { per_page: 100 }, + }); + } + + async getPRReviews(owner, repo, prNumber) { + return this.request({ + method: "GET", + url: `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, + params: { per_page: 100 }, + }); + } + + async getPRComments(owner, repo, prNumber) { + return this.request({ + method: "GET", + url: `/repos/${owner}/${repo}/pulls/${prNumber}/comments`, + params: { per_page: 100 }, + }); + } + + async getCommits(owner, repo, username, sinceIso) { + return this.request({ + method: "GET", + url: `/repos/${owner}/${repo}/commits`, + params: { author: username, since: sinceIso, per_page: 100 }, + }); + } + + async getCommitDetail(owner, repo, sha) { + return this.request({ + method: "GET", + url: `/repos/${owner}/${repo}/commits/${sha}`, + }); + } + + async fetchDeveloperActivity({ + username, + repoFilter, + days = 90, + maxRepos = 5, + onProgress = () => {}, + }) { + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + + onProgress({ step: "fetching_repos", percentage: 5 }); + let repos = await this.getUserRepos(username, maxRepos); + if (repoFilter) { + repos = repos.filter((repo) => `${repo.owner.login}/${repo.name}` === repoFilter || repo.name === repoFilter); + } + + const prRecords = []; + const commitRecords = []; + + onProgress({ step: "fetching_prs", percentage: 15 }); + for (const repo of repos) { + const owner = repo.owner.login; + const repoName = repo.name; + let prs = []; + let commits = []; + try { + prs = await this.getMergedPRs(owner, repoName, username, since); + } catch (error) { + if (error.response?.status !== 404) { + throw error; + } + } + try { + commits = await this.getCommits(owner, repoName, username, since); + } catch (error) { + if (error.response?.status !== 404) { + throw error; + } + } + commitRecords.push( + ...commits.map((commit) => ({ + repo: `${owner}/${repoName}`, + ...commit, + })) + ); + prRecords.push( + ...prs.map((pr) => ({ + repo: `${owner}/${repoName}`, + owner, + repoName, + ...pr, + })) + ); + } + + onProgress({ step: "fetching_diffs", percentage: 30 }); + for (let i = 0; i < prRecords.length; i += 1) { + const pr = prRecords[i]; + const [files, reviews, comments] = await Promise.all([ + this.getPRFiles(pr.owner, pr.repoName, pr.number), + this.getPRReviews(pr.owner, pr.repoName, pr.number), + this.getPRComments(pr.owner, pr.repoName, pr.number), + ]); + pr.files = files; + pr.reviews = reviews; + pr.comments = comments; + pr.raw = { pr, files, reviews, comments }; + } + + return { + since, + until: new Date().toISOString(), + repos, + prs: prRecords, + commits: commitRecords, + }; + } +} + +module.exports = { + GitHubClient, +}; diff --git a/demos/dev-reputation/agent/src/index.js b/demos/dev-reputation/agent/src/index.js new file mode 100644 index 0000000000..c12c7cd072 --- /dev/null +++ b/demos/dev-reputation/agent/src/index.js @@ -0,0 +1,392 @@ +require("dotenv").config(); + +const express = require("express"); +const axios = require("axios"); +const crypto = require("crypto"); +const { GitHubClient } = require("./github"); +const { analysePR } = require("./analyser"); +const { applyAntiCheat } = require("./anticheat"); +const { signEvidence } = require("./signer"); +const { writeEvidenceToBasin } = require("./basin"); + +const VERSION = "1.0.0"; + +const jobStore = new Map(); +const latestScores = new Map(); + +function validateRequiredEnv() { + const required = ["ANTHROPIC_API_KEY", "AGENT_PRIVATE_KEY", "BASIN_API_URL", "BASIN_BUCKET", "IPC_RPC_URL"]; + const missing = required.filter((key) => !String(process.env[key] || "").trim()); + if (missing.length) { + throw new Error(`Missing required environment variables: ${missing.join(", ")}`); + } +} + +function tierForScore(score) { + if (score >= 90) return { code: "T1", label: "principal" }; + if (score >= 75) return { code: "T2", label: "senior" }; + if (score >= 55) return { code: "T3", label: "mid" }; + if (score >= 35) return { code: "T4", label: "junior" }; + return { code: "T5", label: "early-career" }; +} + +function calculateConsistencyScore(commits, sinceIso) { + const since = new Date(sinceIso).getTime(); + const weeks = new Map(); + for (let i = 0; i < 13; i += 1) { + weeks.set(i, 0); + } + for (const commit of commits) { + const ts = new Date(commit.commit?.author?.date || commit.commit?.committer?.date || Date.now()).getTime(); + const week = Math.max(0, Math.min(12, Math.floor((ts - since) / (7 * 24 * 60 * 60 * 1000)))); + weeks.set(week, (weeks.get(week) || 0) + 1); + } + const values = [...weeks.values()]; + const mean = values.reduce((a, b) => a + b, 0) / values.length || 0; + if (mean === 0) { + return 0; + } + const variance = values.reduce((sum, x) => sum + (x - mean) ** 2, 0) / values.length; + const stddev = Math.sqrt(variance); + const cv = stddev / mean; + const normalized = Math.max(0, Math.min(1, 1 - cv / 2.0)); + return Math.round(normalized * 100); +} + +function aggregateScore(prAnalyses, commits, sinceIso) { + const weightedNumerator = prAnalyses.reduce( + (sum, pr) => sum + Number(pr.pr_score || 0) * Number(pr.weight_multiplier || 0), + 0 + ); + const weightedDenominator = prAnalyses.reduce((sum, pr) => sum + Number(pr.weight_multiplier || 0), 0); + const weightedPRScore = weightedDenominator > 0 ? weightedNumerator / weightedDenominator : 0; + + const consistencyScore = calculateConsistencyScore(commits, sinceIso); + const overallScore = Math.round(weightedPRScore * 0.82 + consistencyScore * 0.18); + const tier = tierForScore(overallScore); + + const avg = (key) => + prAnalyses.length + ? prAnalyses.reduce((sum, p) => sum + Number(p.dimensions?.[key] || 0), 0) / prAnalyses.length + : 0; + const dimensionContributions = { + problem_solving_depth: Number((avg("problem_solving_depth") * 0.28).toFixed(2)), + code_quality: Number((avg("code_quality") * 0.23).toFixed(2)), + review_responsiveness: Number((avg("review_responsiveness") * 0.14).toFixed(2)), + consistency_over_time: Number((consistencyScore * 0.18).toFixed(2)), + scope_appropriateness: Number((avg("scope_appropriateness") * 0.1).toFixed(2)), + net_complexity_reduction: Number((avg("net_complexity_reduction") * 0.07).toFixed(2)), + }; + + return { + weighted_pr_score: Number(weightedPRScore.toFixed(2)), + consistency_score: consistencyScore, + final_score: overallScore, + tier: tier.label, + tier_code: tier.code, + dimension_contributions: dimensionContributions, + }; +} + +function computePRScoreFromDimensions(dimensions) { + const p = Number(dimensions.problem_solving_depth || 0); + const c = Number(dimensions.code_quality || 0); + const r = Number(dimensions.review_responsiveness || 50); + const s = Number(dimensions.scope_appropriateness || 0); + const n = Number(dimensions.net_complexity_reduction || 0); + return Number((p * 0.3 + c * 0.25 + r * 0.15 + s * 0.15 + n * 0.15).toFixed(2)); +} + +async function submitOnChain(payload) { + const rpcUrl = process.env.IPC_RPC_URL; + const method = process.env.REPUTATION_SET_SCORE_METHOD; + if (!rpcUrl || !method) { + return { skipped: true, reason: "RPC method not configured" }; + } + const response = await axios.post( + rpcUrl, + { jsonrpc: "2.0", id: 1, method, params: [payload] }, + { headers: { "Content-Type": "application/json" }, timeout: 30000 } + ); + return { skipped: false, result: response.data?.result || null }; +} + +async function runPipeline({ github_handle, wallet_address, repo_filter, onProgress = () => {} }) { + validateRequiredEnv(); + const github = new GitHubClient({ token: process.env.GITHUB_TOKEN }); + const commitDetailsBySha = {}; + + const progress = (step, percentage) => onProgress({ step, percentage }); + const activity = await github.fetchDeveloperActivity({ + username: github_handle, + repoFilter: repo_filter, + onProgress: ({ step, percentage }) => progress(step, percentage), + }); + + const prAnalyses = []; + const total = activity.prs.length || 1; + for (let i = 0; i < activity.prs.length; i += 1) { + const pr = activity.prs[i]; + progress("analysing_prs", Math.round(30 + ((i + 1) / total) * 45)); + const analysisRaw = await analysePR(pr, { anthropicApiKey: process.env.ANTHROPIC_API_KEY }); + const dimensions = { + problem_solving_depth: Number(analysisRaw.problem_solving_depth || 0), + code_quality: Number(analysisRaw.code_quality || 0), + review_responsiveness: Number( + analysisRaw.review_responsiveness == null ? 50 : analysisRaw.review_responsiveness + ), + scope_appropriateness: Number(analysisRaw.scope_appropriateness || 0), + net_complexity_reduction: Number(analysisRaw.net_complexity_reduction || 0), + gaming_likelihood: Number(analysisRaw.gaming_likelihood || 0), + }; + const prScore = Number( + analysisRaw.pr_score == null ? computePRScoreFromDimensions(dimensions) : analysisRaw.pr_score + ); + const weight = Math.max(0, Math.min(1, Number(analysisRaw.weight_multiplier == null ? 1 : analysisRaw.weight_multiplier))); + pr.analysis = { + dimensions, + pr_score: prScore, + verdict: analysisRaw.verdict || "No verdict provided.", + weight_multiplier: weight, + }; + const analysisRecord = { + repo: pr.repo, + pr_number: pr.number, + pr_title: pr.title, + pr_url: pr.html_url, + raw_additions: pr.additions, + raw_deletions: pr.deletions, + changed_files: pr.changed_files, + review_rounds: (pr.reviews || []).length, + dimensions, + pr_score: prScore, + weight_multiplier: weight, + verdict: pr.analysis.verdict, + }; + prAnalyses.push(analysisRecord); + } + + for (const commit of activity.commits) { + try { + commitDetailsBySha[commit.sha] = await github.getCommitDetail( + commit.repo.split("/")[0], + commit.repo.split("/")[1], + commit.sha + ); + } catch (_) { + commitDetailsBySha[commit.sha] = {}; + } + } + + progress("detecting_gaming", 80); + const antiCheat = applyAntiCheat({ + prs: activity.prs, + commits: activity.commits, + commitDetailsBySha, + }); + + const finalWeights = new Map( + activity.prs.map((pr) => [`${pr.repo}#${pr.number}`, Number(pr.analysis?.weight_multiplier ?? 1)]) + ); + const adjustedPrAnalyses = prAnalyses.map((pr) => ({ + ...pr, + weight_multiplier: finalWeights.get(`${pr.repo}#${pr.pr_number}`) ?? pr.weight_multiplier, + })); + + progress("computing_score", 85); + const scoreBreakdown = aggregateScore(adjustedPrAnalyses, activity.commits, activity.since); + const period = `${new Date(activity.since).toISOString().slice(0, 10)}..${new Date(activity.until) + .toISOString() + .slice(0, 10)}`; + + const evidenceUnsigned = { + schema_version: "1.0", + generated_at: new Date().toISOString(), + developer: { + github_handle, + wallet_address, + }, + period: { start: activity.since, end: activity.until }, + raw_stats: { + total_commits: activity.commits.length, + total_prs_merged: activity.prs.length, + raw_lines_added: activity.prs.reduce((sum, pr) => sum + Number(pr.additions || 0), 0), + raw_lines_removed: activity.prs.reduce((sum, pr) => sum + Number(pr.deletions || 0), 0), + }, + adjusted_stats: { + weighted_commits: antiCheat.adjusted.weighted_commits, + effective_lines_added: antiCheat.adjusted.effective_lines_added, + inflation_removed_pct: antiCheat.adjusted.inflation_removed_pct, + }, + gaming_flags: antiCheat.gaming_flags, + pr_analyses: adjustedPrAnalyses, + score_breakdown: scoreBreakdown, + raw_github_payload: { + repos: activity.repos, + prs: activity.prs.map((pr) => pr.raw), + commits: activity.commits, + commit_details: commitDetailsBySha, + }, + }; + + const signedEvidence = await signEvidence(evidenceUnsigned, process.env.AGENT_PRIVATE_KEY); + + progress("writing_to_basin", 90); + const cid = await writeEvidenceToBasin(signedEvidence, { + basinApiUrl: process.env.BASIN_API_URL, + bucket: process.env.BASIN_BUCKET, + }); + + progress("submitting_on_chain", 95); + const chain = await submitOnChain({ + developer: wallet_address, + github_handle, + score: scoreBreakdown.final_score, + tier: scoreBreakdown.tier, + evidence_cid: cid, + period, + document_hash: signedEvidence.document_hash, + agent_address: signedEvidence.agent_address, + signature: signedEvidence.agent_signature, + }); + + const result = { + github_handle, + wallet_address, + score: scoreBreakdown.final_score, + tier: scoreBreakdown.tier, + tier_code: scoreBreakdown.tier_code, + evidence_cid: cid, + score_breakdown: scoreBreakdown, + pr_analyses: adjustedPrAnalyses, + gaming_flags: antiCheat.gaming_flags, + adjusted_stats: antiCheat.adjusted, + on_chain_submission: chain, + document_hash: signedEvidence.document_hash, + agent_signature: signedEvidence.agent_signature, + agent_address: signedEvidence.agent_address, + updated_at: new Date().toISOString(), + }; + + progress("complete", 100); + return result; +} + +function createApp() { + const app = express(); + app.use(express.json({ limit: "2mb" })); + + app.get("/health", (_req, res) => { + res.status(200).json({ + ok: true, + version: VERSION, + authorised_agent_address: process.env.ADMIN_ADDRESS || null, + }); + }); + + app.post("/score", async (req, res) => { + const github_handle = String(req.body?.github_handle || "").trim(); + const wallet_address = String(req.body?.wallet_address || "").trim(); + const repo_filter = req.body?.repo_filter; + if (!github_handle || !wallet_address) { + return res.status(400).json({ error: "github_handle and wallet_address are required" }); + } + + const job_id = crypto.randomUUID(); + const job = { + job_id, + status: "queued", + progress: { step: "queued", percentage: 0 }, + created_at: new Date().toISOString(), + }; + jobStore.set(job_id, job); + + setImmediate(async () => { + job.status = "running"; + try { + const result = await runPipeline({ + github_handle, + wallet_address, + repo_filter, + onProgress: (progress) => { + job.progress = progress; + }, + }); + job.status = "complete"; + job.progress = { step: "complete", percentage: 100 }; + job.result = result; + latestScores.set(github_handle.toLowerCase(), result); + } catch (error) { + job.status = "error"; + job.error = error.message; + } + job.updated_at = new Date().toISOString(); + }); + + return res.status(202).json({ job_id }); + }); + + app.get("/job/:job_id", (req, res) => { + const job = jobStore.get(req.params.job_id); + if (!job) { + return res.status(404).json({ error: "job not found" }); + } + return res.json(job); + }); + + app.get("/score/:github_handle", (req, res) => { + const result = latestScores.get(String(req.params.github_handle || "").toLowerCase()); + if (!result) { + return res.status(404).json({ error: "score not found for handle" }); + } + return res.json(result); + }); + + return app; +} + +function startServer() { + const app = createApp(); + const port = Number(process.env.PORT || 3001); + app.listen(port, () => { + console.log(`dev-reputation agent listening on :${port}`); + }); +} + +async function runCli() { + const github_handle = process.argv[2]; + const wallet_address = process.argv[3]; + const repo_filter = process.argv[4]; + if (!github_handle || !wallet_address) { + console.error("Usage: node src/index.js [repo_filter]"); + process.exit(1); + } + const result = await runPipeline({ + github_handle, + wallet_address, + repo_filter, + onProgress: (p) => console.log(`${p.step} ${p.percentage}%`), + }); + console.log(JSON.stringify(result, null, 2)); +} + +if (require.main === module) { + if (process.env.AGENT_MODE === "cli") { + runCli().catch((error) => { + console.error(error); + process.exit(1); + }); + } else { + startServer(); + } +} + +module.exports = { + VERSION, + aggregateScore, + calculateConsistencyScore, + computePRScoreFromDimensions, + createApp, + runPipeline, + tierForScore, +}; diff --git a/demos/dev-reputation/agent/src/signer.js b/demos/dev-reputation/agent/src/signer.js new file mode 100644 index 0000000000..4eb6096e56 --- /dev/null +++ b/demos/dev-reputation/agent/src/signer.js @@ -0,0 +1,35 @@ +const { ethers } = require("ethers"); + +function canonicalizeTopLevel(evidence) { + const sortedKeys = Object.keys(evidence).sort(); + return JSON.stringify(evidence, sortedKeys); +} + +function hashEvidence(evidence) { + const canonical = canonicalizeTopLevel(evidence); + return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(canonical)); +} + +async function signEvidence(evidence, privateKeyNoPrefix) { + const wallet = new ethers.Wallet(`0x${privateKeyNoPrefix.replace(/^0x/, "")}`); + const documentHash = hashEvidence(evidence); + const signature = await wallet.signMessage(ethers.utils.arrayify(documentHash)); + return { + ...evidence, + document_hash: documentHash, + agent_signature: signature, + agent_address: wallet.address, + }; +} + +function verifyEvidence(documentHash, signature, expectedAddress) { + const recovered = ethers.utils.verifyMessage(ethers.utils.arrayify(documentHash), signature); + return recovered.toLowerCase() === expectedAddress.toLowerCase(); +} + +module.exports = { + canonicalizeTopLevel, + hashEvidence, + signEvidence, + verifyEvidence, +}; diff --git a/demos/dev-reputation/agent/tests/anticheat.test.js b/demos/dev-reputation/agent/tests/anticheat.test.js new file mode 100644 index 0000000000..52ff48ce40 --- /dev/null +++ b/demos/dev-reputation/agent/tests/anticheat.test.js @@ -0,0 +1,81 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { applyAntiCheat } = require("../src/anticheat"); + +function basePR() { + return { + number: 1, + additions: 100, + deletions: 50, + comments: [], + files: [], + analysis: { weight_multiplier: 1.0 }, + }; +} + +test("pattern 1 triggers relocation downweight", () => { + const pr = basePR(); + pr.files = [ + { + filename: "foo.ts", + status: "renamed", + additions: 80, + deletions: 70, + patch: "-const a = 1;\n+const a = 1;", + }, + ]; + const result = applyAntiCheat({ prs: [pr], commits: [], commitDetailsBySha: {} }); + assert.equal(result.gaming_flags.length > 0, true); + assert.equal(pr.analysis.weight_multiplier <= 0.1, true); +}); + +test("pattern 1 does not trigger for dissimilar patches", () => { + const pr = basePR(); + pr.files = [{ filename: "bar.ts", status: "modified", additions: 10, deletions: 5, patch: "-a\n+b\n+c" }]; + const result = applyAntiCheat({ prs: [pr], commits: [], commitDetailsBySha: {} }); + assert.equal(result.gaming_flags.length, 0); + assert.equal(pr.analysis.weight_multiplier, 1); +}); + +test("pattern 2 formatter commit excluded", () => { + const commits = [{ sha: "abc", commit: { message: "refactor style" } }]; + const details = { abc: { files: [{ patch: "+ \n- " }, { patch: "+;\n-;" }] } }; + const result = applyAntiCheat({ prs: [], commits, commitDetailsBySha: details }); + assert.equal(result.adjusted.weighted_commits, 0); + assert.equal(result.adjusted.excluded_commits, 1); +}); + +test("pattern 2 non-formatter commit included", () => { + const commits = [{ sha: "def", commit: { message: "real fix for bug" } }]; + const details = { def: { files: [{ patch: "-x=1\n+x=2" }] } }; + const result = applyAntiCheat({ prs: [], commits, commitDetailsBySha: details }); + assert.equal(result.adjusted.weighted_commits, 1); +}); + +test("pattern 3 generated file gets heavy discount", () => { + const pr = basePR(); + pr.additions = 300; + pr.files = [{ filename: "api.generated.ts", status: "added", additions: 300, deletions: 0, patch: "+Code generated by tool" }]; + const result = applyAntiCheat({ prs: [pr], commits: [], commitDetailsBySha: {} }); + assert.equal(result.adjusted.effective_lines_added < 100, true); +}); + +test("pattern 3 normal file stays near full weight", () => { + const pr = basePR(); + pr.additions = 80; + pr.files = [{ filename: "service.ts", status: "modified", additions: 80, deletions: 0, patch: "+function run() {}" }]; + const result = applyAntiCheat({ prs: [pr], commits: [], commitDetailsBySha: {} }); + assert.equal(result.adjusted.effective_lines_added, 80); +}); + +test("pattern 4 commit message inflation excludes known noise terms", () => { + const commits = [{ sha: "ghi", commit: { message: "whitespace cleanup" } }]; + const result = applyAntiCheat({ prs: [], commits, commitDetailsBySha: { ghi: { files: [{ patch: "-a\n+b" }] } } }); + assert.equal(result.adjusted.weighted_commits, 0); +}); + +test("pattern 4 short commit messages are downweighted to 0.3", () => { + const commits = [{ sha: "jkl", commit: { message: "tiny fix" } }]; + const result = applyAntiCheat({ prs: [], commits, commitDetailsBySha: { jkl: { files: [{ patch: "-a\n+b" }] } } }); + assert.equal(result.adjusted.weighted_commits, 0.3); +}); diff --git a/demos/dev-reputation/agent/tests/score.test.js b/demos/dev-reputation/agent/tests/score.test.js new file mode 100644 index 0000000000..590736fce4 --- /dev/null +++ b/demos/dev-reputation/agent/tests/score.test.js @@ -0,0 +1,42 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { aggregateScore } = require("../src/index"); + +test("aggregateScore follows weighted formula", () => { + const prs = [ + { + pr_score: 90, + weight_multiplier: 1.0, + dimensions: { + problem_solving_depth: 90, + code_quality: 85, + review_responsiveness: 80, + scope_appropriateness: 70, + net_complexity_reduction: 75, + }, + }, + { + pr_score: 50, + weight_multiplier: 0.5, + dimensions: { + problem_solving_depth: 45, + code_quality: 55, + review_responsiveness: 60, + scope_appropriateness: 50, + net_complexity_reduction: 40, + }, + }, + ]; + + const commits = Array.from({ length: 12 }, (_, i) => ({ + commit: { author: { date: new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000).toISOString() } }, + })); + + const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(); + const result = aggregateScore(prs, commits, since); + + const weightedPr = (90 * 1 + 50 * 0.5) / 1.5; + assert.equal(result.weighted_pr_score, Number(weightedPr.toFixed(2))); + assert.equal(result.final_score >= 0 && result.final_score <= 100, true); + assert.equal(typeof result.tier, "string"); +}); diff --git a/demos/dev-reputation/agent/tests/signer.test.js b/demos/dev-reputation/agent/tests/signer.test.js new file mode 100644 index 0000000000..0922f5a562 --- /dev/null +++ b/demos/dev-reputation/agent/tests/signer.test.js @@ -0,0 +1,19 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { hashEvidence, signEvidence, verifyEvidence } = require("../src/signer"); + +const PRIVATE_KEY = "59c6995e998f97a5a0044966f094538c5f2f5d4d2f3ecf0f7d8ef6a5a9d2d8f7"; + +test("sign and verify roundtrip", async () => { + const evidence = { schema_version: "1.0", developer: { github_handle: "alice" } }; + const signed = await signEvidence(evidence, PRIVATE_KEY); + assert.equal(verifyEvidence(signed.document_hash, signed.agent_signature, signed.agent_address), true); +}); + +test("modified document hash fails original signature verification", async () => { + const evidence = { schema_version: "1.0", score: 75 }; + const signed = await signEvidence(evidence, PRIVATE_KEY); + const modifiedHash = hashEvidence({ ...evidence, score: 76 }); + assert.notEqual(modifiedHash, signed.document_hash); + assert.equal(verifyEvidence(modifiedHash, signed.agent_signature, signed.agent_address), false); +}); diff --git a/demos/dev-reputation/frontend/app.js b/demos/dev-reputation/frontend/app.js new file mode 100644 index 0000000000..a56df4a031 --- /dev/null +++ b/demos/dev-reputation/frontend/app.js @@ -0,0 +1,266 @@ +/* global ethers, keccak256 */ +const state = { + leaderboard: [], + selected: null, +}; + +function scoreClass(value) { + if (value >= 75) return "score-good"; + if (value >= 50) return "score-mid"; + return "score-low"; +} + +function tierBadge(tier) { + return `${tier}`; +} + +function truncCid(cid = "") { + if (cid.length < 16) return cid; + return `${cid.slice(0, 8)}...${cid.slice(-6)}`; +} + +function initials(handle = "") { + return handle.slice(0, 2).toUpperCase(); +} + +function renderLeaderboard() { + const body = document.getElementById("leaderboard-body"); + body.innerHTML = ""; + + const sorted = [...state.leaderboard].sort((a, b) => b.score - a.score); + sorted.forEach((row, idx) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${idx + 1} + ${initials(row.github_handle)} + ${row.github_handle} + ${row.score} + ${tierBadge(row.tier)} + ${row.pr_analyses?.filter((p) => p.weight_multiplier > 0).length ?? 0} + ${row.adjusted_stats?.inflation_removed_pct ?? 0}% + ${new Date(row.updated_at).toLocaleString()} + ${truncCid(row.evidence_cid)} +
+ `; + body.appendChild(tr); + }); + + body.querySelectorAll("a[data-profile]").forEach((el) => { + el.addEventListener("click", (ev) => { + ev.preventDefault(); + openProfile(el.dataset.profile); + }); + }); + body.querySelectorAll("button[data-verify]").forEach((el) => { + el.addEventListener("click", () => verifyRow(el.dataset.verify)); + }); +} + +function renderProfile() { + const row = state.selected; + if (!row) return; + + document.getElementById("profile-header").innerHTML = ` +

${row.github_handle} ${row.tier.toUpperCase()}

+

Score: ${row.score} | period: ${row.score_breakdown?.period || "90-day window"} | block: ${row.on_chain_submission?.block_height || "n/a"} | F3 finality demo

+ `; + + const summary = [ + ["Effective PRs", row.pr_analyses?.filter((p) => p.weight_multiplier > 0).length ?? 0], + ["Weighted Commits", row.adjusted_stats?.weighted_commits ?? 0], + ["Inflation Removed", `${row.adjusted_stats?.inflation_removed_pct ?? 0}%`], + ["Evidence CID", truncCid(row.evidence_cid)], + ]; + document.getElementById("summary-row").innerHTML = summary + .map(([k, v]) => `
${k}
${v}
`) + .join(""); + + const dimensions = row.score_breakdown?.dimension_contributions || {}; + const labels = Object.keys(dimensions); + document.getElementById("breakdown-bars").innerHTML = labels + .map((key) => { + const score = Math.max(0, Math.min(100, Math.round((dimensions[key] / 0.28) * 0.28))); + const cls = score >= 75 ? "#2ecc71" : score >= 50 ? "#f5b041" : "#ff5e5e"; + return ` +
+
${key}
+
+
${score}/100
+
+ `; + }) + .join(""); + + const prBody = document.getElementById("pr-table-body"); + prBody.innerHTML = ""; + (row.pr_analyses || []).forEach((pr) => { + const tr = document.createElement("tr"); + const warning = pr.weight_multiplier <= 0.1 ? ' style="background:rgba(255,94,94,0.1)"' : ""; + tr.innerHTML = ` + #${pr.pr_number} ${pr.pr_title} + +${pr.raw_additions}/-${pr.raw_deletions} + ${pr.pr_score} + ${pr.weight_multiplier.toFixed(2)} + ${pr.verdict} + `; + prBody.appendChild(tr); + }); + + const flagsWrap = document.getElementById("flags-wrap"); + const flagsList = document.getElementById("flags-list"); + if (row.gaming_flags && row.gaming_flags.length) { + flagsWrap.classList.remove("hidden"); + flagsList.innerHTML = row.gaming_flags + .map((f) => `
  • ${f.pattern}: ${f.description} (weight=${f.weight_applied})
  • `) + .join(""); + } else { + flagsWrap.classList.add("hidden"); + flagsList.innerHTML = ""; + } + + document.getElementById("audit").innerHTML = ` +
    Document hash: ${row.document_hash}
    +
    Agent address: ${row.agent_address}
    +
    Signature: ${row.agent_signature}
    +
    Basin CID: ${row.evidence_cid}
    + +
    + `; + + document.getElementById("verify-onchain-btn").addEventListener("click", async () => { + const out = document.getElementById("verify-onchain-result"); + out.textContent = "Checking on-chain record..."; + const ok = await verifyOnChain(row); + out.textContent = ok ? "On-chain verification passed" : "On-chain verification could not be confirmed"; + }); +} + +async function rpcCall(method, params) { + const response = await fetch(window.IPC_CONFIG.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const body = await response.json(); + if (body.error) throw new Error(body.error.message || "RPC error"); + return body.result; +} + +async function fetchBasinDoc(cid) { + const url = `${window.IPC_CONFIG.basinUrl}/api/v1/buckets/${encodeURIComponent( + window.IPC_CONFIG.basinBucket || "default" + )}/objects/${encodeURIComponent(cid)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Basin fetch failed: ${response.status}`); + } + return response.text(); +} + +async function verifyRow(handle) { + const row = state.leaderboard.find((r) => r.github_handle === handle); + const target = document.getElementById(`verify-${handle}`); + if (!row || !target) return; + + target.innerHTML = `
    running checks...
    `; + try { + const docText = await fetchBasinDoc(row.evidence_cid); + const computedHash = `0x${keccak256(docText)}`; + const contentOk = computedHash.toLowerCase() === String(row.document_hash).toLowerCase(); + + const recovered = ethers.utils.verifyMessage(ethers.utils.arrayify(row.document_hash), row.agent_signature); + const agentOk = recovered.toLowerCase() === String(row.agent_address).toLowerCase(); + + let blockOk = false; + let blockInfo = "n/a"; + try { + const tipset = await rpcCall("Filecoin.ChainGetTipSetByHeight", [ + row.on_chain_submission?.block_height || 0, + null, + ]); + const ts = Number(tipset?.Blocks?.[0]?.Timestamp || 0); + const expected = Math.floor(new Date(row.updated_at).getTime() / 1000); + blockOk = Math.abs(ts - expected) <= 60; + blockInfo = `computed=${ts} expected=${expected}`; + } catch (_e) { + blockInfo = "unavailable"; + } + + target.innerHTML = ` +
    integrity: ${contentOk ? "PASS" : "FAIL"} (computed=${computedHash}, expected=${row.document_hash})
    +
    agent identity: ${agentOk ? "PASS" : "FAIL"} (computed=${recovered}, expected=${row.agent_address})
    +
    block timestamp: ${blockOk ? "PASS" : "FAIL"} (${blockInfo})
    + `; + } catch (error) { + target.textContent = `Verification error: ${error.message}`; + } +} + +async function verifyOnChain(row) { + try { + const result = await rpcCall("IPC.ReputationGetScore", [window.IPC_CONFIG.actorAddress, row.wallet_address]); + if (!result) return false; + return String(result.evidence_cid || "").toLowerCase() === String(row.evidence_cid || "").toLowerCase(); + } catch (_e) { + return false; + } +} + +function openProfile(handle) { + state.selected = state.leaderboard.find((row) => row.github_handle === handle) || null; + renderProfile(); + document.getElementById("leaderboard-view").classList.add("hidden"); + document.getElementById("profile-view").classList.remove("hidden"); +} + +function closeProfile() { + state.selected = null; + document.getElementById("profile-view").classList.add("hidden"); + document.getElementById("leaderboard-view").classList.remove("hidden"); +} + +async function pollJob(jobId) { + const statusEl = document.getElementById("job-status"); + for (;;) { + const response = await fetch(`${window.IPC_CONFIG.agentUrl}/job/${jobId}`); + const job = await response.json(); + statusEl.textContent = `${job.status} - ${job.progress?.step || "waiting"} (${job.progress?.percentage || 0}%)`; + if (job.status === "complete") { + const scoreResponse = await fetch(`${window.IPC_CONFIG.agentUrl}/score/${state.pendingHandle}`); + const result = await scoreResponse.json(); + state.leaderboard = [result, ...state.leaderboard.filter((r) => r.github_handle !== result.github_handle)]; + renderLeaderboard(); + return; + } + if (job.status === "error") { + statusEl.textContent = `error: ${job.error}`; + return; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +} + +async function scoreHandle() { + const github_handle = document.getElementById("handle-input").value.trim(); + const wallet_address = document.getElementById("wallet-input").value.trim(); + if (!github_handle || !wallet_address) { + alert("Provide GitHub handle and wallet address"); + return; + } + state.pendingHandle = github_handle; + const response = await fetch(`${window.IPC_CONFIG.agentUrl}/score`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ github_handle, wallet_address }), + }); + const body = await response.json(); + if (!response.ok) { + document.getElementById("job-status").textContent = `error: ${body.error || "request failed"}`; + return; + } + pollJob(body.job_id); +} + +document.getElementById("score-btn").addEventListener("click", scoreHandle); +document.getElementById("back-btn").addEventListener("click", closeProfile); +renderLeaderboard(); diff --git a/demos/dev-reputation/frontend/config.js b/demos/dev-reputation/frontend/config.js new file mode 100644 index 0000000000..f495d357cb --- /dev/null +++ b/demos/dev-reputation/frontend/config.js @@ -0,0 +1,7 @@ +window.IPC_CONFIG = { + actorAddress: "", + rpcUrl: "https://api.calibration.node.glif.io/rpc/v1", + basinUrl: "https://basin.tableland.xyz", + agentUrl: "http://localhost:3001", + chainId: 314159, +}; diff --git a/demos/dev-reputation/frontend/index.html b/demos/dev-reputation/frontend/index.html new file mode 100644 index 0000000000..ecd07dc4b9 --- /dev/null +++ b/demos/dev-reputation/frontend/index.html @@ -0,0 +1,74 @@ + + + + + + IPC Developer Reputation Dashboard + + + +
    +
    +

    IPC Developer Reputation

    +

    Verifiable off-chain scoring with Basin evidence and IPC on-chain anchoring.

    +
    + +
    +
    + + + +
    +

    + + + + + + + + + + + + + + + + +
    RankAvatarHandleScoreTierEffective PRsInflation RemovedLast UpdatedBasin CIDVerify
    +
    + + +
    + + + + + + + diff --git a/demos/dev-reputation/frontend/style.css b/demos/dev-reputation/frontend/style.css new file mode 100644 index 0000000000..523ca88a3d --- /dev/null +++ b/demos/dev-reputation/frontend/style.css @@ -0,0 +1,157 @@ +:root { + color-scheme: dark; + --bg: #0a0f1e; + --panel: #111933; + --text: #eff5ff; + --muted: #91a2c4; + --good: #2ecc71; + --warn: #f5b041; + --bad: #ff5e5e; + --line: #2a3b63; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: radial-gradient(circle at 10% 10%, #182850, var(--bg)); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 24px; +} + +.hero { + margin-bottom: 18px; +} + +.hero p, +.muted { + color: var(--muted); +} + +.card { + background: color-mix(in srgb, var(--panel), black 5%); + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px; + margin-bottom: 16px; +} + +.hidden { + display: none; +} + +.search-row { + display: flex; + gap: 10px; + margin-bottom: 12px; +} + +input, +button { + border-radius: 8px; + border: 1px solid var(--line); + background: #0f1730; + color: var(--text); + padding: 10px; +} + +input { + flex: 1; +} + +button { + cursor: pointer; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + border-bottom: 1px solid var(--line); + text-align: left; + padding: 8px; + vertical-align: top; +} + +.badge { + display: inline-block; + font-size: 12px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--line); +} + +.score-good { + color: var(--good); +} + +.score-mid { + color: var(--warn); +} + +.score-low { + color: var(--bad); +} + +.summary-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 12px 0; +} + +.summary-card { + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; +} + +.bars { + display: grid; + gap: 8px; +} + +.bar-row { + display: grid; + grid-template-columns: 200px 1fr 70px; + gap: 8px; + align-items: center; +} + +.bar { + height: 10px; + border-radius: 5px; + background: #0c1226; + overflow: hidden; +} + +.bar-fill { + height: 100%; +} + +.spinner { + width: 12px; + height: 12px; + border: 2px solid var(--muted); + border-top-color: transparent; + border-radius: 50%; + display: inline-block; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/demos/dev-reputation/scripts/deploy.sh b/demos/dev-reputation/scripts/deploy.sh new file mode 100755 index 0000000000..1ed57d8b80 --- /dev/null +++ b/demos/dev-reputation/scripts/deploy.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ACTOR_DIR="${ROOT_DIR}/actor" +OUT_ENV="${ROOT_DIR}/.env.actor" +FRONTEND_CONFIG="${ROOT_DIR}/frontend/config.js" + +if [[ -z "${ADMIN_ADDRESS:-}" ]]; then + echo "ADMIN_ADDRESS is required" + exit 1 +fi + +if [[ -z "${AGENT_ADDRESS:-}" ]]; then + echo "AGENT_ADDRESS is required" + exit 1 +fi + +echo "Building actor WASM..." +( + cd "${ACTOR_DIR}" + rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true + cargo build --target wasm32-unknown-unknown --release +) + +WASM_PATH="${ACTOR_DIR}/target/wasm32-unknown-unknown/release/ipc_reputation_actor.wasm" +if [[ ! -f "${WASM_PATH}" ]]; then + echo "WASM not found at ${WASM_PATH}" + exit 1 +fi + +echo "Deploying actor via ipc-cli..." +if ! command -v ipc-cli >/dev/null 2>&1; then + echo "ipc-cli not found in PATH" + exit 1 +fi + +# These commands are intentionally explicit so operators can swap method names per network setup. +ACTOR_ADDRESS="$(ipc-cli actor create --wasm "${WASM_PATH}" | awk '/f0/{print $1}' | tail -n 1)" +if [[ -z "${ACTOR_ADDRESS}" ]]; then + echo "Failed to parse actor address from deployment output" + exit 1 +fi + +ipc-cli actor invoke \ + --to "${ACTOR_ADDRESS}" \ + --method 1 \ + --params "{\"admin\":\"${ADMIN_ADDRESS}\",\"initial_agent\":\"${AGENT_ADDRESS}\"}" >/dev/null + +cat >"${OUT_ENV}" <"${FRONTEND_CONFIG}" </dev/null 2>&1; then + kill "${AGENT_PID}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +echo "Starting agent..." +( + cd "${AGENT_DIR}" + npm install >/dev/null + node src/index.js >/tmp/dev-reputation-agent.log 2>&1 & + echo $! > /tmp/dev-reputation-agent.pid +) +AGENT_PID="$(cat /tmp/dev-reputation-agent.pid)" +sleep 2 + +echo "Submitting score job for ${HANDLE}..." +JOB_ID="$(curl -sS -X POST "${AGENT_URL}/score" -H "Content-Type: application/json" -d "{\"github_handle\":\"${HANDLE}\",\"wallet_address\":\"${WALLET}\"}" | python3 -c 'import json,sys;print(json.load(sys.stdin)["job_id"])')" +echo "job_id=${JOB_ID}" + +START_TS="$(date +%s)" +while true; do + JOB_JSON="$(curl -sS "${AGENT_URL}/job/${JOB_ID}")" + STATUS="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("status",""))' <<<"${JOB_JSON}")" + STEP="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("progress",{}).get("step",""))' <<<"${JOB_JSON}")" + PCT="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("progress",{}).get("percentage",0))' <<<"${JOB_JSON}")" + echo "status=${STATUS} step=${STEP} pct=${PCT}" + if [[ "${STATUS}" == "complete" ]]; then + break + fi + if [[ "${STATUS}" == "error" ]]; then + echo "FAIL: scoring job failed" + exit 1 + fi + NOW="$(date +%s)" + if (( NOW - START_TS > TIMEOUT_SECONDS )); then + echo "FAIL: timeout waiting for scoring job" + exit 1 + fi + sleep 5 +done + +SCORE_JSON="$(curl -sS "${AGENT_URL}/score/${HANDLE}")" +SCORE="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["score"])' <<<"${SCORE_JSON}")" +TIER="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["tier"])' <<<"${SCORE_JSON}")" +CID="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["evidence_cid"])' <<<"${SCORE_JSON}")" +HASH_EXPECTED="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["document_hash"])' <<<"${SCORE_JSON}")" + +if (( SCORE < 0 || SCORE > 100 )); then + echo "FAIL: score out of range: ${SCORE}" + exit 1 +fi + +if [[ ! "${TIER}" =~ ^(principal|senior|mid|junior|early-career)$ ]]; then + echo "FAIL: invalid tier: ${TIER}" + exit 1 +fi + +echo "Verifying Basin content hash..." +BASIN_API_URL="${BASIN_API_URL:-https://basin.tableland.xyz}" +BASIN_BUCKET="${BASIN_BUCKET:-default}" +DOC="$(curl -sS "${BASIN_API_URL}/api/v1/buckets/${BASIN_BUCKET}/objects/${CID}")" +HASH_COMPUTED="$(python3 -c 'import sys;from hashlib import sha3_256;data=sys.stdin.read().encode();print("0x"+sha3_256(data).hexdigest())' <<<"${DOC}")" +if [[ "${HASH_COMPUTED,,}" != "${HASH_EXPECTED,,}" ]]; then + echo "FAIL: hash mismatch computed=${HASH_COMPUTED} expected=${HASH_EXPECTED}" + exit 1 +fi + +echo "Checking on-chain record (best effort)..." +if [[ -n "${REPUTATION_ACTOR_ADDRESS:-}" && -n "${IPC_RPC_URL:-}" ]]; then + RPC_PAYLOAD="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"IPC.ReputationGetScore\",\"params\":[\"${REPUTATION_ACTOR_ADDRESS}\",\"${WALLET}\"]}" + RPC_OUT="$(curl -sS -X POST "${IPC_RPC_URL}" -H "Content-Type: application/json" -d "${RPC_PAYLOAD}")" + ONCHAIN_CID="$(python3 -c 'import json,sys;print((json.load(sys.stdin).get("result") or {}).get("evidence_cid",""))' <<<"${RPC_OUT}")" + if [[ -n "${ONCHAIN_CID}" && "${ONCHAIN_CID}" != "${CID}" ]]; then + echo "FAIL: on-chain CID mismatch" + exit 1 + fi +fi + +echo "PASS: score=${SCORE} tier=${TIER} cid=${CID}"