Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
57 changes: 57 additions & 0 deletions crates/cli/examples/emit-calldata-fixture.rs
Original file line number Diff line number Diff line change
@@ -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();
}
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
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
17 changes: 17 additions & 0 deletions script/build.sh
Original file line number Diff line number Diff line change
@@ -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
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"
}
74 changes: 74 additions & 0 deletions test/lib/EmitCalldataFixture.t.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading