diff --git a/.github/workflows/tests-accelerators.yml b/.github/workflows/tests-accelerators.yml new file mode 100644 index 000000000..e4907dafa --- /dev/null +++ b/.github/workflows/tests-accelerators.yml @@ -0,0 +1,67 @@ +name: OpenVM Accelerators Tests + +on: + pull_request: + paths: + - "crates/accelerators/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/tests-accelerators.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + +jobs: + test: + runs-on: + - runs-on=${{ github.run_id }} + - runner=64cpu-linux-arm64 + - extras=s3-cache + + steps: + - uses: runs-on/action@v2 + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - uses: taiki-e/install-action@nextest + + - name: Run tests + run: cargo nextest run -p openvm-accelerators + + guest-build: + runs-on: + - runs-on=${{ github.run_id }} + - runner=64cpu-linux-arm64 + - extras=s3-cache + + steps: + - uses: runs-on/action@v2 + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.91.1" + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Cache openvm toolchain + uses: actions/cache@v4 + with: + path: ~/.openvm/toolchains + key: openvm-toolchain-openvm-1.94.0-${{ runner.os }}-${{ runner.arch }} + + - name: Install OpenVM CLI + run: | + cargo install --git https://github.com/openvm-org/openvm.git --branch develop-v2.1.0 --locked --force cargo-openvm + cargo openvm toolchain install + + - name: Build for the guest target + run: | + cd crates/accelerators + cargo openvm build --no-transpile diff --git a/Cargo.lock b/Cargo.lock index d07466a53..1833a04ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6785,6 +6785,16 @@ dependencies = [ "serde", ] +[[package]] +name = "openvm-accelerators" +version = "0.4.0" +dependencies = [ + "hex-literal", + "openvm-keccak256", + "openvm-sha2", + "ripemd", +] + [[package]] name = "openvm-algebra-circuit" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 19035c747..761b39e61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/curve-utils", "crates/kzg", "crates/kzg/tests/programs/verify_kzg", + "crates/accelerators", ] exclude = [] resolver = "3" diff --git a/crates/accelerators/Cargo.toml b/crates/accelerators/Cargo.toml new file mode 100644 index 000000000..c353aa641 --- /dev/null +++ b/crates/accelerators/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "openvm-accelerators" +description = "OpenVM implementation of the zkVM Cryptographic Accelerators C Interface" +version.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +# openvm +openvm-keccak256.workspace = true +openvm-sha2.workspace = true + +# crypto +ripemd = { version = "0.1.3", default-features = false } + +# Host implementations when not building for the zkVM guest. +[target.'cfg(not(any(target_os = "none", target_os = "openvm")))'.dependencies] +openvm-keccak256 = { workspace = true, features = ["tiny_keccak"] } +openvm-sha2 = { workspace = true, features = ["import_sha2"] } + +[dev-dependencies] +hex-literal.workspace = true + +[features] +default = ["ffi"] +# The extern "C" `zkvm_*` symbols. Rust consumers that only need `ops` can +# disable this. +ffi = [] +std = [] diff --git a/crates/accelerators/src/ffi/hash.rs b/crates/accelerators/src/ffi/hash.rs new file mode 100644 index 000000000..0deb16129 --- /dev/null +++ b/crates/accelerators/src/ffi/hash.rs @@ -0,0 +1,90 @@ +//! C ABI for the hash accelerators. + +use crate::{ + ops, + types::{ZkvmKeccak256Hash, ZkvmRipemd160Hash, ZkvmSha256Hash, ZkvmStatus}, +}; + +/// Compute the Keccak-256 hash of `data[..len]` into `output`. +/// +/// Returns [`ZkvmStatus::Fail`] if `output` is NULL, or if `data` is NULL +/// with a non-zero `len`; a NULL `data` with `len == 0` hashes the empty +/// input. +/// +/// # Safety +/// +/// - `data`, if non-NULL, must be valid for reads of `len` bytes. +/// - `output`, if non-NULL, must be valid for writes of 32 bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn zkvm_keccak256( + data: *const u8, + len: usize, + output: *mut ZkvmKeccak256Hash, +) -> ZkvmStatus { + if output.is_null() || (data.is_null() && len != 0) { + return ZkvmStatus::Fail; + } + // SAFETY: non-NULL checked above; validity is guaranteed by the caller. + let data = if len == 0 { &[] } else { unsafe { core::slice::from_raw_parts(data, len) } }; + // SAFETY: non-NULL checked above; validity is guaranteed by the caller. + let output = unsafe { &mut *output }; + ops::keccak256(data, output); + ZkvmStatus::Ok +} + +/// Compute the SHA-256 hash of `data[..len]` into `output`. +/// +/// Returns [`ZkvmStatus::Fail`] if `output` is NULL, or if `data` is NULL +/// with a non-zero `len`; a NULL `data` with `len == 0` hashes the empty +/// input. +/// +/// # Safety +/// +/// - `data`, if non-NULL, must be valid for reads of `len` bytes. +/// - `output`, if non-NULL, must be valid for writes of 32 bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn zkvm_sha256( + data: *const u8, + len: usize, + output: *mut ZkvmSha256Hash, +) -> ZkvmStatus { + if output.is_null() || (data.is_null() && len != 0) { + return ZkvmStatus::Fail; + } + // SAFETY: non-NULL checked above; validity is guaranteed by the caller. + let data = if len == 0 { &[] } else { unsafe { core::slice::from_raw_parts(data, len) } }; + // SAFETY: non-NULL checked above; validity is guaranteed by the caller. + let output = unsafe { &mut *output }; + ops::sha256(data, output); + ZkvmStatus::Ok +} + +/// Compute the RIPEMD-160 hash of `data[..len]` into `output`. +/// +/// The 20-byte digest is written to `output.data[12..]`; the first 12 bytes +/// are zeroed. +/// +/// Returns [`ZkvmStatus::Fail`] if `output` is NULL, or if `data` is NULL +/// with a non-zero `len`; a NULL `data` with `len == 0` hashes the empty +/// input. +/// +/// # Safety +/// +/// - `data`, if non-NULL, must be valid for reads of `len` bytes. +/// - `output`, if non-NULL, must be valid for writes of 32 bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn zkvm_ripemd160( + data: *const u8, + len: usize, + output: *mut ZkvmRipemd160Hash, +) -> ZkvmStatus { + if output.is_null() || (data.is_null() && len != 0) { + return ZkvmStatus::Fail; + } + // SAFETY: non-NULL checked above; validity is guaranteed by the caller. + let data = if len == 0 { &[] } else { unsafe { core::slice::from_raw_parts(data, len) } }; + // SAFETY: non-NULL checked above; validity is guaranteed by the caller. + let output = unsafe { &mut *output }; + ops::ripemd160(data, output); + ZkvmStatus::Ok +} diff --git a/crates/accelerators/src/ffi/mod.rs b/crates/accelerators/src/ffi/mod.rs new file mode 100644 index 000000000..34441d5b6 --- /dev/null +++ b/crates/accelerators/src/ffi/mod.rs @@ -0,0 +1,9 @@ +//! The `extern "C"` layer: `zkvm_*` symbols matching `zkvm_accelerators.h`. +//! +//! Every function is a thin wrapper over [`crate::ops`]: it checks pointers, +//! converts them to references, calls the operation, and maps the result to +//! [`crate::types::ZkvmStatus`]. No other logic lives here. + +mod hash; + +pub use hash::*; diff --git a/crates/accelerators/src/lib.rs b/crates/accelerators/src/lib.rs new file mode 100644 index 000000000..10a85ec01 --- /dev/null +++ b/crates/accelerators/src/lib.rs @@ -0,0 +1,8 @@ +//! OpenVM implementation of the zkVM Cryptographic Accelerators C Interface. + +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(feature = "ffi")] +pub mod ffi; +pub mod ops; +pub mod types; diff --git a/crates/accelerators/src/ops/hash.rs b/crates/accelerators/src/ops/hash.rs new file mode 100644 index 000000000..3bae39f29 --- /dev/null +++ b/crates/accelerators/src/ops/hash.rs @@ -0,0 +1,30 @@ +//! Hash operations. + +use crate::types::{ZkvmKeccak256Hash, ZkvmRipemd160Hash, ZkvmSha256Hash}; + +/// Compute the Keccak-256 hash of `data` into `output`. +#[inline] +pub fn keccak256(data: &[u8], output: &mut ZkvmKeccak256Hash) { + openvm_keccak256::set_keccak256(data, &mut output.data); +} + +/// Compute the SHA-256 hash of `data` into `output`. +#[inline] +pub fn sha256(data: &[u8], output: &mut ZkvmSha256Hash) { + #[cfg(not(openvm_intrinsics))] + use openvm_sha2::Digest; + output.data = openvm_sha2::Sha256::digest(data).into(); +} + +/// Compute the RIPEMD-160 hash of `data` into `output`. +/// +/// The 20-byte digest is written to `output.data[12..]`; the first 12 bytes +/// are zeroed, matching the EVM word layout. +#[inline] +pub fn ripemd160(data: &[u8], output: &mut ZkvmRipemd160Hash) { + use ripemd::Digest; + let mut hasher = ripemd::Ripemd160::new(); + hasher.update(data); + output.data[..12].fill(0); + hasher.finalize_into((&mut output.data[12..]).into()); +} diff --git a/crates/accelerators/src/ops/mod.rs b/crates/accelerators/src/ops/mod.rs new file mode 100644 index 000000000..72a5da521 --- /dev/null +++ b/crates/accelerators/src/ops/mod.rs @@ -0,0 +1,23 @@ +//! OpenVM-accelerated implementations of the zkVM accelerator operations. +//! +//! All functions operate on fixed-size big-endian byte encodings; BLS12-381 +//! G2 is `x_c0 || x_c1 || y_c0 || y_c1`, BN254 G2 uses the EIP-197 +//! `x_c1 || x_c0 || y_c1 || y_c0` order. + +mod hash; + +pub use hash::{keccak256, ripemd160, sha256}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + /// A field element is out of range or otherwise not a field member. + FieldElementInvalid, + /// A point encoding does not satisfy the curve equation. + PointNotOnCurve, + /// A point is on the curve but not in the prime-order subgroup. + PointNotInSubgroup, + /// A signature could not be parsed or key recovery failed. + InvalidSignature, + /// KZG commitment/proof/field-element inputs are malformed. + KzgInvalidInput, +} diff --git a/crates/accelerators/src/types.rs b/crates/accelerators/src/types.rs new file mode 100644 index 000000000..56c2694ef --- /dev/null +++ b/crates/accelerators/src/types.rs @@ -0,0 +1,172 @@ +//! Types mirroring the complete interface standard header. + +/// Status code returned by every accelerator function (`zkvm_status`). +/// +/// Pinned to `i32` so the layout does not depend on target conventions. +#[repr(i32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ZkvmStatus { + /// Success (`ZKVM_EOK`). + Ok = 0, + /// Failure (`ZKVM_EFAIL`). + Fail = -1, +} + +/// 16-byte buffer. +#[repr(C, align(8))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBytes16 { + pub data: [u8; 16], +} + +/// 32-byte buffer. +#[repr(C, align(8))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBytes32 { + pub data: [u8; 32], +} + +/// 48-byte buffer. +#[repr(C, align(8))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBytes48 { + pub data: [u8; 48], +} + +/// 64-byte buffer. +#[repr(C, align(8))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBytes64 { + pub data: [u8; 64], +} + +/// 96-byte buffer. +#[repr(C, align(8))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBytes96 { + pub data: [u8; 96], +} + +/// 128-byte buffer. +#[repr(C, align(8))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBytes128 { + pub data: [u8; 128], +} + +/// 192-byte buffer. +#[repr(C, align(8))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBytes192 { + pub data: [u8; 192], +} + +/* Hash types */ +pub type ZkvmKeccak256Hash = ZkvmBytes32; +pub type ZkvmSha256Hash = ZkvmBytes32; +/// 20-byte hash padded to 32 bytes, first 12 bytes zero. +pub type ZkvmRipemd160Hash = ZkvmBytes32; + +/* secp256k1 types */ +pub type ZkvmSecp256k1Hash = ZkvmBytes32; +/// `r || s`, 32 bytes each, big-endian. +pub type ZkvmSecp256k1Signature = ZkvmBytes64; +/// uncompressed `x || y`, 32 bytes each, big-endian. +pub type ZkvmSecp256k1Pubkey = ZkvmBytes64; + +/* secp256r1 (P-256) types */ +pub type ZkvmSecp256r1Hash = ZkvmBytes32; +/// `r || s`, 32 bytes each, big-endian. +pub type ZkvmSecp256r1Signature = ZkvmBytes64; +/// uncompressed `x || y`, 32 bytes each, big-endian. +pub type ZkvmSecp256r1Pubkey = ZkvmBytes64; + +/* BN254 types */ +/// `x || y`, 32 bytes each, big-endian. +pub type ZkvmBn254G1Point = ZkvmBytes64; +/// `x_c1 || x_c0 || y_c1 || y_c0` (EIP-197 order). +pub type ZkvmBn254G2Point = ZkvmBytes128; +pub type ZkvmBn254Scalar = ZkvmBytes32; + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBn254PairingPair { + pub g1: ZkvmBn254G1Point, + pub g2: ZkvmBn254G2Point, +} + +/* BLS12-381 types */ +/// `x || y`, 48 bytes each, big-endian. +pub type ZkvmBls12381G1Point = ZkvmBytes96; +/// `x_c0 || x_c1 || y_c0 || y_c1` (EIP-2537 order). +pub type ZkvmBls12381G2Point = ZkvmBytes192; +pub type ZkvmBls12381Scalar = ZkvmBytes32; +pub type ZkvmBls12381Fp = ZkvmBytes48; +/// `c0 || c1`, 48 bytes each, big-endian. +pub type ZkvmBls12381Fp2 = ZkvmBytes96; + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBls12381G1MsmPair { + pub point: ZkvmBls12381G1Point, + pub scalar: ZkvmBls12381Scalar, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBls12381G2MsmPair { + pub point: ZkvmBls12381G2Point, + pub scalar: ZkvmBls12381Scalar, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZkvmBls12381PairingPair { + pub g1: ZkvmBls12381G1Point, + pub g2: ZkvmBls12381G2Point, +} + +/* BLAKE2f types */ +/// 8 × u64 little-endian. +pub type ZkvmBlake2fState = ZkvmBytes64; +/// 16 × u64 little-endian. +pub type ZkvmBlake2fMessage = ZkvmBytes128; +/// 2 × u64 little-endian. +pub type ZkvmBlake2fOffset = ZkvmBytes16; + +/* KZG types */ +pub type ZkvmKzgCommitment = ZkvmBytes48; +pub type ZkvmKzgProof = ZkvmBytes48; +pub type ZkvmKzgFieldElement = ZkvmBytes32; + +// Assert 8-byte alignment and sizes. +const _: () = { + use core::mem::{align_of, size_of}; + + assert!(size_of::() == 4); + assert!(align_of::() == 4); + + assert!(size_of::() == 16); + assert!(size_of::() == 32); + assert!(size_of::() == 48); + assert!(size_of::() == 64); + assert!(size_of::() == 96); + assert!(size_of::() == 128); + assert!(size_of::() == 192); + assert!(size_of::() == 192); + assert!(size_of::() == 128); + assert!(size_of::() == 224); + assert!(size_of::() == 288); + + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); + assert!(align_of::() == 8); +}; diff --git a/crates/accelerators/tests/conformance/hash.rs b/crates/accelerators/tests/conformance/hash.rs new file mode 100644 index 000000000..339bc3f79 --- /dev/null +++ b/crates/accelerators/tests/conformance/hash.rs @@ -0,0 +1,156 @@ +//! Hash conformance vectors. + +use hex_literal::hex; +use openvm_accelerators::{ + ffi::{zkvm_keccak256, zkvm_ripemd160, zkvm_sha256}, + ops::{keccak256, ripemd160, sha256}, + types::{ZkvmKeccak256Hash, ZkvmRipemd160Hash, ZkvmSha256Hash, ZkvmStatus}, +}; + +#[test] +fn keccak256_vectors() { + let mut output = ZkvmKeccak256Hash { data: [0; 32] }; + + keccak256(b"", &mut output); + assert_eq!( + output.data, + hex!("c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470") + ); + + keccak256(b"abc", &mut output); + assert_eq!( + output.data, + hex!("4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45") + ); +} + +#[test] +fn zkvm_keccak256_smoke() { + let data = *b"abc"; + let mut output = ZkvmKeccak256Hash { data: [0; 32] }; + let status = unsafe { zkvm_keccak256(data.as_ptr(), data.len(), &mut output) }; + assert_eq!(status, ZkvmStatus::Ok); + assert_eq!( + output.data, + hex!("4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45") + ); +} + +#[test] +fn zkvm_keccak256_null_pointers() { + let data = *b"abc"; + let mut output = ZkvmKeccak256Hash { data: [0; 32] }; + + // A NULL `data` with `len == 0` is the empty input. + let status = unsafe { zkvm_keccak256(core::ptr::null(), 0, &mut output) }; + assert_eq!(status, ZkvmStatus::Ok); + assert_eq!( + output.data, + hex!("c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470") + ); + + let status = unsafe { zkvm_keccak256(core::ptr::null(), data.len(), &mut output) }; + assert_eq!(status, ZkvmStatus::Fail); + + let status = unsafe { zkvm_keccak256(data.as_ptr(), data.len(), core::ptr::null_mut()) }; + assert_eq!(status, ZkvmStatus::Fail); +} + +#[test] +fn sha256_vectors() { + let mut output = ZkvmSha256Hash { data: [0; 32] }; + + sha256(b"", &mut output); + assert_eq!( + output.data, + hex!("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + ); + + sha256(b"abc", &mut output); + assert_eq!( + output.data, + hex!("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + ); +} + +#[test] +fn zkvm_sha256_smoke() { + let data = *b"abc"; + let mut output = ZkvmSha256Hash { data: [0; 32] }; + let status = unsafe { zkvm_sha256(data.as_ptr(), data.len(), &mut output) }; + assert_eq!(status, ZkvmStatus::Ok); + assert_eq!( + output.data, + hex!("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + ); +} + +#[test] +fn zkvm_sha256_null_pointers() { + let data = *b"abc"; + let mut output = ZkvmSha256Hash { data: [0; 32] }; + + // A NULL `data` with `len == 0` is the empty input. + let status = unsafe { zkvm_sha256(core::ptr::null(), 0, &mut output) }; + assert_eq!(status, ZkvmStatus::Ok); + assert_eq!( + output.data, + hex!("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + ); + + let status = unsafe { zkvm_sha256(core::ptr::null(), data.len(), &mut output) }; + assert_eq!(status, ZkvmStatus::Fail); + + let status = unsafe { zkvm_sha256(data.as_ptr(), data.len(), core::ptr::null_mut()) }; + assert_eq!(status, ZkvmStatus::Fail); +} + +#[test] +fn ripemd160_vectors() { + // Start from a dirty buffer to check the 12-byte zero padding is written. + let mut output = ZkvmRipemd160Hash { data: [0xff; 32] }; + + ripemd160(b"", &mut output); + assert_eq!( + output.data, + hex!("0000000000000000000000009c1185a5c5e9fc54612808977ee8f548b2258d31") + ); + + ripemd160(b"abc", &mut output); + assert_eq!( + output.data, + hex!("0000000000000000000000008eb208f7e05d987a9b044a8e98c6b087f15a0bfc") + ); +} + +#[test] +fn zkvm_ripemd160_smoke() { + let data = *b"abc"; + let mut output = ZkvmRipemd160Hash { data: [0xff; 32] }; + let status = unsafe { zkvm_ripemd160(data.as_ptr(), data.len(), &mut output) }; + assert_eq!(status, ZkvmStatus::Ok); + assert_eq!( + output.data, + hex!("0000000000000000000000008eb208f7e05d987a9b044a8e98c6b087f15a0bfc") + ); +} + +#[test] +fn zkvm_ripemd160_null_pointers() { + let data = *b"abc"; + let mut output = ZkvmRipemd160Hash { data: [0xff; 32] }; + + // A NULL `data` with `len == 0` is the empty input. + let status = unsafe { zkvm_ripemd160(core::ptr::null(), 0, &mut output) }; + assert_eq!(status, ZkvmStatus::Ok); + assert_eq!( + output.data, + hex!("0000000000000000000000009c1185a5c5e9fc54612808977ee8f548b2258d31") + ); + + let status = unsafe { zkvm_ripemd160(core::ptr::null(), data.len(), &mut output) }; + assert_eq!(status, ZkvmStatus::Fail); + + let status = unsafe { zkvm_ripemd160(data.as_ptr(), data.len(), core::ptr::null_mut()) }; + assert_eq!(status, ZkvmStatus::Fail); +} diff --git a/crates/accelerators/tests/conformance/main.rs b/crates/accelerators/tests/conformance/main.rs new file mode 100644 index 000000000..12e61b30d --- /dev/null +++ b/crates/accelerators/tests/conformance/main.rs @@ -0,0 +1,6 @@ +//! Conformance tests for the accelerator operations, using official test +//! vectors and reference implementations. +//! +//! Modules mirror the `src/ops` layout: one file per domain. + +mod hash;