diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml index 6e510c9735..8588a269f7 100644 --- a/.github/workflows/real-evm-interface.yml +++ b/.github/workflows/real-evm-interface.yml @@ -43,3 +43,5 @@ jobs: echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}" - name: Verify source-owned compiled bytecode through public CLI and MCP run: npm run verify:evm:interface + - name: Verify content-hash-free and CBOR-free owned compiler artifacts + run: npm run verify:solidity:metadata diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 972d8b92cc..1e28542fb7 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -39,6 +39,18 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) - Upstream also returns terminal metadata during selector recovery. Its reported values remain in `raw_result`; integer precision and non-text-key coverage are unknown in the JS binding. Full bytecode retains the original encoded metadata. +- `bytecode.metadata` independently interprets a plausible terminal CBOR root + map from the selected bytes. Integers use decimal strings; byte strings use hex, + and scalar keys retain their types. The recorded offset/length addresses the + original bytecode, so summary delivery does not copy an opaque CBOR payload. + Nested containers have partial scalar coverage; unsupported or malformed + candidates report `not-decoded` with a diagnostic. Decoding is bounded to 64 + container levels and the terminal payload's encoded size. +- An unavailable trailer does not prove metadata absence or Solidity origin. + Compiler-version bytes/text are metadata declarations, not verified compiler, + source-file or deployed-contract identity. Historical omission parses as a null + unknown facet without changing the original Evidence record. Metadata/source + URLs are not fetched. - Selectors, argument strings and state mutability are **inferences**. Function names/signatures are not looked up. Optimized code may produce different mutability candidates; recovered types are not a verified source ABI. @@ -125,3 +137,15 @@ The real verification lane traces representative raw/hex CLI workflows and check that only declared Node/prlimit launchers and process ownership inspection run. Socket traces permit local stdio socket pairs and reject attempted network requests. These observations verify those fixtures; they are not a general network sandbox. + +For the independent metadata parser, `npm run verify:solidity:metadata` uses an +existing caller-selected solc 0.8.30 module: + +```sh +REA_VERIFY_SOLC_MODULE=/absolute/path/to/solc/index.js npm run verify:solidity:metadata +``` + +It compiles the owned fixture with unoptimized, viaIR, content-hash-free and +CBOR-free settings, verifies the observed trailer facts and reports local decode +timings. No contract is executed. This lane does not establish the complete +Linux EVM provider's CLI/MCP, resource-limit or cleanup behavior. diff --git a/docs/specs/solidity/cbor-metadata/plan.md b/docs/specs/solidity/cbor-metadata/plan.md new file mode 100644 index 0000000000..04b601b668 --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/plan.md @@ -0,0 +1,33 @@ +# Implementation plan + +1. Use cborg 6.1.3 (Apache-2.0) as a pinned format decoder, with a bounded + tokenizer at its owning adapter boundary. Decode maps as Map, preserve integer + precision and strictly validate emitted text bytes before conversion. +2. Add the portable metadata-result schema in src/domain/evm/. Use exact byte + ranges into the already retained bytecode rather than copying an opaque CBOR + hex payload into every selected summary. +3. Add src/evm/SolidityMetadata.ts to interpret the terminal length/root-map + candidate. Return unavailable, decoded, partial or not-decoded facts; keep + unsupported nested values explicit instead of recursively expanding them. +4. Compute the facts in EvmoleInterfaceWorker from its selected bytes. The host + provider validates ranges/identity and projects the additive metadata field. + Preserve raw engine output without rewriting its unknown-precision metadata. +5. Ensure the canonical EVM bytecode shape carries the optional field into complete + and summary Evidence. Include decoding profile, coverage and limitations. +6. Add producer, worker, historical contract and public MCP boundary coverage. + Compile an owned Solidity fixture with a pinned caller-supplied solc module, + and verify its CBOR compiler version. Run relevant static/architecture checks. +7. Extend the existing real Linux CLI/MCP verifier with independent metadata + assertions. Run the compiler-setting matrix in its CI lane and classify its + verifier as EVM-owned, with a production CI-planner regression. + +No new execution provider or target authority is introduced: a format parser is +added inside the existing EVM worker. Linux x64 provider admission, target-free +semantics, cleanup, limits and complete original Evidence remain unchanged. +No metadata JSON or source URL is automatically acquired. + +The parser bounds nesting to 64 and token/collection growth to the selected +terminal payload's byte count (the format's terminal length is at most 65535). +Every data item consumes encoded bytes; broad declared collection lengths cannot +justify allocation without that lower bound. Expanded results still pass the +worker's existing 16 MiB output check. diff --git a/docs/specs/solidity/cbor-metadata/spec.md b/docs/specs/solidity/cbor-metadata/spec.md new file mode 100644 index 0000000000..ea08dcc904 --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/spec.md @@ -0,0 +1,43 @@ +# Solidity terminal metadata inspection + +Issue: https://github.com/morluto/rea/issues/2388 + +## Outcome + +The existing EVM interface operation should retain independently decoded terminal +CBOR candidate facts from the exact selected bytes instead of relying on precision +unknowns in the engine's reported metadata. This increment covers trailer ranges, +bounded decoding, exact integer/non-text scalar key evidence and compiler-version +representations. + +## Requirements + +- Preserve the existing carrier/decoded identities and original full bytecode. +- Report a plausible terminal root-map candidate separately from proof of Solidity + origin. Missing or non-map trailers remain unavailable, not proven absent. +- Interpret scalar root-map keys/values losslessly: integers as decimal strings, + bytes as hex, strings, booleans and null. Preserve undecoded/unsupported values + through the original bytecode plus exact candidate offset/length. +- Report partial and not-decoded coverage, parser/profile failures and unsupported + CBOR forms explicitly. Do not silently coerce unknown keys into object properties. +- Interpret admitted solc release/prerelease representations only from observed + entries; preserve ambiguity if duplicate compiler-version keys occur. +- Keep the additive field compatible with historical Evidence and retain + it through selected summary delivery and public CLI/MCP. + Historical omission parses as a null unknown facet without mutating the source. +- Bound nesting before decoder recursion and collection lengths before allocation. + Keep parsing inside the existing owned worker and its resource/output budgets. +- Do not fetch metadata or source content, contact RPC or execute contract code. + +## Acceptance and limits + +Owned compiler fixtures and malformed/synthetic trailers cover integers beyond +Number precision, non-text keys, nested/uninterpreted values, invalid UTF-8, +invalid lengths, unsupported tags and nesting bounds. Public boundary tests +preserve the field and older omission. A real pinned solc artifact verifies actual +metadata facts; the existing Linux-only EVM host profile remains unchanged. + +## Deferred issue criteria + +Binding a supplied metadata JSON to IPFS/Swarm commitments is a later independent +increment of #2388. This PR must reference rather than close the whole issue. diff --git a/docs/specs/solidity/cbor-metadata/tasks.md b/docs/specs/solidity/cbor-metadata/tasks.md new file mode 100644 index 0000000000..e80aefa01d --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Define single-feature requirements and implementation plan before code. +- [x] Add the pinned decoder and bounded owner boundary. +- [x] Implement lossless scalar metadata facts and compiler-version evidence. +- [x] Integrate worker/provider contracts and summary/historical compatibility. +- [x] Verify producer, worker and public boundary behavior with owned fixtures. +- [x] Run relevant repository checks and prepare a separately scoped PR. diff --git a/docs/specs/solidity/cbor-metadata/verification.md b/docs/specs/solidity/cbor-metadata/verification.md new file mode 100644 index 0000000000..dae558d945 --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/verification.md @@ -0,0 +1,43 @@ +# Verification + +After integrating upstream c462a4ebd, 47 tests passed +across the parser, domain contract, application service, compiled worker and MCP +boundary (five files). Compiled-worker goldens include BOM text, partial nested +values, unavailable trailers, malformed UTF-8 and unsupported floats. Five +upstream adapter tests also passed after its stricter integer validation landed. +All six static tasks and architecture guards +passed. After integration, test discovery found all 1004 files exactly once. +The CI-planner selection passed all 85 tests, including the new EVM-verifier case. + +The parser regressions cover exact large integers and typed keys, malformed UTF-8, +duplicate keys, excessive nesting, unsupported tags/floats, length bounds and +historical omission. A real defect in the decoder's default text handling removed +an initial U+FEFF. The owning tokenizer now validates and decodes the retained +text bytes with BOM preservation; independent fixtures check both values and +compiler-key identity. + +The owned fixture was compiled with caller-selected solc 0.8.30, using unoptimized, +optimized via-IR, content-hash-free and CBOR-free settings. All four metadata +checks passed again after integration. No contract was executed. Local first decode +took approximately 0.81 ms or less. The originally reported approximately 2.92 ms +for 100 warm iterations included equality assertions and is not a decode-only +measurement. The verifier now measures parsing separately from those assertions; +updated warm timings have not been collected. These historical figures describe +these fixtures and this host, not a general workload. +Peak process RSS +included the compiler (299536 KiB); complete process-family CPU/memory and agent +token costs were not measured. + +Compiled worker tests exercise unchanged EVMole WASM on this macOS host. MCP tests +use the production server and contracts with an injected provider seam. The full +Linux-only provider CLI/MCP, resource-limit and cleanup lane remains unverified +here. Metadata JSON commitment binding remains deferred under issue #2388. + +The Linux CLI/MCP verifier asserts exact compiler bytes, declared version and +candidate ranges for its existing owned raw/hex artifacts. Its CI lane additionally +runs the new compiler-setting matrix. A regression using the actual CI planner +checks that edits to the new verifier select the EVM lane. + +The later merge/review correction adds decoded-summary and omitted historical +metadata coverage. Tests and provider reruns for that correction were skipped at +the maintainer's request; the earlier runs above do not validate the changed head. diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index ce45f74594..05fc2f1734 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 586; runtime: 174. +Locked package entries: 587; runtime: 175. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -1430,6 +1430,11 @@ canonicalize@3.0.0 | runtime | Apache-2.0 Package: https://www.npmjs.com/package/canonicalize/v/3.0.0 Archive: https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz Integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA== +cborg@6.1.3 | runtime | Apache-2.0 + Lockfile path: node_modules/cborg + Package: https://www.npmjs.com/package/cborg/v/6.1.3 + Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz + Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ== ccount@2.0.1 | development | MIT Lockfile path: node_modules/ccount Package: https://www.npmjs.com/package/ccount/v/2.0.1 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index d981aa68a3..2a0c45d24e 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 5de9cf97e7c327da1892efeff30b40507e4c7f8e496b5d305b8016c162bc15e3 +Runtime dependency fingerprint (SHA-256): 63d5b55e027a0cfb3e77989ca337aca905f1652d10cfbfc4d4dd876d3ecd4f3a ===== @babel/helper-string-parser@8.0.0 ===== Declared license: MIT @@ -2456,6 +2456,234 @@ Scope: npm runtime dependency, including optional platform packages. limitations under the License. +===== cborg@6.1.3 ===== +Declared license: Apache-2.0 +Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz +Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ== +Scope: npm runtime dependency, including optional platform packages. + +--- package/LICENSE --- + +Copyright 2020 Rod Vagg + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- Apache-2.0 full license (licenses/Apache-2.0.txt) --- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ===== clean-git-ref@2.0.1 ===== Declared license: Apache-2.0 Archive: https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz diff --git a/package-lock.json b/package-lock.json index 5af71e98e1..7eae3799e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "ajv": "8.20.0", "ajv-formats": "3.0.1", "canonicalize": "3.0.0", + "cborg": "6.1.3", "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", @@ -4181,6 +4182,15 @@ "node": ">=18" } }, + "node_modules/cborg": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz", + "integrity": "sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==", + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", diff --git a/package.json b/package.json index 7ee2b42ab5..bc3492d2f2 100644 --- a/package.json +++ b/package.json @@ -270,6 +270,7 @@ "verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs", "verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs", "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs", + "verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs", "verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs", "mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check", "compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/bundle-runtime.mjs", @@ -309,6 +310,7 @@ "ajv": "8.20.0", "ajv-formats": "3.0.1", "canonicalize": "3.0.0", + "cborg": "6.1.3", "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index ffe15cb3d7..e8af46d3b8 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -218,6 +218,7 @@ export const SCOPES = { "src/artifacts/readStableArtifact*.ts", "src/artifacts/ArtifactReader.ts", "scripts/verify-evm-interface.mjs", + "scripts/verify-solidity-metadata.mjs", "scripts/fixtures/evm-interface.sol", "third_party/evmole/**", ], diff --git a/scripts/verify-evm-interface.mjs b/scripts/verify-evm-interface.mjs index b0bfdafa4a..4bacc4bdfd 100644 --- a/scripts/verify-evm-interface.mjs +++ b/scripts/verify-evm-interface.mjs @@ -225,6 +225,26 @@ try { for (const mode of ["cli", "mcp"]) { const report = await inspect(mode, path, encoding); assert.equal(report.bytecode.hex, hex); + assert.equal(report.bytecode.metadata.status, "decoded"); + assert.deepEqual(report.bytecode.metadata.compiler_version, { + representation: "release-bytes", + value: "0.8.30", + }); + assert.equal( + report.bytecode.metadata.bytecode_offset + + report.bytecode.metadata.cbor_bytes + + 2, + Buffer.from(hex, "hex").length, + ); + assert.ok( + report.bytecode.metadata.entries.some( + ({ key, value }) => + key.kind === "text" && + key.value === "solc" && + value.kind === "bytes" && + value.value === "00081e", + ), + ); assert.deepEqual( report.functions .filter(({ dispatch }) => dispatch === "abi") diff --git a/scripts/verify-solidity-metadata.mjs b/scripts/verify-solidity-metadata.mjs new file mode 100644 index 0000000000..f927477d8b --- /dev/null +++ b/scripts/verify-solidity-metadata.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { isAbsolute } from "node:path"; +import { pathToFileURL } from "node:url"; +import { performance } from "node:perf_hooks"; +import { inspectSolidityMetadata } from "../dist/evm/SolidityMetadata.js"; + +const compilerModule = process.env.REA_VERIFY_SOLC_MODULE; +if (!isAbsolute(compilerModule ?? "")) + throw new Error( + "Select an absolute caller-installed solc 0.8.30 module with REA_VERIFY_SOLC_MODULE.", + ); +const solc = (await import(pathToFileURL(compilerModule).href)).default; +assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang"); +const source = await readFile( + new URL("./fixtures/evm-interface.sol", import.meta.url), + "utf8", +); +const cases = [ + { name: "unoptimized", optimizer: false, viaIR: false, metadata: {} }, + { name: "viaIR", optimizer: true, viaIR: true, metadata: {} }, + { + name: "without-content-hash", + optimizer: false, + viaIR: false, + metadata: { bytecodeHash: "none" }, + }, + { + name: "without-CBOR", + optimizer: false, + viaIR: false, + metadata: { appendCBOR: false }, + }, +]; +const reports = []; +for (const fixture of cases) { + const compiled = JSON.parse( + solc.compile( + JSON.stringify({ + language: "Solidity", + sources: { "fixture.sol": { content: source } }, + settings: { + optimizer: { enabled: fixture.optimizer }, + viaIR: fixture.viaIR, + metadata: fixture.metadata, + outputSelection: { "*": { "*": ["evm.deployedBytecode.object"] } }, + }, + }), + ), + ); + assert.ok( + !(compiled.errors ?? []).some((error) => error.severity === "error"), + JSON.stringify(compiled.errors), + ); + const bytes = Buffer.from( + compiled.contracts["fixture.sol"].InterfaceFixture.evm.deployedBytecode + .object, + "hex", + ); + const cpu = process.cpuUsage(); + const started = performance.now(); + const parsed = inspectSolidityMetadata(bytes); + const firstMs = performance.now() - started; + const firstCpu = process.cpuUsage(cpu); + if (fixture.metadata.appendCBOR === false) + assert.equal(parsed.status, "unavailable"); + else { + assert.equal(parsed.status, "decoded"); + assert.deepEqual(parsed.compiler_version, { + representation: "release-bytes", + value: "0.8.30", + }); + assert.equal(parsed.bytecode_offset + parsed.cbor_bytes + 2, bytes.length); + if (fixture.metadata.bytecodeHash === "none") + assert.deepEqual( + parsed.entries.map(({ key }) => key), + [{ kind: "text", value: "solc" }], + ); + } + const warmResults = new Array(100); + const warmStart = performance.now(); + for (let index = 0; index < warmResults.length; index++) + warmResults[index] = inspectSolidityMetadata(bytes); + const warmMs = performance.now() - warmStart; + for (const result of warmResults) assert.deepEqual(result, parsed); + reports.push({ + fixture: fixture.name, + bytes: bytes.length, + status: parsed.status, + first_decode_ms: firstMs, + first_decode_cpu_us: firstCpu, + warm_100_decode_ms: warmMs, + }); +} +console.log( + JSON.stringify({ + ok: true, + compiler_version: solc.version(), + contract_execution: "not-performed", + cases: reports, + process_peak_rss_kib: process.resourceUsage().maxRSS, + measurements: + "In-process decode timings; peak RSS includes the compiler. End-to-end Linux provider/CLI costs and agent tokens were not measured.", + }), +); diff --git a/src/domain/evm/evmInterface.test.ts b/src/domain/evm/evmInterface.test.ts index 93ab32adde..1b2a36de68 100644 --- a/src/domain/evm/evmInterface.test.ts +++ b/src/domain/evm/evmInterface.test.ts @@ -2,6 +2,14 @@ import { expect, it } from "vitest"; import { evmInterfaceSchema } from "./evmInterface.js"; import { evmInterfaceFixture } from "../../../tests/fixtures/evm/interface.js"; +it("accepts historical metadata omission as an unknown facet without changing the source", () => { + const value = evmInterfaceFixture(); + const { metadata: _metadata, ...oldBytecode } = value.bytecode; + const old = { ...value, bytecode: oldBytecode }; + expect(evmInterfaceSchema.parse(old).bytecode.metadata).toBeNull(); + expect(old.bytecode).not.toHaveProperty("metadata"); +}); + it.each(["byte-count", "body-offset", "selector", "authenticity"])( "rejects false interface evidence: %s", (problem) => { diff --git a/src/domain/evm/evmInterface.ts b/src/domain/evm/evmInterface.ts index 6db3f7e161..bde6ecba7c 100644 --- a/src/domain/evm/evmInterface.ts +++ b/src/domain/evm/evmInterface.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; +import { solidityMetadataSchema } from "./solidityMetadata.js"; const byteOffset = z.number().int().nonnegative(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/); @@ -34,6 +35,7 @@ export const evmInterfaceSchema = z kind: z.literal("unknown"), deployment_authenticity: z.literal("unknown"), hardfork: z.literal("unknown"), + metadata: solidityMetadataSchema.nullable().default(null), }), evidence_kind: z.literal("inferred"), functions: z.array( @@ -62,6 +64,23 @@ export const evmInterfaceSchema = z message: "Selected byte count does not match the bytecode hex representation.", }); + const metadata = value.bytecode.metadata; + if ( + metadata !== null && + metadata.status !== "unavailable" && + (metadata.bytecode_offset === null || + metadata.cbor_bytes === null || + metadata.bytecode_offset + metadata.cbor_bytes + 2 !== + value.bytecode.bytes || + metadata.cbor_bytes !== + Number.parseInt(value.bytecode.hex.slice(-4), 16)) + ) + context.addIssue({ + code: "custom", + path: ["bytecode", "metadata"], + message: + "Metadata range must match the terminal length suffix in selected bytecode.", + }); for (const [index, fn] of value.functions.entries()) if (fn.bytecode_offset >= value.bytecode.bytes) context.addIssue({ diff --git a/src/domain/evm/solidityMetadata.ts b/src/domain/evm/solidityMetadata.ts new file mode 100644 index 0000000000..28cdee95f7 --- /dev/null +++ b/src/domain/evm/solidityMetadata.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +/** Scalar view of CBOR; uninterpreted containers remain in original bytecode. */ +export const solidityMetadataScalarSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("text"), value: z.string().max(65535) }), + z.strictObject({ + kind: z.literal("integer"), + value: z + .string() + .max(21) + .regex(/^-?(?:0|[1-9][0-9]*)$/), + }), + z.strictObject({ + kind: z.literal("bytes"), + value: z + .string() + .max(131070) + .regex(/^(?:[a-f0-9]{2})*$/), + }), + z.strictObject({ kind: z.literal("boolean"), value: z.boolean() }), + z.strictObject({ kind: z.literal("null") }), + z.strictObject({ + kind: z.literal("uninterpreted"), + container: z.enum(["array", "map"]), + }), +]); + +/** A trailer candidate, not authentication of Solidity origin or deployment. */ +export const solidityMetadataSchema = z + .strictObject({ + profile: z.literal("terminal-cbor-scalars-v1"), + status: z.enum(["unavailable", "decoded", "partial", "not-decoded"]), + bytecode_offset: z.number().int().nonnegative().nullable(), + cbor_bytes: z.number().int().positive().max(65535).nullable(), + entries: z + .array( + z.strictObject({ + key: solidityMetadataScalarSchema, + value: solidityMetadataScalarSchema, + }), + ) + .max(32767), + compiler_version: z + .strictObject({ + representation: z.enum(["release-bytes", "prerelease-text"]), + value: z.string().min(1).max(65535), + }) + .nullable(), + diagnostic: z.string().max(512).nullable(), + }) + .superRefine((value, context) => { + const unavailable = value.status === "unavailable"; + if ( + unavailable + ? value.bytecode_offset !== null || value.cbor_bytes !== null + : value.bytecode_offset === null || value.cbor_bytes === null + ) + context.addIssue({ + code: "custom", + message: "Only unavailable metadata omits the candidate range.", + }); + const unexamined = unavailable || value.status === "not-decoded"; + if ( + unexamined && + (value.entries.length !== 0 || value.compiler_version !== null) + ) + context.addIssue({ + code: "custom", + message: + "Unavailable interpretation cannot report decoded entries or compiler identity.", + }); + const uninterpreted = value.entries.some( + ({ key, value: entryValue }) => + key.kind === "uninterpreted" || entryValue.kind === "uninterpreted", + ); + if ( + (value.status === "decoded" && uninterpreted) || + (value.status === "partial" && !uninterpreted) + ) + context.addIssue({ + code: "custom", + message: "Scalar coverage must preserve uninterpreted containers.", + }); + }); + +export type SolidityMetadata = z.infer; +export type SolidityMetadataScalar = z.infer< + typeof solidityMetadataScalarSchema +>; diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index c59427e6ac..cbcb316155 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -30,6 +30,7 @@ import { import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { InspectEvmInterfaceInput } from "../domain/evm/evmInterface.js"; import { evmInterfaceSchema } from "../domain/evm/evmInterface.js"; +import { solidityMetadataSchema } from "../domain/evm/solidityMetadata.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -67,6 +68,7 @@ const replySchema = z.discriminatedUnion("ok", [ sha256: z.string().regex(/^[a-f0-9]{64}$/), bytes: z.number().int().nonnegative(), hex: z.string().regex(/^(?:[a-f0-9]{2})*$/), + metadata: solidityMetadataSchema.optional(), }), raw: evmoleInterfaceReplySchema, }), @@ -331,6 +333,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { "Recovered selectors, argument strings and mutability are upstream inferences, not verified ABI signatures or names. Empty results do not prove the absence of callable dispatch.", "Bytecode kind, hardfork and deployed-contract authenticity are unknown. This inspection performs no target runtime execution, chain/RPC lookup or network request.", "Original carrier and decoded bytecode SHA-256 are distinct identities; SHA-256 is not the Ethereum Keccak code hash.", + "Terminal CBOR metadata is an independently decoded candidate, not proof of Solidity origin, source identity or deployment. Nested or unsupported values retain explicit unavailable interpretation and original byte ranges.", "The owned worker has a 256 MiB JavaScript heap, 3 GiB virtual address-space limit and 30-second deadline. Virtual address space is distinct from resident memory; limits fail without partial success.", ]; const validated = evmInterfaceSchema.safeParse({ diff --git a/src/evm/EvmoleInterfaceWorker.ts b/src/evm/EvmoleInterfaceWorker.ts index 061ceb47e7..17e50f7bea 100644 --- a/src/evm/EvmoleInterfaceWorker.ts +++ b/src/evm/EvmoleInterfaceWorker.ts @@ -6,6 +6,7 @@ import { EvmCarrierFailure, } from "./EvmBytecodeCarrier.js"; import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js"; +import { inspectSolidityMetadata } from "./SolidityMetadata.js"; import { EVM_INTERFACE_LIMITS, EVM_FILE_SIZE_FAILURE_EXIT, @@ -64,6 +65,7 @@ const main = async (): Promise => { sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length, hex: Buffer.from(bytes).toString("hex"), + metadata: inspectSolidityMetadata(bytes), }, raw: inferred.raw, }; diff --git a/src/evm/SolidityMetadata.test.ts b/src/evm/SolidityMetadata.test.ts new file mode 100644 index 0000000000..1ca056f186 --- /dev/null +++ b/src/evm/SolidityMetadata.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; +import { inspectSolidityMetadata } from "./SolidityMetadata.js"; + +const bytecode = (hex: string) => { + const payload = Buffer.from(hex, "hex"); + const length = Buffer.alloc(2); + length.writeUInt16BE(payload.length); + return Buffer.concat([Buffer.from("00", "hex"), payload, length]); +}; + +it("reads a compiler version from actual trailer bytes without a magic-prefix assumption", () => { + const result = inspectSolidityMetadata(bytecode("a164736f6c634300081e")); + expect(result).toMatchObject({ + status: "decoded", + bytecode_offset: 1, + cbor_bytes: 10, + compiler_version: { representation: "release-bytes", value: "0.8.30" }, + entries: [ + { + key: { kind: "text", value: "solc" }, + value: { kind: "bytes", value: "00081e" }, + }, + ], + }); +}); + +it("preserves integer and non-text key precision beyond Number", () => { + expect( + inspectSolidityMetadata(bytecode("a1011bffffffffffffffff")).entries, + ).toEqual([ + { + key: { kind: "integer", value: "1" }, + value: { kind: "integer", value: "18446744073709551615" }, + }, + ]); + expect( + inspectSolidityMetadata(bytecode("a1003bffffffffffffffff")).entries[0] + ?.value, + ).toEqual({ + kind: "integer", + value: "-18446744073709551616", + }); +}); + +it("retains opaque nested values as partial rather than fabricating scalar values", () => { + expect(inspectSolidityMetadata(bytecode("a161788100"))).toMatchObject({ + status: "partial", + entries: [ + { + key: { kind: "text", value: "x" }, + value: { kind: "uninterpreted", container: "array" }, + }, + ], + }); +}); + +it("preserves a UTF-8 BOM inside a CBOR text value rather than stripping it", () => { + expect( + inspectSolidityMetadata(bytecode("a1617864efbbbf61")).entries[0]?.value, + ).toEqual({ + kind: "text", + value: "\ufeffa", + }); + expect( + inspectSolidityMetadata(bytecode("a167efbbbf736f6c634300081e")) + .compiler_version, + ).toBeNull(); +}); + +it.each([ + ["invalid text", "a1617861ff"], + ["truncated value", "a16178"], + ["unsupported tag", "a16178c000"], + ["floating point", "a16178fa3f800000"], + ["duplicate key", "a2617801617802"], + ["excessive nesting", "a16178" + "81".repeat(65) + "00"], + ["impossible collection", "a161788a00"], +])("keeps exact ranges and unavailable interpretation for %s", (_name, hex) => { + expect(inspectSolidityMetadata(bytecode(hex))).toMatchObject({ + status: "not-decoded", + bytecode_offset: 1, + entries: [], + compiler_version: null, + }); +}); + +it.each(["", "60006000f3", "000000", "00010001"])( + "does not claim absent Solidity metadata for an unavailable trailer: %s", + (hex) => + expect(inspectSolidityMetadata(Buffer.from(hex, "hex"))).toMatchObject({ + status: "unavailable", + bytecode_offset: null, + cbor_bytes: null, + entries: [], + }), +); diff --git a/src/evm/SolidityMetadata.ts b/src/evm/SolidityMetadata.ts new file mode 100644 index 0000000000..5b24ce2453 --- /dev/null +++ b/src/evm/SolidityMetadata.ts @@ -0,0 +1,158 @@ +import { decode, Tokenizer, Type, type Token } from "cborg"; +import type { + SolidityMetadata, + SolidityMetadataScalar, +} from "../domain/evm/solidityMetadata.js"; + +const PROFILE = "terminal-cbor-scalars-v1"; +const MAX_DEPTH = 64; +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const decodeOptions = { + useMaps: true, + allowBigInt: true, + allowIndefinite: false, + allowUndefined: false, + allowInfinity: false, + allowNaN: false, + rejectDuplicateMapKeys: true, + retainStringBytes: true, +} as const; + +/** Bound recursive decoder entry before collection interpretation/allocation. */ +class BoundedMetadataTokenizer extends Tokenizer { + readonly remaining: number[] = []; + constructor(bytes: Uint8Array) { + super(bytes, decodeOptions); + } + + override next(): Token { + while (this.remaining.at(-1) === 0) this.remaining.pop(); + const token = super.next(); + if ( + Type.equals(token.type, Type.tag) || + Type.equals(token.type, Type.float) + ) + throw new Error( + "CBOR tags and floating-point values are outside this metadata profile.", + ); + if (Type.equals(token.type, Type.string)) { + if (token.byteValue === undefined) + throw new Error( + "CBOR text bytes were unavailable for strict UTF-8 validation.", + ); + token.value = utf8.decode(token.byteValue); + } + const parent = this.remaining.length - 1; + if (parent >= 0) this.remaining[parent]!--; + if ( + Type.equals(token.type, Type.map) || + Type.equals(token.type, Type.array) + ) { + const count: unknown = token.value; + const children = + typeof count === "number" + ? count * (Type.equals(token.type, Type.map) ? 2 : 1) + : NaN; + if ( + !Number.isSafeInteger(children) || + children < 0 || + children > this.data.length + ) + throw new Error( + "Declared CBOR collection exceeds the encoded item-byte bound.", + ); + if (this.remaining.length >= MAX_DEPTH) + throw new Error("CBOR nesting exceeds the 64-level metadata profile."); + this.remaining.push(children); + } + return token; + } +} + +const scalar = (value: unknown): SolidityMetadataScalar => { + if (typeof value === "string") return { kind: "text", value }; + if (typeof value === "bigint") + return { kind: "integer", value: value.toString() }; + if (typeof value === "number" && Number.isSafeInteger(value)) + return { kind: "integer", value: String(value) }; + if (typeof value === "boolean") return { kind: "boolean", value }; + if (value === null) return { kind: "null" }; + if (value instanceof Uint8Array) + return { kind: "bytes", value: Buffer.from(value).toString("hex") }; + if (value instanceof Map) return { kind: "uninterpreted", container: "map" }; + if (Array.isArray(value)) + return { kind: "uninterpreted", container: "array" }; + throw new Error( + "CBOR decoder returned a value outside the admitted profile.", + ); +}; + +/** Parse only a plausible terminal root-map candidate; never establish absence. */ +export const inspectSolidityMetadata = ( + bytes: Uint8Array, +): SolidityMetadata => { + const empty: SolidityMetadata = { + profile: PROFILE, + status: "unavailable", + bytecode_offset: null, + cbor_bytes: null, + entries: [], + compiler_version: null, + diagnostic: + "No plausible terminal CBOR map was identified; Solidity origin and metadata absence remain unknown.", + }; + if (bytes.length < 3) return empty; + const length = bytes[bytes.length - 2]! * 256 + bytes[bytes.length - 1]!; + const offset = bytes.length - 2 - length; + if (length === 0 || offset < 0 || bytes[offset]! >> 5 !== 5) return empty; + const selected = bytes.subarray(offset, bytes.length - 2); + const result: SolidityMetadata = { + ...empty, + status: "not-decoded", + bytecode_offset: offset, + cbor_bytes: length, + }; + try { + const raw: unknown = decode(selected, { + ...decodeOptions, + tokenizer: new BoundedMetadataTokenizer(selected), + }); + if (!(raw instanceof Map)) + throw new Error("Terminal CBOR candidate did not decode to a root map."); + for (const [key, value] of raw as Map) + result.entries.push({ key: scalar(key), value: scalar(value) }); + result.status = result.entries.some( + ({ key, value }) => + key.kind === "uninterpreted" || value.kind === "uninterpreted", + ) + ? "partial" + : "decoded"; + result.diagnostic = + result.status === "partial" + ? "Nested containers are uninterpreted; their exact bytes remain at the recorded range in original bytecode." + : null; + const version: unknown = raw.get("solc"); + if (version instanceof Uint8Array && version.length === 3) + result.compiler_version = { + representation: "release-bytes", + value: [...version].join("."), + }; + else if (typeof version === "string" && version.length > 0) + result.compiler_version = { + representation: "prerelease-text", + value: version, + }; + return result; + } catch (cause: unknown) { + return { + ...result, + status: "not-decoded", + entries: [], + compiler_version: null, + diagnostic: (cause instanceof Error + ? cause.message + : String(cause) + ).slice(0, 512), + }; + } +}; diff --git a/tests/boundary/evm/solidityMetadataWorker.test.ts b/tests/boundary/evm/solidityMetadataWorker.test.ts new file mode 100644 index 0000000000..0577983c45 --- /dev/null +++ b/tests/boundary/evm/solidityMetadataWorker.test.ts @@ -0,0 +1,75 @@ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { solidityMetadataSchema } from "../../../src/domain/evm/solidityMetadata.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +/** Real compiled worker and unchanged WASM engine; no Linux provider-admission claim. */ +it.each([ + ["compiler", "00a164736f6c634300081e000a", "decoded"], + ["integer", "00a1011bffffffffffffffff000b", "decoded"], + ["BOM text", "00a1617864efbbbf610008", "decoded"], + ["nested value", "00a1617881000005", "partial"], + ["unavailable trailer", "00", "unavailable"], + ["unsupported tag", "00a16178c0000005", "not-decoded"], + ["floating point", "00a16178fa3f8000000008", "not-decoded"], + ["invalid UTF-8", "00a1617861ff0005", "not-decoded"], +])( + "retains independent %s metadata through the actual worker", + async (kind, hex, status) => { + const directory = await createTestTempDirectory("rea-solidity-worker-"); + const snapshot = join(directory, "selected.hex"); + const reply = join(directory, "reply.json"); + const request = join(directory, "request.json"); + await writeFile(snapshot, hex); + await writeFile( + request, + JSON.stringify({ + snapshot_path: snapshot, + reply_path: reply, + failure_marker_path: join(directory, "resource.failure"), + encoding: "hex", + }), + ); + await promisify(execFile)( + process.execPath, + [ + "--max-old-space-size=256", + "--disable-wasm-trap-handler", + new URL("../../../dist/evm/EvmoleInterfaceWorker.js", import.meta.url) + .pathname, + request, + ], + { timeout: 30_000, maxBuffer: 1024 * 1024 }, + ); + const value = JSON.parse(await readFile(reply, "utf8")); + expect(value.ok, JSON.stringify(value)).toBe(true); + expect(value.bytecode.hex).toBe(hex); + const metadata = solidityMetadataSchema.parse(value.bytecode.metadata); + expect(metadata.status).toBe(status); + if (kind === "integer") + expect(metadata.entries[0]?.value).toEqual({ + kind: "integer", + value: "18446744073709551615", + }); + if (kind === "compiler") + expect(metadata.compiler_version?.value).toBe("0.8.30"); + if (kind === "BOM text") + expect(metadata.entries[0]?.value).toEqual({ + kind: "text", + value: "\ufeffa", + }); + if (kind === "nested value") + expect(metadata.entries[0]?.value).toEqual({ + kind: "uninterpreted", + container: "array", + }); + if (kind === "unavailable trailer") { + expect(metadata.bytecode_offset).toBeNull(); + expect(metadata.entries).toEqual([]); + expect(metadata.compiler_version).toBeNull(); + } + }, +); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 321160a364..0ba0437210 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -449,6 +449,10 @@ it.each([ sqlite: false, }, ], + [ + "scripts/verify-solidity-metadata.mjs", + { code: true, evm: true, browser: false, package: false }, + ], [ "src/browser/execution/evaluate.ts", { diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index 823a4d3cf0..60f493b2c6 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -1,4 +1,5 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { createHash } from "node:crypto"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished, vi } from "vitest"; import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js"; @@ -10,6 +11,7 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { evmInterfaceSummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { inspectSolidityMetadata } from "../../../src/evm/SolidityMetadata.js"; import { err, ok } from "../../../src/domain/result.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; @@ -26,8 +28,21 @@ import { evmInterfaceExecution, } from "../../fixtures/evm/interface.js"; -it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => { +const decodedMetadataFixture = () => { + const bytes = Buffer.from("00a1011bffffffffffffffff000b", "hex"); const value = evmInterfaceFixture(); + value.bytecode = { + ...value.bytecode, + hex: bytes.toString("hex"), + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + metadata: inspectSolidityMetadata(bytes), + }; + return value; +}; + +it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => { + const value = decodedMetadataFixture(); const inspect = vi.fn(() => Promise.resolve(ok(evmInterfaceExecution(value))), ); @@ -280,7 +295,8 @@ it("settles the admitted EVM operation after SDK cancellation and accepts a subs }); it("returns the exact inferred interface summary while preserving complete defaults and retained source", async () => { - const value = evmInterfaceFixture(); + const value = decodedMetadataFixture(); + expect(value.bytecode.metadata?.status).toBe("decoded"); value.functions = [ { selector: "0x11223344", @@ -419,6 +435,7 @@ it.each(["valid", "wrong-subject", "no-subject"])( "preserves historical portable EVM Evidence and rejects incompatible identity: %s", async (scenario) => { const value = evmInterfaceFixture(); + const { metadata: _metadata, ...historicalBytecode } = value.bytecode; const source = createEvidence( scenario === "no-subject" ? undefined @@ -434,7 +451,7 @@ it.each(["valid", "wrong-subject", "no-subject"])( { operation: "inspect_evm_interface", parameters: { path: value.artifact.path, encoding: "hex" }, - result: value, + result: { ...value, bytecode: historicalBytecode }, confidence: "inferred", rawResult: { original: true }, }, @@ -463,6 +480,7 @@ it.each(["valid", "wrong-subject", "no-subject"])( expect(view.parent_evidence_id).toBe(source.evidence_id); expect(view.summary.functions).toEqual([]); expect(view.summary.discovery_completeness).toBe("unknown"); + expect(view.summary.bytecode.metadata).toBeNull(); expect(view.coverage).toMatchObject({ total: 0, examined: 0, diff --git a/tests/fixtures/evm/interface.ts b/tests/fixtures/evm/interface.ts index f0f1960bae..0c876c37bf 100644 --- a/tests/fixtures/evm/interface.ts +++ b/tests/fixtures/evm/interface.ts @@ -18,6 +18,7 @@ export const evmInterfaceFixture = ( kind: "unknown", deployment_authenticity: "unknown", hardfork: "unknown", + metadata: null, }, evidence_kind: "inferred", functions: [],