diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18b5eeb..370e8a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v4 + - uses: foundry-rs/foundry-toolchain@v1 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -57,7 +58,6 @@ jobs: uses: nick-fields/retry@v3 env: FORK_URL: ${{ secrets.FORK_URL }} - TEST_PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY }} with: timeout_minutes: 10 max_attempts: 2 diff --git a/package.json b/package.json index 2fda454..c122e59 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,9 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest run", - "test:cov": "vitest run --coverage", + "test": "vitest run --project unit", + "test:cov": "vitest run --project unit --coverage", + "test:fork": "vitest run --project fork", "typecheck": "tsc --noEmit", "generate": "wagmi generate", "check": "biome check", @@ -49,9 +50,11 @@ "@arethetypeswrong/cli": "^0.18.2", "@biomejs/biome": "^2.4.10", "@size-limit/file": "^12.0.1", + "@types/node": "^25.5.2", "@vitest/coverage-v8": "^4.1.2", "@wagmi/cli": "^2.10.0", "knip": "^6.3.0", + "prool": "^0.2.4", "publint": "^0.3.18", "size-limit": "^12.0.1", "tsup": "^8.5.1", @@ -64,6 +67,14 @@ "path": "dist/index.js", "limit": "5 kB" }, + { + "path": "dist/identity/index.js", + "limit": "5 kB" + }, + { + "path": "dist/reputation/index.js", + "limit": "5 kB" + }, { "path": "dist/abis/index.js", "limit": "50 kB" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f1a4bc..0cbf177 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,15 +17,21 @@ importers: '@size-limit/file': specifier: ^12.0.1 version: 12.0.1(size-limit@12.0.1(jiti@2.6.1)) + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 '@vitest/coverage-v8': specifier: ^4.1.2 - version: 4.1.2(vitest@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3))) + version: 4.1.2(vitest@4.1.2(@types/node@25.5.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3))) '@wagmi/cli': specifier: ^2.10.0 version: 2.10.0(typescript@5.9.3) knip: specifier: ^6.3.0 version: 6.3.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + prool: + specifier: ^0.2.4 + version: 0.2.4 publint: specifier: ^0.3.18 version: 0.3.18 @@ -43,7 +49,7 @@ importers: version: 2.47.10(typescript@5.9.3)(zod@4.3.6) vitest: specifier: ^4.1.2 - version: 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.2(@types/node@25.5.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)) packages: @@ -464,6 +470,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -965,10 +975,17 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@size-limit/file@12.0.1': resolution: {integrity: sha512-Kvbnz46iV7WeHaANf1HmWjXBVMU2KkCU+0xJ78FzIjZwlVKKEqy+QCZprdBMfIWrzrvYeqP4cfuzKG8z6xVivg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -990,6 +1007,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.5.2': + resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@vitest/coverage-v8@4.1.2': resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} peerDependencies: @@ -1124,6 +1144,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -1164,6 +1188,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1218,9 +1246,16 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1247,6 +1282,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1254,6 +1293,15 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -1268,6 +1316,14 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-port@7.2.0: + resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} + engines: {node: '>=16'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -1285,6 +1341,14 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1301,6 +1365,21 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: @@ -1451,6 +1530,19 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -1476,6 +1568,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1501,6 +1597,10 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -1510,6 +1610,14 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1565,6 +1673,22 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prool@0.2.4: + resolution: {integrity: sha512-KAGs6e++7MJNQ/vq8Xrk6akz0lRk6AmhuGzSHkluX3kwVj2XjNDDOYSINZwahRv3xfSD0rXYv3iA/2vXw7z47w==} + engines: {node: '>=22'} + peerDependencies: + '@pimlico/alto': '*' + testcontainers: '>=11.10.0' + peerDependenciesMeta: + '@pimlico/alto': + optional: true + testcontainers: + optional: true + publint@0.3.18: resolution: {integrity: sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog==} engines: {node: '>=18'} @@ -1581,6 +1705,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1614,9 +1741,21 @@ packages: engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + size-limit@12.0.1: resolution: {integrity: sha512-vuFj+6lDOoBJQu6OLhcMQv7jnbXjuoEn4WsQHlSLOV/8EFfOka/tfjtLQ/rZig5Gagi3R0GnU/0kd4EY/y2etg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1657,6 +1796,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -1674,6 +1817,11 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + tar@7.2.0: + resolution: {integrity: sha512-hctwP0Nb4AB60bj8WQgRYaMOuJYRAPMGiQUAotms5igN8ppfQM+IvjQ5HcKu1MaZh2Wy2KWVTe563Yj8dfc14w==} + engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1749,10 +1897,17 @@ packages: resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} engines: {node: '>=14'} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1847,6 +2002,11 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1872,6 +2032,10 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -1885,6 +2049,10 @@ packages: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2142,6 +2310,10 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2463,8 +2635,12 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + '@sec-ant/readable-stream@0.4.1': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@size-limit/file@12.0.1(size-limit@12.0.1(jiti@2.6.1))': dependencies: size-limit: 12.0.1(jiti@2.6.1) @@ -2485,7 +2661,11 @@ snapshots: '@types/estree@1.0.8': {} - '@vitest/coverage-v8@4.1.2(vitest@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)))': + '@types/node@25.5.2': + dependencies: + undici-types: 7.18.2 + + '@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@25.5.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.2 @@ -2497,7 +2677,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)) + vitest: 4.1.2(@types/node@25.5.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)) '@vitest/expect@4.1.2': dependencies: @@ -2508,13 +2688,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3) '@vitest/pretty-format@4.1.2': dependencies: @@ -2634,6 +2814,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@3.0.0: {} + cjs-module-lexer@1.4.3: {} cli-highlight@2.1.11: @@ -2673,6 +2855,12 @@ snapshots: convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2757,8 +2945,25 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} fast-glob@3.3.3: @@ -2787,6 +2992,10 @@ snapshots: fflate@0.8.2: {} + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -2797,6 +3006,8 @@ snapshots: mlly: 1.8.2 rollup: 4.60.1 + follow-redirects@1.15.11: {} + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -2806,6 +3017,13 @@ snapshots: get-caller-file@2.0.5: {} + get-port@7.2.0: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -2820,6 +3038,16 @@ snapshots: html-escaper@2.0.2: {} + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.11 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + human-signals@8.0.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2830,6 +3058,14 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + isows@1.0.7(ws@8.18.3): dependencies: ws: 8.18.3 @@ -2967,6 +3203,14 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@3.0.1: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -2997,6 +3241,11 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + object-assign@4.1.1: {} obug@2.1.1: {} @@ -3072,6 +3321,8 @@ snapshots: package-manager-detector@1.6.0: {} + parse-ms@4.0.0: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -3080,6 +3331,10 @@ snapshots: parse5@6.0.1: {} + path-key@3.1.1: {} + + path-key@4.0.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -3116,6 +3371,21 @@ snapshots: prettier@3.8.1: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prool@0.2.4: + dependencies: + change-case: 5.4.4 + eventemitter3: 5.0.1 + execa: 9.6.1 + get-port: 7.2.0 + http-proxy: 1.18.1 + tar: 7.2.0 + transitivePeerDependencies: + - debug + publint@0.3.18: dependencies: '@publint/pack': 0.1.4 @@ -3129,6 +3399,8 @@ snapshots: require-directory@2.1.1: {} + requires-port@1.0.0: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -3200,8 +3472,16 @@ snapshots: semver@7.7.4: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + size-limit@12.0.1(jiti@2.6.1): dependencies: bytes-iec: 3.1.1 @@ -3236,6 +3516,8 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-final-newline@4.0.0: {} + strip-json-comments@5.0.3: {} sucrase@3.35.1: @@ -3257,6 +3539,15 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + tar@7.2.0: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + mkdirp: 3.0.1 + yallist: 5.0.0 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -3325,8 +3616,12 @@ snapshots: unbash@2.2.0: {} + undici-types@7.18.2: {} + unicode-emoji-modifier-base@1.0.0: {} + unicorn-magic@0.3.0: {} + validate-npm-package-name@5.0.1: {} viem@2.47.10(typescript@5.9.3)(zod@4.3.6): @@ -3346,7 +3641,7 @@ snapshots: - utf-8-validate - zod - vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3): + vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -3354,6 +3649,7 @@ snapshots: rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.5.2 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.6.1 @@ -3362,10 +3658,10 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)): + vitest@4.1.2(@types/node@25.5.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)) + '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 @@ -3382,13 +3678,19 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.2 transitivePeerDependencies: - msw walk-up-path@4.0.0: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -3404,6 +3706,8 @@ snapshots: y18n@5.0.8: {} + yallist@5.0.0: {} + yaml@2.8.3: {} yargs-parser@20.2.9: {} @@ -3418,4 +3722,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yoctocolors@2.1.2: {} + zod@4.3.6: {} diff --git a/src/index.ts b/src/index.ts index 83f6636..9c1b468 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,4 +26,29 @@ export { setMetadata, verifyAgentId, } from './identity/index.js' +export type { + AppendResponseParameters, + Feedback, + FeedbackEntry, + GetClientsParameters, + GetLastIndexParameters, + GetResponseCountParameters, + GetSummaryParameters, + GiveFeedbackParameters, + ReadAllFeedbackParameters, + ReadFeedbackParameters, + ReputationSummary, + RevokeFeedbackParameters, +} from './reputation/index.js' +export { + appendResponse, + getClients, + getLastIndex, + getResponseCount, + getSummary, + giveFeedback, + readAllFeedback, + readFeedback, + revokeFeedback, +} from './reputation/index.js' export type { Erc8004Addresses } from './types.js' diff --git a/src/internal/resolveRegistryAddress.ts b/src/internal/resolveRegistryAddress.ts index 7352e3c..c00654c 100644 --- a/src/internal/resolveRegistryAddress.ts +++ b/src/internal/resolveRegistryAddress.ts @@ -26,3 +26,10 @@ export function resolveIdentityRegistry( ): Address { return resolveRegistry(client, 'identityRegistry', registryAddress) } + +export function resolveReputationRegistry( + client: { chain?: Chain | undefined }, + registryAddress?: Address, +): Address { + return resolveRegistry(client, 'reputationRegistry', registryAddress) +} diff --git a/src/reputation/appendResponse.ts b/src/reputation/appendResponse.ts new file mode 100644 index 0000000..7526397 --- /dev/null +++ b/src/reputation/appendResponse.ts @@ -0,0 +1,35 @@ +import type { Hash, WalletClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { requireAccount } from '../internal/requireAccount.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { AppendResponseParameters } from './types.js' + +/** + * Append a response to existing feedback. + * Reverts if `msg.sender` is not the owner of `agentId`. + */ +export async function appendResponse( + walletClient: WalletClient, + parameters: AppendResponseParameters, +): Promise { + const account = requireAccount(walletClient) + const registry = resolveReputationRegistry( + walletClient, + parameters.registryAddress, + ) + + return walletClient.writeContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'appendResponse', + args: [ + parameters.agentId, + parameters.clientAddress, + parameters.feedbackIndex, + parameters.responseURI, + parameters.responseHash, + ], + chain: walletClient.chain, + account, + }) +} diff --git a/src/reputation/getClients.ts b/src/reputation/getClients.ts new file mode 100644 index 0000000..d85dea2 --- /dev/null +++ b/src/reputation/getClients.ts @@ -0,0 +1,24 @@ +import type { Address, PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { GetClientsParameters } from './types.js' + +/** + * Get all addresses that have given feedback to an agent. + */ +export async function getClients( + publicClient: PublicClient, + parameters: GetClientsParameters, +): Promise { + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + return publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'getClients', + args: [parameters.agentId], + }) +} diff --git a/src/reputation/getLastIndex.ts b/src/reputation/getLastIndex.ts new file mode 100644 index 0000000..0c93695 --- /dev/null +++ b/src/reputation/getLastIndex.ts @@ -0,0 +1,24 @@ +import type { PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { GetLastIndexParameters } from './types.js' + +/** + * Get the latest feedback index for a specific agent-client pair. + */ +export async function getLastIndex( + publicClient: PublicClient, + parameters: GetLastIndexParameters, +): Promise { + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + return publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'getLastIndex', + args: [parameters.agentId, parameters.clientAddress], + }) +} diff --git a/src/reputation/getResponseCount.ts b/src/reputation/getResponseCount.ts new file mode 100644 index 0000000..9e115c7 --- /dev/null +++ b/src/reputation/getResponseCount.ts @@ -0,0 +1,30 @@ +import type { PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { GetResponseCountParameters } from './types.js' + +/** + * Get the number of responses to a specific feedback entry from + * a set of responder addresses. + */ +export async function getResponseCount( + publicClient: PublicClient, + parameters: GetResponseCountParameters, +): Promise { + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + return publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'getResponseCount', + args: [ + parameters.agentId, + parameters.clientAddress, + parameters.feedbackIndex, + parameters.responders, + ], + }) +} diff --git a/src/reputation/getSummary.ts b/src/reputation/getSummary.ts new file mode 100644 index 0000000..8ce0c14 --- /dev/null +++ b/src/reputation/getSummary.ts @@ -0,0 +1,33 @@ +import type { PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { GetSummaryParameters, ReputationSummary } from './types.js' + +/** + * Get an aggregated reputation summary for an agent, filtered by + * reviewer addresses and tag pair. + */ +export async function getSummary( + publicClient: PublicClient, + parameters: GetSummaryParameters, +): Promise { + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + const [count, summaryValue, summaryValueDecimals] = + await publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'getSummary', + args: [ + parameters.agentId, + parameters.clientAddresses, + parameters.tag1, + parameters.tag2, + ], + }) + + return { count, summaryValue, summaryValueDecimals } +} diff --git a/src/reputation/giveFeedback.ts b/src/reputation/giveFeedback.ts new file mode 100644 index 0000000..d8d26d4 --- /dev/null +++ b/src/reputation/giveFeedback.ts @@ -0,0 +1,39 @@ +import { type Hash, type WalletClient, zeroHash } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { requireAccount } from '../internal/requireAccount.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { GiveFeedbackParameters } from './types.js' + +/** + * Give feedback to an agent. The caller (msg.sender) is recorded as the + * reviewer (client). Feedback includes a numeric value, two category tags, + * and optional off-chain data (endpoint, URI, hash). + */ +export async function giveFeedback( + walletClient: WalletClient, + parameters: GiveFeedbackParameters, +): Promise { + const account = requireAccount(walletClient) + const registry = resolveReputationRegistry( + walletClient, + parameters.registryAddress, + ) + + return walletClient.writeContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'giveFeedback', + args: [ + parameters.agentId, + parameters.value, + parameters.valueDecimals, + parameters.tag1, + parameters.tag2, + parameters.endpoint ?? '', + parameters.feedbackURI ?? '', + parameters.feedbackHash ?? zeroHash, + ], + chain: walletClient.chain, + account, + }) +} diff --git a/src/reputation/index.ts b/src/reputation/index.ts index 08a984b..33be68f 100644 --- a/src/reputation/index.ts +++ b/src/reputation/index.ts @@ -1 +1,24 @@ -// Reputation functions will be added in Step 5. +export { appendResponse } from './appendResponse.js' +export { getClients } from './getClients.js' +export { getLastIndex } from './getLastIndex.js' +export { getResponseCount } from './getResponseCount.js' +export { getSummary } from './getSummary.js' +export { giveFeedback } from './giveFeedback.js' +export { readAllFeedback } from './readAllFeedback.js' +export { readFeedback } from './readFeedback.js' +export { revokeFeedback } from './revokeFeedback.js' + +export type { + AppendResponseParameters, + Feedback, + FeedbackEntry, + GetClientsParameters, + GetLastIndexParameters, + GetResponseCountParameters, + GetSummaryParameters, + GiveFeedbackParameters, + ReadAllFeedbackParameters, + ReadFeedbackParameters, + ReputationSummary, + RevokeFeedbackParameters, +} from './types.js' diff --git a/src/reputation/readAllFeedback.ts b/src/reputation/readAllFeedback.ts new file mode 100644 index 0000000..999153e --- /dev/null +++ b/src/reputation/readAllFeedback.ts @@ -0,0 +1,52 @@ +import type { PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { FeedbackEntry, ReadAllFeedbackParameters } from './types.js' + +/** + * Read all feedback for an agent, filtered by reviewer addresses, tags, + * and revocation status. + * + * The contract returns 7 parallel arrays — this function transforms them + * into an array of structured objects for better DX. + */ +export async function readAllFeedback( + publicClient: PublicClient, + parameters: ReadAllFeedbackParameters, +): Promise { + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + const [ + clients, + feedbackIndexes, + values, + valueDecimals, + tag1s, + tag2s, + revokedStatuses, + ] = await publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'readAllFeedback', + args: [ + parameters.agentId, + parameters.clientAddresses, + parameters.tag1, + parameters.tag2, + parameters.includeRevoked ?? false, + ], + }) + + return clients.map((client, i) => ({ + client, + feedbackIndex: feedbackIndexes[i], + value: values[i], + valueDecimals: valueDecimals[i], + tag1: tag1s[i], + tag2: tag2s[i], + isRevoked: revokedStatuses[i], + })) +} diff --git a/src/reputation/readFeedback.ts b/src/reputation/readFeedback.ts new file mode 100644 index 0000000..9cd76e4 --- /dev/null +++ b/src/reputation/readFeedback.ts @@ -0,0 +1,31 @@ +import type { PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { Feedback, ReadFeedbackParameters } from './types.js' + +/** + * Read a single feedback entry by agent, client address, and index. + */ +export async function readFeedback( + publicClient: PublicClient, + parameters: ReadFeedbackParameters, +): Promise { + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + const [value, valueDecimals, tag1, tag2, isRevoked] = + await publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'readFeedback', + args: [ + parameters.agentId, + parameters.clientAddress, + parameters.feedbackIndex, + ], + }) + + return { value, valueDecimals, tag1, tag2, isRevoked } +} diff --git a/src/reputation/revokeFeedback.ts b/src/reputation/revokeFeedback.ts new file mode 100644 index 0000000..b29c5ae --- /dev/null +++ b/src/reputation/revokeFeedback.ts @@ -0,0 +1,29 @@ +import type { Hash, WalletClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { requireAccount } from '../internal/requireAccount.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { RevokeFeedbackParameters } from './types.js' + +/** + * Revoke previously given feedback. Only the original feedback giver + * (msg.sender at giveFeedback time) can revoke. + */ +export async function revokeFeedback( + walletClient: WalletClient, + parameters: RevokeFeedbackParameters, +): Promise { + const account = requireAccount(walletClient) + const registry = resolveReputationRegistry( + walletClient, + parameters.registryAddress, + ) + + return walletClient.writeContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'revokeFeedback', + args: [parameters.agentId, parameters.feedbackIndex], + chain: walletClient.chain, + account, + }) +} diff --git a/src/reputation/types.ts b/src/reputation/types.ts new file mode 100644 index 0000000..59fe679 --- /dev/null +++ b/src/reputation/types.ts @@ -0,0 +1,103 @@ +import type { Address, Hex } from 'viem' + +// --------------------------------------------------------------------------- +// Write parameters +// --------------------------------------------------------------------------- + +export interface GiveFeedbackParameters { + registryAddress?: Address + agentId: bigint + value: bigint + valueDecimals: number + tag1: string + tag2: string + endpoint?: string + feedbackURI?: string + feedbackHash?: Hex +} + +export interface RevokeFeedbackParameters { + registryAddress?: Address + agentId: bigint + feedbackIndex: bigint +} + +export interface AppendResponseParameters { + registryAddress?: Address + agentId: bigint + clientAddress: Address + feedbackIndex: bigint + responseURI: string + responseHash: Hex +} + +// --------------------------------------------------------------------------- +// Read parameters +// --------------------------------------------------------------------------- + +export interface GetSummaryParameters { + registryAddress?: Address + agentId: bigint + clientAddresses: readonly Address[] + tag1: string + tag2: string +} + +export interface ReputationSummary { + count: bigint + summaryValue: bigint + summaryValueDecimals: number +} + +export interface GetClientsParameters { + registryAddress?: Address + agentId: bigint +} + +export interface ReadAllFeedbackParameters { + registryAddress?: Address + agentId: bigint + clientAddresses: readonly Address[] + tag1: string + tag2: string + includeRevoked?: boolean +} + +export interface FeedbackEntry { + client: Address + feedbackIndex: bigint + value: bigint + valueDecimals: number + tag1: string + tag2: string + isRevoked: boolean +} + +export interface ReadFeedbackParameters { + registryAddress?: Address + agentId: bigint + clientAddress: Address + feedbackIndex: bigint +} + +export interface Feedback { + value: bigint + valueDecimals: number + tag1: string + tag2: string + isRevoked: boolean +} + +export interface GetLastIndexParameters { + registryAddress?: Address + agentId: bigint + clientAddress: Address +} + +export interface GetResponseCountParameters { + registryAddress?: Address + agentId: bigint + clientAddress: Address + feedbackIndex: bigint + responders: readonly Address[] +} diff --git a/tests/fork/identity.fork.test.ts b/tests/fork/identity.fork.test.ts new file mode 100644 index 0000000..41a4b37 --- /dev/null +++ b/tests/fork/identity.fork.test.ts @@ -0,0 +1,105 @@ +import type { PublicClient, WalletClient } from 'viem' +import { parseEventLogs } from 'viem' +import { beforeAll, describe, expect, it } from 'vitest' +import { identityRegistryAbi } from '../../src/abis/index.js' +import { getAgentWallet } from '../../src/identity/getAgentWallet.js' +import { getMetadata } from '../../src/identity/getMetadata.js' +import { isRegistered } from '../../src/identity/isRegistered.js' +import { registerAgent } from '../../src/identity/register.js' +import { resolveAgent } from '../../src/identity/resolveAgent.js' +import { setAgentURI } from '../../src/identity/setAgentURI.js' +import { setMetadata } from '../../src/identity/setMetadata.js' +import { verifyAgentId } from '../../src/identity/verifyAgentId.js' +import { anvilBaseSepolia } from '../setup/anvil.js' +import { accounts } from '../setup/constants.js' + +describe('Identity Registry (fork)', () => { + let publicClient: PublicClient + let walletClient: WalletClient + let agentId: bigint + + beforeAll(async () => { + publicClient = anvilBaseSepolia.getPublicClient() + walletClient = anvilBaseSepolia.getWalletClient(accounts[0].address) + }) + + it('registers an agent and extracts agentId', async () => { + const hash = await registerAgent(walletClient, { + agentURI: 'https://test.example.com/agent.json', + }) + + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + const logs = parseEventLogs({ + abi: identityRegistryAbi, + logs: receipt.logs, + eventName: 'Registered', + }) + + expect(logs).toHaveLength(1) + agentId = logs[0].args.agentId + expect(agentId).toBeGreaterThan(0n) + }) + + it('isRegistered returns true for registered address', async () => { + const result = await isRegistered(publicClient, { + address: accounts[0].address, + }) + expect(result).toBe(true) + }) + + it('verifyAgentId returns true for correct owner', async () => { + const result = await verifyAgentId(publicClient, { + agentId, + claimedAddress: accounts[0].address, + }) + expect(result).toBe(true) + }) + + it('verifyAgentId returns false for wrong address', async () => { + const result = await verifyAgentId(publicClient, { + agentId, + claimedAddress: accounts[1].address, + }) + expect(result).toBe(false) + }) + + it('resolveAgent returns full agent info', async () => { + const agent = await resolveAgent(publicClient, { agentId }) + + expect(agent.agentId).toBe(agentId) + expect(agent.owner).toBe(accounts[0].address) + expect(agent.agentURI).toBe('https://test.example.com/agent.json') + expect(agent.ownerMismatch).toBe(false) + }) + + it('setMetadata and getMetadata round-trip', async () => { + const hash = await setMetadata(walletClient, { + agentId, + key: 'x402r.test', + value: '0x1234', + }) + await publicClient.waitForTransactionReceipt({ hash }) + + const result = await getMetadata(publicClient, { + agentId, + key: 'x402r.test', + }) + expect(result).toBe('0x1234') + }) + + it('setAgentURI updates the URI', async () => { + const hash = await setAgentURI(walletClient, { + agentId, + newURI: 'https://updated.example.com/agent.json', + }) + await publicClient.waitForTransactionReceipt({ hash }) + + const agent = await resolveAgent(publicClient, { agentId }) + expect(agent.agentURI).toBe('https://updated.example.com/agent.json') + }) + + it('getAgentWallet returns the agent wallet', async () => { + const wallet = await getAgentWallet(publicClient, { agentId }) + expect(wallet).toBe(accounts[0].address) + }) +}) diff --git a/tests/fork/reputation.fork.test.ts b/tests/fork/reputation.fork.test.ts new file mode 100644 index 0000000..5e08a0a --- /dev/null +++ b/tests/fork/reputation.fork.test.ts @@ -0,0 +1,200 @@ +import type { PublicClient, WalletClient } from 'viem' +import { isAddressEqual, parseEventLogs, zeroHash } from 'viem' +import { beforeAll, describe, expect, it } from 'vitest' +import { identityRegistryAbi } from '../../src/abis/index.js' +import { registerAgent } from '../../src/identity/register.js' +import { appendResponse } from '../../src/reputation/appendResponse.js' +import { getClients } from '../../src/reputation/getClients.js' +import { getLastIndex } from '../../src/reputation/getLastIndex.js' +import { getResponseCount } from '../../src/reputation/getResponseCount.js' +import { getSummary } from '../../src/reputation/getSummary.js' +import { giveFeedback } from '../../src/reputation/giveFeedback.js' +import { readAllFeedback } from '../../src/reputation/readAllFeedback.js' +import { readFeedback } from '../../src/reputation/readFeedback.js' +import { revokeFeedback } from '../../src/reputation/revokeFeedback.js' +import { anvilBaseSepolia } from '../setup/anvil.js' +import { accounts } from '../setup/constants.js' + +describe('Reputation Registry (fork)', () => { + let publicClient: PublicClient + let agentOwnerClient: WalletClient + let feedbackGiverClient: WalletClient + let agentId: bigint + + beforeAll(async () => { + publicClient = anvilBaseSepolia.getPublicClient() + agentOwnerClient = anvilBaseSepolia.getWalletClient(accounts[0].address) + feedbackGiverClient = anvilBaseSepolia.getWalletClient(accounts[1].address) + + // Register an agent so we have a target for feedback + const hash = await registerAgent(agentOwnerClient, { + agentURI: 'https://reputation-test.example.com/agent.json', + }) + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + const logs = parseEventLogs({ + abi: identityRegistryAbi, + logs: receipt.logs, + eventName: 'Registered', + }) + agentId = logs[0].args.agentId + }) + + it('giveFeedback submits feedback', async () => { + const hash = await giveFeedback(feedbackGiverClient, { + agentId, + value: 85n, + valueDecimals: 0, + tag1: 'x402r.resolution', + tag2: 'quality', + endpoint: '', + feedbackURI: '', + feedbackHash: zeroHash, + }) + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + expect(receipt.status).toBe('success') + }) + + it('getClients includes the feedback giver', async () => { + const clients = await getClients(publicClient, { agentId }) + expect(clients.some((c) => isAddressEqual(c, accounts[1].address))).toBe( + true, + ) + }) + + it('getLastIndex returns the feedback index', async () => { + const index = await getLastIndex(publicClient, { + agentId, + clientAddress: accounts[1].address, + }) + expect(index).toBe(1n) + }) + + it('readFeedback returns the submitted feedback', async () => { + const lastIndex = await getLastIndex(publicClient, { + agentId, + clientAddress: accounts[1].address, + }) + + const feedback = await readFeedback(publicClient, { + agentId, + clientAddress: accounts[1].address, + feedbackIndex: lastIndex, + }) + + expect(feedback.value).toBe(85n) + expect(feedback.tag1).toBe('x402r.resolution') + expect(feedback.tag2).toBe('quality') + expect(feedback.isRevoked).toBe(false) + }) + + it('getSummary aggregates feedback', async () => { + const summary = await getSummary(publicClient, { + agentId, + clientAddresses: [accounts[1].address], + tag1: 'x402r.resolution', + tag2: 'quality', + }) + + expect(summary.count).toBe(1n) + expect(summary.summaryValue).toBe(85n) + expect(summary.summaryValueDecimals).toBe(0) + }) + + it('readAllFeedback returns structured entries', async () => { + const entries = await readAllFeedback(publicClient, { + agentId, + clientAddresses: [accounts[1].address], + tag1: 'x402r.resolution', + tag2: 'quality', + includeRevoked: false, + }) + + expect(entries).toHaveLength(1) + expect(isAddressEqual(entries[0].client, accounts[1].address)).toBe(true) + expect(entries[0].feedbackIndex).toBe(1n) + expect(entries[0].value).toBe(85n) + expect(entries[0].valueDecimals).toBe(0) + expect(entries[0].tag1).toBe('x402r.resolution') + expect(entries[0].tag2).toBe('quality') + expect(entries[0].isRevoked).toBe(false) + }) + + it('appendResponse adds a response to feedback', async () => { + const lastIndex = await getLastIndex(publicClient, { + agentId, + clientAddress: accounts[1].address, + }) + + const hash = await appendResponse(agentOwnerClient, { + agentId, + clientAddress: accounts[1].address, + feedbackIndex: lastIndex, + responseURI: 'https://response.example.com', + responseHash: zeroHash, + }) + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + expect(receipt.status).toBe('success') + }) + + it('getResponseCount returns the response count', async () => { + const lastIndex = await getLastIndex(publicClient, { + agentId, + clientAddress: accounts[1].address, + }) + + const count = await getResponseCount(publicClient, { + agentId, + clientAddress: accounts[1].address, + feedbackIndex: lastIndex, + responders: [accounts[0].address], + }) + + expect(count).toBe(1n) + }) + + it('revokeFeedback marks feedback as revoked', async () => { + const lastIndex = await getLastIndex(publicClient, { + agentId, + clientAddress: accounts[1].address, + }) + + const hash = await revokeFeedback(feedbackGiverClient, { + agentId, + feedbackIndex: lastIndex, + }) + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + expect(receipt.status).toBe('success') + + const feedback = await readFeedback(publicClient, { + agentId, + clientAddress: accounts[1].address, + feedbackIndex: lastIndex, + }) + expect(feedback.isRevoked).toBe(true) + }) + + it('readAllFeedback excludes revoked when includeRevoked is false', async () => { + const entries = await readAllFeedback(publicClient, { + agentId, + clientAddresses: [accounts[1].address], + tag1: 'x402r.resolution', + tag2: 'quality', + includeRevoked: false, + }) + + expect(entries).toHaveLength(0) + }) + + it('readAllFeedback includes revoked when includeRevoked is true', async () => { + const entries = await readAllFeedback(publicClient, { + agentId, + clientAddresses: [accounts[1].address], + tag1: 'x402r.resolution', + tag2: 'quality', + includeRevoked: true, + }) + + expect(entries).toHaveLength(1) + expect(entries[0].isRevoked).toBe(true) + }) +}) diff --git a/tests/identity.test.ts b/tests/identity.test.ts index d6b00e3..007e03d 100644 --- a/tests/identity.test.ts +++ b/tests/identity.test.ts @@ -8,30 +8,13 @@ import { resolveAgent } from '../src/identity/resolveAgent.js' import { setAgentURI } from '../src/identity/setAgentURI.js' import { setMetadata } from '../src/identity/setMetadata.js' import { verifyAgentId } from '../src/identity/verifyAgentId.js' - -const REGISTRY = '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432' as Address -const ADDR_A = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as Address -const ADDR_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' as Address - -function mockPublic(impl: Record): PublicClient { - return { - chain: { id: 8453 }, - readContract: vi.fn(({ functionName }: { functionName: string }) => { - if (functionName in impl) return Promise.resolve(impl[functionName]) - return Promise.reject(new Error(`unexpected call: ${functionName}`)) - }), - } as unknown as PublicClient -} - -function mockWallet( - opts: { account?: { address: Address } } = { account: { address: ADDR_A } }, -): WalletClient { - return { - writeContract: vi.fn().mockResolvedValue('0xabc' as `0x${string}`), - chain: { id: 8453 }, - ...opts, - } as unknown as WalletClient -} +import { + ADDR_A, + ADDR_B, + mockPublic, + mockWallet, + REGISTRY, +} from './setup/mocks.js' // --- isRegistered --- diff --git a/tests/reputation.test.ts b/tests/reputation.test.ts new file mode 100644 index 0000000..08b51d2 --- /dev/null +++ b/tests/reputation.test.ts @@ -0,0 +1,365 @@ +import type { PublicClient } from 'viem' +import { zeroHash } from 'viem' +import { describe, expect, it, vi } from 'vitest' +import { appendResponse } from '../src/reputation/appendResponse.js' +import { getClients } from '../src/reputation/getClients.js' +import { getLastIndex } from '../src/reputation/getLastIndex.js' +import { getResponseCount } from '../src/reputation/getResponseCount.js' +import { getSummary } from '../src/reputation/getSummary.js' +import { giveFeedback } from '../src/reputation/giveFeedback.js' +import { readAllFeedback } from '../src/reputation/readAllFeedback.js' +import { readFeedback } from '../src/reputation/readFeedback.js' +import { revokeFeedback } from '../src/reputation/revokeFeedback.js' +import { + ADDR_A, + ADDR_B, + mockPublic, + mockWallet, + REPUTATION_REGISTRY, +} from './setup/mocks.js' + +// --- giveFeedback --- + +describe('giveFeedback', () => { + it('throws when walletClient has no account', async () => { + await expect( + giveFeedback(mockWallet({ account: undefined }), { + registryAddress: REPUTATION_REGISTRY, + agentId: 1n, + value: 85n, + valueDecimals: 0, + tag1: 'x402r.resolution', + tag2: 'quality', + endpoint: '', + feedbackURI: '', + feedbackHash: zeroHash, + }), + ).rejects.toThrow('walletClient must have an account') + }) + + it('passes all 8 args in correct order', async () => { + const client = mockWallet() + await giveFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + value: 85n, + valueDecimals: 2, + tag1: 'x402r.resolution', + tag2: 'quality', + endpoint: 'https://api.example.com', + feedbackURI: 'https://feedback.example.com', + feedbackHash: zeroHash, + }) + + expect(client.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'giveFeedback', + args: [ + 42n, + 85n, + 2, + 'x402r.resolution', + 'quality', + 'https://api.example.com', + 'https://feedback.example.com', + zeroHash, + ], + }), + ) + }) +}) + +// --- revokeFeedback --- + +describe('revokeFeedback', () => { + it('throws when walletClient has no account', async () => { + await expect( + revokeFeedback(mockWallet({ account: undefined }), { + registryAddress: REPUTATION_REGISTRY, + agentId: 1n, + feedbackIndex: 1n, + }), + ).rejects.toThrow('walletClient must have an account') + }) + + it('passes args in correct order (agentId, feedbackIndex)', async () => { + const client = mockWallet() + await revokeFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + feedbackIndex: 3n, + }) + + expect(client.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'revokeFeedback', + args: [42n, 3n], + }), + ) + }) +}) + +// --- appendResponse --- + +describe('appendResponse', () => { + it('throws when walletClient has no account', async () => { + await expect( + appendResponse(mockWallet({ account: undefined }), { + registryAddress: REPUTATION_REGISTRY, + agentId: 1n, + clientAddress: ADDR_B, + feedbackIndex: 1n, + responseURI: 'https://response.example.com', + responseHash: zeroHash, + }), + ).rejects.toThrow('walletClient must have an account') + }) + + it('passes args in correct order', async () => { + const client = mockWallet() + await appendResponse(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddress: ADDR_B, + feedbackIndex: 1n, + responseURI: 'https://response.example.com', + responseHash: zeroHash, + }) + + expect(client.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'appendResponse', + args: [42n, ADDR_B, 1n, 'https://response.example.com', zeroHash], + }), + ) + }) +}) + +// --- getSummary --- + +describe('getSummary', () => { + it('returns structured summary', async () => { + const client = mockPublic({ + getSummary: [5n, 425n, 2], + }) + + const result = await getSummary(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddresses: [ADDR_A], + tag1: 'x402r.resolution', + tag2: 'quality', + }) + + expect(result).toEqual({ + count: 5n, + summaryValue: 425n, + summaryValueDecimals: 2, + }) + }) +}) + +// --- getClients --- + +describe('getClients', () => { + it('returns array of addresses', async () => { + const client = mockPublic({ getClients: [ADDR_A, ADDR_B] }) + + const result = await getClients(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + }) + + expect(result).toEqual([ADDR_A, ADDR_B]) + }) +}) + +// --- readFeedback --- + +describe('readFeedback', () => { + it('returns structured feedback', async () => { + const client = mockPublic({ + readFeedback: [85n, 0, 'x402r.resolution', 'quality', false], + }) + + const result = await readFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddress: ADDR_A, + feedbackIndex: 1n, + }) + + expect(result).toEqual({ + value: 85n, + valueDecimals: 0, + tag1: 'x402r.resolution', + tag2: 'quality', + isRevoked: false, + }) + }) +}) + +// --- readAllFeedback --- + +describe('readAllFeedback', () => { + it('transforms parallel arrays to structured objects', async () => { + const client = mockPublic({ + readAllFeedback: [ + [ADDR_A, ADDR_B], // clients + [1n, 1n], // feedbackIndexes + [85n, 90n], // values + [0, 0], // valueDecimals + ['x402r.resolution', 'x402r.resolution'], // tag1s + ['quality', 'quality'], // tag2s + [false, false], // revokedStatuses + ], + }) + + const result = await readAllFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddresses: [ADDR_A, ADDR_B], + tag1: 'x402r.resolution', + tag2: 'quality', + includeRevoked: false, + }) + + expect(client.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([false]), + }), + ) + expect(result).toEqual([ + { + client: ADDR_A, + feedbackIndex: 1n, + value: 85n, + valueDecimals: 0, + tag1: 'x402r.resolution', + tag2: 'quality', + isRevoked: false, + }, + { + client: ADDR_B, + feedbackIndex: 1n, + value: 90n, + valueDecimals: 0, + tag1: 'x402r.resolution', + tag2: 'quality', + isRevoked: false, + }, + ]) + }) + + it('includes revoked entries when includeRevoked is true', async () => { + const client = mockPublic({ + readAllFeedback: [ + [ADDR_A], + [1n], + [85n], + [0], + ['x402r.resolution'], + ['quality'], + [true], + ], + }) + + const result = await readAllFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddresses: [ADDR_A], + tag1: 'x402r.resolution', + tag2: 'quality', + includeRevoked: true, + }) + + expect(result).toEqual([ + { + client: ADDR_A, + feedbackIndex: 1n, + value: 85n, + valueDecimals: 0, + tag1: 'x402r.resolution', + tag2: 'quality', + isRevoked: true, + }, + ]) + expect(client.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([true]), + }), + ) + }) + + it('returns empty array when no feedback exists', async () => { + const client = mockPublic({ + readAllFeedback: [[], [], [], [], [], [], []], + }) + + const result = await readAllFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddresses: [ADDR_A], + tag1: 'x402r.resolution', + tag2: 'quality', + includeRevoked: false, + }) + + expect(result).toEqual([]) + }) +}) + +// --- getLastIndex --- + +describe('getLastIndex', () => { + it('returns bigint index', async () => { + const client = mockPublic({ getLastIndex: 3n }) + + const result = await getLastIndex(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddress: ADDR_A, + }) + + expect(result).toBe(3n) + }) +}) + +// --- getResponseCount --- + +describe('getResponseCount', () => { + it('returns bigint count', async () => { + const client = mockPublic({ getResponseCount: 2n }) + + const result = await getResponseCount(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddress: ADDR_A, + feedbackIndex: 1n, + responders: [ADDR_B], + }) + + expect(result).toBe(2n) + }) +}) + +// --- registryAddress auto-resolve --- + +describe('registryAddress auto-resolve', () => { + it('resolves from client.chain when registryAddress omitted', async () => { + const client = mockPublic({ getClients: [ADDR_A] }) + const result = await getClients(client, { agentId: 42n }) + expect(result).toEqual([ADDR_A]) + }) + + it('throws when no chain and no registryAddress', async () => { + const client = { + readContract: vi.fn(), + chain: undefined, + } as unknown as PublicClient + + await expect(getClients(client, { agentId: 42n })).rejects.toThrow( + 'client chain not configured', + ) + }) +}) diff --git a/tests/setup/anvil.ts b/tests/setup/anvil.ts new file mode 100644 index 0000000..ba1c371 --- /dev/null +++ b/tests/setup/anvil.ts @@ -0,0 +1,105 @@ +import { + type Address, + type Chain, + createPublicClient, + createTestClient, + createWalletClient, + http, + type PublicClient, + type TestClient, + type WalletClient, +} from 'viem' +import { baseSepolia } from 'viem/chains' +import { poolId } from './constants.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface AnvilInstance { + start(): Promise + stop(): Promise + rpcUrl: string + chain: Chain + getPublicClient(): PublicClient + getWalletClient(account: Address): WalletClient + getTestClient(): TestClient +} + +// --------------------------------------------------------------------------- +// defineAnvil — creates a lazily-started Anvil fork managed by prool +// --------------------------------------------------------------------------- + +function defineAnvil(options: { + chain: Chain + forkUrl: string + port: number + forkBlockNumber?: bigint +}): AnvilInstance { + const { chain, forkUrl, port, forkBlockNumber } = options + + // Pool-isolated RPC URL — each vitest worker gets its own Anvil instance + const rpcUrl = `http://127.0.0.1:${port}/${poolId}` + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- prool's CreateServerReturnType + let server: any + + const transport = http(rpcUrl) + + return { + rpcUrl, + chain, + + async start() { + const { Instance, Server } = await import('prool') + const srv = Server.create({ + instance: Instance.anvil({ + chainId: chain.id, + forkUrl, + forkBlockNumber, + }), + port, + }) + await srv.start() + server = srv + }, + + async stop() { + await server?.stop() + }, + + getPublicClient(): PublicClient { + return createPublicClient({ + chain, + transport, + cacheTime: 0, + }) + }, + + getWalletClient(account: Address): WalletClient { + return createWalletClient({ + account, + chain, + transport, + }) + }, + + getTestClient(): TestClient { + return createTestClient({ + chain, + transport, + mode: 'anvil', + }) + }, + } +} + +// --------------------------------------------------------------------------- +// Pre-configured instance for Base Sepolia fork +// --------------------------------------------------------------------------- + +export const anvilBaseSepolia = defineAnvil({ + chain: baseSepolia, + forkUrl: process.env.FORK_URL ?? 'https://sepolia.base.org', + port: 8745, +}) diff --git a/tests/setup/constants.ts b/tests/setup/constants.ts new file mode 100644 index 0000000..83a2a2a --- /dev/null +++ b/tests/setup/constants.ts @@ -0,0 +1,30 @@ +import type { Address, Hex } from 'viem' + +// --------------------------------------------------------------------------- +// Anvil / Hardhat default accounts (deterministic mnemonic) +// "test test test test test test test test test test test junk" +// --------------------------------------------------------------------------- + +interface TestAccount { + address: Address + privateKey: Hex +} + +export const accounts: readonly TestAccount[] = [ + { + address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + privateKey: + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + }, + { + address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + privateKey: + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + }, +] as const + +// --------------------------------------------------------------------------- +// Pool ID for parallel test isolation +// --------------------------------------------------------------------------- + +export const poolId = Number(process.env.VITEST_POOL_ID ?? 1) diff --git a/tests/setup/globalSetup.ts b/tests/setup/globalSetup.ts new file mode 100644 index 0000000..7b9d5ad --- /dev/null +++ b/tests/setup/globalSetup.ts @@ -0,0 +1,6 @@ +import { anvilBaseSepolia } from './anvil.js' + +export default async function globalSetup() { + await anvilBaseSepolia.start() + return () => anvilBaseSepolia.stop() +} diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts new file mode 100644 index 0000000..c94b7f3 --- /dev/null +++ b/tests/setup/mocks.ts @@ -0,0 +1,30 @@ +import type { Address, PublicClient, WalletClient } from 'viem' +import { vi } from 'vitest' + +export const REGISTRY = '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432' as Address +export const REPUTATION_REGISTRY = + '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63' as Address +export const ADDR_A = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as Address +export const ADDR_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' as Address + +export function mockPublic(impl: Record): PublicClient { + return { + chain: { id: 8453 }, + readContract: vi.fn(({ functionName }: { functionName: string }) => { + if (functionName in impl) return Promise.resolve(impl[functionName]) + return Promise.reject(new Error(`unexpected call: ${functionName}`)) + }), + } as unknown as PublicClient +} + +export function mockWallet( + opts: { account?: { address: Address } } = { + account: { address: ADDR_A }, + }, +): WalletClient { + return { + writeContract: vi.fn().mockResolvedValue('0xabc' as `0x${string}`), + chain: { id: 8453 }, + ...opts, + } as unknown as WalletClient +} diff --git a/vitest.config.ts b/vitest.config.ts index f064da8..0911a3f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,24 +2,25 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { + coverage: { + provider: 'v8', + reporter: [process.env.CI ? 'lcov' : 'text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/index.ts', 'src/**/types.ts', 'src/abis/**'], + }, projects: [ { test: { name: 'unit', include: ['tests/**/*.test.ts'], exclude: ['tests/fork/**'], - coverage: { - provider: 'v8', - reporter: [process.env.CI ? 'lcov' : 'text', 'json', 'html'], - include: ['src/**/*.ts'], - exclude: ['src/**/index.ts', 'src/**/types.ts'], - }, }, }, { test: { name: 'fork', include: ['tests/fork/**/*.fork.test.ts'], + globalSetup: ['tests/setup/globalSetup.ts'], testTimeout: 60_000, hookTimeout: 60_000, retry: 3,