diff --git a/REUSE.toml b/REUSE.toml index 31076b3f..ffe8d0e8 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -22,6 +22,7 @@ path = [ "soldeer.lock", ".soldeerignore", ".prettierignore", + "test/fixtures/**/", ] SPDX-FileCopyrightText = "Copyright (c) 2020 Rain Open Source Software Ltd" SPDX-License-Identifier = "LicenseRef-DCL-1.0" diff --git a/crates/cli/examples/emit-calldata-fixture.rs b/crates/cli/examples/emit-calldata-fixture.rs new file mode 100644 index 00000000..868b4d8d --- /dev/null +++ b/crates/cli/examples/emit-calldata-fixture.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +//! Writes the `emitMeta` calldata the rust encoders produce to +//! `test/fixtures/emit-calldata.json`, for `test/lib/EmitCalldataFixture.t.sol` +//! to send to a real metaboard. +//! +//! That seam is what neither half tested: rust builds the calldata, solidity +//! decides whether it is acceptable, and each was only ever checked against its +//! own idea of the bytes. `generate_emit_meta_calldata` built a bare cbor map +//! that `LibMeta.checkMetaUnhashedV1` reverts `NotRainMetaV1` on, with both +//! suites green. +//! +//! `script/build.sh` runs this and git-clean diffs the result, so a producer +//! that changes without its fixture being committed turns the lane red. That is +//! the same treatment the committed abis get from `CopyArtifacts.sol`. + +use std::{fs, path::PathBuf}; + +use rain_metadata::{ + ContentEncoding, ContentLanguage, ContentType, KnownMagic, RainMetaDocumentV1Item, + generate_dotrain_source_emit_tx_data, generate_emit_meta_calldata, +}; + +/// A meta item with every optional field defaulted, so the fixture pins the +/// shortest encoding rather than an unusually decorated one. +fn plain_item(content: &str) -> RainMetaDocumentV1Item { + RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(content.as_bytes().to_vec()), + magic: KnownMagic::DotrainSourceV1, + content_type: ContentType::OctetStream, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + schema: None, + } +} + +fn main() { + let mut cases = serde_json::Map::new(); + + let generic = generate_emit_meta_calldata(plain_item("emit calldata fixture")).unwrap(); + cases.insert( + "generic_item".to_string(), + alloy::hex::encode_prefixed(generic).into(), + ); + + let dotrain = generate_dotrain_source_emit_tx_data("#main _ _: int-add(1 2);").unwrap(); + cases.insert("dotrain_source".to_string(), dotrain.calldata.into()); + + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../test/fixtures/emit-calldata.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::Value::Object(cases)).unwrap() + "\n", + ) + .unwrap(); +} diff --git a/crates/cli/src/metaboard.rs b/crates/cli/src/metaboard.rs index d2fbfa68..ef17945a 100644 --- a/crates/cli/src/metaboard.rs +++ b/crates/cli/src/metaboard.rs @@ -16,9 +16,17 @@ fn generate_emit_data_calldata(subject: FixedBytes<32>, data: Vec) -> Vec Result, Error> { - let meta_bytes = meta.cbor_encode()?; let hash = meta.hash(false)?; + let meta_bytes = RainMetaDocumentV1Item::cbor_encode_seq( + &vec![meta], + crate::KnownMagic::RainMetaDocumentV1, + )?; Ok(generate_emit_data_calldata(hash.into(), meta_bytes)) } @@ -103,12 +111,37 @@ mod tests { // Decode and verify structure let decoded = emitMetaCall::abi_decode(&calldata).unwrap(); let expected_hash = meta.hash(false).unwrap(); - let expected_meta_bytes = meta.cbor_encode().unwrap(); + let expected_meta_bytes = + RainMetaDocumentV1Item::cbor_encode_seq(&vec![meta], KnownMagic::RainMetaDocumentV1) + .unwrap(); assert_eq!(decoded.subject, FixedBytes::from(expected_hash)); assert_eq!(decoded.meta.as_ref(), expected_meta_bytes.as_slice()); } + /// `IMetaBoardV1_2.emitMeta` reverts `NotRainMetaV1` on anything that is + /// not magic-prefixed (`LibMeta.checkMetaUnhashedV1`), so calldata this + /// function builds is only submittable if the meta carries the prefix. + #[test] + fn test_emit_meta_bytes_are_a_rain_meta_document() { + let meta = create_test_meta("test content"); + let calldata = generate_emit_meta_calldata(meta.clone()).unwrap(); + let emitted = emitMetaCall::abi_decode(&calldata).unwrap().meta; + + // The magic number as the eight wire bytes, independent of KnownMagic. + assert_eq!(hex::encode(&emitted[..8]), "ff0a89c674ee7874"); + + // Prefix and nothing else: the item still round-trips out of it. + let decoded = RainMetaDocumentV1Item::cbor_decode(&emitted).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0], meta); + + // The bare item map is what the subject is over, not what is emitted. + let bare = meta.cbor_encode().unwrap(); + assert_ne!(emitted.as_ref(), bare.as_slice()); + assert_eq!(&emitted[8..], bare.as_slice()); + } + #[test] fn test_validate_dotrain_content() { // Valid content diff --git a/foundry.toml b/foundry.toml index 88f6313a..375e0201 100644 --- a/foundry.toml +++ b/foundry.toml @@ -29,6 +29,7 @@ libs = ["dependencies"] # `git` over `vm.ffi`, which this list does not gate. fs_permissions = [ { access = "read", path = "out/" }, + { access = "read", path = "test/fixtures/" }, { access = "read-write", path = "crates/bindings/abi/" }, ] diff --git a/script/build.sh b/script/build.sh new file mode 100755 index 00000000..c5921353 --- /dev/null +++ b/script/build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-DCL-1.0 +# SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +# Regenerate derived artifacts that forge cannot produce. +# +# The `rainix-copy-artifacts` workflow runs this after its forge steps and then +# asserts `git diff --exit-code`, so anything written here is held to the same +# freshness bar as the committed abis: change a producer without committing what +# it now produces and git-clean goes red. +# +# Runs outside any nix devshell, so each command picks its own. +set -euo pipefail + +# The `emitMeta` calldata the rust encoders produce, consumed by +# `test/lib/EmitCalldataFixture.t.sol`, which sends it to a real metaboard. The +# fixture is how the solidity half gets to judge bytes the rust half built. +nix develop -c cargo run -p rain-metadata --example emit-calldata-fixture diff --git a/test/fixtures/emit-calldata.json b/test/fixtures/emit-calldata.json new file mode 100644 index 00000000..5cd165e1 --- /dev/null +++ b/test/fixtures/emit-calldata.json @@ -0,0 +1,4 @@ +{ + "dotrain_source": "0x37480e2a3fd2b7b238e68674f97d936e0e457eae32fe6b647984644c1162c9885502f68600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000049ff0a89c674ee7874a3005818236d61696e205f205f3a20696e742d61646428312032293b011bffa15ef0fc4370990278186170706c69636174696f6e2f6f637465742d73747265616d0000000000000000000000000000000000000000000000", + "generic_item": "0x37480e2aa8a816410fb3aa58c6c2440b66b741d975394347626bd243dc08c6444ae9a29f00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000045ff0a89c674ee7874a30055656d69742063616c6c646174612066697874757265011bffa15ef0fc4370990278186170706c69636174696f6e2f6f637465742d73747265616d000000000000000000000000000000000000000000000000000000" +} diff --git a/test/lib/EmitCalldataFixture.t.sol b/test/lib/EmitCalldataFixture.t.sol new file mode 100644 index 00000000..900ba13a --- /dev/null +++ b/test/lib/EmitCalldataFixture.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test, Vm} from "forge-std-1.16.2/src/Test.sol"; +import {IMetaV1_2} from "src/interface/unstable/IMetaV1_2.sol"; +import {TestMetaBoard} from "test/concrete/TestMetaBoard.sol"; + +/// @title EmitCalldataFixtureTest +/// @notice The rust half of this repo builds `emitMeta` calldata and the +/// solidity half decides whether it is acceptable, and until now nothing tested +/// that seam: each half was checked against its own idea of the bytes. That is +/// how `generate_emit_meta_calldata` came to build a bare cbor map, which +/// `LibMeta.checkMetaUnhashedV1` reverts `NotRainMetaV1` on, while both test +/// suites stayed green. +/// +/// `crates/cli/tests/emit_calldata_fixture.rs` writes the calldata rust +/// actually produces to `test/fixtures/emit-calldata.json` and fails if the +/// committed copy is stale. This sends that calldata to a real metaboard, so +/// what accepts or rejects it is the contract rather than an assertion +/// restating what the encoder did. +contract EmitCalldataFixtureTest is Test { + TestMetaBoard internal metaBoard; + + function setUp() external { + metaBoard = new TestMetaBoard(); + } + + /// Every entry in the fixture is calldata a metaboard accepts, and emits + /// verbatim as a single `MetaV1_2`. A case that reverts fails here rather + /// than in production. + /// @param key The fixture key naming the rust producer under test. + function _checkFixtureCase(string memory key) internal { + string memory json = vm.readFile("test/fixtures/emit-calldata.json"); + bytes memory callData = vm.parseJsonBytes(json, string.concat(".", key)); + + vm.recordLogs(); + (bool success,) = address(metaBoard).call(callData); + assertTrue(success, string.concat("metaboard rejected calldata for ", key)); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1, string.concat("log count for ", key)); + assertEq(logs[0].topics[0], IMetaV1_2.MetaV1_2.selector, string.concat("topic for ", key)); + + // The emitted meta is the calldata's own meta argument, carried + // verbatim. Decoding the log against the calldata rather than against a + // literal keeps this test about the seam and not about the encoding. + (bytes32 subjectArg, bytes memory metaArg) = abi.decode(_args(callData), (bytes32, bytes)); + (address sender, bytes32 subject, bytes memory meta) = abi.decode(logs[0].data, (address, bytes32, bytes)); + assertEq(sender, address(this), string.concat("sender for ", key)); + assertEq(subject, subjectArg, string.concat("subject for ", key)); + assertEq(meta, metaArg, string.concat("meta for ", key)); + } + + /// The abi encoded arguments, with the four byte selector dropped. + /// callData Whole calldata as the fixture carries it. + /// out The arguments alone, decodable as `(bytes32, bytes)`. + function _args(bytes memory callData) internal pure returns (bytes memory out) { + out = new bytes(callData.length - 4); + for (uint256 i = 0; i < out.length; i++) { + out[i] = callData[i + 4]; + } + } + + /// `generate_emit_meta_calldata`, the generic producer. + function testGenericItemCalldataIsAcceptable() external { + _checkFixtureCase("generic_item"); + } + + /// `generate_dotrain_source_emit_tx_data`, the dotrain producer. + function testDotrainSourceCalldataIsAcceptable() external { + _checkFixtureCase("dotrain_source"); + } +}