Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions crates/cli/src/metaboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ fn generate_emit_data_calldata(subject: FixedBytes<32>, data: Vec<u8>) -> Vec<u8
}

/// Generate calldata for IMetaBoardV1_2.emitMeta() function from any RainMetaDocumentV1Item
///
/// The subject stays the BARE cbor item hash — the key `Store` caches inner
/// items under — while the emitted bytes are the magic-prefixed cbor-seq that
/// `IMetaBoardV1_2.emitMeta` reverts `NotRainMetaV1` without. Two digests, on
/// purpose.
pub fn generate_emit_meta_calldata(meta: RainMetaDocumentV1Item) -> Result<Vec<u8>, 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))
}

Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions crates/cli/tests/emit_calldata_fixture.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! The seam between the two halves of this repo: rust builds `emitMeta`
//! calldata, solidity's `IMetaBoardV1_2` decides whether it is acceptable.
//! Each half was tested against its own idea of the bytes and neither against
//! the other, which is how `generate_emit_meta_calldata` came to build a bare
//! cbor map that `LibMeta.checkMetaUnhashedV1` reverts `NotRainMetaV1` on.
//!
//! This writes the calldata rust actually produces to a committed fixture.
//! `test/lib/EmitCalldataFixture.t.sol` reads that fixture and sends it to a
//! real `TestMetaBoard`, so the contract itself is what says the bytes are
//! acceptable rather than an assertion here restating what the encoder did.
//!
//! Regenerate with `BLESS=1 cargo test -p rain-metadata --test
//! emit_calldata_fixture`. Without `BLESS` the test asserts the committed
//! fixture still matches, so CI fails if the two drift apart.

use std::{fs, path::PathBuf};

use rain_metadata::{
ContentEncoding, ContentLanguage, ContentType, KnownMagic, RainMetaDocumentV1Item,
generate_dotrain_source_emit_tx_data, generate_emit_meta_calldata,
};

fn fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../test/fixtures/emit-calldata.json")
}

/// 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,
}
}

/// Every case solidity should be handed. Keyed by name so a failure names the
/// case rather than an index.
fn cases() -> Vec<(String, String)> {
let mut out = Vec::new();

let generic = generate_emit_meta_calldata(plain_item("emit calldata fixture")).unwrap();
out.push((
"generic_item".to_string(),
alloy::hex::encode_prefixed(generic),
));

let dotrain = generate_dotrain_source_emit_tx_data("#main _ _: int-add(1 2);").unwrap();
out.push(("dotrain_source".to_string(), dotrain.calldata));

out
}

#[test]
fn emit_calldata_fixture_is_current() {
let map: serde_json::Map<String, serde_json::Value> = cases()
.into_iter()
.map(|(name, calldata)| (name, serde_json::Value::String(calldata)))
.collect();
let generated = serde_json::to_string_pretty(&serde_json::Value::Object(map)).unwrap() + "\n";

let path = fixture_path();
if std::env::var("BLESS").is_ok() {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, &generated).unwrap();
return;
}

let committed = fs::read_to_string(&path).unwrap_or_else(|error| {
panic!(
"{} is missing ({error}). Regenerate with BLESS=1 cargo test -p rain-metadata --test emit_calldata_fixture",
path.display()
)
});

assert_eq!(
committed, generated,
"committed emit calldata is stale. Regenerate with BLESS=1 cargo test -p rain-metadata --test emit_calldata_fixture"
);
}
1 change: 1 addition & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/" },
]

Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/emit-calldata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
Comment thread
thedavidmeister marked this conversation as resolved.
"dotrain_source": "0x37480e2a3fd2b7b238e68674f97d936e0e457eae32fe6b647984644c1162c9885502f68600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000049ff0a89c674ee7874a3005818236d61696e205f205f3a20696e742d61646428312032293b011bffa15ef0fc4370990278186170706c69636174696f6e2f6f637465742d73747265616d0000000000000000000000000000000000000000000000",
"generic_item": "0x37480e2aa8a816410fb3aa58c6c2440b66b741d975394347626bd243dc08c6444ae9a29f00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000045ff0a89c674ee7874a30055656d69742063616c6c646174612066697874757265011bffa15ef0fc4370990278186170706c69636174696f6e2f6f637465742d73747265616d000000000000000000000000000000000000000000000000000000"
}
75 changes: 75 additions & 0 deletions test/lib/EmitCalldataFixture.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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");
}
}
Loading